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:
co-authored by
Aaron Queen
Colby McHenry
parent
2c251e2c61
commit
bffd50e4f1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user