Skip to content

Commit 288d86e

Browse files
committed
Fix false call edges and dropped module-graph edges; harden pop/path guards
Two correctness fixes surfaced by a bug-hunt pass over the codebase, plus two defensive guards for currently-unreachable panics. Fixes: - pythonextractor: resolveCall no longer fabricates same-module RelCalls edges for callable parameters, locals, and loop variables. It now mirrors valueRefTarget — skipping parameter names and resolving only known module-level defs (via idx.moduleDefs) when an index is present, with a best-effort fallback for the index-less single-file path. Removes spurious "used" signals, inflated fan-out, and misleading impact analysis. - explainers/common: BuildModuleGraph no longer pre-filters imports through IsExternalImport, which misclassified single-segment internal modules (e.g. top-level "cmd", "config") as external and dropped real edges. The authoritative moduleNames gate now decides inclusion; IsExternalImport is left unchanged so Go stdlib / npm classification is preserved. Hardening (defensive; not currently reachable): - pythonextractor: popOwner/popType are now no-ops on empty stacks instead of panicking on a slice-bounds underflow. - server: bestPath returns a clean not-found PathResult for empty candidate slices instead of indexing fromCands[0]/toCands[0] out of range. All changes covered by unit tests. Also initializes moduleDefs in the astExtractWithIndex test helper so it mirrors the production index.
1 parent 6efb114 commit 288d86e

6 files changed

Lines changed: 224 additions & 9 deletions

File tree

internal/explainers/common/common.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,14 @@ func BuildModuleGraph(store *facts.Store) map[string][]string {
107107
}
108108
target := rel.Target
109109

110-
if IsExternalImport(target) {
111-
continue
112-
}
113-
114110
if strings.HasPrefix(target, ".") {
115111
target = ResolveRelativeImport(sourceModule, target)
116112
}
117113

114+
// A target is included only if it names a known internal module. This gate is
115+
// authoritative; we deliberately do NOT pre-filter with IsExternalImport, which
116+
// misclassifies single-segment internal modules (top-level "cmd", "config") as
117+
// external and would drop real edges before this check.
118118
if moduleNames[target] {
119119
graph[sourceModule] = append(graph[sourceModule], target)
120120
}

internal/explainers/common/common_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,47 @@ func TestBuildModuleGraph_ExcludesTestRole(t *testing.T) {
233233
t.Errorf("edge to a test-role module should be dropped, got %v", graph["src/app"])
234234
}
235235
}
236+
237+
// TestBuildModuleGraph_SingleSegmentInternalModule: a top-level internal module
238+
// with a single-segment name (e.g. "config") must be included as an import edge.
239+
// Before the fix, IsExternalImport("config") returned true and the edge was
240+
// dropped before the authoritative moduleNames gate.
241+
func TestBuildModuleGraph_SingleSegmentInternalModule(t *testing.T) {
242+
s := facts.NewStore()
243+
s.Add(facts.Fact{Kind: facts.KindModule, Name: "config"})
244+
s.Add(facts.Fact{Kind: facts.KindModule, Name: "handlers"})
245+
s.Add(facts.Fact{
246+
Kind: facts.KindDependency,
247+
File: "handlers/h.go",
248+
Relations: []facts.Relation{{Kind: facts.RelImports, Target: "config"}},
249+
})
250+
251+
graph := BuildModuleGraph(s)
252+
253+
if edges := graph["handlers"]; len(edges) != 1 || edges[0] != "config" {
254+
t.Errorf("handlers edges = %v, want [config]", edges)
255+
}
256+
}
257+
258+
// TestBuildModuleGraph_ExternalStillDropped: single-segment names that are NOT
259+
// declared modules (Go stdlib "fmt", npm "react") must still be dropped — the
260+
// moduleNames gate remains authoritative after removing the IsExternalImport
261+
// pre-filter.
262+
func TestBuildModuleGraph_ExternalStillDropped(t *testing.T) {
263+
s := facts.NewStore()
264+
s.Add(facts.Fact{Kind: facts.KindModule, Name: "handlers"})
265+
s.Add(facts.Fact{
266+
Kind: facts.KindDependency,
267+
File: "handlers/h.go",
268+
Relations: []facts.Relation{
269+
{Kind: facts.RelImports, Target: "fmt"},
270+
{Kind: facts.RelImports, Target: "react"},
271+
},
272+
})
273+
274+
graph := BuildModuleGraph(s)
275+
276+
if edges := graph["handlers"]; len(edges) != 0 {
277+
t.Errorf("handlers edges = %v, want none (fmt/react are not modules)", edges)
278+
}
279+
}

internal/extractors/pythonextractor/python_ast.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,11 @@ func (w *pyWalker) recordCallMetrics(target string) {
141141
}
142142

143143
func (w *pyWalker) pushOwner(idx int) { w.ownerStack = append(w.ownerStack, idx) }
144-
func (w *pyWalker) popOwner() { w.ownerStack = w.ownerStack[:len(w.ownerStack)-1] }
144+
func (w *pyWalker) popOwner() {
145+
if len(w.ownerStack) > 0 {
146+
w.ownerStack = w.ownerStack[:len(w.ownerStack)-1]
147+
}
148+
}
145149
func (w *pyWalker) currentOwner() *facts.Fact {
146150
if len(w.ownerStack) == 0 {
147151
return nil
@@ -164,8 +168,12 @@ func (w *pyWalker) pushType(name string, methods map[string]bool) {
164168
}
165169

166170
func (w *pyWalker) popType() {
167-
w.typeStack = w.typeStack[:len(w.typeStack)-1]
168-
w.methodSets = w.methodSets[:len(w.methodSets)-1]
171+
if len(w.typeStack) > 0 {
172+
w.typeStack = w.typeStack[:len(w.typeStack)-1]
173+
}
174+
if len(w.methodSets) > 0 {
175+
w.methodSets = w.methodSets[:len(w.methodSets)-1]
176+
}
169177
}
170178

171179
func (w *pyWalker) currentMethods() map[string]bool {
@@ -1493,7 +1501,20 @@ func (w *pyWalker) resolveCall(name string) string {
14931501
if target, ok := w.importMap[name]; ok {
14941502
return target // "" means external → no edge
14951503
}
1496-
// Same-module top-level function.
1504+
// Same-module top-level function. A bare callee that shadows a parameter is the
1505+
// parameter, not the module-level def (e.g. def wrapper(cb): cb()). When an index
1506+
// is available, resolve only names that are actually module-level defs, so callable
1507+
// locals/params/loop vars don't fabricate edges. Without an index (single-file
1508+
// extraction) fall back to best-effort; production always supplies one.
1509+
if w.paramNames[name] {
1510+
return ""
1511+
}
1512+
if w.idx != nil {
1513+
if w.idx.moduleDefs[w.module][name] {
1514+
return w.module + "." + name
1515+
}
1516+
return ""
1517+
}
14971518
return w.module + "." + name
14981519
}
14991520

internal/extractors/pythonextractor/python_ast_test.go

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func astExtract(t *testing.T, filename, src string, isDjango bool) []facts.Fact
2727
// extracts facts from targetFile using that index.
2828
func astExtractWithIndex(t *testing.T, files map[string]string, targetFile string, isDjango bool) []facts.Fact {
2929
t.Helper()
30-
idx := &pySymbolIndex{classes: make(map[string]*pyClassInfo)}
30+
idx := &pySymbolIndex{classes: make(map[string]*pyClassInfo), moduleDefs: make(map[string]map[string]bool)}
3131
for filename, src := range files {
3232
buildFileIndex([]byte(src), filename, idx)
3333
}
@@ -1173,3 +1173,117 @@ def build(a, b):
11731173
}
11741174
}
11751175
}
1176+
1177+
// --- resolveCall must not fabricate edges for params/locals/loop vars (bug 05) ---
1178+
1179+
// hasCallTo reports whether fact f has a RelCalls edge to target.
1180+
func hasCallTo(f facts.Fact, target string) bool {
1181+
for _, c := range relsByKind(f, facts.RelCalls) {
1182+
if c == target {
1183+
return true
1184+
}
1185+
}
1186+
return false
1187+
}
1188+
1189+
// TestAST_ParamCall_NoEdge: a callable parameter invoked by name must not resolve
1190+
// to a same-module symbol.
1191+
func TestAST_ParamCall_NoEdge(t *testing.T) {
1192+
files := map[string]string{
1193+
"svc.py": `
1194+
def wrapper(callback):
1195+
callback()
1196+
`,
1197+
}
1198+
result := astExtractWithIndex(t, files, "svc.py", false)
1199+
idx := byName(result)
1200+
1201+
fn, ok := idx["svc.wrapper"]
1202+
if !ok {
1203+
t.Fatalf("missing svc.wrapper; keys: %v", keys(idx))
1204+
}
1205+
if hasCallTo(fn, "svc.callback") {
1206+
t.Errorf("param 'callback' wrongly resolved to svc.callback; calls=%v", relsByKind(fn, facts.RelCalls))
1207+
}
1208+
}
1209+
1210+
// TestAST_LocalCallable_NoEdge: a locally-assigned callable invoked by name must
1211+
// not resolve to a same-module symbol (it is not a module-level def).
1212+
func TestAST_LocalCallable_NoEdge(t *testing.T) {
1213+
files := map[string]string{
1214+
"svc.py": `
1215+
def f():
1216+
fn = lambda: None
1217+
fn()
1218+
`,
1219+
}
1220+
result := astExtractWithIndex(t, files, "svc.py", false)
1221+
idx := byName(result)
1222+
1223+
fn, ok := idx["svc.f"]
1224+
if !ok {
1225+
t.Fatalf("missing svc.f; keys: %v", keys(idx))
1226+
}
1227+
if hasCallTo(fn, "svc.fn") {
1228+
t.Errorf("local 'fn' wrongly resolved to svc.fn; calls=%v", relsByKind(fn, facts.RelCalls))
1229+
}
1230+
}
1231+
1232+
// TestAST_LoopVarCall_NoEdge: a loop variable invoked by name must not resolve to
1233+
// a same-module symbol.
1234+
func TestAST_LoopVarCall_NoEdge(t *testing.T) {
1235+
files := map[string]string{
1236+
"svc.py": `
1237+
def f(handlers):
1238+
for handler in handlers:
1239+
handler()
1240+
`,
1241+
}
1242+
result := astExtractWithIndex(t, files, "svc.py", false)
1243+
idx := byName(result)
1244+
1245+
fn, ok := idx["svc.f"]
1246+
if !ok {
1247+
t.Fatalf("missing svc.f; keys: %v", keys(idx))
1248+
}
1249+
if hasCallTo(fn, "svc.handler") {
1250+
t.Errorf("loop var 'handler' wrongly resolved to svc.handler; calls=%v", relsByKind(fn, facts.RelCalls))
1251+
}
1252+
}
1253+
1254+
// TestAST_SameModuleCall_StillResolves: a genuine same-module top-level def call
1255+
// must still emit a RelCalls edge (regression guard for the bug 05 fix).
1256+
func TestAST_SameModuleCall_StillResolves(t *testing.T) {
1257+
files := map[string]string{
1258+
"svc.py": `
1259+
def helper():
1260+
pass
1261+
1262+
def main():
1263+
helper()
1264+
`,
1265+
}
1266+
result := astExtractWithIndex(t, files, "svc.py", false)
1267+
idx := byName(result)
1268+
1269+
fn, ok := idx["svc.main"]
1270+
if !ok {
1271+
t.Fatalf("missing svc.main; keys: %v", keys(idx))
1272+
}
1273+
if !hasCallTo(fn, "svc.helper") {
1274+
t.Errorf("main: expected RelCalls to svc.helper; got %v", relsByKind(fn, facts.RelCalls))
1275+
}
1276+
}
1277+
1278+
// TestAST_PopOnEmptyStack_NoPanic: popOwner/popType must be no-ops on empty
1279+
// stacks rather than panicking with a slice-bounds underflow (defensive hardening).
1280+
func TestAST_PopOnEmptyStack_NoPanic(t *testing.T) {
1281+
w := &pyWalker{}
1282+
// Must not panic on empty stacks.
1283+
w.popOwner()
1284+
w.popType()
1285+
if len(w.ownerStack) != 0 || len(w.typeStack) != 0 || len(w.methodSets) != 0 {
1286+
t.Fatalf("expected all stacks to remain empty, got owner=%d type=%d methodSets=%d",
1287+
len(w.ownerStack), len(w.typeStack), len(w.methodSets))
1288+
}
1289+
}

internal/server/server.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,12 +1963,26 @@ func (s *Server) pathCandidates(store *facts.Store, input, resolved string, res
19631963
return out
19641964
}
19651965

1966+
// firstOr returns the first element of s, or fallback when s is empty.
1967+
func firstOr(s []string, fallback string) string {
1968+
if len(s) == 0 {
1969+
return fallback
1970+
}
1971+
return s[0]
1972+
}
1973+
19661974
// bestPath tries to connect any from-candidate to any to-candidate, expanding each
19671975
// to-candidate with RollupSeeds (a path to a type usually ends at one of its
19681976
// methods/constructor). It scans in ranked order, keeps the shortest path found,
19691977
// and is bounded by maxPathAttempts (returning early on a ≤2-hop hit). When no
19701978
// path exists it returns a not-found PathResult naming the first from/to tried.
19711979
func (s *Server) bestPath(graph *facts.Graph, fromCands, toCands []string, relKinds []string, maxDepth int) facts.PathResult {
1980+
// Self-defensive: callers are expected to pass non-empty candidate slices, but
1981+
// guard so an empty slice yields a clean not-found result instead of indexing
1982+
// fromCands[0]/toCands[0] out of range.
1983+
if len(fromCands) == 0 || len(toCands) == 0 {
1984+
return facts.PathResult{From: firstOr(fromCands, ""), To: firstOr(toCands, ""), Found: false}
1985+
}
19721986
var best facts.PathResult
19731987
attempts := 0
19741988
for _, from := range fromCands {

internal/server/server_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,28 @@ func TestBestPath_NoPath(t *testing.T) {
19131913
}
19141914
}
19151915

1916+
// TestBestPath_EmptyCandidates_NoPanic: bestPath must return a clean not-found
1917+
// result rather than panicking on fromCands[0]/toCands[0] when either candidate
1918+
// slice is empty (defensive hardening; callers are expected to pre-filter).
1919+
func TestBestPath_EmptyCandidates_NoPanic(t *testing.T) {
1920+
store := facts.NewStore()
1921+
store.Add(facts.Fact{Kind: facts.KindSymbol, Name: "a.Foo", Props: map[string]any{"symbol_kind": "struct"}})
1922+
store.BuildGraph()
1923+
srv := newTestServer(store)
1924+
1925+
cases := []struct{ from, to []string }{
1926+
{nil, []string{"a.Foo"}},
1927+
{[]string{"a.Foo"}, nil},
1928+
{nil, nil},
1929+
}
1930+
for _, tc := range cases {
1931+
res := srv.bestPath(store.Graph(), tc.from, tc.to, nil, 0)
1932+
if res.Found {
1933+
t.Errorf("bestPath(from=%v, to=%v): Found = true, want false", tc.from, tc.to)
1934+
}
1935+
}
1936+
}
1937+
19161938
func TestBuildCoverageReport_Classifications(t *testing.T) {
19171939
store := facts.NewStore()
19181940
store.Add(

0 commit comments

Comments
 (0)