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

* fix(resolution): a binding in a module that exports nothing is not a 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 }`.

* fix(resolution): count bracket CommonJS exports and `declare global` 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.

* test(resolution): bind the #1719 fixture without a bare import

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 #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 #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.

* docs(changelog): record the sealed-module guard under Unreleased

* fix(resolution): the sealed test rejects fuzzy's survivor, never filters 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 #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.

* fix(resolution): reject invalid fallback targets without retargeting

---------

Co-authored-by: Aaron Queen <bompus@users.noreply.github.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 00:13:49 -05:00
committed by GitHub
co-authored by Aaron Queen Colby McHenry
parent 2c251e2c61
commit bffd50e4f1
7 changed files with 501 additions and 43 deletions
+266 -1
View File
@@ -11,7 +11,7 @@ import * as os from 'os';
import { CodeGraph } from '../src';
import { Node, UnresolvedReference } from '../src/types';
import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
import { matchReference, resolveMethodOnType, matchByQualifiedName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher';
import { matchReference, resolveMethodOnType, matchByQualifiedName, matchByExactName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher';
import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver';
import type { UnresolvedRef } from '../src/resolution/types';
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
@@ -5386,4 +5386,269 @@ in
expect(importedFilePaths('main.nix')).toEqual([]);
});
});
describe('Bindings in a module that exports nothing (#1719)', () => {
it('does not treat documentation headings as package imports', () => {
// Inject the planned Markdown node shape without depending on its extractor.
const heading: Node = {
id: 'heading:vite', name: 'vite', qualifiedName: 'guide.md#vite',
kind: 'module', language: 'markdown' as Node['language'], filePath: 'guide.md',
startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0,
};
const context = {
getNodesByName: () => [heading], getNodesInFile: () => [],
getNodesByQualifiedName: () => [], getNodesByKind: () => [],
fileExists: () => false, readFile: () => null,
getProjectRoot: () => tempDir, getAllFiles: () => [],
} as ResolutionContext;
const ref: UnresolvedRef = {
fromNodeId: 'file:consumer.ts', referenceName: 'vite', referenceKind: 'imports',
filePath: 'consumer.ts', language: 'typescript', line: 1, column: 0,
};
expect(matchByExactName(ref, context)).toBeNull();
expect(matchByExactName({ ...ref, language: 'markdown' as Node['language'] }, context)?.targetNodeId).toBe(heading.id);
context.getNodesByName = () => [{ ...heading, id: 'fn:vite', kind: 'function', language: 'typescript', filePath: 'vite.ts' }];
expect(matchByExactName(ref, context)?.targetNodeId).toBe('fn:vite');
});
it('ignores export examples in strings and comments when checking module visibility', async () => {
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(path.join(tempDir, 'src/private.js'), [
"import fs from 'node:fs'",
'const example = `',
'export const example = 1',
'`',
'/*',
'export { hidden }',
'*/',
'function hidden() { return fs }',
'hidden()',
].join('\n'));
fs.writeFileSync(path.join(tempDir, 'src/consumer.js'), 'hidden()');
fs.mkdirSync(path.join(tempDir, 'legacy'));
fs.writeFileSync(path.join(tempDir, 'legacy/global.js'), 'function hidden() { return 1 }');
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const hidden = cg.getNodesByKind('function').find((n) => n.name === 'hidden' && n.filePath === 'src/private.js');
expect(hidden).toBeDefined();
const callers = cg.getIncomingEdges(hidden!.id).filter((e) => e.kind === 'calls');
expect(callers.some((e) => cg.getNode(e.source)?.filePath === 'src/consumer.js')).toBe(false);
expect(callers.some((e) => cg.getNode(e.source)?.filePath === 'src/private.js')).toBe(true);
const consumer = cg.getNodesByKind('file').find((n) => n.filePath === 'src/consumer.js');
expect(cg.getOutgoingEdges(consumer!.id).filter((e) => e.kind === 'calls')).toEqual([]);
});
it('does not name-match a method call to another file\'s JSON value', async () => {
fs.writeFileSync(path.join(tempDir, 'data.json'), '{"content": "hello"}');
fs.writeFileSync(path.join(tempDir, 'data.js'), "const content = require('./data.json')\nmodule.exports = { content }\n");
fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'export async function read(page) { return page.frame("main").content() }');
fs.writeFileSync(path.join(tempDir, 'use-data.js'), "import { content } from './data'\nconsole.log(content)\n");
fs.writeFileSync(path.join(tempDir, 'callback.js'), "const callback = require('./handler.js')\nmodule.exports = { callback }\n");
fs.writeFileSync(path.join(tempDir, 'call.js'), 'callback()');
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const content = cg.getNodesByKind('constant').find((n) => n.name === 'content');
expect(content).toBeDefined();
expect(cg.getIncomingEdges(content!.id).filter((e) => e.kind === 'calls')).toEqual([]);
expect(cg.getIncomingEdges(content!.id).some((e) => e.kind === 'imports')).toBe(true);
const callback = cg.getNodesByKind('constant').find((n) => n.name === 'callback');
expect(callback).toBeDefined();
expect(cg.getIncomingEdges(callback!.id).some((e) => e.kind === 'calls')).toBe(true);
});
it('keeps a local file dependency import when a closer private name collides', async () => {
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ dependencies: { 'local-dep': 'file:./dep' } }));
fs.mkdirSync(path.join(tempDir, 'dep'));
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(path.join(tempDir, 'dep/package.json'), JSON.stringify({ name: 'local-dep', main: 'index.js' }));
fs.writeFileSync(path.join(tempDir, 'dep/index.js'), "export const msg = 'local'\n");
fs.writeFileSync(path.join(tempDir, 'src/private.js'), "import fs from 'node:fs'\nconst msg = 'private'\n");
fs.writeFileSync(path.join(tempDir, 'src/consumer.js'), "import { msg } from 'local-dep'\nconsole.log(msg)\n");
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const msg = cg.getNodesByKind('constant').find((n) => n.name === 'msg' && n.filePath === 'dep/index.js');
expect(msg).toBeDefined();
expect(cg.getIncomingEdges(msg!.id).some((e) => e.kind === 'imports')).toBe(true);
});
it('preserves executable CommonJS exports inside nested template interpolations', async () => {
fs.writeFileSync(path.join(tempDir, 'cjs.js'), [
"import fs from 'node:fs'",
'function helper() { return fs }',
'const text = `outer ${`inner ${module.exports = { helper }}`}`',
].join('\n'));
fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'helper()');
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const helper = cg.getNodesByKind('function').find((n) => n.name === 'helper');
expect(helper).toBeDefined();
expect(cg.getIncomingEdges(helper!.id).some((e) =>
e.kind === 'calls' && cg.getNode(e.source)?.filePath === 'consumer.js')).toBe(true);
});
it.each(['export function visible() { return fs }', 'function visible() { return fs }\nexport { visible }'])('preserves real exports after a regex containing a backtick: %s', async (declaration) => {
fs.writeFileSync(path.join(tempDir, 'exported.js'), "import fs from 'node:fs'\nconst re = /`/\nif (fs) /`/.test('text')\nelse /`/.test('other')\nconst make = () => /`/\n" + declaration + '\n');
fs.writeFileSync(path.join(tempDir, 'consumer.js'), 'visible()');
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const visible = cg.getNodesByKind('function').find((n) => n.name === 'visible');
expect(visible).toBeDefined();
expect(cg.getIncomingEdges(visible!.id).some((e) => e.kind === 'calls')).toBe(true);
});
// 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. Only
// `sealed.js` may be filtered; every other file here is a class that must
// NOT be — a classic script (a top-level binding really is a reachable
// global), a CommonJS module, one exporting through `exports["x"]`, an ESM
// file whose export is a later `export { … }` statement (which leaves
// `isExported` false on the declaration's node), and one contributing a
// name through `declare global` while exporting nothing of its own.
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 }
`
);
// `exports["x"]` is a CommonJS export too, and a file declaring globals
// offers them to every other file whether or not it exports anything of
// its own. Both would read as sealed on a test that looked only for
// `export …`, `module.exports` and `exports.x`.
fs.writeFileSync(
path.join(tmpDir, 'bracket.js'),
`import urlp from 'node:url'
function bracketed() {
return urlp
}
exports["bracketed"] = bracketed
`
);
// A module with imports and no export of its own still contributes every
// name in `declare global` to every other file. `plain.ts` is the control
// that makes the assertion mean something: it is the same "import, no
// export" shape holding the same kind of declaration, so the pair differs
// only by the `declare global`, and an assertion on StrayFace alone would
// pass whatever the guard did.
fs.writeFileSync(
path.join(tmpDir, 'ambient.ts'),
`import './later'
declare global {
interface StrayFace {
a: number
}
}
`
);
fs.writeFileSync(
path.join(tmpDir, 'plain.ts'),
`import './later'
interface HiddenFace {
a: number
}
const unused: HiddenFace = { a: 1 }
`
);
// A type annotation is the reference here, so this consumer must be .ts.
fs.writeFileSync(
path.join(tmpDir, 'consumer.ts'),
`const face: StrayFace = { a: 1 }
const hidden: HiddenFace = { a: 2 }
export function use(): number {
return face.a + hidden.a
}
`
);
// Nothing here is bound by an import, so every name is a free reference
// that falls through to exact name matching — the path this rule sits on.
// A bare import would reach that path too, but a bare specifier names a
// package outside the graph, so no project node is the right target for
// it and such a fixture would assert a resolution nothing should make.
fs.writeFileSync(
path.join(tmpDir, 'consumer.js'),
`widget()
gadget()
helper()
parser()
bracketed()
`
);
cg = await CodeGraph.init(tmpDir, { index: true });
cg.resolveReferences();
// Incoming edges rather than callers, so the interfaces are asked the
// same question as the functions: a type annotation is a reference, not
// a call.
const reachedFrom = (consumer: string, name: string): boolean => {
const target = cg
.searchNodes(name, { limit: 10 })
.find((r) => r.node.name === name && r.node.filePath !== consumer);
expect(target, `no node named ${name}`).toBeDefined();
return cg
.getIncomingEdges(target!.node.id)
.some((e) => cg.getNode(e.source)?.filePath === consumer);
};
expect(reachedFrom('consumer.js', 'widget')).toBe(false);
expect(reachedFrom('consumer.js', 'gadget')).toBe(true);
expect(reachedFrom('consumer.js', 'helper')).toBe(true);
expect(reachedFrom('consumer.js', 'parser')).toBe(true);
expect(reachedFrom('consumer.js', 'bracketed')).toBe(true);
expect(reachedFrom('consumer.ts', 'StrayFace')).toBe(true);
expect(reachedFrom('consumer.ts', 'HiddenFace')).toBe(false);
}, 30000);
});
});