diff --git a/.changeset/translation-groups-strict.md b/.changeset/translation-groups-strict.md new file mode 100644 index 0000000000..4cf04c9454 --- /dev/null +++ b/.changeset/translation-groups-strict.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +--- + +Translation bundles and `translation` items reject unknown keys, at both doors — and a bespoke guard that only covered one of them retires into the rejection message. + +Translation data has the cruellest version of the silent-strip failure in the spec. A misspelled group is dropped, the bundle loads without complaint, and the string renders in the source language — **indistinguishable from a translation nobody has written yet.** There is no wrong output to notice, so the bug looks like a coverage gap forever. + +**#3778 already knew this, and fixed it for ten keys.** Retiring the object-first (`o.`) dialect meant old-shape items saved cleanly and resolved to nothing, so it added a `z.preprocess` that scanned for the ten retired keys and raised a 422 naming the right destination for each. + +That guard has the shape every workaround for `.strip` has: + +- **It could only catch mistakes someone had already thought of.** `object` for `objects`, `message` for `messages`, an invented group — still dropped in silence. +- **It ran on one of the two doors.** Only `TranslationItemSchema` (Studio / the metadata API). The same ten keys in a file-authored bundle — the path the examples and the platform apps actually use — were stripped with no complaint at all. The same asymmetry #4522 found in #1535's object guard, two authors solving the problem in front of them. + +With the shape closed the guard is redundant, so it is gone and its ten prescriptions ride the rejection as `guidance`. **What was worth keeping was never the detection — it was the prose.** Detection generalizes for free once the default flips; the sentence telling an author where their content goes does not. + +Closed across every authorable group: object/field/view/action/section translations, apps and navigation, dashboards and widgets, pages, settings, metadata forms, and the i18n config. Aliases carry the near-misses edit distance cannot: `views` → `_views`, `help` → `helpText` on an action param (`help` is correct one surface over), `label` → `title` on a widget (a dashboard's headline is `label`, its widget's is `title` — one level apart, opposite spellings). + +**The i18n config's four removed knobs get tombstones.** #3494 deleted `fileOrganization`, `messageFormat`, `lazyLoad` and `cache` because no runtime read them. Removing a key that was already a no-op leaves the author with the same silence and one more reason for it; the rejection now says which issue removed it and why. + +**Two gates were found doing half their job.** + +`translation` was on the ADR-0010 envelope debt list — the loader stamps `_packageId`/`_provenance` and the schema could not hold them, so `authored-translation-sync` strips them by hand on the read side. Declared; the list is down to four. + +And `metadata-create-seeds.test.ts` — the canonical guard against a designer's create shape drifting from the spec — asserts every seed parses. The `translation` seed ships `{ name, label, locale, objects }` and the type declared neither `name` nor `label`, so **two thirds of the authoritative create shape was being stripped while the gate that exists to catch that reported green.** A gate built on a `.strip` schema catches a missing required key and can never catch an extra undeclared one. `name`/`label` are now declared (`translation` was the only registered type of 25 without a `name`), classified in the liveness ledger as dead *body* keys with the row column as the live one. + +Registered types closed at the top level: **17 of 25**. Still open: `action`, `agent`, `dashboard`, `field`, `mapping`, `page`, `view`. + +Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working translation changes. Verified against the real bundles in `examples/app-crm`, `examples/app-todo` and `platform-objects`, all of which `.parse()` at module load, and against the live `GET /translations/:locale` body. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index e807063d0a..d25db79f97 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -208,8 +208,10 @@ const zh: TranslationData = { `o.` (with `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`) was once documented here for runtime-authored translations. No resolver ever read it, so translations authored in that shape saved successfully and rendered - nothing. It was removed in #3778; the metadata door now rejects those keys - with a message naming the group to use instead. Author everything — + nothing. It was removed in #3778, which rejected those ten keys at the + metadata door; #4001 closed both shapes entirely, so **any** undeclared key + is now rejected in a runtime item and in a file-authored bundle alike, with + the retired ones still naming the group to use instead. Author everything — files and runtime items alike — under `objects.`. @@ -850,7 +852,9 @@ stack's metadata, so there is no set of legal names to resolve against. **Design intent — not yet implemented.** `i18n.translationService` is **not** a recognized key on `TranslationConfigSchema` (`packages/spec/src/system/translation.zod.ts`), and no auto-translate provider -integration ships today. The snippet below describes planned behaviour. +integration ships today. The snippet below describes planned behaviour — since +#4001 the config shape is closed, so copying it into a real `defineStack` is a +build-time rejection rather than a key silently dropped on the floor. ObjectStack integrates with professional translation services: diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 8660e32726..5ee9046149 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -5,9 +5,19 @@ description: Translation protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Field Translation Schema +Shared history sentence for every shape in this file (#4001). -Translation data for a single field. +Translation data has the most literal version of the silent-strip failure in + +the whole spec: a misspelled group or key is dropped, the bundle saves or + +loads without complaint, and the string it was meant to translate renders in + +the source language. There is no error, no log line, and no difference + +between "not translated yet" and "translated into a key nothing reads" — so + +the bug looks like missing coverage forever. **Source:** `packages/spec/src/system/translation.zod.ts` @@ -208,6 +218,15 @@ One locale of translations — the `translation` metadata type | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | +| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | +| **label** | `string` | optional | Human-readable label shown in metadata lists | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | --- diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index d2194a2869..f1ada45c3f 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -166,10 +166,18 @@ Two things to know: - `locale` is required. An item whose locale can't be resolved is skipped, and a silent skip is the hardest kind of missing translation to diagnose. -- Only the groups on this page are accepted. Keys from the retired `o.` - shape (`o`, `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`, …) are - rejected at save time with a message naming the group to use instead — they - used to save cleanly and then render nothing (#3778). +- Only the groups on this page are accepted, and since #4001 that is literally + true: a key none of them declares is rejected, in a runtime item **and** in a + file-authored bundle. Keys from the retired `o.` shape (`o`, `app`, + `nav`, `dashboard`, `_globalOptions`, `_meta`, …) carry a message naming the + group to use instead — they used to save cleanly and then render nothing + (#3778). Everything else gets the nearest declared key suggested. + + Before that, only those ten keys were checked and only on the runtime door, + so a misspelled group in a bundle file was dropped in silence. On this + surface that is the worst possible outcome: **a translation that resolves to + nothing looks exactly like a translation nobody has written yet** — no wrong + string appears, just the source language, indefinitely. ## Draft with the CLI, gate in CI diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 0c15194b4b..abf3a136ac 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -198,6 +198,47 @@ dropped at parse, and nothing failed. this file keeps re-deriving: before trusting a green check, make it go red on something you know is there.** +10. **A bespoke guard against silent stripping could only catch the ten + mistakes someone had already thought of — and only at one of the two + doors.** `translation` (step 5). #3778 retired the object-first (`o.`) + dialect and hit this campaign's problem head-on: an item authored in the old + shape saved cleanly and then resolved to nothing. The fix available then was + a `z.preprocess` that scanned for ten named keys and raised a 422 with the + right destination for each. + + It worked, and it had the shape every workaround for `.strip` has. Ten keys + were enumerated; `object` for `objects`, `message` for `messages`, a group + invented wholesale — all still stripped in silence. And it ran on the *item* + door only, so those same ten keys written into a file-authored bundle (the + path the examples and the platform apps actually use) were dropped with no + complaint at all. **The same asymmetry #4522 found in #1535's object guard**: + covered at one door, open at the other, by two different authors solving the + problem in front of them. + + With the shape closed the guard is redundant — `.strict()` catches all ten + and everything else, at both doors — so it was retired and its ten + prescriptions became `guidance` on the rejection. Which is the general + lesson: **what was worth keeping from a bespoke guard is never the + detection, it is the prose.** Detection generalizes for free the moment the + default flips; the sentence telling an author where their content goes does + not, and is the part these workarounds were really carrying. + +11. **The canonical "create seed ≠ spec" gate could only fail in one + direction.** `metadata-create-seeds.test.ts` exists so a designer's minimal + create shape cannot drift from the schema — it asserts every seed parses. + The `translation` seed ships `{ name, label, locale, objects }`, and the type + declared neither `name` nor `label`, so **two thirds of the authoritative + create shape was being stripped while the gate that exists to catch exactly + that reported green.** A gate built on a `.strip` schema can catch a MISSING + required key and can never catch an EXTRA undeclared one; it was doing half + its job and reading as if it did all of it. + + Fixed by declaring them — `translation` was the only registered type of 25 + without a `name`, and that irregularity is what an AI author trips on — and + classified honestly in the liveness ledger as dead *body* keys, the row + column being the live one. Fifth instance of finding 9's pattern, and the + first where the blinded instrument was a gate rather than a measurement. + This is the empirical argument for the ratchet: the inference "no metadata in the repo carries unknown keys" was **false three times over**, and only the strict gate could prove it. Note the asymmetry in the two schema gaps — both diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 5dc5b16391..5067ec62c4 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7056,12 +7056,21 @@ "system/TranslationDiffItem:objectName", "system/TranslationDiffItem:sourceHash", "system/TranslationDiffItem:status", + "system/TranslationItem:_lock", + "system/TranslationItem:_lockDocsUrl", + "system/TranslationItem:_lockReason", + "system/TranslationItem:_lockSource", + "system/TranslationItem:_packageId", + "system/TranslationItem:_packageVersion", + "system/TranslationItem:_provenance", "system/TranslationItem:apps", "system/TranslationItem:dashboards", "system/TranslationItem:globalActions", + "system/TranslationItem:label", "system/TranslationItem:locale", "system/TranslationItem:messages", "system/TranslationItem:metadataForms", + "system/TranslationItem:name", "system/TranslationItem:objects", "system/TranslationItem:pages", "system/TranslationItem:settings", diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 772980651b..fb59f4fa09 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -1,7 +1,17 @@ { "type": "translation", - "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). Registered schema is a z.preprocess pipe (the retired object-first-dialect guard), which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, settings..keys..options., …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through — the resolvers read only the declared conventions. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488).", + "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). NO LONGER A PIPE: the schema was a z.preprocess wrapping the retired object-first-dialect guard, which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. #4001 closed the shape with `.strict()` and folded the guard's ten prescriptions into the unknown-key `guidance`, so the preprocess is gone and the registered schema is a plain strict object. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, settings..keys..options., …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through on rows already stored — the resolvers read only the declared conventions. Since #4001 no NEW row can acquire one: the metadata door rejects an undeclared key instead of stripping it, so that residue is a finite set that only shrinks. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488).", "props": { + "name": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "Item identity, declared in #4001 and dead as a BODY key — which is the honest reading of a copy nobody reads. The row's `name` column is the live one: the metadata door takes it from the URL (`saveMetaItem({ name })`, rest-server.ts:3903), and `authored-translation-sync` reads `row.name` as the locale fallback for pre-#3778 rows (:140-148) while explicitly destructuring the body copy back out before merging (:154-159). Declared anyway, and deliberately not authorWarn'd, for two reasons: the platform's own create seed sends it (`metadata-create-seeds.ts`, whose stated contract is that EVERY seed carries placeholder identity), so a warning would fire on our own shape; and `translation` being the one registered type of 25 without a `name` is exactly the irregularity an AI author trips on. Undeclared it was silently stripped, which is how the seed gate — the canonical 'designer create shape ≠ spec' guard — passed for years while two thirds of the seed it validated was being thrown away." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "Display metadata with no runtime consumer in this repo. Not authorWarn'd: the README's rule is that pure display annotation (`description`, `tags`, `icon`) must not warn, and the `job.label` precedent is exactly this — docs-shaped, deliberately kept, ADR-0033 exempt from enforce-or-remove. Shipped with `name` in the create seed for the same reason." + }, "locale": { "status": "live", "verifiedAt": "2026-08-01", diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index c0c8ae14b0..d905914337 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -72,10 +72,11 @@ const PROBE: Record = { * * The structural walk found 8 of these; the probe it replaced had been hiding 7. * `job` and `book` were closed in the same pass, leaving 6; `validation` came off - * when its six union variants were converted, leaving 5. + * when its six union variants were converted, leaving 5; `translation` came off + * when its groups were closed, leaving 4. */ const UNDECLARED_ENVELOPE = new Set([ - 'action', 'field', 'mapping', 'page', 'translation', + 'action', 'field', 'mapping', 'page', ]); /** diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 6ca35b9ec9..9085a2b295 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -666,11 +666,70 @@ describe('TranslationItemSchema', () => { [key]: { account: { label: '客户' } }, }); expect(result.success).toBe(false); - const issue = result.error?.issues.find((i) => i.path[0] === key); + // The prescriptions used to come from a bespoke `z.preprocess` that scanned + // for these ten keys; since #4001 they ride the strict unknown-key error as + // `guidance`, so the issue is `unrecognized_keys` naming the key rather than + // a custom issue at `path: [key]`. The message is what an author reads, and + // it still has to carry the destination. + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); expect(issue).toBeDefined(); + expect((issue as { keys?: string[] } | undefined)?.keys).toContain(key); expect(issue?.message).toContain(hint); }); + it('should not offer `app` → `apps` as a rename — the inner shapes differ', () => { + // Edit distance 1, so without a `guidance` entry the suggester would call + // this a typo. It is not: the object-first `app.` and the current + // `apps.` hold different content, so the fix is a rewrite. A + // guidance entry suppresses the rename, which is the point of having one. + const result = TranslationItemSchema.safeParse({ locale: 'en', app: { account: { label: 'Account' } } }); + expect(result.success).toBe(false); + const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + expect(message).not.toContain('Did you mean'); + expect(message).toContain('apps.'); + }); + + it('should reject an unknown key the retired-key list never named (#4001)', () => { + // What the #3778 guard could not do. It enumerated ten keys someone had + // thought of; `object` for `objects` was not one of them, and a bundle + // written that way saved clean and resolved to nothing. + const result = TranslationItemSchema.safeParse({ + locale: 'zh-CN', + object: { account: { label: '客户' } }, + }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`object` → `objects`'); + }); + + it('should round-trip its own identity keys (the Studio create seed sends both)', () => { + // `metadata-create-seeds.ts` ships `{ name, label, locale, objects }` as the + // authoritative minimal create shape, and its test asserts every seed is + // spec-valid — the canonical guard for "designer create shape ≠ spec". + // That guard could only ever catch a MISSING required key: an extra key the + // schema did not declare was stripped, so the seed validated while two + // thirds of it was being thrown away. Closing the shape made it fail, which + // is the guard finally working in both directions. + const item = TranslationItemSchema.parse({ name: 'zh-CN', label: '简体中文', locale: 'zh-CN' }); + expect(item.name).toBe('zh-CN'); + expect(item.label).toBe('简体中文'); + }); + + it('should declare the ADR-0010 protection envelope', () => { + // The loader stamps these on every registered type; undeclared, they were + // dropped on each parse, and `authored-translation-sync` strips them by + // hand on the read side because the schema could not hold them. + const item = TranslationItemSchema.parse({ + locale: 'en', + objects: { account: { label: 'Account' } }, + _packageId: 'com.example.pkg', + _provenance: 'package', + _lock: 'no-overlay', + }); + expect(item._packageId).toBe('com.example.pkg'); + expect(item._lock).toBe('no-overlay'); + }); + it('should reject the retired shape rather than silently stripping it (#3778)', () => { // The pre-fix failure mode: Zod strips undeclared keys, so an `o.`-shaped // item saved cleanly and then resolved to nothing. A save that succeeds @@ -721,6 +780,115 @@ describe('TranslationItemSchema', () => { }); }); +// ============================================================================ +// Unknown-key strictness across the translation groups (#4001) +// ============================================================================ + +describe('translation unknown-key strictness (#4001)', () => { + // The failure this file is closing is unusually cruel: a translation that + // resolves to nothing is indistinguishable from a translation nobody wrote. + // There is no wrong string on screen to notice — just the source language, + // forever, exactly as if the key were still on the backlog. + + it('rejects a retired key on the file-authored BUNDLE path, which the guard never covered', () => { + // #3778's `z.preprocess` ran on the item door only. The examples and the + // platform apps author bundles (`defineTranslationBundle`), and the same + // ten keys were stripped there in silence. Same asymmetry #4522 found in + // #1535's object guard: closed at one door, open at the other. + const result = TranslationBundleSchema.safeParse({ + en: { o: { account: { label: 'Account' } } }, + }); + expect(result.success).toBe(false); + const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message ?? ''; + expect(message).toContain('objects.'); + }); + + it.each([ + ['a top-level group', { messsages: { 'common.save': 'Save' } }, 'messages'], + ['an app translation', { apps: { crm: { label: 'CRM', nagivation: {} } } }, 'navigation'], + ['a page translation', { pages: { home: { subtitel: 'Welcome' } } }, 'subtitle'], + ['a dashboard widget', { dashboards: { sales: { widgets: { rev: { titel: 'Revenue' } } } } }, 'title'], + ['a settings key', { settings: { mail: { keys: { host: { lable: 'Host' } } } } }, 'label'], + ['a metadata form field', { metadataForms: { object: { fields: { name: { helpTxt: 'x' } } } } }, 'helpText'], + ])('rejects a typo in %s and names the key it meant', (_what, body, expected) => { + const result = TranslationDataSchema.safeParse(body); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain(`→ \`${expected}\``); + }); + + it.each([ + ['object', { objects: { account: { views: {} } } }, '_views'], + ['object', { objects: { account: { actions: {} } } }, '_actions'], + ['object', { objects: { account: { sections: {} } } }, '_sections'], + ])('points the un-prefixed %s group at its `_`-prefixed name', (_what, body, expected) => { + const result = TranslationDataSchema.safeParse(body); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain(`→ \`${expected}\``); + }); + + it('sends `help` on an action param to `helpText`, the spelling that surface uses', () => { + // `help` is correct on a FIELD translation and on a settings key; on an + // action param it is `helpText`. Borrowing from a neighbouring surface + // reads as a real word, not a slip, so edit distance cannot rule on it — + // this is what the alias tables are for. + const result = TranslationDataSchema.safeParse({ + globalActions: { export_csv: { params: { format: { help: 'CSV or XLSX' } } } }, + }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`help` → `helpText`'); + }); + + it('names which action surface the key landed on', () => { + const onObject = TranslationDataSchema.safeParse({ + objects: { account: { _actions: { merge: { confirm: 'ok?' } } } }, + }); + const onGlobal = TranslationDataSchema.safeParse({ + globalActions: { log_call: { confirm: 'ok?' } }, + }); + expect(onObject.error?.issues[0]?.message).toContain('this object action translation'); + expect(onGlobal.error?.issues[0]?.message).toContain('this global action translation'); + }); + + it('still accepts every declared group together', () => { + // The shape is spread into two schemas (bundle entry + metadata item); this + // is the guard against closing one of them against a stale key list. + const body = { + objects: { account: { label: 'Account', _views: { all: { label: 'All', emptyState: { title: 'None' } } } } }, + apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } }, + messages: { 'common.save': 'Save' }, + validationMessages: { discount_limit: 'Too high' }, + globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } }, + dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue' } } } }, + pages: { home: { label: 'Home', title: 'Welcome' } }, + settings: { mail: { title: 'Mail', keys: { host: { label: 'Host' } } } }, + metadataForms: { object: { label: 'Object', fields: { name: { label: 'Name' } } } }, + settingsCommon: { sourceLabels: { env: 'Env', tenant: 'Tenant' } }, + }; + expect(() => TranslationDataSchema.parse(body)).not.toThrow(); + expect(() => TranslationItemSchema.parse({ locale: 'en', ...body })).not.toThrow(); + }); + + it.each(['fileOrganization', 'messageFormat', 'lazyLoad', 'cache'])( + 'tells an author upgrading from <17 where the removed i18n knob `%s` went', + (key) => { + // #3494 removed these four because no runtime read them. Removing a key + // that was already a no-op leaves the author with the same silence and one + // more reason for it — the tombstone is the only thing that says why. + const result = TranslationConfigSchema.safeParse({ + defaultLocale: 'en', + supportedLocales: ['en'], + [key]: true, + }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('#3494'); + }, + ); +}); + // ============================================================================ // TranslationDiffStatusSchema // ============================================================================ diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 5e81296cb9..e8658bd0d5 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -7,8 +7,25 @@ import { z } from 'zod'; // ──────────────────────────────────────────────────────────────────────────── import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const LocaleSchema = lazySchema(() => z.string().describe('BCP-47 Language Tag (e.g. en-US, zh-CN)')); +/** + * Shared history sentence for every shape in this file (#4001). + * + * Translation data has the most literal version of the silent-strip failure in + * the whole spec: a misspelled group or key is dropped, the bundle saves or + * loads without complaint, and the string it was meant to translate renders in + * the source language. There is no error, no log line, and no difference + * between "not translated yet" and "translated into a key nothing reads" — so + * the bug looks like missing coverage forever. + */ +const TRANSLATION_HISTORY = + 'Until #4001 closed these shapes an unknown key was dropped silently — the bundle still ' + + 'loaded, and whatever it was meant to translate rendered in the source language with no ' + + 'diagnostic, indistinguishable from a translation nobody had written yet.'; + // ──────────────────────────────────────────────────────────────────────────── // Object-level Translation (per-object file) // ──────────────────────────────────────────────────────────────────────────── @@ -17,7 +34,11 @@ export const LocaleSchema = lazySchema(() => z.string().describe('BCP-47 Languag * Field Translation Schema * Translation data for a single field. */ -export const FieldTranslationSchema = lazySchema(() => z.object({ +export const FieldTranslationSchema = lazySchema(() => strictObject({ + surface: 'this field translation', + history: TRANSLATION_HISTORY, + aliases: { helpText: 'help', hint: 'help', tooltip: 'help', picklist: 'options', values: 'options', choices: 'options' }, +}, { label: z.string().optional().describe('Translated field label'), help: z.string().optional().describe('Translated help text'), placeholder: z.string().optional().describe('Translated placeholder text for form inputs'), @@ -43,7 +64,11 @@ export type FieldTranslation = z.infer; * action metadata (e.g. `"user.email"`, `"temporaryPassword"`). Keys may * contain dots — resolvers must index the record directly, not split on `.`. */ -export const ActionResultDialogTranslationSchema = lazySchema(() => z.object({ +export const ActionResultDialogTranslationSchema = lazySchema(() => strictObject({ + surface: 'this action result dialog translation', + history: TRANSLATION_HISTORY, + aliases: { label: 'title', message: 'description', body: 'description', ok: 'acknowledge', confirm: 'acknowledge', acknowledgeLabel: 'acknowledge' }, +}, { title: z.string().optional().describe('Translated result dialog title'), description: z.string().optional().describe('Translated result dialog description'), acknowledge: z.string().optional().describe('Translated acknowledge button label'), @@ -53,6 +78,47 @@ export const ActionResultDialogTranslationSchema = lazySchema(() => z.object({ export type ActionResultDialogTranslation = z.infer; +/** + * Action translations — one shape, used at two addresses: an object's + * `_actions.` and the top-level `globalActions.` (the same split + * `resolveActionLabel` walks). Built by a factory rather than transcribed + * twice so the two cannot drift, and so closing them is one edit. + * + * `surface` differs because the rejection should say which of the two the key + * was written on — an author who put `confirm` under `globalActions` and one + * who put it under `objects.account._actions` need different things next. + */ +const actionTranslationSchema = (surface: string) => strictObject({ + surface, + history: TRANSLATION_HISTORY, + aliases: { + name: 'label', title: 'label', + confirm: 'confirmText', confirmation: 'confirmText', confirmMessage: 'confirmText', + success: 'successMessage', successText: 'successMessage', toast: 'successMessage', + parameters: 'params', args: 'params', inputs: 'params', + result: 'resultDialog', dialog: 'resultDialog', + }, +}, { + label: z.string().optional().describe('Translated action label'), + confirmText: z.string().optional().describe('Translated confirmation prompt'), + successMessage: z.string().optional().describe('Translated success toast/message'), + params: z.record(z.string(), strictObject({ + surface: 'this action parameter translation', + history: TRANSLATION_HISTORY, + // `help` is the correct spelling on a FIELD translation and on a settings + // key; on an action param it is `helpText`. Neighbouring-surface borrowing, + // which edit distance reads as a legitimate word rather than a slip. + aliases: { help: 'helpText', hint: 'helpText', tooltip: 'helpText', name: 'label', choices: 'options', values: 'options' }, + }, { + label: z.string().optional().describe('Translated action parameter label'), + helpText: z.string().optional().describe('Translated action parameter help/hint text'), + placeholder: z.string().optional().describe('Translated action parameter placeholder'), + options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), + })).optional().describe('Action parameter translations keyed by parameter name'), + resultDialog: ActionResultDialogTranslationSchema.optional() + .describe('Translations for the action result dialog'), +}); + /** * Object Translation Data Schema * @@ -74,7 +140,17 @@ export type ActionResultDialogTranslation = z.infer z.object({ +export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ + surface: 'this object translation', + history: TRANSLATION_HISTORY, + aliases: { + // The group prefixes are the whole point of the `_`-prefixed convention, + // and the un-prefixed spelling is the one an author reaches for first. + views: '_views', actions: '_actions', sections: '_sections', + plural: 'pluralLabel', labelPlural: 'pluralLabel', name: 'label', title: 'label', + columns: 'fields', properties: 'fields', attributes: 'fields', + }, +}, { /** * Translated singular label for the object. * @@ -101,10 +177,18 @@ export const ObjectTranslationDataSchema = lazySchema(() => z.object({ * objects.._views..emptyState.title * objects.._views..emptyState.message */ - _views: z.record(z.string(), z.object({ + _views: z.record(z.string(), strictObject({ + surface: 'this view translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', empty: 'emptyState', emptyMessage: 'emptyState' }, + }, { label: z.string().optional().describe('Translated view label'), description: z.string().optional().describe('Translated view description'), - emptyState: z.object({ + emptyState: strictObject({ + surface: 'this view empty-state translation', + history: TRANSLATION_HISTORY, + aliases: { label: 'title', heading: 'title', description: 'message', text: 'message', body: 'message' }, + }, { title: z.string().optional().describe('Translated empty-state title'), message: z.string().optional().describe('Translated empty-state message'), }).optional().describe('Translated empty-state copy shown when the view has no rows'), @@ -118,19 +202,8 @@ export const ObjectTranslationDataSchema = lazySchema(() => z.object({ * objects.._actions..successMessage * objects.._actions..resultDialog.* */ - _actions: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated action label'), - confirmText: z.string().optional().describe('Translated confirmation prompt'), - successMessage: z.string().optional().describe('Translated success toast/message'), - params: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated action parameter label'), - helpText: z.string().optional().describe('Translated action parameter help/hint text'), - placeholder: z.string().optional().describe('Translated action parameter placeholder'), - options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), - })).optional().describe('Action parameter translations keyed by parameter name'), - resultDialog: ActionResultDialogTranslationSchema.optional() - .describe('Translations for the action result dialog'), - })).optional().describe('Action translations keyed by action name'), + _actions: z.record(z.string(), actionTranslationSchema('this object action translation')) + .optional().describe('Action translations keyed by action name'), /** * Section translations keyed by section name (snake_case). @@ -140,7 +213,11 @@ export const ObjectTranslationDataSchema = lazySchema(() => z.object({ * (e.g. "Opportunity Information" → "商机信息"). Each section in the * page schema must declare a stable `name` for the lookup to fire. */ - _sections: z.record(z.string(), z.object({ + _sections: z.record(z.string(), strictObject({ + surface: 'this section translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', heading: 'label' }, + }, { label: z.string().optional().describe('Translated section label'), description: z.string().optional().describe('Translated section description'), })).optional().describe('Section translations keyed by section name'), @@ -148,6 +225,75 @@ export const ObjectTranslationDataSchema = lazySchema(() => z.object({ export type ObjectTranslationData = z.infer; +// ──────────────────────────────────────────────────────────────────────────── +// The retired object-first dialect +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Top-level keys of the retired object-first (`o.`) dialect. + * + * Exported for the runtime's benefit, not the schema's: `authored-translation-sync` + * scans rows that were saved *before* this shape was closed and warns rather + * than loading them, since nothing resolves from them. New authoring is handled + * by {@link TRANSLATION_KEY_GUIDANCE} on the strict shapes below. + */ +export const LEGACY_OBJECT_FIRST_KEYS = [ + 'o', + 'app', + 'nav', + 'dashboard', + 'reports', + 'notifications', + 'errors', + '_globalOptions', + '_meta', + 'namespace', +] as const; + +export type LegacyObjectFirstKey = (typeof LEGACY_OBJECT_FIRST_KEYS)[number]; + +/** + * Where each retired key's content belongs now (or that it has no home). + * + * ## Why this is `guidance` and no longer a bespoke pre-parse guard + * + * #3778 retired the object-first dialect and hit the problem this whole + * campaign is about: Zod strips undeclared keys, so an item authored in the old + * shape saved cleanly and then resolved to nothing. The fix available at the + * time was a `z.preprocess` that scanned for these ten keys before parsing and + * raised a 422 naming the right group. + * + * That guard worked, and it had the shape of every workaround for silent + * stripping: **it could only catch the mistakes someone had already thought + * of.** Ten keys were named; every other misspelling — `object` for `objects`, + * `message` for `messages`, a group invented wholesale — went on being dropped + * in silence. And it only ever ran on the *item* door (`TranslationItemSchema`), + * so the same ten keys written into a file-authored bundle + * ({@link TranslationBundleSchema}, the path the examples and platform apps + * actually use) were stripped with no complaint at all. Exactly the asymmetry + * #4522 found in #1535's object guard: covered at one door, open at the other. + * + * With the shapes closed (#4001) the guard is redundant — `.strict()` catches + * all ten *and* everything else, at both doors — and what was worth keeping is + * the prescriptions, which now ride the rejection as {@link strictObject} + * `guidance`. An entry here suppresses the edit-distance rename, which matters: + * `app` → `apps` is distance 1 and would otherwise be offered as a typo fix, + * when the two carry different inner shapes and the content has to be rewritten, + * not renamed. + */ +const TRANSLATION_KEY_GUIDANCE: Record = { + o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", + app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.'", + nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps..navigation..label'", + dashboard: "`dashboard` is the retired object-first dialect, which no resolver reads — use 'dashboards.' (plural)", + reports: '`reports` is the retired object-first dialect — reports have no translation group, omit them', + notifications: '`notifications` is the retired object-first dialect — notifications have no translation group, omit them', + errors: "`errors` is the retired object-first dialect — use 'validationMessages' for rule messages; other errors have no translation group", + _globalOptions: "`_globalOptions` is the retired object-first dialect — use 'objects..fields..options'", + _meta: "`_meta` is the retired object-first dialect — use the top-level 'locale' field (on a bundle, the locale is the map key)", + namespace: '`namespace` is not part of the translation contract — omit it (ADR-0006 D4 retired namespaces platform-wide)', +}; + // ──────────────────────────────────────────────────────────────────────────── // Locale-level Translation Data (per-locale aggregate) // ──────────────────────────────────────────────────────────────────────────── @@ -164,15 +310,41 @@ export type ObjectTranslationData = z.infer; * } * ``` */ -export const TranslationDataSchema = lazySchema(() => z.object({ +/** + * The translation groups, as a shape rather than a schema. + * + * Two schemas need exactly these keys: {@link TranslationDataSchema} (one entry + * of a file-authored bundle) and {@link TranslationItemSchema} (the registered + * `translation` metadata type — the same groups plus `locale` and the ADR-0010 + * envelope). The item used to reach them with `.extend()`, which is correct for + * the *validation* but wrong for the *error*: `.extend()` inherits the strict + * error map that closed over the keys the BASE was built with, so a typo of + * `locale` on an item would be rejected with `locale` missing from the + * candidate list. A shape spread into two `strictObject` calls gives each + * surface its own full key set — the pattern the six `validation` variants + * settled on in the same campaign. + * + * A function, not a `const`: the values reference `lazySchema` proxies, and + * building the shape eagerly at module load would defeat the laziness those + * exist for. + */ +const translationDataShape = () => ({ /** Object translations */ objects: z.record(z.string(), ObjectTranslationDataSchema).optional().describe('Object translations keyed by object name'), - + /** App/Menu translations */ - apps: z.record(z.string(), z.object({ + apps: z.record(z.string(), strictObject({ + surface: 'this app translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', nav: 'navigation', menu: 'navigation', menus: 'navigation' }, + }, { label: z.string().describe('Translated app label'), description: z.string().optional().describe('Translated app description'), - navigation: z.record(z.string(), z.object({ + navigation: z.record(z.string(), strictObject({ + surface: 'this navigation group translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', text: 'label' }, + }, { label: z.string().describe('Translated navigation group label'), })).optional().describe('Navigation group translations keyed by group ID'), })).optional().describe('App translations keyed by app name'), @@ -193,19 +365,8 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * globalActions..successMessage * globalActions..resultDialog.* */ - globalActions: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated action label'), - confirmText: z.string().optional().describe('Translated confirmation prompt'), - successMessage: z.string().optional().describe('Translated success toast/message'), - params: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated action parameter label'), - helpText: z.string().optional().describe('Translated action parameter help/hint text'), - placeholder: z.string().optional().describe('Translated action parameter placeholder'), - options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), - })).optional().describe('Action parameter translations keyed by parameter name'), - resultDialog: ActionResultDialogTranslationSchema.optional() - .describe('Translations for the action result dialog'), - })).optional().describe('Global action translations keyed by action name'), + globalActions: z.record(z.string(), actionTranslationSchema('this global action translation')) + .optional().describe('Global action translations keyed by action name'), /** * Dashboard translations keyed by dashboard name. @@ -216,13 +377,28 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * dashboards..widgets..title * dashboards..widgets..description */ - dashboards: z.record(z.string(), z.object({ + dashboards: z.record(z.string(), strictObject({ + surface: 'this dashboard translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', components: 'widgets', charts: 'widgets', cards: 'widgets' }, + }, { label: z.string().optional().describe('Translated dashboard title'), description: z.string().optional().describe('Translated dashboard description'), - actions: z.record(z.string(), z.object({ + actions: z.record(z.string(), strictObject({ + surface: 'this dashboard header action translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', text: 'label' }, + }, { label: z.string().optional().describe('Translated header action label'), })).optional().describe('Header action label translations keyed by action url/key'), - widgets: z.record(z.string(), z.object({ + widgets: z.record(z.string(), strictObject({ + surface: 'this widget translation', + history: TRANSLATION_HISTORY, + // A widget's headline is `title`; a dashboard's is `label`. Same document, + // one level apart, opposite spellings — so `label` on a widget is the + // likeliest mistake on this surface and the least likely to be noticed. + aliases: { label: 'title', name: 'title', heading: 'title', subtitle: 'description' }, + }, { title: z.string().optional().describe('Translated widget title'), description: z.string().optional().describe('Translated widget description'), })).optional().describe('Widget translations keyed by widget id'), @@ -245,7 +421,11 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * `page:header` instances carry no stable `id`; the page name is the only * addressable identifier on the metadata document. */ - pages: z.record(z.string(), z.object({ + pages: z.record(z.string(), strictObject({ + surface: 'this page translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', heading: 'title', header: 'title', subheading: 'subtitle', tagline: 'subtitle' }, + }, { label: z.string().optional().describe('Translated page label (nav / breadcrumb)'), description: z.string().optional().describe('Translated page description'), title: z.string().optional().describe('Translated `page:header` title (defaults to `label`)'), @@ -269,21 +449,44 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * settings..actions..confirmText * settings..actions..successMessage */ - settings: z.record(z.string(), z.object({ + settings: z.record(z.string(), strictObject({ + surface: 'this settings manifest translation', + history: TRANSLATION_HISTORY, + aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys' }, + }, { title: z.string().optional().describe('Translated settings manifest title'), description: z.string().optional().describe('Translated settings manifest description'), - groups: z.record(z.string(), z.object({ + groups: z.record(z.string(), strictObject({ + surface: 'this settings group translation', + history: TRANSLATION_HISTORY, + aliases: { label: 'title', name: 'title', heading: 'title' }, + }, { title: z.string().optional().describe('Translated group title'), description: z.string().optional().describe('Translated group description'), })).optional().describe('Group translations keyed by group key'), - keys: z.record(z.string(), z.object({ + keys: z.record(z.string(), strictObject({ + surface: 'this setting translation', + history: TRANSLATION_HISTORY, + aliases: { title: 'label', name: 'label', helpText: 'help', hint: 'help', description: 'help', choices: 'options', values: 'options' }, + }, { label: z.string().optional().describe('Translated setting label'), help: z.string().optional().describe('Translated setting help text'), placeholder: z.string().optional().describe('Translated input placeholder'), options: z.record(z.string(), z.string()).optional() .describe('Enum option value → translated label'), })).optional().describe('Per-setting field translations keyed by setting key'), - actions: z.record(z.string(), z.object({ + actions: z.record(z.string(), strictObject({ + surface: 'this settings action translation', + history: TRANSLATION_HISTORY, + // Settings actions have no `params`/`resultDialog` — a settings button is + // not an action-metadata action. Say so, rather than suggesting the + // nearest key and sending the author round again. + aliases: { name: 'label', title: 'label', confirm: 'confirmText', success: 'successMessage' }, + guidance: { + params: 'settings actions take no parameters — there is no `params` group to translate', + resultDialog: 'settings actions have no result dialog — `resultDialog` translations belong under `objects.._actions` or `globalActions`', + }, + }, { label: z.string().optional().describe('Translated action label'), confirmText: z.string().optional().describe('Translated confirmation prompt'), successMessage: z.string().optional().describe('Translated success toast/message'), @@ -328,14 +531,26 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * } * ``` */ - metadataForms: z.record(z.string(), z.object({ + metadataForms: z.record(z.string(), strictObject({ + surface: 'this metadata-form translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', groups: 'sections', properties: 'fields' }, + }, { label: z.string().optional().describe('Translated metadata-type display label (overrides registry label)'), description: z.string().optional().describe('Translated metadata-type description'), - sections: z.record(z.string(), z.object({ + sections: z.record(z.string(), strictObject({ + surface: 'this metadata-form section translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', heading: 'label' }, + }, { label: z.string().optional().describe('Translated section label'), description: z.string().optional().describe('Translated section description'), })).optional().describe('Section translations keyed by section.name'), - fields: z.record(z.string(), z.object({ + fields: z.record(z.string(), strictObject({ + surface: 'this metadata-form field translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', help: 'helpText', hint: 'helpText', description: 'helpText' }, + }, { label: z.string().optional().describe('Translated field label'), helpText: z.string().optional().describe('Translated field help/hint text'), placeholder: z.string().optional().describe('Translated field placeholder text'), @@ -347,8 +562,18 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * badges, inheritance chips, lock reasons, common actions. Resolved * via the `resolveSettingsCommon*` helpers in `i18n-resolver.ts`. */ - settingsCommon: z.object({ - sourceLabels: z.object({ + settingsCommon: strictObject({ + surface: 'the shared Settings UI translations', + history: TRANSLATION_HISTORY, + }, { + sourceLabels: strictObject({ + surface: 'the settings source-badge labels', + history: TRANSLATION_HISTORY, + // The layers are a closed set (ADR-0010 / the settings resolution order), + // so an unknown one here is a badge that will never render, not a layer + // the platform is about to grow. + aliases: { org: 'tenant', workspace: 'tenant', system: 'global', fallback: 'default', environment: 'env' }, + }, { env: z.string().optional(), global: z.string().optional(), tenant: z.string().optional(), @@ -356,7 +581,18 @@ export const TranslationDataSchema = lazySchema(() => z.object({ default: z.string().optional(), }).optional().describe('Source badge labels by resolution layer'), }).optional().describe('Cross-namespace Settings UI strings'), -}).describe('Translation data for objects, apps, and UI messages')); +}); + +export const TranslationDataSchema = lazySchema(() => strictObject({ + surface: 'this locale of the translation bundle', + history: TRANSLATION_HISTORY, + guidance: TRANSLATION_KEY_GUIDANCE, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the + // locales). Naming it keeps the suggestion useful for an author who moved a + // `translation` item into a bundle and left the field behind. + extraKeys: ['locale'], +}, translationDataShape()).describe('Translation data for objects, apps, and UI messages')); export type TranslationData = z.infer; @@ -405,7 +641,24 @@ export function defineTranslationBundle(config: z.input z.object({ +export const TranslationConfigSchema = lazySchema(() => strictObject({ + surface: 'the i18n configuration', + history: + 'Until #4001 closed this shape an unknown key was dropped silently — the stack still ' + + 'built, with the setting the author wrote never reaching any runtime.', + aliases: { locale: 'defaultLocale', defaultLanguage: 'defaultLocale', locales: 'supportedLocales', languages: 'supportedLocales', fallback: 'fallbackLocale', fallbackLanguage: 'fallbackLocale' }, + // #3494 removed these four. They were the exemplar the liveness audit + // (#1878/#1893) was built to find — declared, authored, and read by nothing — + // and removing them made a config that was *already* a no-op into a config + // that was a silent no-op for a second reason. A tombstone is the only thing + // that tells an author upgrading from <17 why their setting vanished. + guidance: { + fileOrganization: '`fileOrganization` was removed in #3494 — no runtime ever read it; how you split bundle files is a convention of your source tree, and the loader reads whatever `translations` you hand `defineStack`', + messageFormat: '`messageFormat` was removed in #3494 — there is no ICU engine; interpolation is always simple `{variable}` substitution', + lazyLoad: '`lazyLoad` was removed in #3494 — no runtime ever read it, so setting it changed nothing', + cache: '`cache` was removed in #3494 — no runtime ever read it, so setting it changed nothing', + }, +}, { /** Default locale for the application */ defaultLocale: LocaleSchema.describe('Default locale (e.g., "en")'), /** Supported BCP-47 locale codes */ @@ -420,46 +673,6 @@ export type TranslationConfig = z.infer; // Translation Item (the runtime-authored `translation` metadata type) // ──────────────────────────────────────────────────────────────────────────── -/** - * Top-level keys of the retired object-first (`o.`) dialect. - * - * {@link TranslationItemSchema} checks for them *before* parsing, because Zod - * strips undeclared keys silently: an item authored in the old shape would - * otherwise save cleanly and then resolve to nothing — exactly the failure - * this type is being fixed for (#3778). Guarding ahead of the parse turns - * that silence into a 422 naming the correct group, while keeping the retired - * keys out of the schema itself, so neither the generated JSON Schema nor the - * Studio editor advertises a shape that cannot work. - */ -export const LEGACY_OBJECT_FIRST_KEYS = [ - 'o', - 'app', - 'nav', - 'dashboard', - 'reports', - 'notifications', - 'errors', - '_globalOptions', - '_meta', - 'namespace', -] as const; - -export type LegacyObjectFirstKey = (typeof LEGACY_OBJECT_FIRST_KEYS)[number]; - -/** Where each retired key's content belongs now (or that it has no home). */ -const LEGACY_KEY_MIGRATION: Record = { - o: "use 'objects.'", - app: "use 'apps.'", - nav: "use 'apps..navigation..label'", - dashboard: "use 'dashboards.'", - reports: 'reports have no translation group — omit them', - notifications: 'notifications have no translation group — omit them', - errors: "use 'validationMessages' for rule messages; other errors have no translation group", - _globalOptions: "use 'objects..fields..options'", - _meta: "use the top-level 'locale' field", - namespace: 'namespaces are not part of the translation contract — omit it', -}; - /** * TranslationItemSchema * @@ -474,7 +687,8 @@ const LEGACY_KEY_MIGRATION: Record = { * object-first (`o.`) dialect that no resolver ever read, so a * translation authored in the product saved successfully and then rendered * nothing. That dialect is gone, and its keys are rejected outright rather - * than stripped — see {@link LEGACY_OBJECT_FIRST_KEYS}. + * than stripped — see {@link LEGACY_OBJECT_FIRST_KEYS}. Since #4001 so is + * every *other* undeclared key, which is the part #3778 could not reach. * * `locale` is required rather than inferred from the item name: the runtime * sync skips an item whose locale it cannot resolve, and a skip is invisible @@ -497,40 +711,49 @@ const LEGACY_KEY_MIGRATION: Record = { * }); * ``` */ -/** - * The item's own shape, without the retired-key guard. Private: it exists so - * the guard can wrap it (and so the factory below can type its argument) — - * {@link TranslationItemSchema} is the schema every caller should use. - */ -const TranslationItemDataSchema = lazySchema(() => TranslationDataSchema.extend({ +export const TranslationItemSchema = lazySchema(() => strictObject({ + surface: 'this translation', + history: TRANSLATION_HISTORY, + guidance: TRANSLATION_KEY_GUIDANCE, + aliases: { object: 'objects', app: 'apps', page: 'pages', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, +}, { + ...translationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), -}).describe('One locale of translations — the `translation` metadata type')); -export const TranslationItemSchema = lazySchema(() => z.preprocess((raw, ctx) => { - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { - for (const key of LEGACY_OBJECT_FIRST_KEYS) { - if ((raw as Record)[key] === undefined) continue; - ctx.addIssue({ - code: 'custom', - path: [key], - message: - `'${key}' belongs to the retired object-first translation shape, which no resolver ` - + `reads — ${LEGACY_KEY_MIGRATION[key]}.`, - }); - } - } - return raw; -}, TranslationItemDataSchema)); + // Item identity. Every other registered metadata type declares these; + // `translation` did not, because #3778 rebuilt the shape as "one entry of a + // bundle, plus `locale`" and the identity keys were not part of a bundle + // entry. They ARE part of an item: the platform's own create seed + // (`metadata-create-seeds.ts`) sends both, and `authored-translation-sync` + // destructures `name` back out of the stored body — it knows the body has it. + // Undeclared, they were stripped on every parse, so the metadata door quietly + // discarded an item's own name on the way through. + // + // Not snake_case-constrained the way `job.name` is: translation items are + // conventionally named after their locale (`zh-CN`), and the runtime sync + // reads `row.name` as a locale fallback for exactly that reason. Optional + // because `locale`, not `name`, is this type's required identity — that call + // was deliberate (a skipped item is invisible) and is not being reopened. + name: z.string().optional().describe('Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent'), + label: z.string().optional().describe('Human-readable label shown in metadata lists'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // `translation` is a registered metadata type, so `MetadataPlugin`'s artifact + // loader stamps `_packageId` / `_provenance` on it. Undeclared, they were + // dropped on every parse — and `authored-translation-sync` has to strip them + // by hand on the read side precisely because the schema could not hold them. + ...MetadataProtectionFields, +}).describe('One locale of translations — the `translation` metadata type')); /** A single `translation` metadata item. */ -export type TranslationItem = z.infer; +export type TranslationItem = z.infer; /** * Type-safe factory for a single-locale `translation` item. Validates at * authoring time via `.parse()` — preferred over a bare `: TranslationItem` * literal, which cannot catch a retired key. */ -export function defineTranslation(config: z.input): TranslationItem { +export function defineTranslation(config: z.input): TranslationItem { return TranslationItemSchema.parse(config); } diff --git a/skills/objectstack-i18n/references/_index.md b/skills/objectstack-i18n/references/_index.md index 995e7c645f..dedad513a2 100644 --- a/skills/objectstack-i18n/references/_index.md +++ b/skills/objectstack-i18n/references/_index.md @@ -9,9 +9,18 @@ from `node_modules` — there is no local copy in the skill bundle. ## Core schemas -- `node_modules/@objectstack/spec/src/system/translation.zod.ts` — Field Translation Schema +- `node_modules/@objectstack/spec/src/system/translation.zod.ts` — Shared history sentence for every shape in this file (#4001). - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema +## Transitive dependencies + +- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum +- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification +- `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol +- `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema +- `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities + ## How to read these 1. The schemas are runtime Zod definitions. Use `Read` on the absolute