Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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())
Expand Down
79 changes: 79 additions & 0 deletions internal/indexer/indexer_resolve_crossfile_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
73 changes: 73 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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 ".<name>") 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
}
Comment thread
isink17 marked this conversation as resolved.
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
Expand Down
Loading