Skip to content

fix(resolution): don't believe a bare-name call across language families - #1662

Open
ferrine wants to merge 1 commit into
colbymchenry:mainfrom
ferrine:fix/cross-language-call-confidence
Open

fix(resolution): don't believe a bare-name call across language families#1662
ferrine wants to merge 1 commit into
colbymchenry:mainfrom
ferrine:fix/cross-language-call-confidence

Conversation

@ferrine

@ferrine ferrine commented Aug 31, 2026

Copy link
Copy Markdown

Branch: ferrine:fix/cross-language-call-confidencemain
Size: 3 files, +181/−12 · Opt-out: CODEGRAPH_CROSS_FAMILY_CALL_FLOOR=0

⚠ This changes resolution behaviour for every user and removes edges.
Happy to discuss the principle before the code — see "Why this is your rule,
not a new one" below.

Why

Two problems at the same comparison, one masking the other.

1. The single-candidate confidence penalty compares raw language tags. A
.tsx file calling a .ts helper counts as cross-language and is scored 0.5
instead of 0.9 — that is most calls in any React codebase. sameLanguageFamily
already exists two functions above for exactly this.

2. With that fixed, 0.5 identifies the real problem. Cross-family calls
are deliberately allowed — FFI bindings and native bridges are real. But a
language's own builtins and macros have no definition in the graph to bind to,
so the lone same-named foreign symbol wins by default. Observed on a
mixed-language repository: an ExUnit test macro binding to a test runner's
fixture in TypeScript; Python's dataclasses.field binding to an unrelated
Elixir helper; apply, max, min, path.

The matcher already recognised these and wrote the verdict into the edge as a
low confidence — and every consumer then believed the edge anyway, so callers
and impact reported it as fact. On that repo it was thousands of impossible
edges, concentrated on a small set of generically-named symbols whose reported
callers were then mostly or entirely fictional. One helper's impact returned
dozens of affected symbols, nearly all of them in a language that cannot call it.

It is also unstable under indexing scope: indexing more files of one
language moved thousands of these edges elsewhere, because the fallback fires
only when no same-language candidate exists.

Why this is your rule, not a new one

src/resolution/index.ts already hardcodes exactly this, for one language:

"The reverse direction is just as impossible: no other language can
symbolically call into a .nix binding (interop is eval/CLI, never a linkable
symbol) — without this, a Python script's split() lands on some module's
split = ... binding as a low-confidence match."

This PR generalizes that from a per-language special case to the rule it is an
instance of.

What changed

  • The single-candidate penalty and the fuzzy penalty compare family, not raw
    language tag.
  • A name match that crosses a family and lands at or below the floor is
    declined rather than recorded.
  • Tunable via CODEGRAPH_CROSS_FAMILY_CALL_FLOOR, =0 restores the old
    behaviour — same convention as CODEGRAPH_AMBIGUOUS_NAME_CEILING.

Blast radius, checked

  • RN/Expo JS→native is untouched. Those resolve through the framework
    strategy (Strategy 1/2); this gate is in name matching (Strategy 3). The
    Cross-language type/import gate (RN name collisions) test passes.
  • Genuine FFI keeps its edge, because it resolves through an import or a
    qualified name (0.85+), never a bare one.
  • On the repository above: impossible cross-language call edges dropped from
    ~3,800 to ~100, with no loss of same-language edges and all tsxts
    edges retained.
  • I checked the one case that looked like a real cost — a pybind11 binding in
    the repo. Every removed pythoncpp edge pointed at C++ test files and
    headers via Python builtins (bytes, iter); none pointed at the actual
    bindings file. The real bridge was never captured before or after — that is a
    separate coverage gap, not a regression from this change.
Two problems at the same comparison, one masking the other.

The single-candidate confidence penalty compared raw language tags, so a
`.tsx` file calling a `.ts` helper counted as cross-language and every
ordinary call in a React codebase was scored 0.5 instead of 0.9. Families
already exist for exactly this (`sameLanguageFamily`); using them makes 0.5
mean what it says.

With that fixed, 0.5 identifies the real problem: a call resolved to a
same-named symbol in an unrelated language on no evidence but the name.
Cross-family calls are deliberately allowed — FFI bindings, native bridges
are real, which is why the language gate covers `references` and `imports`
but not `calls`. But a language's own builtins and macros have no definition
of their own in the graph to bind to, so the lone same-named foreign symbol
won by default: an ExUnit `test` binding to a test runner's fixture in
TypeScript, `dataclasses.field` binding to an unrelated Elixir helper.

The matcher already recognised these and wrote the verdict into the edge's
metadata as a low confidence — and every consumer then believed the edge
anyway, so `callers` and `impact` reported it as fact. On a mixed-language
repository that was thousands of impossible edges, concentrated on a small
set of generically-named symbols whose reported callers were then mostly or
entirely fictional.

Matches at or below the floor are now declined rather than recorded. The
precise strategies are untouched, which is how a genuine binding keeps its
edge: it resolves through an import or a qualified name, well above the
floor, never through a bare one. Tunable via
`CODEGRAPH_CROSS_FAMILY_CALL_FLOOR`, and `=0` restores the old behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant