fix(schema): one body node per call site, even when two share a start position (#215) - #217
Merged
Merged
Conversation
Two nested ast.Call nodes that share a start position collide on the body map key, so the dynamic invocation in getattr(self, x)(y) is lost from the payload, the L3/L4 graphs and the Neo4j projection. The local id grammar gains a disambiguator, adopted verbatim from codeanalyzer-typescript's callBodyKeys: line:col, then /2, /3 for each further call site at the same position, outermost first. Records why java is immune, why re-anchoring python onto java's name-token rule was rejected, and why python-sdk needs no change.
… position (#215) `body` was keyed on the call site's start position, and two nested `ast.Call` nodes can start at the same one. `getattr(o, n)(x)` is that shape: the outer application and the inner `getattr` both begin at the `g`, so the dict kept one -- the inner `getattr`, written last -- and the dynamic invocation was gone from the payload, from the L3/L4 graphs that key off the same format, and from the Neo4j projection that iterates `body`. Measured on a merged Odoo graph: 193 `getattr` sites, 193 edges to the builtin, and no node for any of the invocations that followed. The key sequence gains a disambiguator -- `line:col`, then `/2`, `/3`, ... per further call site at that position, outermost first, since call sites are recorded pre-order. `schema/ids.py::call_body_keys` is the single definition; L1, L2, the dataflow builder, the defuse linker and the graph projection now re-derive the pairing from it instead of rebuilding a key from a position. The spelling is codeanalyzer-typescript's `callBodyKeys`, adopted verbatim. Two smaller defects at the same site: - The Neo4j projection joined `callee_signature` on `(line, column)`, so co-positioned sites shared whichever signature the dict kept. It joins on the body key now. - `_callee_anchor` fell back to the call expression's start for any non-attribute callee, so a call whose callee is itself a call was anchored on the inner call's name and labelled a call to it. It returns `None` there, and the site carries `callee_signature=None` with `method_name="<unknown>"`. What the dynamic call reaches is still not inferred, by design: the site is recorded so a consumer can decide, and no `PY_CALLS` edge is invented. Spec: docs/design/specs/2026-09-14-co-positioned-call-site-identity.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #215. Spec:
docs/design/specs/2026-09-14-co-positioned-call-site-identity.md(committed in this PR).The bug
bodywas keyed on the call site's start position, and two nestedast.Callnodes can start at the same one.getattr(o, n)(x)is that shape — the outer application and the innergetattrboth begin at theg— so the dict kept one of them, the innergetattr, and the dynamic invocation disappeared from the payload, from the L3/L4 graphs that key off the same format, and from the Neo4j projection that iteratesbody. Measured on a merged Odoo graph: 193getattrsites, 193PY_RESOLVES_TOedges to the builtin, and no node for any invocation that followed.The change
The key sequence gains a disambiguator:
line:col, then/2,/3, … per further call site at that position, outermost first (call sites are recorded pre-order).schema/ids.py::call_body_keysis the single definition — L1, L2, the dataflow builder, the defuse linker and the graph projection all re-derive the pairing from it instead of rebuilding a key from a position.The spelling is codeanalyzer-typescript's
callBodyKeys(src/schema/l1Body.ts), adopted verbatim —/and a 2-based counter, not#, not an end-position key. Under the parity clause a term coined twice is permanently wrong. codeanalyzer-java is structurally immune: it anchors a call key at the invoked name token, so its nested calls never share an anchor.Two smaller defects at the same site:
callee_signatureon(line, column), so co-positioned sites shared whichever signature the dict kept. It joins on the body key now._callee_anchorfell back to the call expression's start for any non-attribute callee, so a call whose callee is itself a call was anchored on the inner call's name and labelled a call to it. It returnsNonethere, and the site carriescallee_signature=Nonewithmethod_name="<unknown>".What the dynamic call reaches is still not inferred, deliberately: the site is recorded so a consumer can decide, and no
PY_CALLSedge is invented.Impact
decorators_and_hofat L1–L4: payload byte-identical to before the change apart from the fixture'slast_modified, i.e. no collateral movement where nothing collides.Gates, run on a8e1a02
519 passed, 11 skipped(512 before, +7 new tests)2.0.0at L1–L4, each validates againstAnalysis-a 4runs byte-identical (126591 B)Caveats
11:18used to resolve to the innergetattr; it now resolves to the invocation. Keys that do not collide are untouched.body_key_columndoeskey.split("/", 1)[0], so11:18/2yields column 18, and the existing rank tuples break the outer/inner tie toward the outer node.schema_versionand the graphSCHEMA_VERSIONstay2.0.0per the 2026-09-07 hold; the payload shape does not move, only the key space below the callable.ast.unparsefor non-Name, non-Constantargs) stays deferred to its own issue, as The body map keys call sites by start position, so getattr(self, x)(y) loses its dynamic call #215 scoped it.