Skip to content

Commit 76876ac

Browse files
fix(pm): make check:i18n derivable from a metadata form module edit (#9143)
* fix(pm): derive check:i18n from a metadata form module edit (#9116) A `.form.ts` change in packages/spec moves platform-objects' committed metadata-form bundles, but no derived gate list could name check:i18n for it: the gate walks packages/ for extract configs, packages/spec owns none, and the gate's own path literals are its CLI/stale-dist prerequisites. The family scored neither matched nor undetermined — printed nowhere. PR #9113 paid one CI round trip plus a patch commit for that edge. Two changes, one contract: - scripts/i18n-bundle-surface.mjs — the config walk and the docstring-flag parse move here and BOTH readers import them. dispatch-gates used to carry a hand-written mirror of the gate's walk, described in its own comment as mirroring it "exactly": a second contract that agrees until one side moves, with nothing to report the day it stops. - a second convention entry in CHANGE_KIND_GATES for a metadata form module, with the population walked at runtime and the applicability read from the configs' own documented flags — the day every config passes --no-metadata-forms, no form module can move a committed bundle and the entry stops firing by itself. Both consumers declare the shared module as a bare module-body constant, the shape check-type-check-coverage.mjs already uses: an import specifier is not a discoverable watch hint, so without it a card editing the shared enumeration would move two gates while deriving neither — the same blind spot one layer down. Pinned live in the tool's self-test, both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm * test(pm): fail the metadata-form cases by name, not on a TypeError (#9116) Reverse-verifying the new entry by making every extract config opt out emptied the rendered lines, and the bare `formHit[0]` crashed the whole self-test — one stack in place of 183 named verdicts, hiding every other finding behind it. Same fallback discipline check-i18n-bundles.mjs states for its own classifiers. Ablated again after the fix: 5 named failures, all of them the new claims, nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm * docs(pm): cite the filed follow-up for the uncovered type-registry edge (#9116) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce435c1 commit 76876ac

4 files changed

Lines changed: 432 additions & 66 deletions

File tree

scripts/check-i18n-bundles.mjs

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
// `ObjectStackDefinitionSchema` strict (rejected option C, which would silence
2929
// the lint itself — see `metadata-authoring-lint.ts`).
3030
//
31-
// Coverage needs no manifest: `findConfigs` walks `packages/`, so a config that
32-
// lands tomorrow is gated tomorrow.
31+
// Coverage needs no manifest: `findExtractConfigs` walks `packages/`, so a
32+
// config that lands tomorrow is gated tomorrow. That walk is shared with the
33+
// dispatch-gates derivation rather than mirrored by it (#9116) — see
34+
// SURFACE_MODULE below.
3335
//
3436
// The command each package is checked with is not repeated here: it is parsed
3537
// out of the config file's own docstring, which already documents how to
@@ -84,9 +86,7 @@
8486
// is the classifier that closes that gap, and the verdict it raises is a hard
8587
// prerequisite failure naming the package whose dist is stale — never a count.
8688
import { spawnSync } from 'node:child_process';
87-
import { readFileSync, existsSync } from 'node:fs';
88-
import { readdirSync, statSync } from 'node:fs';
89-
import { join } from 'node:path';
89+
import { existsSync } from 'node:fs';
9090
import {
9191
CLI,
9292
CLI_BUILD_FIX,
@@ -96,35 +96,36 @@ import {
9696
resolveCliCommandFile,
9797
workspaceBuildFix,
9898
} from './cli-build-prerequisite.mjs';
99+
import { findExtractConfigs, flagsFromDocstring } from './i18n-bundle-surface.mjs';
100+
101+
/**
102+
* The module this gate's POPULATION is enumerated by, declared as a whole
103+
* literal so the derivation can see it (#9116).
104+
*
105+
* `findConfigs` and `flagsFromDocstring` used to live in this file, and
106+
* scripts/pm/dispatch-gates.mjs carried a hand-written mirror of the first —
107+
* two spellings of one contract, agreeing only until one side moved. They are
108+
* imported from one module now. That module is a real input of this gate, but
109+
* an import specifier is not a discoverable watch hint (the leading `./`
110+
* strips to a bare filename), so a card editing the shared enumeration would
111+
* derive nothing at all. Naming it here as a bare module-body constant is what
112+
* makes this family match such a card — the same declared-coupling shape
113+
* check-type-check-coverage.mjs uses for the root-program script whose errors
114+
* it ratchets, and it is pinned live in dispatch-gates' own self-test.
115+
*/
116+
const SURFACE_MODULE = 'scripts/i18n-bundle-surface.mjs';
99117

100118
/** The one command this gate invokes per package, as oclif topic/command parts. */
101119
const EXTRACT_COMMAND_ID = ['i18n', 'extract'];
102120
const write = process.argv.includes('--write');
103121
const filterArg = process.argv.find((a) => a.startsWith('--filter='));
104122
const filter = filterArg ? filterArg.slice('--filter='.length) : '';
105123

106-
/** Every `scripts/i18n-extract.config.ts` under packages/. */
107-
function findConfigs(dir, out = []) {
108-
for (const e of readdirSync(dir, { withFileTypes: true })) {
109-
if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
110-
const p = join(dir, e.name);
111-
if (e.isDirectory()) findConfigs(p, out);
112-
else if (e.name === 'i18n-extract.config.ts' && p.includes('/scripts/')) out.push(p);
113-
}
114-
return out;
115-
}
116-
117-
/**
118-
* Read the regenerate command the config documents about itself. Every flag the
119-
* gate passes comes from there, so a package that changes its locales or output
120-
* directory updates one place and the gate follows.
121-
*/
122-
function flagsFromDocstring(configPath) {
123-
const src = readFileSync(configPath, 'utf8');
124-
const head = src.slice(0, src.indexOf('*/') + 2);
125-
const flags = head.match(/--(?:locales|fill|out)=[^\s\\*]+|--(?:objects-only|no-metadata-forms|no-merge)\b/g) ?? [];
126-
return [...new Set(flags)];
127-
}
124+
// `findConfigs` (every scripts/i18n-extract.config.ts under packages/) and
125+
// `flagsFromDocstring` (the regenerate command each config documents about
126+
// itself) moved to the shared module named in SURFACE_MODULE above. This gate
127+
// and the dispatch-gates derivation now run the SAME walk instead of two
128+
// spellings of it; see that module's header for why the mirror had to go.
128129

129130
// ---------------------------------------------------------------------------
130131
// Output classifiers. Pure string -> findings, so `--self-test` can drive them
@@ -653,7 +654,10 @@ function checkCliBuildPrerequisite() {
653654

654655
checkCliBuildPrerequisite();
655656

656-
const configs = findConfigs('packages').sort().filter((c) => !filter || c.includes(filter));
657+
const configs = findExtractConfigs('packages', 'packages')
658+
.map((c) => c.rel)
659+
.sort()
660+
.filter((c) => !filter || c.includes(filter));
657661
if (configs.length === 0) {
658662
console.error(`check-i18n-bundles: no extract configs matched${filter ? ` --filter=${filter}` : ''}`);
659663
process.exit(1);

scripts/i18n-bundle-surface.mjs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* i18n-bundle-surface — the ONE enumeration of what moves a translation bundle.
5+
*
6+
* Two readers need the same answer and used to compute it apart:
7+
*
8+
* - scripts/check-i18n-bundles.mjs the GATE. Walks packages/ for extract
9+
* configs, reads each config's documented flags, re-extracts, fails on drift.
10+
* - scripts/pm/dispatch-gates.mjs the DERIVATION. Has to predict, from a
11+
* card's file surface alone, whether that gate can move — before the code
12+
* exists.
13+
*
14+
* The derivation used to mirror the gate's walk by hand ("same skip set, same
15+
* filename test", said its comment). A mirror is a second contract: it agrees
16+
* until the day one side changes, and the day it disagrees nothing says so. So
17+
* the walk lives here once and both import it — the shape scripts/cli-build-
18+
* prerequisite.mjs already has for the two prerequisite classifiers this gate
19+
* and check-i18n-coverage.mjs share.
20+
*
21+
* ## Why the metadata-forms half is here and not inferred
22+
*
23+
* A bundle has two producers, and only one of them lives in the package that
24+
* owns the bundle:
25+
*
26+
* - `objects` — the config's own package enumerates them, so the owning
27+
* package IS the trigger surface;
28+
* - `metadataForms` — registry-driven and identical for every stack, so
29+
* exactly one package commits that baseline (platform-objects today) and
30+
* every other config passes --no-metadata-forms. Its source is not in the
31+
* owning package at all: it is the form modules in packages/spec that
32+
* METADATA_FORM_REGISTRY collects.
33+
*
34+
* That second edge is what cost PR 9113 a CI round: two form entries were added
35+
* in packages/spec, four platform-objects bundles moved, check:i18n went red,
36+
* and no derivation from those paths could have named the family — the gate's
37+
* own walk never reaches packages/spec, and packages/spec owns no extract
38+
* config. The KIND is written down (a form module), the POPULATION is walked at
39+
* runtime, and whether the surface is extracted AT ALL is read from the configs'
40+
* own documented flags rather than assumed.
41+
*
42+
* ## The one convention this module writes down
43+
*
44+
* A metadata form module is a file whose name ends `.form.ts`. That is the
45+
* producer's own convention, stated by the registry it feeds — packages/spec/
46+
* src/system/metadata-form-registry.ts: "the FormView produced by
47+
* defineForm({ schemaId }) in the corresponding *.form.ts". Measured on this
48+
* tree when this module landed: 17 files in the repo carry that suffix, all of
49+
* them under packages/spec/src, and the registry has exactly 17 entries with a
50+
* form — the convention and the population coincide, with nothing left over on
51+
* either side.
52+
*
53+
* What it deliberately does NOT cover, measured and stated so the next reader
54+
* does not mistake silence for coverage: the type-level half of the same
55+
* surface. `walkMetadataForms` in packages/cli/src/utils/i18n-extract.ts emits
56+
* `metadataForms.TYPE.label`/`.description` for every entry of
57+
* DEFAULT_METADATA_TYPE_REGISTRY (packages/spec/src/kernel/metadata-plugin.zod.ts),
58+
* and the registry module itself decides which forms are walked. Editing either
59+
* moves the same four bundles and matches no convention here, because neither
60+
* carries a filename that distinguishes it. Closing that edge needs an anchor
61+
* this module does not have, and the candidates trade off against each other
62+
* rather than being one obvious shape, so it is filed rather than guessed at:
63+
* issue 9144.
64+
*/
65+
66+
import { readFileSync } from 'node:fs';
67+
import { readdirSync } from 'node:fs';
68+
import { basename, join } from 'node:path';
69+
70+
/** Directory entries no walk here descends into — the gate's original skip set. */
71+
const SKIPPED_DIRS = new Set(['node_modules', 'dist']);
72+
73+
/** The filename every extract config carries. */
74+
export const EXTRACT_CONFIG_FILENAME = 'i18n-extract.config.ts';
75+
76+
/**
77+
* The flag a package passes to keep the shared Studio metadata-form baseline
78+
* out of its own bundles.
79+
*
80+
* Mirrors the ONE condition the emitter uses (`emitsMetadataForms` in
81+
* packages/cli/src/commands/i18n/extract.ts): the companion
82+
* metadata-forms.generated.ts file is written when the `metadata-forms` boolean
83+
* flag is on and the locale has keys. `--objects-only` is orthogonal — that one
84+
* picks a sub-tree of the objects module and the emitter says so in its own
85+
* comment — so it is deliberately not consulted here.
86+
*/
87+
export const METADATA_FORMS_OPT_OUT_FLAG = '--no-metadata-forms';
88+
89+
/** The filename suffix of a metadata form module. See the module note. */
90+
export const METADATA_FORM_MODULE_SUFFIX = '.form.ts';
91+
92+
/**
93+
* Does this path name an extract config? The FILENAME plus a `scripts/`
94+
* segment — the gate's original test, kept whole rather than approximated.
95+
*/
96+
export function isExtractConfigPath(path) {
97+
return basename(path) === EXTRACT_CONFIG_FILENAME && path.includes('/scripts/');
98+
}
99+
100+
/**
101+
* Does this path name a metadata form module?
102+
*
103+
* The suffix must be preceded by a name: a file called exactly `.form.ts` is a
104+
* dotted entry every walk here already skips, and accepting it would let the
105+
* bare suffix match as a path.
106+
*/
107+
export function isMetadataFormModulePath(path) {
108+
const name = basename(path);
109+
return name.length > METADATA_FORM_MODULE_SUFFIX.length && name.endsWith(METADATA_FORM_MODULE_SUFFIX);
110+
}
111+
112+
/**
113+
* Every extract config under `absDir`, as `{ rel, abs }`.
114+
*
115+
* Both halves are returned because both readers need a different one: the gate
116+
* runs from the repo root and reports repo-relative paths, while a caller
117+
* walking from an absolute root still has to OPEN each config to read its
118+
* documented flags. Deriving one from the other at the call site is how the two
119+
* spellings drift.
120+
*/
121+
export function findExtractConfigs(absDir, rel, out = []) {
122+
for (const e of readdirSync(absDir, { withFileTypes: true })) {
123+
if (SKIPPED_DIRS.has(e.name) || e.name.startsWith('.')) continue;
124+
const child = `${rel}/${e.name}`;
125+
const abs = join(absDir, e.name);
126+
if (e.isDirectory()) findExtractConfigs(abs, child, out);
127+
else if (isExtractConfigPath(child)) out.push({ rel: child, abs });
128+
}
129+
return out;
130+
}
131+
132+
/**
133+
* Every metadata form module under `absDir`, repo-relative, in walk order.
134+
*
135+
* Same skip set as the config walk: a form module inside node_modules or dist
136+
* is a build artifact of one, not a source the extractor reads.
137+
*/
138+
export function findMetadataFormModules(absDir, rel, out = []) {
139+
for (const e of readdirSync(absDir, { withFileTypes: true })) {
140+
if (SKIPPED_DIRS.has(e.name) || e.name.startsWith('.')) continue;
141+
const child = `${rel}/${e.name}`;
142+
if (e.isDirectory()) findMetadataFormModules(join(absDir, e.name), child, out);
143+
else if (isMetadataFormModulePath(child)) out.push(child);
144+
}
145+
return out;
146+
}
147+
148+
/**
149+
* Read the regenerate command a config documents about itself. Every flag the
150+
* gate passes comes from there, so a package that changes its locales or output
151+
* directory updates one place and both readers follow.
152+
*/
153+
export function flagsFromDocstring(configPath) {
154+
const src = readFileSync(configPath, 'utf8');
155+
const head = src.slice(0, src.indexOf('*/') + 2);
156+
const flags = head.match(/--(?:locales|fill|out)=[^\s\\*]+|--(?:objects-only|no-metadata-forms|no-merge)\b/g) ?? [];
157+
return [...new Set(flags)];
158+
}
159+
160+
/** Same question, over already-parsed flags — the pure half, for offline tests. */
161+
export function flagsExtractMetadataForms(flags) {
162+
return !flags.includes(METADATA_FORMS_OPT_OUT_FLAG);
163+
}
164+
165+
/**
166+
* Does ANY discovered config still commit the shared metadata-form baseline?
167+
*
168+
* This is the applicability question the form-module convention hangs on, and
169+
* it is read from the configs rather than assumed: the day the last config
170+
* passes --no-metadata-forms, no form module can move a committed bundle any
171+
* more, and a derivation that kept naming check:i18n for one would be sending
172+
* every spec card to a gate that cannot go red.
173+
*/
174+
export function anyConfigExtractsMetadataForms(configs) {
175+
return configs.some((c) => flagsExtractMetadataForms(flagsFromDocstring(c.abs)));
176+
}

scripts/pm/check-dispatch-gates.mjs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,25 @@ import process from 'node:process';
104104

105105
const ROOT = new URL('../..', import.meta.url).pathname;
106106

107-
/** The tool under test, repo-relative — and this gate's only watch hint. */
107+
/** The tool under test, repo-relative — and one of this gate's two watch hints. */
108108
const TOOL = 'scripts/pm/dispatch-gates.mjs';
109109

110+
/**
111+
* The tool's shared enumeration module, declared so a card editing it derives
112+
* this gate (#9116).
113+
*
114+
* The tool imports its i18n walks from there instead of mirroring the gate's
115+
* copies, which is the point of that module — but an import specifier is not a
116+
* discoverable watch hint (`../i18n-bundle-surface.mjs` strips to a bare
117+
* filename, which the extractor rejects as unpathy). Without this constant, a
118+
* change to the shared module would move this gate's verdict — the tool's
119+
* self-test drives those very functions — while deriving nothing, which is the
120+
* blind-spot shape the tool exists to remove. Named here, not in the tool: this
121+
* family resolves to THIS file, and hints are scanned from the file a family
122+
* resolves to. Pinned live in the tool's own self-test.
123+
*/
124+
const SURFACE_MODULE = 'scripts/i18n-bundle-surface.mjs';
125+
110126
const result = spawnSync(process.execPath, [join(ROOT, TOOL), '--self-test'], { stdio: 'inherit' });
111127

112128
if (result.error) {

0 commit comments

Comments
 (0)