Skip to content
Open
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
35 changes: 35 additions & 0 deletions internal/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ func (p *Pipeline) runFullPasses(files []discover.FileInfo) error {
p.passConfigLinker()
slog.Info("pass.timing", "pass", "configlinker", "elapsed", time.Since(t))

t = time.Now()
p.passPubSubLinks()
slog.Info("pass.timing", "pass", "pubsublinks", "elapsed", time.Since(t))

t = time.Now()
p.passGitHistory()
slog.Info("pass.timing", "pass", "githistory", "elapsed", time.Since(t))
Expand Down Expand Up @@ -503,6 +507,7 @@ func (p *Pipeline) runIncrementalPasses(
slog.Warn("pass.httplink.err", "err", err)
}
p.passConfigLinker()
p.passPubSubLinks()
p.passImplements()
p.passGitHistory()

Expand Down Expand Up @@ -1214,6 +1219,36 @@ func (p *Pipeline) resolveCallWithTypes(
if p.registry.Exists(candidate) {
return ResolutionResult{QualifiedName: candidate, Strategy: "type_dispatch", Confidence: 0.90, CandidateCount: 1}
}

// Two-hop resolution for chained field access: h.svc.Method()
// where h is *Handler and svc is a field. Resolve the last
// segment as a method name, excluding methods from the same
// module as the receiver (avoids self-referencing).
// Primarily useful for Go receiver patterns; resolves only the
// last segment (a.b.c.d() β†’ resolves d()), not intermediate hops.
if strings.Contains(methodName, ".") {
chainParts := strings.Split(methodName, ".")
lastMethod := chainParts[len(chainParts)-1]
allCandidates := p.registry.FindByName(lastMethod)
// Exclude candidates from the receiver's module (same file)
receiverModule := modulePrefix(classQN)
var candidates []string
for _, c := range allCandidates {
if modulePrefix(c) != receiverModule {
candidates = append(candidates, c)
}
}
if len(candidates) == 1 {
return ResolutionResult{QualifiedName: candidates[0], Strategy: "type_dispatch", Confidence: 0.80, CandidateCount: 1}
}
if len(candidates) > 1 {
// Prefer candidates closest to caller's module tree
best := bestByImportDistance(candidates, moduleQN)
if best != "" {
return ResolutionResult{QualifiedName: best, Strategy: "type_dispatch", Confidence: 0.70, CandidateCount: len(candidates)}
}
}
}
}
}

Expand Down
76 changes: 69 additions & 7 deletions internal/pipeline/pipeline_cbm.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,29 @@ func cbmParseFileFromSource(projectName string, f discover.FileInfo, source []by

// Convert CBM definitions to store.Node objects
for i := range cbmResult.Definitions {
node, edge := cbmDefToNode(&cbmResult.Definitions[i], projectName, moduleQN)
// Fix: mark test functions as entry points.
// The C extractor only sets is_test on the module, not on individual functions.
// Test functions (Go Test*/Benchmark*/Example*, Python test_*, etc.) are invoked
// by the test runner, not by the call graph β€” they must be entry points.
def := &cbmResult.Definitions[i]
if cbmResult.IsTestFile && !def.IsEntryPoint &&
(def.Label == "Function" || def.Label == "Method") &&
isTestFunction(def.Name, f.Language) {
def.IsEntryPoint = true
def.IsTest = true
}

// Mark exported Go handler methods as entry points.
// Echo handlers are registered via method value references (g.POST("", h.Method))
// which the C extractor doesn't track as explicit calls.
if !def.IsEntryPoint && def.Label == "Method" && def.IsExported &&
f.Language == lang.Go &&
strings.Contains(f.RelPath, "handler") &&
strings.Contains(def.Signature, "echo.Context") {
def.IsEntryPoint = true
}

node, edge := cbmDefToNode(def, projectName, moduleQN)
result.Nodes = append(result.Nodes, node)
result.PendingEdges = append(result.PendingEdges, edge)
}
Expand Down Expand Up @@ -200,6 +222,7 @@ func enrichModuleNodeCBM(moduleNode *store.Node, cbmResult *cbm.FileResult, _ *p
// Replaces the 14 language-specific infer*Types() functions.
func inferTypesCBM(
typeAssigns []cbm.TypeAssign,
defs []cbm.Definition,
registry *FunctionRegistry,
moduleQN string,
importMap map[string]string,
Expand All @@ -216,23 +239,55 @@ func inferTypesCBM(
}
}

// Return type propagation is handled by CBM TypeAssigns which already
// detect constructor patterns. Additional return-type-based inference
// from the returnTypes map is still useful for non-constructor calls.
// This would require the call data which we have in CBM Calls.
// For now, constructor-based inference covers the primary use case.
// Receiver type inference: for Go methods like func (h *Handler) Foo(),
// the receiver "h" has type Handler. Extract this from the Receiver field
// and add to the TypeMap so calls like h.svc.Method() can resolve.
for i := range defs {
if defs[i].Receiver == "" || defs[i].Label != "Method" {
continue
}
varName, typeName := parseGoReceiver(defs[i].Receiver)
if varName == "" || typeName == "" {
continue
}
if _, exists := types[varName]; exists {
continue // don't overwrite explicit type assignments
}
classQN := resolveAsClass(typeName, registry, moduleQN, importMap)
if classQN != "" {
types[varName] = classQN
}
}

return types
}

// parseGoReceiver extracts (varName, typeName) from a Go receiver string.
// Examples: "(h *Handler)" β†’ ("h", "Handler"), "(s MyService)" β†’ ("s", "MyService")
func parseGoReceiver(recv string) (string, string) {
// Strip parens
recv = strings.TrimSpace(recv)
recv = strings.TrimPrefix(recv, "(")
recv = strings.TrimSuffix(recv, ")")
recv = strings.TrimSpace(recv)

parts := strings.Fields(recv)
if len(parts) != 2 {
return "", ""
}
varName := parts[0]
typeName := strings.TrimPrefix(parts[1], "*")
return varName, typeName
}

// resolveFileCallsCBM resolves all call targets using pre-extracted CBM data.
// Replaces resolveFileCalls() β€” no AST walking needed.
func (p *Pipeline) resolveFileCallsCBM(relPath string, ext *cachedExtraction) []resolvedEdge {
moduleQN := fqn.ModuleQN(p.ProjectName, relPath)
importMap := p.importMaps[moduleQN]

// Build type map from CBM type assignments
typeMap := inferTypesCBM(ext.Result.TypeAssigns, p.registry, moduleQN, importMap)
typeMap := inferTypesCBM(ext.Result.TypeAssigns, ext.Result.Definitions, p.registry, moduleQN, importMap)

var edges []resolvedEdge

Expand Down Expand Up @@ -262,6 +317,9 @@ func (p *Pipeline) resolveFileCallsCBM(relPath string, ext *cachedExtraction) []
result := p.resolveCallWithTypes(calleeName, moduleQN, importMap, typeMap)
if result.QualifiedName == "" {
if fuzzyResult, ok := p.registry.FuzzyResolve(calleeName, moduleQN, importMap); ok {
if fuzzyResult.QualifiedName == callerQN {
continue // skip self-reference
}
edges = append(edges, resolvedEdge{
CallerQN: callerQN,
TargetQN: fuzzyResult.QualifiedName,
Expand All @@ -276,6 +334,10 @@ func (p *Pipeline) resolveFileCallsCBM(relPath string, ext *cachedExtraction) []
continue
}

if result.QualifiedName == callerQN {
continue // skip self-reference
}

edges = append(edges, resolvedEdge{
CallerQN: callerQN,
TargetQN: result.QualifiedName,
Expand Down
67 changes: 67 additions & 0 deletions internal/pipeline/pipeline_cbm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package pipeline

import "testing"

func TestParseGoReceiver(t *testing.T) {
tests := []struct {
name string
input string
wantVar string
wantType string
}{
{
name: "pointer receiver",
input: "(h *Handler)",
wantVar: "h",
wantType: "Handler",
},
{
name: "value receiver",
input: "(s MyService)",
wantVar: "s",
wantType: "MyService",
},
{
name: "empty string",
input: "",
wantVar: "",
wantType: "",
},
{
name: "single word no pair",
input: "invalid",
wantVar: "",
wantType: "",
},
{
name: "too many parts",
input: "(a b c)",
wantVar: "",
wantType: "",
},
{
name: "empty parens",
input: "()",
wantVar: "",
wantType: "",
},
{
name: "whitespace only parens",
input: "( )",
wantVar: "",
wantType: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotVar, gotType := parseGoReceiver(tt.input)
if gotVar != tt.wantVar {
t.Errorf("parseGoReceiver(%q) varName = %q, want %q", tt.input, gotVar, tt.wantVar)
}
if gotType != tt.wantType {
t.Errorf("parseGoReceiver(%q) typeName = %q, want %q", tt.input, gotType, tt.wantType)
}
})
}
}
Loading