|
@@ -11,7 +11,7 @@ import * as os from 'os';
|
|
|
import { CodeGraph } from '../src';
|
|
import { CodeGraph } from '../src';
|
|
|
import { Node, UnresolvedReference } from '../src/types';
|
|
import { Node, UnresolvedReference } from '../src/types';
|
|
|
import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
|
|
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 { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver';
|
|
|
import type { UnresolvedRef } from '../src/resolution/types';
|
|
import type { UnresolvedRef } from '../src/resolution/types';
|
|
|
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
|
|
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
|
|
@@ -5386,4 +5386,269 @@ in
|
|
|
expect(importedFilePaths('main.nix')).toEqual([]);
|
|
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);
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|