diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 8744fce..aaf5ac6 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,25 @@ func (i *Indexer) run(ctx context.Context, opts Options) (store.ScanSummary, err return summary, err } summary.ResolveMode = "paths" + + // 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) + } + 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..6ce8718 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,77 @@ 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 + } + + // 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() + + nameSet := make(map[string]struct{}, len(unique)) + for _, name := range unique { + nameSet[name] = struct{}{} + } + + 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 + } + if _, ok := nameSet[dstName]; ok { + targets = append(targets, edgeTarget{edgeID: id, dstName: dstName}) + continue + } + 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 + } + return len(targets), nil +} + func scanEdgeTargets(rows *sql.Rows) ([]edgeTarget, error) { defer rows.Close() var targets []edgeTarget