Skip to content

fix(ingestion): credit the receiver of an unresolved method call - #2157

Closed
nickbrus wants to merge 1 commit into
repowise-dev:mainfrom
nickbrus:fix/credit-method-call-receiver
Closed

fix(ingestion): credit the receiver of an unresolved method call#2157
nickbrus wants to merge 1 commit into
repowise-dev:mainfrom
nickbrus:fix/credit-method-call-receiver

Conversation

@nickbrus

@nickbrus nickbrus commented Sep 7, 2026

Copy link
Copy Markdown

Summary

  • A method call uses its receiver. STAGES.forEach(...) resolves on the member — forEach, an unresolvable builtin — and so produced no edge at all, leaving STAGES with zero inbound edges and the dead-code analyzer calling a module-level constant used four times unused.
  • This credits the receiver as a references edge only when the member resolved to nothing, and only when the receiver names a same-file symbol.
  • This is a partial mitigation, not a fix. It is labelled that way on purpose; the numbers below say how partial.

Where it sits relative to what's already here

python_local_refs closes exactly this gap for Python — a top-level symbol referenced within its own module in a non-call position carries no graph edge. There is no JS/TS equivalent, and the shape is not Python-specific: a module-level table consumed through a builtin (ROUTES.map(...), SUITES.filter(...), DEFAULTS.timeout) is invisible in every language.

This is one narrow slice of that, at the resolver layer rather than per language.

The two limits, and why

Only when the member resolved to nothing. Defaults.timeout() where timeout is a same-file method already puts an edge inside the receiver, and the receiver is reachable from it. Crediting it again would mint an edge for every qualified property read — which is precisely what TestKotlinCallableReferences::test_plain_member_access_is_not_a_reference guards against. That test stays green, unmodified.

Only same-file receivers. An imported name already carries an imports edge, and a bare receiver matching an unrelated symbol in another file would be a guess, not a resolution.

references rather than calls: naming a value is not executing it. It is in SYMBOL_USE_EDGE_TYPES so dead code counts it, and outside EXECUTION_EDGE_TYPES so call graphs, flow analysis and the inferred test map are unaffected. Origin same_file at 0.95 keeps the one-confidence-per-origin invariant.

Measured

On 0.48.0 with the tsconfig JSONC fix applied (#2156), over a 1,275-file TS/JS + Node repo:

rows plausibly true precision
unused_internal, before 462 9 1.9 %
unused_internal, after 333 9 2.7 %

129 rows removed, none of them true: a control set of 9 hand-verified genuinely-dead symbols (built before the run, not after) stayed flagged 9 of 9.

Two honest caveats on that table:

  1. Those numbers are for the broader rule — credit every same-file receiver. The narrower rule shipped here emits a strict subset, so read 129 as an upper bound and the 9-of-9 control result as strictly safe under it. On the file that motivated the change, both rules clear the same five symbols and leave the same six flagged.
  2. The category is still mostly wrong. 2.7 % precision is not usable. Arguments and template interpolations, not receivers, are how constants are actually consumed. I would rather this went in labelled as a partial mitigation than be mistaken for a fix.

What the real fix is

An identifier-reference pass: the JS/TS analogue of python_local_refs, crediting a name appearing in an argument list, a template substitution, a subscript, a property base, a JSX expression and a return position. That is a per-language job in the tree-sitter query layer touching parser.py, language_configs.py and the graph builder, across ~20 languages — much larger than this, and guessing at it without a per-language corpus would trade a category that is honestly noisy for one that is quietly wrong. Happy to open an issue for it if that is useful.

Test Plan

TestReceiverIsUsed in tests/unit/ingestion/test_call_resolver_strategies.py — 8 cases, all through the real parser rather than spies:

  • a same-file receiver is credited at 0.95 / same_file / references;
  • the edge is in SYMBOL_USE_EDGE_TYPES and not in EXECUTION_EDGE_TYPES;
  • a receiver used twice is credited once, not per call site;
  • console — a receiver naming nothing local — mints nothing;
  • control: a constant nothing reads stays uncredited;
  • a symbol does not credit itself;
  • it is not JavaScript-only (Python case);
  • the limit: a resolved member (Defaults.timeout()) does not credit its receiver again.

Five of the eight fail on main before the change; the three negatives pass in both states, which is what makes them controls.

  • Tests pass (pytest tests/unit/ingestion tests/unit/dead_code tests/unit/analysis) — failure set identical to main on the same machine, and +8 passing
  • Lint passes (ruff check on both touched files)
  • Web build — no frontend changes

Checklist

  • My code follows the project's code style
  • I have added tests for new functionality
  • All existing tests still pass
  • I have updated documentation if needed (none needed)

`STAGES.forEach(...)` resolves on the member -- `forEach`, an unresolvable
builtin -- and so produced no edge at all. `STAGES` was left with zero
inbound edges, and the dead-code analyzer, which credits a symbol only on
an inbound edge, reported a module-level constant used four times as
unused. The same shape covers every module-level table consumed through a
builtin: `ROUTES.map(...)`, `DEFAULTS.timeout`, `SUITES.filter(...)`.

This is the JS/TS-shaped half of the gap `python_local_refs` closes for
Python. It is a partial mitigation, not a fix -- see below.

Two limits keep it from guessing:

* Only when the member resolved to nothing. `Defaults.timeout()` where
  `timeout` is a same-file method already puts an edge inside the
  receiver, so crediting the receiver again would mint an edge for every
  qualified property read. This is what keeps
  `test_plain_member_access_is_not_a_reference` green.
* Only same-file receivers. An imported name already carries an `imports`
  edge, and a bare receiver matching an unrelated symbol in another file
  would be a guess.

Emitted as `references`, not `calls`: naming a value is not executing it.
`references` is in SYMBOL_USE_EDGE_TYPES so dead code counts it, and
outside EXECUTION_EDGE_TYPES so call graphs, flow analysis and the
inferred test map are unchanged. Origin `same_file` at 0.95 keeps the
one-confidence-per-origin invariant.

Measured on 0.48.0 with the tsconfig JSONC fix applied, over a 1,275-file
TS/JS repo: unused_internal 462 -> 333, and 9 of 9 hand-verified
truly-dead symbols stayed flagged -- 129 rows removed, none of them true.
That was the broader rule (credit every same-file receiver); the narrower
rule shipped here emits a subset, so read 129 as an upper bound. On the
file that motivated it, both rules clear the same five symbols.

What this does NOT fix: the category is still mostly wrong. Precision on
unused_internal in that repo moves 1.9% -> 2.7%, because arguments and
template interpolations, not receivers, are how constants are mostly
used. The real fix is an identifier-reference pass -- the JS/TS analogue
of python_local_refs, crediting a name in an argument list, a template
substitution, a subscript, a JSX expression or a return position -- which
is a per-language job in the tree-sitter query layer and much larger than
this.
@nickbrus nickbrus closed this Sep 7, 2026
@nickbrus
nickbrus deleted the fix/credit-method-call-receiver branch September 7, 2026 15:58
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