mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
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:
parent
2220f4d851
commit
79f210c5b5
15 changed files with 2551 additions and 92 deletions
|
|
@ -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`
|
||||
// (780–845 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(/^\//, '');
|
||||
|
|
|
|||
|
|
@ -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 name→def 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 name→def 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
203
gitnexus/test/integration/resolvers/barrel-workspace-hop.test.ts
Normal file
203
gitnexus/test/integration/resolvers/barrel-workspace-hop.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
115
gitnexus/test/unit/node-workspace-memo-rejection.test.ts
Normal file
115
gitnexus/test/unit/node-workspace-memo-rejection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
258
gitnexus/test/unit/node-workspace-nested-roots.test.ts
Normal file
258
gitnexus/test/unit/node-workspace-nested-roots.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
134
gitnexus/test/unit/node-workspace-repo-root-race.test.ts
Normal file
134
gitnexus/test/unit/node-workspace-repo-root-race.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
372
gitnexus/test/unit/node-workspace-review-fixes.test.ts
Normal file
372
gitnexus/test/unit/node-workspace-review-fixes.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
171
gitnexus/test/unit/node-workspace-scan-determinism.test.ts
Normal file
171
gitnexus/test/unit/node-workspace-scan-determinism.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
113
gitnexus/test/unit/node-workspace-vite-structure.test.ts
Normal file
113
gitnexus/test/unit/node-workspace-vite-structure.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue