fix(workspace): discover source entries from scoped static configuration

This commit is contained in:
Abhinav Pandey 2026-09-06 09:20:23 +05:30
parent bf34a89031
commit ae927cd613
No known key found for this signature in database
11 changed files with 1843 additions and 17 deletions

View file

@ -18,6 +18,7 @@
import fs from 'fs/promises';
import path from 'path';
import { createRequire } from 'node:module';
import type Parser from 'tree-sitter';
import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
@ -221,10 +222,24 @@ function admits(scope: WorkspaceScope | null, dir: string): boolean {
// The root package is always itself, workspace or not.
if (dir === '') return true;
if (scope === null) return false;
if (scope.exclude.some((pattern) => globToRegExp(pattern).test(dir))) return false;
// An exclusion covers the directory AND everything under it: `!packages/legacy`
// must keep `packages/legacy/foo` out even when a nested workspace root inside
// the excluded subtree re-declares `packages/*`.
if (scope.exclude.some((pattern) => matchesDirOrAncestor(globToRegExp(pattern), dir)))
return false;
return scope.include.some((pattern) => globToRegExp(pattern).test(dir));
}
function matchesDirOrAncestor(re: RegExp, dir: string): boolean {
let current = dir;
while (current !== '') {
if (re.test(current)) return true;
const slash = current.lastIndexOf('/');
current = slash === -1 ? '' : current.slice(0, slash);
}
return false;
}
/**
* Match one workspace glob.
*
@ -272,9 +287,50 @@ function globToRegExp(pattern: string): RegExp {
* tooling that does not read pnpm's file).
*/
async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | null> {
const patterns: string[] = [];
// Every workspace ROOT in the repo, not just the top level. keycloak keeps its
// JavaScript workspace at `js/pnpm-workspace.yaml`; reading only the repo root
// found no workspace, admitted no package, and `@keycloak/keycloak-ui-shared`
// (780845 raw calls per SHA) resolved 7 times. Patterns from a nested root
// are rebased onto that root so `packages/*` under `js/` admits `js/packages/x`.
//
// Gated, though: a nested root counts only when the repo root declares NO
// workspace (keycloak), or when the nested root's directory is itself admitted
// by the root's scope. Unioning every nested root unconditionally let an
// `examples/*/package.json` starter (turborepo/vite/nuxt templates carry
// `workspaces`) admit its example packages — and, being shallow, outrank the
// real package of the same name — and let a root inside an excluded subtree
// re-admit what the outer `!exclusion` had removed.
const rootPatterns = await readWorkspacePatternsAt(repoRoot);
const rootScope = rootPatterns.length === 0 ? null : toScope(rootPatterns);
const patterns: string[] = [...rootPatterns];
for (const root of await findWorkspaceRoots(repoRoot)) {
if (root === repoRoot) continue;
const prefix = repoRelativeDir(repoRoot, root);
if (rootScope !== null && !admits(rootScope, prefix)) continue;
const rebase = (p: string): string => {
const negated = p.startsWith('!');
const body = negated ? p.slice(1) : p;
const joined = prefix === '' ? body : `${prefix}/${body.replace(/^\.\//, '')}`;
return negated ? `!${joined}` : joined;
};
for (const pattern of await readWorkspacePatternsAt(root)) patterns.push(rebase(pattern));
}
const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json'));
if (patterns.length === 0) return null;
return toScope(patterns);
}
function toScope(patterns: readonly string[]): WorkspaceScope {
return {
include: patterns.filter((p) => !p.startsWith('!')),
exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)),
};
}
/** The workspace patterns declared at ONE directory, all three spellings merged. */
async function readWorkspacePatternsAt(root: string): Promise<string[]> {
const patterns: string[] = [];
const rootManifest = await readJsonFile(path.join(root, 'package.json'));
const workspaces = rootManifest?.workspaces;
if (Array.isArray(workspaces)) {
patterns.push(...workspaces.filter((w): w is string => typeof w === 'string'));
@ -285,20 +341,81 @@ async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | nu
patterns.push(...nested.filter((w): w is string => typeof w === 'string'));
}
}
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yaml'))));
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yml'))));
const lerna = await readJsonFile(path.join(repoRoot, 'lerna.json'));
patterns.push(...(await readYamlPackages(path.join(root, 'pnpm-workspace.yaml'))));
patterns.push(...(await readYamlPackages(path.join(root, 'pnpm-workspace.yml'))));
const lerna = await readJsonFile(path.join(root, 'lerna.json'));
if (Array.isArray(lerna?.packages)) {
patterns.push(...lerna.packages.filter((w): w is string => typeof w === 'string'));
}
return patterns;
}
if (patterns.length === 0) return null;
return {
include: patterns.filter((p) => !p.startsWith('!')),
exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)),
};
/**
* Directories that declare a workspace: the repo root plus any directory (to a
* shallow depth workspace roots sit near the top) holding a
* `pnpm-workspace.yaml`, a `lerna.json`, or a `package.json` with `workspaces`.
*/
const WORKSPACE_ROOT_MAX_DEPTH = 4;
/**
* Bound on the depth-4 directory walk. Generous on purpose: the previous 2,000
* cap tripped silently in readdir order, so WHICH packages existed and which
* imports resolved varied between two checkouts of the same commit. Tripping
* it now warns, so a truncated scan is a logged fact rather than a quiet one.
*/
const WORKSPACE_ROOT_SCAN_MAX_DIRS = 50_000;
/** Directory names whose nested `workspaces` are starters/fixtures, never members. */
const NON_MEMBER_ROOT_DIRS = new Set([
'example',
'examples',
'fixture',
'fixtures',
'template',
'templates',
'sample',
'samples',
]);
async function findWorkspaceRoots(repoRoot: string): Promise<string[]> {
const roots: string[] = [repoRoot];
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
let scanned = 0;
while (scanned < queue.length) {
if (scanned >= WORKSPACE_ROOT_SCAN_MAX_DIRS) {
logger.warn(
`[node] workspace-root scan of ${repoRoot} hit the ${WORKSPACE_ROOT_SCAN_MAX_DIRS}-directory cap; nested workspace roots below it were not considered`,
);
break;
}
const { dir, depth } = queue[scanned++]!;
let entries: import('fs').Dirent[];
try {
entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} catch {
continue;
}
if (dir !== repoRoot) {
const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
let declares =
names.has('pnpm-workspace.yaml') ||
names.has('pnpm-workspace.yml') ||
names.has('lerna.json');
if (!declares && names.has('package.json')) {
const manifest = await readJsonFile(path.join(dir, 'package.json'));
declares = manifest?.workspaces !== undefined && manifest.workspaces !== null;
}
if (declares) roots.push(dir);
}
if (depth >= WORKSPACE_ROOT_MAX_DEPTH) continue;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (NON_MEMBER_ROOT_DIRS.has(entry.name.toLowerCase())) continue;
const child = path.join(dir, entry.name);
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, child)) continue;
queue.push({ dir: child, depth: depth + 1 });
}
}
return roots;
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
@ -334,27 +451,64 @@ async function readYamlPackages(filePath: string): Promise<string[]> {
* declaration, so this is far cheaper than the C# namespace scan next door,
* which reads every `.cs` file.
*/
/**
* Per-repo memo. The TS/JS/Vue scope resolvers and the unresolved-call ledger
* classifier each ask for the same map during one analyze; without this the
* 20k-directory walk ran once per asker (four times on a full run, one of them
* inside the index lock). Invalidated at the start of every `runFullAnalysis`
* so a long-lived server never serves a stale package map across runs.
*/
const workspacePackagesMemo = new Map<string, Promise<NodeWorkspacePackages | null>>();
export function invalidateNodeWorkspacePackages(repoRoot?: string): void {
if (repoRoot === undefined) workspacePackagesMemo.clear();
else workspacePackagesMemo.delete(path.resolve(repoRoot));
}
export async function loadNodeWorkspacePackages(
repoRoot: string,
): Promise<NodeWorkspacePackages | null> {
const key = path.resolve(repoRoot);
const cached = workspacePackagesMemo.get(key);
if (cached !== undefined) return cached;
const pending: Promise<NodeWorkspacePackages | null> = loadNodeWorkspacePackagesUncached(
repoRoot,
).catch((err: unknown) => {
// Evict only OUR entry. If this load was invalidated while in flight and a
// newer load has since been installed under the same key, deleting by key
// alone would evict that one, and every later caller would start another
// full scan instead of joining it.
if (workspacePackagesMemo.get(key) === pending) workspacePackagesMemo.delete(key);
throw err;
});
workspacePackagesMemo.set(key, pending);
return pending;
}
async function loadNodeWorkspacePackagesUncached(
repoRoot: string,
): Promise<NodeWorkspacePackages | null> {
const scope = await loadWorkspaceScope(repoRoot);
const byName = new Map<string, NodeWorkspacePackage>();
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
let dirsScanned = 0;
while (queue.length > 0) {
while (dirsScanned < queue.length) {
if (dirsScanned >= SCAN_MAX_DIRS) {
logger.warn(
`[node] package.json scan of ${repoRoot} hit the ${SCAN_MAX_DIRS}-directory cap; workspace packages below it will not resolve`,
);
break;
}
const { dir, depth } = queue.shift()!;
dirsScanned++;
const { dir, depth } = queue[dirsScanned++]!;
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
// Sorted like findWorkspaceRoots: same-depth name collisions resolve first-wins,
// and readdir order is filesystem-dependent.
entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} catch {
continue;
}
@ -420,10 +574,34 @@ async function readManifest(
const value = parsed[field];
if (typeof value === 'string') push(entries, rebase(value));
}
}
// Entry points that name BUILD OUTPUT (`main: dist/x.js`, `exports: ./dist/…`)
// never match an indexed source file — the package's real source entry has to
// be discovered. keycloak's `@keycloak/keycloak-ui-shared` publishes
// `dist/keycloak-ui-shared.js` and keeps its entry in `vite.config.ts`
// (`build.lib.entry: 'src/main.ts'`); with only the declared fields it had 7
// resolved calls against ~845 raw. Tried in a fixed order, and ONLY appended
// (declared entries keep precedence): `source` / `publishConfig.source`, the
// vite `lib.entry`, then `src/main` / `src/index`. More than one candidate
// that exists on disk is recorded as ambiguous rather than picked — a wrong
// entry binds every import of the package to the wrong file.
// A package whose `exports` map has no `"."` (only subpaths) refuses the bare
// specifier outright; discovery must not manufacture a `src/index` root for it.
const rootlessExports = declaresExports && rootExports.length === 0;
const discovered = rootlessExports
? { entries: [], ambiguous: [], allowConventional: false }
: await discoverSourceEntries(parsed, dir, rebase, packageDir, repoRoot);
for (const entry of discovered.entries) push(entries, entry);
if (!declaresExports && discovered.allowConventional) {
for (const conventional of ['src/index', 'index', 'lib/index']) {
push(entries, joinRepoPath(packageDir, conventional));
}
}
if (discovered.ambiguous.length > 0) {
logger.warn(
`[node] package ${name}: ${discovered.ambiguous.length} candidate source entries (${discovered.ambiguous.join(', ')}) — none adopted; declare \`source\` or a single lib entry`,
);
}
const subpathImports = new Map<string, readonly string[]>();
collectImports(parsed.imports, subpathImports, rebase);
@ -507,6 +685,204 @@ function collectImports(
}
}
/**
* Does a declared entry point at build output rather than source? Output
* directories and minified bundles. A plain `.js` is NOT build output on its
* own `main: "src/index.js"` / `index.js` is a JavaScript package's source,
* and treating every `.js` as output ran discovery for essentially every CJS
* package and could adopt a `src/main` beside the real entry.
*/
function looksLikeBuildOutput(entry: string): boolean {
return /(^|\/)(dist|build|lib|out|esm|cjs|umd)\//.test(entry) || /\.min\.[cm]?js$/.test(entry);
}
async function discoverSourceEntries(
parsed: Record<string, unknown>,
dir: string,
rebase: (raw: string) => string,
packageDir: string,
repoRoot: string,
): Promise<{ entries: string[]; ambiguous: string[]; allowConventional: boolean }> {
const declared: string[] = [];
const exportsRoot = parsed.exports;
if (typeof exportsRoot === 'string') declared.push(exportsRoot);
else if (exportsRoot !== null && typeof exportsRoot === 'object') {
const dot = (exportsRoot as Record<string, unknown>)['.'];
if (typeof dot === 'string') declared.push(dot);
else if (dot !== null && typeof dot === 'object') {
for (const v of Object.values(dot as Record<string, unknown>))
if (typeof v === 'string') declared.push(v);
}
}
for (const field of ['module', 'main']) {
const value = parsed[field];
if (typeof value === 'string') declared.push(value);
}
// Only when every declared entry is build output (or nothing is declared and
// the conventional stems are absent) does discovery run at all.
if (declared.length > 0 && !declared.every(looksLikeBuildOutput))
return { entries: [], ambiguous: [], allowConventional: true };
const candidates: string[] = [];
const source = parsed.source;
if (typeof source === 'string') candidates.push(rebase(source));
const publishConfig = parsed.publishConfig;
if (publishConfig !== null && typeof publishConfig === 'object') {
const ps = (publishConfig as Record<string, unknown>).source;
if (typeof ps === 'string') candidates.push(rebase(ps));
}
let hasViteConfig = false;
for (const cfg of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
try {
const text = await fs.readFile(path.join(dir, cfg), 'utf-8');
hasViteConfig = true;
const entry = await staticViteEntry(text);
if (entry !== null) push(candidates, rebase(entry));
} catch {
/* no such config */
}
}
const existing: string[] = [];
for (const candidate of candidates) {
if (await stemExists(repoRoot, candidate)) push(existing, candidate);
}
// A config we cannot establish is not evidence for a conventional entry.
if (existing.length === 0 && !hasViteConfig) {
for (const conventional of ['src/main', 'src/index']) {
const stem = joinRepoPath(packageDir, conventional);
if (await stemExists(repoRoot, stem)) push(existing, stem);
}
}
const allowConventional = !hasViteConfig && candidates.length === 0;
if (existing.length > 1) return { entries: [], ambiguous: existing, allowConventional };
return { entries: existing, ambiguous: [], allowConventional };
}
/** Read a literal exported build.lib.entry without evaluating repository code. */
async function staticViteEntry(text: string): Promise<string | null> {
// Most manifests need no Vite discovery. Keep native grammars off that path.
const [{ default: TreeSitter }, { default: grammar }, { parseSourceSafe }] = await Promise.all([
import('tree-sitter'),
import('tree-sitter-typescript'),
import('../../tree-sitter/safe-parse.js'),
]);
const parser = new TreeSitter();
parser.setLanguage(grammar.typescript);
const tree = parseSourceSafe(parser, text);
if (tree.rootNode.hasError) return null;
const exports = tree.rootNode.namedChildren.filter(
(node) => node.type === 'export_statement' && node.children.some((c) => c.type === 'default'),
);
if (exports.length !== 1) return null;
let config = exports[0]!.childForFieldName('value');
if (config?.type === 'call_expression') {
if (config.childForFieldName('function')?.text !== 'defineConfig') return null;
// A locally defined helper need not preserve its argument like Vite does.
if (
tree.rootNode
.descendantsOfType(['function_declaration', 'variable_declarator'])
.some((node) => node.childForFieldName('name')?.text === 'defineConfig')
)
return null;
for (const statement of tree.rootNode.namedChildren) {
if (statement.type !== 'import_statement') continue;
if (!statement.descendantsOfType('identifier').some((n) => n.text === 'defineConfig'))
continue;
const source = statement.childForFieldName('source')?.text;
if (source !== "'vite'" && source !== '"vite"') return null;
const identityImport = statement.descendantsOfType('import_specifier').some((specifier) => {
const imported = specifier.childForFieldName('name')?.text;
const local = specifier.childForFieldName('alias')?.text ?? imported;
return imported === 'defineConfig' && local === 'defineConfig';
});
if (!identityImport) return null;
}
const args = config
.childForFieldName('arguments')
?.namedChildren.filter((n) => n.type !== 'comment');
if (args?.length !== 1) return null;
config = args[0]!;
}
for (const key of ['build', 'lib', 'entry']) config = staticObjectProperty(config, key);
if (config?.type !== 'string' || config.namedChildren.some((n) => n.type === 'escape_sequence'))
return null;
return config.text.slice(1, -1);
}
/** Reject computed keys, spreads and duplicate properties that could override a value. */
function staticObjectProperty(
node: Parser.SyntaxNode | null,
key: string,
): Parser.SyntaxNode | null {
if (node?.type !== 'object') return null;
let value: Parser.SyntaxNode | null = null;
for (const member of node.namedChildren) {
if (member.type === 'comment') continue;
if (member.type !== 'pair') return null;
const name = member.childForFieldName('key');
if (name?.type !== 'property_identifier' && name?.type !== 'string') return null;
if (name.type === 'string' && name.namedChildren.some((n) => n.type === 'escape_sequence'))
return null;
const property = name.type === 'string' ? name.text.slice(1, -1) : name.text;
if (property !== key) continue;
if (value !== null) return null;
value = member.childForFieldName('value');
}
return value;
}
/**
* Remove line (`//`) and block comments from JS/TS config text before a regex
* reads it. String contents are preserved (a `//` inside quotes is not a
* comment), so `entry: 'src/index.ts'` survives, as does a comment opener
* written inside a string.
*/
export function stripJsComments(text: string): string {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i]!;
const next = text[i + 1];
if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
let j = i + 1;
while (j < text.length && text[j] !== quote) {
if (text[j] === '\\') j++;
j++;
}
out += text.slice(i, j + 1);
i = j + 1;
} else if (ch === '/' && next === '/') {
const end = text.indexOf('\n', i);
i = end === -1 ? text.length : end;
} else if (ch === '/' && next === '*') {
const end = text.indexOf('*/', i + 2);
i = end === -1 ? text.length : end + 2;
} else {
out += ch;
i++;
}
}
return out;
}
/** A repo-relative stem exists as a source file (with any TS/JS extension). */
// The root is threaded explicitly: a module-level "current root" clobbered
// under two concurrent scans and turned an ambiguity refusal into a confident
// wrong entry (the second repo's root made one candidate "not exist").
async function stemExists(repoRoot: string, stem: string): Promise<boolean> {
const abs = path.join(repoRoot, stem);
for (const ext of ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']) {
try {
const st = await fs.stat(abs + ext);
if (st.isFile()) return true;
} catch {
/* try next */
}
}
return false;
}
/** `"./src/index.ts"` -> `"src/index"`; leaves an extension-less path alone. */
function stripEntryPrefixes(entry: string): string {
const withoutDot = entry.replace(/^\.\//, '').replace(/^\//, '');

View file

@ -17,6 +17,7 @@ import { constants as fsConstants } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { retryRename } from '../storage/fs-atomic.js';
import { acquireIndexLock } from '../storage/index-lock.js';
import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js';
import {
logNameFallbackSummary,
summarizeNameFallback,
@ -1081,6 +1082,9 @@ export async function runFullAnalysis(
// Scope the degraded-parse log throttle to this run (module-level counter
// would otherwise stay saturated on a reused process).
resetDegradedParseCounter();
// The workspace-package memo is per process: this run must see the tree as it
// is now, not as the previous run in a long-lived watch/server process saw it.
invalidateNodeWorkspacePackages(repoPath);
const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg));
const acquireOpts = {

View file

@ -0,0 +1,203 @@
/**
* The workspace-package barrel hop.
*
* A call imported from a workspace package (`@x/ui`) resolves through the
* package's `main`, through three re-export forms, and through a barrel chain
* three levels deep; a call imported through a tsconfig `paths` alias resolves
* too. Those resolutions are pinned here against regression, together with the
* one shape the resolver must REFUSE: two `export *` sources publishing the
* same name (a star-vs-star collision) is recorded as `reexport-ambiguous`
* naming both candidates, and the name appears in the run's census.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
import { summarizeNameFallback } from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js';
describe('workspace-package barrel hop', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1c-barrel-'));
writeFixtureRepo(repoDir, {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json':
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
// Three re-export forms in one barrel, plus a nested barrel chain.
'packages/ui/src/index.ts': `export { Button } from './components/Button/Button';
export * from './components/Stack';
export type { ButtonProps } from './components/Button/Button';
export * from './themes';
`,
'packages/ui/src/components/Button/Button.tsx': `export interface ButtonProps { label: string }
export function Button(props: ButtonProps) {
return props.label;
}
`,
'packages/ui/src/components/Stack/index.ts': `export * from './Stack';\n`,
'packages/ui/src/components/Stack/Stack.tsx': `export function Stack(children: string) {
return children;
}
`,
// themes -> hooks -> useStyles2: three barrels deep.
'packages/ui/src/themes/index.ts': `export * from './hooks';\n`,
'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`,
'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) {
return fn('theme');
}
`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/tsconfig.json': `{
"compilerOptions": {
"jsx": "react-jsx",
"baseUrl": "src",
"paths": { "app/core/*": ["core/*"] }
}
}
`,
'packages/app/src/core/utils/format.ts': `export function formatTitle(raw: string) {
return raw.trim();
}
`,
// Calls AND JSX through the same imported names.
'packages/app/src/features/Panel.tsx': `import { Button, Stack, useStyles2 } from '@x/ui';
import { formatTitle } from 'app/core/utils/format';
export function Panel() {
const styles = useStyles2((t) => t);
const title = formatTitle(' hi ');
const label = Button({ label: title });
const stacked = Stack(label);
return <Stack><Button label={styles + stacked} /></Stack>;
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 180000);
afterAll(() => {
if (repoDir !== undefined) {
fs.rmSync(repoDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it('resolves a call through the package barrel to its real definition file', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'Button',
);
expect(edge?.targetFilePath).toBe('packages/ui/src/components/Button/Button.tsx');
});
it('resolves through an `export *` re-export', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'Stack',
);
expect(edge?.targetFilePath).toBe('packages/ui/src/components/Stack/Stack.tsx');
});
it('resolves through a barrel chain three levels deep', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'useStyles2',
);
expect(edge?.targetFilePath).toBe('packages/ui/src/themes/hooks/useStyles2.ts');
});
it('resolves through a tsconfig `paths` alias', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'Panel' && c.target === 'formatTitle',
);
expect(edge?.targetFilePath).toBe('packages/app/src/core/utils/format.ts');
});
});
/**
* `export * from './a'; export * from './b'` where both files declare `collide`
* is ambiguous: the language names no winner (ECMAScript excludes the name from
* the module's exports). The resolver used to pick the first-listed source and
* publish the edge as `import-resolved` at 0.85 a definite target for a call
* that has none, the "incorrect context is worse than missing context" failure
* in its purest form.
*
* Fixed in the shared finalize pass (`collectAmbiguousWildcards`): the name is
* refused in BOTH places it used to win the barrel's re-export closure and
* the barrel's own wildcard-expanded module scope and reported through
* `FinalizeStats.ambiguousWildcardExports`, which the pipeline records as a
* `reexport-ambiguous` resolution outcome. The importer stays unresolved
* a missing edge, never a wrong one.
*/
describe('ambiguous `export *` collision is refused, not guessed', () => {
let result: PipelineResult;
let repoDir: string | undefined;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws1c-ambig-'));
writeFixtureRepo(repoDir, {
'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n',
'packages/ui/package.json':
'{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n',
'packages/ui/src/index.ts': `export * from './a';\nexport * from './b';\nexport * from './c';\n`,
'packages/ui/src/a.ts': `export function collide() { return 'a'; }\nexport function onlyA() { return 1; }\n`,
'packages/ui/src/b.ts': `export function collide() { return 'b'; }\n`,
'packages/ui/src/c.ts': `export function onlyC() { return 3; }\n`,
'packages/app/package.json':
'{ "name": "@x/app", "version": "1.0.0", "main": "src/m.ts", "dependencies": { "@x/ui": "1.0.0" } }\n',
'packages/app/src/m.ts': `import { collide, onlyA, onlyC } from '@x/ui';
export function useIt() { onlyA(); onlyC(); return collide(); }
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 180000);
afterAll(() => {
if (repoDir !== undefined) {
fs.rmSync(repoDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it('emits NO CALLS edge for the colliding name', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'useIt' && c.target === 'collide',
);
expect(edges).toEqual([]);
});
it('still resolves the names that do NOT collide through the same barrel', () => {
const targets = getRelationships(result, 'CALLS')
.filter((c) => c.source === 'useIt')
.map((c) => c.target)
.sort();
expect(targets).toEqual(['onlyA', 'onlyC']);
});
it('records the refusal as a `reexport-ambiguous` outcome naming both candidates', () => {
const refused = result.resolutionOutcomes.filter(
(o) => o.kind === 'reexport-ambiguous' && o.name === 'collide',
);
expect(refused).toHaveLength(1);
const [outcome] = refused;
expect(outcome!.kind === 'reexport-ambiguous' && outcome!.filePath).toBe(
'packages/ui/src/index.ts',
);
expect(outcome!.kind === 'reexport-ambiguous' ? outcome!.candidateIds.length : 0).toBe(2);
});
// The census persists the refused barrel name itself, not just a count. This
// is the real star-vs-star collision the pipeline produced (not a hand-built
// `ResolutionOutcome`), closing the loop from the `reexport-ambiguous`
// outcome through to the persisted name list.
it('the barrel census names the refused collision in `ambiguousReexportNames`', () => {
const summary = summarizeNameFallback(result.resolutionOutcomes);
expect(summary?.totalAmbiguousReexports).toBe(1);
expect(summary?.ambiguousReexportNames).toEqual(['packages/ui/src/index.ts:collide']);
});
});

View file

@ -0,0 +1,84 @@
/**
* `runFullAnalysis` must invalidate the per-process workspace-package memo
* BEFORE it takes the index lock, so a long-lived watch/server process never
* resolves the second analyze's imports against the first analyze's package
* map (a changed `package.json`, a new workspace member, or a moved entry point
* would otherwise bind to the old file a confident wrong edge).
*
* Delegating mocks: the real implementations run; the spies only record order.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { execSync } from 'child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
type Workspace =
typeof import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
type Lock = typeof import('../../src/storage/index-lock.js');
const ctx = vi.hoisted(() => ({
invalidate: vi.fn(),
acquire: vi.fn(),
}));
vi.mock(
'../../src/core/ingestion/import-resolvers/node-workspace-packages.js',
async (importOriginal) => {
const actual = await importOriginal<Workspace>();
ctx.invalidate.mockImplementation(actual.invalidateNodeWorkspacePackages);
return { ...actual, invalidateNodeWorkspacePackages: ctx.invalidate };
},
);
vi.mock('../../src/storage/index-lock.js', async (importOriginal) => {
const actual = await importOriginal<Lock>();
ctx.acquire.mockImplementation(actual.acquireIndexLock);
return { ...actual, acquireIndexLock: ctx.acquire };
});
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { createTempDir } from '../helpers/test-db.js';
describe('runFullAnalysis invalidates the workspace-package memo before the lock', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
beforeEach(async () => {
tmpHome = await createTempDir('gn-ws-memo-home-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
ctx.invalidate.mockClear();
ctx.acquire.mockClear();
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
});
it('calls invalidateNodeWorkspacePackages(repoPath) and does so before acquireIndexLock', async () => {
const tmp = await createTempDir('gn-ws-memo-repo-');
const repo = tmp.dbPath;
try {
execSync('git init', { cwd: repo, stdio: 'pipe' });
await fs.writeFile(
path.join(repo, 'a.ts'),
'export function greet(n: string) { return `hi ${n}`; }\nexport function caller() { return greet("x"); }\n',
);
execSync('git add -A && git -c user.name=t -c user.email=t@t commit -m init', {
cwd: repo,
stdio: 'pipe',
});
await runFullAnalysis(repo, {}, { onProgress: () => {} });
expect(ctx.invalidate).toHaveBeenCalled();
const invalidateArgs = ctx.invalidate.mock.calls[0]!;
expect(invalidateArgs[0]).toBe(repo);
expect(ctx.acquire).toHaveBeenCalled();
const firstInvalidate = ctx.invalidate.mock.invocationCallOrder[0]!;
const firstAcquire = ctx.acquire.mock.invocationCallOrder[0]!;
expect(firstInvalidate).toBeLessThan(firstAcquire);
} finally {
await tmp.cleanup();
}
}, 120_000);
});

View file

@ -0,0 +1,115 @@
/**
* Regression (review finding on #3182, node-workspace-packages.ts:474) a
* rejected, already-INVALIDATED load must not evict the newer load memoized
* under the same key. Sequence: load A in flight `invalidate(key)` load B
* installed A rejects. A's handler used to `delete(key)` unconditionally,
* throwing B away so every later caller started another full scan.
*/
import { describe, it, expect, vi, afterAll } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
const ctx = vi.hoisted(() => ({
gateRoot: null as string | null,
reachedResolve: null as (() => void) | null,
reached: null as Promise<void> | null,
releaseGate: null as (() => void) | null,
gate: null as Promise<void> | null,
failNextIgnoreCheck: false,
rootReaddirCalls: 0,
watchedRoot: null as string | null,
}));
ctx.reached = new Promise<void>((resolve) => {
ctx.reachedResolve = resolve;
});
ctx.gate = new Promise<void>((resolve) => {
ctx.releaseGate = resolve;
});
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
const d = (actual as unknown as { default: typeof actual }).default ?? actual;
return {
default: new Proxy(d, {
get(target, prop) {
if (prop === 'readdir') {
return async (p: string, opts: unknown) => {
// Park load A on its FIRST readdir of the repo root; everything
// else — including load B's entire scan — proceeds unmodified.
if (String(p) === ctx.watchedRoot) ctx.rootReaddirCalls++;
if (ctx.gateRoot !== null && String(p) === ctx.gateRoot) {
ctx.gateRoot = null;
ctx.reachedResolve!();
await ctx.gate;
}
return (target.readdir as (p: string, o: unknown) => Promise<unknown>)(p, opts);
};
}
const v = Reflect.get(target, prop, target) as unknown;
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
},
}),
};
});
vi.mock('../../src/config/ignore-service.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/config/ignore-service.js')>();
return {
...actual,
// Called from the scan loop OUTSIDE any try/catch — the one place a
// throw turns into a rejected load promise.
isHardcodedIgnoredDirectoryAtPath: (repoRoot: string, dir: string) => {
if (ctx.failNextIgnoreCheck) {
ctx.failNextIgnoreCheck = false;
throw new Error('injected scan failure');
}
return actual.isHardcodedIgnoredDirectoryAtPath(repoRoot, dir);
},
};
});
describe('node-workspace-packages memo: a rejected invalidated load keeps the newer entry', () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-memo-reject-'));
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(repo, p)), { recursive: true });
fs.writeFileSync(path.join(repo, p), s);
};
w('package.json', JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }));
w('packages/lib/package.json', JSON.stringify({ name: '@m/lib', main: 'src/index.ts' }));
w('packages/lib/src/index.ts', 'export const x = 1;\n');
afterAll(() => {
fs.rmSync(repo, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it('load B survives load A rejecting after invalidation', async () => {
const { loadNodeWorkspacePackages, invalidateNodeWorkspacePackages } =
await import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
const key = path.resolve(repo);
ctx.gateRoot = key;
ctx.watchedRoot = key;
const loadA = loadNodeWorkspacePackages(repo);
await ctx.reached; // A is parked mid-scan
invalidateNodeWorkspacePackages(repo);
const loadB = loadNodeWorkspacePackages(repo);
expect(loadB).not.toBe(loadA);
const packagesB = await loadB; // B completes and is memoized
expect(packagesB?.byName.has('@m/lib') ?? false).toBe(true);
// Now let A resume and blow up.
ctx.failNextIgnoreCheck = true;
ctx.releaseGate!();
await expect(loadA).rejects.toThrow('injected scan failure');
// The memo must still serve B, not start a fresh scan. (`async` re-wraps
// the cached promise, so identity cannot be compared — count scans instead.)
const scansBefore = ctx.rootReaddirCalls;
expect(scansBefore).toBeGreaterThan(0);
const third = await loadNodeWorkspacePackages(repo);
expect(third).toBe(packagesB);
expect(ctx.rootReaddirCalls).toBe(scansBefore);
});
});

View file

@ -0,0 +1,258 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
loadNodeWorkspacePackages,
resolveNodeWorkspaceImport,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
/**
* C3 a workspace declared BELOW the repo root (keycloak: `js/pnpm-workspace.yaml`).
* C4 a package whose `main`/`exports` name build output; the source entry is
* discovered from `source`, `publishConfig.source`, or vite `lib.entry`.
*/
describe('nested workspace roots and non-dist entry discovery', () => {
let dir: string;
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
fs.writeFileSync(path.join(dir, p), s);
};
beforeAll(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c3c4-'));
// Root has NO workspace declaration (a Java repo with a JS subtree).
w('pom.xml', '<project/>');
w('js/pnpm-workspace.yaml', 'packages:\n - "libs/*"\n - "apps/*"\n');
// C4: dist main + vite lib entry → discover src/main.ts
w(
'js/libs/ui-shared/package.json',
JSON.stringify({
name: '@keycloak/keycloak-ui-shared',
main: './dist/keycloak-ui-shared.js',
module: './dist/keycloak-ui-shared.js',
types: './dist/keycloak-ui-shared.d.ts',
}),
);
w(
'js/libs/ui-shared/vite.config.ts',
`export default defineConfig({ build: { lib: { entry: 'src/main.ts', formats: ['es'] } } });\n`,
);
w('js/libs/ui-shared/src/main.ts', 'export const x = 1;\n');
// `source` field wins when present.
w(
'js/libs/with-source/package.json',
JSON.stringify({ name: '@acme/with-source', main: 'dist/index.js', source: 'src/entry.ts' }),
);
w('js/libs/with-source/src/entry.ts', 'export const y = 1;\n');
// Ambiguous: `source` and vite entry name DIFFERENT existing files → refuse both.
w(
'js/libs/ambiguous/package.json',
JSON.stringify({ name: '@acme/ambiguous', main: 'dist/index.js', source: 'src/a.ts' }),
);
w(
'js/libs/ambiguous/vite.config.ts',
`export default { build: { lib: { entry: 'src/b.ts' } } };\n`,
);
w('js/libs/ambiguous/src/a.ts', 'export const a = 1;\n');
w('js/libs/ambiguous/src/b.ts', 'export const b = 1;\n');
// A source `main` is left alone — discovery never runs.
w(
'js/apps/admin/package.json',
JSON.stringify({ name: '@keycloak/admin', main: 'src/index.tsx' }),
);
w('js/apps/admin/src/index.tsx', 'export const z = 1;\n');
// A manifest OUTSIDE the declared workspace is not admitted.
w(
'js/examples/demo/package.json',
JSON.stringify({ name: '@keycloak/demo', main: 'src/index.ts' }),
);
// `source` declared but the file does NOT exist on disk; the vite lib
// entry does. Discovery must fall through to it rather than treating the
// dangling `source` as a second, disagreeing candidate.
w(
'js/libs/source-missing/package.json',
JSON.stringify({
name: '@acme/source-missing',
main: 'dist/index.js',
source: 'src/does-not-exist.ts',
}),
);
w(
'js/libs/source-missing/vite.config.ts',
`export default { build: { lib: { entry: 'src/real.ts' } } };\n`,
);
w('js/libs/source-missing/src/real.ts', 'export const real = 1;\n');
// `source` and `publishConfig.source` name the SAME existing file — two
// candidate strings, one real target. Must NOT read as ambiguous.
w(
'js/libs/dup-source/package.json',
JSON.stringify({
name: '@acme/dup-source',
main: 'dist/index.js',
source: 'src/shared.ts',
publishConfig: { source: 'src/shared.ts' },
}),
);
w('js/libs/dup-source/src/shared.ts', 'export const shared = 1;\n');
// Nothing declared, nothing conventional but BOTH `src/main` and
// `src/index` exist — the fallback tier has no priority order of its
// own, so two existing conventional candidates are ambiguous too.
w(
'js/libs/both-conventional/package.json',
JSON.stringify({ name: '@acme/both-conventional' }),
);
w('js/libs/both-conventional/src/main.ts', 'export const m = 1;\n');
w('js/libs/both-conventional/src/index.ts', 'export const i = 1;\n');
});
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('admits packages declared by a nested pnpm-workspace.yaml', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect([...pkgs!.byName.keys()].sort()).toEqual([
'@acme/ambiguous',
'@acme/both-conventional',
'@acme/dup-source',
'@acme/source-missing',
'@acme/with-source',
'@keycloak/admin',
'@keycloak/keycloak-ui-shared',
]);
expect(pkgs!.byName.has('@keycloak/demo')).toBe(false);
});
it('discovers the vite lib entry when main points at dist', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@keycloak/keycloak-ui-shared')!.entries).toContain(
'js/libs/ui-shared/src/main',
);
});
it('honours `source` when main points at dist', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@acme/with-source')!.entries).toContain(
'js/libs/with-source/src/entry',
);
});
it('refuses when two discovered candidates disagree', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/ambiguous')!.entries;
expect(entries).not.toContain('js/libs/ambiguous/src/a');
expect(entries).not.toContain('js/libs/ambiguous/src/b');
});
it('leaves a source `main` untouched', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@keycloak/admin')!.entries[0]).toBe('js/apps/admin/src/index');
});
it('falls through to the vite entry when `source` names a file that does not exist on disk', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/source-missing')!.entries;
expect(entries).toContain('js/libs/source-missing/src/real');
expect(entries).not.toContain('js/libs/source-missing/src/does-not-exist');
});
it('does not treat `source` and `publishConfig.source` naming the SAME file as ambiguous', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/dup-source')!.entries;
expect(entries).toContain('js/libs/dup-source/src/shared');
});
// Declared entries keep precedence over a discovered one (per the code
// comment: discovered entries are ONLY appended). This is the resolution-
// time consequence of that ordering, not just an entries-array shape check:
// if the declared `dist/*.js` happens to exist among the indexed files
// (e.g. a repo that does not gitignore build output), it is still what a
// bare `import '@keycloak/keycloak-ui-shared'` resolves to — the
// discovered `src/main.ts` entry is only reached when the dist file is
// NOT among the indexed files, which is the common case.
it('a declared dist entry that exists among the indexed files is still used over the discovered source entry', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const allFilesWithDist = new Set([
'js/libs/ui-shared/dist/keycloak-ui-shared.js',
'js/libs/ui-shared/src/main.ts',
]);
expect(resolveNodeWorkspaceImport('@keycloak/keycloak-ui-shared', pkgs, allFilesWithDist)).toBe(
'js/libs/ui-shared/dist/keycloak-ui-shared.js',
);
// Without the dist file indexed (the common case — build output is not
// checked in), resolution falls through to the discovered source entry.
const allFilesSourceOnly = new Set(['js/libs/ui-shared/src/main.ts']);
expect(
resolveNodeWorkspaceImport('@keycloak/keycloak-ui-shared', pkgs, allFilesSourceOnly),
).toBe('js/libs/ui-shared/src/main.ts');
});
// `discoverSourceEntries`'s ambiguity refusal only governs ITS OWN
// candidates (`source` / `publishConfig.source` / vite / its own
// `src/main`-then-`src/index` fallback). It does NOT reach the older,
// separate unconditional `src/index` fallback `readManifest` already adds
// for every package with no `exports` map — so when nothing is declared
// and BOTH `src/main.ts` and `src/index.ts` exist, discovery contributes
// NOTHING (refused as ambiguous, `src/main` never appears), but
// `src/index` still ends up in `entries` anyway, through the unrelated
// unconditional path. Documented here because it means the "no binding on
// ambiguity" guarantee is a discovery-local property, not a package-wide one.
it('an ambiguous discovery still leaves the unconditional src/index fallback standing', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/both-conventional')!.entries;
expect(entries).toContain('js/libs/both-conventional/src/index');
expect(entries).not.toContain('js/libs/both-conventional/src/main');
});
});
describe('workspace root discovery depth cap (WORKSPACE_ROOT_MAX_DEPTH = 4)', () => {
let dir: string;
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
fs.writeFileSync(path.join(dir, p), s);
};
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('finds a workspace root exactly at depth 4, but not one at depth 5', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c3-depth-'));
// No root workspace declaration.
w('README.md', '# root\n');
// depth 4: a/b/c/d/pnpm-workspace.yaml (d is the 4th directory level).
w('a/b/c/d/pnpm-workspace.yaml', 'packages:\n - "pkgs/*"\n');
w('a/b/c/d/pkgs/at-depth-4/package.json', JSON.stringify({ name: '@depth/four' }));
w('a/b/c/d/pkgs/at-depth-4/index.ts', 'export const x = 1;\n');
// depth 5: a/b/c/d/e/pnpm-workspace.yaml — one level too deep to be found.
w('a/b/c/d/e/pnpm-workspace.yaml', 'packages:\n - "pkgs/*"\n');
w('a/b/c/d/e/pkgs/at-depth-5/package.json', JSON.stringify({ name: '@depth/five' }));
w('a/b/c/d/e/pkgs/at-depth-5/index.ts', 'export const y = 1;\n');
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect(pkgs!.byName.has('@depth/four')).toBe(true);
expect(pkgs!.byName.has('@depth/five')).toBe(false);
});
});
describe('a nested `package.json` "workspaces" field (not pnpm-workspace.yaml) is also found as a root', () => {
let dir: string;
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
fs.writeFileSync(path.join(dir, p), s);
};
afterAll(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }));
it('admits packages declared by a nested package.json "workspaces" array', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c3-pkgjson-root-'));
w('go.mod', 'module example.com/root\n');
// Nested JS workspace root declared via `package.json`'s `workspaces`
// field rather than a pnpm-workspace.yaml — the OTHER of the three
// spellings `readWorkspacePatternsAt` merges, exercised here at a non-root directory.
w(
'frontend/package.json',
JSON.stringify({ name: 'frontend-root', private: true, workspaces: ['packages/*'] }),
);
w('frontend/packages/ui/package.json', JSON.stringify({ name: '@fe/ui' }));
w('frontend/packages/ui/index.ts', 'export const x = 1;\n');
// Outside the nested workspace's own pattern scope — must not be admitted.
w('frontend/other/package.json', JSON.stringify({ name: '@fe/other' }));
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect(pkgs!.byName.has('@fe/ui')).toBe(true);
expect(pkgs!.byName.has('@fe/other')).toBe(false);
});
});

View file

@ -0,0 +1,134 @@
/**
* Regression entry discovery must not share mutable state across concurrent
* `loadNodeWorkspacePackages` calls. A module-level repo-root singleton once let
* a second repo's scan interleave with the first's `stemExists` checks, so a
* package with two real candidate source entries (which must be REFUSED as
* ambiguous) was adopted with a single confident, wrong winner. The repo root is
* now threaded explicitly; both candidates must be refused under interleaving.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
const raceCtx = vi.hoisted(() => ({
reachedResolve: null as (() => void) | null,
reached: null as Promise<void> | null,
releaseGate: null as (() => void) | null,
gate: null as Promise<void> | null,
}));
raceCtx.reached = new Promise<void>((resolve) => {
raceCtx.reachedResolve = resolve;
});
raceCtx.gate = new Promise<void>((resolve) => {
raceCtx.releaseGate = resolve;
});
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
const d = (actual as unknown as { default: typeof actual }).default ?? actual;
return {
default: new Proxy(d, {
get(target, prop) {
if (prop === 'stat') {
return async (p: string) => {
// Gate ONLY the stat check for repo A's SECOND package's real
// source file — everything else (including repo B's entire
// scan, and repo A's first package) proceeds unmodified.
if (String(p).includes('pkg2-sentinel')) {
raceCtx.reachedResolve!();
await raceCtx.gate;
}
return (target as typeof import('fs/promises')).stat(p as unknown as never);
};
}
const v = Reflect.get(target, prop, target) as unknown;
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
},
}),
};
});
describe('node-workspace-packages: entry discovery is safe under concurrent scans (regression for a former shared-global (race)', () => {
let repoA: string;
let repoB: string;
const w = (root: string, p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
fs.writeFileSync(path.join(root, p), s);
};
beforeAll(() => {
repoA = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-race-a-'));
repoB = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-race-b-'));
// Repo A: two packages needing entry discovery, package1 SHALLOWER than
// package2 so the BFS visits package1 first (queue is layer-ordered).
w(repoA, 'package.json', JSON.stringify({ name: 'root-a', private: true, workspaces: ['**'] }));
w(
repoA,
'pkg1/package.json',
JSON.stringify({ name: '@race/pkg1', main: 'dist/index.js', source: 'src/real1.ts' }),
);
w(repoA, 'pkg1/src/real1.ts', 'export const one = 1;\n');
// pkg2 declares TWO candidate source fields, both real files. Candidate 1
// (`source`) is checked first — its `stemExists` call is the one whose
// STAT gets gated, but its `abs` path was already computed (correctly,
// against repo A) before the gate blocks the underlying `fs.stat`, so it
// still resolves correctly once released. Candidate 2 (`publishConfig.
// source`) is checked SECOND, in a separate `stemExists` call whose
// `abs` is computed fresh AFTER repo B's scan has already clobbered the
// shared global — that is the call the race corrupts.
w(
repoA,
'pkg1/pkg2-sentinel/package.json',
JSON.stringify({
name: '@race/pkg2',
main: 'dist/index.js',
source: 'src/real2a.ts',
publishConfig: { source: 'src/real2b.ts' },
}),
);
w(repoA, 'pkg1/pkg2-sentinel/src/real2a.ts', 'export const twoA = 1;\n');
w(repoA, 'pkg1/pkg2-sentinel/src/real2b.ts', 'export const twoB = 2;\n');
// Repo B: a single trivial package needing NO discovery at all — its
// scan completes purely on the strength of a declared source `main`,
// (the former shared repo-root global would have been clobbered here).
w(repoB, 'package.json', JSON.stringify({ name: 'root-b', private: true, workspaces: ['*'] }));
w(repoB, 'lib/package.json', JSON.stringify({ name: '@other/lib', main: 'src/index.ts' }));
w(repoB, 'lib/src/index.ts', 'export const b = 1;\n');
});
afterAll(() => {
fs.rmSync(repoA, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
fs.rmSync(repoB, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("repo B finishing mid-scan does not corrupt repo A's in-flight source-entry discovery", async () => {
const { loadNodeWorkspacePackages } =
await import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
const pA = loadNodeWorkspacePackages(repoA);
// Wait until A is blocked right before checking pkg2's real source file.
await raceCtx.reached;
// Run B to completion WHILE A is gated — this is the clobber.
await loadNodeWorkspacePackages(repoB);
raceCtx.releaseGate!();
const pkgsA = await pA;
// pkg1 (checked before the clobber) is unaffected.
expect(pkgsA!.byName.get('@race/pkg1')!.entries).toContain('pkg1/src/real1');
// pkg2 declares TWO real, DIFFERENT source candidates — the correct
// behavior is REFUSAL (both exist, genuinely ambiguous, per the same
// "ambiguous when >1 exists" rule the C4 fixture in
// node-workspace-nested-roots.test.ts pins), so NEITHER should be
// adopted.
//
// With the root threaded per call, candidate 2's existence check uses ITS repo root,
// both candidates are found, and the package is refused as ambiguous.
const pkg2Entries = pkgsA!.byName.get('@race/pkg2')!.entries;
expect(pkg2Entries).not.toContain('pkg1/pkg2-sentinel/src/real2a');
expect(pkg2Entries).not.toContain('pkg1/pkg2-sentinel/src/real2b');
});
});

View file

@ -0,0 +1,330 @@
/**
* Final-review fixes on workspace-package discovery:
* - B2: an `exports` map with no `"."` (rootless) refuses the bare specifier
* discovery must not manufacture a `src/index` root entry for it.
* - M6: nested workspace roots are gated by the outer scope; an outer
* `!exclusion` keeps binding under a nested root; starter/fixture roots
* (`examples/`, `fixtures/`, `templates/`, `samples/`) are never roots.
* - M9: the package map is memoised per repo root within a process and can be
* invalidated explicitly.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
loadNodeWorkspacePackages,
invalidateNodeWorkspacePackages,
resolveNodeWorkspaceImport,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
import { _captureLogger } from '../../src/core/logger.js';
function mkRepo(prefix: string) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
fs.writeFileSync(path.join(dir, p), s);
};
return { dir, w };
}
const rm = (dir: string) =>
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
describe('B2 — rootless `exports` refuses the bare specifier', () => {
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-b2-');
dir = r.dir;
r.w('package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }));
// exports with only a subpath, no main: bare `@repo/subonly` does not resolve in Node.
r.w(
'packages/subonly/package.json',
JSON.stringify({ name: '@repo/subonly', exports: { './feature': './src/feature.ts' } }),
);
r.w('packages/subonly/src/feature.ts', 'export const f = 1;\n');
r.w('packages/subonly/src/index.ts', 'export const trap = 1;\n');
// exports subpath-only PLUS a build-output main: Node ignores `main` when
// `exports` exists, so the root is still refused.
r.w(
'packages/submain/package.json',
JSON.stringify({
name: '@repo/submain',
main: './dist/index.js',
exports: { './feature': './src/feature.ts' },
}),
);
r.w('packages/submain/src/feature.ts', 'export const f = 1;\n');
r.w('packages/submain/src/index.ts', 'export const trap = 1;\n');
// exports WITH a root: resolves as before.
r.w(
'packages/rooted/package.json',
JSON.stringify({ name: '@repo/rooted', exports: { '.': './src/index.ts' } }),
);
r.w('packages/rooted/src/index.ts', 'export const ok = 1;\n');
// exports as a bare STRING — Node's shorthand for `{".": "<string>"}`. The
// walker's `currentSubpath === ''` default treats a top-level string as the
// root export directly.
r.w(
'packages/stringform/package.json',
JSON.stringify({ name: '@repo/stringform', exports: './src/index.ts' }),
);
r.w('packages/stringform/src/index.ts', 'export const ok = 1;\n');
// exports declaring ONLY a subpath PATTERN (`"./*"`), no `"."` at all. Same
// rootless rule as `subonly` — the pattern populates `subpathExports`, the
// bare specifier still refuses.
r.w(
'packages/patternonly/package.json',
JSON.stringify({ name: '@repo/patternonly', exports: { './*': './src/*.ts' } }),
);
r.w('packages/patternonly/src/anything.ts', 'export const a = 1;\n');
r.w('packages/patternonly/src/index.ts', 'export const trap = 1;\n');
});
afterAll(() => rm(dir));
it('a subpath-only exports map yields no root entry (no src/index edge)', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
expect(pkgs!.byName.get('@repo/subonly')?.entries).toEqual([]);
expect(pkgs!.byName.get('@repo/submain')?.entries).toEqual([]);
// The subpath the map DOES declare still resolves.
// Entries are extension-less stems.
expect(pkgs!.byName.get('@repo/subonly')?.subpathExports.get('feature')).toEqual([
'packages/subonly/src/feature',
]);
});
it('a `"."` export still resolves the root', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/rooted')?.entries).toEqual(['packages/rooted/src/index']);
});
it('a string-form `exports` (Node shorthand for `{".": "..."}`) resolves the root', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/stringform')?.entries).toEqual([
'packages/stringform/src/index',
]);
});
it('a pattern-only `exports` (`"./*"`, no `"."`) does NOT fabricate a root — bare specifier still refuses', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const pkg = pkgs!.byName.get('@repo/patternonly');
expect(pkg).toBeDefined();
// No root entry manufactured — this is the same rootless refusal as `subonly`.
expect(pkg!.entries).toEqual([]);
// The pattern itself IS recorded (so `@repo/patternonly/anything` still
// resolves) — the refusal is scoped to the bare specifier only.
expect(pkg!.subpathExports.get('*')).toEqual(['packages/patternonly/src/*']);
expect(
resolveNodeWorkspaceImport(
'@repo/patternonly/anything',
pkgs,
new Set(['packages/patternonly/src/anything.ts']),
),
).toBe('packages/patternonly/src/anything.ts');
});
it('none of the rootless refusals are recorded as ambiguous — a refusal is silent, not a warning', async () => {
const cap = _captureLogger();
try {
invalidateNodeWorkspacePackages(dir);
await loadNodeWorkspacePackages(dir);
} finally {
cap.restore();
}
const text = cap.text();
// The ONLY ambiguity warning this module ever emits names "candidate
// source entries" (discoverSourceEntries) — must never fire for a
// rootlessExports package, since discovery is skipped entirely for them.
expect(text.includes('candidate source entries')).toBe(false);
});
});
describe('M6 — nested workspace roots are gated by the outer scope', () => {
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m6-');
dir = r.dir;
r.w(
'package.json',
JSON.stringify({ name: 'root', workspaces: ['packages/*', '!packages/legacy'] }),
);
r.w('packages/real/package.json', JSON.stringify({ name: '@repo/real', main: 'src/index.ts' }));
r.w('packages/real/src/index.ts', 'export const real = 1;\n');
// Excluded subtree that re-declares a workspace of its own: must stay out.
r.w(
'packages/legacy/package.json',
JSON.stringify({ name: '@repo/legacy', workspaces: ['libs/*'] }),
);
r.w(
'packages/legacy/libs/old/package.json',
JSON.stringify({ name: '@repo/old', main: 'src/index.ts' }),
);
r.w('packages/legacy/libs/old/src/index.ts', 'export const old = 1;\n');
// A starter under examples/ carrying `workspaces`: never a root.
r.w(
'examples/starter/package.json',
JSON.stringify({ name: 'starter', workspaces: ['apps/*'] }),
);
r.w(
'examples/starter/apps/web/package.json',
JSON.stringify({ name: '@repo/real', main: 'src/index.ts' }),
);
r.w('examples/starter/apps/web/src/index.ts', 'export const fake = 1;\n');
// A nested root that IS admitted by the outer scope (packages/*): its members count.
r.w(
'packages/nested/package.json',
JSON.stringify({ name: '@repo/nested', workspaces: ['inner/*'] }),
);
r.w(
'packages/nested/inner/leaf/package.json',
JSON.stringify({ name: '@repo/leaf', main: 'src/index.ts' }),
);
r.w('packages/nested/inner/leaf/src/index.ts', 'export const leaf = 1;\n');
});
afterAll(() => rm(dir));
it('an outer `!exclusion` keeps binding under a nested root inside the excluded subtree', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.has('@repo/legacy')).toBe(false);
expect(pkgs!.byName.has('@repo/old')).toBe(false);
});
it('an examples/ starter never becomes a root, so its name collision cannot outrank the real package', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/real')?.dir).toBe('packages/real');
expect(pkgs!.byName.has('starter')).toBe(false);
for (const pkg of pkgs!.byName.values()) expect(pkg.dir.startsWith('examples/')).toBe(false);
});
it('a nested root admitted by the outer scope contributes its members', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/leaf')?.dir).toBe('packages/nested/inner/leaf');
expect(pkgs!.byName.get('@repo/leaf')?.entries).toContain(
'packages/nested/inner/leaf/src/index',
);
});
});
describe('M9 — per-repo memo', () => {
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m9-');
dir = r.dir;
r.w('package.json', JSON.stringify({ name: 'root', workspaces: ['packages/*'] }));
r.w('packages/a/package.json', JSON.stringify({ name: '@repo/a', main: 'src/index.ts' }));
r.w('packages/a/src/index.ts', 'export const a = 1;\n');
});
afterAll(() => rm(dir));
it('returns the same map for the same root until invalidated', async () => {
invalidateNodeWorkspacePackages(dir);
const first = await loadNodeWorkspacePackages(dir);
const second = await loadNodeWorkspacePackages(dir);
expect(second).toBe(first);
// A package added after the first scan is invisible until invalidation…
fs.mkdirSync(path.join(dir, 'packages/b/src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'packages/b/package.json'),
JSON.stringify({ name: '@repo/b', main: 'src/index.ts' }),
);
fs.writeFileSync(path.join(dir, 'packages/b/src/index.ts'), 'export const b = 1;\n');
expect((await loadNodeWorkspacePackages(dir))!.byName.has('@repo/b')).toBe(false);
// …and visible after it.
invalidateNodeWorkspacePackages(dir);
expect((await loadNodeWorkspacePackages(dir))!.byName.has('@repo/b')).toBe(true);
expect(typeof resolveNodeWorkspaceImport).toBe('function');
});
});
describe('M6 — the outer exclusion covers a nested root declared via pnpm-workspace.yaml too', () => {
// Same shape as the "M6" describe block above (`packages/legacy` re-declares
// its own workspace and must stay excluded), but the nested declaration is
// the OTHER of the two spellings `readWorkspacePatternsAt` merges —
// `pnpm-workspace.yaml` rather than `package.json`'s `workspaces` field —
// exercising the gate against the spelling `findWorkspaceRoots` treats
// identically for "declares a workspace" but differently for `admits()`.
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m6-yaml-');
dir = r.dir;
r.w(
'package.json',
JSON.stringify({ name: 'root', workspaces: ['packages/*', '!packages/legacy'] }),
);
r.w(
'packages/real/package.json',
JSON.stringify({ name: '@repo/real2', main: 'src/index.ts' }),
);
r.w('packages/real/src/index.ts', 'export const real = 1;\n');
// Excluded subtree whose OWN workspace is declared via pnpm-workspace.yaml.
r.w('packages/legacy/package.json', JSON.stringify({ name: '@repo/legacy2' }));
r.w('packages/legacy/pnpm-workspace.yaml', 'packages:\n - "libs/*"\n');
r.w(
'packages/legacy/libs/old/package.json',
JSON.stringify({ name: '@repo/old2', main: 'src/index.ts' }),
);
r.w('packages/legacy/libs/old/src/index.ts', 'export const old = 1;\n');
});
afterAll(() => rm(dir));
it('the pnpm-workspace.yaml-declared nested root inside the excluded subtree is NOT admitted', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
// The excluded package.json itself (packages/legacy) is also outside the
// outer scope, independent of its own nested declaration.
expect(pkgs!.byName.has('@repo/legacy2')).toBe(false);
expect(pkgs!.byName.has('@repo/old2')).toBe(false);
expect(pkgs!.byName.get('@repo/real2')?.dir).toBe('packages/real');
});
});
describe('M6 — name collision: the root package always wins over an admitted nested duplicate', () => {
// `admits(scope, '')` is unconditionally true (`if (dir === '') return true`)
// — the repo root is always itself a package, workspace or not. The BFS in
// `loadNodeWorkspacePackagesUncached` visits shallower directories first
// (queue is depth-ordered), so when the ROOT package and a nested workspace
// member declare the SAME name, "first declaration wins" must mean the root,
// not the shallowest scanned nested match. Documented behavior, not a
// "correct" resolution in any package-manager sense — pnpm/npm would refuse
// to install two packages with the same name at all. What matters here is
// that GitNexus's own winner is deterministic and repeatable.
let dir: string;
beforeAll(() => {
const r = mkRepo('gn-m6-collide-');
dir = r.dir;
r.w(
'package.json',
JSON.stringify({
name: '@repo/dup',
private: true,
workspaces: ['packages/*'],
// `exports: {"."}` keeps `entries` to exactly this one declared stem —
// `main` alone would also pull in the always-appended conventional
// fallbacks (`src/index`, `index`, `lib/index`), which would make the
// "exactly one entry, the root's" assertion below false positive-prone.
exports: { '.': './root-src/index.ts' },
}),
);
r.w('root-src/index.ts', 'export const rootWins = 1;\n');
// A nested, admitted package reusing the SAME name as the root.
r.w(
'packages/dupnested/package.json',
JSON.stringify({ name: '@repo/dup', main: 'src/index.ts' }),
);
r.w('packages/dupnested/src/index.ts', 'export const nestedLoses = 1;\n');
});
afterAll(() => rm(dir));
it('the root package (shallowest, dir === "") wins the name collision, deterministically', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs).not.toBeNull();
const winner = pkgs!.byName.get('@repo/dup');
expect(winner).toBeDefined();
expect(winner!.dir).toBe('');
expect(winner!.entries).toEqual(['root-src/index']);
// Repeated scans (memo invalidated each time) keep picking the same winner.
invalidateNodeWorkspacePackages(dir);
const second = await loadNodeWorkspacePackages(dir);
expect(second!.byName.get('@repo/dup')?.dir).toBe('');
});
});

View file

@ -0,0 +1,171 @@
/**
* M7 `findWorkspaceRoots`'s workspace-ROOT scan (nested `pnpm-workspace.yaml`
* / `lerna.json` / `package.json#workspaces` discovery inside
* `node-workspace-packages.ts`): sorted `readdir` (deterministic) and a
* 50,000-directory cap that warns instead of silently truncating.
*
* Real disk I/O can't cheaply exercise a 50,000-directory tree, so `readdir` /
* `readFile` are intercepted for one virtual root and everything else falls
* through to the real `fs/promises` (same Proxy-over-`importOriginal` shape as
* `node-workspace-repo-root-race.test.ts`), so unrelated code (the logger's
* own init, etc.) is unaffected.
*
* Two things are proven, deliberately kept SEPARATE because they can't both be
* observed through the same signal: a package admitted through a NESTED
* root's declaration sits one directory level BELOW the declaring directory,
* and the wide level needed to trip the 50k root-scan cap already exceeds the
* SEPARATE 20k-directory package-scan cap so a package nested under the
* wide, capped subtree is not a reachable signal for either scan.
*
* 1. The cap trips and warns (`logger.warn`, captured via `_captureLogger`)
* rather than hanging or throwing, however the synthetic `readdir` order
* is shuffled.
* 2. A package admitted through a path that does NOT depend on the wide,
* capped subtree (`members/only`, admitted directly by the repo root's
* OWN declared `workspaces: ['members/*']`) still resolves correctly and
* IDENTICALLY no matter how the wide subtree's `readdir` order is
* shuffled the capped/warned branch does not corrupt or drop unrelated,
* already-resolvable results.
*/
import { describe, it, expect, vi } from 'vitest';
import { _captureLogger } from '../../src/core/logger.js';
const ROOT = '/virtual-gn-m7-repo';
const FILLER_COUNT = 50_010; // > WORKSPACE_ROOT_SCAN_MAX_DIRS (50_000)
// The wide subtree is named to sort AFTER `members`: both scans now read directories in
// sorted order (deterministic), so a capped scan drops whatever sorts last — the
// fixture must not rely on unsorted readdir order to reach `members/only` first.
interface FakeDirent {
readonly name: string;
isDirectory(): boolean;
isFile(): boolean;
}
const dirEnt = (name: string): FakeDirent => ({
name,
isDirectory: () => true,
isFile: () => false,
});
const fileEnt = (name: string): FakeDirent => ({
name,
isDirectory: () => false,
isFile: () => true,
});
/** Fisher-Yates, seeded by a simple LCG so each "shuffle" is reproducible. */
function shuffled<T>(arr: readonly T[], seed: number): T[] {
const out = [...arr];
let s = seed;
const rand = (): number => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
const fillerNames = Array.from(
{ length: FILLER_COUNT },
(_, i) => `d${String(i).padStart(6, '0')}`,
);
// A mutable box the mock factory closes over — flipped per shuffle from
// inside the test, so `vi.mock` (hoisted, registered once) can still serve a
// different `filler` order on each call without `vi.resetModules()`.
const box = vi.hoisted(() => ({ fillerOrder: [] as string[] }));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
const d = (actual as unknown as { default: typeof actual }).default ?? actual;
const relOf = (p: string): string | null =>
p === ROOT ? '' : p.startsWith(`${ROOT}/`) ? p.slice(ROOT.length + 1) : null;
const fakeReaddir = async (dir: string) => {
const rel = relOf(dir);
if (rel === null)
return (d as typeof import('fs/promises')).readdir(
dir as never,
{
withFileTypes: true,
} as never,
);
if (rel === '') return [dirEnt('members'), dirEnt('zzz-filler')];
if (rel === 'members') return [dirEnt('only')];
if (rel === 'members/only') return [fileEnt('package.json')];
if (rel === 'zzz-filler') return box.fillerOrder.map(dirEnt);
// Every filler child (and anything deeper, unreached in practice) is empty.
return [];
};
const fakeReadFile = async (file: string) => {
const rel = relOf(file);
if (rel === null) return (d as typeof import('fs/promises')).readFile(file as never, 'utf-8');
if (rel === 'package.json') {
return JSON.stringify({ name: 'root', private: true, workspaces: ['members/*'] });
}
if (rel === 'members/only/package.json') {
return JSON.stringify({ name: '@repo/only', exports: { '.': './index.ts' } });
}
const err = Object.assign(new Error(`ENOENT: ${file}`), { code: 'ENOENT' });
throw err;
};
return {
default: new Proxy(d, {
get(target, prop) {
if (prop === 'readdir') return fakeReaddir;
if (prop === 'readFile') return fakeReadFile;
const v = Reflect.get(target, prop, target) as unknown;
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
},
}),
};
});
describe('M7 — workspace-root scan: sorted readdir + a cap that warns (50,010-directory synthetic tree)', () => {
it('trips the cap and warns, and the run still completes deterministically for a result outside the capped subtree', async () => {
const { loadNodeWorkspacePackages, invalidateNodeWorkspacePackages } =
await import('../../src/core/ingestion/import-resolvers/node-workspace-packages.js');
const results: {
warned: boolean;
onlyDir: string | undefined;
onlyEntries: readonly string[] | undefined;
}[] = [];
for (const seed of [1, 2]) {
box.fillerOrder = shuffled(fillerNames, seed);
invalidateNodeWorkspacePackages(ROOT);
const cap = _captureLogger();
let pkgs;
try {
pkgs = await loadNodeWorkspacePackages(ROOT);
} finally {
cap.restore();
}
const text = cap.text();
const warned =
text.includes('workspace-root scan') &&
text.includes('50000-directory cap') &&
text.includes(ROOT);
const only = pkgs?.byName.get('@repo/only');
results.push({ warned, onlyDir: only?.dir, onlyEntries: only?.entries });
}
// Both shuffles hit the cap and warned about it.
expect(results[0]!.warned).toBe(true);
expect(results[1]!.warned).toBe(true);
// Both shuffles still admit the package reachable independently of the
// wide/capped `filler` subtree, identically.
expect(results[0]!.onlyDir).toBe('members/only');
expect(results[1]!.onlyDir).toBe('members/only');
expect(results[0]!.onlyEntries).toEqual(['members/only/index']);
expect(results[1]!.onlyEntries).toEqual(results[0]!.onlyEntries);
}, 60_000);
});

View file

@ -0,0 +1,76 @@
/**
* Review finding on #3182 (magyargergo, node-workspace-packages.ts:730): the
* vite `lib.entry` regex took its FIRST match, which could sit inside a comment
* (`// old lib: { entry: 'src/wrong.ts' }`) ahead of the live config. Comments
* are stripped first, and every live `lib.entry` is a candidate so two
* disagreeing ones are refused as ambiguous rather than first-wins.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
loadNodeWorkspacePackages,
stripJsComments,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
describe('stripJsComments', () => {
it('drops line and block comments and keeps string contents intact', () => {
expect(stripJsComments("a; // lib: { entry: 'x' }\nb /* lib: {\n entry: 'y' } */ c")).toBe(
'a; \nb c',
);
expect(stripJsComments("const u = 'http://x/*y'; // c")).toBe("const u = 'http://x/*y'; ");
expect(stripJsComments('const s = "a\\"//b"; x')).toBe('const s = "a\\"//b"; x');
});
});
describe('vite lib.entry discovery ignores comments and refuses disagreeing entries', () => {
let dir: string;
const w = (p: string, s: string) => {
fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
fs.writeFileSync(path.join(dir, p), s);
};
beforeAll(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-vite-comment-'));
w('package.json', JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'] }));
// A commented-out stale entry BEFORE the live one; both files exist.
w(
'packages/commented/package.json',
JSON.stringify({ name: '@acme/commented', exports: { '.': './dist/bundle.js' } }),
);
w(
'packages/commented/vite.config.ts',
`// old lib: { entry: "src/wrong.ts" }\n/* also once: lib: { entry: 'src/wrong.ts' } */\nexport default defineConfig({ build: { lib: { entry: "src/right.ts" } } });\n`,
);
w('packages/commented/src/wrong.ts', 'export const wrong = 1;\n');
w('packages/commented/src/right.ts', 'export const right = 1;\n');
// Two LIVE lib objects that disagree: ambiguous, refuse.
w(
'packages/twolive/package.json',
JSON.stringify({ name: '@acme/twolive', main: 'dist/index.js' }),
);
w(
'packages/twolive/vite.config.ts',
`const a = { lib: { entry: 'src/a.ts' } };\nexport default process.env.X ? a : { build: { lib: { entry: 'src/b.ts' } } };\n`,
);
w('packages/twolive/src/a.ts', 'export const a = 1;\n');
w('packages/twolive/src/b.ts', 'export const b = 1;\n');
});
afterAll(() => {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it('picks the live entry, never the commented one', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/commented')!.entries;
expect(entries).toContain('packages/commented/src/right');
expect(entries).not.toContain('packages/commented/src/wrong');
});
it('refuses when two live lib entries name different existing files', async () => {
const pkgs = await loadNodeWorkspacePackages(dir);
const entries = pkgs!.byName.get('@acme/twolive')!.entries;
expect(entries).not.toContain('packages/twolive/src/a');
expect(entries).not.toContain('packages/twolive/src/b');
});
});

View file

@ -0,0 +1,75 @@
import { afterAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadNodeWorkspacePackages } from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
const roots: string[] = [];
afterAll(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
});
async function entriesFor(config: string, legacy = false): Promise<readonly string[]> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-vite-structure-'));
roots.push(root);
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({
name: '@test/config',
...(legacy ? { main: './dist/bundle.js' } : { exports: { '.': './dist/bundle.js' } }),
}),
);
fs.writeFileSync(path.join(root, 'vite.config.ts'), config);
for (const name of ['wrong', 'right', 'index']) {
fs.writeFileSync(path.join(root, 'src', `${name}.ts`), `export const ${name} = 1;`);
}
return (await loadNodeWorkspacePackages(root))!.byName.get('@test/config')!.entries;
}
describe('Vite discovery follows only the exported static build.lib.entry', () => {
it('refuses conventional legacy main fallbacks when a config is dynamic', async () => {
expect(
await entriesFor(`export default { build: { lib: { entry: dynamic } } };`, true),
).toEqual(['dist/bundle']);
});
it('does not let a conventional legacy entry outrank the explicit Vite entry', async () => {
expect(
await entriesFor(`export default { build: { lib: { entry: 'src/right.ts' } } };`, true),
).toEqual(['dist/bundle', 'src/right']);
});
it.each([
`const old = { lib: { entry: 'src/wrong.ts' } };`,
`const text = "lib: { entry: 'src/wrong.ts' }";`,
'const text = `lib: { entry: "src/wrong.ts" }`;',
])('ignores unrelated config-shaped syntax: %s', async (prefix) => {
const entries = await entriesFor(
`${prefix}\nexport default { build: { lib: { entry: 'src/right.ts' } } };`,
);
expect(entries).toContain('src/right');
expect(entries).not.toContain('src/wrong');
});
it.each([
`const old = { lib: { entry: 'src/wrong.ts' } }; export default {};`,
`export default choose({ build: { lib: { entry: 'src/wrong.ts' } } });`,
`import { defineConfig } from './custom'; export default defineConfig({ build: { lib: { entry: 'src/wrong.ts' } } });`,
`function defineConfig() { return {}; } export default defineConfig({ build: { lib: { entry: 'src/wrong.ts' } } });`,
`export default { build: { lib: { entry: 'src/wrong.ts', ...override } } };`,
`export default { build: { lib: { entry: 'src/wrong.ts' } }, ...override };`,
`export default { build: { lib: { entry: 'src/wrong.ts', ['entry']: dynamic } } };`,
`export default { build: { lib: { entry: dynamic } } };`,
`export default process.env.X ? {} : { build: { lib: { entry: 'src/wrong.ts' } } };`,
])('refuses an unestablished entry without conventional fallback: %s', async (config) => {
expect(await entriesFor(config)).toEqual(['dist/bundle']);
});
it('accepts quoted property names through defineConfig', async () => {
expect(
await entriesFor(
`import { defineConfig } from 'vite'; export default defineConfig({ 'build': { "lib": { entry: 'src/right.ts' } } });`,
),
).toContain('src/right');
});
});