Skip to content

Commit 8af76ae

Browse files
qq9340100claude
andauthored
fix(cli,spec,i18n): default-locale bundle tracks the source; promote approval labels into the contract (#8597)
* fix(cli,spec,i18n): default-locale bundle tracks the source; promote approval labels into the contract The i18n extractor's merge branch ran for every locale including the default one, so an existing en entry always beat the source seed: an author's edit to a label/description/help never reached the committed en bundle and the drift gate stayed green (53 stale entries across 6 packages at branch time). The default locale is now rewritten from the source on every extract; translated locales keep merge semantics exactly as before (30 translated bundles verified byte-identical through a fixed regeneration). Contract-first (#8543 ruling, variant C): the five humanized sys_approval_request.status labels lived only in the generated en bundle, so a source-tracking en channel would have regressed them to raw machine values. They are promoted into packages/spec as APPROVAL_STATUS_LABELS beside APPROVAL_STATUSES, and the column derives its options from both — never re-typed at the column (#3786). Same treatment for the sibling sys_approval_action.action (#8580): APPROVAL_ACTION_KIND_LABELS, whose twelve entries previously shipped raw (submit / request_info / ...) because the #7232 humanization missed the field. Guard rail (variant B's axis): bare-string / label-less select options — including the {value, label: value} shape Field.select normalizes bare strings into — now seed through the extractor's derived channel: the value still seeds the skeleton, but inline stays unset, so the coverage gate no longer demands translations of machine identifiers and a copied value cannot masquerade as authored display text. In-repo coverage-ratchet blast radius: zero (12 configs, no count moved). Fixes #8543 Fixes #8580 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz * docs(agents): state the default-locale bundle lesson self-contained, no issue-ID citation The corrected Documentation Guardrails row cited the card's issue number in operative text, which check-skill-id-lint rejects: a lesson there must stand on its own (failure mode + discipline + boundary) because a reader hitting the rule cannot go read the issue. The row now says what went wrong (a merged default locale let a stale bundle entry beat an edited source string, drifting silently under a green gate), what to do (edit the source metadata, not the generated en file), and where the boundary sits (translated-locale values stay hand-written and merge-protected) — number dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz * test(cli): annotate the three new test-layer params the TEST_DEBT ratchet caught The two i18n test files live in the package's hidden test layer (tsconfig includes only src), where a frozen TS2835 relative-import cascade makes every bare arrow parameter an implicit any. The three parameters the #8543 tests added (one map in i18n-extract.test.ts, one filter+map pair in i18n-coverage.test.ts) each cost +1 raw tsc error against the shrink-only TEST_DEBT entry (188 -> 191). Structural annotations bring the measurement back to exactly 188; the ledger itself is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz * regen(i18n): regenerate plugin-security en bundle from the merged source Discharges the merge commit's placeholder: node scripts/check-i18n-bundles.mjs --write over the merged tree. The result carries both sides — main's #8599 source wording and this branch's source-tracking rewrites — as the extractor's own output, not a hand-picked side. Only the en file changed; the 30 translated-locale bundles were rewritten in place and came back byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f96ce66 commit 8af76ae

20 files changed

Lines changed: 389 additions & 104 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/spec': minor
4+
'@objectstack/plugin-approvals': patch
5+
'@objectstack/platform-objects': patch
6+
'@objectstack/plugin-audit': patch
7+
'@objectstack/plugin-security': patch
8+
'@objectstack/plugin-webhooks': patch
9+
'@objectstack/service-messaging': patch
10+
---
11+
12+
The i18n extractor's default locale now tracks the source instead of merging (#8543), and the approval vocabularies carry authored English labels in the contract (#8580).
13+
14+
- `os i18n extract` merge mode no longer applies to the default locale: `en` is a copy of the source, not a translation, so an edited label/description/help now reaches the regenerated `en` bundle instead of being silently shadowed by the stale entry forever (53 stale entries had accumulated across 6 packages under the old behavior; all rewritten here). Translated locales (`zh-CN` / `ja-JP` / `es-ES`) keep merge semantics exactly as before — no existing translation is overwritten.
15+
- Bare-string and label-less select options now seed through the extractor's derived channel: the machine value still seeds the skeleton, but the coverage gate no longer demands "translations" of machine identifiers, and a copied value can no longer masquerade as authored display text.
16+
- New `@objectstack/spec/contracts` exports `APPROVAL_STATUS_LABELS` and `APPROVAL_ACTION_KIND_LABELS`: the authored English for `sys_approval_request.status` (previously living only in the generated `en` bundle) and `sys_approval_action.action` (previously shipping raw machine values such as `submit` / `request_info` — the #7232 humanization missed this sibling field). Both columns derive their option labels from these maps; the regenerated `en` bundles copy them verbatim.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d
478478
|:---|:---|:---|
479479
| `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. |
480480
| `content/docs/releases/` | **RELEASE-OWNED** | ❌ Never edit in a code PR. Release notes are written **centrally at release time**, compiled from changesets + the ADR-0087 registries — not accreted a row per PR. Per-PR appends made `releases/v<major>.mdx` the repo's hottest conflict magnet (three PRs raced the same table inside one afternoon), and every manual resolution risks dropping someone else's row. Your PR's input is its **changeset**; for spec removals also the D2/D3 registry entries. Factual error on a releases page → dedicated docs-only PR or an issue, never a rider on code changes. |
481-
| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode — every existing translation is preserved); `pnpm i18n:extract` still covers `platform-objects` alone. Translation *values* are hand-written and expected to be: the gate compares against a merge-mode extract, so editing a string is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. |
481+
| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode preserves every existing **translated-locale** value; the default locale `en` is rewritten from the source on every run — it is a copy of the source, not a translation: when the extractor merged `en` too, a stale bundle entry always beat an edited source string, so the served text drifted from the source silently under a green gate. Hand-edits to `en.*.generated.ts` therefore do not survive and belong in the source metadata instead); `pnpm i18n:extract` still covers `platform-objects` alone. Translated-locale *values* (`zh-CN` / `ja-JP` / `es-ES`) are hand-written and expected to be: the gate compares against a merge-mode extract, so editing one of those strings is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. |
482482
| `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. |
483483
| `content/docs/concepts/` | hand-written ||
484484
| `content/docs/getting-started/` | hand-written ||

packages/cli/src/utils/i18n-extract.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -645,30 +645,38 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
645645
pushOptional(out, ['objects', objectName, 'fields', fieldName, 'placeholder'], field.placeholder, 'field', { objectName });
646646

647647
// Options — accept either `{value, label}[]` arrays or a record map.
648+
//
649+
// An option whose label is absent — or byte-equal to its own machine
650+
// value, which is what `Field.select(['pending'])` normalizes a bare
651+
// string into — is seeded from the value but recorded as DERIVED
652+
// (#8543): the seed keeps the skeleton usable, while `inline` stays
653+
// unset so the coverage gate never demands a translation of a machine
654+
// identifier, and nothing downstream mistakes the copied value for
655+
// deliberately-authored display text. Authored English for a select
656+
// belongs on the option (or in the contract beside the vocabulary —
657+
// see `APPROVAL_STATUS_LABELS` in @objectstack/spec/contracts), where
658+
// this walk sees it as a real label.
659+
const pushOption = (value: string, label: unknown): void => {
660+
const path = ['objects', objectName, 'fields', fieldName, 'options', value];
661+
const authored = inlineText(label);
662+
if (authored !== undefined && authored !== value) {
663+
pushEntry(out, path, authored, 'option', { objectName });
664+
} else {
665+
pushDerived(out, path, value, undefined, 'option', { objectName });
666+
}
667+
};
648668
const opts = field.options;
649669
if (Array.isArray(opts)) {
650670
for (const opt of opts) {
651671
if (opt && typeof opt === 'object' && 'value' in opt) {
652-
pushEntry(
653-
out,
654-
['objects', objectName, 'fields', fieldName, 'options', String(opt.value)],
655-
String(opt.label ?? opt.value),
656-
'option',
657-
{ objectName },
658-
);
672+
pushOption(String(opt.value), opt.label);
659673
} else if (typeof opt === 'string') {
660-
pushEntry(out, ['objects', objectName, 'fields', fieldName, 'options', opt], opt, 'option', { objectName });
674+
pushOption(opt, undefined);
661675
}
662676
}
663677
} else if (opts && typeof opts === 'object') {
664678
for (const [value, label] of Object.entries<any>(opts)) {
665-
pushEntry(
666-
out,
667-
['objects', objectName, 'fields', fieldName, 'options', value],
668-
typeof label === 'string' ? label : String(value),
669-
'option',
670-
{ objectName },
671-
);
679+
pushOption(value, label);
672680
}
673681
}
674682
}
@@ -1077,7 +1085,16 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext
10771085
// verbatim so the generated file remains a complete, self-contained
10781086
// bundle (not just the missing-key delta). Set --no-merge to skip
10791087
// baselines entirely.
1080-
if (opts.mergeExisting !== false) {
1088+
//
1089+
// The default locale is deliberately NOT merged (#8543): it is a copy of
1090+
// the source, not a translation, so "never overwrite an existing entry"
1091+
// protects the wrong thing there — an author edits a field description,
1092+
// the regeneration keeps the stale entry, and the served text drifts from
1093+
// the source silently while the drift gate reports OK (measured at 53
1094+
// stale entries across 6 packages when this branch ran for every locale).
1095+
// The seed IS the source text for the default locale (line below), so it
1096+
// always wins; translated locales keep merge semantics exactly as before.
1097+
if (opts.mergeExisting !== false && locale !== defaultLocale) {
10811098
const existingValue = lookupDeep(existing[locale], entry.path);
10821099
if (existingValue !== undefined && existingValue !== '') {
10831100
value = String(existingValue);

packages/cli/test/i18n-coverage.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,16 @@ describe('computeI18nCoverage', () => {
157157
expect(zhKeys.has('objects.account.fields.stage.options.direct_mail')).toBe(true);
158158
});
159159

160-
it('covers options declared as a bare string array', () => {
160+
it('#8543: bare-string options are DERIVED — no translation demanded for a machine identifier', () => {
161+
// A bare-string option has no authored display text: its "label" is a
162+
// copy of the machine value (`Field.select(['planning'])` normalizes to
163+
// `{ value: 'planning', label: 'planning' }`). This test used to pin the
164+
// opposite — that the gate demands a zh-CN translation of `planning` /
165+
// `closed` — which both erased the authored/derived axis the extractor
166+
// documents and taught authors to "translate" machine identifiers.
167+
// Authored option labels (previous test) stay gated; a bundle that
168+
// externalizes text for a derived key re-enters the expected set via
169+
// `authoredInBundle`.
161170
const stringOptionConfig: any = {
162171
objects: [
163172
{
@@ -170,8 +179,42 @@ describe('computeI18nCoverage', () => {
170179
};
171180
const report = computeI18nCoverage(stringOptionConfig, { defaultLocale: 'en' });
172181
const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key));
182+
expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(false);
183+
expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(false);
184+
// The field's own authored label is still owed.
185+
expect(zhKeys.has('objects.account.fields.stage.label')).toBe(true);
186+
});
187+
188+
it('#8543: a bundle that authors text for a derived option key re-enters the expected set', () => {
189+
// The other half of the derived-channel contract: `inline` unset does not
190+
// mean "never gated" — a project that externalizes display text for a
191+
// bare-string option into some bundle owes the other locales a
192+
// translation of it, exactly like any externalized string.
193+
const externalized: any = {
194+
objects: [
195+
{
196+
name: 'account',
197+
label: 'Account',
198+
fields: { stage: { label: 'Stage', options: ['planning'] } },
199+
},
200+
],
201+
translations: [
202+
{
203+
en: { objects: { account: { fields: { stage: { options: { planning: 'Planning' } } } } } },
204+
'zh-CN': {},
205+
},
206+
],
207+
};
208+
const report = computeI18nCoverage(externalized, { defaultLocale: 'en' });
209+
// Structural annotations: the module import is outside this file's tsc
210+
// program reach (frozen TS2835 debt), so bare parameters here would be
211+
// implicitly-any additions to the package's TEST_DEBT ledger.
212+
const zhKeys = new Set(
213+
report.issues
214+
.filter((i: { locale: string }) => i.locale === 'zh-CN')
215+
.map((i: { key: string }) => i.key),
216+
);
173217
expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(true);
174-
expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(true);
175218
});
176219

177220
it('promotes warnings to errors under --strict', () => {

packages/cli/test/i18n-extract.test.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,55 @@ describe('collectExpectedEntries', () => {
133133
expect(byPath['metadataForms.flow.fields.name.label']).toBe('Name');
134134
});
135135

136+
it('#8543: label-less options seed through the DERIVED channel, authored labels stay authored', () => {
137+
// `Field.select(['pending'])` normalizes a bare string to
138+
// `{ value: 'pending', label: 'pending' }` — the label is a copy of the
139+
// machine value, not authored English. Recording that as authored is how
140+
// the coverage gate came to demand translations of machine identifiers,
141+
// and how a raw machine value could ship as rendered text without any
142+
// gate noticing (#8580 is the shipped instance). All three authoring
143+
// shapes are pinned: `{value,label}` with the label equal to the value,
144+
// a bare string, and a record map whose label restates the key.
145+
const cfg: any = {
146+
objects: [
147+
{
148+
name: 'w',
149+
label: 'W',
150+
fields: {
151+
normalized: {
152+
label: 'Normalized',
153+
options: [
154+
{ value: 'pending', label: 'pending' }, // Field.select(['pending']) shape
155+
{ value: 'approved', label: 'Approved' }, // genuinely authored
156+
{ value: 'rejected' }, // no label at all
157+
],
158+
},
159+
bare: { label: 'Bare', options: ['draft'] },
160+
map: { label: 'Map', options: { open: 'open', closed: 'Closed' } },
161+
},
162+
},
163+
],
164+
};
165+
const entries = collectExpectedEntries(cfg);
166+
// Structural annotation: the module import is outside this file's tsc
167+
// program reach (frozen TS2835 debt), so the parameter would otherwise be
168+
// an implicitly-any addition to the package's TEST_DEBT ledger.
169+
const byPath = Object.fromEntries(
170+
entries.map((e: { path: string[] }) => [e.path.join('.'), e]),
171+
);
172+
const opt = (p: string) => byPath[`objects.w.fields.${p}`];
173+
174+
// Derived: seeded from the value so skeletons stay usable, but `inline`
175+
// stays unset — nobody authored display text.
176+
for (const p of ['normalized.options.pending', 'normalized.options.rejected', 'bare.options.draft', 'map.options.open']) {
177+
expect(opt(p)?.sourceValue, p).toBe(p.split('.').pop());
178+
expect(opt(p)?.inline, p).toBeUndefined();
179+
}
180+
// Authored: the label is real display text and drives the coverage gate.
181+
expect(opt('normalized.options.approved')?.inline).toBe('Approved');
182+
expect(opt('map.options.closed')?.inline).toBe('Closed');
183+
});
184+
136185
it('emits action param entries (inline + top-level), skipping field-backed labels without overrides', () => {
137186
const entries = collectExpectedEntries(config);
138187
const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue]));
@@ -265,15 +314,61 @@ describe('extractTranslations', () => {
265314
locales: ['en'],
266315
mergeExisting: true,
267316
});
268-
// Existing translations are preserved verbatim so the generated file
269-
// is a complete, self-contained bundle (not just a delta).
317+
// The fixture's en bundle matches the source, so this only proves the
318+
// seed path; the divergence cases live in the #8543 test below.
270319
expect(bundles.en.objects?.sys_position?.label).toBe('Role');
271320
expect(bundles.en.objects?.sys_position?.fields?.active?.label).toBe('Active');
272321
// Missing keys are still filled from schema defaults.
273322
expect(bundles.en.objects?.sys_position?.pluralLabel).toBe('Roles');
274323
expect(bundles.en.objects?.sys_position?.fields?.label?.label).toBe('Display Name');
275324
});
276325

326+
it('#8543: the default locale tracks the SOURCE, not a stale existing entry; translated locales keep merge', () => {
327+
// The en bundle is a copy of the source, not a translation. Before #8543
328+
// the merge branch ran for every locale, so an author editing a field
329+
// description could never get the edit into the committed en bundle — the
330+
// stale entry always won and the drift gate stayed green (53 stale
331+
// entries had accumulated across 6 packages when this was fixed).
332+
const cfg: any = {
333+
objects: [
334+
{
335+
name: 'thing',
336+
label: 'Thing (new wording)',
337+
fields: { note: { label: 'Note', help: 'New help text' } },
338+
},
339+
],
340+
translations: [
341+
{
342+
en: {
343+
objects: {
344+
thing: {
345+
label: 'Thing (stale wording)',
346+
fields: { note: { label: 'Note', help: 'Old help text' } },
347+
},
348+
},
349+
},
350+
'zh-CN': {
351+
objects: {
352+
thing: { label: '事物', fields: { note: { label: '备注', help: '说明' } } },
353+
},
354+
},
355+
},
356+
],
357+
};
358+
const { bundles } = extractTranslations(cfg, {
359+
defaultLocale: 'en',
360+
locales: ['zh-CN'],
361+
mergeExisting: true,
362+
});
363+
// en: the source seed wins over the stale bundle entry.
364+
expect(bundles.en.objects?.thing?.label).toBe('Thing (new wording)');
365+
expect(bundles.en.objects?.thing?.fields?.note?.help).toBe('New help text');
366+
// zh-CN: the human translation is preserved verbatim — merge semantics
367+
// for translated locales are exactly what they were.
368+
expect(bundles['zh-CN'].objects?.thing?.label).toBe('事物');
369+
expect(bundles['zh-CN'].objects?.thing?.fields?.note?.help).toBe('说明');
370+
});
371+
277372
it('filters by object name regex', () => {
278373
const cfg = {
279374
objects: [

0 commit comments

Comments
 (0)