Skip to content

fix(schema): one body node per call site, even when two share a start position (#215) - #217

Merged
rahlk merged 2 commits into
mainfrom
fix/issue-215-co-positioned-call-sites
Sep 14, 2026
Merged

rahlk merged 2 commits into
mainfrom
fix/issue-215-co-positioned-call-sites

Conversation

@rahlk

@rahlk rahlk commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Closes #215. Spec: docs/design/specs/2026-09-14-co-positioned-call-site-identity.md (committed in this PR).

The bug

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 of them, the inner getattr, 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 iterates body. Measured on a merged Odoo graph: 193 getattr sites, 193 PY_RESOLVES_TO edges 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_keys is 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:

  • 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, deliberately: the site is recorded so a consumer can decide, and no PY_CALLS edge is invented.

Impact

  • flask fixture: 1200 body keys, of which 68 are disambiguated — 68 call nodes this analyzer used to drop on the floor.
  • decorators_and_hof at L1–L4: payload byte-identical to before the change apart from the fixture's last_modified, i.e. no collateral movement where nothing collides.

Gates, run on a8e1a02

Gate Result
Fixture suite 519 passed, 11 skipped (512 before, +7 new tests)
Schema conformance 2.0.0 at L1–L4, each validates against Analysis
Monotonicity 78 → 79 → 165 → 262 ids, 0 lost
Determinism two -a 4 runs byte-identical (126591 B)
Cross-projection 262 JSON ids vs 284 graph ids, 0 missing

Caveats

  • A colliding key changes meaning. 11:18 used to resolve to the inner getattr; it now resolves to the invocation. Keys that do not collide are untouched.
  • python-sdk needs no change, verified: body_key_column does key.split("/", 1)[0], so 11:18/2 yields column 18, and the existing rank tuples break the outer/inner tie toward the outer node.
  • Older graphs do not gain the node. A consumer reading one still sees a single node per position.
  • schema_version and the graph SCHEMA_VERSION stay 2.0.0 per the 2026-09-07 hold; the payload shape does not move, only the key space below the callable.
  • Argument text (ast.unparse for non-Name, non-Constant args) 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.

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
@rahlk
rahlk merged commit fac750c into main Sep 14, 2026
@rahlk
rahlk deleted the fix/issue-215-co-positioned-call-sites branch September 14, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The body map keys call sites by start position, so getattr(self, x)(y) loses its dynamic call

1 participant