Skip to content

Commit 8c39591

Browse files
committed
test(integration): incremental==full determinism + idempotence + delete-then-add
1 parent 9f8f889 commit 8c39591

1 file changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package analyzer_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sort"
7+
"testing"
8+
9+
"github.com/randomcodespace/codeiq/internal/analyzer"
10+
"github.com/randomcodespace/codeiq/internal/cache"
11+
"github.com/randomcodespace/codeiq/internal/graph"
12+
)
13+
14+
// graphSnapshot returns a stable, comparable representation of the graph:
15+
// every node by id+kind+label and every edge by id+kind+source+target,
16+
// sorted. Excludes anything timestamped or otherwise legitimately variable.
17+
func graphSnapshot(t *testing.T, graphDir string) (nodes []string, edges []string) {
18+
t.Helper()
19+
s, err := graph.OpenReadOnly(graphDir, 0)
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
defer s.Close()
24+
nodeRows, err := s.Cypher(
25+
`MATCH (n:CodeNode) RETURN n.id AS id, n.kind AS kind, n.label AS label ORDER BY n.id`)
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
for _, r := range nodeRows {
30+
nodes = append(nodes,
31+
asString(r["id"])+"|"+asString(r["kind"])+"|"+asString(r["label"]))
32+
}
33+
edgeRows, err := s.Cypher(
34+
`MATCH (a:CodeNode)-[r]->(b:CodeNode)
35+
RETURN r.id AS id, a.id AS src, b.id AS tgt ORDER BY r.id, a.id, b.id`)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
for _, r := range edgeRows {
40+
edges = append(edges, asString(r["id"])+"|"+asString(r["src"])+"|"+asString(r["tgt"]))
41+
}
42+
sort.Strings(nodes)
43+
sort.Strings(edges)
44+
return nodes, edges
45+
}
46+
47+
func asString(v any) string {
48+
if s, ok := v.(string); ok {
49+
return s
50+
}
51+
return ""
52+
}
53+
54+
// TestIncrementalEqualsFull is the core determinism gate: a sequence of
55+
// incremental runs (file add → modify → delete) must produce a graph
56+
// identical (modulo metadata) to a clean full rebuild on the final state.
57+
//
58+
// Scenario:
59+
// Stage 1: write A.java + B.java + C.java, full index + enrich.
60+
// Stage 2: modify B.java, delete C.java, add D.java, index + enrich.
61+
// Stage 3: blow away .codeiq/, run index + enrich --force on the final tree.
62+
// Assert: snapshots from Stage 2 and Stage 3 are identical.
63+
func TestIncrementalEqualsFull(t *testing.T) {
64+
if testing.Short() {
65+
t.Skip("incremental integration test; -short")
66+
}
67+
src := t.TempDir()
68+
mustWrite := func(rel, content string) {
69+
t.Helper()
70+
full := filepath.Join(src, rel)
71+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
72+
t.Fatal(err)
73+
}
74+
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
75+
t.Fatal(err)
76+
}
77+
}
78+
79+
cachePath := filepath.Join(src, ".codeiq", "cache.sqlite")
80+
graphDir := filepath.Join(src, ".codeiq", "graph", "codeiq.kuzu")
81+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
82+
t.Fatal(err)
83+
}
84+
85+
indexAndEnrich := func() {
86+
t.Helper()
87+
c, err := cache.Open(cachePath)
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
defer c.Close()
92+
a := analyzer.NewAnalyzer(analyzer.Options{Cache: c, Workers: 1})
93+
if _, err := a.Run(src); err != nil {
94+
t.Fatal(err)
95+
}
96+
if _, err := analyzer.Enrich(src, c, analyzer.EnrichOptions{GraphDir: graphDir}); err != nil {
97+
t.Fatal(err)
98+
}
99+
}
100+
101+
// Stage 1: initial corpus.
102+
mustWrite("A.java", "public class A {}")
103+
mustWrite("B.java", "public class B {}")
104+
mustWrite("C.java", "public class C {}")
105+
indexAndEnrich()
106+
107+
// Stage 2: modify B, delete C, add D — incremental on top of Stage 1.
108+
if err := os.WriteFile(filepath.Join(src, "B.java"), []byte("public class B { int v = 2; }"), 0o644); err != nil {
109+
t.Fatal(err)
110+
}
111+
if err := os.Remove(filepath.Join(src, "C.java")); err != nil {
112+
t.Fatal(err)
113+
}
114+
mustWrite("D.java", "public class D {}")
115+
indexAndEnrich()
116+
incNodes, incEdges := graphSnapshot(t, graphDir)
117+
118+
// Stage 3: blow away .codeiq/ entirely, run again from scratch on the
119+
// same final filesystem. Use --force on enrich for belt-and-braces.
120+
if err := os.RemoveAll(filepath.Join(src, ".codeiq")); err != nil {
121+
t.Fatal(err)
122+
}
123+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
124+
t.Fatal(err)
125+
}
126+
c, err := cache.Open(cachePath)
127+
if err != nil {
128+
t.Fatal(err)
129+
}
130+
a := analyzer.NewAnalyzer(analyzer.Options{Cache: c, Workers: 1, Force: true})
131+
if _, err := a.Run(src); err != nil {
132+
t.Fatal(err)
133+
}
134+
if _, err := analyzer.Enrich(src, c, analyzer.EnrichOptions{GraphDir: graphDir, Force: true}); err != nil {
135+
t.Fatal(err)
136+
}
137+
c.Close()
138+
fullNodes, fullEdges := graphSnapshot(t, graphDir)
139+
140+
compareSnap := func(label string, got, want []string) {
141+
t.Helper()
142+
if len(got) != len(want) {
143+
t.Errorf("%s: len mismatch incremental=%d full=%d", label, len(got), len(want))
144+
}
145+
// Identify the symmetric difference.
146+
gotSet := make(map[string]struct{}, len(got))
147+
for _, v := range got {
148+
gotSet[v] = struct{}{}
149+
}
150+
wantSet := make(map[string]struct{}, len(want))
151+
for _, v := range want {
152+
wantSet[v] = struct{}{}
153+
}
154+
for v := range gotSet {
155+
if _, ok := wantSet[v]; !ok {
156+
t.Errorf("%s only in incremental: %q", label, v)
157+
}
158+
}
159+
for v := range wantSet {
160+
if _, ok := gotSet[v]; !ok {
161+
t.Errorf("%s only in full: %q", label, v)
162+
}
163+
}
164+
}
165+
compareSnap("nodes", incNodes, fullNodes)
166+
compareSnap("edges", incEdges, fullEdges)
167+
}
168+
169+
// TestIncrementalRerunIsIdempotent — three successive `index → enrich`
170+
// runs against an unchanged tree produce identical graphs and the 2nd/3rd
171+
// enrichments short-circuit.
172+
func TestIncrementalRerunIsIdempotent(t *testing.T) {
173+
src := t.TempDir()
174+
if err := os.WriteFile(filepath.Join(src, "X.java"), []byte("class X {}"), 0o644); err != nil {
175+
t.Fatal(err)
176+
}
177+
cachePath := filepath.Join(src, ".codeiq", "cache.sqlite")
178+
graphDir := filepath.Join(src, ".codeiq", "graph", "codeiq.kuzu")
179+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
180+
t.Fatal(err)
181+
}
182+
183+
var summaries []analyzer.EnrichSummary
184+
for i := 0; i < 3; i++ {
185+
c, err := cache.Open(cachePath)
186+
if err != nil {
187+
t.Fatal(err)
188+
}
189+
a := analyzer.NewAnalyzer(analyzer.Options{Cache: c, Workers: 1})
190+
if _, err := a.Run(src); err != nil {
191+
t.Fatal(err)
192+
}
193+
summary, err := analyzer.Enrich(src, c, analyzer.EnrichOptions{GraphDir: graphDir})
194+
if err != nil {
195+
t.Fatalf("enrich #%d: %v", i, err)
196+
}
197+
c.Close()
198+
summaries = append(summaries, summary)
199+
}
200+
201+
if summaries[0].ShortCircuited {
202+
t.Fatal("first enrich short-circuited; want full")
203+
}
204+
if !summaries[1].ShortCircuited {
205+
t.Fatal("second enrich did NOT short-circuit")
206+
}
207+
if !summaries[2].ShortCircuited {
208+
t.Fatal("third enrich did NOT short-circuit")
209+
}
210+
}
211+
212+
// TestIncrementalAcrossDeleteThenAdd — delete a file, re-index, add it
213+
// back with the same content, re-index. The cache should reflect the
214+
// transition correctly (path purged on delete, re-added with same hash).
215+
func TestIncrementalAcrossDeleteThenAdd(t *testing.T) {
216+
src := t.TempDir()
217+
cachePath := filepath.Join(src, ".codeiq", "cache.sqlite")
218+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
219+
t.Fatal(err)
220+
}
221+
222+
mustWrite := func(rel, content string) {
223+
full := filepath.Join(src, rel)
224+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
225+
t.Fatal(err)
226+
}
227+
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
228+
t.Fatal(err)
229+
}
230+
}
231+
232+
runIndex := func() *cache.Cache {
233+
c, err := cache.Open(cachePath)
234+
if err != nil {
235+
t.Fatal(err)
236+
}
237+
a := analyzer.NewAnalyzer(analyzer.Options{Cache: c, Workers: 1})
238+
if _, err := a.Run(src); err != nil {
239+
t.Fatal(err)
240+
}
241+
return c
242+
}
243+
244+
mustWrite("A.java", "class A {}")
245+
c := runIndex()
246+
c.Close()
247+
248+
// Delete the file. Next index should purge it from the cache.
249+
if err := os.Remove(filepath.Join(src, "A.java")); err != nil {
250+
t.Fatal(err)
251+
}
252+
c = runIndex()
253+
if _, _, ok := c.GetFileByPath("A.java"); ok {
254+
t.Fatal("deleted file still in cache after re-index")
255+
}
256+
c.Close()
257+
258+
// Recreate the same file. Next index should re-add it.
259+
mustWrite("A.java", "class A {}")
260+
c = runIndex()
261+
if _, _, ok := c.GetFileByPath("A.java"); !ok {
262+
t.Fatal("re-added file missing from cache")
263+
}
264+
c.Close()
265+
}

0 commit comments

Comments
 (0)