Skip to content
Merged
Show file tree
Hide file tree
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
16 changes: 16 additions & 0 deletions .changeset/i18n-en-bundle-tracks-source.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
'@objectstack/cli': minor
'@objectstack/spec': minor
'@objectstack/plugin-approvals': patch
'@objectstack/platform-objects': patch
'@objectstack/plugin-audit': patch
'@objectstack/plugin-security': patch
'@objectstack/plugin-webhooks': patch
'@objectstack/service-messaging': patch
---

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).

- `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.
- 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.
- 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.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -478,7 +478,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d
|:---|:---|:---|
| `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. |
| `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. |
| `**/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. |
| `**/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. |
| `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. |
| `content/docs/concepts/` | hand-written | ✅ |
| `content/docs/getting-started/` | hand-written | ✅ |
Expand Down
49 changes: 33 additions & 16 deletions packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -645,30 +645,38 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
pushOptional(out, ['objects', objectName, 'fields', fieldName, 'placeholder'], field.placeholder, 'field', { objectName });

// Options — accept either `{value, label}[]` arrays or a record map.
//
// An option whose label is absent — or byte-equal to its own machine
// value, which is what `Field.select(['pending'])` normalizes a bare
// string into — is seeded from the value but recorded as DERIVED
// (#8543): the seed keeps the skeleton usable, while `inline` stays
// unset so the coverage gate never demands a translation of a machine
// identifier, and nothing downstream mistakes the copied value for
// deliberately-authored display text. Authored English for a select
// belongs on the option (or in the contract beside the vocabulary —
// see `APPROVAL_STATUS_LABELS` in @objectstack/spec/contracts), where
// this walk sees it as a real label.
const pushOption = (value: string, label: unknown): void => {
const path = ['objects', objectName, 'fields', fieldName, 'options', value];
const authored = inlineText(label);
if (authored !== undefined && authored !== value) {
pushEntry(out, path, authored, 'option', { objectName });
} else {
pushDerived(out, path, value, undefined, 'option', { objectName });
}
};
const opts = field.options;
if (Array.isArray(opts)) {
for (const opt of opts) {
if (opt && typeof opt === 'object' && 'value' in opt) {
pushEntry(
out,
['objects', objectName, 'fields', fieldName, 'options', String(opt.value)],
String(opt.label ?? opt.value),
'option',
{ objectName },
);
pushOption(String(opt.value), opt.label);
} else if (typeof opt === 'string') {
pushEntry(out, ['objects', objectName, 'fields', fieldName, 'options', opt], opt, 'option', { objectName });
pushOption(opt, undefined);
}
}
} else if (opts && typeof opts === 'object') {
for (const [value, label] of Object.entries<any>(opts)) {
pushEntry(
out,
['objects', objectName, 'fields', fieldName, 'options', value],
typeof label === 'string' ? label : String(value),
'option',
{ objectName },
);
pushOption(value, label);
}
}
}
Expand DownExpand Up@@ -1077,7 +1085,16 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext
// verbatim so the generated file remains a complete, self-contained
// bundle (not just the missing-key delta). Set --no-merge to skip
// baselines entirely.
if (opts.mergeExisting !== false) {
//
// The default locale is deliberately NOT merged (#8543): it is a copy of
// the source, not a translation, so "never overwrite an existing entry"
// protects the wrong thing there — an author edits a field description,
// the regeneration keeps the stale entry, and the served text drifts from
// the source silently while the drift gate reports OK (measured at 53
// stale entries across 6 packages when this branch ran for every locale).
// The seed IS the source text for the default locale (line below), so it
// always wins; translated locales keep merge semantics exactly as before.
if (opts.mergeExisting !== false && locale !== defaultLocale) {
const existingValue = lookupDeep(existing[locale], entry.path);
if (existingValue !== undefined && existingValue !== '') {
value = String(existingValue);
Expand Down
47 changes: 45 additions & 2 deletions packages/cli/test/i18n-coverage.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,7 +157,16 @@ describe('computeI18nCoverage', () => {
expect(zhKeys.has('objects.account.fields.stage.options.direct_mail')).toBe(true);
});

it('covers options declared as a bare string array', () => {
it('#8543: bare-string options are DERIVED — no translation demanded for a machine identifier', () => {
// A bare-string option has no authored display text: its "label" is a
// copy of the machine value (`Field.select(['planning'])` normalizes to
// `{ value: 'planning', label: 'planning' }`). This test used to pin the
// opposite — that the gate demands a zh-CN translation of `planning` /
// `closed` — which both erased the authored/derived axis the extractor
// documents and taught authors to "translate" machine identifiers.
// Authored option labels (previous test) stay gated; a bundle that
// externalizes text for a derived key re-enters the expected set via
// `authoredInBundle`.
const stringOptionConfig: any = {
objects: [
{
Expand All@@ -170,8 +179,42 @@ describe('computeI18nCoverage', () => {
};
const report = computeI18nCoverage(stringOptionConfig, { defaultLocale: 'en' });
const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key));
expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(false);
expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(false);
// The field's own authored label is still owed.
expect(zhKeys.has('objects.account.fields.stage.label')).toBe(true);
});

it('#8543: a bundle that authors text for a derived option key re-enters the expected set', () => {
// The other half of the derived-channel contract: `inline` unset does not
// mean "never gated" — a project that externalizes display text for a
// bare-string option into some bundle owes the other locales a
// translation of it, exactly like any externalized string.
const externalized: any = {
objects: [
{
name: 'account',
label: 'Account',
fields: { stage: { label: 'Stage', options: ['planning'] } },
},
],
translations: [
{
en: { objects: { account: { fields: { stage: { options: { planning: 'Planning' } } } } } },
'zh-CN': {},
},
],
};
const report = computeI18nCoverage(externalized, { defaultLocale: 'en' });
// Structural annotations: the module import is outside this file's tsc
// program reach (frozen TS2835 debt), so bare parameters here would be
// implicitly-any additions to the package's TEST_DEBT ledger.
const zhKeys = new Set(
report.issues
.filter((i: { locale: string }) => i.locale === 'zh-CN')
.map((i: { key: string }) => i.key),
);
expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(true);
expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(true);
});

it('promotes warnings to errors under --strict', () => {
Expand Down
99 changes: 97 additions & 2 deletions packages/cli/test/i18n-extract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,55 @@ describe('collectExpectedEntries', () => {
expect(byPath['metadataForms.flow.fields.name.label']).toBe('Name');
});

it('#8543: label-less options seed through the DERIVED channel, authored labels stay authored', () => {
// `Field.select(['pending'])` normalizes a bare string to
// `{ value: 'pending', label: 'pending' }` — the label is a copy of the
// machine value, not authored English. Recording that as authored is how
// the coverage gate came to demand translations of machine identifiers,
// and how a raw machine value could ship as rendered text without any
// gate noticing (#8580 is the shipped instance). All three authoring
// shapes are pinned: `{value,label}` with the label equal to the value,
// a bare string, and a record map whose label restates the key.
const cfg: any = {
objects: [
{
name: 'w',
label: 'W',
fields: {
normalized: {
label: 'Normalized',
options: [
{ value: 'pending', label: 'pending' }, // Field.select(['pending']) shape
{ value: 'approved', label: 'Approved' }, // genuinely authored
{ value: 'rejected' }, // no label at all
],
},
bare: { label: 'Bare', options: ['draft'] },
map: { label: 'Map', options: { open: 'open', closed: 'Closed' } },
},
},
],
};
const entries = collectExpectedEntries(cfg);
// Structural annotation: the module import is outside this file's tsc
// program reach (frozen TS2835 debt), so the parameter would otherwise be
// an implicitly-any addition to the package's TEST_DEBT ledger.
const byPath = Object.fromEntries(
entries.map((e: { path: string[] }) => [e.path.join('.'), e]),
);
const opt = (p: string) => byPath[`objects.w.fields.${p}`];

// Derived: seeded from the value so skeletons stay usable, but `inline`
// stays unset — nobody authored display text.
for (const p of ['normalized.options.pending', 'normalized.options.rejected', 'bare.options.draft', 'map.options.open']) {
expect(opt(p)?.sourceValue, p).toBe(p.split('.').pop());
expect(opt(p)?.inline, p).toBeUndefined();
}
// Authored: the label is real display text and drives the coverage gate.
expect(opt('normalized.options.approved')?.inline).toBe('Approved');
expect(opt('map.options.closed')?.inline).toBe('Closed');
});

it('emits action param entries (inline + top-level), skipping field-backed labels without overrides', () => {
const entries = collectExpectedEntries(config);
const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue]));
Expand DownExpand Up@@ -265,15 +314,61 @@ describe('extractTranslations', () => {
locales: ['en'],
mergeExisting: true,
});
// Existing translations are preserved verbatim so the generated file
// is a complete, self-contained bundle (not just a delta).
// The fixture's en bundle matches the source, so this only proves the
// seed path; the divergence cases live in the #8543 test below.
expect(bundles.en.objects?.sys_position?.label).toBe('Role');
expect(bundles.en.objects?.sys_position?.fields?.active?.label).toBe('Active');
// Missing keys are still filled from schema defaults.
expect(bundles.en.objects?.sys_position?.pluralLabel).toBe('Roles');
expect(bundles.en.objects?.sys_position?.fields?.label?.label).toBe('Display Name');
});

it('#8543: the default locale tracks the SOURCE, not a stale existing entry; translated locales keep merge', () => {
// The en bundle is a copy of the source, not a translation. Before #8543
// the merge branch ran for every locale, so an author editing a field
// description could never get the edit into the committed en bundle — the
// stale entry always won and the drift gate stayed green (53 stale
// entries had accumulated across 6 packages when this was fixed).
const cfg: any = {
objects: [
{
name: 'thing',
label: 'Thing (new wording)',
fields: { note: { label: 'Note', help: 'New help text' } },
},
],
translations: [
{
en: {
objects: {
thing: {
label: 'Thing (stale wording)',
fields: { note: { label: 'Note', help: 'Old help text' } },
},
},
},
'zh-CN': {
objects: {
thing: { label: '事物', fields: { note: { label: '备注', help: '说明' } } },
},
},
},
],
};
const { bundles } = extractTranslations(cfg, {
defaultLocale: 'en',
locales: ['zh-CN'],
mergeExisting: true,
});
// en: the source seed wins over the stale bundle entry.
expect(bundles.en.objects?.thing?.label).toBe('Thing (new wording)');
expect(bundles.en.objects?.thing?.fields?.note?.help).toBe('New help text');
// zh-CN: the human translation is preserved verbatim — merge semantics
// for translated locales are exactly what they were.
expect(bundles['zh-CN'].objects?.thing?.label).toBe('事物');
expect(bundles['zh-CN'].objects?.thing?.fields?.note?.help).toBe('说明');
});

it('filters by object name regex', () => {
const cfg = {
objects: [
Expand Down
Loading
Loading