Skip to content

fix(resolution): a binding in a module that exports nothing is not a cross-file candidate - #1720

Open
bompus wants to merge 6 commits into
colbymchenry:mainfrom
bompus:fix/unexported-module-locals
Open

fix(resolution): a binding in a module that exports nothing is not a cross-file candidate#1720
bompus wants to merge 6 commits into
colbymchenry:mainfrom
bompus:fix/unexported-module-locals

Conversation

@bompus

@bompus bompus commented Sep 6, 2026

Copy link
Copy Markdown

Bare-name fallback can connect imports and calls to private JS/TS bindings or documentation headings. Removing an unreachable candidate before ranking can also promote a different unrelated target.

This extends the module-boundary fix for #1719:

  • Reject an unreachable call target after ranking, without promoting a runner-up. Preserve import ranking among modules that can export bindings.
  • Ignore ESM export examples in strings/comments when checking module visibility, while preserving actual exports, later exports, regex literals, CommonJS exports, and ambient globals.
  • Reject JSON data as a call target and reject code imports into Markdown through both name matching and framework resolution.
  • Keep same-file calls, real middleware imports/calls, local file dependencies, and Markdown-origin references covered by positive controls.

Markdown fixtures inject indexed heading nodes, so this update can be reviewed before #1699's extractor lands. The broader export-status redesign in #1721 remains separate.

Validation on Windows x64, Node 24.16.0:

The update changes six code/test files and contains no fork-specific README or benchmark-data changes.

…cross-file candidate

On vitejs/vite, 157 cross-file `imports` refs — every `import { defineConfig }
from 'vite'` in the playground and the create-vite templates — resolved onto
`playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await
createServer(...)` at module scope in a file with zero exports.

Neither existing guard can see it. `isLexicallyReachable` returns early for any
candidate that is not a `function`, and the bare-import guard correctly declines
because `vite` IS a workspace member, so the specifier really is project-local.
What is wrong is only which node the name lands on.

A JS/TS file that contains an `import` statement and no export of any form
offers nothing to any other file, so none of its bindings is a candidate for a
cross-file name match. Applied in both name-based strategies: declining in
matchByExactName alone just hands the same target to matchFuzzy, which resolves
a unique candidate on its own.

Narrow on three axes, each a class this would otherwise get wrong in the
opposite direction: a classic script is exempt (a top-level binding really is a
reachable global), CommonJS is exempt (`module.exports` and `exports.x` count as
exports), and every non-JS/TS language is exempt. The export test reads source
rather than the node's `isExported` flag, because that flag is set only from an
`export_statement` ancestor and so reads false for `const x = ...; export { x }`.
bompus added a commit to bompus/codegraph that referenced this pull request Sep 6, 2026
…of the fork's six changes

The section named colbymchenry#1719 as the defect and stopped there. colbymchenry#1720 measures the
guard for it on the same merge base and corpus: 320 rows removed, every one
verified against source to land on a file that exports nothing, and 18 added by
the dilution mechanism running in reverse. Both halves belong here — a removal
count stated without the additions is the projection error this README's own
method footnote warns about.
@danusha2345

Copy link
Copy Markdown
Contributor

Reproduced on the same checkout (vite 8492422, base b9ca4b7, edge sets joined back to symbols): LOST 320, GAINED 18, all 18 exact-match, fuzzy 13 → 13. The 18 are the six targets you list (create-vite/src/index.ts::start ×5, parse.spec.ts::run ×4, build.spec.ts::dirname ×4, decoder.ts::version ×3, msg, content). Your test passes here too (190/190 in resolution.test.ts).

Composition with #1718. The two auto-merge — adjacent lines in matchFuzzy, no conflict — and stacked on b9ca4b7 the result is exactly additive: LOST 332, GAINED 18, fuzzy 13 → 1; against #1720 alone it is LOST 12 / GAINED 0, against #1718 alone LOST 320 / GAINED 18. Both PRs' test files pass on the merge (195/195). So there is no ordering question between them either.

One shape note, not a blocker. In matchFuzzy the sealed-module test is applied as a filter on the candidate set, which is the form #1718 moves off for reachability: thinning a crowd to one survivor lets the strategy commit to it, and that is the same mechanism as the 18 you documented on the exact-match side, just one strategy over. On vite it costs nothing — fuzzy is 13 → 13 alone and 13 → 1 stacked — so there is no instance to point at, only the predicate. If #1718 lands first, the cheap version is to put the check beside the reachability one on the survivor:

if (finalCandidates.length === 1 && isLexicallyReachable(finalCandidates[0]!, ref, context) && isCrossFileReachable(finalCandidates[0]!, ref, context)) {

That has no more order-sensitivity than the length === 1 test already there, so it should not trip #874 the way the exact-match variant you tried did. Happy to leave it to whichever PR merges second.

Two limits of the source test, both rare, both worth a line in the doc comment rather than a change: a module with imports, no exports and a declare global { … } block is sealed although its globals are reachable from every file; and exports["x"] = … (bracket form) is not matched as a CommonJS export, so such a file would be sealed too. Neither occurs on vite.

The removals themselves I did not re-audit file by file; your 320/320 claim is about the target files' source, and the 14-file table reads as the expected shape (entry scripts and specs).

…as exports

A file writing `exports["x"] = …` exports x, and a file with a `declare
global` block contributes every name in it to every other file whether or not
it exports anything of its own — the extractor emits nodes for the ambient
`var` and `interface` members, so sealing such a file would hide names that
really are reachable everywhere. Neither shape occurs on the vite corpus, so
this changes no measured count; both are now covered by the test.
@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown
Author

Thanks for reproducing it independently — and the #1718 stack result is more than I had; I had only measured against #1715.

First, a correction you should not have to discover: the #874 claim in my description was wrong, and you repeated it back to me. I withdraw it.

index-command.test.ts is flaky on my Windows host, full stop. Five runs of that file alone:

Branch Result Which test
this branch 1 failed, 4 clean #874 > is idempotent
unpatched b9ca4b7 1 failed, 4 clean #1067 > rebuilds to current disk state

A different test each time, and once with no patch at all. The variant could not have caused it in any case: #874's fixture is two files that both open with export function, so neither is ever sealed and the rule is inert on it. I should have checked that before publishing a mechanism. Description updated.

The variant is still rejected, on the real reason. It removes 57 rows this branch keeps, and 41 of them are same-fileresolve.spec.ts calling its own createTestServer, external.ts its own createIsExternal, six spec files their own getSourceMapUrl. "A sealed sibling was dropped" is a property of the whole candidate set, so a legitimate local resolution gets declined because some unrelated file elsewhere happened to be sealed. Of the 16 cross-file ones several also look right (hmr-full-bundle-mode/main.jshmr.js::assetUrl, relative, same directory). So it buys 9 fewer manufactured edges by destroying 41 correct ones — not a trade, a bug.

Your two limits: both fixed in code rather than documented, and the second is bigger than you framed it. declare global is not only about globals being conceptually reachable — the extractor emits real nodes for ambient members:

strayVar:  variable@ambient.ts
StrayFace: interface@ambient.ts

(the bare function stray(): number signature yields none). So a sealed ambient file would hide nodes that genuinely are reachable from every file. The predicate now reads:

/^[ \t]*export[\s{*]|^[ \t]*declare\s+global\b|\bmodule\.exports\b|\bexports\s*[.[]/m

which covers the bracket form too. You were right that neither occurs on vite — I re-ran the arm rather than take it on trust, and the widened predicate gives an edge set identical to the previous revision's, LOST 0 / GAINED 0 against it, so your 320/18 still holds row for row. The test now covers both shapes, asserting through getIncomingEdges so the non-callable ambient var is asked the same question as the functions.

On the matchFuzzy shape note — agreed, and I'd rather it went in whichever of us merges second, as you suggest. Applying it as a survivor test instead of a set filter is the better form for the reason you give, and since fuzzy is 13 → 13 here there is no measurement that distinguishes them on this corpus; it is a predicate argument, and it holds.

Head is now 172d9f7. Suite unchanged at 4,172 passing / 23 failing, the same 23 that fail on unpatched b9ca4b7.

…mport

The consumer bound every name from 'some-external-pkg'. A bare specifier
names a package that is not in the graph, so no project node is the right
target for such a reference and colbymchenry#1715 declines it -- which made four of the
five positive assertions depend on a resolution that should not happen, and
they failed the moment this branch was stacked on colbymchenry#1715. Free references
reach the same exact-match path without asserting that.

`strayVar` was not testable at all: a bare identifier read emits no edge, so
that assertion only ever passed through the bare-import binding. The
`declare global` coverage moves to an interface reached through a type
annotation, paired with an identical file whose interface is not in a
`declare global` -- so the assertion turns on that clause rather than
passing whichever way the guard goes.
@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown
Author

All three open name-matcher PRs stacked on their shared base and measured on the same corpus, plus a fixture defect in this PR that the stack exposed.

Composition

vitejs/vite @ 8492422, all arms from base b9ca4b7, kernel purged and rebuilt per arm, db copied out immediately after indexing. #1715 is d0efd27, #1718 is 4c8f165, #1720 is 172d9f7. Only CHANGELOG.md conflicted between #1715 and #1718; name-matcher.ts conflicted between the #1715 side and #1720 in two spots, both pure additions sharing a closing brace, resolved by keeping both — no logic was rewritten to make them fit.

Arm Edges vs base
base b9ca4b7 27,778
#1720 alone 27,476 320 removed / 18 added
#1715 alone 25,291 2,487 net
#1715 + #1718 25,283 2,495 net
all three 24,986 2,805 removed / 13 added

The arms interact; the deltas are not additive

On top of #1720 contributes
base 320 removed / 18 added
#1715 + #1718 310 removed / 13 added

So #1720 overlaps the other two by only ~10 of its 320 removals and 5 of its 18 additions — they are very largely complementary. #1718 shows the same effect from the other side: 12 standalone, but 8 on top of #1715.

The stack found a defect in this PR's test, not in the others

The three-way suite ran 4,205 passing / 24 failing. Twenty-three of those fail on unpatched b9ca4b7 too. The twenty-fourth was this PR's own #1719 test, and #1715 was the cause — confirmed by building #1720 + #1715 directly rather than by elimination.

The defect was mine. The fixture bound every name through import { widget, gadget, … } from 'some-external-pkg'. A bare specifier names a package that is not in the graph, so no project node is the right target for such a reference — which is exactly what #1715 declines, correctly. Four of the five positive assertions were therefore riding on a resolution that should not happen. Rebinding them as free references reaches the same exact-match path without asserting it, and the test then passes both alone and stacked on #1715.

A fifth assertion was worse than wrong. strayVar — the declare global coverage added earlier in this thread — turns out never to have been observable: a bare identifier read (console.log(strayVar)) emits no edge at all, so that assertion only ever passed through the bare-import binding it should not have had. It is replaced by an interface inside declare global, reached through a type annotation, paired with a control file of identical shape whose interface is not in a declare global. The pair differs only by that clause, so the assertion discriminates on it (StrayFace reached via references/exact-match, HiddenFace not) instead of passing whichever way the guard goes.

Pushed as b88e24c. The guard itself is unchanged — the 320/18 measurement stands, and the independent reproduction at exactly 320/18 is unaffected.

On the matchFuzzy shape

The merge puts both patterns in one function, which makes the inconsistency raised earlier concrete: #1720 filters the candidate set before the survivor count, while #1718 tests the single survivor after it. They are not equivalent under a set-wide predicate — the earlier decline-variant of this PR removed 57 more edges than the filter form, 41 of them same-file, precisely because a set-wide property declined a legitimate local resolution when an unrelated file elsewhere was sealed. Worth settling in one place rather than per-PR.

@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown
Author

Restating the suite line from the matrix above, now that the fixture is fixed. Same three-way tree, only the test file changed, so the edge counts are unaffected — 24,986, and the 310 removed / 13 added that #1720 contributes on top of #1715 + #1718 both stand.

The three-way suite is 4,205 passing / 24 failing, and the #1719 test is no longer among them. The count is unchanged but the set is not: exactly one name swapped.

gone Bindings in a module that exports nothing (#1719) > drops them as cross-file candidates…
new Shared MCP daemon (issue #411) > does NOT reap a live-but-quiet client on the inactivity backstop (#692)

The daemon test passes 10/10 on three consecutive isolated runs of mcp-daemon.test.ts on this same tree, so it is a flake under full-suite load rather than anything the stack does — the change touched only a resolution fixture and cannot reach it. That leaves 23 failures, which is the set that fails on unpatched b9ca4b7.

Worth flagging for anyone reproducing the matrix: an equal failure count across two arms is not the same set, and here it hid both a real fix and a new flake. The arms are also worth committing before building from them — two of mine were resolved merges left in a working tree that a later git checkout -f silently discarded, which produced arms labelled as stacked while actually holding a single PR. Neither failed loudly; the branch-deletion output is what exposed it.

@danusha2345

Copy link
Copy Markdown
Contributor

Rebuilt the three-way arm here to settle the matchFuzzy shape by measurement rather than argument.

Matrix reproduces. Same merge, same two name-matcher.ts hunks kept both-sides, CHANGELOG.md by union. My base sits 9 rows above yours (wasm arm; I have not staged the kernel on this box), so the comparison is on deltas: all three on b9ca4b7 gives me 2,836 removed / 44 added against your 2,805 / 13 — and the gap is my row key, which includes resolvedBy. 29 PluginContext rows (import type { PluginContext } from 'rolldown'pluginContainer.ts) only change resolver, from exact-match to a framework resolver at 0.8, once #1715 stops exact-match binding them; keyed on source/target/kind as you do, that is 2,807 / 15, inside the wasm-vs-kernel noise. All five test files across the three PRs pass on the merge (224).

The shape. With the three merged, I moved the sealed test off the fuzzy candidate set and onto the survivor, beside the reachability one:

const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);

if (
  finalCandidates.length === 1 &&
  isLexicallyReachable(finalCandidates[0]!, ref, context) &&
  isCrossFileReachable(finalCandidates[0]!, ref, context)
) {

Re-indexed vite: row-identical to the filter form — 24,995 both ways, LOST 0 / GAINED 0 between them, fuzzy 1 in both — and the three PRs' tests still pass (206 across the three fuzzy/resolution files). So on this corpus there is no instance either way; what the survivor form buys is closing the manufacturing path for the case that has one.

On your 57 / 41 same-file number: that was the exact-match decline-variant, where "the guard created the uniqueness" is a set-wide property. The survivor form in fuzzy is not set-wide — isCrossFileReachable already exempts a same-file candidate, and a [sealed X, same-file Y] pair that fuzzy would now decline as a crowd of two is resolved by exact-match first (two candidates → findBestMatch, and the same-file proximity term picks Y), so fuzzy never sees it. Which is why I would settle it as: in matchFuzzy, every per-candidate test sits on the survivor; in matchByExactName, the sealed filter stays as this PR has it, since the decline-variant there trips #874 and the 18 it would have saved are the price of determinism.

It is a three-line change. Happy to carry it in #1718 if that merges first, or you can take it here if this one does — either way the diff is the block above.

Thanks for tracing the fixture failure to #1715 rather than to the stack; declining import { widget } from 'some-external-pkg' is exactly what it is for.

…ers its set

matchFuzzy declines an ambiguous name outright, so filtering sealed
candidates out of its set can leave a lone survivor and manufacture a 0.5
edge from an ambiguity that would have been declined. Testing the single
survivor instead closes that path; matchByExactName keeps the filter,
because it ranks a crowd rather than declining one.

No instance on vitejs/vite either way (row-identical, LOST 0 / GAINED 0
per colbymchenry#1720 review). It also declines one shape the filter form resolved: a
sealed same-language survivor no longer yields to a cross-language
candidate at 0.3.
@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown
Author

Taken here, as 247413cmatchFuzzy now tests the survivor and filters nothing; matchByExactName keeps the filter. Your framing is what settles it for me: fuzzy declines an ambiguous name outright, so a filter there can only ever turn a decline into a 0.5 guess, while exact-match ranks a crowd instead of declining it, which is exactly the case a filter is for. That is the split written into the isCrossFileReachable doc comment now, so the next reader does not have to re-derive why the same predicate is applied at two different points.

Thank you for reproducing it on your base rather than arguing the shape — and for isolating the 29 PluginContext rows to resolvedBy in your row key. 2,807 / 15 against my 2,805 / 13 is the wasm-vs-kernel noise I would expect; I have not staged the kernel comparison the other way, so I am taking your 24,995-both-ways row-identical result as measured rather than reproducing it: no vite checkout survives on this box, and the change can only remove edges, never add them.

One shape it declines that the filter form resolved, which your corpus has no instance of and which is a second effect, not the manufactured-uniqueness one. For a candidate set [sealed X (ts), real Y (js)] under a ts ref, the filter form drops X before the same-language split, so Y survives and resolves cross-language at 0.3; the survivor form keeps X as the same-language survivor, finds it sealed, and declines. Strictly a decline, and a 0.3 cross-language fuzzy edge onto a name whose same-language holder is sealed is not one I would defend — but it is a behavior change beyond the three lines' stated purpose, so it belongs on the record rather than in someone's later bisect. It is in the commit message too.

Local checks: tsc --noEmit clean, and resolution.test.ts passes the sealed-module cases unchanged. The four reds in that file on my Windows host are fs.rmSync EPERM in a finally on PHP/C++ temp dirs — the assertions passed, the cleanup did not — which is the same host flake as the 23 in the description.

If #1718 merges first I will rebase onto it; the survivor block then just gains your isLexicallyReachable term beside this one, which is the form you quoted.

@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown
Author

@colbymchenry, could you review the updated head 8ec7374?

It rejects invalid call targets without promoting unrelated replacements, handles export examples in strings/comments, and prevents JSON data and Markdown headings from becoming inappropriate targets. Four negative fixtures reproduce on the previous head and pass with the update.

The build passes, and 32 focused tests pass with native extraction and again with WASM forced. The full Windows run is 4,181 passed / 24 cleanup failures / 44 skipped: 23 failures match the recorded upstream baseline; the additional daemon cleanup failure passes its isolated 10-test rerun. Details are in the updated PR description.

bompus added a commit to bompus/codegraph that referenced this pull request Sep 6, 2026
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.

2 participants