From e9aa08befc7df19274bb78e01e75239ab6545769 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:18:39 +0200 Subject: [PATCH 1/2] indexer: targeted cross-file edge resolution on update (preserve path-scoped win) --- internal/indexer/indexer.go | 25 ++++++ .../indexer/indexer_resolve_crossfile_test.go | 79 +++++++++++++++++++ internal/store/store.go | 79 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 internal/indexer/indexer_resolve_crossfile_test.go diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 8744fce..7fcf62f 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -281,6 +281,7 @@ func (i *Indexer) run(ctx context.Context, opts Options) (store.ScanSummary, err parseFailedBatch := make([]store.FileMetadataUpdate, 0, metadataBatchSize) replaceBatch := make([]store.ReplaceFileGraphInput, 0, replaceBatchSize) changedPathSet := make(map[string]struct{}, 64) + changedSymbolNameSet := make(map[string]struct{}, 256) var writeMetadataDur time.Duration var writeReplaceDur time.Duration @@ -444,6 +445,12 @@ func (i *Indexer) run(ctx context.Context, opts Options) (store.ScanSummary, err Parsed: res.parsed, }) changedPathSet[res.task.rel] = struct{}{} + for _, sym := range res.parsed.Symbols { + if sym.Name == "" { + continue + } + changedSymbolNameSet[sym.Name] = struct{}{} + } summary.FilesChanged++ summary.FilesIndexed++ coverage := summary.LanguageCoverage[coverageLanguage] @@ -576,6 +583,24 @@ func (i *Indexer) run(ctx context.Context, opts Options) (store.ScanSummary, err return summary, err } summary.ResolveMode = "paths" + + // Correctness: if this update introduced symbols, try to resolve previously-unresolved + // edges in other files that reference those names without falling back to repo-wide resolve. + if scanKind == "update" && len(changedSymbolNameSet) > 0 { + names := make([]string, 0, len(changedSymbolNameSet)) + for name := range changedSymbolNameSet { + names = append(names, name) + } + crossStart := time.Now() + n, err := i.store.ResolveEdgesForNames(ctx, repo.ID, names) + if err != nil { + _ = i.store.CompleteScan(ctx, scanID, summary, started, "failed", err.Error()) + return summary, err + } + summary.ResolveCrossFileTargets = n + summary.ResolveCrossFileMS = time.Since(crossStart).Milliseconds() + summary.ResolveMode = "paths+names" + } } else { if _, resolveErr := i.store.ResolveEdges(ctx, repo.ID); resolveErr != nil { _ = i.store.CompleteScan(ctx, scanID, summary, started, "failed", resolveErr.Error()) diff --git a/internal/indexer/indexer_resolve_crossfile_test.go b/internal/indexer/indexer_resolve_crossfile_test.go new file mode 100644 index 0000000..3722fb9 --- /dev/null +++ b/internal/indexer/indexer_resolve_crossfile_test.go @@ -0,0 +1,79 @@ +package indexer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/isink17/codegraph/internal/parser" + "github.com/isink17/codegraph/internal/parser/golang" + "github.com/isink17/codegraph/internal/store" +) + +func TestUpdateResolvesUnresolvedEdgesInOtherFilesWhenNewSymbolIntroduced(t *testing.T) { + ctx := context.Background() + repoRoot := t.TempDir() + dbPath := filepath.Join(t.TempDir(), "codegraph.db") + + write := func(rel, content string) { + t.Helper() + abs := filepath.Join(repoRoot, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + write("a.go", "package p\n\nfunc A() { Foo() }\n") + write("b.go", "package p\n\nfunc B() { Foo() }\n") + + s, err := store.Open(dbPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + reg := parser.NewRegistry(golang.New()) + idx := New(s, reg, nil) + + if _, err := idx.Index(ctx, Options{RepoRoot: repoRoot, ScanKind: "index"}); err != nil { + t.Fatalf("index: %v", err) + } + repo, err := s.UpsertRepo(ctx, repoRoot) + if err != nil { + t.Fatalf("upsert repo: %v", err) + } + + // Introduce Foo in a.go and update only that path. + write("a.go", "package p\n\nfunc Foo() {}\n\nfunc A() { Foo() }\n") + if _, err := idx.Update(ctx, Options{RepoRoot: repoRoot, ScanKind: "update", Paths: []string{"a.go"}}); err != nil { + t.Fatalf("update: %v", err) + } + + // Verify b.go's edge now resolves. + edges, err := s.ExportEdgesPage(ctx, repo.ID, 10000, 0) + if err != nil { + t.Fatalf("export edges: %v", err) + } + foundB := false + resolvedB := false + for _, e := range edges { + if e.FilePath != "b.go" { + continue + } + foundB = true + if e.DstSymbolID != nil { + resolvedB = true + break + } + } + if !foundB { + t.Fatalf("expected at least one edge from b.go") + } + if !resolvedB { + t.Fatalf("expected unresolved edge in b.go to be resolved after updating only a.go") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index b8e40f6..53ed088 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -127,6 +127,8 @@ type ScanSummary struct { MarkMissingMS int64 `json:"mark_missing_ms,omitempty"` ResolveMS int64 `json:"resolve_ms,omitempty"` ResolveMode string `json:"resolve_mode,omitempty"` + ResolveCrossFileMS int64 `json:"resolve_cross_file_ms,omitempty"` + ResolveCrossFileTargets int `json:"resolve_cross_file_targets,omitempty"` DurationMS int64 `json:"duration_ms"` } @@ -1822,6 +1824,83 @@ func (s *Store) ResolveEdgesForPaths(ctx context.Context, repoID int64, paths [] return s.resolveEdgeTargets(ctx, repoID, targets) } +// ResolveEdgesForNames attempts to resolve currently-unresolved edges across the +// repo whose dst_name matches (or ends with ".") any of the provided +// names. This is used to keep incremental update runs correct when newly +// introduced symbols should resolve previously-unresolved edges in other files. +// +// It returns the number of candidate edges selected for resolution. +func (s *Store) ResolveEdgesForNames(ctx context.Context, repoID int64, names []string) (int, error) { + if len(names) == 0 { + return 0, nil + } + seen := make(map[string]struct{}, len(names)) + unique := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + unique = append(unique, name) + } + if len(unique) == 0 { + return 0, nil + } + + const chunkSize = 200 + targets := make([]edgeTarget, 0, 64) + for start := 0; start < len(unique); start += chunkSize { + end := min(start+chunkSize, len(unique)) + chunk := unique[start:end] + + var b strings.Builder + b.WriteString(`SELECT id, dst_name FROM edges WHERE repo_id = ? AND dst_symbol_id IS NULL AND (`) + + // Exact matches. + b.WriteString(`dst_name IN (`) + for i := range chunk { + if i > 0 { + b.WriteString(",") + } + b.WriteString("?") + } + b.WriteString(`)`) + + // Suffix matches for qualified-like names (e.g. "pkg.Foo", "import/path.Foo"). + for range chunk { + b.WriteString(` OR dst_name LIKE ?`) + } + b.WriteString(`)`) + + args := make([]any, 0, 1+(len(chunk)*2)) + args = append(args, repoID) + for _, name := range chunk { + args = append(args, name) + } + for _, name := range chunk { + args = append(args, "%."+name) + } + + rows, err := s.db.QueryContext(ctx, b.String(), args...) + if err != nil { + return 0, err + } + chunkTargets, err := scanEdgeTargets(rows) + if err != nil { + return 0, err + } + targets = append(targets, chunkTargets...) + } + if err := s.resolveEdgeTargets(ctx, repoID, targets); err != nil { + return 0, err + } + return len(targets), nil +} + func scanEdgeTargets(rows *sql.Rows) ([]edgeTarget, error) { defer rows.Close() var targets []edgeTarget From 8029c1de8bf4bddb857ed9722e075545e6b4effb Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:33:20 +0200 Subject: [PATCH 2/2] indexer: generalize cross-file resolve for partial runs; simplify ResolveEdgesForNames --- internal/indexer/indexer.go | 7 ++-- internal/store/store.go | 70 +++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 7fcf62f..aaf5ac6 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -584,9 +584,10 @@ func (i *Indexer) run(ctx context.Context, opts Options) (store.ScanSummary, err } summary.ResolveMode = "paths" - // Correctness: if this update introduced symbols, try to resolve previously-unresolved - // edges in other files that reference those names without falling back to repo-wide resolve. - if scanKind == "update" && len(changedSymbolNameSet) > 0 { + // Correctness: partial runs can introduce symbols that should resolve previously-unresolved + // edges in other files. Do a narrow cross-file pass keyed by the introduced symbol names, + // without falling back to repo-wide resolution. + if len(changedSymbolNameSet) > 0 { names := make([]string, 0, len(changedSymbolNameSet)) for name := range changedSymbolNameSet { names = append(names, name) diff --git a/internal/store/store.go b/internal/store/store.go index 53ed088..6ce8718 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1851,49 +1851,43 @@ func (s *Store) ResolveEdgesForNames(ctx context.Context, repoID int64, names [] return 0, nil } - const chunkSize = 200 - targets := make([]edgeTarget, 0, 64) - for start := 0; start < len(unique); start += chunkSize { - end := min(start+chunkSize, len(unique)) - chunk := unique[start:end] - - var b strings.Builder - b.WriteString(`SELECT id, dst_name FROM edges WHERE repo_id = ? AND dst_symbol_id IS NULL AND (`) - - // Exact matches. - b.WriteString(`dst_name IN (`) - for i := range chunk { - if i > 0 { - b.WriteString(",") - } - b.WriteString("?") - } - b.WriteString(`)`) - - // Suffix matches for qualified-like names (e.g. "pkg.Foo", "import/path.Foo"). - for range chunk { - b.WriteString(` OR dst_name LIKE ?`) - } - b.WriteString(`)`) + // Query shape: do a single unresolved-edge scan, then filter in Go. + // This avoids repeated SQL queries with many OR/L LIKE clauses (which are + // difficult for SQLite to optimize with indexes). + rows, err := s.db.QueryContext(ctx, ` + SELECT id, dst_name + FROM edges + WHERE repo_id = ? AND dst_symbol_id IS NULL AND dst_name != '' + `, repoID) + if err != nil { + return 0, err + } + defer rows.Close() - args := make([]any, 0, 1+(len(chunk)*2)) - args = append(args, repoID) - for _, name := range chunk { - args = append(args, name) - } - for _, name := range chunk { - args = append(args, "%."+name) - } + nameSet := make(map[string]struct{}, len(unique)) + for _, name := range unique { + nameSet[name] = struct{}{} + } - rows, err := s.db.QueryContext(ctx, b.String(), args...) - if err != nil { + targets := make([]edgeTarget, 0, 64) + for rows.Next() { + var id int64 + var dstName string + if err := rows.Scan(&id, &dstName); err != nil { return 0, err } - chunkTargets, err := scanEdgeTargets(rows) - if err != nil { - return 0, err + if _, ok := nameSet[dstName]; ok { + targets = append(targets, edgeTarget{edgeID: id, dstName: dstName}) + continue } - targets = append(targets, chunkTargets...) + if dot := strings.LastIndexByte(dstName, '.'); dot >= 0 && dot+1 < len(dstName) { + if _, ok := nameSet[dstName[dot+1:]]; ok { + targets = append(targets, edgeTarget{edgeID: id, dstName: dstName}) + } + } + } + if err := rows.Err(); err != nil { + return 0, err } if err := s.resolveEdgeTargets(ctx, repoID, targets); err != nil { return 0, err