Skip to content

Commit e7b0c26

Browse files
authored
feat(incremental): delta-aware index + enrich (skip-by-hash + manifest short-circuit) (#174)
* feat(cache): add GetFileByPath lookup * feat(cache): add AllFiles streaming iterator * feat(cache): add PurgeByPath for incremental cleanup * feat(cache): add ManifestHash for graph-freshness short-circuit * feat(analyzer): add Diff for incremental file classification * feat(analyzer): cache-hit early-exit + Diff/purge integration in Run * feat(graph): RemoveFile/InsertFile/ReplaceFile per-file mutation APIs * feat(graph): GraphMeta table + ReadManifest/WriteManifest * feat(linker+graph): source-tag linker emissions + WipeLinkerEdges API * feat(enrich): manifest short-circuit + Reset for re-runnable enrich * feat(cli): index/enrich --force, enrich --diff, codeiq diff subcommand * test(integration): incremental==full determinism + idempotence + delete-then-add
1 parent 6dd90b5 commit e7b0c26

25 files changed

Lines changed: 2029 additions & 35 deletions

internal/analyzer/analyzer.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"runtime"
77
"sync"
8+
"sync/atomic"
89
"time"
910

1011
"github.com/randomcodespace/codeiq/internal/cache"
@@ -19,13 +20,19 @@ const DefaultBatchSize = 500
1920
type Options struct {
2021
Cache *cache.Cache
2122
Registry *detector.Registry
22-
BatchSize int // defaults to DefaultBatchSize
23-
Workers int // defaults to 2 * GOMAXPROCS
23+
BatchSize int // defaults to DefaultBatchSize
24+
Workers int // defaults to 2 * GOMAXPROCS
25+
Force bool // bypass cache early-exit; re-parse every file
2426
}
2527

2628
// Analyzer orchestrates the index pipeline.
2729
type Analyzer struct {
28-
opts Options
30+
opts Options
31+
counter runCounter
32+
}
33+
34+
type runCounter struct {
35+
cacheHits atomic.Int64
2936
}
3037

3138
// NewAnalyzer returns an analyzer wired to opts.
@@ -47,20 +54,48 @@ func NewAnalyzer(opts Options) *Analyzer {
4754
// Plan §1.5 — DedupedNodes/DedupedEdges/DroppedEdges expose dedup activity
4855
// so operators can see "graph collapsed 312 duplicate nodes, dropped 14
4956
// phantom edges" — the visibility is what makes "meaningful" diagnosable.
57+
//
58+
// Added/Modified/Deleted/Unchanged/CacheHits are incremental counters,
59+
// zero on full `--force` runs.
5060
type Stats struct {
5161
Files int
5262
Nodes int
5363
Edges int
5464
DedupedNodes int
5565
DedupedEdges int
5666
DroppedEdges int
67+
Added int
68+
Modified int
69+
Deleted int
70+
Unchanged int
71+
CacheHits int
5772
}
5873

5974
// Run executes FileDiscovery → parse → detectors → GraphBuilder → cache writes
6075
// and returns aggregate stats. Errors from individual file processing are
6176
// logged to stderr but do not stop the run — partial output is better than no
6277
// output (matches Java's per-file try/catch behaviour).
78+
//
79+
// On non-Force runs with a cache present, Run first runs Diff() to classify
80+
// files, purges cache rows for deleted files, then proceeds. processFile
81+
// skips parse+detect for UNCHANGED files (content_hash hit in cache).
6382
func (a *Analyzer) Run(root string) (Stats, error) {
83+
a.counter.cacheHits.Store(0)
84+
85+
var d Delta
86+
if a.opts.Cache != nil && !a.opts.Force {
87+
var err error
88+
d, err = a.Diff(root)
89+
if err != nil {
90+
return Stats{}, err
91+
}
92+
for _, path := range d.Deleted {
93+
if err := a.opts.Cache.PurgeByPath(path); err != nil {
94+
fmt.Fprintf(os.Stderr, "codeiq: purge %s: %v\n", path, err)
95+
}
96+
}
97+
}
98+
6499
disc := NewFileDiscovery()
65100
files, err := disc.Discover(root)
66101
if err != nil {
@@ -99,6 +134,11 @@ func (a *Analyzer) Run(root string) (Stats, error) {
99134
DedupedNodes: snap.DedupedNodes,
100135
DedupedEdges: snap.DedupedEdges,
101136
DroppedEdges: snap.DroppedEdges,
137+
Added: len(d.Added),
138+
Modified: len(d.Modified),
139+
Deleted: len(d.Deleted),
140+
Unchanged: len(d.Unchanged),
141+
CacheHits: int(a.counter.cacheHits.Load()),
102142
}, nil
103143
}
104144

@@ -108,6 +148,18 @@ func (a *Analyzer) processFile(f DiscoveredFile, gb *GraphBuilder) error {
108148
return err
109149
}
110150
hash := cache.HashString(string(content))
151+
152+
// Fast path: cache hit. Reuse the previous emissions; skip parse+detect.
153+
if a.opts.Cache != nil && !a.opts.Force && a.opts.Cache.Has(hash) {
154+
entry, gerr := a.opts.Cache.Get(hash)
155+
if gerr == nil && entry != nil {
156+
gb.Add(&detector.Result{Nodes: entry.Nodes, Edges: entry.Edges})
157+
a.counter.cacheHits.Add(1)
158+
return nil
159+
}
160+
// Has() true but Get() failed — pathological. Fall through to re-parse.
161+
}
162+
111163
tree, err := parser.Parse(f.Language, content)
112164
if err != nil {
113165
// Continue with regex-only detectors when the parser bails — matches
@@ -142,6 +194,9 @@ func (a *Analyzer) processFile(f DiscoveredFile, gb *GraphBuilder) error {
142194
entry.Edges = append(entry.Edges, r.Edges...)
143195
}
144196
if a.opts.Cache != nil {
197+
// MODIFIED files: purge prior (path, old_hash) row so a single path
198+
// never has two cache entries.
199+
_ = a.opts.Cache.PurgeByPath(f.RelPath)
145200
if err := a.opts.Cache.Put(entry); err != nil {
146201
return fmt.Errorf("cache put: %w", err)
147202
}

internal/analyzer/analyzer_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,134 @@ func TestAnalyzerEndToEnd(t *testing.T) {
6666
}
6767
}
6868

69+
func TestStatsHasIncrementalCounters(t *testing.T) {
70+
var s Stats
71+
// Compile-time check that the new fields exist with the expected names.
72+
_ = s.Added
73+
_ = s.Modified
74+
_ = s.Deleted
75+
_ = s.Unchanged
76+
_ = s.CacheHits
77+
}
78+
79+
func TestProcessFileSkipsOnCacheHit(t *testing.T) {
80+
root := t.TempDir()
81+
cachePath := filepath.Join(root, ".codeiq", "cache.sqlite")
82+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
83+
t.Fatal(err)
84+
}
85+
src := "public class A {}"
86+
if err := os.WriteFile(filepath.Join(root, "A.java"), []byte(src), 0o644); err != nil {
87+
t.Fatal(err)
88+
}
89+
90+
c, err := cache.Open(cachePath)
91+
if err != nil {
92+
t.Fatalf("cache: %v", err)
93+
}
94+
defer c.Close()
95+
96+
// Seed the cache with a row for this content hash. processFile MUST
97+
// not re-parse the file when its hash already lives in the cache.
98+
if err := c.Put(&cache.Entry{
99+
ContentHash: cache.HashString(src),
100+
Path: "A.java",
101+
Language: "java",
102+
ParsedAt: "2026-01-01T00:00:00Z",
103+
}); err != nil {
104+
t.Fatal(err)
105+
}
106+
107+
a := NewAnalyzer(Options{Cache: c, Registry: detector.Default, Workers: 1})
108+
stats, err := a.Run(root)
109+
if err != nil {
110+
t.Fatalf("run: %v", err)
111+
}
112+
if stats.CacheHits != 1 {
113+
t.Fatalf("CacheHits = %d, want 1", stats.CacheHits)
114+
}
115+
if stats.Files != 1 {
116+
t.Fatalf("Files = %d, want 1", stats.Files)
117+
}
118+
if stats.Unchanged != 1 {
119+
t.Fatalf("Unchanged = %d, want 1", stats.Unchanged)
120+
}
121+
}
122+
123+
func TestForceBypassesCacheHit(t *testing.T) {
124+
root := t.TempDir()
125+
cachePath := filepath.Join(root, ".codeiq", "cache.sqlite")
126+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
127+
t.Fatal(err)
128+
}
129+
src := "public class A {}"
130+
if err := os.WriteFile(filepath.Join(root, "A.java"), []byte(src), 0o644); err != nil {
131+
t.Fatal(err)
132+
}
133+
134+
c, err := cache.Open(cachePath)
135+
if err != nil {
136+
t.Fatal(err)
137+
}
138+
defer c.Close()
139+
_ = c.Put(&cache.Entry{
140+
ContentHash: cache.HashString(src),
141+
Path: "A.java",
142+
Language: "java",
143+
ParsedAt: "t",
144+
})
145+
146+
a := NewAnalyzer(Options{Cache: c, Registry: detector.Default, Workers: 1, Force: true})
147+
stats, err := a.Run(root)
148+
if err != nil {
149+
t.Fatal(err)
150+
}
151+
if stats.CacheHits != 0 {
152+
t.Fatalf("Force=true should bypass cache; CacheHits = %d", stats.CacheHits)
153+
}
154+
}
155+
156+
func TestRunPurgesDeletedFiles(t *testing.T) {
157+
root := t.TempDir()
158+
cachePath := filepath.Join(root, ".codeiq", "cache.sqlite")
159+
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
160+
t.Fatal(err)
161+
}
162+
c, err := cache.Open(cachePath)
163+
if err != nil {
164+
t.Fatal(err)
165+
}
166+
defer c.Close()
167+
168+
// Seed a phantom file that's gone from disk.
169+
if err := c.Put(&cache.Entry{
170+
ContentHash: "ghost-hash",
171+
Path: "deleted.java",
172+
Language: "java",
173+
ParsedAt: "t",
174+
}); err != nil {
175+
t.Fatal(err)
176+
}
177+
if !c.Has("ghost-hash") {
178+
t.Fatal("seed didn't take")
179+
}
180+
if err := os.WriteFile(filepath.Join(root, "real.java"), []byte("class R {}"), 0o644); err != nil {
181+
t.Fatal(err)
182+
}
183+
184+
a := NewAnalyzer(Options{Cache: c, Registry: detector.Default, Workers: 1})
185+
stats, err := a.Run(root)
186+
if err != nil {
187+
t.Fatal(err)
188+
}
189+
if c.Has("ghost-hash") {
190+
t.Fatal("deleted file's cache row not purged")
191+
}
192+
if stats.Deleted != 1 {
193+
t.Fatalf("Deleted = %d, want 1", stats.Deleted)
194+
}
195+
}
196+
69197
func TestAnalyzerDeterminism(t *testing.T) {
70198
dir := t.TempDir()
71199
if err := os.WriteFile(filepath.Join(dir, "UserController.java"), []byte(fixtureJava), 0644); err != nil {

internal/analyzer/diff.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package analyzer
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/randomcodespace/codeiq/internal/cache"
8+
)
9+
10+
// Delta is the result of comparing the on-disk file set to the cache state.
11+
// All slices are sorted by path (FileDiscovery sorts; AllFiles iterates in
12+
// path order) so callers can rely on stable order.
13+
type Delta struct {
14+
Added []string // on disk, not in cache
15+
Modified []string // path in cache but content_hash differs from disk
16+
Deleted []string // in cache, missing from disk
17+
Unchanged []string // path + content_hash match cache exactly
18+
}
19+
20+
// Diff walks the project root via FileDiscovery and classifies each file
21+
// against the cache. UNCHANGED files cost one hash per file; nothing else
22+
// is parsed or detected.
23+
//
24+
// Returns Delta with empty slices (not nil) when there is no work in a
25+
// bucket.
26+
func (a *Analyzer) Diff(root string) (Delta, error) {
27+
d := Delta{}
28+
if a.opts.Cache == nil {
29+
return d, fmt.Errorf("diff: cache is required")
30+
}
31+
disc := NewFileDiscovery()
32+
files, err := disc.Discover(root)
33+
if err != nil {
34+
return d, fmt.Errorf("file discovery: %w", err)
35+
}
36+
37+
seen := make(map[string]bool, len(files))
38+
for _, f := range files {
39+
seen[f.RelPath] = true
40+
content, err := os.ReadFile(f.AbsPath)
41+
if err != nil {
42+
fmt.Fprintf(os.Stderr, "codeiq: diff: %s: %v\n", f.RelPath, err)
43+
continue
44+
}
45+
curHash := cache.HashString(string(content))
46+
cachedHash, _, ok := a.opts.Cache.GetFileByPath(f.RelPath)
47+
switch {
48+
case !ok:
49+
d.Added = append(d.Added, f.RelPath)
50+
case cachedHash == curHash:
51+
d.Unchanged = append(d.Unchanged, f.RelPath)
52+
default:
53+
d.Modified = append(d.Modified, f.RelPath)
54+
}
55+
}
56+
57+
if err := a.opts.Cache.AllFiles(func(path, _ string) error {
58+
if !seen[path] {
59+
d.Deleted = append(d.Deleted, path)
60+
}
61+
return nil
62+
}); err != nil {
63+
return d, err
64+
}
65+
return d, nil
66+
}

0 commit comments

Comments
 (0)