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}