From ae665bdf7c85f76d762b8df2c0d0373a6a7b9f8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 09:01:56 +0000 Subject: [PATCH 1/2] fix(pm): derive check:i18n by owning-package walk in dispatch-gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:i18n` was invisible to the derivation in BOTH halves of the output. The path half cannot reach it: check-i18n-bundles.mjs discovers its targets at runtime by walking packages/ for files named i18n-extract.config.ts, so its source names no population path to match. Worse than silent — it does carry eleven path-ish literals (CLI prerequisite, stale-dist checks), so it also never lands in the "repo-wide / undetermined" bucket. The convention half did not know the kind. Measured on PR #8348: an object-definition edit under packages/services/service-messaging/src/objects/ regenerates that package's four bundles, and the derivation named the gate nowhere at all. Adds a second CHANGE_KIND_GATES entry that repeats the gate's own walk (same skip set, same filename-plus-/scripts/ test) and matches any input path inside an owning package. The whole package counts, config file included — narrowing to src/objects/** would under-cover, since extraction reads whatever each package's config enumerates. Lists nothing: the KIND is written down, never its population, so a tenth package growing a bundle is matched by the next run. Owner population verified at parity with the gate's own nine. Self-test 28 -> 47 cases, pinning both directions and the new entry's STALE branch. Fixes #8352 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01139NJ9Wg5pFeZi1Zh8WLg6 --- scripts/pm/dispatch-gates.mjs | 186 +++++++++++++++++++++++++++++++--- 1 file changed, 174 insertions(+), 12 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 454188755a..599d8875dd 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -55,7 +55,7 @@ */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import process from 'node:process'; const ROOT = new URL('../..', import.meta.url).pathname; @@ -158,19 +158,113 @@ export function isTestFilePath(path) { return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(path); } +/** + * Does this path name an i18n extract config, judged the way `check:i18n` + * judges it? `scripts/check-i18n-bundles.mjs` (`findConfigs`, ~line 107) tests + * the FILENAME and additionally requires the file to sit under a `scripts/` + * directory: `e.name === 'i18n-extract.config.ts' && p.includes('/scripts/')`. + * Mirrored exactly rather than approximated — a copy that widened the test + * would name a gate that cannot move, the failure mode this whole script + * exists to avoid. + */ +export function isExtractConfigPath(path) { + return basename(path) === 'i18n-extract.config.ts' && path.includes('/scripts/'); +} + +/** + * The package directory that OWNS an extract config: everything above the + * `scripts/` segment `isExtractConfigPath` required. Returns null when the + * owner would collapse to a bare top-level directory (a config sitting at + * `packages/scripts/…`) — such an owner covers the entire tree below it, which + * is the same over-broad match `hintCovers` rejects for watch hints. + */ +export function owningPackageOfExtractConfig(configPath) { + const i = configPath.indexOf('/scripts/'); + if (i < 0) return null; + const owner = configPath.slice(0, i); + return owner.includes('/') ? owner : null; +} + +/** + * Is this input path inside a package that owns an extract config? + * + * The WHOLE owning package counts, the config file included. Narrowing to the + * object definitions would under-cover: the extraction reads whatever each + * package's config enumerates, and the config itself is part of the trigger + * surface — edit it and the emitted bundles change. + * + * One-directional on purpose: the input must sit inside an owner, never the + * reverse. Letting a shorter input "cover" owners below it would make a + * directory argument like `packages/services` drag in every bundle package + * under it, and `packages` drag in all nine. + */ +export function isInI18nBundlePackage(path, ownerDirs) { + return ownerDirs.some((dir) => path === dir || path.startsWith(`${dir}/`)); +} + +/** + * Walk `packages/` for extract configs exactly the way the gate walks it — + * same skip set (`node_modules`, `dist`, dotted entries), same file test — and + * return the repo-relative package directories that own one, deduped. + * + * Runtime discovery, like `extractCheckInvocations` re-reading the workflows: + * when a tenth package grows a bundle, the next run matches it with nothing to + * update here. `absDir` is the directory to read; `rel` is the repo-relative + * path it corresponds to, so the answers are comparable to the input paths a + * card is dispatched with. + */ +export function findI18nBundlePackages(absDir, rel = 'packages', out = []) { + for (const e of readdirSync(absDir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue; + const child = `${rel}/${e.name}`; + if (e.isDirectory()) findI18nBundlePackages(join(absDir, e.name), child, out); + else if (isExtractConfigPath(child)) { + const owner = owningPackageOfExtractConfig(child); + if (owner && !out.includes(owner)) out.push(owner); + } + } + return out; +} + +/** + * The walk, memoised per process — one answer serves every input path. An + * unreadable `packages/` throws rather than degrading to "no owners": under + * this script's contract unreadable input must never look like an empty + * answer, and the entrypoint turns the throw into a non-zero exit. + */ +let i18nOwnerDirs = null; +export function i18nBundlePackageDirs() { + i18nOwnerDirs ??= findI18nBundlePackages(join(ROOT, 'packages')); + return i18nOwnerDirs; +} + /** * Gates that fire on what a change IS, keyed by a mechanically-detectable * convention. Everything else in this script is derived at runtime and lists * nothing; this table is the one exception, and it is bounded on purpose. * - * ## Why these two cannot be derived like the rest + * ## Why these cannot be derived like the rest * * The path derivation matches a gate when the gate's own source names a - * directory that covers your file. Both gates here compute their population - * instead of naming it — one lints a glob set that lives in the shared ESLint - * config, the other walks the workspace members — so neither source carries a - * literal to match, and both sit permanently in the "undetermined" bucket. No - * per-card gate list derived from paths can ever name them, however the + * directory that covers your file. Every gate here computes its population + * instead of naming it, so no source carries a literal to match: + * + * - the two test-file gates — one lints a glob set that lives in the shared + * ESLint config, the other walks the workspace members — sit permanently + * in the "undetermined" bucket; + * - `check:i18n` walks `packages/` at runtime for files NAMED + * `i18n-extract.config.ts` and re-extracts each owning package's bundles. + * Its source is worse than silent: the path-ish literals it does carry are + * its CLI prerequisite and stale-dist checks (`packages/cli/dist/commands/ + * i18n/extract.js`, `packages/spec/dist`, measured — eleven hints, none of + * them the population). So it matches nothing AND, having hints, never + * reaches the "undetermined" bucket either: before this entry existed, an + * edit to `packages/services/service-messaging/src/objects/` — which + * regenerates that package's four bundles — printed the gate in NEITHER + * half of the output. A gate the derivation cannot mention at all is the + * one shape this script must not produce; it cost a PR a CI round. + * + * No per-card gate list derived from paths can ever name these, however the * derivation improves. * * ## Why a named table and not a wider heuristic @@ -183,16 +277,33 @@ export function isTestFilePath(path) { * as none. So the pair is written down, and the cost of writing it down is paid * back by the two properties below. * - * ## How this entry stays honest + * ## What the i18n entry still refuses to list + * + * Its `matches` does not enumerate the packages that own a bundle today — it + * repeats the gate's own walk (`findI18nBundlePackages` mirrors `findConfigs` + * in `scripts/check-i18n-bundles.mjs`: same skip set, same filename-plus- + * `/scripts/` test). What is written down here is the KIND, not its + * population, so a tenth package growing a bundle is matched by the next run + * with nothing to update — the same runtime-discovery contract the workflow + * and package.json reads already keep. + * + * ## How these entries stay honest * * - Every `name` here is resolved against the families actually discovered in * the workflows at runtime. A gate that is renamed, retired or dropped from * CI does not silently stop being suggested — the run prints it as STALE and * says to fix this table. A hand-written list that reports its own rot is a * different object from one that quietly ages. - * - The entry is deletable, with a stated criterion: when a gate here grows a - * discoverable path literal, the ordinary derivation names it and its line - * below becomes redundant. Delete it then. + * - Each entry is deletable, with a stated criterion: + * - test-file entry: when a gate on it grows a discoverable path literal, + * the ordinary derivation names it and its line becomes redundant. + * - i18n entry: when `check-i18n-bundles.mjs` stops discovering its targets + * at runtime and names its POPULATION in its own source — a literal each + * owning package path starts with — the path half matches and this entry + * is redundant. Growing more prerequisite paths does not qualify; that is + * what it already has. + * + * Delete an entry the day its criterion is met, not before. */ export const CHANGE_KIND_GATES = [ { @@ -209,6 +320,16 @@ export const CHANGE_KIND_GATES = [ }, ], }, + { + kind: 'edits a file in a package that owns an i18n-extract.config.ts', + matches: (path) => isInI18nBundlePackage(path, i18nBundlePackageDirs()), + gates: [ + { + name: 'check:i18n', + why: "it re-extracts every owning package's translation bundles and fails on drift, so any edit that changes what the extractor emits (an object definition, a label, the config itself) moves it — regenerate with `node scripts/check-i18n-bundles.mjs --write`", + }, + ], + }, ]; /** @@ -320,7 +441,15 @@ function derive(paths) { } // --------------------------------------------------------------------------- -// Self-test — extraction + matching over fixtures; no filesystem beyond this file. +// Self-test — extraction + matching over fixtures. +// +// The extraction and hint cases run over inline fixtures and touch no +// filesystem. The i18n change-kind cases deliberately do: that entry's whole +// content IS a walk of the real `packages/` tree, and a fixture-only test +// passes just as happily when the walk is rooted at the wrong directory or +// skips the wrong entries. So the pure judgments (filename test, owner +// derivation, containment) are pinned offline, and the walk is pinned against +// the tree, in both directions. // --------------------------------------------------------------------------- function selfTest() { @@ -392,10 +521,43 @@ function selfTest() { t('the section names both convention gates, runnably', kindHit.some((l) => l.includes('pnpm check:query-options-erasure')) && kindHit.some((l) => l.includes('pnpm check:type-check-coverage'))); t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0); + // i18n change-kind derivation — the pure judgments first, each mirroring one + // line of the gate's own `findConfigs`. + t('an extract config under scripts/ is one', isExtractConfigPath('packages/services/service-messaging/scripts/i18n-extract.config.ts')); + t('the same filename OUTSIDE scripts/ is not', !isExtractConfigPath('packages/services/service-messaging/src/i18n-extract.config.ts')); + t('another config under scripts/ is not', !isExtractConfigPath('packages/platform-objects/scripts/build-docs.config.ts')); + t('owner is the package above scripts/', owningPackageOfExtractConfig('packages/plugins/plugin-audit/scripts/i18n-extract.config.ts') === 'packages/plugins/plugin-audit'); + t('an owner collapsing to a bare top-level dir is refused', owningPackageOfExtractConfig('packages/scripts/i18n-extract.config.ts') === null); + + const owners = ['packages/platform-objects', 'packages/services/service-messaging']; + t('a deep path inside an owning package qualifies', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', owners)); + t('the config file itself qualifies (whole package, not just objects)', isInI18nBundlePackage('packages/services/service-messaging/scripts/i18n-extract.config.ts', owners)); + t('the package directory itself qualifies', isInI18nBundlePackage('packages/platform-objects', owners)); + t('a path in a package WITHOUT a config does not', !isInI18nBundlePackage('packages/objectql/src/engine.ts', owners)); + t('a sibling sharing a name prefix does not', !isInI18nBundlePackage('packages/services/service-messaging-extra/src/x.ts', owners)); + t('a parent directory does not drag in owners below it', !isInI18nBundlePackage('packages/services', owners)); + + // The walk itself, against the real tree — the half no fixture can prove. + const liveOwners = i18nBundlePackageDirs(); + t('the live walk discovers owning packages', liveOwners.length > 0 && liveOwners.every((d) => d.startsWith('packages/'))); + t('the live walk finds no duplicate owners', new Set(liveOwners).size === liveOwners.length); + t('the live walk excludes a package that owns no config', !liveOwners.includes('packages/objectql')); + // Regression pin for the measured miss (PR #8348): this exact path derived no + // check:i18n. If service-messaging ever stops owning a bundle, this case fails + // and the answer is to re-point it at a package that does, not to delete it. + t('the measured incident path now derives the kind', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', liveOwners)); + + const i18nHit = changeKindLines(['packages/services/service-messaging/src/objects/http-delivery.object.ts'], resolved); + t('an owning-package path emits the i18n convention section', i18nHit.length === 2 && i18nHit[0].includes('owns an i18n-extract.config.ts')); + t('the i18n section names check:i18n, runnably', i18nHit.some((l) => l.includes('pnpm check:i18n'))); + t('a path outside every owning package emits no i18n section', !changeKindLines(['packages/objectql/src/engine.ts'], resolved).some((l) => l.includes('check:i18n'))); + // The table's own rot detector: a name no live run discovers must say so, // never disappear quietly. const stale = changeKindLines(['a.test.ts'], () => null); t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 2); + const i18nStale = changeKindLines(['packages/services/service-messaging/scripts/i18n-extract.config.ts'], () => null); + t('an undiscoverable check:i18n renders as STALE', i18nStale.filter((l) => l.includes('STALE') && l.includes('check:i18n')).length === 1); t('every declared convention gate carries a reason', CHANGE_KIND_GATES.every((k) => k.gates.every((g) => g.name && g.why))); let failed = 0; From 341407c29d8c9d78d1997f3782ac0dfcd57b2c53 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 09:03:32 +0000 Subject: [PATCH 2/2] test(pm): anchor the i18n gate-name pins on exact rendered delimiters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by reverse-verifying the entry above: renaming its gate to `check:i18n-renamed-probe` made the live run print the STALE line exactly as designed, and the self-test stayed green at 47/47 — `includes('pnpm check:i18n')` is satisfied by every name that merely STARTS with it, so a prefix-preserving rename is invisible to a substring pin. That is precisely the rot the STALE branch exists to report. Anchors both pins on the rendered delimiters (`- pnpm x —`, `⚠ x: STALE`) so the name must match exactly. Re-verified: the same rename now fails 2 cases instead of 0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01139NJ9Wg5pFeZi1Zh8WLg6 --- scripts/pm/dispatch-gates.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 599d8875dd..59546b4ef6 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -547,9 +547,15 @@ function selfTest() { // and the answer is to re-point it at a package that does, not to delete it. t('the measured incident path now derives the kind', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', liveOwners)); + // The name assertions below anchor on the rendered DELIMITERS (`- pnpm x —`, + // `⚠ x: STALE`), not on a bare substring. Measured while reverse-verifying this + // entry: renaming the gate to `check:i18n-renamed-probe` made the live run + // print STALE exactly as designed, and a `includes('pnpm check:i18n')` pin + // stayed green through it — every prefix-preserving rename is invisible to a + // substring, which is the one class of rot the STALE branch exists to catch. const i18nHit = changeKindLines(['packages/services/service-messaging/src/objects/http-delivery.object.ts'], resolved); t('an owning-package path emits the i18n convention section', i18nHit.length === 2 && i18nHit[0].includes('owns an i18n-extract.config.ts')); - t('the i18n section names check:i18n, runnably', i18nHit.some((l) => l.includes('pnpm check:i18n'))); + t('the i18n section names check:i18n exactly, runnably', i18nHit.some((l) => l.includes('- pnpm check:i18n —'))); t('a path outside every owning package emits no i18n section', !changeKindLines(['packages/objectql/src/engine.ts'], resolved).some((l) => l.includes('check:i18n'))); // The table's own rot detector: a name no live run discovers must say so, @@ -557,7 +563,7 @@ function selfTest() { const stale = changeKindLines(['a.test.ts'], () => null); t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 2); const i18nStale = changeKindLines(['packages/services/service-messaging/scripts/i18n-extract.config.ts'], () => null); - t('an undiscoverable check:i18n renders as STALE', i18nStale.filter((l) => l.includes('STALE') && l.includes('check:i18n')).length === 1); + t('an undiscoverable check:i18n renders as STALE', i18nStale.filter((l) => l.includes('⚠ check:i18n: STALE')).length === 1); t('every declared convention gate carries a reason', CHANGE_KIND_GATES.every((k) => k.gates.every((g) => g.name && g.why))); let failed = 0;