From 86b40ceb7cb1ebfa0830922408c96f6dd43d3b22 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 6 Sep 2026 06:30:51 -0600 Subject: [PATCH 1/6] fix(resolution): a binding in a module that exports nothing is not a cross-file candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 }`. --- __tests__/resolution.test.ts | 91 +++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 101 ++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee5..15088dbe3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -5386,4 +5386,95 @@ in expect(importedFilePaths('main.nix')).toEqual([]); }); }); + + describe('Bindings in a module that exports nothing (#1719)', () => { + // On vitejs/vite, every `import { defineConfig } from 'vite'` across the + // playground resolved onto `playground/ssr-html/test-stacktrace.js::vite` + // — `const vite = await createServer(…)` at module scope in a file with + // zero exports — because exact-match commits whenever one candidate + // survives, and nothing asked whether an import could reach it. The three + // files below the sealed one are the classes that must NOT be filtered: + // a classic script (a top-level binding really is a reachable global), a + // CommonJS module, and an ESM file whose export is a later `export { … }` + // statement, which leaves `isExported` false on the declaration's node. + let tmpDir: string; + let cg: CodeGraph; + + afterEach(() => { + cg?.close(); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('drops them as cross-file candidates, and keeps scripts, CJS and later exports', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1719-')); + fs.writeFileSync( + path.join(tmpDir, 'sealed.js'), + `import fsp from 'node:fs/promises' + +function widget() { + return fsp +} + +widget() +` + ); + fs.writeFileSync( + path.join(tmpDir, 'script.js'), + `function gadget() { + return 1 +} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'cjs.js'), + `import osp from 'node:os' + +function helper() { + return osp +} + +module.exports = { helper } +` + ); + fs.writeFileSync( + path.join(tmpDir, 'later.js'), + `import pathp from 'node:path' + +function parser() { + return pathp +} + +export { parser } +` + ); + // Every name here is bound by a BARE import, so none resolves through the + // import resolver and all four fall through to exact name matching. + fs.writeFileSync( + path.join(tmpDir, 'consumer.js'), + `import { widget, gadget, helper, parser } from 'some-external-pkg' + +widget() +gadget() +helper() +parser() +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + cg.resolveReferences(); + + const calledFromConsumer = (name: string): boolean => { + const target = cg + .searchNodes(name, { limit: 10 }) + .find((r) => r.node.name === name && r.node.filePath !== 'consumer.js'); + expect(target, `no node named ${name}`).toBeDefined(); + return cg.getCallers(target!.node.id).some((c) => c.node.filePath === 'consumer.js'); + }; + + expect(calledFromConsumer('widget')).toBe(false); + expect(calledFromConsumer('gadget')).toBe(true); + expect(calledFromConsumer('helper')).toBe(true); + expect(calledFromConsumer('parser')).toBe(true); + }, 30000); + }); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..289c90002 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -388,6 +388,96 @@ function isLexicallyReachable( ); } +/** Languages whose module boundary is `import`/`export` (or CommonJS). */ +const ESM_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); + +/** + * A line-initial `import` statement — the marker that a JS/TS file is a MODULE + * rather than a classic script. Line-anchored and followed by a name, brace, + * star or quote, so a dynamic `import(` and the word inside a comment or string + * do not match. + */ +const HAS_IMPORT_STATEMENT = /^[ \t]*import[\s{*'"]/m; + +/** + * Any export the file could offer, in every form the extractor's own + * `isExported` flag misses. `^export` covers the declaration and later forms + * (`export const`, `export { x }`, `export default x`, `export *`); the two + * CommonJS shapes cover files that never use ESM syntax at all. Kept as a + * source test rather than a node scan precisely because `isExported` is set + * only from an `export_statement` ancestor, so `const x = …; export { x }` and + * `module.exports = { x }` both read as unexported on the node. + */ +const HAS_ANY_EXPORT = /^[ \t]*export[\s{*]|\bmodule\.exports\b|\bexports\.[A-Za-z_$]/m; + +/** + * Per-context memo of "this file is a module that exports nothing", asked once + * per candidate FILE rather than once per reference. Derived from file source, + * so it drops with the context's file caches — clearNameMatcherMemos deletes it + * alongside INFER_SCAN_STATES. + */ +const SEALED_MODULES = new WeakMap>(); + +/** + * Whether `filePath` is a JS/TS module that exports NOTHING — an import + * statement present, no export of any form. No reference from another file can + * reach any binding in such a file, so every one of its symbols is a false + * candidate for a cross-file name match. + * + * This is the general case behind a package name capturing a same-named local: + * 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. The existing + * guards cannot 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. + * + * Deliberately narrow on three axes, because each is a class this would + * otherwise resolve wrongly in the opposite direction: + * + * - **A classic script is exempt.** Requiring an `import` statement means a + * non-module `.js` file — concatenated globals, a browser `