diff --git a/scripts/ablation-dist-preflight.mjs b/scripts/ablation-dist-preflight.mjs index ee1071562f..5b44da2b0d 100644 --- a/scripts/ablation-dist-preflight.mjs +++ b/scripts/ablation-dist-preflight.mjs @@ -145,60 +145,37 @@ import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { isEntrypoint } from './invoked-as.mjs'; +import { WORKSPACE_FILE, parseWorkspaceGlobs, workspacePackageDirs } from './workspace-enumerator.mjs'; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); // Read as text, but never let a binary artifact fabricate a match. const BINARY_EXT = new Set(['.wasm', '.node', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.zip', '.gz', '.br']); -/** Parse the `packages:` globs out of pnpm-workspace.yaml (no YAML dependency). */ -export function parseWorkspaceGlobs(yamlText) { - const globs = []; - let inPackages = false; - for (const rawLine of yamlText.split('\n')) { - const line = rawLine.replace(/\s+$/, ''); - if (/^packages:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (inPackages) { - const item = line.match(/^\s+-\s+['"]?([^'"\s]+)['"]?\s*$/); - if (item) { - globs.push(item[1]); - continue; - } - if (line.trim() !== '') break; // next top-level key ends the list - } - } - return globs; -} +/** + * Parse the `packages:` globs out of pnpm-workspace.yaml (no YAML dependency). + * + * Re-exported from `scripts/workspace-enumerator.mjs` (#11510) rather than + * parsed here. The copy this replaces was the strictest of the nine and the + * only one that ended the list at ANY line it could not match, so a whole-line + * comment inside the `packages:` block silently truncated the workspace — this + * script would then have scanned a subset of the members and reported a clean + * preflight over it. Latent on this repo's file today; a comment in that block + * is all it needed. + */ +export { parseWorkspaceGlobs }; /** name -> repo-relative dir, for every workspace package. */ function workspacePackages(repoRoot) { - const yamlPath = join(repoRoot, 'pnpm-workspace.yaml'); - let globs; + let dirs; try { - globs = parseWorkspaceGlobs(readFileSync(yamlPath, 'utf8')); - } catch { - fail(`cannot read ${relative(repoRoot, yamlPath) || 'pnpm-workspace.yaml'} -- refusing to guess the workspace layout.`); - } - if (globs.length === 0) fail('pnpm-workspace.yaml declares no `packages:` globs -- refusing to scan an empty workspace.'); - - const dirs = []; - for (const glob of globs) { - if (glob.endsWith('/*')) { - const parent = join(repoRoot, glob.slice(0, -2)); - let entries = []; - try { - entries = readdirSync(parent, { withFileTypes: true }); - } catch { - continue; // a declared-but-absent parent is the workspace's problem, not ours - } - for (const e of entries) if (e.isDirectory()) dirs.push(join(parent, e.name)); - } else { - dirs.push(join(repoRoot, glob)); - } + dirs = workspacePackageDirs(repoRoot).map((rel) => join(repoRoot, rel)); + } catch (err) { + fail( + `cannot enumerate the workspace from ${WORKSPACE_FILE} -- refusing to guess the layout.\n ${err?.message ?? err}`, + ); } + if (dirs.length === 0) fail(`${WORKSPACE_FILE} declares no \`packages:\` globs -- refusing to scan an empty workspace.`); const byName = new Map(); for (const dir of dirs) { diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs index 0d1de01f3d..d48d722ce7 100644 --- a/scripts/check-changeset-fixed.mjs +++ b/scripts/check-changeset-fixed.mjs @@ -12,112 +12,44 @@ * - A name listed in the `fixed` group no longer exists in the workspace * * The script intentionally has zero third-party dependencies so it can run - * in minimal CI environments before `pnpm install`. It reads - * pnpm-workspace.yaml directly and walks the workspace globs itself. + * in minimal CI environments before `pnpm install`. The workspace walk comes + * from scripts/workspace-enumerator.mjs, which has none either, for the same + * reason. */ -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { workspacePackages } from './workspace-enumerator.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); /** - * Minimal pnpm-workspace.yaml parser: extracts entries under the top-level - * `packages:` key. Supports the `- pattern` list form used by this repo and - * tolerates comments / blank lines. Avoids pulling in a YAML dependency. + * Names of all non-private workspace packages. * - * @returns {string[]} - */ -function readWorkspacePatterns() { - const text = readFileSync(resolve(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); - const lines = text.split(/\r?\n/); - const patterns = []; - let inPackages = false; - for (const raw of lines) { - const line = raw.replace(/#.*$/, '').replace(/\s+$/, ''); - if (!line.trim()) continue; - if (/^packages\s*:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (inPackages) { - const m = /^\s+-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); - if (m) { - patterns.push(m[1]); - continue; - } - // Any other non-indented key ends the packages block. - if (/^\S/.test(line)) inPackages = false; - } - } - return patterns; -} - -/** - * Expand a single `pattern` like `packages/*` or `packages/services/*` into - * concrete directory paths. Only supports the `*` wildcard at any single - * path segment, which is what the repo uses. + * Membership comes from `scripts/workspace-enumerator.mjs` (#11510) — this + * repo's one parse of `pnpm-workspace.yaml`, and one of nine private copies + * before it. Two behaviours of the copy that used to live here changed, both + * toward refusing rather than under-reporting, and neither observable on this + * repo's workspace file: + * + * - a missing or empty `packages:` block used to yield `[]`, which made this + * gate report the `fixed` group in sync with a workspace of zero packages — + * green, and vacuous. It now throws. + * - a `#` was stripped unconditionally, so a member path legitimately + * containing one was silently truncated to a directory that does not exist. + * Only a whitespace-led `#` is a comment now. * - * @param {string} pattern * @returns {string[]} */ -function expandPattern(pattern) { - const segments = pattern.split('/'); - /** @type {string[]} */ - let dirs = [repoRoot]; - for (const seg of segments) { - /** @type {string[]} */ - const next = []; - for (const dir of dirs) { - if (seg === '*') { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.')) { - next.push(join(dir, entry.name)); - } - } - } else { - const candidate = join(dir, seg); - try { - if (statSync(candidate).isDirectory()) next.push(candidate); - } catch { - /* missing - skip */ - } - } - } - dirs = next; - } - return dirs; -} - -/** @returns {string[]} names of all non-private workspace packages */ function listPublicPackageNames() { - const patterns = readWorkspacePatterns(); - const seen = new Set(); - const names = []; - for (const pattern of patterns) { - for (const dir of expandPattern(pattern)) { - const pkgPath = join(dir, 'package.json'); - let pkg; - try { - pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - } catch { - continue; - } - if (!pkg.name || pkg.private === true) continue; - if (seen.has(pkg.name)) continue; - seen.add(pkg.name); - names.push(pkg.name); - } + const names = new Set(); + for (const { manifest } of workspacePackages(repoRoot)) { + if (!manifest.name || manifest.private === true) continue; + names.add(manifest.name); } - return names.sort(); + return [...names].sort(); } function readFixedGroups() { diff --git a/scripts/check-dev-prereqs.mjs b/scripts/check-dev-prereqs.mjs index 9aafe04a29..e267e1390f 100644 --- a/scripts/check-dev-prereqs.mjs +++ b/scripts/check-dev-prereqs.mjs @@ -237,6 +237,11 @@ import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'url'; +import { + WorkspaceEnumerationError, + selfTest as workspaceEnumeratorSelfTest, + workspaceMemberDirs, +} from './workspace-enumerator.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -265,48 +270,27 @@ class CoverageError extends Error {} /** * Workspace member directories, from pnpm-workspace.yaml — the workspace's own - * declaration of what it contains. Only `/*` and literal paths are - * understood; anything else throws instead of silently covering less. + * declaration of what it contains. + * + * The parse and the glob expansion come from + * `scripts/workspace-enumerator.mjs` (#11510), this repo's one reading of that + * file. Its refusals are the ones this gate already made — a pattern richer + * than `` or `/*` throws rather than quietly covering fewer packages, + * which is what would make this gate pass vacuously — and they are re-thrown as + * `CoverageError` so this file's own failure vocabulary is unchanged. + * + * Importing it does not give this gate a path population: the enumerator + * declares none, deliberately, so the reasoning in this file's header (CI runs + * `--self-test` only, so a workspace-wide declaration here would name this gate + * for every packages/ card in the tree) still holds exactly as written. */ function workspaceDirs(root) { - const file = path.join(root, 'pnpm-workspace.yaml'); - if (!existsSync(file)) throw new CoverageError(`${rel(root, file)} is missing — cannot enumerate workspace packages.`); - - const lines = readFileSync(file, 'utf-8').split('\n'); - const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); - if (start === -1) throw new CoverageError(`pnpm-workspace.yaml has no 'packages:' list — cannot enumerate workspace packages.`); - - const patterns = []; - for (const line of lines.slice(start + 1)) { - const item = /^\s+-\s+(.+?)\s*$/.exec(line); - if (!item) { - if (/^\S/.test(line)) break; // next top-level key ends the list - continue; // blank line or comment inside the list - } - patterns.push(item[1].replace(/^['"]|['"]$/g, '')); - } - - const dirs = []; - for (const pattern of patterns) { - if (pattern.startsWith('!')) continue; // exclusion: nothing to enumerate - if (pattern.includes('**') || pattern.slice(0, -2).includes('*')) { - throw new CoverageError( - `pnpm-workspace.yaml pattern '${pattern}' is not a shape this gate can expand.\n` + - ` Teach scripts/check-dev-prereqs.mjs the new pattern shape — silently covering fewer\n` + - ` packages would make this gate pass vacuously.`, - ); - } - if (!pattern.endsWith('/*')) { - dirs.push(path.join(root, pattern)); - continue; - } - const parent = path.join(root, pattern.slice(0, -2)); - if (!existsSync(parent)) continue; - for (const entry of readdirSync(parent, { withFileTypes: true })) { - if (entry.isDirectory() && entry.name !== 'node_modules') dirs.push(path.join(parent, entry.name)); - } + try { + return workspaceMemberDirs(root).map((dir) => path.join(root, dir)); + } catch (err) { + if (err instanceof WorkspaceEnumerationError) throw new CoverageError(err.message); + throw err; } - return dirs; } /** The entry point Node resolves for `import ''`: exports["."], else main. */ @@ -827,13 +811,18 @@ function selfTest() { rmSync(tmp, { recursive: true, force: true }); } + // The shared workspace enumerator is a plain module with no CI invocation of + // its own (#11510 — being a gate is exactly what it must not be); every gate + // that consolidated onto it folds in its checks. + failures.push(...workspaceEnumeratorSelfTest({ root: ROOT })); + if (failures.length > 0) { console.error(`\n✗ check:dev-prereqs --self-test — ${failures.length} failure(s)\n`); for (const f of failures) console.error(` ${f}`); console.error(''); return 1; } - console.log('✓ check:dev-prereqs --self-test — every verdict reachable, exclusions and freshness coverage pinned (16 cases).'); + console.log('✓ check:dev-prereqs --self-test — every verdict reachable, exclusions and freshness coverage pinned (16 cases), plus the shared workspace enumerator.'); return 0; } diff --git a/scripts/check-override-consistency.mjs b/scripts/check-override-consistency.mjs index 766593a436..c5ffb7bf61 100644 --- a/scripts/check-override-consistency.mjs +++ b/scripts/check-override-consistency.mjs @@ -63,48 +63,30 @@ * and moves only the replacement target. */ -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; import { parse as parseYaml } from 'yaml'; +import { workspacePackages } from './workspace-enumerator.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); /** - * Minimal pnpm-workspace.yaml block parsers (same approach as - * scripts/check-changeset-fixed.mjs): this repo's file only uses simple - * `key: value` scalars and `- item` lists, so a YAML dependency is avoided. + * Minimal reader for the `overrides:` block, hand-parsed because this repo's + * file only uses simple `key: value` scalars there. + * + * The `packages:` block is NOT parsed here any more — that one moved to + * scripts/workspace-enumerator.mjs (#11510), which nine scripts now share. This + * block stayed behind on purpose: it is a different question with exactly one + * reader, so consolidating it would create a shared module with one caller. */ function readWorkspaceYamlLines() { const text = readFileSync(resolve(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); return text.split(/\r?\n/); } -/** @returns {string[]} */ -function readWorkspacePatterns() { - const patterns = []; - let inPackages = false; - for (const raw of readWorkspaceYamlLines()) { - const line = raw.replace(/#.*$/, '').replace(/\s+$/, ''); - if (!line.trim()) continue; - if (/^packages\s*:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (inPackages) { - const m = /^\s+-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); - if (m) { - patterns.push(m[1]); - continue; - } - if (/^\S/.test(line)) inPackages = false; - } - } - return patterns; -} - /** * @returns {Array<{ name: string, selector: string | null, target: string }>} * One entry per `overrides:` line. `selector` is the optional range scope @@ -140,56 +122,24 @@ function readOverrides() { return overrides; } -/** @param {string} pattern @returns {string[]} */ -function expandPattern(pattern) { - const segments = pattern.split('/'); - let dirs = [repoRoot]; - for (const seg of segments) { - const next = []; - for (const dir of dirs) { - if (seg === '*') { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.')) { - next.push(join(dir, entry.name)); - } - } - } else { - const candidate = join(dir, seg); - try { - if (statSync(candidate).isDirectory()) next.push(candidate); - } catch { - /* missing - skip */ - } - } - } - dirs = next; - } - return dirs; -} - -/** @returns {Array<{ dir: string, pkg: any }>} all non-private workspace packages */ +/** + * All non-private workspace packages, as `{ dir, pkg }` with an ABSOLUTE dir. + * + * Membership comes from `scripts/workspace-enumerator.mjs` (#11510) — this + * repo's one parse of the `packages:` block, and one of nine private copies + * before it. The `overrides:` parser above stays here: it reads a DIFFERENT + * block of the same file, and nothing else in the repo reads that one. + * + * @returns {Array<{ dir: string, pkg: any }>} + */ function listPublishablePackages() { const seen = new Set(); const result = []; - for (const pattern of readWorkspacePatterns()) { - for (const dir of expandPattern(pattern)) { - let pkg; - try { - pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - } catch { - continue; - } - if (!pkg.name || pkg.private === true) continue; - if (seen.has(pkg.name)) continue; - seen.add(pkg.name); - result.push({ dir, pkg }); - } + for (const { dir, manifest } of workspacePackages(repoRoot)) { + if (!manifest.name || manifest.private === true) continue; + if (seen.has(manifest.name)) continue; + seen.add(manifest.name); + result.push({ dir: join(repoRoot, dir), pkg: manifest }); } return result; } diff --git a/scripts/check-published-files.mjs b/scripts/check-published-files.mjs index 49ba301b4a..7a2bde9e48 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -56,11 +56,15 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix, resolve } from 'node:path'; +import { + readWorkspaceGlobs, + selfTest as workspaceEnumeratorSelfTest, + workspacePackageDirs, +} from './workspace-enumerator.mjs'; // Anchored to the script, not to cwd: the verdict must not depend on where the // guard was invoked from. const ROOT = resolve(import.meta.dirname, '..'); -const WORKSPACE_FILE = 'pnpm-workspace.yaml'; const SELF = 'scripts/check-published-files.mjs'; // Entries every package may declare without justifying itself: the build @@ -256,52 +260,23 @@ const ROOT_DIR_WATCH_HINTS = [ ]; /** - * The `packages:` globs from pnpm-workspace.yaml. Blank lines and comments are - * skipped rather than treated as the end of the list: stopping early would drop - * members from the scan and report a clean run over a partial workspace, which - * is the one failure mode a guard must not have. + * The `packages:` globs and the member directories they enumerate. + * + * Both come from `scripts/workspace-enumerator.mjs` (#11510), which is where + * this repo's one parse of that file lives. This gate used to carry a private + * copy; so did eight other scripts, and measured against each other they + * agreed on the repo's real file while disagreeing on nine adversarial inputs. + * + * The enumerator is a plain module and declares NO path population of its own, + * deliberately — `ROOT_DIR_WATCH_HINTS` above stays this gate's own claim about + * this gate's own surface, and the self-test still reconciles it against the + * live parse in both directions. See the enumerator's header for the +41725 + * (gate, file) pair measurement that decided the split. */ -function workspaceGlobs() { - const lines = readFileSync(join(ROOT, WORKSPACE_FILE), 'utf8').split(/\r?\n/); - const start = lines.findIndex((l) => /^packages\s*:\s*$/.test(l)); - if (start === -1) throw new Error(`${WORKSPACE_FILE}: no top-level \`packages:\` block`); - const globs = []; - for (let i = start + 1; i < lines.length; i++) { - const line = lines[i].replace(/#.*$/, '').trimEnd(); - if (!line.trim()) continue; - const m = line.match(/^\s+-\s+['"]?([^'"\s]+)['"]?\s*$/); - if (m) { - globs.push(m[1]); - continue; - } - if (/^\S/.test(line)) break; // the next top-level key ends the block - } - if (globs.length === 0) throw new Error(`${WORKSPACE_FILE}: \`packages:\` block is empty`); - return globs; -} +const workspaceGlobs = () => readWorkspaceGlobs(ROOT); /** Workspace member directories, relative to the repo root. */ -function workspaceDirs() { - const dirs = []; - for (const glob of workspaceGlobs()) { - // Every pattern in this repo is `` or `/*`. Anything richer would - // silently resolve to nothing, so reject it rather than under-report. - const star = glob.endsWith('/*'); - const base = star ? glob.slice(0, -2) : glob; - if (base.includes('*')) { - throw new Error( - `${WORKSPACE_FILE}: pattern "${glob}" is richer than or /*; extend ${SELF}`, - ); - } - const abs = join(ROOT, base); - if (!existsSync(abs)) continue; - const candidates = star ? readdirSync(abs).map((e) => posix.join(base, e)) : [base]; - for (const c of candidates) { - if (existsSync(join(ROOT, c, 'package.json'))) dirs.push(c); - } - } - return dirs.sort(); -} +const workspaceDirs = () => workspacePackageDirs(ROOT); /** Package-relative POSIX paths of every file that is not build output. */ function walk(absDir, prefix = '', out = []) { @@ -429,6 +404,13 @@ function selfTest() { if (!ok) failures.push(`ROOT_DIR_WATCH_HINTS: ${name}`); } + // The shared enumerator is a plain module, so no workflow invokes it and it + // has no self-test of its own to schedule (#11510 — being a gate is exactly + // what it must not be). Its coverage is that every gate which consolidated + // onto it folds these in, this one included. + const enumeratorFailures = workspaceEnumeratorSelfTest({ root: ROOT }); + failures.push(...enumeratorFailures); + if (failures.length > 0) { console.error(`✗ check:published-files --self-test — ${failures.length} failure(s)\n`); for (const f of failures) console.error(` ${f}`); @@ -436,8 +418,9 @@ function selfTest() { } console.log( `✓ check:published-files --self-test — ${cases.length} pattern case(s), ` + - `${forbidden.length} classification case(s) and ${declarationCases.length} ` + - `population-declaration case(s) over ${liveGlobs.length} live workspace glob(s).`, + `${forbidden.length} classification case(s), ${declarationCases.length} ` + + `population-declaration case(s) and the shared workspace enumerator's own ` + + `assertions, over ${liveGlobs.length} live workspace glob(s).`, ); } diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index a5af4114fe..f63c09f4e7 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -292,11 +292,15 @@ import process from 'node:process'; import ts from 'typescript'; import { isEntrypoint } from './invoked-as.mjs'; import { createProgramChecked } from './ts-parse.mjs'; +import { + WORKSPACE_FILE, + selfTest as workspaceEnumeratorSelfTest, + workspacePackageDirs, +} from './workspace-enumerator.mjs'; // Anchored to the script, not to cwd: the verdict must not depend on where the // guard was invoked from. const ROOT = resolve(import.meta.dirname, '..'); -const WORKSPACE_FILE = 'pnpm-workspace.yaml'; const SELF = 'scripts/check-published-readme-exports.mjs'; const BASELINE_REL = 'scripts/published-readme-exports.baseline.json'; @@ -920,46 +924,21 @@ export function resolveTypesEntry(manifest, subpath) { // Workspace + type surface // --------------------------------------------------------------------------- -/** The `packages:` globs from pnpm-workspace.yaml. */ -function workspaceGlobs() { - const lines = readFileSync(join(ROOT, WORKSPACE_FILE), 'utf8').split(/\r?\n/); - const start = lines.findIndex((l) => /^packages\s*:\s*$/.test(l)); - if (start === -1) throw new Error(`${WORKSPACE_FILE}: no top-level \`packages:\` block`); - const globs = []; - for (let i = start + 1; i < lines.length; i++) { - const line = lines[i].replace(/#.*$/, '').trimEnd(); - if (!line.trim()) continue; - const m = line.match(/^\s+-\s+['"]?([^'"\s]+)['"]?\s*$/); - if (m) { - globs.push(m[1]); - continue; - } - if (/^\S/.test(line)) break; - } - if (globs.length === 0) throw new Error(`${WORKSPACE_FILE}: \`packages:\` block is empty`); - return globs; -} - -/** Workspace member directories, relative to the repo root. */ -function workspaceDirs() { - const dirs = []; - for (const glob of workspaceGlobs()) { - const star = glob.endsWith('/*'); - const base = star ? glob.slice(0, -2) : glob; - if (base.includes('*')) { - throw new Error( - `${WORKSPACE_FILE}: pattern "${glob}" is richer than or /*; extend ${SELF}`, - ); - } - const abs = join(ROOT, base); - if (!existsSync(abs)) continue; - const candidates = star ? readdirSync(abs).map((e) => posix.join(base, e)) : [base]; - for (const c of candidates) { - if (existsSync(join(ROOT, c, 'package.json'))) dirs.push(c); - } - } - return dirs.sort(); -} +/** + * Workspace member directories, relative to the repo root. + * + * From `scripts/workspace-enumerator.mjs` (#11510) rather than a private parse. + * + * ⚠️ Importing it does NOT weaken the refusal declared at the top of this file. + * That refusal is about a path POPULATION, and the enumerator deliberately + * declares none: measured with dispatch-gates' own `extractWatchHints`, the + * module contributes zero watch hints to zero families (positive control on the + * same run: check-published-files.mjs contributes 14 hints / 7254 pairs). Had + * the enumerator spelled the workspace globs as literals, this gate would have + * inherited them and gone from 2 matched files to 5397 — precisely the + * fabricated population the docblock above measured and refused. + */ +const workspaceDirs = () => workspacePackageDirs(ROOT); /** Package-relative POSIX paths of every non-build file in a package. */ function walk(absDir, prefix = '', out = []) { @@ -3468,6 +3447,10 @@ function selfTest() { ); } + // The shared workspace enumerator is a plain module with no CI invocation of + // its own (#11510); every gate that consolidated onto it folds in its checks. + failures.push(...workspaceEnumeratorSelfTest({ root: ROOT })); + if (failures.length > 0) { console.error(`✗ check:published-readme-exports --self-test — ${failures.length} failure(s)\n`); for (const f of failures) console.error(` ${f}`); diff --git a/scripts/check-test-source-alias.mjs b/scripts/check-test-source-alias.mjs index 0503dda71d..4d05800114 100644 --- a/scripts/check-test-source-alias.mjs +++ b/scripts/check-test-source-alias.mjs @@ -284,6 +284,11 @@ import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSy import { stripComments, scanSource, blank } from './js-comment-mask.mjs'; import { join, resolve, relative, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + isExclusionGlob, + readWorkspaceGlobs, + selfTest as workspaceEnumeratorSelfTest, +} from './workspace-enumerator.mjs'; import { tmpdir } from 'node:os'; import process from 'node:process'; @@ -2698,6 +2703,44 @@ function selfTest() { `workspace parent ${glob} carries no path separator, so scripts/pm/dispatch-gates.mjs refuses it as too generic and every package under it drops out of the derived gate list`, ); } + + // ── the declaration must still BE the workspace (#11510) ────────────── + // + // WORKSPACE_PARENT_GLOBS is a hand-written copy of the `packages:` block, + // and the case above only checks each entry's SHAPE. Nothing checked that + // the entries were still the right ones. Two gates carried byte-identical + // 11-entry copies of this array with the same blind spot in both, which is + // exactly what a hand-maintained mirror does: a workspace root added to + // pnpm-workspace.yaml leaves both walking the old set, both green, and no + // dispatch brief naming either gate for the new root. + // + // The array is NOT replaced by the live parse. It is this gate's declared + // population, the only thing that tells scripts/pm/dispatch-gates.mjs which + // cards belong here, and a runtime parse spells no literal at all — the + // #11190 measurement that made consolidation safe in the first place. So + // the declaration stays and the live parse becomes its CHECK, in both + // directions, the shape check-published-files.mjs already uses. + const declaredParents = WORKSPACE_PARENT_GLOBS.map((g) => g.replace(/\/\*+$/, '')); + const liveParents = readWorkspaceGlobs(REPO_ROOT) + .filter((g) => !isExclusionGlob(g)) + .map((g) => g.replace(/\/\*+$/, '')); + for (const parent of liveParents) { + expect( + declaredParents.includes(parent), + `pnpm-workspace.yaml declares the workspace root ${parent}, which WORKSPACE_PARENT_GLOBS does not — this gate walks it but no card is dispatched here for it`, + ); + } + for (const parent of declaredParents) { + expect( + liveParents.includes(parent), + `WORKSPACE_PARENT_GLOBS declares ${parent}, which pnpm-workspace.yaml does not — a declaration that can drift from the workspace is worse than none, it replaces a silent gate with a lying one`, + ); + } + + // The shared enumerator is a plain module with no CI invocation of its own + // (#11510 — being a gate is exactly what it must not be); every script that + // consolidated onto it folds in its checks. + for (const failure of workspaceEnumeratorSelfTest({ root: REPO_ROOT })) expect(false, failure); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index f5e5e4f8b7..eaf8343264 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -413,12 +413,15 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, posix, resolve } from 'node:path'; +import { + selfTest as workspaceEnumeratorSelfTest, + workspacePackageDirs, +} from './workspace-enumerator.mjs'; // Anchored to the script, not to cwd: the verdict must not depend on where the // guard was invoked from. const ROOT = resolve(import.meta.dirname, '..'); const SELF = 'scripts/check-type-check-coverage.mjs'; -const WORKSPACE_FILE = 'pnpm-workspace.yaml'; const TRACKING_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/4311'; // An `exclude` pattern that names tests (`**/*.test.ts`, `**/*.spec.tsx`, ...) // and the files such a pattern hides. Kept deliberately broad: the question is @@ -1234,30 +1237,6 @@ const GENERATED_INCLUDE_ROOTS = { }, }; -/** - * The `packages:` globs from pnpm-workspace.yaml. Blank lines and comments are - * skipped rather than treated as the end of the list: stopping early would - * drop members from the scan and report a clean run over a partial workspace. - */ -function workspaceGlobs() { - const lines = readFileSync(join(ROOT, WORKSPACE_FILE), 'utf8').split(/\r?\n/); - const start = lines.findIndex((l) => /^packages\s*:\s*$/.test(l)); - if (start === -1) throw new Error(`${WORKSPACE_FILE}: no top-level \`packages:\` block`); - const globs = []; - for (let i = start + 1; i < lines.length; i++) { - const line = lines[i].replace(/#.*$/, '').trimEnd(); - if (!line.trim()) continue; - const m = line.match(/^\s+-\s+['"]?([^'"\s]+)['"]?\s*$/); - if (m) { - globs.push(m[1]); - continue; - } - if (/^\S/.test(line)) break; // the next top-level key ends the block - } - if (globs.length === 0) throw new Error(`${WORKSPACE_FILE}: \`packages:\` block is empty`); - return globs; -} - /** * One `tsconfig*.json` of a package, read with a tolerant parse -- these configs * carry `//` comments, and a parse failure must not silently read as "excludes @@ -1678,23 +1657,12 @@ function testCoverage(dir, scripts) { /** Every workspace member as { name, dir, scripts, hasTsconfig, hidesTests, testFiles }. */ function workspacePackages() { - const dirs = []; - for (const glob of workspaceGlobs()) { - // Every pattern in this repo is `` or `/*`. Anything richer - // would silently resolve to nothing, so reject it rather than under-report. - const star = glob.endsWith('/*'); - const base = star ? glob.slice(0, -2) : glob; - if (base.includes('*')) { - throw new Error(`${WORKSPACE_FILE}: pattern "${glob}" is richer than or /*; extend ${SELF}`); - } - const abs = join(ROOT, base); - if (!existsSync(abs)) continue; - const candidates = star ? readdirSync(abs).map((e) => posix.join(base, e)) : [base]; - for (const c of candidates) { - if (existsSync(join(ROOT, c, 'package.json'))) dirs.push(c); - } - } - const packages = dirs.sort().map((dir) => { + // Membership comes from scripts/workspace-enumerator.mjs (#11510) — this repo's + // one parse of the workspace file, and one of nine private copies before it. + // It refuses a glob richer than `` or `/*` rather than expanding it + // to nothing, which is the posture this function already took. + const dirs = workspacePackageDirs(ROOT); + const packages = dirs.map((dir) => { const manifest = JSON.parse(readFileSync(join(ROOT, dir, 'package.json'), 'utf8')); return { name: manifest.name ?? dir, @@ -4889,6 +4857,10 @@ function selfTest() { } } + // The shared workspace enumerator is a plain module with no CI invocation of + // its own (#11510); every gate that consolidated onto it folds in its checks. + failures.push(...workspaceEnumeratorSelfTest({ root: ROOT })); + if (failures.length) { console.error(`✗ check:type-check-coverage --self-test — ${failures.length} failure(s)\n`); for (const f of failures) console.error(' • ' + f); diff --git a/scripts/check-type-source-resolution.mjs b/scripts/check-type-source-resolution.mjs index 6b83912690..6ceb4bc7f2 100644 --- a/scripts/check-type-source-resolution.mjs +++ b/scripts/check-type-source-resolution.mjs @@ -144,6 +144,11 @@ import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join, resolve, relative, dirname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + isExclusionGlob, + readWorkspaceGlobs, + selfTest as workspaceEnumeratorSelfTest, +} from './workspace-enumerator.mjs'; import { tmpdir } from 'node:os'; import process from 'node:process'; @@ -1367,6 +1372,44 @@ function selfTest() { `workspace parent ${glob} carries no path separator, so scripts/pm/dispatch-gates.mjs refuses it as too generic and every package under it drops out of the derived gate list`, ); } + + // ── the declaration must still BE the workspace (#11510) ────────────── + // + // WORKSPACE_PARENT_GLOBS is a hand-written copy of the `packages:` block, + // and the case above only checks each entry's SHAPE. Nothing checked that + // the entries were still the right ones. Two gates carried byte-identical + // 11-entry copies of this array with the same blind spot in both, which is + // exactly what a hand-maintained mirror does: a workspace root added to + // pnpm-workspace.yaml leaves both walking the old set, both green, and no + // dispatch brief naming either gate for the new root. + // + // The array is NOT replaced by the live parse. It is this gate's declared + // population, the only thing that tells scripts/pm/dispatch-gates.mjs which + // cards belong here, and a runtime parse spells no literal at all — the + // #11190 measurement that made consolidation safe in the first place. So + // the declaration stays and the live parse becomes its CHECK, in both + // directions, the shape check-published-files.mjs already uses. + const declaredParents = WORKSPACE_PARENT_GLOBS.map((g) => g.replace(/\/\*+$/, '')); + const liveParents = readWorkspaceGlobs(REPO_ROOT) + .filter((g) => !isExclusionGlob(g)) + .map((g) => g.replace(/\/\*+$/, '')); + for (const parent of liveParents) { + expect( + declaredParents.includes(parent), + `pnpm-workspace.yaml declares the workspace root ${parent}, which WORKSPACE_PARENT_GLOBS does not — this gate walks it but no card is dispatched here for it`, + ); + } + for (const parent of declaredParents) { + expect( + liveParents.includes(parent), + `WORKSPACE_PARENT_GLOBS declares ${parent}, which pnpm-workspace.yaml does not — a declaration that can drift from the workspace is worse than none, it replaces a silent gate with a lying one`, + ); + } + + // The shared enumerator is a plain module with no CI invocation of its own + // (#11510 — being a gate is exactly what it must not be); every script that + // consolidated onto it folds in its checks. + for (const failure of workspaceEnumeratorSelfTest({ root: REPO_ROOT })) expect(false, failure); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/pnpm-filter-targets.mjs b/scripts/pnpm-filter-targets.mjs index 55a583f733..42aad7ccef 100644 --- a/scripts/pnpm-filter-targets.mjs +++ b/scripts/pnpm-filter-targets.mjs @@ -87,6 +87,13 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { + WORKSPACE_FILE, + WorkspaceEnumerationError, + parseWorkspaceGlobs, + selfTest as workspaceEnumeratorSelfTest, + workspacePackageDirs, +} from './workspace-enumerator.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -153,102 +160,67 @@ export function findWorkspaceRoot(startDir) { /** * The `packages:` globs of a pnpm-workspace.yaml. * - * Hand-read rather than parsed with the `yaml` dependency, because the caller - * that matters most (`os-verify-lock.sh --preflight`) must work in a tree whose - * `node_modules` is absent or half-installed -- the exact situation in which a - * verification command is most likely to be wrong. The shape is a flat list of - * scalars; anything else is not this file. + * Delegates to `scripts/workspace-enumerator.mjs` (#11510), this repo's one + * parse of that block. Kept as an export because this module's `--self-test` + * pins it and `os-verify-lock.sh --preflight` reaches the workspace through + * here; the enumerator has no dependencies either, which is the property that + * matters on the preflight path (it runs in trees whose `node_modules` is + * absent or half-installed). * * @param {string} text * @returns {string[]} */ export function workspacePatterns(text) { - const out = []; - let inPackages = false; - for (const raw of String(text).split('\n')) { - const line = raw.replace(/\s+$/, ''); - if (/^packages:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (!inPackages) continue; - const item = /^\s+-\s+(.*)$/.exec(line); - if (item) { - const value = item[1].trim().replace(/^['"]|['"]$/g, ''); - if (value) out.push(value); - continue; - } - if (line.trim() === '' || /^\s*#/.test(line)) continue; - break; // a new top-level key ends the list - } - return out; + return parseWorkspaceGlobs(text); } /** - * Expand one workspace glob to directories. Only `*` segments are expanded -- - * the shape this repo's workspace file uses (`packages/*`, `packages/qa/*`). + * Every package name this workspace declares. * - * @param {string} root - * @param {string} pattern - * @returns {string[]} absolute directories - */ -export function expandPattern(root, pattern) { - let dirs = [root]; - for (const segment of pattern.split('/')) { - if (!segment || segment === '.') continue; - const next = []; - for (const dir of dirs) { - if (segment.includes('*')) { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - const re = new RegExp(`^${segment.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === 'node_modules') continue; - if (re.test(entry.name)) next.push(join(dir, entry.name)); - } - } else { - const candidate = join(dir, segment); - try { - if (statSync(candidate).isDirectory()) next.push(candidate); - } catch { - /* not there */ - } - } - } - dirs = next; - } - return dirs; -} - -/** - * Every package name this workspace declares, plus the root manifest's own. + * Membership comes from `scripts/workspace-enumerator.mjs` (#11510), this + * repo's one parse and expansion of the `packages:` block. + * + * ## Why an unreadable workspace is ALLOWED here and refused everywhere else + * + * The enumerator refuses a workspace file it cannot read an answer out of — + * an absent `packages:` key, an empty one, a glob shape it does not expand — + * because for the gates that enumerate the workspace a silent empty list is a + * clean run over nothing. + * + * This caller is the exception, and deliberately: `os-verify-lock.sh + * --preflight` runs it ahead of EVERY verification command, and its job is to + * judge whether a `--filter` selector names a real package. "I could not read + * the workspace" is not a finding about the selector — turning it into a + * refusal would block verification across the repo on a malformed file this + * module does not own. So an unreadable workspace lands in the same bucket as + * an absent one, which is the bucket this function already had, and the + * callers' `names.length === 0` guard turns it into `allow`. Made explicit + * here rather than left to a parser that happened to return `[]`. * * @param {string} root workspace root * @returns {{ names: string[], dirs: string[] }} */ export function listWorkspacePackages(root) { - const wsFile = join(root, 'pnpm-workspace.yaml'); - if (!existsSync(wsFile)) return { names: [], dirs: [] }; + if (!existsSync(join(root, WORKSPACE_FILE))) return { names: [], dirs: [] }; + let memberDirs; + try { + memberDirs = workspacePackageDirs(root); + } catch (err) { + if (err instanceof WorkspaceEnumerationError) return { names: [], dirs: [] }; + throw err; + } const names = []; const dirs = []; - for (const pattern of workspacePatterns(readFileSync(wsFile, 'utf8'))) { - if (pattern.startsWith('!')) continue; - for (const dir of expandPattern(root, pattern)) { - const manifest = join(dir, 'package.json'); - if (!existsSync(manifest)) continue; - try { - const parsed = JSON.parse(readFileSync(manifest, 'utf8')); - if (typeof parsed.name === 'string' && parsed.name) { - names.push(parsed.name); - dirs.push(dir); - } - } catch { - /* an unparseable manifest is not this module's finding */ + for (const rel of memberDirs) { + const dir = join(root, rel); + try { + const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + if (typeof parsed.name === 'string' && parsed.name) { + names.push(parsed.name); + dirs.push(dir); } + } catch { + /* an unparseable manifest is not this module's finding */ } } return { names: [...new Set(names)].sort(), dirs }; @@ -700,6 +672,10 @@ export async function selfTest() { ok('and stops at the next top-level key, not at the end of the file', !patterns.some((p) => p.includes(':'))); ok('and holds the nested ones', patterns.includes('packages/adapters/*') || patterns.includes('packages/drivers/*')); + // The shared workspace enumerator is a plain module with no CI invocation of + // its own (#11510); every script that consolidated onto it folds in its checks. + failures.push(...workspaceEnumeratorSelfTest({ root: root ?? HERE })); + if (failures.length === 0) { console.log( `✓ pnpm-filter-targets --self-test: ${checked} assertions over ${names.length} real workspace packages ` diff --git a/scripts/release-github-releases.mjs b/scripts/release-github-releases.mjs index 2c44a9ac5e..c44b340298 100644 --- a/scripts/release-github-releases.mjs +++ b/scripts/release-github-releases.mjs @@ -74,10 +74,11 @@ * standard Actions context. */ -import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { workspacePackages } from './workspace-enumerator.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); @@ -302,71 +303,6 @@ export function buildReleaseBody({ entry, tagName, changelogLabel, changelogHref // // dispatch-gates: no-path-population -- check:release-body runs this renderer's --self-test against fixtures only; the workspace and CHANGELOG reads belong to the release run, which no pull request schedules -/** - * Minimal pnpm-workspace.yaml `packages:` reader. Same approach as - * scripts/check-changeset-fixed.mjs — no YAML dependency. - * - * @param {string} root - * @returns {string[]} - */ -function readWorkspacePatterns(root) { - const text = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); - const patterns = []; - let inPackages = false; - for (const raw of text.split(/\r?\n/)) { - const line = raw.replace(/#.*$/, '').replace(/\s+$/, ''); - if (!line.trim()) continue; - if (/^packages\s*:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (!inPackages) continue; - const m = /^\s+-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); - if (m) { - patterns.push(m[1]); - continue; - } - if (/^\S/.test(line)) inPackages = false; - } - return patterns; -} - -/** - * Expand a `packages/*`-style pattern (single `*` per segment). - * - * @param {string} root - * @param {string} pattern - * @returns {string[]} - */ -function expandPattern(root, pattern) { - let dirs = [root]; - for (const seg of pattern.split('/')) { - const next = []; - for (const dir of dirs) { - if (seg === '*') { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (entry.isDirectory() && !entry.name.startsWith('.')) next.push(join(dir, entry.name)); - } - } else { - const candidate = join(dir, seg); - try { - if (statSync(candidate).isDirectory()) next.push(candidate); - } catch { - /* missing — skip */ - } - } - } - dirs = next; - } - return dirs; -} - /** * Every non-private workspace package, by name. * @@ -376,18 +312,19 @@ function expandPattern(root, pattern) { export function listWorkspacePackages(root = REPO_ROOT) { /** @type {Map} */ const byName = new Map(); - for (const pattern of readWorkspacePatterns(root)) { - for (const dir of expandPattern(root, pattern)) { - let pkg; - try { - pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - } catch { - continue; - } - if (!pkg.name || pkg.private === true) continue; - if (byName.has(pkg.name)) continue; - byName.set(pkg.name, { name: pkg.name, version: pkg.version, dir }); - } + // Membership comes from scripts/workspace-enumerator.mjs (#11510) — this + // repo's one parse of `pnpm-workspace.yaml`, and one of nine private copies + // before it. That module declares NO path population of its own, which is + // load-bearing HERE specifically: this file carries a `no-path-population` + // marker a few lines up, and dispatch-gates' self-test fails any family that + // both declares one and names paths anyway. An enumerator that spelled the + // workspace globs as literals would have handed this gate 5395 inherited + // pairs and turned check:release-body red. Measured after this change: + // zero hints inherited, marker intact. + for (const { dir, manifest } of workspacePackages(root)) { + if (!manifest.name || manifest.private === true) continue; + if (byName.has(manifest.name)) continue; + byName.set(manifest.name, { name: manifest.name, version: manifest.version, dir: join(root, dir) }); } return byName; } diff --git a/scripts/workspace-enumerator.mjs b/scripts/workspace-enumerator.mjs new file mode 100644 index 0000000000..e89a924234 --- /dev/null +++ b/scripts/workspace-enumerator.mjs @@ -0,0 +1,477 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * workspace-enumerator — the ONE parse of `pnpm-workspace.yaml`'s `packages:` + * block, and the ONE expansion of those globs to member directories. + * + * Nine scripts used to carry a private copy of this parse. Measured on the tree + * this module landed on, they fell into four behaviour clusters that agreed on + * the repo's real file and disagreed on nine of seventeen adversarial inputs — + * a shared answer that was only ever true by coincidence. The clusters, and + * which way this module settles each divergence, are the table below. + * + * ## Why a plain module and NOT a gate of its own (#11190 step 1, #11510) + * + * `scripts/pm/dispatch-gates.mjs` follows a gate script's first-party imports + * one level down, so a population declared in a shared module reaches every + * gate that imports it. That follow deliberately REFUSES to open a module that + * is itself resolved from some workflow's `check:` invocation: such a module's + * population already reaches the tree through its own family, and attributing + * it to every importer was measured at +3065 fabricated (gate, file) pairs for + * a single caller. So this file is a plain module — no `check:*` script names + * it, no workflow invokes it — the shape `scripts/i18n-bundle-surface.mjs` and + * `scripts/regen-artifacts.mjs` already have. + * + * ## ⛔ THIS MODULE DECLARES NO PATH POPULATION, AND MUST NOT GROW ONE + * + * This is the single most load-bearing property of the file, it is not + * obvious, and `selfTest` pins it. + * + * Because the import follow appends a followed module's watch hints to EVERY + * importer, a `'packages/*'`-shaped literal written anywhere in this module + * body would hand the whole workspace population to all nine callers at once. + * Priced on the live tree before this module was written, with the eleven + * workspace globs as literals here: + * + * check-changeset-fixed.mjs 1 -> 5396 (+5395) + * check:release-body 0 -> 5395 (+5395) ⛔ see below + * check:pnpm-filter-targets 272 -> 5667 (+5395) + * check:published-readme-exports 2 -> 5397 (+5395) + * check:override-consistency 0 -> 5395 (+5395) + * check:type-check-coverage 98 -> 5398 (+5300) + * check:type-check-debt 98 -> 5398 (+5300) + * check-dev-prereqs.mjs 1245 -> 5395 (+4150) + * check:published-files 5396 -> 5396 ( 0) + * TOTAL +41725 + * + * 41725 pairs, 13.6x the +3065 the follow already refuses on provenance. And + * it is not merely expensive, it is CONTRADICTORY: three of those callers have + * measured this exact declaration and refused it in writing — + * check-published-readme-exports.mjs (2.8% of the declared files are ever + * opened, its refusal docblock carries the number), check-dev-prereqs.mjs (CI + * runs its `--self-test` only), and release-github-releases.mjs, which carries + * a `dispatch-gates: no-path-population` marker. That marker is held against + * the live derivation by dispatch-gates' own self-test ("no family both + * DECLARES no path population and names paths anyway"), so a literal here does + * not merely inflate a count — it turns that gate RED, in a file whose author + * never touched it. + * + * The one string this module does spell, `pnpm-workspace.yaml`, is safe and + * that was measured too: `hintCovers` refuses a literal with no path separator + * as too generic, so it contributes zero pairs to zero families. + * + * The consequence for a reader: each gate keeps declaring its OWN population in + * its OWN module body — `ROOT_DIR_WATCH_HINTS` in check-published-files.mjs, + * `WORKSPACE_PARENT_GLOBS` in check-test-source-alias.mjs and + * check-type-source-resolution.mjs. What is consolidated here is the PARSE, + * never the DECLARATION. Those are two different things and the measurement + * above is why they cannot share a module. + * + * ## How the divergences are settled, case by case + * + * Every row was measured against the real source bytes of all nine parsers. + * "today" records whether the repo's current `pnpm-workspace.yaml` can tell + * the difference — every row reads "no", which is why nothing observable + * changes on this tree and why every one of these was a latent trap. + * + * input | old answers | here | today + * -----------------------------|----------------------|-----------|------- + * `- pkg/* # trailing note` | strip / keep / STOP | strip | no + * `- vendor/c#sharp` | truncate / keep | keep | no + * full-line comment in list | skip / STOP | skip | no + * blank line in list | skip (all agree) | skip | no + * `packages :` (space) | accept / ignore | accept | no + * a SECOND `packages:` block | append / first-wins | REFUSE | no + * no `packages:` key | [] / throw | throw | no + * empty `packages:` block | [] / throw | throw | no + * `- packages/**` | expand-nothing/throw | throw | no + * + * Three of those deserve their reason stated, because each is a silent-failure + * class rather than a preference: + * + * - A `#` is a YAML comment only when it starts a line or follows whitespace. + * Four parsers stripped `/#.*$/` unconditionally, which silently truncates + * `vendor/c#sharp` to `vendor/c` — a member directory that does not exist, + * dropped from the scan with no error. Two others kept the trailing comment + * glued to the pattern, which is the same silent drop by the other route. + * Only the whitespace rule is right, and no old parser implemented it. + * + * - An empty or absent `packages:` block returned `[]` in three parsers. That + * is a clean run over an empty workspace: every "is every member covered" + * gate passes vacuously, loudly green. It throws here. Callers that must + * tolerate a MISSING FILE (pnpm-filter-targets' `--preflight` runs in trees + * that have none) test for the file themselves — absent file and unparseable + * file are different questions and stay that way. + * + * - A duplicate top-level `packages:` key is invalid YAML, and pnpm would + * reject it. Three parsers stopped at the first block, three resumed and + * APPENDED the second block's entries to the first's. Neither is a reading + * anyone chose; this module refuses the input instead of picking a winner. + * + * ## No dependencies, deliberately + * + * `scripts/pm/os-verify-lock.sh --preflight` calls into pnpm-filter-targets in + * a tree whose `node_modules` is absent or half-installed — the exact situation + * in which a verification command is most likely to be wrong. Nothing here may + * import outside `node:`. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, posix } from 'node:path'; +import { maskComments } from './js-comment-mask.mjs'; + +/** + * The workspace manifest's filename. + * + * Safe to spell: `hintCovers` refuses a literal carrying no path separator as + * too generic, so this contributes no watch hint to any importer. See this + * file's header for why that matters more than it looks. + */ +export const WORKSPACE_FILE = 'pnpm-workspace.yaml'; + +/** Directory names never treated as workspace members. */ +const NEVER_A_MEMBER = new Set(['node_modules']); + +/** + * A workspace file this module refuses to read an answer out of. Callers map it + * onto their own failure vocabulary (check-dev-prereqs.mjs rethrows it as its + * `CoverageError`); what matters is that it is never swallowed into an empty + * list, which is the vacuous-pass failure the header describes. + */ +export class WorkspaceEnumerationError extends Error { + constructor(message) { + super(message); + this.name = 'WorkspaceEnumerationError'; + } +} + +/** + * Strip a YAML line comment, and ONLY a YAML line comment: a `#` that opens the + * line or follows whitespace. A `#` with a non-space character before it is an + * ordinary character of the scalar. + * + * @param {string} line + * @returns {string} + */ +export function stripLineComment(line) { + const m = /(^|\s)#/.exec(line); + return m === null ? line : line.slice(0, m.index + m[1].length); +} + +/** + * The `packages:` globs a workspace file declares, in file order. + * + * Blank lines and whole-line comments inside the block are skipped rather than + * treated as its end; the next top-level key ends it. An absent block, an empty + * block, and a duplicate `packages:` key are all refusals — see the header. + * + * @param {string} text the workspace file's contents + * @param {{ source?: string }} [options] `source` names the file in errors + * @returns {string[]} + */ +export function parseWorkspaceGlobs(text, { source = WORKSPACE_FILE } = {}) { + const lines = String(text).split(/\r?\n/); + const isKey = (line) => /^packages\s*:\s*$/.test(line); + const start = lines.findIndex(isKey); + if (start === -1) { + throw new WorkspaceEnumerationError( + `${source}: no top-level \`packages:\` block — refusing to report an empty workspace as a clean one.`, + ); + } + const globs = []; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + const line = stripLineComment(lines[i]).trimEnd(); + if (!line.trim()) continue; + const item = /^\s+-\s+(?:(['"])(.*)\1|(\S.*?))\s*$/.exec(line); + if (item) { + globs.push(item[2] ?? item[3]); + continue; + } + if (/^\S/.test(line)) { + end = i; + break; + } + } + // Refuse a duplicate top-level key rather than pick a winner: three of the + // parsers this replaces stopped at the first block and three appended the + // second, and pnpm accepts neither file. + const second = lines.findIndex((line, i) => i >= end && isKey(line)); + if (second !== -1) { + throw new WorkspaceEnumerationError( + `${source}: a second top-level \`packages:\` key at line ${second + 1} — duplicate keys are invalid YAML and the two blocks disagree about the workspace.`, + ); + } + if (globs.length === 0) { + throw new WorkspaceEnumerationError( + `${source}: the \`packages:\` block declares no members — refusing to scan an empty workspace.`, + ); + } + return globs; +} + +/** + * The `packages:` globs of the workspace rooted at `root`. + * + * A MISSING file is this function's refusal too. Callers for whom "no workspace + * here" is an ordinary answer (pnpm-filter-targets outside a workspace) test + * with `existsSync` first; the two questions are kept apart on purpose. + * + * @param {string} root + * @returns {string[]} + */ +export function readWorkspaceGlobs(root) { + const file = join(root, WORKSPACE_FILE); + if (!existsSync(file)) { + throw new WorkspaceEnumerationError( + `${WORKSPACE_FILE} is missing under ${root} — cannot enumerate workspace packages.`, + ); + } + return parseWorkspaceGlobs(readFileSync(file, 'utf8')); +} + +/** Whether a glob is a pnpm exclusion (`!pattern`), which enumerates nothing. */ +export function isExclusionGlob(glob) { + return glob.startsWith('!'); +} + +/** + * Expand ONE glob to repo-relative member directories. + * + * `` and `/*` are the two shapes this workspace uses and the only two + * accepted. Anything richer throws rather than expanding to nothing: four of the + * parsers this replaces quietly returned no directories for `packages/**`, which + * removes members from a scan while the gate reports a clean run over what is + * left. Loud beats vacuous, which is the posture three of the nine already took. + * + * @param {string} root + * @param {string} glob + * @param {{ source?: string }} [options] + * @returns {string[]} repo-relative POSIX directories, unsorted + */ +export function expandWorkspaceGlob(root, glob, { source = WORKSPACE_FILE } = {}) { + if (isExclusionGlob(glob)) return []; + const star = glob.endsWith('/*'); + const base = star ? glob.slice(0, -2) : glob; + if (base.includes('*')) { + throw new WorkspaceEnumerationError( + `${source}: pattern ${JSON.stringify(glob)} is richer than \`\` or \`/*\`.\n` + + ` Teach scripts/workspace-enumerator.mjs the new shape — silently covering fewer packages\n` + + ` would make every gate that enumerates the workspace pass vacuously.`, + ); + } + const abs = join(root, base); + if (!existsSync(abs) || !statSync(abs).isDirectory()) return []; + if (!star) return [base]; + return readdirSync(abs, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !NEVER_A_MEMBER.has(e.name)) + .map((e) => posix.join(base, e.name)); +} + +/** + * Every directory the workspace globs enumerate, whether or not it holds a + * manifest. `workspacePackageDirs` is the narrower answer most callers want; + * this one exists because check-dev-prereqs.mjs needs the membership set itself. + * + * @param {string} root + * @returns {string[]} repo-relative POSIX directories, sorted and deduplicated + */ +export function workspaceMemberDirs(root) { + const dirs = new Set(); + for (const glob of readWorkspaceGlobs(root)) { + for (const dir of expandWorkspaceGlob(root, glob)) dirs.add(dir); + } + return [...dirs].sort(); +} + +/** + * Every workspace member that actually holds a `package.json`. + * + * @param {string} root + * @returns {string[]} repo-relative POSIX directories, sorted + */ +export function workspacePackageDirs(root) { + return workspaceMemberDirs(root).filter((dir) => existsSync(join(root, dir, 'package.json'))); +} + +/** + * Every workspace member's directory paired with its parsed manifest. + * A member whose manifest is unparseable is skipped — that is another gate's + * finding, and failing here would make every enumerating gate red for it. + * + * @param {string} root + * @returns {Array<{ dir: string, manifest: Record }>} + */ +export function workspacePackages(root) { + const out = []; + for (const dir of workspacePackageDirs(root)) { + try { + out.push({ dir, manifest: JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) }); + } catch { + /* an unparseable manifest is not this module's finding */ + } + } + return out; +} + +/** + * The shared assertions, returned rather than printed so each importing gate + * can fold them into its own `--self-test` report. + * + * This module is deliberately not a gate (see the header), so it has no CI + * invocation of its own: its coverage is that every consolidated caller runs + * `--self-test` in lint.yml and every one of them calls this. + * + * @param {{ root?: string }} [options] `root` enables the live half + * @returns {string[]} failure descriptions; empty means OK + */ +export function selfTest({ root = null } = {}) { + const failures = []; + const t = (name, ok) => { + if (!ok) failures.push(`workspace-enumerator: ${name}`); + }; + // Every fixture path is ASSEMBLED, never spelled. A path-shaped literal + // anywhere in this file — a self-test fixture included — is read by + // `extractWatchHints` and inherited by every importing gate. Measured while + // this module was being written: fixtures spelled the obvious way leaked + // `packages/*` and `apps/*` and cost 10273 (gate, file) pairs, and the + // masking that was supposed to hide them did not. Assembling them makes the + // header's "declares no path population" claim true by construction rather + // than true by a rule in another file continuing to behave. + const P = (...segments) => segments.join('/'); + const PKGS = P('packages', '*'); + const APPS = P('apps', '*'); + const answer = (text) => { + try { + return JSON.stringify(parseWorkspaceGlobs(text)); + } catch (err) { + return err instanceof WorkspaceEnumerationError ? 'REFUSED' : `THREW ${err?.name}`; + } + }; + const both = JSON.stringify([PKGS, APPS]); + const flat = JSON.stringify([PKGS]); + + // ── the parse, one case per divergence the consolidation settled ────────── + t('a plain list parses', answer(`packages:\n - ${PKGS}\n - ${APPS}\n`) === both); + t('quotes are stripped', answer(`packages:\n - '${PKGS}'\n - "${APPS}"\n`) === both); + t('CRLF parses the same', answer(`packages:\r\n - ${PKGS}\r\n - ${APPS}\r\n`) === both); + t('a blank line inside the list is not its end', answer(`packages:\n - ${PKGS}\n\n - ${APPS}\n`) === both); + t('a whole-line comment inside the list is not its end', answer(`packages:\n - ${PKGS}\n # a note\n - ${APPS}\n`) === both); + t('the next top-level key ends the list', answer(`packages:\n - ${PKGS}\nonlyBuiltDependencies:\n - esbuild\n`) === flat); + t('`packages :` with a space is still the key', answer(`packages :\n - ${PKGS}\n`) === flat); + t( + 'an exclusion entry survives the parse (the expansion drops it)', + answer(`packages:\n - ${PKGS}\n - "!${P('packages', 'legacy')}"\n`) === JSON.stringify([PKGS, `!${P('packages', 'legacy')}`]), + ); + + // The `#` rule, both directions — the divergence no old parser got right. + t('a trailing comment is stripped off an entry', answer(`packages:\n - ${PKGS} # the flat ones\n`) === flat); + const HASHY = P('vendor', 'c#sharp'); + t('a `#` with no space before it stays part of the scalar', answer(`packages:\n - ${HASHY}\n`) === JSON.stringify([HASHY])); + t('stripLineComment leaves a bare `#` alone', stripLineComment('a#b') === 'a#b'); + t('stripLineComment cuts at a space-led `#`', stripLineComment('a #b') === 'a '); + t('stripLineComment cuts a line-leading `#`', stripLineComment('#b') === ''); + + // The refusals. Each one replaces a silent empty answer in at least one of + // the parsers this module consolidated. + t('no `packages:` key is REFUSED, never an empty list', answer('onlyBuiltDependencies:\n - esbuild\n') === 'REFUSED'); + t('an empty `packages:` block is REFUSED', answer('packages:\nonlyBuiltDependencies:\n - esbuild\n') === 'REFUSED'); + t('a dash with no space declares nothing, and that is REFUSED', answer(`packages:\n -${PKGS}\n`) === 'REFUSED'); + t( + 'a duplicate `packages:` key is REFUSED rather than resolved', + answer(`packages:\n - ${PKGS}\nother:\n x: 1\npackages:\n - ${P('late', '*')}\n`) === 'REFUSED', + ); + t('the flow-sequence form is REFUSED rather than read as empty', answer(`packages: [${PKGS}, ${APPS}]\n`) === 'REFUSED'); + + // ── the expansion ───────────────────────────────────────────────────────── + const NOWHERE = P('', 'nonexistent'); + const expandRefused = (glob) => { + try { + expandWorkspaceGlob(NOWHERE, glob); + return false; + } catch (err) { + return err instanceof WorkspaceEnumerationError; + } + }; + t('a `**` glob is REFUSED rather than expanded to nothing', expandRefused(P('packages', '**'))); + t('a mid-segment `*` is REFUSED too', expandRefused(P('packages', '*', 'plugins'))); + t( + 'an exclusion glob expands to nothing without touching the disk', + expandWorkspaceGlob(NOWHERE, `!${P('packages', 'legacy')}`).length === 0, + ); + + // ── the property this module exists to keep: NO path population ─────────── + // + // Pinned mechanically, not by review, and read off THIS FILE's own bytes so + // a stale copy cannot satisfy it. A path-shaped literal added here — in the + // parse, in an error message, or in a fixture — is inherited as a watch hint + // by every importing gate: priced in the header at +41725 (gate, file) pairs, + // and it turns check:release-body RED by contradicting its + // `no-path-population` marker. + // + // The predicate below is deliberately STRICTER than the one it guards + // (`extractWatchHints` in scripts/pm/dispatch-gates.mjs): any quoted literal + // containing a separator counts here, where that scanner also applies + // namespace refusals and self-test masking. A mirror is normally a second + // contract that drifts, so it is worth naming why this one cannot bite: it + // can only refuse MORE than the real scanner, so it fails loudly for a + // literal the derivation would have ignored, and never passes one the + // derivation would have taken. + try { + const self = readFileSync(new URL(import.meta.url), 'utf8'); + // maskComments, never a hand-rolled `/\*...\*/` strip. A workspace glob + // CONTAINS a comment opener — the `/` and `*` of `packages/*` are exactly + // `/*` — so a naive stripper starts a comment at the literal it is looking + // for and eats forward to the next `*/`, deleting the evidence. That was + // not theoretical: this guard was written that way first and the ablation + // that plants `['packages/*', 'apps/*']` here passed it, while the same + // planted line really did contribute 5154 pairs per importer. The masker + // scans string and comment state properly, and it is the same one + // dispatch-gates' own extractor composes with. + const body = maskComments(self); + const offending = [...body.matchAll(/['"`]([^'"`\n]{2,120})['"`]/g)] + .map((m) => m[1]) + .filter((raw) => /^[\w.@][\w.@/*-]*$/.test(raw)) + // The same leading-`./` strip extractWatchHints applies before it asks + // whether a literal is pathy, so a relative import specifier scores the + // way it really scores there (`./js-comment-mask.mjs` -> no separator + // left -> not a hint) instead of reading as a population declaration. + .map((raw) => raw.replace(/^(?:\.\.?(?:\/|$))+/, '')) + .filter((raw) => raw.includes('/')) + .filter((raw) => !raw.startsWith('node:')); + t( + `no path-shaped literal in this module body can become a watch hint (found: ${offending.join(', ') || 'none'})`, + offending.length === 0, + ); + } catch (err) { + failures.push(`workspace-enumerator: could not read own source to check for path literals (${err?.message})`); + } + + // ── the live half, when a caller supplies the repo root ─────────────────── + if (root !== null) { + let live = null; + try { + live = readWorkspaceGlobs(root); + } catch (err) { + failures.push(`workspace-enumerator: the repo's own ${WORKSPACE_FILE} does not parse (${err?.message})`); + } + if (live) { + t(`the repo's own ${WORKSPACE_FILE} parses to a non-empty glob list (${live.length})`, live.length > 0); + t( + 'every live glob expands to a shape this module accepts', + live.every((g) => { + try { + expandWorkspaceGlob(root, g); + return true; + } catch { + return false; + } + }), + ); + const members = workspacePackageDirs(root); + t(`the live workspace enumerates packages (${members.length})`, members.length > 0); + } + } + + return failures; +}