Skip to content

Commit de47336

Browse files
os-warrenclaude
andauthored
feat(i18n): opt every bundle set into the generated-leaf provenance companion (#12644)
* feat(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets Seven of the eight uncovered i18n bundle sets now document `--source-hashes` in their extract config and commit the three `<locale>.source-hashes.generated.ts` companions produced by the same extract run as their bundles. The eighth, `service-realtime`, declares itself legacy-trusted with the measurement behind that choice, and a test beside its bundles fails the day the measurement stops holding. Also narrows the provenance table to the sections a run actually commits: the seven sets pass `--no-metadata-forms`, and their unrecorded `metadataForms` subtree arrived as a fresh fill of `en`, so the table carried 761 digests of a baseline `@objectstack/platform-objects` owns. `platform-objects` commits both sections, so its committed companions are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * docs(i18n): name the opt-in flag once, in the command the config documents `flagsFromDocstring` harvests every recognised flag spelling out of a config's whole docstring, so the paragraph explaining `--source-hashes` was a SECOND declaration of it: deleting the flag from the documented command left the opt-in switched on, which is not what anyone editing that command would read. Measured while ablating the opt-out path — the extract still emitted 7 bundles for a config whose command block no longer named the flag. The prose now spells it without leading dashes and says why, the same rule service-realtime's legacy-trusted declaration already states from the other side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * feat(i18n): opt every bundle set into the generated-leaf provenance companion Rolls `--source-hashes` out to the eighth and last uncovered set (`service-realtime`) and puts all eight remaining sets on ONE shared opt-in paragraph, so no set carries a hand-maintained exception. The recovered branch left `service-realtime` opted out on the ground that its companions would hold zero entries, and paired the opt-out with a bespoke test to keep that prose honest. Measured here, the same is true of `plugin-sharing` in zh-CN and ja-JP, which the rollout opted in anyway — so the exception was not a property of `service-realtime`, it was a property of a fully translated locale, and two of the nine sets shipped exactly the artifact the exception argued against. An empty companion is the instrument ARMED, not an instrument that measures nothing: the extractor rewrites it on every run, so a leaf that becomes a fill is recorded by itself. The opted-out shape needed a human to re-read a paragraph, which is why it needed a test. Uniform opt-in needs neither. Records therefore count what is currently RECORDABLE, never what is covered — stated in the shared paragraph, since it is the inference the card was filed about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa3f9ba commit de47336

36 files changed

Lines changed: 1341 additions & 1 deletion
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/plugin-approvals": patch
4+
"@objectstack/plugin-audit": patch
5+
"@objectstack/plugin-security": patch
6+
"@objectstack/plugin-sharing": patch
7+
"@objectstack/plugin-webhooks": patch
8+
"@objectstack/service-messaging": patch
9+
"@objectstack/service-realtime": patch
10+
"@objectstack/service-storage": patch
11+
---
12+
13+
chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559)
14+
15+
`os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A)
16+
records, per generated translation leaf, the digest of the source revision that
17+
leaf is **still a byte copy of** — the one signal that tells a stale fill from a
18+
real translation once the source has moved and the two stopped being
19+
distinguishable by value. It shipped opt-in, and exactly one of the nine i18n
20+
bundle sets opted in. A landed detector, a changeset announcing it and a green
21+
gate read together as *"generated translation staleness is now caught"*; for
22+
eight of nine sets it was not, and the thing making it not caught was a single
23+
absent flag in an extract config — invisible from all three of those surfaces.
24+
25+
**All eight remaining sets now opt in**`plugin-approvals`, `plugin-audit`,
26+
`plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`,
27+
`service-realtime`, `service-storage`. Each documents `source-hashes` in its
28+
extract config and commits three `<locale>.source-hashes.generated.ts`
29+
companions, produced by the same extract run as the bundles they sit beside
30+
(`check:i18n` compares them byte-for-byte, so they cannot be written by hand).
31+
`check:i18n` now reports 7 bundles per set where it reported 4, and 11 for
32+
`platform-objects` where it reported 8.
33+
34+
**Records count what is currently RECORDABLE, never what is covered.** A record
35+
is written only for a leaf that *is* right now a byte copy of the current
36+
source, so a fully translated locale starts with an empty table — which is the
37+
instrument armed, not an instrument that measures nothing: the entry appears by
38+
itself on the first extract after a leaf becomes a fill. Measured at this
39+
commit, per set over its three translated locales: `service-messaging` 289,
40+
`plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20,
41+
`plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and
42+
ja-JP are fully translated and start empty), `service-realtime` 0 (all three
43+
locales fully translated). **419 records written across the eight sets, 0
44+
stale.**
45+
46+
**One extractor fix the rollout forced.** `--source-hashes` had one user, and
47+
that user commits both generated sections, so the interaction with
48+
`--no-metadata-forms` had never been exercised. The provenance table is computed
49+
over every generated section the extractor builds; the eight sets here commit no
50+
metadata-forms bundle, and their `metadataForms` subtree — absent from their
51+
merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf
52+
of it was recordable. First measured on `plugin-audit`: **763 records, of which
53+
2 were its own objects and 761 were digests of the Studio metadata-form baseline
54+
`@objectstack/platform-objects` owns.** Those records are unreadable in the
55+
package holding them and would have rewritten all 24 companions on any unrelated
56+
`*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8
57+
and every `bundle-ownership.test.ts` keep out of committed bundles. The
58+
companion now covers exactly the sections a run commits, decided by the same two
59+
predicates that decide the bundle files. `platform-objects` commits both, so its
60+
three committed companions are byte-for-byte unchanged.
61+
62+
**Grade: `patch`, and behaviour on the day it lands is unchanged for every
63+
leaf.** A record is written only where a leaf is currently a byte copy of the
64+
**current** source, so every record written equals the current digest and none
65+
of them can be stale; the mechanism cannot arrive red. No committed translation
66+
bundle changed a byte, no public API moved, and no leaf's rendered text changed.
67+
`narrowToCommittedSections` is new but internal to `@objectstack/cli` — the
68+
package's entrypoint does not re-export the extractor utils.
69+
70+
**What this does not do**, stated so the boundary is not inferred wrongly a
71+
second time: these eight sets now *record* provenance. Reading it at serving
72+
time is `withSourceFallback`, and that is still wired in
73+
`@objectstack/platform-objects` alone — so a stale fill in one of the eight is
74+
now recorded and reportable, but not yet substituted at runtime. Tracked
75+
separately.

packages/cli/src/commands/i18n/extract.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
renderTranslationModule,
2222
renderSourceHashModule,
2323
parseSourceHashModule,
24+
narrowToCommittedSections,
2425
type FillStrategy,
2526
} from '../../utils/i18n-extract.js';
2627

@@ -198,6 +199,52 @@ export default class I18nExtract extends Command {
198199
const emitsMetadataForms = (locale: string): boolean =>
199200
flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0;
200201

202+
/**
203+
* The provenance table for one locale, narrowed to the sections this run
204+
* actually COMMITS (#12559).
205+
*
206+
* `extractTranslations` computes the table over every generated section it
207+
* built — `objects` and `metadataForms` both — because the rule that fills
208+
* it (`collectFilledFromHashes`) is a statement about generated leaves, not
209+
* about files. Which of those sections becomes a committed bundle is this
210+
* layer's decision, and the two must agree: a record describes the leaf
211+
* sitting in a bundle beside it, and a record for a leaf this package does
212+
* not commit describes nothing that exists here.
213+
*
214+
* The mismatch is not hypothetical — it is what the eight-set rollout in
215+
* #12559 measured on first contact. A package that owns only its own
216+
* objects passes `--no-metadata-forms`, and the emitter's own note two
217+
* blocks up says why: "without it, `--check` demands a baseline copy the
218+
* package deliberately does not commit". Its `metadataForms` subtree is
219+
* nonetheless built, and — having no entry in that package's merge
220+
* baseline — arrives as a fresh `--fill=default` copy of `en`, so EVERY
221+
* leaf of it satisfies `value === currentSource` and gets recorded.
222+
* Measured on `plugin-audit`: 763 records, of which **2** were its own
223+
* objects and 761 were digests of the Studio metadata-form baseline that
224+
* `@objectstack/platform-objects` owns. Those records are unreadable here
225+
* (no `metadataForms` bundle exists in this package for them to be about),
226+
* and they would move all three of this package's companions every time an
227+
* unrelated `*.form.ts` in `packages/spec` changed — the same cross-package
228+
* coupling ADR-0029 D8 and each package's `bundle-ownership.test.ts` exist
229+
* to keep out of its committed bundles.
230+
*
231+
* So the section list is decided by the SAME predicates that decide the
232+
* bundle files, never by a second rule: `result.counts` for `objects` and
233+
* {@link emitsMetadataForms} for `metadataForms`. A set that commits both —
234+
* `platform-objects` is the one today — keeps every record it had. The
235+
* narrowing itself is `narrowToCommittedSections`, a pure function in the
236+
* extractor's utils so it can be pinned without driving oclif; this layer
237+
* contributes only the two booleans it alone knows.
238+
*/
239+
const committedSourceHashes = (locale: string): Record<string, string> | undefined => {
240+
const table = result.sourceHashes[locale];
241+
if (!table) return undefined;
242+
const committed: string[] = [];
243+
if ((result.counts[locale] ?? 0) > 0) committed.push('objects');
244+
if (emitsMetadataForms(locale)) committed.push('metadataForms');
245+
return narrowToCommittedSections(table, committed);
246+
};
247+
201248
if (flags.json) {
202249
await emitJson({
203250
totalExpected: result.totalExpected,
@@ -275,7 +322,7 @@ export default class I18nExtract extends Command {
275322
// The provenance companion rides in the SAME list, so `--check` compares
276323
// it by the same byte-for-byte rule as the bundles it belongs to and can
277324
// never diverge from what a real extract writes.
278-
const table = result.sourceHashes[locale];
325+
const table = committedSourceHashes(locale);
279326
if (flags['source-hashes'] && table) {
280327
emitted.push({
281328
file: path.join(resolvedOutDir, `${locale}.source-hashes.generated.ts`),

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,49 @@ export function renderSourceHashModule(
14751475
return lines.join('\n');
14761476
}
14771477

1478+
/**
1479+
* Narrow a provenance table to the generated sections a run actually COMMITS
1480+
* (#12559).
1481+
*
1482+
* {@link extractTranslations} computes the table over every generated section it
1483+
* built — `objects` and `metadataForms` both — because the rule that fills it
1484+
* (`collectFilledFromHashes`) is a statement about generated leaves, not about
1485+
* files. Which of those sections becomes a committed bundle is the command
1486+
* layer's decision, and the two must agree: a record describes the leaf sitting
1487+
* in a bundle beside it, so a record for a section this package does not commit
1488+
* describes nothing that exists there.
1489+
*
1490+
* The mismatch is measured, not hypothetical. A package that owns only its own
1491+
* objects passes `--no-metadata-forms`, and the emitter's own note says why:
1492+
* "without it, `--check` demands a baseline copy the package deliberately does
1493+
* not commit". Its `metadataForms` subtree is built anyway, and — having no
1494+
* entry in that package's merge baseline — arrives as a fresh `--fill=default`
1495+
* copy of `en`, so EVERY leaf of it satisfies `value === currentSource` and is
1496+
* recorded. Measured on `@objectstack/plugin-audit` while rolling the companion
1497+
* out in #12559: 763 records, of which **2** were its own objects and 761 were
1498+
* digests of the Studio metadata-form baseline `@objectstack/platform-objects`
1499+
* owns — unreadable in that package (no `metadataForms` bundle exists there for
1500+
* them to be about) and re-written in all three of its companions every time an
1501+
* unrelated `*.form.ts` in `packages/spec` moved. That is the cross-package
1502+
* coupling ADR-0029 D8 and each package's `bundle-ownership.test.ts` keep out of
1503+
* the committed bundles; the companion is not exempt from it.
1504+
*
1505+
* A section is named by a leaf path's FIRST dotted segment — the same identity
1506+
* `collectGeneratedLeaves` seeds its walk with, so the two cannot disagree about
1507+
* what section a path belongs to. Callers pass the sections they are committing;
1508+
* this function invents none, so a set that commits both (`platform-objects` is
1509+
* the one today) keeps every record it had.
1510+
*/
1511+
export function narrowToCommittedSections(
1512+
hashes: Record<string, string>,
1513+
committedSections: Iterable<string>,
1514+
): Record<string, string> {
1515+
const committed = new Set(committedSections);
1516+
return Object.fromEntries(
1517+
Object.entries(hashes).filter(([leafPath]) => committed.has(leafPath.split('.', 1)[0])),
1518+
);
1519+
}
1520+
14781521
/**
14791522
* Read a committed `<locale>.source-hashes.generated.ts` back into a table.
14801523
*

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
extractTranslations,
1616
renderSourceHashModule,
1717
parseSourceHashModule,
18+
narrowToCommittedSections,
1819
} from '../src/utils/i18n-extract.js';
1920

2021
const stack = (help: string) => ({
@@ -84,3 +85,62 @@ describe('the emitted module', () => {
8485
expect(parseSourceHashModule('export const x: Readonly<Record<string, string>> = { "a": 3 };')).toBeUndefined();
8586
});
8687
});
88+
89+
// ---------------------------------------------------------------------------
90+
// The companion covers the sections the run COMMITS (#12559)
91+
// ---------------------------------------------------------------------------
92+
//
93+
// `extractTranslations` fills the table over every generated section it built,
94+
// `metadataForms` included, because the rule behind it is about generated
95+
// leaves rather than about files. A package that owns only its own objects
96+
// passes `--no-metadata-forms` and commits no metadata-forms bundle — and its
97+
// `metadataForms` subtree, absent from its merge baseline, arrives as a fresh
98+
// `--fill=default` copy of `en`, so every leaf of it is recordable. Unnarrowed,
99+
// that is what the table carries: measured on `@objectstack/plugin-audit` during
100+
// the #12559 rollout, 763 records of which 2 were its own objects and 761 were
101+
// digests of the metadata-form baseline `@objectstack/platform-objects` owns.
102+
//
103+
// The narrowing is pinned here rather than mirrored from the command, because a
104+
// mirror of an emit rule is a second contract — it agrees until the day one side
105+
// changes, and nothing says so on that day.
106+
107+
describe('the provenance table is narrowed to the committed sections (#12559)', () => {
108+
const result = extractTranslations(
109+
{ objects: [{ name: 'account', label: 'Account', fields: { name: { label: 'Name' } } }] },
110+
{ defaultLocale: 'en', locales: ['zh-CN'], fill: 'default' },
111+
);
112+
const table = result.sourceHashes['zh-CN'];
113+
const sectionsIn = (t: Record<string, string>) => [...new Set(Object.keys(t).map((k) => k.split('.', 1)[0]))].sort();
114+
115+
it('has both sections to narrow — otherwise every case below would pass vacuously', () => {
116+
// The registry-driven metadata-form baseline is present for any stack, so
117+
// this is the precondition that makes the two cases a measurement rather
118+
// than an empty walk.
119+
expect(sectionsIn(table)).toEqual(['metadataForms', 'objects']);
120+
expect(Object.keys(table).length).toBeGreaterThan(100);
121+
});
122+
123+
it('drops the metadata-forms records for a set that commits no metadata-forms bundle', () => {
124+
const narrowed = narrowToCommittedSections(table, ['objects']);
125+
expect(sectionsIn(narrowed)).toEqual(['objects']);
126+
// Every surviving record is unchanged — this narrows the table, it does not
127+
// recompute it.
128+
for (const [path, digest] of Object.entries(narrowed)) expect(digest).toBe(table[path]);
129+
});
130+
131+
it('keeps every record for a set that commits both, so the covered set is untouched', () => {
132+
expect(narrowToCommittedSections(table, ['objects', 'metadataForms'])).toEqual(table);
133+
});
134+
135+
it('names a section by a leaf path\'s first dotted segment, never by a prefix match', () => {
136+
// `objects` must not be reached through a section that merely starts with
137+
// it, and a nested key called `objects` must not be mistaken for the section.
138+
const odd = { 'objectsExtra.a.label': 'aaaaaaaaaaaaaaaa', 'metadataForms.x.objects.b': 'bbbbbbbbbbbbbbbb' };
139+
expect(narrowToCommittedSections(odd, ['objects'])).toEqual({});
140+
expect(narrowToCommittedSections(odd, ['metadataForms'])).toEqual({ 'metadataForms.x.objects.b': 'bbbbbbbbbbbbbbbb' });
141+
});
142+
143+
it('commits nothing when no section is committed', () => {
144+
expect(narrowToCommittedSections(table, [])).toEqual({});
145+
});
146+
});

packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,32 @@
99
*
1010
* os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
1111
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
12+
* --source-hashes \
1213
* --out=packages/plugins/plugin-approvals/src/translations
1314
*
15+
* The `source-hashes` flag on the command above also emits
16+
* `<locale>.source-hashes.generated.ts` — the provenance companion from
17+
* maintainer ruling #12069 Option A (#11671), rolled out to every bundle set by
18+
* #12559. Without it every generated leaf here is LEGACY-TRUSTED: a leaf filled
19+
* from the source and then left behind when the source was revised is
20+
* indistinguishable BY VALUE from a real translation, so it publishes a
21+
* superseded draft under a green `check:i18n` forever, and
22+
* `check:i18n-stale-fill` cannot see it either unless two locales happen to
23+
* hold the same stale bytes.
24+
*
25+
* A record is written only for a leaf that IS currently a byte copy of the
26+
* CURRENT source, so the companion arrives 0-stale by construction and only
27+
* ever reports drift accruing afterwards. Records count the leaves currently
28+
* RECORDABLE, never the leaves covered — a table with few entries, or with none
29+
* at all where a locale is fully translated, is the instrument armed, and an
30+
* entry appears by itself on the first extract after a leaf becomes a fill.
31+
*
32+
* The flag is named in prose WITHOUT its leading dashes on purpose:
33+
* `flagsFromDocstring` (scripts/i18n-bundle-surface.mjs) harvests every
34+
* recognised flag spelling out of this whole comment, so a second spelling
35+
* would keep the opt-in switched on after someone deleted it from the command
36+
* block — the one place that decides.
37+
*
1438
* `--no-metadata-forms` because the Studio metadata-form baseline is owned by
1539
* `@objectstack/platform-objects` — this plugin translates only the objects it
1640
* owns, so it must not commit a second copy. Add `--check` to run the same
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Auto-generated by 'os i18n extract' for locale 'es-ES'. Do not hand-edit.
5+
*
6+
* Each entry is the digest of the SOURCE REVISION that this locale's leaf at
7+
* that path is still a byte copy of — provenance for the generated half of the
8+
* bundles (#11671, maintainer ruling #12069 Option A, extending #8765 Option B).
9+
*
10+
* An entry exists only while the leaf IS such a copy. Re-translate the leaf in
11+
* `<locale>.objects.generated.ts` and the next extract drops its entry by
12+
* itself — the table makes no claim about text a translator wrote. A path with
13+
* no entry is LEGACY-TRUSTED and never reported stale.
14+
*
15+
* ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest
16+
* records that the current text was copied from the current source, which is
17+
* the false claim the mechanism exists to detect. Fix the TRANSLATION.
18+
*/
19+
20+
export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
21+
"objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
22+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
23+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
24+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
25+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
26+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
27+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
28+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
29+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
30+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
31+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
32+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
33+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
34+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
35+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
36+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
37+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
38+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
39+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
40+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
41+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
42+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
43+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
44+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
45+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
46+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
47+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
48+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033",
49+
};

0 commit comments

Comments
 (0)