diff --git a/scripts/check-console-intercept-disarm.mjs b/scripts/check-console-intercept-disarm.mjs index 41a54894b94..b0b6840f32c 100644 --- a/scripts/check-console-intercept-disarm.mjs +++ b/scripts/check-console-intercept-disarm.mjs @@ -115,6 +115,56 @@ const VITEST_CONFIG_NAMES = [ 'vitest.config.cjs', ]; +// The population this gate reads, declared for `scripts/pm/dispatch-gates.mjs` +// (the ROOT_DIR_WATCH_HINTS idiom), so a card that edits a package-root +// manifest or vitest config is told about this gate before CI is. `scan()` +// below opens exactly two files per workspace package: its `package.json` and +// its package-root vitest config. Nothing else under a package moves the +// verdict, so a new test file under an existing package derives nothing. +// +// One pair per glob `workspaceGlobs()` reads, in that order. A `/*` glob +// is spelled `/*/package.json` and `/*/vitest.config.*` — the glob in +// a non-final segment makes the derivation judge it as a pattern, one path +// segment per `*`. A literal member `` would be spelled `/package.json` +// and `/vitest.config` (`watchHintsForGlob`): with no glob ahead of it a +// trailing `.*` is collapsed to `/vitest.config.`, which names nothing, +// while the bare stem reaches every one of VITEST_CONFIG_NAMES through the +// derivation's module-extension equality. None is read today: the parse stops +// at the first comment line in the `packages:` list, so the `ui` members below +// it are not in this gate's population, and are not declared here either. +// +// ⛔ A literal array, never computed from the globs (`check:watch-hint-literal`): +// the derivation reads source text, so a computed list contributes no hint. +// Hand-kept, and `main()` holds it to the workspace file: a glob in +// `pnpm-workspace.yaml` with no pair here, or a pair here for a glob that file +// no longer lists, is a finding (`watchHintDrift`). Not readable from the +// repo-root files themselves — `pnpm-workspace.yaml` and `vitest.preset.mts` +// carry no separator, so no spelling of them is a hint the derivation admits. +export const ROOT_DIR_WATCH_HINTS = [ + 'packages/*/package.json', + 'packages/*/vitest.config.*', + 'packages/apps/*/package.json', + 'packages/apps/*/vitest.config.*', + 'packages/drivers/*/package.json', + 'packages/drivers/*/vitest.config.*', + 'packages/plugins/*/package.json', + 'packages/plugins/*/vitest.config.*', + 'packages/qa/*/package.json', + 'packages/qa/*/vitest.config.*', + 'packages/triggers/*/package.json', + 'packages/triggers/*/vitest.config.*', + 'packages/services/*/package.json', + 'packages/services/*/vitest.config.*', + 'packages/adapters/*/package.json', + 'packages/adapters/*/vitest.config.*', + 'packages/connectors/*/package.json', + 'packages/connectors/*/vitest.config.*', + 'apps/*/package.json', + 'apps/*/vitest.config.*', + 'examples/*/package.json', + 'examples/*/vitest.config.*', +]; + const DISARM_RE = /\bdisableConsoleIntercept\s*:\s*true\b/; const REARM_RE = /\bdisableConsoleIntercept\s*:\s*false\b/; const PROJECTS_RE = /\bprojects\s*:\s*\[/; @@ -207,6 +257,31 @@ const REMEDY = ` // Late console writes must not redden a green suite (#10374 * instead of silently emptying its population. */ export function workspacePackageDirs(root) { + const dirs = []; + for (const glob of workspaceGlobs(root)) { + if (glob.endsWith('/*')) { + const base = join(root, glob.slice(0, -2)); + if (!existsSync(base)) continue; + for (const entry of readdirSync(base, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = join(base, entry.name); + if (existsSync(join(dir, 'package.json'))) dirs.push(dir); + } + } else if (!glob.includes('*')) { + const dir = join(root, glob); + if (existsSync(join(dir, 'package.json'))) dirs.push(dir); + } else { + throw new Error( + `pnpm-workspace.yaml glob '${glob}' is wider than '/*' — teach ` + + `workspacePackageDirs() the new shape before trusting this gate again`, + ); + } + } + return dirs; +} + +/** `pnpm-workspace.yaml`'s `packages:` globs, in file order. Throws when none parse. */ +export function workspaceGlobs(root) { const raw = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8'); const globs = []; let inPackages = false; @@ -227,27 +302,51 @@ export function workspacePackageDirs(root) { if (globs.length === 0) { throw new Error(`no package globs parsed from ${join(root, 'pnpm-workspace.yaml')}`); } - const dirs = []; - for (const glob of globs) { - if (glob.endsWith('/*')) { - const base = join(root, glob.slice(0, -2)); - if (!existsSync(base)) continue; - for (const entry of readdirSync(base, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(base, entry.name); - if (existsSync(join(dir, 'package.json'))) dirs.push(dir); - } - } else if (!glob.includes('*')) { - const dir = join(root, glob); - if (existsSync(join(dir, 'package.json'))) dirs.push(dir); - } else { - throw new Error( - `pnpm-workspace.yaml glob '${glob}' is wider than '/*' — teach ` + - `workspacePackageDirs() the new shape before trusting this gate again`, - ); - } + return globs; +} + +/** + * The watch-hint pair one workspace glob owes `ROOT_DIR_WATCH_HINTS`, spelled + * the way that declaration's comment explains. A glob wider than `/*` owes + * nothing here: `workspacePackageDirs()` already refuses it, loudly. + */ +export function watchHintsForGlob(glob) { + if (glob.endsWith('/*')) return [`${glob}/package.json`, `${glob}/vitest.config.*`]; + if (!glob.includes('*')) return [`${glob}/package.json`, `${glob}/vitest.config`]; + return []; +} + +/** + * Where `ROOT_DIR_WATCH_HINTS` and the workspace globs disagree: `missing` are + * pairs a listed glob owes and the declaration lacks (a package there edits its + * config and no dispatch derivation names this gate); `stale` are declared + * hints no listed glob owes (a lead for a population this gate no longer reads). + */ +export function watchHintDrift(globs, hints = ROOT_DIR_WATCH_HINTS) { + const owed = globs.flatMap(watchHintsForGlob); + return { + missing: owed.filter((h) => !hints.includes(h)), + stale: hints.filter((h) => !owed.includes(h)), + }; +} + +function watchHintDriftFindings(root) { + const { missing, stale } = watchHintDrift(workspaceGlobs(root)); + const findings = []; + if (missing.length > 0) { + findings.push( + `ROOT_DIR_WATCH_HINTS lacks ${missing.join(', ')} — pnpm-workspace.yaml lists the glob, so a ` + + `card editing a package-root manifest or vitest config there is not told about this gate ` + + `until CI. Add the pair to the declaration in scripts/check-console-intercept-disarm.mjs.`, + ); } - return dirs; + if (stale.length > 0) { + findings.push( + `ROOT_DIR_WATCH_HINTS declares ${stale.join(', ')}, which no pnpm-workspace.yaml glob owes — ` + + `remove it from the declaration in scripts/check-console-intercept-disarm.mjs.`, + ); + } + return findings; } /** @returns {{findings: string[], vitestPackages: number}} */ @@ -325,11 +424,12 @@ function main() { let result; try { result = scan(REPO_ROOT); + result.driftFindings = watchHintDriftFindings(REPO_ROOT); } catch (error) { console.error(`check-console-intercept-disarm: MEASUREMENT FAILED — ${error.message}`); process.exit(2); } - const { findings, vitestPackages } = result; + const { findings, vitestPackages, driftFindings } = result; if (vitestPackages === 0) { console.error( 'check-console-intercept-disarm: MEASUREMENT FAILED — the scan found ZERO ' + @@ -343,8 +443,14 @@ function main() { `check-console-intercept-disarm: ${findings.length} package(s) exposed to the ` + `vitest late-console teardown race (#10374):\n\n${findings.join('\n\n')}\n`, ); - process.exit(1); } + if (driftFindings.length > 0) { + console.error( + `check-console-intercept-disarm: the declared watch population has drifted from ` + + `pnpm-workspace.yaml:\n\n${driftFindings.join('\n\n')}\n`, + ); + } + if (findings.length > 0 || driftFindings.length > 0) process.exit(1); console.log( `OK: ${vitestPackages} vitest-running package(s), every one disarms console ` + `interception at the package root.`, @@ -644,6 +750,54 @@ check(() => { } }); + // The declared watch population (ROOT_DIR_WATCH_HINTS) against the REAL + // workspace file, and the drift reading in both directions. Fixture globs are + // assembled from segments so no fixture string reads as a path literal of + // this gate's own. + const realDrift = watchHintDrift(workspaceGlobs(REPO_ROOT)); +check(() => { + if (realDrift.missing.length > 0 || realDrift.stale.length > 0) { + failures += 1; + console.error( + `self-test FAIL: ROOT_DIR_WATCH_HINTS has drifted from pnpm-workspace.yaml — ` + + `missing: ${realDrift.missing.join(', ') || 'none'}; stale: ${realDrift.stale.join(', ') || 'none'}.`, + ); + } +}); + const extraGlob = ['fixture-root', '*'].join('/'); + const addedGlob = watchHintDrift([...workspaceGlobs(REPO_ROOT), extraGlob]); +check(() => { + const want = watchHintsForGlob(extraGlob); + if (want.length !== 2 || addedGlob.missing.join('|') !== want.join('|') || addedGlob.stale.length !== 0) { + failures += 1; + console.error(`self-test FAIL: a workspace glob the declaration lacks is not read as missing: ${JSON.stringify(addedGlob)}`); + } +}); + const droppedGlob = watchHintDrift(workspaceGlobs(REPO_ROOT).filter((g) => g !== workspaceGlobs(REPO_ROOT)[0])); +check(() => { + const want = watchHintsForGlob(workspaceGlobs(REPO_ROOT)[0]); + if (droppedGlob.stale.join('|') !== want.join('|') || droppedGlob.missing.length !== 0) { + failures += 1; + console.error(`self-test FAIL: a declared pair no workspace glob owes is not read as stale: ${JSON.stringify(droppedGlob)}`); + } +}); +check(() => { + // A literal member is spelled with the bare config STEM, never a trailing + // `.*` (which the derivation collapses to a name no file has); a `/*` + // member keeps the `.*`, and a wider glob owes nothing. + const literal = watchHintsForGlob('fixture-root'); + const starred = watchHintsForGlob(extraGlob); + const wide = watchHintsForGlob(['fixture-root', '**'].join('/')); + if ( + literal[1] !== ['fixture-root', 'vitest.config'].join('/') || + starred[1] !== [extraGlob, 'vitest.config.*'].join('/') || + wide.length !== 0 + ) { + failures += 1; + console.error(`self-test FAIL: watchHintsForGlob spelling — ${JSON.stringify({ literal, starred, wide })}`); + } +}); + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -692,7 +846,7 @@ check(() => { } if (failures > 0) process.exit(1); - console.log(`self-test OK: ${cases.length} cases + real-tree population floor.`); + console.log(`self-test OK: ${cases.length} cases + real-tree population floor + watch-hint declaration.`); return SELF_TEST_VERDICT; } diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 1a725273c54..d3d2e7f8a5e 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -278,23 +278,22 @@ export const PATH_LIST_MARKER_KEYS = Object.freeze(['inherited-population', 'sel * reds too: a stale exclusion is an exclusion nobody is measuring any more. */ export const ROOT_WALK_RESIDUE_LEDGER = [ - [ - 'scripts/check-console-intercept-disarm.mjs', - 'its `scan(REPO_ROOT)` walks `workspacePackageDirs(root)` — every workspace PACKAGE ROOT\'s package.json and ' - + 'vitest.config.*, read off pnpm-workspace.yaml. That is workspace-wide but it is not every file: a new test ' - + 'file under an existing package does not move it, a new PACKAGE does. Declaring the whole tree would put it ' - + 'on every card on a population it does not read.', - ], - [ - 'scripts/check-console-intercept-disarm.mjs --self-test', - 'the same gate file, reached through its self-test invocation; the reading above is the whole of it.', - ], [ 'scripts/check-skill-frame-freshness.mjs --self-test', 'lint.yml runs the SELF-TEST HALF and never the scan — its step is named that, and the step comment states why ' + '(the pnpm script would drag the scan in with it). The repo-root default parameter belongs to the scan half ' + 'CI does not schedule, so a whole-tree row for this family would advertise work no workflow performs.', ], + // ⚖️ `scripts/check-console-intercept-disarm.mjs` and its `--self-test` LEFT this + // population under #29353 and their two rows are gone with them — a listed + // family that stops being a member reds here. They left BY PATH: the gate now + // declares what its scan opens, a package-root `package.json` and vitest + // config under each workspace glob, as `ROOT_DIR_WATCH_HINTS` in its own + // source, and its scan reds when that declaration drifts from + // `pnpm-workspace.yaml`. The rows had stated the population right and drawn + // the wrong conclusion from it: an EDITED package-root config moves the gate + // as surely as a new package does, so a card editing one learned of the gate + // only from CI (PR #29345 paid one fix round). // ⚖️ `check:pm-half-states` LEFT this population under #16904's D2 and its row // is gone with it — a listed family that stops being a member reds here, and // a stale exclusion is an exclusion nobody measures. It is now placeable BY diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 4ed0a5e9c21..76259f25953 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -7988,6 +7988,70 @@ export function selfTest({ tier = 'full' } = {}) { ); } + // ── A package-root config edit derives the console-intercept gate (#29353) ── + // + // The card's acceptance, held against the LIVE tree. The gate's scan opens a + // package-root `package.json` and vitest config under each workspace glob and + // nothing else, and it declares exactly that population in its own source, so + // BOTH invocations CI runs (scan and `--self-test`, one lint.yml step) are + // placed by every such file, while a test file under an existing package — + // tracked, or one a card adds — places neither. The rows it left in + // ROOT_WALK_RESIDUE_LEDGER are held gone by the stale limb above. + { + const gate = 'scripts/check-console-intercept-disarm.mjs'; + const invocations = [gate, `${gate} --self-test`]; + const entries = invocations.map((c) => liveDiscovery.byCheck.get(c)); + t('both console-intercept invocations are discovered families', entries.every(Boolean)); + const tracked = [...liveTree.files]; + const trackedSet = new Set(tracked); + // Package roots read off the tree, not off a copy of the workspace globs: + // a tracked vitest config whose own directory carries a tracked manifest, + // under one of the three workspace top-level roots. + const rootConfigs = tracked.filter((f) => { + const m = /^((?:packages|apps|examples)\/(?:[^/]+\/)?[^/]+)\/vitest\.config\.[cm]?[jt]s$/.exec(f); + return m && trackedSet.has(`${m[1]}/package.json`); + }); + t( + `control: the live tree carries package-root vitest configs to probe with (${rootConfigs.length})`, + rootConfigs.length >= 60, + ); + const unplacedBy = (entry, probe) => placeFamily(entry, [probe]).verdict !== 'matched'; + const missedConfigs = rootConfigs.filter((f) => entries.some((e) => e && unplacedBy(e, f))); + t( + 'every package-root vitest config places both invocations — the card PR #29345 paid a CI round for' + + (missedConfigs.length ? ` — not placed by: ${missedConfigs.slice(0, 5).join(', ')}` : ''), + entries.every(Boolean) && missedConfigs.length === 0, + ); + const manifests = rootConfigs.map((f) => f.replace(/\/[^/]+$/, '/package.json')); + const missedManifests = manifests.filter((f) => entries.some((e) => e && unplacedBy(e, f))); + t( + 'and so does the package-root package.json beside each — the other file the scan opens' + + (missedManifests.length ? ` — not placed by: ${missedManifests.slice(0, 5).join(', ')}` : ''), + entries.every(Boolean) && missedManifests.length === 0, + ); + // A card's file surface is often paths that do not exist yet: a NEW package. + const newPackage = ['packages', 'plugins', 'plugin-not-yet-written'].join('/'); + t( + 'a NEW package\'s root config and manifest place both invocations too', + entries.every((e) => e && !unplacedBy(e, `${newPackage}/vitest.config.ts`) && !unplacedBy(e, `${newPackage}/package.json`)), + ); + const testFiles = tracked.filter((f) => /^(?:packages|apps|examples)\/.+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(f)); + t( + `control: the live tree carries test files under packages to probe with (${testFiles.length})`, + testFiles.length >= 1000, + ); + t( + 'no test file under an existing package places either invocation, tracked or brand new', + entries.every((e) => e + && placeFamily(e, testFiles).verdict !== 'matched' + && unplacedBy(e, ['packages', 'services', 'service-sms', 'src', 'not-yet-written.test.ts'].join('/'))), + ); + t( + 'nor does a vitest config BELOW a package root — the scan deliberately reads the root one only', + entries.every((e) => e && unplacedBy(e, ['packages', 'services', 'service-sms', 'test', 'fixtures', 'vitest.config.ts'].join('/'))), + ); + } + // ── The CI-MEASURED-ONLY shape (#14004) ─────────────────────────────────── // // The two markers above are DECLARATIONS a gate carries. This one is the