From 44470785d7cc21c9908f62d44ab32ea692557a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:40:20 +0000 Subject: [PATCH 1/3] refactor(scripts): consolidate the private pnpm-workspace.yaml parsers behind one shared enumerator Nine scripts carried a private parse of the `packages:` block. Measured against each other on their real source bytes, they formed four behaviour clusters that agreed on this repo's workspace file and disagreed on nine of seventeen adversarial inputs. The shared module is deliberately NOT a gate file and declares NO path population: priced on the live tree, spelling the workspace globs as literals there would have handed importers +41725 (gate, file) pairs and turned check:release-body red by contradicting its no-path-population marker. Each gate keeps declaring its own population. check-test-source-alias and check-type-source-resolution keep WORKSPACE_PARENT_GLOBS and gain a both-directions reconciliation against the live parse, which is what retires the drift between their byte-identical copies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- scripts/ablation-dist-preflight.mjs | 63 +-- scripts/check-changeset-fixed.mjs | 116 ++---- scripts/check-dev-prereqs.mjs | 69 ++- scripts/check-override-consistency.mjs | 98 ++--- scripts/check-published-files.mjs | 76 ++-- scripts/check-published-readme-exports.mjs | 66 ++- scripts/check-test-source-alias.mjs | 43 ++ scripts/check-type-check-coverage.mjs | 57 +-- scripts/check-type-source-resolution.mjs | 43 ++ scripts/pnpm-filter-targets.mjs | 134 +++--- scripts/release-github-releases.mjs | 93 +---- scripts/workspace-enumerator.mjs | 462 +++++++++++++++++++++ 12 files changed, 785 insertions(+), 535 deletions(-) create mode 100644 scripts/workspace-enumerator.mjs 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..0509d20fa2 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -56,11 +56,16 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix, resolve } from 'node:path'; +import { + WORKSPACE_FILE, + 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 +261,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 +405,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 +419,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..4dcdd12d97 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -292,11 +292,16 @@ import process from 'node:process'; import ts from 'typescript'; import { isEntrypoint } from './invoked-as.mjs'; import { createProgramChecked } from './ts-parse.mjs'; +import { + WORKSPACE_FILE, + 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-readme-exports.mjs'; const BASELINE_REL = 'scripts/published-readme-exports.baseline.json'; @@ -920,46 +925,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 +3448,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..7fd72e7da7 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -413,12 +413,16 @@ 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 { + 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 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 +1238,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 +1658,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 +4858,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..7aff942edb --- /dev/null +++ b/scripts/workspace-enumerator.mjs @@ -0,0 +1,462 @@ +// 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'; + +/** + * 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'); + const body = self.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, ''); + const offending = [...body.matchAll(/['"`]([^'"`\n]{2,120})['"`]/g)] + .map((m) => m[1]) + .filter((raw) => /^[\w.@][\w.@/*-]*$/.test(raw)) + .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; +} From 45c62e9f42106ba0990d54fe167dc7f409a26718 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:42:22 +0000 Subject: [PATCH 2/3] fix(scripts): make the enumerator's zero-population guard actually able to fail The guard stripped block comments with a hand-rolled /\*...\*/ regex. A workspace glob contains a comment opener -- the `/` and `*` of `packages/*` ARE `/*` -- so the stripper opened a comment at the literal it was hunting and ate forward to the next `*/`. An ablation planting `['packages/*', 'apps/*']` in the module passed the guard while really contributing 5154 pairs per importer. Now uses maskComments from js-comment-mask.mjs (proper string/comment scanning) and applies extractWatchHints' leading-`./` strip so a relative import specifier is not misread as a declaration. Re-ablated: the planted literal is now caught by all five consolidated gates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- scripts/workspace-enumerator.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/workspace-enumerator.mjs b/scripts/workspace-enumerator.mjs index 7aff942edb..e89a924234 100644 --- a/scripts/workspace-enumerator.mjs +++ b/scripts/workspace-enumerator.mjs @@ -118,6 +118,7 @@ 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. @@ -418,10 +419,24 @@ export function selfTest({ root = null } = {}) { // derivation would have taken. try { const self = readFileSync(new URL(import.meta.url), 'utf8'); - const body = self.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, ''); + // 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( From 38a1d66c9198d3e62b9641256bf9b1c44b26bcc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:48:29 +0000 Subject: [PATCH 3/3] chore(scripts): drop the three enumerator imports left unused by the migration Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- scripts/check-published-files.mjs | 1 - scripts/check-published-readme-exports.mjs | 1 - scripts/check-type-check-coverage.mjs | 1 - 3 files changed, 3 deletions(-) diff --git a/scripts/check-published-files.mjs b/scripts/check-published-files.mjs index 0509d20fa2..7a2bde9e48 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -57,7 +57,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix, resolve } from 'node:path'; import { - WORKSPACE_FILE, readWorkspaceGlobs, selfTest as workspaceEnumeratorSelfTest, workspacePackageDirs, diff --git a/scripts/check-published-readme-exports.mjs b/scripts/check-published-readme-exports.mjs index 4dcdd12d97..f63c09f4e7 100644 --- a/scripts/check-published-readme-exports.mjs +++ b/scripts/check-published-readme-exports.mjs @@ -294,7 +294,6 @@ import { isEntrypoint } from './invoked-as.mjs'; import { createProgramChecked } from './ts-parse.mjs'; import { WORKSPACE_FILE, - readWorkspaceGlobs, selfTest as workspaceEnumeratorSelfTest, workspacePackageDirs, } from './workspace-enumerator.mjs'; diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 7fd72e7da7..eaf8343264 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -414,7 +414,6 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, w import { tmpdir } from 'node:os'; import { join, posix, resolve } from 'node:path'; import { - WORKSPACE_FILE, selfTest as workspaceEnumeratorSelfTest, workspacePackageDirs, } from './workspace-enumerator.mjs';