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;
+}