fix(resolution): fuzzy matching skips a nested function the reference cannot reach - #1709
fix(resolution): fuzzy matching skips a nested function the reference cannot reach#1709bompus wants to merge 1 commit into
Conversation
Don't merge this yet — on a real repo it trades 12 false positives for 59I ran this against vitejs/vite (1,635 files, 9,354 nodes) rather than only the fixtures. Method: index the same checkout with each build, diff the full edge set joined back to symbol names and call-site lines. Indexing is deterministic — two indexes from the same build differ by 0 edges — so everything below is this change. Edges: 27,499 → 27,546. Not a net removal: 12 lost, 59 gained. The 12 removals are exactly rightEvery one targets a genuinely nested function, unreachable from the call site:
That's the fix doing what it says. The 59 additions are a new false-positive class52 // playground/multiple-entrypoints/vite.config.js
import { resolve } from 'node:path'
...
a0: resolve(dirname, 'entrypoints/a0.js'),That The mechanism follows from the fix. vite has ~12 things named So the filter is correct and the bug it exposes is one layer up: when filtering leaves a single survivor, we commit without checking that the call site's binding is even internal. The import statement naming I'd rather fix that than ship a net increase in wrong edges. The likely shape: if the name is bound by an import whose source resolves outside the project (a builtin, or a bare dependency specifier), don't fuzzy-match it — and the vite configs above make a good regression fixture, since the count is large and the correct answer is unambiguous. Marking my own PR as not-ready. The removal half is worth keeping and I'll resubmit once the survivor case is handled. |
|
Follow-up: my numbers above came from a run with no kernel binary staged, so extraction took the wasm path. Since Rebuilt the kernel and re-ran. It is not: Identical to the wasm arm, down to the counts and the same One more thing worth flagging: #1710 is stacked on this commit and carries it verbatim, so it inherits the 59 edges until this is fixed or #1710 is rebased off it. |
|
@danusha2345 measured this on two repos and got They are not contradictory. Both are real, and the difference is repo shape. Your metric, applied to viteSame checkout indexed with
Every other resolver is unchanged at exactly zero. The whole effect of the commit is the So the metric is not the disagreementI had a hypothesis that your metric couldn't see my failure mode — that counting fuzzy edges wouldn't detect wrong ones. That hypothesis is wrong, and this run is what killed it. Your metric would have caught this immediately on a tree like vite; it just moves in the opposite direction there. I'm glad I measured it rather than posting the theory. Cross-tabbing the edge-set delta against the resolver settles what the 47 is made of: The 12 removals are the fix working: genuinely nested functions the call site cannot reach. The 59 additions are the regression I described, and nearly all target one symbol: That Why the two repos disagreevite has ~12 symbols named That is the pathological shape: a common name, many nested definitions, one surviving unrelated method, and the real target external. Your two trees (codegraph's own 787 files, the Kotlin+Go+TS app's 274) plausibly don't contain it — on them the filter only ever removes bad guesses, which is exactly what 337 → 2 and 142 → 0 describe. So I read your result as correct for those trees and mine as correct for vite, with the underlying bug being one layer up from this commit: when filtering leaves a single survivor, we commit without checking the call site's binding is internal at all. Reproduction note12 lost / 59 gained reproduces my earlier numbers exactly. Absolute edge totals differ from my first comment (27,778 → 27,825 here vs 27,499 → 27,546 before) because vite moved — this run is a fresh shallow clone at Kernel note: my first run was wasm-only and I re-ran it with the kernel built; the counts were identical. This run is likewise a single vite tree indexed twice with only the codegraph build changing. On sequencingI'm not arguing #1709 should be abandoned — the removal half is right and your two repos show it doing real work. But it isn't a strict improvement yet, and landing it ahead of #1679 would ship the single-survivor regression to anyone whose tree has vite's shape. I'd rather fix the survivor check and resubmit, which I'll pick up next unless you'd prefer to take it. If it's useful, the vite playground configs make an unambiguous regression fixture: the count is large and the correct answer is "no edge". |
|
Tightening one thing in my comment above, since @danusha2345's numbers are "kernel on" and mine need to be comparable. My kernel note there was ambiguous. To be exact: that run was the wasm arm — the clone had no So I built the native kernel and re-ran both arms. Every number is identical.
Totals 27,778 → 27,825; LOST 12, GAINED 59, all That is the expected result rather than a surprise — the change is in Credit where due: a colleague caught that |
# Conflicts: # CHANGELOG.md # src/resolution/name-matcher.ts
|
I said I'd pick up the survivor check rather than leave it as an objection to your PR. That's now open as #1713. It is standalone — one commit off Before opening it I measured the guard without your commit, because "it only helps because #1709 broke something" would be a fair challenge:
Every other resolver is 0 delta in all four. #1713 is subtractive on its own — the 4 it removes without your commit in play are On the sequencing question I raised: I think it's resolved, and not in the direction I argued. I said this PR wasn't a strict improvement and shouldn't land first. With #1713 the pair is +3 fuzzy on vite instead of +47, and your 12 removals are correct in every arm I measured — they're the same 12 whether or not my guard is present. If both land, order doesn't matter. If only one lands, #1713 is the one that is subtractive on every tree I have. What I got wrong along the way, since it's on this thread: I hypothesised your metric couldn't see my failure mode. It can — it just moves the opposite way on vite, and measuring it is what killed the hypothesis. #1713 does not fix everything on vite. 15 wrong edges remain, and they're a different mode — |
# Conflicts: # CHANGELOG.md
|
Measured the survivor question from the other side, on the same tree (vite The 59 appear because the reachability filter runs on the candidate set: it thins a crowd of const callableCandidates = applyLanguageGate(
candidates.filter((n) => callableKinds.has(n.kind)), // no reachability here
ref
);
…
if (finalCandidates.length === 1 && isLexicallyReachable(finalCandidates[0]!, ref, context)) {Same tree, that variant on top of your commit: base → LOST 12, GAINED 0, fuzzy 13 → 1; against your tip it is LOST 59, GAINED 0 — the 12 correct removals stay, all 59 It composes with #1713 rather than replacing it: #1713 answers "is the target even in the graph", this answers "did filtering create the uniqueness". The bare-import guard alone leaves 15 ( |
|
Following up on the variant above with a concrete ask. #1713 landed standalone off Would you be fine with a standalone PR off |
|
Happy for you to drive this, but two corrections first — the second is the one that matters. On the stacking. This PR isn't stacked. On why it's marked not-ready. That isn't a base or hygiene issue, and rebasing won't clear it. I measured this change on vitejs/vite (1,635 files) and it is a net increase in wrong edges: 27,499 → 27,546, 12 lost and 59 gained. The 12 removals are exactly right — genuinely nested functions the call site cannot reach. The 59 additions are a new false-positive class, 52 The mechanism follows from the fix rather than contradicting it. vite has around a dozen things named So the practical point for your plan: a standalone PR carrying this On the fixture — yes, please use it, and the author credit you offered is appreciated. It's a two-file reachability case and it should live wherever the change lands. If it's more convenient to write your own, that's fine too. I'd suggest keeping this one as-is for now: I'll rebase and mark it ready once the survivor case is handled, or close it if the change ends up landing in your PR instead. Either outcome is fine by me — I care about the ordering, not about which PR carries it. |
|
Thanks — fixture taken with your authorship on the commit: #1718. One clarification, because it changes the ordering point. #1718 does not carry this PR's Your fixture is unchanged in it and passes; four direct On "not stacked": agreed, this PR is one commit off |
|
Closing in favor of #1718, which fixes #1708 correctly where this doesn't. The bug this PR identified is real, and the fix is wrong. Filtering unreachable candidates out of the set makes the strategy see a crowd of one and commit to it — which is how this traded 12 correct removals for 59 wrong additions on vite, nearly all of them The fixture from this PR lives on in #1718 with my authorship. Nothing here is lost. Thanks @danusha2345 for the reproduction and the credit. |
Fixes #1708.
What
matchFuzzynow filters its callable candidates with the sameisLexicallyReachablecheckmatchByExactNameapplies (#1230): afunctionnested inside another function is a candidate only for references from inside that container.Why
When the only project symbol with a given name is a nested function, exact-match correctly declines, the ref falls through to fuzzy, and fuzzy adopted the closure as its "unique" candidate at confidence 0.5. Any builtin method call that reaches the resolver as a bare name (
settled.value.text(),items.push()) hit whichever file happened to declare a closure of that name.Measured
Real TS repo, 584 files, same build with and without the change:
resolvedBy: fuzzyNothing else moves. With #1679 (closure extraction) applied the same repo had 160 such edges, so that PR benefits from this one landing first.
Tests
__tests__/fuzzy-lexical-reach.test.ts: two files, a nestedfunction text()in one, asettled.value.text()call in the other; asserts nocallsedge from the caller to the closure and that the in-container call still resolves. Fails onmain, passes with the change.