fix(extract): isolate member-call resolvers and import edges from cross-language interference - #2503
Conversation
The Swift, Python and TypeScript member-call resolvers consumed every raw
call in the corpus. Only the cpp, csharp, java and objc extractors stamp a
`lang` tag, and those three languages carry none, so they mined each other's
data: a TypeScript `Lead.search({})` reached the Python resolver's
capitalized-receiver class arm and minted an EXTRACTED edge into a Python
method with no TypeScript `Lead` anywhere in the corpus. Each resolver now
consumes only raw calls written in the source files it owns (`_raw_call_is_owned`,
the form `ruby_resolution._ruby_raw_calls` already uses) — a positive suffix
filter that is closed by construction rather than a list of languages to
exclude. The tagged languages keep matching on `lang`, because C++ and ObjC
share `.h` and a suffix cannot tell their raw calls apart.
The Java and C# receiver-type indexes are language-scoped for the same reason,
which cuts both ways: a Java `Lead lead; lead.search()` bound to a Python
`class Lead` at INFERRED, and a foreign class merely SHARING a short name
pushed the single-definition guard to 2 and silently suppressed the correct
same-language edge. Polyglot corpora therefore lose always-wrong cross-language
edges and gain Java/C# edges a name collision previously deleted; single-language
corpora are unaffected. The same index exposure in the C++, ObjC, Swift,
TypeScript and Python resolvers is untouched.
Per-resolver suffix tuples are shared between the filters and the
`LanguageResolver` registrations so the two cannot drift.
Adapted from #28.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sources The C++, Objective-C, Swift and TypeScript member-call resolvers each built `type_def_nids` from every type-like node in the corpus, so the receiver's declared type name was matched against class definitions written in ANY language — the last copies of the shape already fixed for Java and C#. It cut both ways in every one of them. A Python `class Lead` could answer the receiver type behind `Lead lead; lead.search()` (C++, INFERRED), `[Lead search]` (ObjC, EXTRACTED), `let lead: Lead` (Swift, INFERRED) and `private lead: Lead` (TypeScript, EXTRACTED); and in the other direction a foreign class merely SHARING the short name pushed the single-definition guard to 2 and silently deleted the correct same-language edge. Raw-call ownership cannot close this: the raw call being resolved is genuinely the resolver's own, and the leak is in what its index offers up. `.h` is scoped into both the C++ and the ObjC index, because it routes to either extractor by content and a C++ class and an ObjC @interface both live in one. The two are therefore isolated from every other language but not from each other, which no suffix can fix. The Python resolver's class index is a different shape (built from `method` edges, not source-scoped nodes) and is left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last unscoped member-call index, and the one shaped differently from all the others: the Python resolver builds its class index by walking `method` edges rather than by filtering source-backed nodes, and resolves `module.func()` through the caller's own `imports` edges. Both were corpus-wide. The class arm leaked both ways. `Lead.search()` bound to a Java `class Lead` at EXTRACTED with no Python `Lead` in the corpus, and a foreign class merely SHARING the name pushed the single-definition guard to 2 and deleted the correct Python edge. Only classes declared in Python sources are candidates now; `method_index` needs no scoping, since it is only ever keyed by a class id the scoped index already admitted. The module arm leaked one way: it matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound `lead.search()` to a TypeScript function at EXTRACTED. A candidate module must now be a Python file. Its suppression direction is unreachable and left alone — two same-stem files disambiguate the file node ids while the `imports` edge target stays the bare alias, so the arm sees zero candidates rather than an ambiguous two. That is a separate defect in the import-alias remap. Index scoping across all seven resolvers now goes through one `_is_owned_definition` helper, the definition-index twin of `_raw_call_is_owned`, replacing the inline suffix checks added alongside each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gn sibling A Python import edge targets the imported module's bare file-node id (`import lead` -> `lead`), which holds only while that id is unique. Drop a `lead.ts` beside `lead.py` and the two file nodes collide, so `_disambiguate_colliding_node_ids` salts them into `lead_py_lead` and `lead_ts_lead`. The edge's target salt is keyed by the IMPORTER's source_file, which matches neither, so the edge was left pointing at an id that no longer named anything — silently dropped, together with everything downstream of it. The disambiguator already accepts a `target_file` hint for exactly this shape (Graphify-Labs#1814), keying the target salt by that file instead. Python import edges now stamp it. A Python import can only ever mean a Python file, so the hint is unambiguous even when the colliding sibling belongs to another language; an id claimed by more than one Python file is left dangling, as before. The hint is transient and popped by its only reader, so it never reaches graph.json. `.pyi` is excluded: it has no extractor, so it mints no file node to point at. This is what made the Python resolver's `module.func()` arm look like it had an ambiguity guard firing on a foreign same-stem file. It did not — it was seeing zero candidates, not two. Both layers are now asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ibling Generalizes the Python fix to every language with the same exposure, found by auditing each import emitter against a control/collision corpus pair. Rust, Zig, Elixir, PowerShell and Pascal all name an import's target by the imported file's bare stem id, which resolves only while that id is unique. Any same-stem file — a `lead.md` is enough — collides the two file nodes, so id-disambiguation salts them apart and the edge, keyed by the IMPORTER's file rather than the target's, matches neither salt and dangles. They now stamp the `target_file` hint the disambiguator already reads (Graphify-Labs#1814), through one corpus-level pass rather than per extractor: an extractor sees a single file and cannot know which same-stem candidate the id will end up naming. The hint only selects among salted variants of an id the edge already named, so it cannot change which node an edge resolves to, and a corpus with no collision is bit-identical. Bash was different, and stamping there would have been useless: its second producer of the same edge, in `resolve_bash_source_edges`, runs AFTER disambiguation and derives every id from the path formula, so under a collision it emitted a dangling duplicate beside the extractor's correct edge. It now receives the file node ids as they actually stand; the duplicate collapses back into one deduped edge, and the source-backed `calls` edges the same pass resolves stop pointing at renamed-away ids too. Audited and left alone: TypeScript/JavaScript stamp the hint already, C/C++/ObjC are covered by the header carve-out (Graphify-Labs#1475), and Julia/Fortran/Verilog target an importer-scoped node that cannot collide. Go's package imports dangle with or without a collision — a separate gap, not this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR is scoped to graphify/extract.py (with corresponding CHANGELOG entries and test additions referenced in the changed symbols). The changes center on member-call resolution and import-edge targeting across multiple languages: - Adds import-target hinting for languages whose import edges name their target by a bare file-stem id (Python, Rust, Zig, Elixir, PowerShell, Pascal, Bash), introducing helpers (_hint_import_targets, _file_nids_by_path, and an _IMPORT_STEM_LANGUAGES table) intended to stamp a target_file hint before node-id disambiguation runs. - Reworks the language-specific member-call resolvers (Swift, Python, TypeScript, C#, Java, etc.) to consume only raw calls from source files they own and to scope receiver-type indexes to each resolver's own language, adding per-resolver suffix constants. - Touches import extraction and related edge-keying paths for Swift, C#, and Java, plus adjustments to helper functions like _extract_edge_key, _node_label_key, and Bash source-edge resolution. Reviewers may want to focus on the new stem-language import table, the ordering constraint that hinting runs before disambiguation, and how the per-language suffix scoping affects polyglot vs. single-language corpora.
No blocking issues surfaced. 5 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1593 functions depend on the 469 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 369 callers, 41 callees
Verification — 1593 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1463 function(s) in the blast radius were not formally verified this run
· 1 more finding(s) on lines outside this diff (see the check run).
Cross-language isolation fixes for the member-call resolvers and import-edge resolution, in five commits spanning two related defect families. Both families share one root shape — language-blind indexes and ids letting one language's facts bind another language's nodes. The first commit is a backport of a fix that landed on our fork with full red-first evidence (lawnstarter#28); the rest were found by measuring outward from it on this tree. Happy to split into two PRs (commits 1–3 / 4–5) if you prefer — the later commits extend the first commit's test file, so they're presented together here.
Family 1 — resolvers consuming other languages' facts
0e6dd75— raw-call ownership. The Swift, Python, and TypeScript member-call resolvers consume every untagged raw call, including each other's: a TSLead.search({})with a matching class only in a.pyfile mintsrunner.ts:.go() → svc.py:.search()at EXTRACTED. Fix: a positive source-file suffix filter (_raw_call_is_owned), with per-resolver suffix tuples shared with theLanguageResolverregistrations so filter and registration cannot drift. Also scopes the Java and C#type_def_nidsfallbacks (checked against the current post-e300587C# code).7ddf65c— the remaining receiver-type indexes. C++, ObjC, Swift, and TypeScripttype_def_nidswere still corpus-wide; each leaked (measured per-language, both directions: false cross-language edge, and a foreign namesake suppressing the true edge)..hremains in both the C++ and ObjC sets by necessity — they're now isolated from every other language but not from each other.0f417fc— the Python indexes.class_def_nidsand the module arm, same defect, different index shape (built from method edges rather than source-backed nodes).Family 2 — import edges dying to a same-stem foreign sibling
1764280— Python. Alead.tsnext tolead.pymakes id disambiguation salt both file nodes while the import edge still targets the bare alias — the edge dangles, and everything downstream (including the module-arm resolution) silently loses it. Fix: stamp thetarget_filehint the disambiguator already reads (the#1814mechanism); a Python import can only mean a Python file, so the hint is unambiguous.1c77490— PowerShell, Zig, Rust, Pascal, Elixir, and bash. The same collision kills stem-named import edges in five more languages (a straylead.mdnext tolead.rsis enough). Fixed centrally via a language table in the hint pass — it only picks among salted variants of an id the edge already named, so a collision-free corpus is bit-identical. Bash needed a different fix:symbol_resolution.pyre-derives ids by path formula after disambiguation, so it now consults the post-disambiguation ids (_file_nids_by_path) and its duplicate dangling edge dedupes away. TS/JS, C++/ObjC (header_remaps), Julia/Fortran/Verilog, and Dart were audited and are not vulnerable; a Go dangle that reproduces without a collision was found and deliberately left unasserted — it looks like a separate resolution gap and deserves its own issue.Test evidence
v8@07b9143): 3976 passed / 36 skipped (one known-flaky labeling test passed in isolation).extract()seam; every positive test carries a same-named decoy asserted to get no edge. 30 new tests total intests/test_mixed_corpus_member_calls.py.🤖 Generated with Claude Code