Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 180 additions & 12 deletions scripts/pm/dispatch-gates.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand All@@ -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 = [
{
Expand All@@ -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`",
},
],
},
];

/**
Expand DownExpand Up@@ -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() {
Expand DownExpand Up@@ -392,10 +521,49 @@ 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));

// 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 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,
// 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('⚠ 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;
Expand Down
Loading