main
semdiff_test.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "testing"
5)
6
7const sampleDiff = `diff --git a/foo.go b/foo.go
8index 1111111..2222222 100644
9--- a/foo.go
10+++ b/foo.go
11@@ -1,5 +1,9 @@
12 package foo
13
14-func Add(a int) int {
15- return a
16+func Add(a int, b int) int {
17+ return a + b
18+}
19+
20+func Sub(a, b int) int {
21+ return a - b
22 }
23`
24
25func TestSemanticChangesSmoke(t *testing.T) {
26 files, _, err := ParsePatch(sampleDiff)
27 if err != nil {
28 t.Fatalf("ParsePatch: %v", err)
29 }
30 if len(files) != 1 {
31 t.Fatalf("expected 1 file, got %d", len(files))
32 }
33
34 changes := AnalyzeSemanticChanges(files[0])
35 if len(changes) == 0 {
36 t.Fatalf("expected semantic changes, got none")
37 }
38
39 var sawAdd, sawSub bool
40 for _, c := range changes {
41 t.Logf("change: kind=%s entity=%s name=%s oldSig=%q newSig=%q hunk=%d",
42 c.Kind, c.EntityKind, c.Name, c.OldSig, c.NewSig, c.HunkIndex)
43 if c.Name == "Add" && c.Kind == SemanticSignatureChanged {
44 sawAdd = true
45 }
46 if c.Name == "Sub" && c.Kind == SemanticAdded {
47 sawSub = true
48 }
49 }
50
51 if !sawAdd {
52 t.Errorf("expected Add to be reported as signature_changed")
53 }
54 if !sawSub {
55 t.Errorf("expected Sub to be reported as added")
56 }
57}
58
59// bodyOnlyDiff mirrors a hunk deep inside a large function where the
60// `func Foo(...) {` line itself isn't part of the fragment's context lines
61// -- common in real-world large diffs (e.g. a 400-line function with a
62// change on line 200). Only git's hunk-header comment identifies the
63// enclosing function; tree-sitter finds no complete declaration node in
64// either the old or new fragment text.
65const bodyOnlyDiff = `diff --git a/big.go b/big.go
66index 1111111..2222222 100644
67--- a/big.go
68+++ b/big.go
69@@ -33,6 +32,6 @@ func testSingleTenantE2E(t *testing.T) {
70 // Hack to wait for startup
71 time.Sleep(time.Millisecond * 100)
72
73- suite.userKey.MustCmd(suite.patch, "register")
74+ suite.userKey.MustCmd(suite.patch, "pr create test")
75
76 suite.adminKey.MustCmd(suite.patch, "pr create test")
77`
78
79func TestSemanticChangesBodyOnlyHunkFallsBackToEnclosingFunc(t *testing.T) {
80 files, _, err := ParsePatch(bodyOnlyDiff)
81 if err != nil {
82 t.Fatalf("ParsePatch: %v", err)
83 }
84 if len(files) != 1 {
85 t.Fatalf("expected 1 file, got %d", len(files))
86 }
87
88 changes := AnalyzeSemanticChanges(files[0])
89 if len(changes) == 0 {
90 t.Fatalf("expected a fallback semantic change from the hunk header comment, got none")
91 }
92
93 var sawEnclosing bool
94 for _, c := range changes {
95 t.Logf("change: kind=%s entity=%s name=%s hunk=%d", c.Kind, c.EntityKind, c.Name, c.HunkIndex)
96 if c.Name == "testSingleTenantE2E" && c.Kind == SemanticModified {
97 sawEnclosing = true
98 }
99 }
100
101 if !sawEnclosing {
102 t.Errorf("expected fallback to report testSingleTenantE2E as modified")
103 }
104}
105
106// closureDiff mirrors a hunk inside an anonymous closure passed as a struct
107// field (e.g. cli.Command{Action: func(cCtx *cli.Context) error { ... }}).
108// Git's own hunk-header heuristic can't find a nearby "func Name(...)" line
109// here either -- it picks up unrelated doc text -- so neither the primary
110// extraction nor the enclosing-comment fallback finds a name, and we should
111// fall back to a generic line-range chunk instead of reporting nothing.
112const closureDiff = `diff --git a/cli.go b/cli.go
113index 1111111..2222222 100644
114--- a/cli.go
115+++ b/cli.go
116@@ -239,10 +239,10 @@ To get started, submit a new patch request:
117 }
118
119 args := cCtx.Args()
120- repoName := "bin"
121- if args.Present() {
122- repoName = args.First()
123+ if !args.Present() {
124+ return fmt.Errorf("must provide a repo name")
125 }
126+ repoName := args.First()
127
128 body, err := io.ReadAll(sesh)
129 if err != nil {
130`
131
132func TestSemanticChangesClosureHunkFallsBackToGenericChunk(t *testing.T) {
133 files, _, err := ParsePatch(closureDiff)
134 if err != nil {
135 t.Fatalf("ParsePatch: %v", err)
136 }
137 if len(files) != 1 {
138 t.Fatalf("expected 1 file, got %d", len(files))
139 }
140
141 changes := AnalyzeSemanticChanges(files[0])
142 if len(changes) == 0 {
143 t.Fatalf("expected a fallback generic chunk change, got none")
144 }
145
146 var sawChunk bool
147 for _, c := range changes {
148 t.Logf("change: kind=%s entity=%s name=%s hunk=%d", c.Kind, c.EntityKind, c.Name, c.HunkIndex)
149 if c.EntityKind == "chunk" && c.Kind == SemanticModified {
150 sawChunk = true
151 }
152 }
153
154 if !sawChunk {
155 t.Errorf("expected fallback to report a generic chunk change")
156 }
157}
158
159// multiHunkSameFuncDiff mirrors a large function edited in two separate,
160// non-adjacent hunks (e.g. createPrDetail spanning several hundred lines
161// with edits near the top and bottom). Each hunk independently falls back
162// to reporting the enclosing function, so without deduplication this would
163// produce one "modified" entry per hunk instead of one per function.
164const multiHunkSameFuncDiff = `diff --git a/handler.go b/handler.go
165index 1111111..2222222 100644
166--- a/handler.go
167+++ b/handler.go
168@@ -10,6 +10,6 @@ func createPrDetail(page string) http.HandlerFunc {
169 return func(w http.ResponseWriter, r *http.Request) {
170 id := r.PathValue("id")
171
172- prID, err := strconv.Atoi(id)
173+ prID, convErr := strconv.Atoi(id)
174 if err != nil {
175 w.WriteHeader(http.StatusUnprocessableEntity)
176@@ -40,6 +40,6 @@ func createPrDetail(page string) http.HandlerFunc {
177 }
178
179- logData, err := getLogData(web, pr.ID, aps.Patchsets)
180+ logData, logErr := getLogData(web, pr.ID, aps.Patchsets)
181 if err != nil {
182 web.Logger.Error("cannot fetch log data", "err", err)
183 }
184`
185
186func TestSemanticChangesDedupesSameEntityAcrossHunks(t *testing.T) {
187 files, _, err := ParsePatch(multiHunkSameFuncDiff)
188 if err != nil {
189 t.Fatalf("ParsePatch: %v", err)
190 }
191 if len(files) != 1 {
192 t.Fatalf("expected 1 file, got %d", len(files))
193 }
194 if len(files[0].TextFragments) != 2 {
195 t.Fatalf("expected 2 hunks, got %d", len(files[0].TextFragments))
196 }
197
198 changes := AnalyzeSemanticChanges(files[0])
199
200 var matches []SemanticChange
201 for _, c := range changes {
202 t.Logf("change: kind=%s entity=%s name=%s hunk=%d", c.Kind, c.EntityKind, c.Name, c.HunkIndex)
203 if c.Name == "createPrDetail" {
204 matches = append(matches, c)
205 }
206 }
207
208 if len(matches) != 1 {
209 t.Errorf("expected exactly 1 change for createPrDetail across both hunks, got %d", len(matches))
210 }
211}