Skip to content

fix(extract): isolate member-call resolvers and import edges from cross-language interference - #2503

Open
filipechagas wants to merge 5 commits into
Graphify-Labs:v8from
lawnstarter:upstream-fix/member-call-resolver-ownership
Open

fix(extract): isolate member-call resolvers and import edges from cross-language interference#2503
filipechagas wants to merge 5 commits into
Graphify-Labs:v8from
lawnstarter:upstream-fix/member-call-resolver-ownership

Conversation

@filipechagas

Copy link
Copy Markdown

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

  1. 0e6dd75 — raw-call ownership. The Swift, Python, and TypeScript member-call resolvers consume every untagged raw call, including each other's: a TS Lead.search({}) with a matching class only in a .py file mints runner.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 the LanguageResolver registrations so filter and registration cannot drift. Also scopes the Java and C# type_def_nids fallbacks (checked against the current post-e300587 C# code).
  2. 7ddf65c — the remaining receiver-type indexes. C++, ObjC, Swift, and TypeScript type_def_nids were still corpus-wide; each leaked (measured per-language, both directions: false cross-language edge, and a foreign namesake suppressing the true edge). .h remains in both the C++ and ObjC sets by necessity — they're now isolated from every other language but not from each other.
  3. 0f417fc — the Python indexes. class_def_nids and 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

  1. 1764280 — Python. A lead.ts next to lead.py makes 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 the target_file hint the disambiguator already reads (the #1814 mechanism); a Python import can only mean a Python file, so the hint is unambiguous.
  2. 1c77490 — PowerShell, Zig, Rust, Pascal, Elixir, and bash. The same collision kills stem-named import edges in five more languages (a stray lead.md next to lead.rs is 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.py re-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

  • Baseline established on this tree (v8 @ 07b9143): 3976 passed / 36 skipped (one known-flaky labeling test passed in isolation).
  • Every commit red-first through the public extract() seam; every positive test carries a same-named decoy asserted to get no edge. 30 new tests total in tests/test_mixed_corpus_member_calls.py.
  • Full suite at head: 4006 passed / 36 skipped / 0 failed, independently reproduced by a second runner.

🤖 Generated with Claude Code

filipechagas and others added 5 commits August 6, 2026 08:23
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>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

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.

1 participant