Skip to content

Commit 793000e

Browse files
committed
Improve import package resolution
1 parent b017a6c commit 793000e

14 files changed

Lines changed: 924 additions & 208 deletions

File tree

internal/analysis/impact/impact.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func (a *Analyzer) ImpactRadius(ctx context.Context, nodeID uint, depth int) ([]
7272
// @param opts traversal limits applied during BFS
7373
// @return RadiusResult with visited nodes and truncation metadata
7474
// @domainRule traverses outgoing and incoming edges in lock step at each depth
75-
// @ensures Truncated is true when MaxNodes stopped traversal or post-trim
75+
// @ensures Truncated is true when MaxNodes stopped traversal before adding another node
7676
func (a *Analyzer) ImpactRadiusBounded(ctx context.Context, nodeID uint, depth int, opts RadiusOptions) (*RadiusResult, error) {
7777
if opts.MaxDepth > 0 && depth > opts.MaxDepth {
7878
depth = opts.MaxDepth

internal/analysis/incremental/incremental.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ func (s *Syncer) syncWithExisting(ctx context.Context, syncStore Store, files ma
231231
if err != nil {
232232
return nil, err
233233
}
234+
resolved = edgeresolve.FilterResolved(resolved)
234235
if err := syncStore.UpsertEdges(ctx, resolved); err != nil {
235236
return nil, err
236237
}

internal/analysis/incremental/incremental_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,40 @@ func TestSyncWithExisting_ReleasesContentAfterProcessing(t *testing.T) {
500500
}
501501
}
502502

503+
func TestSyncWithExisting_DoesNotPersistUnresolvedEdges(t *testing.T) {
504+
st := newStore()
505+
parser := &staticParser{result: map[string]parseResult{
506+
"main.go": {
507+
nodes: []model.Node{{
508+
QualifiedName: "pkg.Main",
509+
Kind: model.NodeKindFunction,
510+
Name: "Main",
511+
FilePath: "main.go",
512+
StartLine: 1,
513+
EndLine: 3,
514+
Hash: "hash",
515+
Language: "go",
516+
}},
517+
edges: []model.Edge{{
518+
Kind: model.EdgeKindCalls,
519+
FilePath: "main.go",
520+
Line: 2,
521+
Fingerprint: "calls:main.go:pkg.Missing:2",
522+
}},
523+
},
524+
}}
525+
syncer := New(st, parser)
526+
_, err := syncer.Sync(context.Background(), map[string]FileInfo{
527+
"main.go": {Hash: "hash", Content: []byte("package pkg\nfunc Main(){ Missing() }")},
528+
})
529+
if err != nil {
530+
t.Fatal(err)
531+
}
532+
if len(st.upsertedEdges) != 0 {
533+
t.Fatalf("expected unresolved edge to be skipped, got %+v", st.upsertedEdges)
534+
}
535+
}
536+
503537
func TestSyncWithExisting_ReleasesContentForUnchangedAndUnparsedFiles(t *testing.T) {
504538
st := newStore()
505539
st.nodes["pkg.Existing"] = &model.Node{QualifiedName: "pkg.Existing", Kind: model.NodeKindFunction, Name: "Existing", FilePath: "exist.go", Hash: "same123", Language: "go"}

internal/edgeresolve/resolve.go

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ func Resolve(ctx context.Context, lookup NodeLookup, edges []model.Edge) ([]mode
9999
return out, nil
100100
}
101101

102+
// FilterResolved returns only edges that have both persisted endpoints.
103+
// @intent prevent unresolved syntax candidates from occupying fingerprints before they become traversable
104+
func FilterResolved(edges []model.Edge) []model.Edge {
105+
if len(edges) == 0 {
106+
return nil
107+
}
108+
out := make([]model.Edge, 0, len(edges))
109+
for _, edge := range edges {
110+
if edge.FromNodeID == 0 || edge.ToNodeID == 0 {
111+
continue
112+
}
113+
out = append(out, edge)
114+
}
115+
return out
116+
}
117+
102118
func edgeFiles(edges []model.Edge) []string {
103119
seen := make(map[string]bool)
104120
var files []string
@@ -313,6 +329,11 @@ func resolveImportsFrom(ctx context.Context, lookup NodeLookup, edge *model.Edge
313329
if !ok {
314330
return
315331
}
332+
if target := uniquePackageNode(st.qnIndex[importPath]); target != nil {
333+
edge.ToNodeID = target.ID
334+
st.loadImportFileNodes(ctx, lookup, importPath)
335+
return
336+
}
316337
if target := uniqueFileNode(st.qnIndex[importPath]); target != nil {
317338
edge.ToNodeID = target.ID
318339
st.loadFileNodes(ctx, lookup, target.FilePath)
@@ -324,6 +345,20 @@ func resolveImportsFrom(ctx context.Context, lookup NodeLookup, edge *model.Edge
324345
}
325346
}
326347

348+
func (st *resolveState) loadImportFileNodes(ctx context.Context, lookup NodeLookup, importPath string) {
349+
prefixLookup, ok := lookup.(filePrefixLookup)
350+
if !ok || importPath == "" {
351+
return
352+
}
353+
nodes, err := prefixLookup.GetFileNodesByPathSuffix(ctx, importPath)
354+
if err != nil {
355+
return
356+
}
357+
for _, node := range uniqueFileNodes(nodes) {
358+
st.loadFileNodes(ctx, lookup, node.FilePath)
359+
}
360+
}
361+
327362
func (st *resolveState) loadFileNodes(ctx context.Context, lookup NodeLookup, filePath string) {
328363
if filePath == "" {
329364
return
@@ -408,6 +443,27 @@ func representativeImportFile(nodes []model.Node) *model.Node {
408443
return &files[0]
409444
}
410445

446+
func uniquePackageNode(nodes []model.Node) *model.Node {
447+
var found *model.Node
448+
seen := make(map[uint]bool)
449+
for i := range nodes {
450+
if nodes[i].Kind != model.NodeKindPackage {
451+
continue
452+
}
453+
if nodes[i].ID != 0 && seen[nodes[i].ID] {
454+
continue
455+
}
456+
if nodes[i].ID != 0 {
457+
seen[nodes[i].ID] = true
458+
}
459+
if found != nil {
460+
return nil
461+
}
462+
found = &nodes[i]
463+
}
464+
return found
465+
}
466+
411467
func uniqueFileNodes(nodes []model.Node) []model.Node {
412468
seen := make(map[uint]bool)
413469
var files []model.Node
@@ -458,19 +514,23 @@ func resolveInherits(edge *model.Edge, st *resolveState) {
458514
}
459515

460516
func resolveTestedBy(edge *model.Edge, st *resolveState) {
461-
bare, testQN, ok := testedByEndpoints(*edge)
517+
callee, testQN, ok := testedByEndpoints(*edge)
462518
if !ok {
463519
return
464520
}
465521
if from := uniqueCallable(st.qnIndex[testQN]); from != nil {
466522
edge.FromNodeID = from.ID
467523
}
468-
if to := resolveProductionFunction(st, edge.FilePath, bare); to != nil {
524+
if to := resolveProductionFunction(st, edge.FilePath, callee); to != nil {
469525
edge.ToNodeID = to.ID
470526
}
471527
}
472528

473-
func resolveProductionFunction(st *resolveState, testFilePath, bare string) *model.Node {
529+
func resolveProductionFunction(st *resolveState, testFilePath, callee string) *model.Node {
530+
if target := uniqueCallable(st.qnIndex[callee]); target != nil {
531+
return target
532+
}
533+
bare := lastSegment(callee)
474534
pkg := packageForFile(st.nodesByFile[testFilePath])
475535
if pkg != "" {
476536
if target := uniqueCallable(st.qnIndex[pkg+"."+bare]); target != nil {

internal/edgeresolve/resolve_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,27 @@ func TestResolveImportsFromBindsLexicographicRepresentativeForMultiFilePackage(t
331331
}
332332
}
333333

334+
func TestResolveImportsFromPrefersPackageNodeOverFileRepresentative(t *testing.T) {
335+
lookup := fakeLookup{nodes: []model.Node{
336+
{ID: 10, QualifiedName: "cmd/main.go", Name: "cmd/main.go", Kind: model.NodeKindFile, FilePath: "cmd/main.go", Language: "go"},
337+
{ID: 20, QualifiedName: "internal/mcp/a.go", Name: "internal/mcp/a.go", Kind: model.NodeKindFile, FilePath: "internal/mcp/a.go", Language: "go"},
338+
{ID: 21, QualifiedName: "internal/mcp/z.go", Name: "internal/mcp/z.go", Kind: model.NodeKindFile, FilePath: "internal/mcp/z.go", Language: "go"},
339+
{ID: 30, QualifiedName: "github.com/example/project/internal/mcp", Name: "mcp", Kind: model.NodeKindPackage, FilePath: "internal/mcp", Language: "go"},
340+
}}
341+
edges, err := Resolve(context.Background(), lookup, []model.Edge{{
342+
Kind: model.EdgeKindImportsFrom,
343+
FilePath: "cmd/main.go",
344+
Line: 2,
345+
Fingerprint: "imports_from:cmd/main.go:github.com/example/project/internal/mcp:2",
346+
}})
347+
if err != nil {
348+
t.Fatal(err)
349+
}
350+
if got := edges[0].ToNodeID; got != 30 {
351+
t.Fatalf("ToNodeID=%d, want package node 30", got)
352+
}
353+
}
354+
334355
func TestResolveImportsFromLeavesAmbiguousSuffixUnresolved(t *testing.T) {
335356
lookup := fakeLookup{nodes: []model.Node{
336357
{ID: 10, QualifiedName: "cmd/main.go", Name: "cmd/main.go", Kind: model.NodeKindFile, FilePath: "cmd/main.go", Language: "go"},
@@ -395,6 +416,42 @@ func TestResolveTestedByBindsTestAndProductionEndpoints(t *testing.T) {
395416
}
396417
}
397418

419+
func TestFilterResolvedDropsEdgesWithMissingEndpoints(t *testing.T) {
420+
edges := FilterResolved([]model.Edge{
421+
{FromNodeID: 1, ToNodeID: 2, Kind: model.EdgeKindCalls, Fingerprint: "resolved"},
422+
{FromNodeID: 0, ToNodeID: 2, Kind: model.EdgeKindCalls, Fingerprint: "missing-from"},
423+
{FromNodeID: 1, ToNodeID: 0, Kind: model.EdgeKindCalls, Fingerprint: "missing-to"},
424+
})
425+
if len(edges) != 1 {
426+
t.Fatalf("expected 1 resolved edge, got %d: %+v", len(edges), edges)
427+
}
428+
if edges[0].Fingerprint != "resolved" {
429+
t.Fatalf("expected resolved edge to remain, got %+v", edges[0])
430+
}
431+
}
432+
433+
func TestResolveTestedByBindsQualifiedProductionEndpoint(t *testing.T) {
434+
lookup := fakeLookup{nodes: []model.Node{
435+
{ID: 1, QualifiedName: "calc.Add", Name: "Add", Kind: model.NodeKindFunction, FilePath: "add.go", StartLine: 3, EndLine: 5, Language: "go"},
436+
{ID: 2, QualifiedName: "calc_test.TestAdd", Name: "TestAdd", Kind: model.NodeKindTest, FilePath: "add_test.go", StartLine: 3, EndLine: 7, Language: "go"},
437+
}}
438+
edges, err := Resolve(context.Background(), lookup, []model.Edge{{
439+
Kind: model.EdgeKindTestedBy,
440+
FilePath: "add_test.go",
441+
Line: 5,
442+
Fingerprint: "tested_by:add_test.go:calc.Add:calc_test.TestAdd",
443+
}})
444+
if err != nil {
445+
t.Fatal(err)
446+
}
447+
if got := edges[0].FromNodeID; got != 2 {
448+
t.Fatalf("FromNodeID=%d, want test node 2", got)
449+
}
450+
if got := edges[0].ToNodeID; got != 1 {
451+
t.Fatalf("ToNodeID=%d, want production node 1", got)
452+
}
453+
}
454+
398455
func TestResolveTestedByLeavesAmbiguousProductionUnresolved(t *testing.T) {
399456
lookup := fakeLookup{nodes: []model.Node{
400457
{ID: 1, QualifiedName: "pkg.Add", Name: "Add", Kind: model.NodeKindFunction, FilePath: "add_test.go", StartLine: 3, EndLine: 5, Language: "go"},

internal/model/node.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type NodeKind string
88

99
const (
1010
NodeKindFile NodeKind = "file"
11+
NodeKindPackage NodeKind = "package"
1112
NodeKindClass NodeKind = "class"
1213
NodeKindFunction NodeKind = "function"
1314
NodeKindType NodeKind = "type"

0 commit comments

Comments
 (0)