Skip to content

Commit 3cc921d

Browse files
authored
fix(python): cover assignment/return value-refs, broaden shadow guard to full scope (#79)
* feat(python): add indirect reference handling and shadowing guards in AST extraction * feat(explain): add relation counts to report and render output * fix(explain): remove references from relation counts and update related tests * fix(server): remove stale 'references' relation_kinds mention The RelReferences relation kind was dropped during the rebase merge with upstream's RelCalls-based value-ref fix; these two MCP tool-description strings still advertised it as a valid relation_kinds filter value.
1 parent f2384df commit 3cc921d

7 files changed

Lines changed: 409 additions & 42 deletions

File tree

internal/cachecov/coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ var versionCoverage = map[int][]string{
127127
93: {"TestSwitchReturns_MultiLineCaseLabels", "TestExtractEndpointFacts_MultiLineMethodCase"}, // Swift multi-line case-label method parsing
128128
94: {"TestExtractEndpointFacts_ConstantMethod"}, // Swift single-value (constant) method property
129129
95: {"TestResolveImports_CouplingKindTagged", "TestResolveImports_ReferenceBeatsAssociation"}, // Ruby synthetic-edge coupling_kind prop + framework-const ignore list
130+
96: {"TestAST_AssignedAndReturnedCallback", "TestAST_AssignedCallback_ShadowGuarded", "TestAST_ReturnedPlainVariable_NoPhantomRef", "TestAST_ReturnedForwardReference_Resolves", "TestAST_ShadowedLoopVarNotResolvedAsCall"}, // Python assignment/return value-refs + scope-wide (not just param) shadow guard
130131
}
131132

132133
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,14 @@ import (
253253
// explainer can exclude ActiveRecord associations) and adds common Rails framework constants
254254
// (I18n, Rails, Logger, ...) to the builtin-const ignore list. Cached Ruby snapshots must
255255
// re-extract to pick up the new edge props and suppressed references.
256-
const cacheVersion = "v95"
256+
// v96: Python value-ref resolution now records assignment-RHS and return-statement bare-name
257+
// references (cb = handler; return cb / return handler) as RelCalls edges, not just call
258+
// args/decorator args/collection literals; and its shadow guard (previously param-only, shared
259+
// with resolveCall's v87 fix) now covers any name bound in the enclosing function's own scope —
260+
// assigned/iterated/aliased locals, not just parameters — so a loop var or local reusing a
261+
// same-named top-level def's name no longer fabricates a same-module edge. Cached Python
262+
// snapshots must re-extract.
263+
const cacheVersion = "v96"
257264

258265
// extractorCache holds per-extractor facts keyed by a content hash of the files
259266
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/extractors/pythonextractor/python_ast.go

Lines changed: 125 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ type pyWalker struct {
7272
// canonical qualified type. Reset at the entry of every handleFunction call.
7373
localTypes map[string]string
7474

75-
// paramNames is the set of parameter names of the function whose body is being
76-
// walked. Used to suppress same-module value-ref resolution for a param that
77-
// shadows a same-named top-level def (e.g. get_user(user_id)). Nil outside a body.
78-
paramNames map[string]bool
75+
// localBound is the set of names bound in the current function's own scope
76+
// (params + assigned/iterated/aliased names). Guards bare-identifier call
77+
// and value-reference resolution against shadowing. Reset per handleFunction call.
78+
localBound map[string]bool
7979

8080
// Per-function complexity state, set up by handleFunction around walkForCalls.
8181
// metrics is nil outside a function body walk. loopDepth is the current loop
@@ -804,11 +804,11 @@ func (w *pyWalker) handleFunction(node *sitter.Node, decorators []string) {
804804
// workaround) resolves to an edge. Must run before collectParamTypes/
805805
// collectLocalTypes so those see the local imports too.
806806
w.registerBodyImports(bodyNode)
807-
w.paramNames = collectParamNames(node.ChildByFieldName("parameters"), w.src)
808807
w.localTypes = collectParamTypes(node.ChildByFieldName("parameters"), w.src, w.importMap, w.module)
809808
for k, v := range collectLocalTypes(bodyNode, w.src, w.importMap, w.module) {
810809
w.localTypes[k] = v
811810
}
811+
w.localBound = collectLocalBoundNames(node.ChildByFieldName("parameters"), bodyNode, w.src)
812812
// Set up per-function complexity tracking for this body walk. The props
813813
// map is shared by reference with the fact in w.out, so writing to it
814814
// after the walk updates the emitted fact.
@@ -843,7 +843,7 @@ func (w *pyWalker) handleFunction(node *sitter.Node, decorators []string) {
843843
w.metrics = nil
844844
w.selfName = ""
845845
w.localTypes = nil
846-
w.paramNames = nil
846+
w.localBound = nil
847847
}
848848
// Walk parameter-default expressions (e.g. `body = Depends(parse_login_body)`)
849849
// for call and value-reference edges. Metrics are already finalized/nil here, so
@@ -982,40 +982,16 @@ func (w *pyWalker) valueRefTarget(name string) string {
982982
return t
983983
}
984984
// Same-module top-level def (function or class) referenced by name. Skip when the
985-
// name is a parameter of the enclosing function (it shadows the def — e.g.
985+
// name is bound in the enclosing function's own scope — a param or a
986+
// local assigned/iterated/aliased name (it shadows the def — e.g.
986987
// get_user(user_id) passing the param, not the same-named function). Rescues
987988
// `f(local_helper)` where local_helper is a same-module def.
988-
if w.idx != nil && !w.paramNames[name] && w.idx.moduleDefs[w.module][name] {
989+
if w.idx != nil && !w.localBound[name] && w.idx.moduleDefs[w.module][name] {
989990
return w.module + "." + name
990991
}
991992
return ""
992993
}
993994

994-
// collectParamNames returns the set of parameter names declared by a function's
995-
// parameters node (all forms: bare, typed, defaulted, *args, **kwargs).
996-
func collectParamNames(params *sitter.Node, src []byte) map[string]bool {
997-
out := make(map[string]bool)
998-
if params == nil {
999-
return out
1000-
}
1001-
for i := uint(0); i < uint(params.ChildCount()); i++ {
1002-
c := params.Child(i)
1003-
switch c.Kind() {
1004-
case "identifier":
1005-
out[pyText(c, src)] = true
1006-
case "typed_parameter", "list_splat_pattern", "dictionary_splat_pattern":
1007-
if id := firstChildOfKind(c, "identifier"); id != nil {
1008-
out[pyText(id, src)] = true
1009-
}
1010-
case "default_parameter", "typed_default_parameter":
1011-
if n := c.ChildByFieldName("name"); n != nil {
1012-
out[pyText(n, src)] = true
1013-
}
1014-
}
1015-
}
1016-
return out
1017-
}
1018-
1019995
// stringRefRelation returns a reference edge for a string literal that names an
1020996
// internal symbol by dotted path (e.g. lazy_load_command("airflow.cli.commands.x.y")
1021997
// or a provider "class-name": "airflow.providers….short_circuit_task"). Only plain
@@ -1158,6 +1134,18 @@ func (w *pyWalker) walkForCalls(node *sitter.Node) {
11581134
if kind == "dictionary" || kind == "list" || kind == "set" || kind == "tuple" {
11591135
w.emitCollectionValueRefs(node)
11601136
}
1137+
if kind == "assignment" {
1138+
for _, ident := range collectRefValueIdents(node.ChildByFieldName("right")) {
1139+
w.emitValueRef(ident)
1140+
}
1141+
}
1142+
if kind == "return_statement" {
1143+
for i := uint(0); i < uint(node.ChildCount()); i++ {
1144+
for _, ident := range collectRefValueIdents(node.Child(i)) {
1145+
w.emitValueRef(ident)
1146+
}
1147+
}
1148+
}
11611149

11621150
// Complexity metrics: count decision points so the single body walk doubles
11631151
// as the cyclomatic/loop pass (mirrors the Go extractor).
@@ -1399,6 +1387,9 @@ func (w *pyWalker) emitCallEdge(fn *sitter.Node) {
13991387
if pyBuiltins[name] {
14001388
return
14011389
}
1390+
if w.localBound[name] {
1391+
return // shadowed by a param/local — not the module-level def of this name
1392+
}
14021393
if pyCapitalized(name) {
14031394
owner.Relations = append(owner.Relations, facts.Relation{
14041395
Kind: facts.RelInstantiates,
@@ -1501,12 +1492,12 @@ func (w *pyWalker) resolveCall(name string) string {
15011492
if target, ok := w.importMap[name]; ok {
15021493
return target // "" means external → no edge
15031494
}
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
1495+
// Same-module top-level function. A bare callee that shadows a param/local/loop-var
1496+
// is that local binding, not the module-level def (e.g. def wrapper(cb): cb()). When
1497+
// an index is available, resolve only names that are actually module-level defs, so
1498+
// callable locals/params/loop vars don't fabricate edges. Without an index (single-file
15081499
// extraction) fall back to best-effort; production always supplies one.
1509-
if w.paramNames[name] {
1500+
if w.localBound[name] {
15101501
return ""
15111502
}
15121503
if w.idx != nil {
@@ -1518,6 +1509,102 @@ func (w *pyWalker) resolveCall(name string) string {
15181509
return w.module + "." + name
15191510
}
15201511

1512+
// pyBindTargets recursively binds an assignment/parameter target node into out:
1513+
// identifier -> itself; unpacking forms -> recurse into elements. attribute
1514+
// (self.x) and subscript (d[k]) targets are not name bindings and are skipped.
1515+
func pyBindTargets(node *sitter.Node, src []byte, out map[string]bool) {
1516+
if node == nil {
1517+
return
1518+
}
1519+
switch node.Kind() {
1520+
case "identifier":
1521+
out[pyText(node, src)] = true
1522+
case "pattern_list", "tuple_pattern", "list_pattern", "list_splat_pattern", "dictionary_splat_pattern":
1523+
for i := uint(0); i < uint(node.ChildCount()); i++ {
1524+
pyBindTargets(node.Child(i), src, out)
1525+
}
1526+
}
1527+
}
1528+
1529+
// pyParamBoundNames adds every name a `parameters` node binds to out, covering
1530+
// all parameter shapes (x, x=1, x: T, x: T = 1, *args, **kwargs).
1531+
func pyParamBoundNames(params *sitter.Node, src []byte, out map[string]bool) {
1532+
if params == nil {
1533+
return
1534+
}
1535+
for i := uint(0); i < uint(params.ChildCount()); i++ {
1536+
c := params.Child(i)
1537+
switch c.Kind() {
1538+
case "identifier", "list_splat_pattern", "dictionary_splat_pattern":
1539+
pyBindTargets(c, src, out)
1540+
case "default_parameter", "typed_default_parameter":
1541+
pyBindTargets(c.ChildByFieldName("name"), src, out)
1542+
case "typed_parameter":
1543+
for j := uint(0); j < uint(c.ChildCount()); j++ {
1544+
pyBindTargets(c.Child(j), src, out)
1545+
}
1546+
}
1547+
}
1548+
}
1549+
1550+
// collectLocalBoundNames returns every name bound in a function's own scope:
1551+
// its parameters plus every name assigned, iterated, or aliased in its body.
1552+
// Used to guard bare-identifier call resolution — a name bound here refers to
1553+
// a local value, not a same-module def of the same name.
1554+
func collectLocalBoundNames(params, body *sitter.Node, src []byte) map[string]bool {
1555+
bound := make(map[string]bool)
1556+
pyParamBoundNames(params, src, bound)
1557+
walkLocalBoundNames(body, src, bound)
1558+
return bound
1559+
}
1560+
1561+
// walkLocalBoundNames walks a function body collecting bound names, stopping
1562+
// at nested function/class/lambda scopes (their bindings belong to them).
1563+
func walkLocalBoundNames(node *sitter.Node, src []byte, bound map[string]bool) {
1564+
if node == nil {
1565+
return
1566+
}
1567+
switch node.Kind() {
1568+
case "function_definition", "class_definition", "decorated_definition", "lambda":
1569+
return
1570+
case "assignment", "augmented_assignment":
1571+
pyBindTargets(node.ChildByFieldName("left"), src, bound)
1572+
case "for_statement":
1573+
pyBindTargets(node.ChildByFieldName("left"), src, bound)
1574+
case "named_expression":
1575+
pyBindTargets(node.ChildByFieldName("name"), src, bound)
1576+
case "with_item":
1577+
if v := node.ChildByFieldName("value"); v != nil && v.Kind() == "as_pattern" {
1578+
pyBindTargets(v.ChildByFieldName("alias"), src, bound)
1579+
}
1580+
}
1581+
for i := uint(0); i < uint(node.ChildCount()); i++ {
1582+
walkLocalBoundNames(node.Child(i), src, bound)
1583+
}
1584+
}
1585+
1586+
// collectRefValueIdents returns the bare identifier(s) a value expression
1587+
// resolves to: itself if it's a plain identifier, or each identifier element
1588+
// of a tuple (expression_list), e.g. `cb = handler` / `a, b = f, g`.
1589+
func collectRefValueIdents(node *sitter.Node) []*sitter.Node {
1590+
if node == nil {
1591+
return nil
1592+
}
1593+
switch node.Kind() {
1594+
case "identifier":
1595+
return []*sitter.Node{node}
1596+
case "expression_list":
1597+
var out []*sitter.Node
1598+
for i := uint(0); i < uint(node.ChildCount()); i++ {
1599+
if c := node.Child(i); c.Kind() == "identifier" {
1600+
out = append(out, c)
1601+
}
1602+
}
1603+
return out
1604+
}
1605+
return nil
1606+
}
1607+
15211608
// collectPyMethodNames returns the set of function names declared directly in a
15221609
// class body node.
15231610
func collectPyMethodNames(body *sitter.Node, src []byte) map[string]bool {

0 commit comments

Comments
 (0)