Skip to content

Commit f46cda9

Browse files
hotlongclaude
andauthored
fix(pm): derive check:i18n by owning-package walk in dispatch-gates (#8363)
* fix(pm): derive check:i18n by owning-package walk in dispatch-gates `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01139NJ9Wg5pFeZi1Zh8WLg6 * test(pm): anchor the i18n gate-name pins on exact rendered delimiters 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01139NJ9Wg5pFeZi1Zh8WLg6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 274f7e0 commit f46cda9

1 file changed

Lines changed: 180 additions & 12 deletions

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 180 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
*/
5656

5757
import { readFileSync, readdirSync, existsSync } from 'node:fs';
58-
import { join } from 'node:path';
58+
import { basename, join } from 'node:path';
5959
import process from 'node:process';
6060

6161
const ROOT = new URL('../..', import.meta.url).pathname;
@@ -158,19 +158,113 @@ export function isTestFilePath(path) {
158158
return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(path);
159159
}
160160

161+
/**
162+
* Does this path name an i18n extract config, judged the way `check:i18n`
163+
* judges it? `scripts/check-i18n-bundles.mjs` (`findConfigs`, ~line 107) tests
164+
* the FILENAME and additionally requires the file to sit under a `scripts/`
165+
* directory: `e.name === 'i18n-extract.config.ts' && p.includes('/scripts/')`.
166+
* Mirrored exactly rather than approximated — a copy that widened the test
167+
* would name a gate that cannot move, the failure mode this whole script
168+
* exists to avoid.
169+
*/
170+
export function isExtractConfigPath(path) {
171+
return basename(path) === 'i18n-extract.config.ts' && path.includes('/scripts/');
172+
}
173+
174+
/**
175+
* The package directory that OWNS an extract config: everything above the
176+
* `scripts/` segment `isExtractConfigPath` required. Returns null when the
177+
* owner would collapse to a bare top-level directory (a config sitting at
178+
* `packages/scripts/…`) — such an owner covers the entire tree below it, which
179+
* is the same over-broad match `hintCovers` rejects for watch hints.
180+
*/
181+
export function owningPackageOfExtractConfig(configPath) {
182+
const i = configPath.indexOf('/scripts/');
183+
if (i < 0) return null;
184+
const owner = configPath.slice(0, i);
185+
return owner.includes('/') ? owner : null;
186+
}
187+
188+
/**
189+
* Is this input path inside a package that owns an extract config?
190+
*
191+
* The WHOLE owning package counts, the config file included. Narrowing to the
192+
* object definitions would under-cover: the extraction reads whatever each
193+
* package's config enumerates, and the config itself is part of the trigger
194+
* surface — edit it and the emitted bundles change.
195+
*
196+
* One-directional on purpose: the input must sit inside an owner, never the
197+
* reverse. Letting a shorter input "cover" owners below it would make a
198+
* directory argument like `packages/services` drag in every bundle package
199+
* under it, and `packages` drag in all nine.
200+
*/
201+
export function isInI18nBundlePackage(path, ownerDirs) {
202+
return ownerDirs.some((dir) => path === dir || path.startsWith(`${dir}/`));
203+
}
204+
205+
/**
206+
* Walk `packages/` for extract configs exactly the way the gate walks it —
207+
* same skip set (`node_modules`, `dist`, dotted entries), same file test — and
208+
* return the repo-relative package directories that own one, deduped.
209+
*
210+
* Runtime discovery, like `extractCheckInvocations` re-reading the workflows:
211+
* when a tenth package grows a bundle, the next run matches it with nothing to
212+
* update here. `absDir` is the directory to read; `rel` is the repo-relative
213+
* path it corresponds to, so the answers are comparable to the input paths a
214+
* card is dispatched with.
215+
*/
216+
export function findI18nBundlePackages(absDir, rel = 'packages', out = []) {
217+
for (const e of readdirSync(absDir, { withFileTypes: true })) {
218+
if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
219+
const child = `${rel}/${e.name}`;
220+
if (e.isDirectory()) findI18nBundlePackages(join(absDir, e.name), child, out);
221+
else if (isExtractConfigPath(child)) {
222+
const owner = owningPackageOfExtractConfig(child);
223+
if (owner && !out.includes(owner)) out.push(owner);
224+
}
225+
}
226+
return out;
227+
}
228+
229+
/**
230+
* The walk, memoised per process — one answer serves every input path. An
231+
* unreadable `packages/` throws rather than degrading to "no owners": under
232+
* this script's contract unreadable input must never look like an empty
233+
* answer, and the entrypoint turns the throw into a non-zero exit.
234+
*/
235+
let i18nOwnerDirs = null;
236+
export function i18nBundlePackageDirs() {
237+
i18nOwnerDirs ??= findI18nBundlePackages(join(ROOT, 'packages'));
238+
return i18nOwnerDirs;
239+
}
240+
161241
/**
162242
* Gates that fire on what a change IS, keyed by a mechanically-detectable
163243
* convention. Everything else in this script is derived at runtime and lists
164244
* nothing; this table is the one exception, and it is bounded on purpose.
165245
*
166-
* ## Why these two cannot be derived like the rest
246+
* ## Why these cannot be derived like the rest
167247
*
168248
* The path derivation matches a gate when the gate's own source names a
169-
* directory that covers your file. Both gates here compute their population
170-
* instead of naming it — one lints a glob set that lives in the shared ESLint
171-
* config, the other walks the workspace members — so neither source carries a
172-
* literal to match, and both sit permanently in the "undetermined" bucket. No
173-
* per-card gate list derived from paths can ever name them, however the
249+
* directory that covers your file. Every gate here computes its population
250+
* instead of naming it, so no source carries a literal to match:
251+
*
252+
* - the two test-file gates — one lints a glob set that lives in the shared
253+
* ESLint config, the other walks the workspace members — sit permanently
254+
* in the "undetermined" bucket;
255+
* - `check:i18n` walks `packages/` at runtime for files NAMED
256+
* `i18n-extract.config.ts` and re-extracts each owning package's bundles.
257+
* Its source is worse than silent: the path-ish literals it does carry are
258+
* its CLI prerequisite and stale-dist checks (`packages/cli/dist/commands/
259+
* i18n/extract.js`, `packages/spec/dist`, measured — eleven hints, none of
260+
* them the population). So it matches nothing AND, having hints, never
261+
* reaches the "undetermined" bucket either: before this entry existed, an
262+
* edit to `packages/services/service-messaging/src/objects/` — which
263+
* regenerates that package's four bundles — printed the gate in NEITHER
264+
* half of the output. A gate the derivation cannot mention at all is the
265+
* one shape this script must not produce; it cost a PR a CI round.
266+
*
267+
* No per-card gate list derived from paths can ever name these, however the
174268
* derivation improves.
175269
*
176270
* ## Why a named table and not a wider heuristic
@@ -183,16 +277,33 @@ export function isTestFilePath(path) {
183277
* as none. So the pair is written down, and the cost of writing it down is paid
184278
* back by the two properties below.
185279
*
186-
* ## How this entry stays honest
280+
* ## What the i18n entry still refuses to list
281+
*
282+
* Its `matches` does not enumerate the packages that own a bundle today — it
283+
* repeats the gate's own walk (`findI18nBundlePackages` mirrors `findConfigs`
284+
* in `scripts/check-i18n-bundles.mjs`: same skip set, same filename-plus-
285+
* `/scripts/` test). What is written down here is the KIND, not its
286+
* population, so a tenth package growing a bundle is matched by the next run
287+
* with nothing to update — the same runtime-discovery contract the workflow
288+
* and package.json reads already keep.
289+
*
290+
* ## How these entries stay honest
187291
*
188292
* - Every `name` here is resolved against the families actually discovered in
189293
* the workflows at runtime. A gate that is renamed, retired or dropped from
190294
* CI does not silently stop being suggested — the run prints it as STALE and
191295
* says to fix this table. A hand-written list that reports its own rot is a
192296
* different object from one that quietly ages.
193-
* - The entry is deletable, with a stated criterion: when a gate here grows a
194-
* discoverable path literal, the ordinary derivation names it and its line
195-
* below becomes redundant. Delete it then.
297+
* - Each entry is deletable, with a stated criterion:
298+
* - test-file entry: when a gate on it grows a discoverable path literal,
299+
* the ordinary derivation names it and its line becomes redundant.
300+
* - i18n entry: when `check-i18n-bundles.mjs` stops discovering its targets
301+
* at runtime and names its POPULATION in its own source — a literal each
302+
* owning package path starts with — the path half matches and this entry
303+
* is redundant. Growing more prerequisite paths does not qualify; that is
304+
* what it already has.
305+
*
306+
* Delete an entry the day its criterion is met, not before.
196307
*/
197308
export const CHANGE_KIND_GATES = [
198309
{
@@ -209,6 +320,16 @@ export const CHANGE_KIND_GATES = [
209320
},
210321
],
211322
},
323+
{
324+
kind: 'edits a file in a package that owns an i18n-extract.config.ts',
325+
matches: (path) => isInI18nBundlePackage(path, i18nBundlePackageDirs()),
326+
gates: [
327+
{
328+
name: 'check:i18n',
329+
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`",
330+
},
331+
],
332+
},
212333
];
213334

214335
/**
@@ -320,7 +441,15 @@ function derive(paths) {
320441
}
321442

322443
// ---------------------------------------------------------------------------
323-
// Self-test — extraction + matching over fixtures; no filesystem beyond this file.
444+
// Self-test — extraction + matching over fixtures.
445+
//
446+
// The extraction and hint cases run over inline fixtures and touch no
447+
// filesystem. The i18n change-kind cases deliberately do: that entry's whole
448+
// content IS a walk of the real `packages/` tree, and a fixture-only test
449+
// passes just as happily when the walk is rooted at the wrong directory or
450+
// skips the wrong entries. So the pure judgments (filename test, owner
451+
// derivation, containment) are pinned offline, and the walk is pinned against
452+
// the tree, in both directions.
324453
// ---------------------------------------------------------------------------
325454

326455
function selfTest() {
@@ -392,10 +521,49 @@ function selfTest() {
392521
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')));
393522
t('a non-test path emits nothing', changeKindLines(['scripts/pm/dispatch-gates.mjs'], resolved).length === 0);
394523

524+
// i18n change-kind derivation — the pure judgments first, each mirroring one
525+
// line of the gate's own `findConfigs`.
526+
t('an extract config under scripts/ is one', isExtractConfigPath('packages/services/service-messaging/scripts/i18n-extract.config.ts'));
527+
t('the same filename OUTSIDE scripts/ is not', !isExtractConfigPath('packages/services/service-messaging/src/i18n-extract.config.ts'));
528+
t('another config under scripts/ is not', !isExtractConfigPath('packages/platform-objects/scripts/build-docs.config.ts'));
529+
t('owner is the package above scripts/', owningPackageOfExtractConfig('packages/plugins/plugin-audit/scripts/i18n-extract.config.ts') === 'packages/plugins/plugin-audit');
530+
t('an owner collapsing to a bare top-level dir is refused', owningPackageOfExtractConfig('packages/scripts/i18n-extract.config.ts') === null);
531+
532+
const owners = ['packages/platform-objects', 'packages/services/service-messaging'];
533+
t('a deep path inside an owning package qualifies', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', owners));
534+
t('the config file itself qualifies (whole package, not just objects)', isInI18nBundlePackage('packages/services/service-messaging/scripts/i18n-extract.config.ts', owners));
535+
t('the package directory itself qualifies', isInI18nBundlePackage('packages/platform-objects', owners));
536+
t('a path in a package WITHOUT a config does not', !isInI18nBundlePackage('packages/objectql/src/engine.ts', owners));
537+
t('a sibling sharing a name prefix does not', !isInI18nBundlePackage('packages/services/service-messaging-extra/src/x.ts', owners));
538+
t('a parent directory does not drag in owners below it', !isInI18nBundlePackage('packages/services', owners));
539+
540+
// The walk itself, against the real tree — the half no fixture can prove.
541+
const liveOwners = i18nBundlePackageDirs();
542+
t('the live walk discovers owning packages', liveOwners.length > 0 && liveOwners.every((d) => d.startsWith('packages/')));
543+
t('the live walk finds no duplicate owners', new Set(liveOwners).size === liveOwners.length);
544+
t('the live walk excludes a package that owns no config', !liveOwners.includes('packages/objectql'));
545+
// Regression pin for the measured miss (PR #8348): this exact path derived no
546+
// check:i18n. If service-messaging ever stops owning a bundle, this case fails
547+
// and the answer is to re-point it at a package that does, not to delete it.
548+
t('the measured incident path now derives the kind', isInI18nBundlePackage('packages/services/service-messaging/src/objects/http-delivery.object.ts', liveOwners));
549+
550+
// The name assertions below anchor on the rendered DELIMITERS (`- pnpm x —`,
551+
// `⚠ x: STALE`), not on a bare substring. Measured while reverse-verifying this
552+
// entry: renaming the gate to `check:i18n-renamed-probe` made the live run
553+
// print STALE exactly as designed, and a `includes('pnpm check:i18n')` pin
554+
// stayed green through it — every prefix-preserving rename is invisible to a
555+
// substring, which is the one class of rot the STALE branch exists to catch.
556+
const i18nHit = changeKindLines(['packages/services/service-messaging/src/objects/http-delivery.object.ts'], resolved);
557+
t('an owning-package path emits the i18n convention section', i18nHit.length === 2 && i18nHit[0].includes('owns an i18n-extract.config.ts'));
558+
t('the i18n section names check:i18n exactly, runnably', i18nHit.some((l) => l.includes('- pnpm check:i18n —')));
559+
t('a path outside every owning package emits no i18n section', !changeKindLines(['packages/objectql/src/engine.ts'], resolved).some((l) => l.includes('check:i18n')));
560+
395561
// The table's own rot detector: a name no live run discovers must say so,
396562
// never disappear quietly.
397563
const stale = changeKindLines(['a.test.ts'], () => null);
398564
t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 2);
565+
const i18nStale = changeKindLines(['packages/services/service-messaging/scripts/i18n-extract.config.ts'], () => null);
566+
t('an undiscoverable check:i18n renders as STALE', i18nStale.filter((l) => l.includes('⚠ check:i18n: STALE')).length === 1);
399567
t('every declared convention gate carries a reason', CHANGE_KIND_GATES.every((k) => k.gates.every((g) => g.name && g.why)));
400568

401569
let failed = 0;

0 commit comments

Comments
 (0)