fix(go, workspace): resolve test siblings and tighten package discovery (#3191)

* fix(go): resolve test helpers through package sibling tables

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

* Address PR review feedback (#3191)

- Align sibling comments with the no-bare-name partition and drop the stale same-dir fallback claim.
- Pin `_test.go` dot-import wildcard augmentation so a revert to nonTestFiles cannot stay green.

Note: pre-existing failure in worker-pool startup crashes in the full vitest suite not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: tighten workspace discovery and index Go sibling bindings

Skip leftover test/ workspace roots and extra Vite configs. Publish
same-package Go names from per-package indexes instead of pairing every file.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abhinav Pandey 2026-09-10 04:22:12 -07:00 committed by GitHub
parent 2220f4d851
commit 79f210c5b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2551 additions and 92 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';
@ -214,6 +215,8 @@ interface WorkspaceScope {
readonly include: readonly string[];
/** `!`-prefixed patterns, with the `!` stripped. */
readonly exclude: readonly string[];
readonly includeRe: readonly RegExp[];
readonly excludeRe: readonly RegExp[];
}
/** Whether `dir` (repo-relative, `''` for the root) is an admitted package. */
@ -221,8 +224,21 @@ 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;
return scope.include.some((pattern) => globToRegExp(pattern).test(dir));
// 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.excludeRe.some((re) => matchesDirOrAncestor(re, dir))) return false;
return scope.includeRe.some((re) => re.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;
}
/**
@ -272,9 +288,69 @@ 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, workspaceRoots] = await Promise.all([
readWorkspacePatternsAt(repoRoot),
findWorkspaceRoots(repoRoot),
]);
const rootScope = rootPatterns.length === 0 ? null : toScope(rootPatterns);
const patterns: string[] = [...rootPatterns];
const admitted: { root: string; prefix: string }[] = [];
for (const root of workspaceRoots) {
if (root === repoRoot) continue;
const prefix = repoRelativeDir(repoRoot, root);
if (rootScope !== null && !admits(rootScope, prefix)) continue;
admitted.push({ root, prefix });
}
const nested = await Promise.all(
admitted.map(async ({ root, prefix }) => {
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;
};
return (await readWorkspacePatternsAt(root)).map(rebase);
}),
);
for (const batch of nested) patterns.push(...batch);
const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json'));
if (patterns.length === 0) return null;
return toScope(patterns);
}
function toScope(patterns: readonly string[]): WorkspaceScope {
const include = patterns.filter((p) => !p.startsWith('!'));
const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
return {
include,
exclude,
includeRe: include.map(globToRegExp),
excludeRe: exclude.map(globToRegExp),
};
}
/** The workspace patterns declared at ONE directory, all three spellings merged. */
async function readWorkspacePatternsAt(root: string): Promise<string[]> {
const [rootManifest, yamlPkgs, ymlPkgs, lerna] = await Promise.all([
readJsonFile(path.join(root, 'package.json')),
readYamlPackages(path.join(root, 'pnpm-workspace.yaml')),
readYamlPackages(path.join(root, 'pnpm-workspace.yml')),
readJsonFile(path.join(root, 'lerna.json')),
]);
const patterns: string[] = [];
const workspaces = rootManifest?.workspaces;
if (Array.isArray(workspaces)) {
patterns.push(...workspaces.filter((w): w is string => typeof w === 'string'));
@ -285,20 +361,114 @@ 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(...yamlPkgs, ...ymlPkgs);
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, or test
* trees never members. A leftover `test/pnpm-workspace.yaml` must not become
* a workspace root when the repo root declares none (the #2953 first-wins
* class). A root `workspaces` glob that lists `test/*` still admits those
* packages through the package.json walk; this set only stops nested-root
* discovery from descending into those names.
*/
const NON_MEMBER_ROOT_DIRS = new Set([
'example',
'examples',
'fixture',
'fixtures',
'template',
'templates',
'sample',
'samples',
'test',
'tests',
'e2e',
'__tests__',
'spec',
'specs',
]);
/**
* Vite's `DEFAULT_CONFIG_FILES` order. The first filename that exists is the
* config Vite loads; leftover siblings are not a second live entry.
*/
const VITE_CONFIG_FILES = [
'vite.config.js',
'vite.config.mjs',
'vite.config.ts',
'vite.config.cjs',
'vite.config.mts',
'vite.config.cts',
] as const;
function isMissingPath(err: unknown): boolean {
return (err as NodeJS.ErrnoException).code === 'ENOENT';
}
async function readDirSorted(dir: string): Promise<import('fs').Dirent[] | null> {
try {
return (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
} catch {
return null;
}
}
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++]!;
const entries = await readDirSorted(dir);
if (entries === null) 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> {
@ -327,6 +497,39 @@ async function readYamlPackages(filePath: string): Promise<string[]> {
}
}
/**
* 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 {
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;
}
/**
* Collect the `package.json` of every ADMITTED workspace package.
*
@ -334,7 +537,7 @@ 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.
*/
export async function loadNodeWorkspacePackages(
async function loadNodeWorkspacePackagesUncached(
repoRoot: string,
): Promise<NodeWorkspacePackages | null> {
const scope = await loadWorkspaceScope(repoRoot);
@ -353,12 +556,10 @@ export async function loadNodeWorkspacePackages(
const { dir, depth } = queue[queueHead++]!;
dirsScanned++;
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
// Sorted: same-depth name collisions resolve first-wins, and readdir order
// is filesystem-dependent.
const entries = await readDirSorted(dir);
if (entries === null) continue;
for (const entry of entries) {
if (entry.isDirectory()) {
@ -421,10 +622,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, 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);
@ -508,6 +733,195 @@ 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);
}
function declaredRootExportStrings(exportsRoot: unknown): string[] {
if (typeof exportsRoot === 'string') return [exportsRoot];
if (exportsRoot === null || typeof exportsRoot !== 'object') return [];
const dot = (exportsRoot as Record<string, unknown>)['.'];
if (typeof dot === 'string') return [dot];
if (dot === null || typeof dot !== 'object') return [];
return Object.values(dot).filter((v): v is string => typeof v === 'string');
}
async function existingStems(repoRoot: string, stems: readonly string[]): Promise<string[]> {
const present = await Promise.all(stems.map((stem) => stemExists(repoRoot, stem)));
const existing: string[] = [];
for (let i = 0; i < stems.length; i++) {
if (present[i]) push(existing, stems[i]!);
}
return existing;
}
async function discoverSourceEntries(
parsed: Record<string, unknown>,
dir: string,
repoRoot: string,
): Promise<{ entries: string[]; ambiguous: string[]; allowConventional: boolean }> {
const packageDir = repoRelativeDir(repoRoot, dir);
const rebase = (raw: string): string => joinRepoPath(packageDir, stripEntryPrefixes(raw));
const declared: string[] = declaredRootExportStrings(parsed.exports);
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_FILES) {
let text: string;
try {
text = await fs.readFile(path.join(dir, cfg), 'utf-8');
} catch (err) {
if (isMissingPath(err)) continue;
// Present but unreadable: Vite still selected this filename.
hasViteConfig = true;
break;
}
hasViteConfig = true;
try {
const entry = await staticViteEntry(text);
if (entry !== null) push(candidates, rebase(entry));
} catch {
// Parse/load failure is not a missing file — do not fall through to a leftover sibling.
}
break;
}
let existing = await existingStems(repoRoot, candidates);
// A config we cannot establish is not evidence for a conventional entry.
if (existing.length === 0 && !hasViteConfig) {
existing = await existingStems(
repoRoot,
['src/main', 'src/index'].map((conventional) => joinRepoPath(packageDir, conventional)),
);
}
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 = unwrapViteDefineConfig(tree, exports[0]!.childForFieldName('value'));
if (config === null) return null;
for (const key of ['build', 'lib', 'entry']) config = staticObjectProperty(config, key);
return staticStringValue(config);
}
/** Unwrap `defineConfig({...})` from Vite; refuse local or re-exported helpers. */
function unwrapViteDefineConfig(
tree: { rootNode: Parser.SyntaxNode },
config: Parser.SyntaxNode | null,
): Parser.SyntaxNode | null {
if (config?.type !== 'call_expression') return config;
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;
if (staticStringValue(statement.childForFieldName('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;
return args[0]!;
}
function staticStringValue(node: Parser.SyntaxNode | null): string | null {
if (node?.type !== 'string' || node.namedChildren.some((n) => n.type === 'escape_sequence'))
return null;
return node.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') {
const property = staticStringValue(name);
if (property === null) return null;
if (property !== key) continue;
} else if (name.text !== key) {
continue;
}
if (value !== null) return null;
value = member.childForFieldName('value');
}
return value;
}
/** 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

@ -4,92 +4,189 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import { expandGoDotImports } from './expand-wildcards.js';
import { goPackageDir, inferGoPackageName } from './package-clause.js';
function isGoTestFile(filePath: string): boolean {
return filePath.endsWith('_test.go');
}
/**
* O(n²×d) where n = files per package, d = defs per file.
* Acceptable for V1 since Go packages are typically small (< 20 files).
* Future optimization: build a namedef inverted index per package to reduce
* to O(n×d).
* The package a `_test.go` file's clause belongs to, given the package names
* the directory's NON-test files declare.
*
* `package foo_test` is the external-test convention ONLY when the directory's
* real package is `foo`; the `_test` suffix is otherwise a legal identifier
* (`package foo_test` in a directory whose non-test files also say `foo_test`).
* Stripping it unconditionally keyed such a package's own internal tests as
* external tests of a non-existent `foo`, so they saw no sibling at all a
* resolution miss on every same-package call. Strip only when the stripped
* name is what the non-test siblings declare; with no non-test sibling to ask
* (a test-only directory) the convention is assumed, as before.
*/
function testFilePackageOf(
declared: string,
nonTestPackagesInDir: ReadonlySet<string> | undefined,
): { readonly pkg: string; readonly external: boolean } {
if (!declared.endsWith('_test') || declared.length <= '_test'.length) {
return { pkg: declared, external: false };
}
const stripped = declared.slice(0, -'_test'.length);
if (nonTestPackagesInDir !== undefined && nonTestPackagesInDir.has(declared)) {
return { pkg: declared, external: false };
}
if (nonTestPackagesInDir === undefined || nonTestPackagesInDir.has(stripped)) {
return { pkg: stripped, external: true };
}
// Neither name is declared by a non-test sibling: keep the clause as written.
return { pkg: declared, external: false };
}
interface IndexedDef {
readonly filePath: string;
readonly ref: BindingRef;
}
/** name → defs, in package file order. */
type NameIndex = Map<string, IndexedDef[]>;
function defBareName(def: SymbolDefinition): string {
return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
}
function appendToIndex(
index: NameIndex,
filePath: string,
defs: readonly SymbolDefinition[],
): void {
for (const def of defs) {
const name = defBareName(def);
if (name === '') continue;
const list = index.get(name) ?? [];
list.push({ filePath, ref: { def, origin: 'namespace' } });
index.set(name, list);
}
}
function publishIndex(
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
index: NameIndex,
receiverPath: string,
receiverModule: ScopeId,
): void {
if (index.size === 0) return;
let scopeBindings = augmentations.get(receiverModule);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(receiverModule, scopeBindings);
}
for (const [name, entries] of index) {
let bucket = scopeBindings.get(name);
const seen =
bucket === undefined ? new Set<string>() : new Set(bucket.map((b) => b.def.nodeId));
for (const entry of entries) {
if (entry.filePath === receiverPath) continue;
if (seen.has(entry.ref.def.nodeId)) continue;
if (bucket === undefined) {
bucket = [];
scopeBindings.set(name, bucket);
}
bucket.push(entry.ref);
seen.add(entry.ref.def.nodeId);
}
}
}
/**
* Publish same-package sibling bindings, including `_test.go` files.
*
* Internal tests (`package foo`) see production and other internal-test names.
* External tests (`package foo_test`) get no bare-name bindings across that
* partition qualified `foo.X` and `import .` stay on the import resolver.
* Non-test files never see test-only helpers (`go build` does not compile them).
*
* Per-package namedef indexes are built in O(n×d). Each receiver walks only
* the partitions it can see, so defs are not re-scanned against every sibling
* file. Binding refs are allocated once and reused across receivers.
*/
export function populateGoPackageSiblings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: { readonly fileContents: ReadonlyMap<string, string> },
): void {
// 0. Filter out test files — Go _test.go files should not contribute
// same-package sibling bindings to non-test files.
const nonTestFiles = parsedFiles.filter((f) => !f.filePath.endsWith('_test.go'));
// 1. Expand dot imports first so subsequent same-package sibling
// augmentation can also see dot-imported names.
expandGoDotImports(nonTestFiles, indexes);
// augmentation can also see dot-imported names. Test files dot-import too.
expandGoDotImports(parsedFiles, indexes);
// 2. Group files by package directory plus package name. Go package
// identity is directory-scoped; repeated `package main` directories
// must not see each other's unqualified names.
const packageByFile = new Map<string, string>();
for (const parsed of nonTestFiles) {
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
// the two passes cannot disagree about a file's package (#2837). The
// no-clause case is reported there; warning twice for one fact would be
// noise.
const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (pkgName !== null) {
packageByFile.set(parsed.filePath, `${goPackageDir(parsed.filePath)}\0${pkgName}`);
}
//
// `_test.go` files join the INTERNAL package's bucket (a `foo_test`
// external test is keyed by `foo`, its `external` flag marking the
// partition so no bare-name bindings cross it), so one bucket holds
// everything the test binary compiles together, and the visibility
// rules below decide who sees whom.
interface SiblingFile {
readonly filePath: string;
readonly defs: readonly SymbolDefinition[];
readonly isTest: boolean;
readonly external: boolean;
}
const filesByPackage = new Map<string, SiblingFile[]>();
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
// the two passes cannot disagree about a file's package (#2837). The
// no-clause case is reported there; warning twice for one fact would be
// noise.
const declaredByFile = new Map<string, string>();
const nonTestPackagesByDir = new Map<string, Set<string>>();
for (const parsed of parsedFiles) {
const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
if (declared === null) continue;
declaredByFile.set(parsed.filePath, declared);
if (isGoTestFile(parsed.filePath)) continue;
const dir = goPackageDir(parsed.filePath);
const names = nonTestPackagesByDir.get(dir) ?? new Set<string>();
names.add(declared);
nonTestPackagesByDir.set(dir, names);
}
for (const parsed of parsedFiles) {
const declared = declaredByFile.get(parsed.filePath);
if (declared === undefined) continue;
const isTest = isGoTestFile(parsed.filePath);
const dir = goPackageDir(parsed.filePath);
const { pkg, external } = isTest
? testFilePackageOf(declared, nonTestPackagesByDir.get(dir))
: { pkg: declared, external: false };
const key = `${dir}\0${pkg}`;
const list = filesByPackage.get(key) ?? [];
list.push({ filePath: parsed.filePath, defs: parsed.localDefs, isTest, external });
filesByPackage.set(key, list);
}
const filesByPackage = new Map<string, { filePath: string; defs: SymbolDefinition[] }[]>();
for (const parsed of nonTestFiles) {
const pkgName = packageByFile.get(parsed.filePath);
if (pkgName === undefined) continue;
const list = filesByPackage.get(pkgName) ?? [];
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs] });
filesByPackage.set(pkgName, list);
}
// 2. Use bindingAugmentations channel per I8
// 3. Use bindingAugmentations channel per I8. Same-package files see ALL
// sibling names (exported and unexported). Cross-package visibility is
// the import resolver's job.
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
for (const [, siblings] of filesByPackage) {
for (const target of siblings) {
const targetModule = indexes.moduleScopes.byFilePath.get(target.filePath);
if (targetModule === undefined) continue;
for (const receiver of siblings) {
if (receiver.filePath === target.filePath) continue; // no self-reference
const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath);
if (receiverModule === undefined) continue;
for (const def of target.defs) {
// Go: same-package sibling files can see ALL names (both
// exported/uppercase and unexported/lowercase). Only cross-
// package visibility requires uppercase first letter.
const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (name === '') continue;
const bucket = getAugmentationBucket(augmentations, receiverModule, name);
if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue;
bucket.push({ def, origin: 'namespace' });
}
for (const siblings of filesByPackage.values()) {
if (siblings.length < 2) continue;
const production: NameIndex = new Map();
const internalTest: NameIndex = new Map();
const external: NameIndex = new Map();
const receivers: { file: SiblingFile; module: ScopeId }[] = [];
for (const file of siblings) {
const module = indexes.moduleScopes.byFilePath.get(file.filePath);
if (module === undefined) continue;
receivers.push({ file, module });
if (file.external) appendToIndex(external, file.filePath, file.defs);
else if (file.isTest) appendToIndex(internalTest, file.filePath, file.defs);
else appendToIndex(production, file.filePath, file.defs);
}
for (const { file, module } of receivers) {
if (file.external) {
publishIndex(augmentations, external, file.filePath, module);
continue;
}
publishIndex(augmentations, production, file.filePath, module);
if (file.isTest) publishIndex(augmentations, internalTest, file.filePath, module);
}
}
}
function getAugmentationBucket(
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
scopeId: ScopeId,
name: string,
): BindingRef[] {
let scopeBindings = augmentations.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(scopeId, scopeBindings);
}
let bucketArr = scopeBindings.get(name);
if (bucketArr === undefined) {
bucketArr = [];
scopeBindings.set(name, bucketArr);
}
return bucketArr;
}

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,140 @@
/**
* Go external test packages (`package foo_test`) through the REAL pipeline
* (tree-sitter extraction + import resolution + `populateGoPackageSiblings`),
* not just the isolated `populateGoPackageSiblings` unit in
* `test/unit/scope-resolution/go/go-test-file-siblings.test.ts`.
*
* The fix (`gitnexus/src/core/ingestion/languages/go/package-siblings.ts`):
* an external test package (`package foo_test`, e.g. `a_ext_test.go`) no
* longer gets BARE-name sibling bindings from `foo` at all Go itself
* requires `foo.NewThing`, not `NewThing()`, inside `package foo_test`. The
* QUALIFIED form still resolves it was never routed through
* `populateGoPackageSiblings` in the first place, it goes through the
* ordinary import resolver (`import "…/pkg"` + a member call), which this
* fix does not touch.
*
* Also pins: an INTERNAL test file (`package foo`, e.g. `a_test.go`) keeps
* its bare-name sibling bindings (unchanged).
*
* The same boundary is enforced on the heuristic channel too (#3190, not
* this commit): the Go name-fallback hook classifies files by package
* (internal test / external `foo_test` / non-test), not by directory, so
* neither binding gets even a 0.5-confidence `global-name-fallback` edge
* (asserted at the bottom).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getRelationships, writeFixtureRepo, type PipelineResult } from './helpers.js';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
describe('Go external vs internal test packages — qualified vs bare NewThing (real pipeline)', () => {
let result: PipelineResult;
let dir: string;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-go-exttest-'));
writeFixtureRepo(dir, {
'go.mod': 'module example.com/extpkg\n\ngo 1.21\n',
'pkg/a.go': [
'package a',
'',
'func NewThing() int {',
'\treturn 1',
'}',
'',
'func UsesTestHelper() int {',
'\treturn onlyInInternalTest()',
'}',
'',
].join('\n'),
// Internal test: same package (`a`). Bare `NewThing()` and a
// test-only declaration other internal-test files can see.
'pkg/a_test.go': [
'package a',
'',
'func onlyInInternalTest() int {',
'\treturn 2',
'}',
'',
'func CallBareFromInternalTest() int {',
'\treturn NewThing()',
'}',
'',
].join('\n'),
// External test: `package a_test`, a DIFFERENT package that must
// import `pkg` explicitly to reach it — exactly like any other
// consumer of the package.
'pkg/a_ext_test.go': [
'package a_test',
'',
'import "example.com/extpkg/pkg"',
'',
'func CallQualifiedFromExternalTest() int {',
'\treturn pkg.NewThing()',
'}',
'',
'func CallBareFromExternalTest() int {',
'\treturn NewThing()',
'}',
'',
].join('\n'),
});
result = await runPipelineFromRepo(dir, () => {});
}, 60000);
it('an internal test file (still package `a`) resolves the bare call — unchanged behavior', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallBareFromInternalTest',
);
expect(edges.map((e) => e.target)).toEqual(['NewThing']);
// Confident — no heuristic-fallback reason on this edge.
expect(edges[0]!.rel.reason).not.toBe('global-name-fallback');
});
it('an external test package resolves the QUALIFIED call (pkg.NewThing) through the ordinary import resolver', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallQualifiedFromExternalTest',
);
expect(edges.map((e) => e.target)).toEqual(['NewThing']);
expect(edges[0]!.rel.reason).not.toBe('global-name-fallback');
});
it("an external test package does NOT get a CONFIDENT bare-name edge from `foo`'s package-sibling channel", () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallBareFromExternalTest',
);
const toNewThing = edges.filter((e) => e.target === 'NewThing');
// No binding at all: the confident package-sibling channel refuses the
// cross-package bare name, and the name-fallback hook refuses it too.
expect(toNewThing).toEqual([]);
});
it('a non-test file gets NO edge to a test-only declaration — confident or heuristic', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.source === 'UsesTestHelper');
const toHelper = edges.filter((e) => e.target === 'onlyInInternalTest');
expect(toHelper).toEqual([]);
});
/**
* Regression guard for the name-fallback channel. `goIsGlobalNameFallbackPlausible`
* once treated "same directory" as "same package"; a directory can hold three Go
* packages at once (`foo`, external `foo_test`, and `foo`'s own `_test.go` files),
* so both bindings below used to come back as 0.5-confidence `global-name-fallback`
* edges. The hook now classifies caller and candidate by package (via the package
* clause when sources are available) and refuses non-test test-only and bare
* cross-package names outright. The two tests below pin "no edge at all".
*/
it('a non-test file calling a test-only helper by bare name should get NO edge at all, not even a heuristic one', () => {
const edges = getRelationships(result, 'CALLS').filter((e) => e.source === 'UsesTestHelper');
expect(edges.map((e) => e.target)).not.toContain('onlyInInternalTest');
});
it("an external test package's bare NewThing() should get NO edge at all — Go rejects the call outright, so no confidence tier should bind it", () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'CallBareFromExternalTest',
);
expect(edges.map((e) => e.target)).not.toContain('NewThing');
});
});

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,372 @@
/**
* Workspace-package discovery contracts:
* - an `exports` map with no `"."` refuses the bare specifier
* - nested workspace roots are gated by the outer scope; starter, fixture,
* and test trees are never roots
* - the package map is memoised per repo root and can be invalidated
*/
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('test/ is never a nested workspace root', () => {
it('a leftover test/pnpm-workspace.yaml does not admit fixture package names', async () => {
const { dir, w } = mkRepo('gn-m6-test-root-');
try {
w('README.md', '# polyglot\n');
w('js/pnpm-workspace.yaml', 'packages:\n - "libs/*"\n');
w('js/libs/lodash/package.json', JSON.stringify({ name: 'lodash', main: 'src/index.ts' }));
w('js/libs/lodash/src/index.ts', 'export const real = 1;\n');
w('test/pnpm-workspace.yaml', 'packages:\n - "fixtures/*"\n');
w(
'test/fixtures/lodash/package.json',
JSON.stringify({ name: 'lodash', main: 'src/index.ts' }),
);
w('test/fixtures/lodash/src/index.ts', 'export const fake = 1;\n');
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('lodash')?.dir).toBe('js/libs/lodash');
for (const pkg of pkgs!.byName.values()) expect(pkg.dir.startsWith('test/')).toBe(false);
} finally {
rm(dir);
}
});
it('a root workspaces glob that lists test/* still admits those packages', async () => {
const { dir, w } = mkRepo('gn-m6-test-listed-');
try {
w(
'package.json',
JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*', 'test/*'] }),
);
w('packages/real/package.json', JSON.stringify({ name: '@repo/real', main: 'src/index.ts' }));
w('packages/real/src/index.ts', 'export const real = 1;\n');
w(
'test/helpers/package.json',
JSON.stringify({ name: '@repo/test-helpers', main: 'src/index.ts' }),
);
w('test/helpers/src/index.ts', 'export const help = 1;\n');
const pkgs = await loadNodeWorkspacePackages(dir);
expect(pkgs!.byName.get('@repo/test-helpers')?.dir).toBe('test/helpers');
expect(pkgs!.byName.get('@repo/real')?.dir).toBe('packages/real');
} finally {
rm(dir);
}
});
});
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,60 @@
/**
* AST Vite discovery ignores commented-out `lib.entry` text and refuses a
* default export that is not a single static config object.
*/
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 } from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
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,113 @@
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');
});
});
describe('Vite discovery uses the first existing config filename', () => {
async function entriesForConfigs(files: Record<string, string>): Promise<readonly string[]> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-vite-leftover-'));
roots.push(root);
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({ name: '@test/config', main: './dist/bundle.js' }),
);
for (const [name, text] of Object.entries(files)) {
fs.writeFileSync(path.join(root, name), text);
}
for (const name of ['from-js', 'from-ts', 'from-mjs']) {
fs.writeFileSync(path.join(root, 'src', `${name}.ts`), `export const ${name} = 1;`);
}
return (await loadNodeWorkspacePackages(root))!.byName.get('@test/config')!.entries;
}
it("adopts Vite's first existing file instead of refusing leftover siblings", async () => {
const entries = await entriesForConfigs({
'vite.config.js': `export default { build: { lib: { entry: 'src/from-js.ts' } } };\n`,
'vite.config.ts': `export default { build: { lib: { entry: 'src/from-ts.ts' } } };\n`,
});
expect(entries).toContain('src/from-js');
expect(entries).not.toContain('src/from-ts');
expect(entries).toContain('dist/bundle');
});
it('does not fall through to a later filename when the first config exists', async () => {
const entries = await entriesForConfigs({
'vite.config.mjs': `export default { build: { lib: { entry: 'src/from-mjs.ts' } } };\n`,
'vite.config.ts': `export default { build: { lib: { entry: 'src/from-ts.ts' } } };\n`,
});
expect(entries).toContain('src/from-mjs');
expect(entries).not.toContain('src/from-ts');
});
});

View file

@ -41,19 +41,87 @@ describe('Go package siblings', () => {
expect(augmentations.get('module:foo-a')?.get('OnlyBar')).toBeUndefined();
expect(augmentations.get('module:bar-a')?.get('OnlyFoo')).toBeUndefined();
});
it("publishes same-name sibling defs in file order and never includes a file's own defs", () => {
const aFoo = def('a-foo', 'pkg/a/a.go', 'Foo');
const bFoo = def('b-foo', 'pkg/a/b.go', 'Foo');
const bBar = def('b-bar', 'pkg/a/b.go', 'Bar');
const cBaz = def('c-baz', 'pkg/a/c.go', 'Baz');
const parsedFiles: ParsedFile[] = [
parsed('pkg/a/a.go', 'module:a', aFoo),
parsed('pkg/a/b.go', 'module:b', bFoo, bBar),
parsed('pkg/a/c.go', 'module:c', cBaz),
];
const indexes = {
moduleScopes: {
byFilePath: new Map([
['pkg/a/a.go', 'module:a'],
['pkg/a/b.go', 'module:b'],
['pkg/a/c.go', 'module:c'],
]),
},
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
} as unknown as ScopeResolutionIndexes;
const fileContents = new Map([
['pkg/a/a.go', 'package a\n'],
['pkg/a/b.go', 'package a\n'],
['pkg/a/c.go', 'package a\n'],
]);
populateGoPackageSiblings(parsedFiles, indexes, { fileContents });
const augmentations = indexes.bindingAugmentations;
expect(
augmentations
.get('module:c')
?.get('Foo')
?.map((b) => b.def.nodeId),
).toEqual(['a-foo', 'b-foo']);
expect(
augmentations
.get('module:a')
?.get('Foo')
?.map((b) => b.def.nodeId),
).toEqual(['b-foo']);
expect(
augmentations
.get('module:a')
?.get('Bar')
?.map((b) => b.def.nodeId),
).toEqual(['b-bar']);
expect(
augmentations
.get('module:a')
?.get('Baz')
?.map((b) => b.def.nodeId),
).toEqual(['c-baz']);
expect(
augmentations
.get('module:b')
?.get('Foo')
?.map((b) => b.def.nodeId),
).toEqual(['a-foo']);
});
});
function def(nodeId: string, filePath: string, name: string): SymbolDefinition {
return { nodeId, filePath, type: 'Function', qualifiedName: name };
}
function parsed(filePath: string, moduleScope: string, localDef: SymbolDefinition): ParsedFile {
function parsed(
filePath: string,
moduleScope: string,
...localDefs: SymbolDefinition[]
): ParsedFile {
return {
filePath,
moduleScope,
scopes: [],
parsedImports: [],
localDefs: [localDef],
localDefs,
referenceSites: [],
};
}

View file

@ -0,0 +1,226 @@
import { describe, expect, it } from 'vitest';
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
import { populateGoPackageSiblings } from '../../../../src/core/ingestion/languages/go/index.js';
/**
* C1 `_test.go` files get package-sibling bindings (they used to be dropped,
* sending every same-package call from a test to the global fallback).
*/
function def(nodeId: string, filePath: string, name: string): SymbolDefinition {
return { nodeId, filePath, type: 'Function', qualifiedName: name };
}
function parsed(
filePath: string,
moduleScope: string,
...localDefs: SymbolDefinition[]
): ParsedFile {
return { filePath, moduleScope, scopes: [], parsedImports: [], localDefs, referenceSites: [] };
}
function setup(files: { path: string; scope: string; pkg: string; defs: SymbolDefinition[] }[]) {
const parsedFiles = files.map((f) => parsed(f.path, f.scope, ...f.defs));
const indexes = {
moduleScopes: { byFilePath: new Map(files.map((f) => [f.path, f.scope])) },
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
} as unknown as ScopeResolutionIndexes;
const fileContents = new Map(files.map((f) => [f.path, `package ${f.pkg}\n`]));
populateGoPackageSiblings(parsedFiles, indexes, { fileContents });
const see = (scope: string, name: string) =>
indexes.bindingAugmentations
.get(scope)
?.get(name)
?.map((b) => b.def.nodeId) ?? [];
return { see };
}
describe('Go _test.go package siblings', () => {
const helper = def('helper', 'pkg/a/a.go', 'setUpHelper');
const exported = def('exported', 'pkg/a/a.go', 'NewThing');
const testOnly = def('test-only', 'pkg/a/a_test.go', 'fakeStore');
const otherTest = def('other-test', 'pkg/a/b_test.go', 'scenario');
it('an internal test file sees non-test siblings, exported and unexported', () => {
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
]);
expect(see('m:a-test', 'setUpHelper')).toEqual(['helper']);
expect(see('m:a-test', 'NewThing')).toEqual(['exported']);
});
it('internal test files see each other', () => {
const { see } = setup([
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
{ path: 'pkg/a/b_test.go', scope: 'm:b-test', pkg: 'a', defs: [otherTest] },
]);
expect(see('m:a-test', 'scenario')).toEqual(['other-test']);
expect(see('m:b-test', 'fakeStore')).toEqual(['test-only']);
});
it('a non-test file does NOT see a test-only helper', () => {
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper] },
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
]);
expect(see('m:a', 'fakeStore')).toEqual([]);
});
it('an external test package (`foo_test`) gets NO bare-name bindings from `foo` — it must qualify `foo.X`', () => {
// Go requires `a.NewThing` inside `package a_test`; a bare `NewThing()`
// there is a compile error, so publishing it bound a call Go rejects.
// The qualified form resolves through the test's explicit import of the
// package path, not through sibling augmentation.
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
{ path: 'pkg/a/a_ext_test.go', scope: 'm:ext', pkg: 'a_test', defs: [testOnly] },
]);
expect(see('m:ext', 'NewThing')).toEqual([]);
expect(see('m:ext', 'setUpHelper')).toEqual([]);
// and `a` does not see the external test's declarations
expect(see('m:a', 'fakeStore')).toEqual([]);
});
it('tests in a different directory with the same package name stay isolated', () => {
const far = def('far', 'pkg/b/x_test.go', 'farHelper');
const { see } = setup([
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
{ path: 'pkg/b/x_test.go', scope: 'm:far', pkg: 'a', defs: [far] },
]);
expect(see('m:a-test', 'farHelper')).toEqual([]);
});
// Gap: the existing external-test test only checked visibility FROM the
// external test's own scope (`m:ext`) and confirmed the internal package's
// NON-test file (`m:a`) doesn't see it. It never checked the internal
// TEST's scope — `package foo`'s `a_test.go` is still package `foo`, not
// `foo_test`, and must be just as blind to `foo_test`'s declarations,
// exported or not, as the non-test file is. `target.external &&
// !receiver.external` is the line this exercises; a receiver-side bug
// there (e.g. checking `target.isTest` instead) would leak names across
// the `foo` / `foo_test` package boundary through the internal test only.
it('an internal test file (still package `foo`) does not see the external test package at all, exported or not', () => {
const extExported = def('ext-exported', 'pkg/a/a_ext_test.go', 'ExtHelper');
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
{ path: 'pkg/a/a_test.go', scope: 'm:a-test', pkg: 'a', defs: [testOnly] },
{ path: 'pkg/a/b_test.go', scope: 'm:b-test', pkg: 'a', defs: [otherTest] },
{ path: 'pkg/a/a_ext_test.go', scope: 'm:ext', pkg: 'a_test', defs: [extExported] },
]);
expect(see('m:a-test', 'ExtHelper')).toEqual([]);
// still sees its own package's OTHER internal-test sibling (a different
// file than itself, so the self-reference guard does not apply)
expect(see('m:a-test', 'scenario')).toEqual(['other-test']);
});
// Two `_test.go` files that are BOTH external (`package foo_test`) are, to
// each other, the same package — full visibility, unexported names
// included. Distinct from "internal tests see each other" above (that case
// never crosses the external partition).
it('two external test files in the same directory see each other fully, unexported included', () => {
const extA = def('ext-a', 'pkg/a/a_ext_test.go', 'scaffold');
const extB = def('ext-b', 'pkg/a/b_ext_test.go', 'teardown');
const { see } = setup([
{ path: 'pkg/a/a_ext_test.go', scope: 'm:ext-a', pkg: 'a_test', defs: [extA] },
{ path: 'pkg/a/b_ext_test.go', scope: 'm:ext-b', pkg: 'a_test', defs: [extB] },
]);
expect(see('m:ext-a', 'teardown')).toEqual(['ext-b']);
expect(see('m:ext-b', 'scaffold')).toEqual(['ext-a']);
});
// Requirement: "two packages in one directory ... do not cross-bind
// non-exported names". Two genuinely distinct NON-test packages sharing a
// directory (e.g. a `main` package next to a `//go:build ignore` tool)
// must stay in separate sibling groups — same directory, different
// `dir\0pkgName` key.
it('two distinct non-test packages in the same directory do not cross-bind', () => {
const fooHelper = def('foo-helper', 'pkg/a/main.go', 'setup');
const barHelper = def('bar-helper', 'pkg/a/tool.go', 'setup');
const { see } = setup([
{ path: 'pkg/a/main.go', scope: 'm:foo', pkg: 'foo', defs: [fooHelper] },
{ path: 'pkg/a/tool.go', scope: 'm:bar', pkg: 'bar', defs: [barHelper] },
]);
expect(see('m:foo', 'setup')).toEqual([]);
expect(see('m:bar', 'setup')).toEqual([]);
});
it('a package genuinely NAMED `foo_test` keeps its internal tests in their declared package', () => {
// `package foo_test` is the external-test convention only when the
// directory's real package is `foo`. Here the non-test files themselves say
// `foo_test`, so its `_test.go` files are INTERNAL tests of that package
// and must see unexported siblings; stripping `_test` blindly keyed them
// as external tests of a non-existent `foo` and published nothing.
const impl = def('impl', 'pkg/foo_test/impl.go', 'unexportedHelper');
const fixture = def('fixture', 'pkg/foo_test/impl_test.go', 'newFixture');
const { see } = setup([
{ path: 'pkg/foo_test/impl.go', scope: 'm:impl', pkg: 'foo_test', defs: [impl] },
{ path: 'pkg/foo_test/impl_test.go', scope: 'm:impl-test', pkg: 'foo_test', defs: [fixture] },
]);
expect(see('m:impl-test', 'unexportedHelper')).toEqual(['impl']);
// Non-test files still never see test-only declarations.
expect(see('m:impl', 'newFixture')).toEqual([]);
});
it('`package foo_test` beside `package foo` is still the external-test convention', () => {
const { see } = setup([
{ path: 'pkg/a/a.go', scope: 'm:a', pkg: 'a', defs: [helper, exported] },
{ path: 'pkg/a/a_test.go', scope: 'm:a-ext', pkg: 'a_test', defs: [testOnly] },
]);
expect(see('m:a-ext', 'setUpHelper')).toEqual([]);
expect(see('m:a-ext', 'NewThing')).toEqual([]);
});
// `expandGoDotImports(parsedFiles)` — not `nonTestFiles` — so a `_test.go`
// `import .` can augment that file's own scope. `setup()` above uses an
// empty imports map, so this is the pin that a revert to `nonTestFiles`
// would break. Sibling republish copies `localDefs` only, so the
// non-test sibling must not receive the wildcard name.
it("a `_test.go` import . receives origin: 'wildcard'; a non-test sibling does not", () => {
const libExport = def('lib-export', 'other/lib.go', 'Exported');
const prod = def('prod', 'pkg/a/a.go', 'Prod');
const testOnlyDef = def('test-only', 'pkg/a/a_test.go', 'fakeStore');
const parsedFiles = [
parsed('other/lib.go', 'm:lib', libExport),
parsed('pkg/a/a.go', 'm:a', prod),
parsed('pkg/a/a_test.go', 'm:a-test', testOnlyDef),
];
const indexes = {
moduleScopes: {
byFilePath: new Map([
['other/lib.go', 'm:lib'],
['pkg/a/a.go', 'm:a'],
['pkg/a/a_test.go', 'm:a-test'],
]),
},
imports: new Map([
[
'm:a-test',
[
{
localName: 'Exported',
targetFile: 'other/lib.go',
targetExportedName: 'Exported',
kind: 'wildcard-expanded',
},
],
],
]),
bindings: new Map([
['m:lib', new Map([['Exported', [{ def: libExport, origin: 'local' }]]])],
]),
bindingAugmentations: new Map(),
} as unknown as ScopeResolutionIndexes;
populateGoPackageSiblings(parsedFiles, indexes, {
fileContents: new Map([
['other/lib.go', 'package lib\n'],
['pkg/a/a.go', 'package a\n'],
['pkg/a/a_test.go', 'package a\n'],
]),
});
const testWild = indexes.bindingAugmentations.get('m:a-test')?.get('Exported') ?? [];
expect(testWild.map((b) => b.origin)).toEqual(['wildcard']);
expect(testWild.map((b) => b.def.nodeId)).toEqual(['lib-export']);
expect(indexes.bindingAugmentations.get('m:a')?.get('Exported') ?? []).toEqual([]);
});
});