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