Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -201,6 +203,10 @@ 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 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.

- **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.
Expand Down
112 changes: 112 additions & 0 deletions __tests__/exact-match-bare-import-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* 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<string, string>): 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<string[]> {
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'; }`,
// 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');
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);
});
});
131 changes: 131 additions & 0 deletions __tests__/fuzzy-bare-import-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* 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. 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';
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,
localLinkNames?: Set<string>
): ResolutionContext {
return {
getWorkspacePackages: () => (localLinkNames ? { byName: new Map(), localLinkNames } : null),
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');
});

// 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');
},
);

// 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');
});

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');
});
});
61 changes: 61 additions & 0 deletions __tests__/local-import-bindings.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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(); }",
});
});
});
Loading