From a320ed152b42be718e9f86f28541d48c126c0657 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 01:57:38 -0600 Subject: [PATCH 1/7] fix(resolution): a name bound to a bare import resolves to no project 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. --- CHANGELOG.md | 2 + __tests__/fuzzy-bare-import-binding.test.ts | 92 +++++++++++++++++++++ src/resolution/name-matcher.ts | 46 +++++++++++ 3 files changed, 140 insertions(+) create mode 100644 __tests__/fuzzy-bare-import-binding.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..26a0b1c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- **A name imported from a package no longer fuzzy-matches a project symbol.** `import { scan } from 'rolldown/experimental'` names a symbol that is not in the graph at all, but the fuzzy fallback matched it by name anyway — in that case onto the importing file's own `scan`, a self-edge. Fuzzy matching now declines when the call site's binding is a bare specifier (a builtin or an npm package); relative, alias and workspace imports still fall through, and only JS/TS is affected, since elsewhere a project's own modules are imported by absolute name too. On vite this removed 4 wrong edges and added none. Re-index after upgrading. + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/fuzzy-bare-import-binding.test.ts b/__tests__/fuzzy-bare-import-binding.test.ts new file mode 100644 index 000000000..c283496cc --- /dev/null +++ b/__tests__/fuzzy-bare-import-binding.test.ts @@ -0,0 +1,92 @@ +/** + * Filtering candidates until one survives does not make that survivor the + * target. When the call site's own name comes from a bare import — `resolve` + * from `node:path` — the real target is external and absent from the graph, so + * the last project symbol standing must not inherit the call. + * + * matchFuzzy is driven directly. Which strategy reaches a given ref depends on + * how many same-named symbols the repo holds and on what the earlier stages of + * matchReference make of them, so a source fixture pins the pipeline rather + * than this guard; the shape that routes through fuzzy on a real tree is vite's + * playground configs, and the guard removes 44 of its wrong edges there. + */ + +import { describe, it, expect } from 'vitest'; +import { matchFuzzy } from '../src/resolution/name-matcher'; +import type { Node } from '../src/types'; +import type { ImportMapping, ResolutionContext, UnresolvedRef } from '../src/resolution/types'; + +/** vite's `pluginContainer.ts:resolve` — the sole survivor of the filters. */ +const SURVIVOR: Node = { + id: 'm:resolve', + kind: 'method', + name: 'resolve', + qualifiedName: 'PluginContainer::resolve', + filePath: 'packages/vite/src/node/server/pluginContainer.ts', + language: 'typescript', + startLine: 10, + endLine: 20, + startColumn: 0, + endColumn: 0, + updatedAt: 0, +}; + +function contextWith(imports: ImportMapping[], candidate = SURVIVOR): ResolutionContext { + return { + getNodesInFile: () => [], + getNodesByName: () => [candidate], + getNodesByLowerName: () => [candidate], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getFileLines: () => [], + getProjectRoot: () => '', + getAllFiles: () => [], + getImportMappings: () => imports, + } as unknown as ResolutionContext; +} + +const imported = (source: string): ImportMapping[] => [ + { localName: 'resolve', exportedName: 'resolve', source, isDefault: false, isNamespace: false }, +]; + +const callTo = (language: UnresolvedRef['language']): UnresolvedRef => ({ + fromNodeId: 'f:outDir', + referenceName: 'resolve', + referenceKind: 'calls', + line: 4, + column: 2, + filePath: 'playground/css/vite.config.js', + language, +}); + +describe('matchFuzzy declines a lone survivor bound to a bare import', () => { + it('declines a node: builtin', () => { + expect(matchFuzzy(callTo('javascript'), contextWith(imported('node:path')))).toBeNull(); + }); + + it('declines a bare npm specifier', () => { + expect(matchFuzzy(callTo('javascript'), contextWith(imported('rollup')))).toBeNull(); + }); + + it('still matches when the binding is a relative import the resolver could not follow', () => { + const res = matchFuzzy(callTo('javascript'), contextWith(imported('./generated/chunks'))); + expect(res?.targetNodeId).toBe('m:resolve'); + expect(res?.resolvedBy).toBe('fuzzy'); + }); + + it('still matches when the name is bound by no import at all', () => { + const res = matchFuzzy(callTo('javascript'), contextWith([])); + expect(res?.targetNodeId).toBe('m:resolve'); + }); + + it('leaves languages whose own modules are imported by absolute name alone', () => { + // `from os import path` and `from myapp.util import path` are the same + // shape, so the bare test cannot tell external from internal here. + const pythonRef = { ...callTo('python'), filePath: 'app/main.py' }; + const pythonNode = { ...SURVIVOR, language: 'python' as const }; + const res = matchFuzzy(pythonRef, contextWith(imported('os'), pythonNode)); + expect(res?.targetNodeId).toBe('m:resolve'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..f2e2d3207 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6,6 +6,7 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +import { resolveWorkspaceImport } from './workspace-packages'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -388,6 +389,50 @@ function isLexicallyReachable( ); } +/** + * Whether the call site's own name is bound by an import of a BARE specifier — + * a Node builtin or an npm package. Such a binding names a symbol that is not + * in the graph at all, so no project node is the right target for it, however + * few candidates are left standing. That is the trap the name-based strategies + * fall into: filtering narrows a crowd of same-named symbols but says nothing + * about whether the true target was ever in the crowd, so when one survives it + * inherits the call. `import { resolve } from 'node:path'` is the case that + * matters — a common name, many project definitions, and the real target + * external. + * + * Relative, alias, and workspace imports are deliberately not treated this way: + * those point at project files, so a name match is a reasonable recovery when + * the import resolver could not follow the path. + * + * Only the JS/TS family is checked. There, a project-internal import is + * distinguishable by shape — it is relative, aliased, or a workspace member — + * so "bare" really does mean external. In Java, Kotlin, Go and Python 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. + */ +function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): boolean { + if ( + ref.language !== 'typescript' && + ref.language !== 'javascript' && + ref.language !== 'tsx' && + ref.language !== 'jsx' && + ref.language !== 'arkts' + ) { + return false; + } + const source = context + .getImportMappings(ref.filePath, ref.language) + .find((i) => i.localName === ref.referenceName)?.source; + if (source === undefined) return false; + if (source.startsWith('.') || source.startsWith('/')) return false; + if (source.startsWith('@/') || source.startsWith('~/') || source.startsWith('src/')) return false; + const aliases = context.getProjectAliases?.(); + if (aliases?.patterns.some((p) => source.startsWith(p.prefix))) return false; + const workspaces = context.getWorkspacePackages?.(); + if (workspaces && resolveWorkspaceImport(source, workspaces)) return false; + return true; +} + /** * Try to resolve a reference by exact name match */ @@ -2405,6 +2450,7 @@ export function matchFuzzy( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + if (isBoundToBareImport(ref, context)) return null; const lowerName = ref.referenceName.toLowerCase(); // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes From 6d36f3f66688a7ddbe97e3b736bbb8f376822f26 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 02:57:17 -0600 Subject: [PATCH 2/7] fix(resolution): treat ~, # and $ import prefixes as local, not bare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- __tests__/fuzzy-bare-import-binding.test.ts | 12 ++++++++++++ src/resolution/name-matcher.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/__tests__/fuzzy-bare-import-binding.test.ts b/__tests__/fuzzy-bare-import-binding.test.ts index c283496cc..b544c5b30 100644 --- a/__tests__/fuzzy-bare-import-binding.test.ts +++ b/__tests__/fuzzy-bare-import-binding.test.ts @@ -76,6 +76,18 @@ describe('matchFuzzy declines a lone survivor bound to a bare import', () => { expect(res?.resolvedBy).toBe('fuzzy'); }); + // vite's playground/tsconfig.json declares `"paths": { "~utils": [...] }` — + // a nested tsconfig the alias loader never reads — so shape is the only + // signal that these are local. npm names cannot start with any of them. + it.each(['~utils', '~/utils', '#types/hmrPayload', '$lib/stores'])( + 'still matches a local specifier with no slash after its prefix: %s', + (source) => { + const res = matchFuzzy(callTo('javascript'), contextWith(imported(source))); + expect(res?.targetNodeId).toBe('m:resolve'); + expect(res?.resolvedBy).toBe('fuzzy'); + }, + ); + it('still matches when the name is bound by no import at all', () => { const res = matchFuzzy(callTo('javascript'), contextWith([])); expect(res?.targetNodeId).toBe('m:resolve'); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f2e2d3207..559f7422b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -425,7 +425,14 @@ function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): bo .find((i) => i.localName === ref.referenceName)?.source; if (source === undefined) return false; if (source.startsWith('.') || source.startsWith('/')) return false; - if (source.startsWith('@/') || source.startsWith('~/') || source.startsWith('src/')) return false; + // `~`, `#` and `$` cannot begin an npm package name, so the prefix alone + // proves a local binding and no resolver lookup is needed: `~utils` (a + // tsconfig `paths` entry, which a nested tsconfig the alias loader never + // reads still declares), `#types/hmrPayload` (a package.json `imports` + // subpath), `$lib/...` (SvelteKit). Matching only `~/` classed the slashless + // spellings as bare and sent real project edges out with the wrong ones. + if (source.startsWith('~') || source.startsWith('#') || source.startsWith('$')) return false; + if (source.startsWith('@/') || source.startsWith('src/')) return false; const aliases = context.getProjectAliases?.(); if (aliases?.patterns.some((p) => source.startsWith(p.prefix))) return false; const workspaces = context.getWorkspacePackages?.(); From 95f07e825ae9a4fa6d10fd13e56bfcb4a2783399 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 6 Sep 2026 11:43:25 +0300 Subject: [PATCH 3/7] fix(resolution): a name bound to a bare import exact-matches no other file's symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + .../exact-match-bare-import-binding.test.ts | 110 ++++++++++++++++++ src/resolution/name-matcher.ts | 21 +++- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 __tests__/exact-match-bare-import-binding.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a0b1c1e..c0b073a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **A name imported from a package no longer fuzzy-matches a project symbol.** `import { scan } from 'rolldown/experimental'` names a symbol that is not in the graph at all, but the fuzzy fallback matched it by name anyway — in that case onto the importing file's own `scan`, a self-edge. Fuzzy matching now declines when the call site's binding is a bare specifier (a builtin or an npm package); relative, alias and workspace imports still fall through, and only JS/TS is affected, since elsewhere a project's own modules are imported by absolute name too. On vite this removed 4 wrong edges and added none. Re-index after upgrading. +- **A name imported from a package no longer exact-matches a project symbol either.** `import { test } from 'vitest'` used to link every `test(...)` in a spec to whichever project file defined a function called `test` — on vite, a fixture, 1,747 times — and `import { resolve } from 'node:path'` to a plugin container's `resolve` method. The exact-name strategy now applies the same rule as the fuzzy one: a name bound to a builtin or an npm package binds to no other file's symbol. A definition in the same file still wins, as a local declaration shadows the import; and a name imported through an alias the resolver cannot see (`~utils`, `#types/x`, `$lib`) still reaches its local target by name, as before. Re-index after upgrading. + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/exact-match-bare-import-binding.test.ts b/__tests__/exact-match-bare-import-binding.test.ts new file mode 100644 index 000000000..e8648e015 --- /dev/null +++ b/__tests__/exact-match-bare-import-binding.test.ts @@ -0,0 +1,110 @@ +/** + * The exact-name strategy has the same single-survivor trap the fuzzy one had + * (#1713): when a call site's own name comes from a bare import — `test` from + * `vitest`, `resolve` from `node:path` — the real target is not in the graph, + * and the one project symbol with that name must not inherit the reference. + * + * These drive the whole pipeline over source fixtures: a bare-import binding + * with exactly one same-named project definition routes through + * matchByExactName, which is the strategy under test. The alias cases pin the + * other half of the rule — a binding the resolver cannot follow is not thereby + * external, so its name match must survive. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +let tempDir: string; +let cg: CodeGraph | null = null; + +function project(files: Record): void { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-bare-exact-')); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(tempDir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } +} + +async function callTargets(caller: string): Promise { + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + const from = cg.getNodesByKind('function').find((n) => n.name === caller)!; + expect(from).toBeDefined(); + return cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls').map((e) => e.target); +} + +afterEach(() => { + cg?.close(); + cg = null; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('exact-name matching declines a name bound to a bare import', () => { + it.each([ + ["import { resolve } from 'node:path';", 'resolve'], + ["import { resolve } from 'path';", 'resolve'], + ["import { resolve as joinPath } from 'node:path';", 'joinPath'], + ["import resolve from 'external-resolver';", 'resolve'], + ["import { resolve } from '@scope/external-resolver/deep';", 'resolve'], + ])('%s does not bind to the only project symbol of that name', async (declaration, name) => { + project({ + 'plugin.ts': `export function ${name}() { return 'plugin'; }`, + 'config.ts': `${declaration}\nexport function configure() { return ${name}('src'); }`, + }); + const targets = await callTargets('configure'); + const wrong = cg!.getNodesByKind('function').find((n) => n.filePath === 'plugin.ts')!; + expect(wrong).toBeDefined(); + expect(targets).not.toContain(wrong.id); + const importEdges = cg!.getNodesByKind('file') + .concat(cg!.getNodesByKind('import')) + .filter((n) => n.filePath === 'config.ts') + .flatMap((n) => cg!.getOutgoingEdges(n.id)) + .filter((e) => e.target === wrong.id); + expect(importEdges).toEqual([]); + }); + + it('a same-file definition still shadows the import', async () => { + project({ + 'config.ts': + "import { resolve } from 'node:path';\n" + + "export function configure() {\n function resolve() { return 'local'; }\n return resolve();\n}", + }); + const targets = await callTargets('configure'); + const local = cg!.getNodesByKind('function').find((n) => n.name === 'resolve')!; + expect(local).toBeDefined(); + expect(targets).toContain(local.id); + }); +}); + +describe('a binding the resolver cannot follow is not thereby external', () => { + // `~utils` is a tsconfig `paths` alias in vite's playground/tsconfig.json — + // a nested tsconfig the alias loader never reads; `#lib/utils` a package.json + // `imports` subpath; `$lib` SvelteKit's. Each reaches its target by name only. + it.each(['~utils', '#lib/utils', '@/lib/utils', '$lib/utils', 'src/lib/utils', './generated/utils'])( + 'keeps the name match for a name imported from %s', + async (specifier) => { + project({ + 'lib/utils.ts': 'export function resolve(value: string) { return value; }', + 'config.ts': `import { resolve } from '${specifier}';\nexport function configure() { return resolve('src'); }`, + }); + const targets = await callTargets('configure'); + const target = cg!.getNodesByKind('function').find((n) => n.filePath === 'lib/utils.ts')!; + expect(target).toBeDefined(); + expect(targets).toContain(target.id); + } + ); + + it('keeps a name bound by no import at all', async () => { + project({ + 'lib/utils.ts': 'export function resolve(value: string) { return value; }', + 'config.ts': "export function configure() { return resolve('src'); }", + }); + const targets = await callTargets('configure'); + const target = cg!.getNodesByKind('function').find((n) => n.filePath === 'lib/utils.ts')!; + expect(targets).toContain(target.id); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 559f7422b..1a67fafe4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -410,7 +410,7 @@ function isLexicallyReachable( * project's own modules are imported by absolute name too, and the same test * would reject the internal case along with the external one. */ -function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): boolean { +export function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): boolean { if ( ref.language !== 'typescript' && ref.language !== 'javascript' && @@ -420,9 +420,11 @@ function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): bo ) { return false; } + // Optional-called: a minimal context (tests, embedders) may not carry + // import mappings, and without them nothing is known to be bare. const source = context - .getImportMappings(ref.filePath, ref.language) - .find((i) => i.localName === ref.referenceName)?.source; + .getImportMappings?.(ref.filePath, ref.language) + ?.find((i) => i.localName === ref.referenceName)?.source; if (source === undefined) return false; if (source.startsWith('.') || source.startsWith('/')) return false; // `~`, `#` and `$` cannot begin an npm package name, so the prefix alone @@ -456,11 +458,22 @@ export function matchByExactName( // unresolved import refs each scored K same-named import candidates through // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on // large import-heavy (front-end + back-end) repos (#915). - const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) + let candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) .filter((n) => n.kind !== 'import') // Nested locals are only reachable from inside their container (#1230). .filter((n) => isLexicallyReachable(n, ref, context)); + // A name bound to a bare import (`import { test } from 'vitest'`) has its + // target outside the graph: no other file's `test` is it, however unique. + // A same-file definition stays eligible — a local declaration shadows the + // file-level import, and that is what the reference then means. + if ( + candidates.some((n) => n.filePath !== ref.filePath) && + isBoundToBareImport(ref, context) + ) { + candidates = candidates.filter((n) => n.filePath === ref.filePath); + } + if (candidates.length === 0) { return null; } From 3bea4a39473f4cc908e03301416d99ac0a92bbf4 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 05:09:45 -0600 Subject: [PATCH 4/7] fix(resolution): a link: or file: dependency is local, not a bare package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- __tests__/fuzzy-bare-import-binding.test.ts | 33 ++++++++++-- src/resolution/name-matcher.ts | 15 ++++++ src/resolution/workspace-packages.ts | 56 +++++++++++++++++++-- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/__tests__/fuzzy-bare-import-binding.test.ts b/__tests__/fuzzy-bare-import-binding.test.ts index b544c5b30..ff9ea17ae 100644 --- a/__tests__/fuzzy-bare-import-binding.test.ts +++ b/__tests__/fuzzy-bare-import-binding.test.ts @@ -7,8 +7,10 @@ * matchFuzzy is driven directly. Which strategy reaches a given ref depends on * how many same-named symbols the repo holds and on what the earlier stages of * matchReference make of them, so a source fixture pins the pipeline rather - * than this guard; the shape that routes through fuzzy on a real tree is vite's - * playground configs, and the guard removes 44 of its wrong edges there. + * than this guard. On real trees the shape routes through fuzzy as a + * case-insensitive match — `EvaluatedModules` from `vite/module-runner` onto + * vitest's `VitestMocker::evaluatedModules`, `Bundle` from `magic-string` onto + * a svelte build script's `bundle`. */ import { describe, it, expect } from 'vitest'; @@ -31,8 +33,13 @@ const SURVIVOR: Node = { updatedAt: 0, }; -function contextWith(imports: ImportMapping[], candidate = SURVIVOR): ResolutionContext { +function contextWith( + imports: ImportMapping[], + candidate = SURVIVOR, + localLinkNames?: Set +): ResolutionContext { return { + getWorkspacePackages: () => (localLinkNames ? { byName: new Map(), localLinkNames } : null), getNodesInFile: () => [], getNodesByName: () => [candidate], getNodesByLowerName: () => [candidate], @@ -88,6 +95,26 @@ describe('matchFuzzy declines a lone survivor bound to a bare import', () => { }, ); + // vitest's `test/browser/package.json` declares `"@vitest/bundled-lib": + // "link:./bundled-lib"`, a directory its `test/*` workspace globs do not + // reach, so the workspace map cannot vouch for the name and only the + // dependency protocol shows it is local. + it('still matches a link: dependency, which is local despite its package spelling', () => { + const linked = new Set(['@vitest/bundled-lib']); + const res = matchFuzzy( + callTo('javascript'), + contextWith(imported('@vitest/bundled-lib'), SURVIVOR, linked) + ); + expect(res?.targetNodeId).toBe('m:resolve'); + }); + + it('declines a scoped package that is not linked into the project', () => { + const linked = new Set(['@vitest/bundled-lib']); + expect( + matchFuzzy(callTo('javascript'), contextWith(imported('@vitest/mocker'), SURVIVOR, linked)) + ).toBeNull(); + }); + it('still matches when the name is bound by no import at all', () => { const res = matchFuzzy(callTo('javascript'), contextWith([])); expect(res?.targetNodeId).toBe('m:resolve'); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 559f7422b..9a396afc0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -437,9 +437,24 @@ function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionContext): bo if (aliases?.patterns.some((p) => source.startsWith(p.prefix))) return false; const workspaces = context.getWorkspacePackages?.(); if (workspaces && resolveWorkspaceImport(source, workspaces)) return false; + // A `link:` / `file:` dependency is a directory in the project that no + // workspace glob need cover, so the workspace map above cannot see it: + // vitest imports `@vitest/bundled-lib` from `test/browser/bundled-lib`, + // which its `test/*` globs stop short of. The name is local even though it + // is spelled exactly like a scoped registry package. + if (workspaces?.localLinkNames?.has(packageNameOf(source))) return false; return true; } +/** + * The package a specifier names, without its subpath: `@scope/pkg/sub` → + * `@scope/pkg`, `pkg/sub` → `pkg`. Scoped names keep two segments. + */ +function packageNameOf(source: string): string { + const parts = source.split('/'); + return source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!; +} + /** * Try to resolve a reference by exact name match */ diff --git a/src/resolution/workspace-packages.ts b/src/resolution/workspace-packages.ts index 386c3e45e..78031a77f 100644 --- a/src/resolution/workspace-packages.ts +++ b/src/resolution/workspace-packages.ts @@ -41,6 +41,15 @@ export interface WorkspacePackages { * list). Absent for npm/pnpm members (their index conventions cover it). */ entryByName?: Map; + /** + * Package names declared with a `link:` or `file:` specifier in the root or + * any member manifest (`"@vitest/bundled-lib": "link:./bundled-lib"`). Such + * a package lives in the project but need not sit under a workspace glob, + * so {@link resolveWorkspaceImport} cannot see it — this set exists only so + * a caller can tell that the NAME is project-local, and deliberately carries + * no directory, since resolving these is a separate change. + */ + localLinkNames?: Set; } /** @@ -55,15 +64,26 @@ export interface WorkspacePackages { export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null { const byName = new Map(); + const memberDirs: string[] = []; const patterns = readWorkspaceGlobs(projectRoot); for (const pattern of patterns) { for (const dir of expandWorkspaceGlob(projectRoot, pattern)) { + memberDirs.push(dir); const pkgName = readPackageName(path.join(projectRoot, dir)); // First declaration wins — workspace patterns are tried in order. if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir); } } + // A member may depend on a package that is inside the project but outside + // every workspace glob (vitest's `test/browser` declares `"@vitest/ + // bundled-lib": "link:./bundled-lib"`, and the globs stop at `test/*`). + // Reading each manifest we already opened for its name costs nothing more. + const localLinkNames = new Set(); + for (const dir of ['', ...memberDirs]) { + for (const dep of readLinkDepNames(path.join(projectRoot, dir))) localLinkNames.add(dep); + } + // HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's // oh-package.json5 declares its local siblings as `"data": "file:../../ // core/data"` dependencies, and code then imports the bare name @@ -77,10 +97,14 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | if (entry) entryByName.set(name, entry); } - if (byName.size === 0) return null; + if (byName.size === 0 && localLinkNames.size === 0) return null; - logDebug('workspace packages loaded', { count: byName.size }); - return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined }; + logDebug('workspace packages loaded', { count: byName.size, linked: localLinkNames.size }); + return { + byName, + entryByName: entryByName.size > 0 ? entryByName : undefined, + localLinkNames: localLinkNames.size > 0 ? localLinkNames : undefined, + }; } /** @@ -314,6 +338,32 @@ function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] { } /** Read the `name` field from a member directory's package.json. */ +/** + * Dependency names this manifest declares with a `link:` or `file:` specifier + * — the two protocols npm, yarn, pnpm and bun all read as "this package is a + * directory in the project", so the name is local however much it looks like + * a registry package. A missing or malformed manifest contributes nothing. + */ +function readLinkDepNames(dirAbs: string): string[] { + let pkg: Record; + try { + pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8')); + } catch { + return []; + } + const names: string[] = []; + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + const deps = pkg?.[field]; + if (!deps || typeof deps !== 'object') continue; + for (const [name, spec] of Object.entries(deps as Record)) { + if (typeof spec === 'string' && (spec.startsWith('link:') || spec.startsWith('file:'))) { + names.push(name); + } + } + } + return names; +} + function readPackageName(dirAbs: string): string | null { try { const pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8')); From d146c37fb4ab23ede46085a46b8e840270775c9a Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 05:15:14 -0600 Subject: [PATCH 5/7] docs(changelog): the bare-import entry names the shape a wider corpus showed --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a0b1c1e..6793367c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,7 +201,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer -- **A name imported from a package no longer fuzzy-matches a project symbol.** `import { scan } from 'rolldown/experimental'` names a symbol that is not in the graph at all, but the fuzzy fallback matched it by name anyway — in that case onto the importing file's own `scan`, a self-edge. Fuzzy matching now declines when the call site's binding is a bare specifier (a builtin or an npm package); relative, alias and workspace imports still fall through, and only JS/TS is affected, since elsewhere a project's own modules are imported by absolute name too. On vite this removed 4 wrong edges and added none. Re-index after upgrading. +- **A name imported from a package no longer fuzzy-matches a project symbol.** `import type { EvaluatedModules } from 'vite/module-runner'` names a symbol that is not in the graph at all, but the fuzzy fallback matched it by name anyway — and the match is case-insensitive, so on vitest it landed on the unrelated method `VitestMocker::evaluatedModules`. Fuzzy matching now declines when the call site's binding is a bare specifier (a builtin or an npm package); relative, alias, workspace and `link:`/`file:` imports still fall through, and only JS/TS is affected, since elsewhere a project's own modules are imported by absolute name too. Across vitest, svelte, vite and rollup this removed 46 wrong edges and added none. Re-index after upgrading. - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. From eb9fddfc15829bd73886defa7468aefe70df461d Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 6 Sep 2026 15:01:03 +0300 Subject: [PATCH 6/7] fix(resolution): preserve nested baseUrl and linked imports --- CHANGELOG.md | 2 + .../exact-match-bare-import-binding.test.ts | 2 + __tests__/local-import-bindings.test.ts | 61 +++++++++++++++++++ src/resolution/name-matcher.ts | 19 ++++++ 4 files changed, 84 insertions(+) create mode 100644 __tests__/local-import-bindings.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c9425a7..c2c05c4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Local JavaScript and TypeScript calls stay connected through linked packages and imports configured by a nested `baseUrl` (#1715). + #### Screens, links and navigation - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. diff --git a/__tests__/exact-match-bare-import-binding.test.ts b/__tests__/exact-match-bare-import-binding.test.ts index e8648e015..bafc43288 100644 --- a/__tests__/exact-match-bare-import-binding.test.ts +++ b/__tests__/exact-match-bare-import-binding.test.ts @@ -53,6 +53,8 @@ describe('exact-name matching declines a name bound to a bare import', () => { ])('%s does not bind to the only project symbol of that name', async (declaration, name) => { project({ 'plugin.ts': `export function ${name}() { return 'plugin'; }`, + // A root directory must not turn the Node builtin path into a local import. + 'path/marker.ts': 'export const marker = true;', 'config.ts': `${declaration}\nexport function configure() { return ${name}('src'); }`, }); const targets = await callTargets('configure'); diff --git a/__tests__/local-import-bindings.test.ts b/__tests__/local-import-bindings.test.ts new file mode 100644 index 000000000..e333c41ff --- /dev/null +++ b/__tests__/local-import-bindings.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root: string | undefined; +let cg: CodeGraph | undefined; +afterEach(() => { + cg?.close(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +async function expectLocalCall(files: Record) { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-local-binding-')); + for (const [file, source] of Object.entries(files)) { + const absolute = path.join(root, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, source); + } + cg = await CodeGraph.init(root, { index: true }); + cg.resolveReferences(); + const functions = cg.getNodesByKind('function'); + const caller = functions.find(n => n.name === 'configure')!; + const target = functions.find(n => n.name === 'localHelper')!; + expect(caller).toBeDefined(); + expect(target).toBeDefined(); + expect(cg.getOutgoingEdges(caller.id).filter(e => e.kind === 'calls').map(e => e.target)) + .toContain(target.id); +} + +describe('local import bindings survive external-package guards', () => { + it.each(['link:', 'file:'])('keeps a %s dependency outside workspace globs', async protocol => { + await expectLocalCall({ + 'package.json': JSON.stringify({ private: true, workspaces: ['packages/*'] }), + 'packages/app/package.json': JSON.stringify({ name: 'app', dependencies: { '@demo/local': `${protocol}./linked` } }), + 'packages/app/linked/package.json': JSON.stringify({ name: '@demo/local' }), + 'packages/app/linked/index.ts': 'export function localHelper() { return 1; }', + 'packages/app/main.ts': "import { localHelper } from '@demo/local'; export function configure() { return localHelper(); }", + }); + }); + + it.each(['link:', 'file:'])('keeps a root %s dependency subpath without workspaces', async protocol => { + await expectLocalCall({ + 'package.json': JSON.stringify({ dependencies: { '@demo/local': `${protocol}./linked` } }), + 'linked/package.json': JSON.stringify({ name: '@demo/local' }), + 'linked/utils.ts': 'export function localHelper() { return 1; }', + 'main.ts': "import { localHelper } from '@demo/local/utils'; export function configure() { return localHelper(); }", + }); + }); + + it('keeps a root directory import described by a nested baseUrl', async () => { + await expectLocalCall({ + 'lib/utils.ts': 'export function localHelper() { return 1; }', + 'consumer/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '..' } }), + 'consumer/main.ts': "import { localHelper } from 'lib/utils'; export function configure() { return localHelper(); }", + }); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f5bc62af3..319e2c854 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4,6 +4,7 @@ * Handles symbol name matching for reference resolution. */ +import { builtinModules } from 'module'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; import { resolveWorkspaceImport } from './workspace-packages'; @@ -389,6 +390,9 @@ function isLexicallyReachable( ); } +const NODE_BUILTIN_SPECIFIERS = new Set(builtinModules); +const ROOT_IMPORT_PATHS = new WeakMap>(); + /** * Whether the call site's own name is bound by an import of a BARE specifier — * a Node builtin or an npm package. Such a binding names a symbol that is not @@ -445,6 +449,20 @@ export function isBoundToBareImport(ref: UnresolvedRef, context: ResolutionConte // which its `test/*` globs stop short of. The name is local even though it // is spelled exactly like a scoped registry package. if (workspaces?.localLinkNames?.has(packageNameOf(source))) return false; + // A nested tsconfig may define baseUrl while the project-root alias map + // knows nothing about it. A root path such as lib/utils is still local. + // Builtins keep their meaning even when a same-named directory exists. + if (!source.startsWith('node:') && !NODE_BUILTIN_SPECIFIERS.has(source)) { + const head = packageNameOf(source); + let memo = ROOT_IMPORT_PATHS.get(context); + if (!memo) { memo = new Map(); ROOT_IMPORT_PATHS.set(context, memo); } + let local = memo.get(head); + if (local === undefined) { + local = context.fileExists(head); + memo.set(head, local); + } + if (local) return false; + } return true; } @@ -1379,6 +1397,7 @@ function getInferScanStates(context: ResolutionContext): Map RegExp[]): RegExp[] { From d0efd274a8e33c8f773109564267b9945adbd7b3 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 6 Sep 2026 15:16:06 +0300 Subject: [PATCH 7/7] docs(resolution): readPackageName keeps its own doc comment 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 --- src/resolution/workspace-packages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resolution/workspace-packages.ts b/src/resolution/workspace-packages.ts index 78031a77f..8f1a4362a 100644 --- a/src/resolution/workspace-packages.ts +++ b/src/resolution/workspace-packages.ts @@ -337,7 +337,6 @@ function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] { return out; } -/** Read the `name` field from a member directory's package.json. */ /** * Dependency names this manifest declares with a `link:` or `file:` specifier * — the two protocols npm, yarn, pnpm and bun all read as "this package is a @@ -364,6 +363,7 @@ function readLinkDepNames(dirAbs: string): string[] { return names; } +/** Read the `name` field from a member directory's package.json. */ function readPackageName(dirAbs: string): string | null { try { const pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8'));