Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading.
- In a JavaScript or TypeScript monorepo, an import of a workspace package now resolves to the file its `package.json` names in `exports`, `main` or `module`, like `"main": "./src/index.ts"`, and an import of a subpath it exports, like `@acme/core/schema`, to that subpath's file. Before, only an `index` file at the package's root was found, so these imports linked nothing and a call through them could land on an unrelated function with the same name, leaving real callers out of `callers` and tests out of `codegraph affected`. A package whose entry points at build output that isn't in your project resolves as before. Re-index affected projects after upgrading. Thanks @elderbernardi for the report. (#2502)

## [1.6.2] - 2026-10-03

Expand Down
140 changes: 140 additions & 0 deletions __tests__/workspace-package-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* A workspace member's import resolves to the entry its package.json declares
* (`exports`, `main`, `module`), not only to an `index.*` at the member's root
* (#2502). The common TypeScript monorepo layout declares
* `"main": "./src/index.ts"` with no root index, so `import … from '@acme/core'`
* linked nothing and its calls fell back to name matching, where a same-name
* private function elsewhere could take the edge.
*/
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 roots: string[] = [];
let graphs: CodeGraph[] = [];

afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});

/** The files a file's import statements link it to (its module edges, not its calls). */
function moduleImports(graph: CodeGraph, file: string): string[] {
const ids = graph.getNodesInFile(file).map((n) => n.id);
return [...new Set(graph.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'imports' && e.target.startsWith('file:'))
.map((e) => e.target.slice('file:'.length)))].sort();
}

/** The files calling the function `name` defined in `file`. */
function callerFiles(graph: CodeGraph, name: string, file: string): string[] {
const fn = graph.getNodesByKind('function').find((n) => n.name === name && n.filePath === file);
expect(fn, `${name} in ${file}`).toBeDefined();
return [...new Set(graph.getCallers(fn!.id).map((c) => c.node.filePath))].sort();
}

async function index(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ws-entry-'));
roots.push(root);
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}

/** A pnpm workspace whose `@acme/core` member has the given manifest fields. */
function workspace(core: Record<string, unknown>, extra: Record<string, string> = {}): Record<string, string> {
return {
'package.json': '{"name":"repo","private":true}',
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n - "apps/*"\n',
'packages/core/package.json': JSON.stringify({ name: '@acme/core', ...core }),
'packages/core/src/index.ts': 'export function coreFn(): number { return 1; }\n',
'apps/web/package.json': '{"name":"@acme/web"}',
'apps/web/main.ts': "import { coreFn } from '@acme/core';\nexport const run = () => coreFn();\n",
...extra,
};
}

describe('a workspace member import resolves to its declared entry (#2502)', () => {
it('through `main`', async () => {
const graph = await index(workspace({ main: './src/index.ts' }));
expect(moduleImports(graph, 'apps/web/main.ts')).toEqual(['packages/core/src/index.ts']);
}, 120_000);

it('through `exports["."]` or a string `exports`', async () => {
let graph = await index(workspace({ exports: { '.': './src/index.ts' } }));
expect(moduleImports(graph, 'apps/web/main.ts')).toEqual(['packages/core/src/index.ts']);
graph = await index(workspace({ exports: './src/index.ts' }));
expect(moduleImports(graph, 'apps/web/main.ts')).toEqual(['packages/core/src/index.ts']);
}, 120_000);

it('through a conditional `exports`, taking the first target that is in the project', async () => {
// `types` and `default` name build output that isn't there; `import` names the source.
const conditions = { types: './dist/index.d.ts', import: './src/index.ts', default: './dist/index.js' };
let graph = await index(workspace({ exports: { '.': conditions } }));
expect(moduleImports(graph, 'apps/web/main.ts')).toEqual(['packages/core/src/index.ts']);
// Conditions straight under `exports` apply to the package itself.
graph = await index(workspace({ exports: conditions }));
expect(moduleImports(graph, 'apps/web/main.ts')).toEqual(['packages/core/src/index.ts']);
}, 120_000);

it('through a subpath export and a `./*` pattern', async () => {
const graph = await index(workspace(
{ exports: { '.': './src/index.ts', './schema': './src/schema/index.ts', './utils/*': './src/utils/*.ts' } },
{
'packages/core/src/schema/index.ts': "export const caseTable = 'case';\n",
'packages/core/src/utils/strings.ts': 'export function trim(s: string): string { return s.trim(); }\n',
'apps/web/table.ts': "import { caseTable } from '@acme/core/schema';\nexport const tableName = () => caseTable;\n",
'apps/web/text.ts': "import { trim } from '@acme/core/utils/strings';\nexport const clean = (s: string) => trim(s);\n",
},
));
expect(moduleImports(graph, 'apps/web/table.ts')).toEqual(['packages/core/src/schema/index.ts']);
expect(moduleImports(graph, 'apps/web/text.ts')).toEqual(['packages/core/src/utils/strings.ts']);
}, 120_000);

it('so a same-name private function elsewhere no longer takes the callers', async () => {
// The issue's repro: three consumers of `@acme/core`'s `caseStatus`, and an
// unrelated `caseStatus` in the worker that is nearer to two of them.
const graph = await index({
'package.json': '{"name":"repro","private":true}',
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n - "apps/*"\n',
'packages/core/package.json': JSON.stringify({
name: '@acme/core', type: 'module', main: './src/index.ts', exports: { '.': './src/index.ts' },
}),
'packages/core/src/index.ts': "export * from './status.js';\n",
'packages/core/src/status.ts':
"export function caseStatus(input: { open: boolean }): { value: string } {\n return { value: input.open ? 'open' : 'closed' };\n}\n",
'packages/db/package.json': '{"name":"@acme/db","type":"module"}',
'packages/db/src/load.ts':
"import { caseStatus } from '@acme/core';\nexport const loadStatus = (open: boolean) => caseStatus({ open }).value;\n",
'apps/web/package.json': '{"name":"@acme/web","type":"module"}',
'apps/web/lib/list.ts':
"import { caseStatus } from '@acme/core';\nexport const listCases = (cases: { open: boolean }[]) => cases.map((c) => caseStatus(c).value);\n",
'apps/worker/package.json': '{"name":"@acme/worker","type":"module"}',
'apps/worker/src/run.ts':
"import { caseStatus } from '@acme/core';\nexport const run = () => caseStatus({ open: true }).value;\n",
'apps/worker/src/interpret.ts':
'function caseStatus(raw: string): string { return raw.trim(); }\nexport const interpret = (raw: string) => caseStatus(raw);\n',
});
expect(callerFiles(graph, 'caseStatus', 'packages/core/src/status.ts'))
.toEqual(['apps/web/lib/list.ts', 'apps/worker/src/run.ts', 'packages/db/src/load.ts']);
expect(callerFiles(graph, 'caseStatus', 'apps/worker/src/interpret.ts'))
.toEqual(['apps/worker/src/interpret.ts']);
}, 120_000);

it('and falls back to the member directory when the declared entry is not in the project', async () => {
// `main` names build output that isn't indexed; the root `index.ts` still resolves.
const graph = await index(workspace(
{ main: './dist/index.js', exports: { '.': { import: './dist/index.mjs', require: './dist/index.js' } } },
{ 'packages/core/index.ts': "export * from './src/index';\n" },
));
expect(moduleImports(graph, 'apps/web/main.ts')).toEqual(['packages/core/index.ts']);
}, 120_000);
});
8 changes: 4 additions & 4 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,12 +751,12 @@ function resolveAliasedImport(
}

// 1.5 Workspace packages (`@scope/ui/widgets` → `packages/ui/widgets`).
// Resolves a monorepo member import to the member's directory; the
// extension/index permutations below then find its barrel (#629).
// Resolves a monorepo member import to the entry its package.json
// declares (#2502), else to the member's directory, where the
// extension/index permutations find its barrel (#629).
const workspaces = context.getWorkspacePackages?.();
if (workspaces) {
const base = resolveWorkspaceImport(importPath, workspaces);
if (base) {
for (const base of resolveWorkspaceImport(importPath, workspaces) ?? []) {
const hit = tryWithExt(base);
if (hit) return hit;
}
Expand Down
128 changes: 116 additions & 12 deletions src/resolution/workspace-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
* - expands `*`, `?` and `{a,b}` within a folder name and `**` across
* folders (`packages/*`, pnpm's nested `packages/**\/*`), and leaves out
* what a negated glob (`!**\/example`) matches
* - subpath resolution is directory-based (`@scope/ui/sub` → `<ui>/sub`);
* it does NOT yet honour a member's `exports` map or `main` field
* - a member's declared entry (`exports`, then `main` / `module`) and its
* subpath `exports` are tried first; otherwise, or when that file isn't
* in the project (build output), resolution is directory-based
* (`@scope/ui/sub` → `<ui>/sub`)
* - returns null when the project declares no workspaces, so single-
* package repos pay nothing and see no behaviour change.
*/
Expand All @@ -40,9 +42,17 @@ export interface WorkspacePackages {
* the member's real barrel even when it doesn't follow an index-file
* convention — and independent of the CONSUMER's language (a `.ts` file
* importing an `.ets` barrel resolves without `.ets` in the TS candidate
* list). Absent for npm/pnpm members (their index conventions cover it).
* list). ohpm members only; npm/pnpm members use {@link exportsByName}.
*/
entryByName?: Map<string, string>;
/**
* npm/yarn/bun/pnpm member `name` → its package.json entry points: an
* `exports` key (`.`, `./schema`, `./utils/*`) → candidate target files
* relative to projectRoot (posix), in preference order. `.` also lists
* `main` and `module`. A target may name build output that isn't in the
* project, so the resolver tries each and then the member's directory.
*/
exportsByName?: Map<string, Map<string, string[]>>;
}

/**
Expand Down Expand Up @@ -71,6 +81,12 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
}
}

const exportsByName = new Map<string, Map<string, string[]>>();
for (const [name, dir] of byName) {
const entries = readPackageEntries(projectRoot, dir);
if (entries) exportsByName.set(name, entries);
}

// HarmonyOS/OpenHarmony (ArkTS) modular projects: every module's
// oh-package.json5 declares its local siblings as `"data": "file:../../
// core/data"` dependencies, and code then imports the bare name
Expand All @@ -87,7 +103,70 @@ export function loadWorkspacePackages(projectRoot: string): WorkspacePackages |
if (byName.size === 0) return null;

logDebug('workspace packages loaded', { count: byName.size });
return { byName, entryByName: entryByName.size > 0 ? entryByName : undefined };
return {
byName,
entryByName: entryByName.size > 0 ? entryByName : undefined,
exportsByName: exportsByName.size > 0 ? exportsByName : undefined,
};
}

/**
* Conditions tried for a conditional `exports` target, in this order. Others
* (`node`, `browser`, custom ones) are not guessed.
*/
const EXPORT_CONDITIONS = ['types', 'import', 'default', 'require'];

/**
* Read a member's package.json entry points (see
* {@link WorkspacePackages.exportsByName}). Null when it declares none.
*/
function readPackageEntries(projectRoot: string, dirRel: string): Map<string, string[]> | null {
let pkg: { exports?: unknown; main?: unknown; module?: unknown } | null;
try {
pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, dirRel, 'package.json'), 'utf-8'));
} catch {
return null;
}
if (!pkg || typeof pkg !== 'object') return null;

const raw = new Map<string, string[]>();
const exp = pkg.exports;
if (exp && typeof exp === 'object' && !Array.isArray(exp)
&& Object.keys(exp).some((k) => k.startsWith('.'))) {
for (const [key, value] of Object.entries(exp)) {
if (key.startsWith('.')) raw.set(key, exportTargets(value));
}
} else if (exp !== undefined) {
raw.set('.', exportTargets(exp)); // a string, array or conditions for `.`
}
for (const field of [pkg.main, pkg.module]) {
if (typeof field === 'string') raw.set('.', [...(raw.get('.') ?? []), field]);
}

const out = new Map<string, string[]>();
for (const [key, targets] of raw) {
const files = targets.map((t) => toProjectPath(projectRoot, dirRel, t)).filter((t): t is string => !!t);
if (files.length > 0) out.set(key, files);
}
return out.size > 0 ? out : null;
}

/** Flatten an `exports` value (string, fallback array, conditions) to target paths. */
function exportTargets(value: unknown): string[] {
if (typeof value === 'string') return [value];
if (Array.isArray(value)) return value.flatMap(exportTargets);
if (!value || typeof value !== 'object') return [];
const conditions = value as Record<string, unknown>;
return EXPORT_CONDITIONS.filter((c) => c in conditions).flatMap((c) => exportTargets(conditions[c]));
}

/** A member-relative target (`./src/index.ts`) → projectRoot-relative posix, or null if it escapes. */
function toProjectPath(projectRoot: string, dirRel: string, target: string): string | null {
if (!target.trim()) return null;
const abs = path.resolve(projectRoot, dirRel, target.trim());
const memberRel = path.relative(path.resolve(projectRoot, dirRel), abs);
if (memberRel.startsWith('..') || path.isAbsolute(memberRel)) return null;
return path.relative(projectRoot, abs).replace(/\\/g, '/');
}

/**
Expand Down Expand Up @@ -205,16 +284,18 @@ function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> {
}

/**
* Rewrite a bare workspace import to a path relative to projectRoot,
* WITHOUT an extension — the caller applies the language's extension/index
* resolution. `@scope/ui/widgets` → `packages/ui/widgets`; the bare package
* name `@scope/ui` → its directory. Returns `null` when no member package
* name matches.
* Rewrite a bare workspace import to candidate paths relative to projectRoot,
* in the order to try them — the caller applies the language's extension/
* index resolution to each and takes the first that exists. The member's
* declared entry for the import (package.json `exports` / `main` / `module`,
* or ohpm `main`) comes first; then, for npm members, the directory form:
* `@scope/ui/widgets` → `packages/ui/widgets`, bare `@scope/ui` → its
* directory. Returns `null` when no member package name matches.
*/
export function resolveWorkspaceImport(
importPath: string,
ws: WorkspacePackages
): string | null {
): string[] | null {
// Longest matching package name wins, so `@scope/ui/core` prefers a
// `@scope/ui/core` package over a `@scope/ui` one when both exist.
let bestName: string | null = null;
Expand All @@ -231,9 +312,32 @@ export function resolveWorkspaceImport(
// check hits it without extension/index guessing.
if (!subpath) {
const entry = ws.entryByName?.get(bestName);
if (entry) return entry;
if (entry) return [entry];
}
const declared = matchExport('.' + subpath, ws.exportsByName?.get(bestName));
return [...declared, (dir + subpath).replace(/\/{2,}/g, '/')];
}

/**
* The targets an `exports` map gives `key` (`.`, `./schema`): an exact key,
* else the `*` pattern with the longest prefix, its `*` replaced by the match.
*/
function matchExport(key: string, entries: Map<string, string[]> | undefined): string[] {
if (!entries) return [];
const exact = entries.get(key);
if (exact) return exact;
let best: { prefix: string; star: string; targets: string[] } | null = null;
for (const [pattern, targets] of entries) {
const star = pattern.indexOf('*');
if (star === -1 || pattern.indexOf('*', star + 1) !== -1) continue;
const prefix = pattern.slice(0, star);
const suffix = pattern.slice(star + 1);
if (key.length < prefix.length + suffix.length || !key.startsWith(prefix) || !key.endsWith(suffix)) continue;
if (best && prefix.length <= best.prefix.length) continue;
best = { prefix, star: key.slice(prefix.length, key.length - suffix.length), targets };
}
return (dir + subpath).replace(/\/{2,}/g, '/');
if (!best) return [];
return best.targets.filter((t) => t.split('*').length === 2).map((t) => t.replace('*', best!.star));
}

/** Read workspace glob patterns from package.json + pnpm-workspace.yaml. */
Expand Down