mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 18:23:25 +08:00
Issue 2502: a pnpm (or npm, Yarn, Bun) workspace member that declares its
entry with `main`, `module`, `types` or `exports`, like
`"main": "./src/index.ts"`, has no index file at its root, so
`import { caseStatus } from '@acme/core'` failed and its calls fell back to
name matching, where a same-named private function nearer the caller took
them. Issue 2456: a sibling package another declares as a path dependency
(`"acme-shared": "file:../common"`), with no root `workspaces`, was not
known as a package at all.
Root cause: workspace-packages.ts mapped a member's name to its folder only
(its header said it did not yet honour `exports` or `main`), and the
resolver probed `<folder>/index.*`. Path dependencies were read only from
ohpm manifests.
Fix:
- workspace-packages.ts reads each package's declared entries: `exports`
(subpaths, `*` patterns, fallback arrays, nested conditions, read with
import/require/node/module/default first, other conditions next, types
last) and the root's source/module/main/types/typings. It reads `file:`,
`link:` and `portal:` dependencies from every package.json in the
existing manifest walk: the root manifest's for every file, any other's
for the files under it, as npm installs them.
- The resolver takes the first declared target, `exports` before the
main-style fields, that the index holds or that is build output the
index lacks, read as the source it is built from (dist/, build/, lib/,
out/, es/, esm/, cjs/ mirrored under src/, then without a format folder).
Then the package's folder, as before, and a path into its build output
(`@acme/utils/lib/path`) read as its source. A file a local build left on
disk never wins over an indexed source, and no mapping links nothing.
- Sync: a full reconcile reads the manifests again and, when their digest
moved, re-opens every JavaScript-family reference, as a moved Python root
set does. A file added inside a package re-checks the imports of that
package that landed elsewhere in it. A full index drops the cached map.
Validation:
- __tests__/workspace-package-entries.test.ts (26 tests): 25 fail on main,
the control passes; all pass on the kernel and with CODEGRAPH_KERNEL=0.
Each of 17 rules removed in turn fails at least one test.
- Sync equals a fresh index in 15 CLI scenarios (entries, subpaths, a
member renamed, added or leaving the globs, file: deps added, removed or
retargeted, an entry's file appearing). On real repos: two vben manifest
edits (237 edge rows move) and a vite playground file: edit, synced graph
identical to a fresh index.
- A/B on 14 TS/JS repos and 2 controls: vben +2,955 edges, insomnia +2,222,
webdriverio +2,472, jest +1,700, nuxt +706, memfs +470, vite +251,
t3-turbo +47, social-app +40, vue3-antdv-admin +8, with name-matched
namesakes corrected; nitro, outline, pathe, c12, flask and gin are
byte-identical.
- Cost: fresh index within 3% (median of 3, interleaved); a full sync reads
the manifests, +30 to 160 ms on these monorepos.
Fixes #2502
Fixes #2456
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
737 lines
31 KiB
TypeScript
737 lines
31 KiB
TypeScript
/**
|
|
* JS/TS workspace (monorepo) package resolution.
|
|
*
|
|
* npm / yarn / bun read member packages from the root `package.json`
|
|
* `workspaces` field; pnpm from `pnpm-workspace.yaml`. A cross-package
|
|
* import like `@scope/ui/widgets` is LOCAL to the monorepo, but to a
|
|
* single-package resolver it looks exactly like a third-party npm
|
|
* specifier — so `isExternalImport` flags it external and the
|
|
* consumer↔definition edge is never created. For component barrels
|
|
* (`export { default as X } from './x.svelte'`) that surfaces as a false
|
|
* `0 callers` on a live component (issue #629).
|
|
*
|
|
* This module maps each member package's declared `name` to its
|
|
* directory, and its package.json's declared entries (`exports`, `main`,
|
|
* `module`, `types`) to files, so the resolver can rewrite `@scope/ui` →
|
|
* `packages/ui/src/index.ts` and `@scope/ui/widgets` → the file its
|
|
* `exports` names, or `packages/ui/widgets` with normal extension/index
|
|
* resolution when the manifest names none (#2502).
|
|
*
|
|
* - reads `workspaces` (array OR `{ packages: [...] }`) from package.json,
|
|
* plus a minimal `pnpm-workspace.yaml` `packages:` list
|
|
* - 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
|
|
* - reads the sibling packages a package.json depends on by path
|
|
* (`"acme-shared": "file:../common"`, `link:`, `portal:`), which need no
|
|
* workspace declaration at all: the root manifest's for every file, any
|
|
* other's for the files under it, as npm installs a package's
|
|
* dependencies for that package (#2456)
|
|
* - returns null when the project declares none of these, so single-
|
|
* package repos pay nothing and see no behaviour change.
|
|
*/
|
|
|
|
import { createHash } from 'crypto';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { logDebug } from '../errors';
|
|
|
|
export interface WorkspacePackages {
|
|
/**
|
|
* Package `name` → directory relative to projectRoot (posix): the workspace
|
|
* members, ohpm's `file:` dependencies, and the root package.json's path
|
|
* dependencies.
|
|
*/
|
|
byName: Map<string, string>;
|
|
/**
|
|
* Member package `name` → its declared ENTRY FILE relative to projectRoot
|
|
* (posix), when the member's manifest names one (ohpm's oh-package.json5
|
|
* `"main": "Index.ets"`). Lets a bare `import { X } from "data"` resolve to
|
|
* 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). npm/pnpm packages use `entriesByDir`.
|
|
*/
|
|
entryByName?: Map<string, string>;
|
|
/**
|
|
* The entries an npm/pnpm/yarn/bun package's package.json declares, by the
|
|
* package's directory (see PackageEntries).
|
|
*/
|
|
entriesByDir?: Map<string, PackageEntries>;
|
|
/**
|
|
* The path dependencies a package.json below the root declares: its
|
|
* directory → dependency name → target directory (all projectRoot-relative
|
|
* posix). Seen only from the files under that manifest — a test fixture's
|
|
* `"left-pad": "file:../stub"` is the fixture's own, and `left-pad` stays the
|
|
* npm package for everyone else. The root manifest's are in `byName`.
|
|
*/
|
|
scopedByName?: Map<string, Map<string, string>>;
|
|
}
|
|
|
|
/**
|
|
* What a package.json declares a package's importers reach, as targets
|
|
* relative to projectRoot (posix), best candidate first. A target may name
|
|
* build output the index does not hold; the resolver then reads the source it
|
|
* is built from, where that is clear.
|
|
*/
|
|
export interface PackageEntries {
|
|
/**
|
|
* The `exports` map: `.`, `./utils`, `./schema/*` → targets. A string, an
|
|
* array or a conditions-only `exports` is the `.` entry. A key mapped to
|
|
* `null` (not exported) holds no target.
|
|
*/
|
|
exports?: Map<string, string[]>;
|
|
/**
|
|
* The package root's `source`, `module`, `main`, `types` and `typings`
|
|
* fields, read after `exports` (as TypeScript's older `node` resolution
|
|
* reads them in its place).
|
|
*/
|
|
main?: string[];
|
|
}
|
|
|
|
/** Where an import of a workspace or path-dependency package lands, before any file is probed. */
|
|
export interface WorkspaceImportTargets {
|
|
/**
|
|
* The entries the package declares for the import, best first: what
|
|
* `exports` names for it in condition order, then, for the package root,
|
|
* its `main`-style fields.
|
|
*/
|
|
declared: string[];
|
|
/** The package's directory plus the import's subpath (`packages/ui/widgets`), extension-less. */
|
|
directory: string;
|
|
/** The package's directory. */
|
|
packageDir: string;
|
|
}
|
|
|
|
/**
|
|
* Load workspace member packages for `projectRoot`. Returns `null` when
|
|
* the project declares no workspaces and no package depends on another by
|
|
* path (the common single-package case) — callers then skip all workspace
|
|
* logic.
|
|
*
|
|
* Cheap to call repeatedly only via the resolver's per-instance cache;
|
|
* this function itself touches the filesystem, so the resolver memoises it
|
|
* the same way it does {@link loadProjectAliases} / {@link loadGoModule}.
|
|
*/
|
|
export function loadWorkspacePackages(projectRoot: string): WorkspacePackages | null {
|
|
const byName = new Map<string, string>();
|
|
|
|
const patterns = readWorkspaceGlobs(projectRoot);
|
|
// What a negated glob matches (`!examples/with-nx`, pnpm's `!**/example`) is
|
|
// no member, wherever the glob is listed.
|
|
const excluded = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
|
|
for (const pattern of patterns) {
|
|
if (pattern.startsWith('!')) continue;
|
|
for (const dir of expandWorkspaceGlob(projectRoot, pattern)) {
|
|
if (excluded.some((x) => workspaceGlobMatches(x, dir))) continue;
|
|
const pkgName = readPackageName(path.join(projectRoot, dir));
|
|
// First declaration wins — workspace patterns are tried in order.
|
|
if (pkgName && !byName.has(pkgName)) byName.set(pkgName, dir);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// (`import { CartRepository } from "data"`). Same monorepo problem as npm
|
|
// workspaces, different manifest. An npm package does the same with
|
|
// `"acme-shared": "file:../common"` and no root `workspaces` (#2456).
|
|
const { ohpm, npm } = collectPathDeps(projectRoot);
|
|
const entryByName = new Map<string, string>();
|
|
for (const [name, dir] of ohpm) {
|
|
if (byName.has(name)) continue;
|
|
byName.set(name, dir);
|
|
const entry = readOhpmMain(projectRoot, dir);
|
|
if (entry) entryByName.set(name, entry);
|
|
}
|
|
for (const [name, dir] of npm.get('') ?? []) {
|
|
if (!byName.has(name)) byName.set(name, dir);
|
|
}
|
|
npm.delete('');
|
|
|
|
if (byName.size === 0 && npm.size === 0) return null;
|
|
|
|
// What each npm-style package's manifest declares for its importers.
|
|
const entriesByDir = new Map<string, PackageEntries>();
|
|
const packageDirs = new Set(byName.values());
|
|
for (const names of npm.values()) for (const dir of names.values()) packageDirs.add(dir);
|
|
for (const dir of packageDirs) {
|
|
const entries = readPackageEntries(projectRoot, dir);
|
|
if (entries) entriesByDir.set(dir, entries);
|
|
}
|
|
|
|
logDebug('workspace packages loaded', { count: byName.size, scoped: npm.size, entries: entriesByDir.size });
|
|
return {
|
|
byName,
|
|
entryByName: entryByName.size > 0 ? entryByName : undefined,
|
|
entriesByDir: entriesByDir.size > 0 ? entriesByDir : undefined,
|
|
scopedByName: npm.size > 0 ? npm : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Condition keys an `exports` target is read through first: the ones
|
|
* TypeScript and the bundlers resolve by. Any other condition (`browser`,
|
|
* `development`, a custom `@acme/source`) comes next, and `types`/`typings`
|
|
* last — a declaration file is the package as tsc reads it, the
|
|
* implementation is what its importers run.
|
|
*/
|
|
const RESOLVING_CONDITIONS = new Set(['import', 'require', 'node', 'module', 'module-sync', 'default']);
|
|
const TYPE_CONDITIONS = new Set(['types', 'typings']);
|
|
|
|
/** An `exports` value's targets, best first: a string, a fallback array, or (nested) conditions, by key order within each tier. */
|
|
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 = Object.entries(value as Record<string, unknown>);
|
|
const tier = (key: string): number => (RESOLVING_CONDITIONS.has(key) ? 0 : TYPE_CONDITIONS.has(key) ? 2 : 1);
|
|
return [0, 1, 2].flatMap((t) => conditions.filter(([key]) => tier(key) === t).flatMap(([, v]) => exportTargets(v)));
|
|
}
|
|
|
|
/** The package.json fields an importer of the package root reaches without `exports`, best first. */
|
|
const MAIN_FIELDS = ['source', 'module', 'main', 'types', 'typings'];
|
|
|
|
/** Read a package's declared entries (see PackageEntries); null when its manifest declares none. */
|
|
function readPackageEntries(projectRoot: string, dirRel: string): PackageEntries | null {
|
|
let pkg: Record<string, unknown> | null;
|
|
try {
|
|
pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, dirRel, 'package.json'), 'utf-8')) as Record<string, unknown> | null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!pkg || typeof pkg !== 'object') return null;
|
|
const inPackage = (target: string): string | null => packageTarget(projectRoot, dirRel, target);
|
|
const out: PackageEntries = {};
|
|
const exp = pkg.exports;
|
|
if (exp !== undefined && exp !== null) {
|
|
const exports = new Map<string, string[]>();
|
|
const subpaths = typeof exp === 'object' && !Array.isArray(exp) && Object.keys(exp).some((key) => key.startsWith('.'));
|
|
const entries: Array<[string, unknown]> = subpaths
|
|
? Object.entries(exp as Record<string, unknown>).filter(([key]) => key.startsWith('.'))
|
|
: [['.', exp]];
|
|
for (const [key, value] of entries) {
|
|
exports.set(key, [...new Set(exportTargets(value).map(inPackage).filter((t): t is string => t !== null))]);
|
|
}
|
|
out.exports = exports;
|
|
}
|
|
const main = MAIN_FIELDS
|
|
.map((field) => pkg![field])
|
|
.filter((value): value is string => typeof value === 'string')
|
|
.map(inPackage)
|
|
.filter((t): t is string => t !== null);
|
|
if (main.length > 0) out.main = [...new Set(main)];
|
|
return out.exports || out.main ? out : null;
|
|
}
|
|
|
|
/** A package-relative target (`./src/index.ts`) → projectRoot-relative posix, or null if it leaves the package. */
|
|
function packageTarget(projectRoot: string, dirRel: string, target: string): string | null {
|
|
const trimmed = target.trim();
|
|
if (!trimmed) return null;
|
|
const packageDir = path.resolve(projectRoot, dirRel);
|
|
const abs = path.resolve(packageDir, trimmed);
|
|
const inside = path.relative(packageDir, abs);
|
|
if (inside.startsWith('..') || path.isAbsolute(inside)) return null;
|
|
return path.relative(projectRoot, abs).replace(/\\/g, '/');
|
|
}
|
|
|
|
/**
|
|
* Read an ohpm member's declared entry file: `<dir>/oh-package.json5`'s
|
|
* `main`, normalized to a projectRoot-relative posix path. Null when the
|
|
* manifest or field is missing/escaping.
|
|
*/
|
|
function readOhpmMain(projectRoot: string, dirRel: string): string | null {
|
|
let parsed: unknown;
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
parsed = require('jsonc-parser').parse(
|
|
fs.readFileSync(path.join(projectRoot, dirRel, OHPM_MANIFEST), 'utf-8')
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const main = (parsed as { main?: unknown } | null)?.main;
|
|
if (typeof main !== 'string' || !main.trim()) return null;
|
|
const entryAbs = path.resolve(projectRoot, dirRel, main.trim());
|
|
const entryRel = path.relative(projectRoot, entryAbs).replace(/\\/g, '/');
|
|
if (entryRel.startsWith('..')) return null;
|
|
return entryRel;
|
|
}
|
|
|
|
/**
|
|
* Scan the project for `oh-package.json5` manifests and collect their
|
|
* `file:`-protocol dependencies as workspace members: dep name (what the
|
|
* source imports) → target directory (projectRoot-relative posix).
|
|
*
|
|
* Precision rule: a name declared with DIFFERENT target directories in
|
|
* different manifests (e.g. every sample in a samples monorepo has its own
|
|
* "common") is AMBIGUOUS and dropped entirely — a missing edge beats a wrong
|
|
* cross-module link. Registry dependencies (`@ohos/axios: "^2.0.0"`) don't
|
|
* use `file:` and are ignored, staying external.
|
|
*
|
|
* The same walk reads every package.json's `file:`, `link:` and `portal:`
|
|
* dependencies (#2456), by the directory of the manifest that declares them
|
|
* (`''` for the root): npm installs a package's dependencies for that package,
|
|
* so the files under it are the ones that see them. Only a directory of the
|
|
* project counts, other than the root and nothing under `node_modules` — a
|
|
* tarball or a path into installed packages maps nothing.
|
|
*
|
|
* The walk is bounded (depth + directory budget) and prunes build/dependency
|
|
* dirs; a manifest is parsed only where the walk finds one.
|
|
*/
|
|
const OHPM_MANIFEST = 'oh-package.json5';
|
|
const NPM_MANIFEST = 'package.json';
|
|
const OHPM_WALK_MAX_DEPTH = 6;
|
|
const OHPM_WALK_DIR_BUDGET = 8000;
|
|
const OHPM_SKIP_DIRS = new Set([
|
|
'node_modules', 'oh_modules', '.git', '.codegraph', '.hvigor', '.preview',
|
|
'build', 'dist', 'out', 'oh-package-lock.json5',
|
|
]);
|
|
|
|
function collectPathDeps(projectRoot: string): { ohpm: Map<string, string>; npm: Map<string, Map<string, string>> } {
|
|
const ohpm = new Map<string, string>();
|
|
const ambiguous = new Set<string>();
|
|
const npm = new Map<string, Map<string, string>>();
|
|
|
|
const queue: Array<{ rel: string; depth: number }> = [{ rel: '', depth: 0 }];
|
|
let visited = 0;
|
|
while (queue.length > 0) {
|
|
const { rel, depth } = queue.shift()!;
|
|
if (++visited > OHPM_WALK_DIR_BUDGET) break;
|
|
const abs = path.join(projectRoot, rel);
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const e of entries) {
|
|
if (e.isDirectory()) {
|
|
if (depth >= OHPM_WALK_MAX_DEPTH) continue;
|
|
if (e.name.startsWith('.') || OHPM_SKIP_DIRS.has(e.name)) continue;
|
|
queue.push({ rel: rel ? `${rel}/${e.name}` : e.name, depth: depth + 1 });
|
|
continue;
|
|
}
|
|
if (e.name === NPM_MANIFEST) {
|
|
const deps = new Map<string, string>();
|
|
for (const [name, target] of readNpmPathDeps(path.join(abs, e.name))) {
|
|
const targetAbs = path.resolve(abs, target);
|
|
const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
|
|
if (targetRel === '' || targetRel.startsWith('..') || path.isAbsolute(targetRel)) continue;
|
|
if (/(?:^|\/)node_modules(?:\/|$)/.test(targetRel) || !isDirectory(targetAbs)) continue;
|
|
deps.set(name, targetRel);
|
|
}
|
|
if (deps.size > 0) npm.set(rel, deps);
|
|
continue;
|
|
}
|
|
if (e.name !== OHPM_MANIFEST) continue;
|
|
|
|
const deps = readOhpmFileDeps(path.join(abs, e.name));
|
|
for (const [name, target] of deps) {
|
|
const targetAbs = path.resolve(abs, target);
|
|
const targetRel = path.relative(projectRoot, targetAbs).replace(/\\/g, '/');
|
|
if (targetRel.startsWith('..')) continue; // escapes the project
|
|
const existing = ohpm.get(name);
|
|
if (existing === undefined) {
|
|
if (!ambiguous.has(name)) ohpm.set(name, targetRel);
|
|
} else if (existing !== targetRel) {
|
|
ohpm.delete(name);
|
|
ambiguous.add(name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ohpm, npm };
|
|
}
|
|
|
|
function isDirectory(abs: string): boolean {
|
|
try {
|
|
return fs.statSync(abs).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** A package.json's dependencies on a path (`file:`, `link:`, `portal:`), from every dependency field → [name, target]. */
|
|
function readNpmPathDeps(manifestAbs: string): Array<[string, string]> {
|
|
let pkg: Record<string, unknown> | null;
|
|
try {
|
|
pkg = JSON.parse(fs.readFileSync(manifestAbs, 'utf-8')) as Record<string, unknown> | null;
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!pkg || typeof pkg !== 'object') return [];
|
|
const out: Array<[string, string]> = [];
|
|
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
const deps = pkg[field];
|
|
if (!deps || typeof deps !== 'object') continue;
|
|
for (const [name, value] of Object.entries(deps as Record<string, unknown>)) {
|
|
const m = typeof value === 'string' ? /^(?:file|link|portal):(.+)$/.exec(value.trim()) : null;
|
|
if (m && m[1]!.trim()) out.push([name, m[1]!.trim()]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Parse one oh-package.json5's dependencies → [name, file-target] pairs. */
|
|
function readOhpmFileDeps(manifestAbs: string): Array<[string, string]> {
|
|
const out: Array<[string, string]> = [];
|
|
let parsed: unknown;
|
|
try {
|
|
// JSON5 tolerates comments and trailing commas; jsonc-parser (already a
|
|
// dependency, used by the opencode installer target) handles both.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
parsed = require('jsonc-parser').parse(fs.readFileSync(manifestAbs, 'utf-8'));
|
|
} catch {
|
|
return out;
|
|
}
|
|
const deps = (parsed as { dependencies?: Record<string, unknown> } | null)?.dependencies;
|
|
if (!deps || typeof deps !== 'object') return out;
|
|
for (const [name, value] of Object.entries(deps)) {
|
|
if (typeof value !== 'string' || !value.startsWith('file:')) continue;
|
|
const target = value.slice('file:'.length).trim();
|
|
if (target) out.push([name, target]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 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, or the ohpm entry its manifest names.
|
|
* `fromFile`, the importing file, sees the path dependencies the manifests
|
|
* above it declare. Returns `null` when no package name matches.
|
|
*/
|
|
export function resolveWorkspaceImport(
|
|
importPath: string,
|
|
ws: WorkspacePackages,
|
|
fromFile?: string
|
|
): string | null {
|
|
const hit = matchPackage(importPath, ws, fromFile);
|
|
if (!hit) return null;
|
|
const subpath = importPath.slice(hit.name.length); // '' or '/widgets'
|
|
// A bare member import resolves straight to the member's declared entry
|
|
// file when the ohpm manifest names one — the caller's exact-path check
|
|
// hits it without extension/index guessing.
|
|
const ohpm = subpath ? undefined : ws.entryByName?.get(hit.name);
|
|
return ohpm ?? (hit.dir + subpath).replace(/\/{2,}/g, '/');
|
|
}
|
|
|
|
/**
|
|
* Where an import of a package of the project lands (see
|
|
* WorkspaceImportTargets): what its manifest declares for the import, and
|
|
* the directory form. Null when no package name matches.
|
|
*/
|
|
export function workspaceImportTargets(
|
|
importPath: string,
|
|
ws: WorkspacePackages,
|
|
fromFile?: string
|
|
): WorkspaceImportTargets | null {
|
|
const hit = matchPackage(importPath, ws, fromFile);
|
|
if (!hit) return null;
|
|
const subpath = importPath.slice(hit.name.length); // '' or '/widgets'
|
|
const directory = (hit.dir + subpath).replace(/\/{2,}/g, '/');
|
|
const ohpm = subpath ? undefined : ws.entryByName?.get(hit.name);
|
|
const declared = ohpm ? [ohpm] : declaredEntries(ws.entriesByDir?.get(hit.dir), subpath);
|
|
return { declared, directory, packageDir: hit.dir };
|
|
}
|
|
|
|
/**
|
|
* The package an import names: a path dependency of the nearest manifest
|
|
* above `fromFile` that declares a matching name, else a workspace member or
|
|
* a root path dependency. The longest name wins, so `@scope/ui/core` prefers
|
|
* a `@scope/ui/core` package over a `@scope/ui` one when both exist.
|
|
*/
|
|
function matchPackage(importPath: string, ws: WorkspacePackages, fromFile?: string): { name: string; dir: string } | null {
|
|
const scoped = scopedPackage(importPath, ws, fromFile);
|
|
if (scoped) return scoped;
|
|
const name = longestName(importPath, ws.byName.keys());
|
|
return name === null ? null : { name, dir: ws.byName.get(name)! };
|
|
}
|
|
|
|
/** The path dependency the nearest declaring manifest above `fromFile` maps `importPath` to. */
|
|
function scopedPackage(importPath: string, ws: WorkspacePackages, fromFile?: string): { name: string; dir: string } | null {
|
|
if (!fromFile || !ws.scopedByName) return null;
|
|
let dir = fromFile.replace(/\\/g, '/');
|
|
while (dir !== '') {
|
|
const slash = dir.lastIndexOf('/');
|
|
dir = slash < 0 ? '' : dir.slice(0, slash);
|
|
const names = ws.scopedByName.get(dir);
|
|
if (!names) continue;
|
|
const name = longestName(importPath, names.keys());
|
|
if (name !== null) return { name, dir: names.get(name)! };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function longestName(importPath: string, names: Iterable<string>): string | null {
|
|
let best: string | null = null;
|
|
for (const name of names) {
|
|
if ((importPath === name || importPath.startsWith(name + '/')) && (best === null || name.length > best.length)) best = name;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* The targets a package's manifest declares for `subpath` (`''` or
|
|
* `/widgets`), best first: its `exports` entry, then, for the package root,
|
|
* its `main`-style fields.
|
|
*/
|
|
function declaredEntries(entries: PackageEntries | undefined, subpath: string): string[] {
|
|
if (!entries) return [];
|
|
const exported = entries.exports ? exportedTargets(entries.exports, '.' + subpath) : [];
|
|
return subpath ? exported : [...new Set([...exported, ...(entries.main ?? [])])];
|
|
}
|
|
|
|
/** `exports`' targets for `key`: the exact key's, else the `*` pattern's with the longest prefix, its `*`s filled in. */
|
|
function exportedTargets(exports: Map<string, string[]>, key: string): string[] {
|
|
const exact = exports.get(key);
|
|
if (exact) return exact;
|
|
let best: { prefix: string; match: string; targets: string[] } | null = null;
|
|
for (const [pattern, targets] of exports) {
|
|
const star = pattern.indexOf('*');
|
|
if (star < 0 || pattern.indexOf('*', star + 1) >= 0) 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, match: key.slice(prefix.length, key.length - suffix.length), targets };
|
|
}
|
|
return best ? best.targets.map((t) => t.split('*').join(best!.match)) : [];
|
|
}
|
|
|
|
/**
|
|
* The workspace map as a stable string: every name, scope, directory and
|
|
* declared entry an import can be resolved by. Two loads that agree on it
|
|
* resolve every package import the same way; sync compares it with the one
|
|
* the index was built with to see a package.json edit (#2456, #2502).
|
|
*/
|
|
export function workspaceFingerprint(ws: WorkspacePackages | null): string {
|
|
if (!ws) return 'none';
|
|
const sorted = <V>(map: Map<string, V> | undefined): Array<[string, V]> =>
|
|
map ? [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) : [];
|
|
const canonical = JSON.stringify({
|
|
byName: sorted(ws.byName),
|
|
entryByName: sorted(ws.entryByName),
|
|
scoped: sorted(ws.scopedByName).map(([dir, names]) => [dir, sorted(names)]),
|
|
entries: sorted(ws.entriesByDir).map(([dir, e]) => [dir, sorted(e.exports), e.main ?? []]),
|
|
});
|
|
return createHash('sha256').update(canonical).digest('hex');
|
|
}
|
|
|
|
/** Each map's package folders, outermost first. */
|
|
const PACKAGE_DIRS = new WeakMap<WorkspacePackages, string[]>();
|
|
|
|
/**
|
|
* The folder of the package `file` is in — a workspace member, or a path
|
|
* dependency's target — the outermost when packages nest, or null. Every
|
|
* import of a package by its name lands in its folder, inner packages'
|
|
* files included.
|
|
*/
|
|
export function workspacePackageDirOf(ws: WorkspacePackages, file: string): string | null {
|
|
let dirs = PACKAGE_DIRS.get(ws);
|
|
if (!dirs) {
|
|
const all = new Set(ws.byName.values());
|
|
for (const names of ws.scopedByName?.values() ?? []) for (const dir of names.values()) all.add(dir);
|
|
dirs = [...all].filter((dir) => dir !== '').sort((a, b) => a.length - b.length);
|
|
PACKAGE_DIRS.set(ws, dirs);
|
|
}
|
|
return dirs.find((dir) => file.startsWith(dir + '/')) ?? null;
|
|
}
|
|
|
|
/** Read workspace glob patterns from package.json + pnpm-workspace.yaml. */
|
|
function readWorkspaceGlobs(projectRoot: string): string[] {
|
|
const out: string[] = [];
|
|
|
|
// package.json `workspaces` (npm / yarn / bun): array, or Yarn's
|
|
// `{ packages: [...], nohoist: [...] }` object form.
|
|
try {
|
|
const pkg = JSON.parse(
|
|
fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')
|
|
);
|
|
const ws = pkg?.workspaces;
|
|
if (Array.isArray(ws)) {
|
|
out.push(...ws.filter((w: unknown): w is string => typeof w === 'string'));
|
|
} else if (ws && Array.isArray(ws.packages)) {
|
|
out.push(...ws.packages.filter((w: unknown): w is string => typeof w === 'string'));
|
|
}
|
|
} catch {
|
|
/* no / invalid package.json — not a workspace root */
|
|
}
|
|
|
|
// pnpm-workspace.yaml `packages:` list. Parsed with a minimal line
|
|
// scanner so we don't pull in a YAML dependency.
|
|
try {
|
|
const yaml = fs.readFileSync(path.join(projectRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
|
out.push(...parsePnpmPackages(yaml));
|
|
} catch {
|
|
/* no pnpm-workspace.yaml */
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Minimal pnpm-workspace.yaml `packages:` extractor. Handles the only shape
|
|
* pnpm actually uses:
|
|
* packages:
|
|
* - 'packages/*'
|
|
* - "apps/*"
|
|
* - tools/build
|
|
*/
|
|
function parsePnpmPackages(yaml: string): string[] {
|
|
const out: string[] = [];
|
|
const lines = yaml.split(/\r?\n/);
|
|
let inPackages = false;
|
|
for (const line of lines) {
|
|
if (/^\s*packages\s*:/.test(line)) {
|
|
inPackages = true;
|
|
continue;
|
|
}
|
|
if (inPackages) {
|
|
const item = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
if (item) {
|
|
out.push(item[1]!.replace(/^['"]|['"]$/g, ''));
|
|
continue;
|
|
}
|
|
// A non-list, non-blank line ends the `packages:` block.
|
|
if (line.trim() !== '' && !/^\s/.test(line)) inPackages = false;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Folders no wildcard matches: installed packages. */
|
|
const GLOB_SKIP_DIRS = new Set(['node_modules', 'bower_components']);
|
|
/**
|
|
* Folders a `**` doesn't descend into either, as pnpm's default ignores
|
|
* don't: their fixture packages are no members, and their names (`react`,
|
|
* `lodash`) would make imports of the real package the project's.
|
|
*/
|
|
const GLOBSTAR_SKIP_DIRS = new Set(['test', 'tests']);
|
|
/** How far a `**` reaches below the glob's literal prefix, and how many folders one glob reads. */
|
|
const GLOBSTAR_MAX_DEPTH = 8;
|
|
const GLOB_DIR_BUDGET = 10_000;
|
|
|
|
/**
|
|
* Expand a workspace glob to the member directories it matches, nearest
|
|
* first: `packages/*`, `packages/plugin-*`, `apps/*\/web`, `{apps,libs}/*`,
|
|
* and the nested members of pnpm's `packages/**\/*` or npm's `libs/**\/*`
|
|
* (folo's `@follow/utils` is `packages/internal/utils`, two levels down).
|
|
* A wildcard never matches a dot-folder or one of GLOB_SKIP_DIRS, and a `**`
|
|
* none of GLOBSTAR_SKIP_DIRS; a folder the glob names literally is always
|
|
* entered. Breadth-first with sorted names, so a member nearer the root wins
|
|
* a package name a deeper copy also declares.
|
|
*/
|
|
function expandWorkspaceGlob(projectRoot: string, pattern: string): string[] {
|
|
const norm = pattern.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
const glob = compileGlob(norm);
|
|
const first = glob.findIndex((seg) => seg.wildcard);
|
|
if (first === -1) return [norm]; // exact directory
|
|
|
|
const base = glob.slice(0, first).map((seg) => seg.text).join('/');
|
|
const start = globClosure(glob, new Set([first]));
|
|
// `tools/**` is `tools` too, as npm and pnpm match `tools/**/package.json`.
|
|
const out: string[] = start.has(glob.length) ? [base || '.'] : [];
|
|
let level = [{ rel: base, states: start }];
|
|
let read = 0;
|
|
for (let depth = 0; level.length > 0 && depth <= GLOBSTAR_MAX_DEPTH; depth++) {
|
|
const next: typeof level = [];
|
|
for (const { rel, states } of level) {
|
|
if (++read > GLOB_DIR_BUDGET) {
|
|
logDebug('workspace glob stopped at its folder budget', { pattern, read });
|
|
return out;
|
|
}
|
|
let names: string[];
|
|
try {
|
|
names = fs.readdirSync(path.join(projectRoot, rel), { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
.sort();
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const name of names) {
|
|
const advanced = globStep(glob, states, name, true);
|
|
if (advanced.size === 0) continue;
|
|
const child = rel ? `${rel}/${name}` : name;
|
|
if (advanced.has(glob.length)) out.push(child);
|
|
if ([...advanced].some((state) => state < glob.length)) next.push({ rel: child, states: advanced });
|
|
}
|
|
}
|
|
level = next;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Whether a negated workspace glob's pattern (`**\/example`, `examples/with-*`) matches a member directory. */
|
|
function workspaceGlobMatches(pattern: string, dir: string): boolean {
|
|
const glob = compileGlob(pattern.replace(/\\/g, '/').replace(/\/+$/, ''));
|
|
let states = globClosure(glob, new Set([0]));
|
|
for (const name of dir.split('/').filter((s) => s !== '' && s !== '.')) {
|
|
states = globStep(glob, states, name, false);
|
|
if (states.size === 0) return false;
|
|
}
|
|
return states.has(glob.length);
|
|
}
|
|
|
|
interface GlobSegment {
|
|
text: string;
|
|
/** `**`: any number of folders, none included. */
|
|
globstar: boolean;
|
|
wildcard: boolean;
|
|
re: RegExp | null;
|
|
}
|
|
|
|
/** A glob's folder segments: `*`, `?` and `{a,b}` within one, `**` across them. */
|
|
function compileGlob(pattern: string): GlobSegment[] {
|
|
return pattern.split('/').filter((s) => s !== '' && s !== '.').map((text) => {
|
|
if (text === '**') return { text, globstar: true, wildcard: true, re: null };
|
|
if (!/[*?{]/.test(text)) return { text, globstar: false, wildcard: false, re: null };
|
|
const source = text
|
|
.replace(/[.+^$()|[\]\\]/g, '\\$&')
|
|
.replace(/\*+/g, '[^/]*')
|
|
.replace(/\?/g, '[^/]')
|
|
.replace(/\{([^{}]*)\}/g, (_, alts: string) => `(?:${alts.split(',').join('|')})`);
|
|
return { text, globstar: false, wildcard: true, re: new RegExp(`^${source}$`) };
|
|
});
|
|
}
|
|
|
|
/** The states a folder named `name` leads to from `states`; `walking` skips what pnpm's walk skips. */
|
|
function globStep(glob: GlobSegment[], states: Set<number>, name: string, walking: boolean): Set<number> {
|
|
const next = new Set<number>();
|
|
const hidden = walking && (name.startsWith('.') || GLOB_SKIP_DIRS.has(name));
|
|
for (const i of states) {
|
|
const seg = glob[i];
|
|
if (!seg) continue;
|
|
if (seg.globstar) {
|
|
if (!hidden && !(walking && GLOBSTAR_SKIP_DIRS.has(name))) next.add(i);
|
|
} else if (seg.re ? !hidden && seg.re.test(name) : seg.text === name) {
|
|
next.add(i + 1);
|
|
}
|
|
}
|
|
return globClosure(glob, next);
|
|
}
|
|
|
|
/** `states` with every `**` that can match no folder at all stepped over. */
|
|
function globClosure(glob: GlobSegment[], states: Set<number>): Set<number> {
|
|
for (const i of [...states]) {
|
|
for (let j = i; glob[j]?.globstar; j++) states.add(j + 1);
|
|
}
|
|
return states;
|
|
}
|
|
|
|
/** Read the `name` field from a member directory's package.json. */
|
|
function readPackageName(dirAbs: string): string | null {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(dirAbs, 'package.json'), 'utf-8'));
|
|
return typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|