Skip to content

fix(resolution): a name bound to a bare import exact-matches no other file's symbol - #1715

Open
danusha2345 wants to merge 8 commits into
colbymchenry:mainfrom
danusha2345:fix/1713-exact-match-external-binding
Open

fix(resolution): a name bound to a bare import exact-matches no other file's symbol#1715
danusha2345 wants to merge 8 commits into
colbymchenry:mainfrom
danusha2345:fix/1713-exact-match-external-binding

Conversation

@danusha2345

@danusha2345 danusha2345 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1713 — its commits come first here (a320ed1, 6d36f3f, 3bea4a3, d146c37, merged as 7429ca2); this PR's own change is the rest, and it rebases to a single commit once #1713 lands. It is the exact-match half that #1713's description sets aside ("I also tried the same guard in matchByExactName … 4,048 edges lost — so I reverted it"), with the measurement and with the reason the first attempt lost real edges.

What

matchByExactName has the same single-survivor commit matchFuzzy had: one candidate with the right name, and the reference is bound to it regardless of what the call site's own name is bound to. On vite:

packages/vite/src/node/__tests__/*.spec.ts       import { test } from 'vitest'
  → packages/vite/src/node/__tests__/fixtures/runner-import/basic.ts:test     × 1,747
playground/**/vite.config.js                      import { resolve } from 'node:path'
  → packages/vite/src/node/server/pluginContainer.ts:resolve                  × 92
packages/vite/src/**                              import { … } from 'rolldown'
  → packages/vite/types/metadata.d.ts, pluginContainer.ts, plugin.ts          × 118

The change: when isBoundToBareImport (from #1713) says the binding is a builtin or an npm package, matchByExactName drops the candidates from other files before the single-survivor / best-match logic runs. A same-file definition stays — a local declaration shadows the file-level import, and that is what the reference then means.

What the predicate has to keep

Exact-match is where a name imported through a specifier the resolver cannot follow actually resolves, so a false "external" here is a real edge lost. That is what cost the first attempt 4,048 on vite, and each of these is now covered:

  • ~utils, #types/x, $lib/… — alias-looking prefixes no npm name can start with (fix(resolution): a name bound to a bare import resolves to no project node #1713's 6d36f3f; vite's ~utils alone carried 1,395 real edges).
  • link: / file: dependencies outside every workspace glob (fix(resolution): a name bound to a bare import resolves to no project node #1713's 3bea4a3, found by @bompus on vitest's @vitest/bundled-lib).
  • A first path segment that is a directory at the project root — import { x } from 'lib/utils' under a baseUrl declared in a nested tsconfig the alias loader never reads (eb9fddf, memoised per context, cleared with the other name-matcher memos). A Node builtin stays external even when a same-named directory exists (path/).
  • The predicate is exported, and optional-called on getImportMappings: the older resolution.test.ts mocks carry none, and without mappings nothing is known to be bare.

Measurement

vitejs/vite at 8492422, edge sets joined back to symbol names and call-site lines (two indexes from one build differ by 0 edges).

Against #1713's first commit (a320ed1, the earlier head of this branch): LOST 2,536, GAINED 33. Every lost edge grouped by the import that bound the name at the call site: vitest 1,747, node:path 261, rolldown 126, node:fs 65, picocolors 44, node:http 33, kill-port 22, escape-html 12, … — not one through ~utils, #types/… or a relative path. Of the 33 gained, 29 are the same wrong PluginContext targets coming back through a framework resolver at 0.8 once exact-match stepped aside (unchanged in the graph, only resolvedBy moved; that resolver does not consult the binding either and is a separate fix), 2 are plugin.ts's own declare module 'rolldown' augmentation, which the same-file rule keeps, 2 a playground dedupe fixture.

Against main (b9ca4b7) on the current head, @bompus's independent run (below): −2,487 edges, 0 gained, and every one of the 2,487 reads back to a bare npm package (2,088) or a Node builtin (399) — relative, alias and unbound: 0.

Tests

  • __tests__/exact-match-bare-import-binding.test.ts drives the whole pipeline over source fixtures, because a bare-import binding with exactly one same-named project definition is precisely the shape that routes through matchByExactName: five bindings that must not bind cross-file (node:path, bare path, an aliased named import, a default import from an npm name, a scoped deep path — ablating the guard fails exactly these five); a same-file definition that still shadows the import; six bindings the resolver cannot follow that must keep their name match (~utils, #lib/utils, @/lib/utils, $lib/utils, src/lib/utils, an unresolvable relative path), plus a name bound by no import at all. A path/ directory in the fixture pins that a builtin stays external.
  • __tests__/local-import-bindings.test.ts: link: and file: dependencies in a workspace member and at the root without workspaces, including a subpath import, and a root-directory import under a nested baseUrl. The nested-baseUrl case fails before eb9fddf and passes after; the four link/file cases fail against the guard without 3bea4a3.

Full suite on Linux, Node 24.15.0, native kernel built from this checkout and confirmed loaded with CODEGRAPH_KERNEL_DEBUG=1: 239 files, 4,258 passed, 11 skipped, 0 failed. npm run build including the viewer assets: clean.

Known residual, not in scope

import { defineConfig } from 'vite' inside vite's own playground still lands on playground/ssr-html/test-stacktrace.js::vite (60 edges): the workspace map rightly says vite is project-local, and the name match then picks any node called vite rather than the member's entry. That is the workspace branch resolving a package name to a same-named node, separate from this change — tracked as #1719, fix in #1720 (@bompus).

🤖 Generated with Claude Code

… node

Fuzzy matching commits to a lone surviving candidate. Filtering narrows a
crowd of same-named symbols; it does not establish that the true target was
ever in the crowd. `import { scan } from 'rolldown/experimental'` is the case
that matters: the real target is external and absent from the graph, so the
last project symbol standing inherits the reference -- in that instance the
importing file's own `scan`, a self-edge.

Decline fuzzy matching when the call site's own binding is a bare specifier.
Relative, alias and workspace imports point at project files and still fall
through, and only the JS/TS family is checked, since elsewhere a project's
own modules are imported by absolute name too and the same test would reject
the internal case along with the external one.

On vite this removes 4 wrong edges and adds none; no other resolver moves.
bompus and others added 2 commits September 6, 2026 02:57
isBoundToBareImport tested `startsWith('~/')`, so a slashless alias was classed
as an external package. vite's playground/tsconfig.json declares
`"paths": { "~utils": ["./test-utils.ts"] }` — a nested tsconfig the alias
loader never reads — and `#types/hmrPayload` is a package.json `imports`
subpath; both name project files.

None of `~`, `#` or `$` can begin an npm package name, so the prefix alone is
sufficient evidence of a local binding and no resolver lookup is needed.

In matchFuzzy this changes nothing measurable on vite, because those names
resolve by exact match before fuzzy is reached — which is exactly why the
defect survived a green measurement. It is load-bearing for any use of the
predicate in matchByExactName, where classing `~utils` as bare took 1,395 real
edges out with the wrong ones.
… file's symbol

The exact-name strategy has the single-survivor trap the fuzzy one had
(a320ed1): `import { test } from 'vitest'` linked every `test(...)` in a
spec to the one project function called `test` — on vite, a fixture,
1,747 times — and `import { resolve } from 'node:path'` linked to a
plugin container's `resolve` method. matchByExactName now drops the
candidates from other files when isBoundToBareImport says the binding is
a builtin or an npm package; a same-file definition stays, since a local
declaration shadows the import.

This is what the exact-match attempt 6d36f3f's parent reverted, and the
reason it lost 4,048 is the prefix rule 6d36f3f fixed: with `~utils`
classed as bare, its 1,395 real edges on vite went with the wrong ones.

vitejs/vite@8492422, indexed at a320ed1 and at this change, edge sets
joined back to symbols: 2,536 lost (1,757 calls, 642 imports, 129
references, 6 instantiates), every one bound through vitest, node:path,
node:fs, node:http, rolldown, picocolors, kill-port, escape-html and the
like; 33 gained, of which 29 are the SAME wrong `PluginContext` targets
re-resolved by a framework resolver once exact-match stepped aside, 2 are
a `declare module 'rolldown'` augmentation in the importing file, and 2
are a playground dedupe fixture. fuzzy stays at 9. The `~utils` edges are
all present.

The predicate is exported, and optional-called on getImportMappings: a
minimal context (the older resolution.test.ts mocks) carries none, and
without mappings nothing is known to be bare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@danusha2345
danusha2345 force-pushed the fix/1713-exact-match-external-binding branch from 8c71e12 to 95f07e8 Compare September 6, 2026 09:29
bompus and others added 4 commits September 6, 2026 05:09
…kage

vitest's test/browser declares "@vitest/bundled-lib": "link:./bundled-lib",
a directory its test/* workspace globs do not reach, so the workspace map
could not vouch for the name and the guard classed it external — removing two
correct edges onto the linked package's own source. link: and file: are the
protocols every package manager reads as "this is a directory in the project",
so the name is local however much it is spelled like a scoped registry package.
@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown

Ran the vite arm you said was not re-run on the final head. It supports the patch: 2,487 edge rows removed, 0 added, and every one of the 2,487 is attributable to a bare import.

I went in expecting the opposite. #1713 tried guarding matchByExactName's single-survivor commit and was reverted when it cost ~4,048 edges on vite, and this guard sits at the same commit, so I built the arm to see whether it reproduced that. It does not — or rather, it produces a comparable count that turns out to be correct.

Method

eb9fddf against b9ca4b7, their merge base, so every row below belongs to this PR. Kernel purged and rebuilt from this checkout's own source (a stale .node silently invalidated an earlier arm of mine, so the driver now deletes it and aborts if build:kernel fails). Corpus is vitejs/vite @ 8492422. Edge rows keyed by source, target and kind; the db is copied out immediately after indexing and read from the copy.

b9ca4b7 eb9fddf Δ
Edges 27,778 25,291 −2,487
Nodes 9,354 9,354 0
Files 1,608 1,608 0
Unresolved refs 24,920 27,429 +2,509

Removed by kind: calls 1,752, imports 620, references 109, instantiates 6. Gained: 0 — purely subtractive, so there is no new wrong edge to worry about.

Every removal is a true positive

For each of the 2,487 removed rows I read the source file and found the specifier that binds that name:

Specifier the source actually imports from Rows
bare npm package 2,088
node builtin 399
relative 0
alias / ~ / # / $ / @/ / src/ 0
no import of that name found 0

100.00%. Nothing was removed from a project-internal import, which is the failure mode that would matter. Concretely, the top targets:

  • test — 1,747 rows. import { test } from 'vitest' in spec files was resolving to packages/vite/src/node/__tests__/fixtures/runner-import/basic.ts::test, a fixture.
  • path — 168. import path from 'node:path'packages/vite/src/types/ws.d.ts::path.
  • resolve — 104. import { resolve } from 'node:path'pluginContainer.ts::resolve and config.ts::resolve.
  • defineConfig — 22. import { defineConfig } from 'vitepress' and from eslint/configvite's own config.ts::defineConfig.
  • escapeHtml — 12. import escapeHtml from 'escape-html'playground/ssr/src/utils.js::escapeHtml.

The unresolved count rising by 2,509 is the intended cost: a declined reference is counted as unresolved, which is the honest state for an import whose target is not in the graph.

One gap, and it is not a fault in this patch

It removes 0 edges targeting a node named vite, and there are 60 wrong ones in this corpus: import { defineConfig } from 'vite' across the playground resolving onto playground/ssr-html/test-stacktrace.js::vite, a file that merely shares the name.

The guard is behaving as designed. packages/vite/package.json declares the name vite and pnpm-workspace.yaml globs packages/*, so resolveWorkspaceImport('vite', …) reports the specifier project-local and isBoundToBareImport returns false. That is the right call for the specifier and still leaves a package import matched against a same-named file node. So the residual defect is the workspace branch resolving a package name to any node with that name, rather than to the member's entry — separate from this change, and worth its own issue.

Nit

workspace-packages.ts: the one-line /** Read the \name` field from a member directory's package.json. */now sits above the newreadLinkDepNamesdoc block, soreadPackageNameis undocumented andreadLinkDepNames` carries two comments.

Limits

Graph effects only. I did not reproduce your suite run — your 4,258 passing on Linux/Node 24.15.0 stands unchecked by me. My classifier takes the first import statement binding each name, which is sound in JS/TS since one name cannot have two import bindings in a file, but it is a regex over source rather than the resolver's own view.

bompus added a commit to bompus/codegraph that referenced this pull request Sep 6, 2026
…ymchenry#1715 supersedes the bare-import row

Two things a reader cannot check from the table.

The millisecond figures depend on a machine-local Windows Defender exclusion.
The corpus sat under an excluded directory and the interpreter in Program
Files; on this host an unexcluded directory read by an untrusted binary costs
~12.6s for one 1.2MB file. All three arms shared the configuration so the
comparison holds, but the absolute totals do not transfer to a stock host.

And the bare-import row is no longer the best available fix. colbymchenry#1715 moves the
guard into matchByExactName, where these references actually resolve, and
measured on the same corpus against the same merge base it removes 2,487 edge
rows to this change's 4, adding none. All 2,487 have a source file importing
that name from a bare npm package or node builtin, so the removals are
precision gains rather than a regression.
The one-line comment had drifted above readLinkDepNames's block in 3bea4a3,
leaving readPackageName undocumented and readLinkDepNames with two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bompus added a commit to bompus/codegraph that referenced this pull request Sep 6, 2026
…0 of them

The paragraph described the 60 as the class. They are the fork's share of it.
Measured on main: 157 cross-file `imports` rows resolve onto a single target,
and it is not a file-level symbol as written but a `const` at module scope in
`playground/ssr-html/test-stacktrace.js`, a file with no exports at all.

None of the six changes fix the cause. Neither does colbymchenry#1715, which removes 0 of
the 157 while removing 2,487 other wrong edges. A non-exported top-level
binding is admitted as a cross-file exact-match candidate, filed as colbymchenry#1719.

Table figures unchanged; only the explanation and the markdown-index cell move.
@danusha2345

Copy link
Copy Markdown
Contributor Author

Thanks for running the arm — 2,487 removed / 0 added with every row read back to a bare package or a builtin is the number this PR needed and did not have on its final head. I've put it in the description, and rewritten the description in English (the last revision had slipped into Russian).

Nit fixed in d0efd27: readPackageName has its one-line comment back and readLinkDepNames carries only its own block.

On the vite residual: agreed it is the workspace branch, not this guard — resolveWorkspaceImport is right that vite is project-local, and the name match then takes any node called vite instead of the member's entry. I've listed it under "known residual" in the description so it is not mistaken for coverage. It wants its own issue, since the fix is on the resolver side (a workspace name should resolve to the member's entry export or decline, never to a same-named symbol elsewhere); I'll open one unless you already have it drafted from the four-repo run.

@bompus

bompus commented Sep 6, 2026

Copy link
Copy Markdown

Already filed — #1719. Don't open a second one.

Two things in it are sharper than what I wrote above, because I went looking for the target after posting and it is not what I described.

The class is 157 rows, not 60. On b9ca4b7, 157 cross-file imports rows resolve onto a node named vite, and they all land on the same target. The 60 I mentioned is only the subset that the markdown-index branch happens to remove by dilution; 97 survive it.

The target is not the member's entry being missed — it is a non-exported local. It is playground/ssr-html/test-stacktrace.js::vite, and that is:

const vite = await createServer({ /* ... */ })

at module scope in a file with zero exports. So the framing we agreed on — workspace name should resolve to the member's entry rather than a same-named symbol elsewhere — is right about the symptom, but the cause is more general than the workspace branch: isLexicallyReachable admits a top-level binding that its own file never exports as a cross-file candidate. Nothing about that is specific to workspace packages; any unexported top-level name can capture any same-named import anywhere.

I put both framings in #1719 and did not pick one, since the export-visibility fix would subsume the workspace one and has the wider blast radius. That is the maintainer's call, not mine.

Thanks for d0efd27 and for moving the description to English — and for listing the residual as a known gap rather than letting the edge delta imply coverage. For what it is worth, my measurement was prompted by your own note that the vite benchmark had not been re-run on the final head; without that line I would probably have read the diff and not built the arm.

@danusha2345

Copy link
Copy Markdown
Contributor Author

Scratch the offer — you already had it: #1719, with the fix in #1720. I'll review that one there rather than duplicate it, and I've pointed the "known residual" line in this description at #1719.

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