From 2fb2fcd7d167bda8130e060099c9c122adf15d68 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:03:19 +0000 Subject: [PATCH 1/3] feat(cli,spec): gate the whole declared surface for i18n; translate inline object actions (#3370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zh-CN workspace localized the platform chrome but leaked English from author-declared labels: the approval drawer rendered Approve / Reject / Reassign beside the inbox's own 通过 / 拒绝. Two independent holes. The lint gate could not see them. `os lint`'s coverage detector kept its own walk of the metadata, separate from the walker `os i18n extract` uses to scaffold bundles, and the two had drifted — coverage only walked the top-level `actions` array, while `sys_approval_request` declares its decision actions inline on the object. Those labels were extractable but ungated. Coverage now derives its expected keys from `collectExpectedEntries()`, so the gated surface and the scaffolded surface cannot disagree again. Newly gated: inline object actions, action params and resultDialog copy, object-nested listViews, object description, field help/placeholder, and apps/dashboards/pages. Extract output is byte-identical against the committed plugin bundles. It stays silent for projects that do not translate. `os lint`, `os i18n check` and `os i18n extract` now read the stack's own `i18n.defaultLocale` / `i18n.supportedLocales`, falling back to the locales a bundle already covers, then to `en`. A project declaring neither is checked against its default locale alone, which its inline labels already satisfy — zero issues. This also stops a monolingual non-English project being told it owes `en` translations. The server sent English regardless of locale. `translateObject` walked an object's label/pluralLabel/description/fields but never its inline `actions`, so the meta endpoint returned the authored literals even though plugin-approvals ships `_actions` translations for all eight decision actions. The Console compensated client-side; every other consumer rendered the source language. Inline actions now run through `translateAction`. Also fixes `os i18n extract --check` demanding metadata-forms bundles under `--objects-only`, where a plain run writes none — the drift gate failed on a tree that was in sync. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW --- .changeset/i18n-gate-declared-labels.md | 50 +++ packages/cli/src/commands/i18n/check.ts | 7 +- packages/cli/src/commands/i18n/extract.ts | 35 +- packages/cli/src/commands/lint.ts | 9 +- packages/cli/src/utils/i18n-coverage.ts | 309 +++++++----------- packages/cli/src/utils/i18n-extract.ts | 226 ++++++++----- .../test/i18n-declared-surface-gate.test.ts | 242 ++++++++++++++ .../spec/src/system/i18n-resolver.test.ts | 78 +++++ packages/spec/src/system/i18n-resolver.ts | 32 +- 9 files changed, 688 insertions(+), 300 deletions(-) create mode 100644 .changeset/i18n-gate-declared-labels.md create mode 100644 packages/cli/test/i18n-declared-surface-gate.test.ts diff --git a/.changeset/i18n-gate-declared-labels.md b/.changeset/i18n-gate-declared-labels.md new file mode 100644 index 0000000000..77fa6dd844 --- /dev/null +++ b/.changeset/i18n-gate-declared-labels.md @@ -0,0 +1,50 @@ +--- +"@objectstack/cli": minor +"@objectstack/spec": patch +--- + +feat(cli,spec): gate the whole declared surface for i18n, and translate inline object actions server-side (#3370) + +In a zh-CN workspace the platform chrome was localized while author-declared +labels leaked English — the approval drawer rendered **Approve / Reject / +Reassign** right beside the inbox's own 通过 / 拒绝. Two independent holes, both +closed here. + +**The lint gate could not see them.** `os lint`'s i18n coverage kept its own +walk of the metadata, separate from the one `os i18n extract` uses to scaffold +bundles, and the two had drifted: coverage only ever walked the *top-level* +`actions` array, while `sys_approval_request` declares its decision actions +**inline on the object**. Those labels were extractable but ungated, so an +untranslated one could ship and no lint run would notice. Coverage now derives +its expected keys from `collectExpectedEntries()` — the extractor's walker — so +the gated surface and the scaffolded surface cannot disagree again. Newly gated +as a result: inline object actions, action `params` and `resultDialog` copy, +object-nested `listViews` (label / description / `emptyState`), object +`description`, field `help` / `placeholder`, and the `apps` / `dashboards` / +`pages` surfaces. Extract output is byte-identical — verified against the +committed plugin bundles. + +**It stays silent for projects that do not translate.** Which locales get +checked is the project's declaration, never an assumption: `os lint`, +`os i18n check` and `os i18n extract` now read the stack's own +`i18n.defaultLocale` / `i18n.supportedLocales`, falling back to the locales a +bundle already exists for, and finally to `en`. A project with neither is +checked against its default locale alone — which its inline labels already +satisfy — so it reports zero i18n issues. That also fixes a monolingual +*non-English* project being told it owed `en` translations it never claimed to +speak. Locked by regression tests; the three bundled examples stay at 0 errors. + +**The server sent English regardless of locale.** `translateObject` walked an +object's `label` / `pluralLabel` / `description` / `fields` but never its inline +`actions`, so `GET /api/v1/meta/object/:name` returned the authored English +literals even though `@objectstack/plugin-approvals` ships `_actions` +translations for all eight decision actions in zh-CN / ja-JP / es-ES. The +Console compensated by re-resolving labels client-side against a separately +fetched bundle; every other consumer — mobile, plain HTTP, SDUI — rendered the +source language. It now runs inline actions through `translateAction`, without +stamping a synthetic `objectName` onto the response. + +Also fixes `os i18n extract --check` demanding `.metadata-forms.generated.ts` +files under `--objects-only` (the default), where a plain run writes none — the +drift gate failed on a tree that was in sync, which made it unusable as the CI +check the gate is meant to be. diff --git a/packages/cli/src/commands/i18n/check.ts b/packages/cli/src/commands/i18n/check.ts index 4bec3567e1..0bf7a6fd99 100644 --- a/packages/cli/src/commands/i18n/check.ts +++ b/packages/cli/src/commands/i18n/check.ts @@ -34,11 +34,12 @@ export default class I18nCheck extends Command { static override flags = { json: Flags.boolean({ description: 'Output as JSON' }), 'default-locale': Flags.string({ - description: 'Locale that must be 100% translated (errors raised against it)', - default: 'en', + description: + "Locale that must be 100% translated (errors raised against it). Defaults to the config's i18n.defaultLocale, else 'en'.", }), locales: Flags.string({ - description: 'Comma-separated list of locales to check (default: every locale found)', + description: + 'Comma-separated list of locales to check (default: the config\'s i18n.supportedLocales, else every locale found)', }), strict: Flags.boolean({ description: 'Treat missing keys in non-default locales as errors (CI parity gate)', diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index 1ec17a4ad3..b90fce3eb9 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -55,11 +55,11 @@ export default class I18nExtract extends Command { static override flags = { json: Flags.boolean({ description: 'Output JSON instead of writing files' }), 'default-locale': Flags.string({ - description: 'Locale filled from schema labels', - default: 'en', + description: "Locale filled from schema labels. Defaults to the config's i18n.defaultLocale, else 'en'.", }), locales: Flags.string({ - description: 'Comma-separated list of locales to emit (always includes default-locale)', + description: + "Comma-separated list of locales to emit (always includes default-locale). Defaults to the config's i18n.supportedLocales.", }), fill: Flags.string({ description: 'How non-default locales are filled: empty | default | todo', @@ -106,12 +106,25 @@ export default class I18nExtract extends Command { const normalized = normalizeStackInput(config as Record); const filter = flags.filter ? new RegExp(flags.filter) : undefined; + // The stack's own `i18n` block already names the languages it ships, so + // scaffolding those by default saves repeating them on every invocation. + const declared = (normalized as { i18n?: { defaultLocale?: unknown; supportedLocales?: unknown } }).i18n; + const declaredLocales = Array.isArray(declared?.supportedLocales) + ? declared.supportedLocales.filter((l): l is string => typeof l === 'string' && l.length > 0) + : []; const locales = flags.locales ? flags.locales.split(',').map((s) => s.trim()).filter(Boolean) - : undefined; + : declaredLocales.length > 0 + ? declaredLocales + : undefined; + const defaultLocale = + flags['default-locale'] ?? + (typeof declared?.defaultLocale === 'string' && declared.defaultLocale.length > 0 + ? declared.defaultLocale + : 'en'); const result = extractTranslations(normalized, { - defaultLocale: flags['default-locale'], + defaultLocale, locales, fill: flags.fill as FillStrategy, filter, @@ -128,6 +141,12 @@ export default class I18nExtract extends Command { metadataFormsCounts[locale] = countLeaves(result.bundles[locale]?.metadataForms); } const anyMetadataForms = Object.values(metadataFormsCounts).some((n) => n > 0); + // …but under --objects-only they are only ever *reported*, never written. + // Counting them as emitted files made `--check` demand + // `.metadata-forms.generated.ts` next to bundles a plain run does + // not produce, so the drift gate failed on a tree that was in fact in sync. + const emitsMetadataForms = (locale: string): boolean => + !objectsOnly && (metadataFormsCounts[locale] ?? 0) > 0; if (flags.json) { console.log(JSON.stringify({ @@ -169,7 +188,7 @@ export default class I18nExtract extends Command { locale, objectsOnly, })); - if (metadataFormsCounts[locale] > 0) { + if (emitsMetadataForms(locale)) { console.log(chalk.dim(`── ${locale} (metadataForms) ──`)); console.log(renderTranslationModule(result.bundles[locale], { locale, @@ -195,7 +214,7 @@ export default class I18nExtract extends Command { keys: result.counts[locale], }); } - if (metadataFormsCounts[locale] > 0) { + if (emitsMetadataForms(locale)) { emitted.push({ file: path.join(outDir, `${locale}.metadata-forms.generated.ts`), content: renderTranslationModule(result.bundles[locale], { locale, kind: 'metadataForms' }), @@ -222,7 +241,7 @@ export default class I18nExtract extends Command { console.log(''); printError( 'Translation bundles have drifted from the schema. Regenerate and commit:\n' + - ` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== flags['default-locale']).join(',')} ` + + ` os i18n extract ${args.config ?? ''} --locales=${localesEmitted.filter((l) => l !== defaultLocale).join(',')} ` + `--fill=${flags.fill} --out=${flags.out}`.replace(/\s+/g, ' '), ); process.exit(1); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 45e050a16c..e6b0fbbd7e 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -604,8 +604,8 @@ export default class Lint extends Command { description: 'Treat missing translations in non-default locales as errors', }), 'default-locale': Flags.string({ - description: 'Default locale for i18n coverage (must be 100% translated)', - default: 'en', + description: + "Default locale for i18n coverage (must be 100% translated). Defaults to the config's i18n.defaultLocale, else 'en'.", }), }; @@ -645,6 +645,11 @@ export default class Lint extends Command { } // ── Translation coverage ── + // No locale is forced here: `computeI18nCoverage` falls back to the + // stack's own `i18n` block and, failing that, to the locales its bundles + // already cover. A project that ships neither is checked against its + // default locale alone, which its inline labels already satisfy — so this + // stays silent for projects that do not translate. let hiddenPlatform = 0; if (!flags['skip-i18n']) { const coverage = computeI18nCoverage(normalized, { diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 854d3bf023..e649631a83 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -3,13 +3,17 @@ /** * I18n Coverage Detector * - * Walks a normalized stack config and computes the set of translation keys - * that *should* exist for every registered locale (object labels & plural - * labels, field labels, select-option labels, view labels, action labels + - * confirm + success messages, including object-less actions resolved through - * the top-level `globalActions` namespace). Compares the expected set against - * the actual translation bundles attached to the stack and reports any keys - * that are missing or set to an empty string. + * Compares the translation keys a stack *should* carry against the bundles + * actually attached to it, and reports the ones that are missing or empty. + * + * The expected set is **not** computed here. It comes from + * {@link collectExpectedEntries} in `i18n-extract.ts` — the same walker that + * scaffolds bundles for `os i18n extract`. That sharing is the point: this + * detector used to keep its own parallel walk, and the two drifted until whole + * declared surfaces were extractable but ungated — most visibly the action + * labels declared *inline on an object* (`sys_approval_request`'s + * Approve/Reject/Reassign), which shipped English into a zh-CN workspace with + * no lint ever noticing (#3370). One walker, one surface, no drift. * * The inline `label:` in the metadata is the *source* string, authored in the * default locale: the runtime resolver falls back to it when a bundle carries @@ -18,14 +22,17 @@ * need. Keys with no source string anywhere are not reported here; a missing * label is `required/label`'s finding. * + * Which locales get checked is the project's call, never an assumption: the + * `i18n.supportedLocales` block declares them, and absent that block only the + * locales a bundle already exists for are checked. A project that does not + * translate therefore reports nothing at all — see {@link computeI18nCoverage}. + * * Pure: no filesystem or network. Safe to invoke from `os lint`, `os i18n * check`, IDE tooling, and unit tests. */ import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; -import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; -import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; -import { humanizeFieldPath } from './i18n-extract.js'; +import { collectExpectedEntries, type ExpectedEntry } from './i18n-extract.js'; export type CoverageSeverity = 'error' | 'warning'; @@ -35,8 +42,20 @@ export interface CoverageIssue { locale: string; /** Dot-path of the missing key (e.g. `objects.account._views.all_accounts.label`). */ key: string; - /** Source kind: object / field / option / view / action / globalAction / metadataForm. */ - source: 'object' | 'field' | 'option' | 'view' | 'action' | 'globalAction' | 'metadataForm'; + /** Source kind the key was harvested from. */ + source: + | 'object' + | 'field' + | 'option' + | 'view' + | 'action' + | 'globalAction' + | 'app' + | 'navigation' + | 'dashboard' + | 'widget' + | 'page' + | 'metadataForm'; /** Human-readable explanation. */ message: string; } @@ -144,10 +163,6 @@ function flattenBundles(bundles: TranslationBundle[]): { merged: TranslationBund return { merged, locales: Array.from(localesSet).sort() }; } -function viewObjectName(view: any): string | undefined { - return view?.objectName ?? view?.object ?? view?.data?.object; -} - // ─── Expected key extraction ─────────────────────────────────────────── interface ExpectedKey { @@ -159,192 +174,78 @@ interface ExpectedKey { /** Description shown in the issue message when the key is missing. */ context: string; /** - * The source string authored inline in the metadata (`label: 'Note'`), when - * there is one. This *is* the default-locale text — see `computeI18nCoverage`. + * The source string the reader sees in the default locale, when the metadata + * authors one. This *is* the default-locale text — see `computeI18nCoverage`. */ inline?: string; } -function pushKey( - out: ExpectedKey[], - path: string[], - source: CoverageIssue['source'], - context: string, - inline?: string, -): void { - out.push({ source, path, displayKey: path.join('.'), context, inline }); -} - -/** Narrow to a usable source string; empty strings are not authored text. */ -function inlineText(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - /** - * Collects every key a translation bundle *may* carry, paired with the inline - * source string the metadata already authors for it. Callers drop the keys that - * are authored nowhere — see `computeI18nCoverage`. + * Map the shared walker's fine-grained kinds onto the coverage taxonomy. + * + * The three registry-driven kinds all describe the same Studio metadata-form + * baseline, so they collapse to one `metadataForm` bucket — that is the bucket + * `os lint` hides wholesale unless `--include-platform` is passed. */ -function collectExpectedKeys(config: any): ExpectedKey[] { - const keys: ExpectedKey[] = []; - const objects: any[] = Array.isArray(config?.objects) ? config.objects : []; - - for (const obj of objects) { - if (!obj?.name) continue; - const objectName = obj.name as string; - pushKey(keys, ['objects', objectName, 'label'], 'object', `Object "${objectName}" label`, inlineText(obj.label)); - pushKey( - keys, - ['objects', objectName, 'pluralLabel'], - 'object', - `Object "${objectName}" pluralLabel`, - inlineText(obj.pluralLabel), - ); - if (obj.fields && typeof obj.fields === 'object') { - for (const [fieldName, field] of Object.entries(obj.fields)) { - pushKey( - keys, - ['objects', objectName, 'fields', fieldName, 'label'], - 'field', - `Field ${objectName}.${fieldName} label`, - inlineText(field?.label), - ); - // Options — accept BOTH shapes, exactly as `i18n-extract.ts` does. - // `FieldSchema.options` is canonically a `{value, label}[]` ARRAY, but - // this only ever handled the record map, so option coverage silently - // never fired for a canonically-shaped select field (issue #3583). - const opts = field?.options; - const optionEntries: Array<[string, unknown]> = Array.isArray(opts) - ? opts.flatMap((opt: any) => - opt && typeof opt === 'object' && 'value' in opt - ? [[String(opt.value), opt.label ?? opt.value] as [string, unknown]] - : typeof opt === 'string' - ? [[opt, opt] as [string, unknown]] - : [], - ) - : opts && typeof opts === 'object' - ? Object.entries(opts) - : []; - for (const [optionKey, optionLabel] of optionEntries) { - // Mirrors the extractor: an option's source text is its label, or - // its own value when no label string is present. - pushKey( - keys, - ['objects', objectName, 'fields', fieldName, 'options', optionKey], - 'option', - `Option ${objectName}.${fieldName}.${optionKey}`, - inlineText(optionLabel) ?? optionKey, - ); - } - } - } - } - - const views: any[] = Array.isArray(config?.views) ? config.views : []; - for (const view of views) { - if (!view?.name) continue; - const objectName = viewObjectName(view); - if (!objectName) continue; - pushKey( - keys, - ['objects', objectName, '_views', view.name, 'label'], - 'view', - `View ${objectName}.${view.name} label`, - inlineText(view.label), - ); - } - - const actions: any[] = Array.isArray(config?.actions) ? config.actions : []; - for (const action of actions) { - if (!action?.name) continue; - const objectName = action.objectName ?? action.object; - const root = objectName ? ['objects', objectName, '_actions', action.name] : ['globalActions', action.name]; - const source: CoverageIssue['source'] = objectName ? 'action' : 'globalAction'; - const ctxOwner = objectName ? `${objectName}.${action.name}` : action.name; - pushKey(keys, [...root, 'label'], source, `Action ${ctxOwner} label`, inlineText(action.label)); - pushKey(keys, [...root, 'confirmText'], source, `Action ${ctxOwner} confirmText`, inlineText(action.confirmText)); - pushKey( - keys, - [...root, 'successMessage'], - source, - `Action ${ctxOwner} successMessage`, - inlineText(action.successMessage), - ); - } - - collectMetadataFormKeys(keys); - return keys; +const COVERAGE_SOURCE: Record = { + object: 'object', + field: 'field', + option: 'option', + view: 'view', + action: 'action', + globalAction: 'globalAction', + app: 'app', + navigation: 'navigation', + dashboard: 'dashboard', + widget: 'widget', + page: 'page', + metadataType: 'metadataForm', + metadataFormSection: 'metadataForm', + metadataFormField: 'metadataForm', +}; + +const SOURCE_NOUN: Record = { + object: 'Object', + field: 'Field', + option: 'Option', + view: 'View', + action: 'Action', + globalAction: 'Global action', + app: 'App', + navigation: 'Navigation item', + dashboard: 'Dashboard', + widget: 'Widget', + page: 'Page', + metadataForm: 'Metadata form', +}; + +/** Subject line for the "missing translation" message. */ +function describeEntry(entry: ExpectedEntry, source: CoverageIssue['source']): string { + const noun = SOURCE_NOUN[source]; + const owner = entry.objectName ?? entry.appName ?? entry.metadataType; + // Everything past the owning collection and its name reads as the attribute + // path: `objects.account.fields.name.label` → `fields.name.label`. + const attribute = entry.path.slice(2).join('.'); + return owner && attribute ? `${noun} "${owner}" ${attribute}` : `${noun} ${entry.path.join('.')}`; } /** - * Walks the canonical METADATA_FORM_REGISTRY + DEFAULT_METADATA_TYPE_REGISTRY - * and pushes every translation key the resolver may look up under - * `metadataForms.*`. Mirrors the extractor walker so coverage stays in lock- - * step with what `os i18n extract` generates. + * Every key a bundle *may* carry, paired with the text the metadata already + * shows for it. Sourced from the extractor's walker so the gated surface and + * the scaffolded surface can never disagree. Callers drop the keys that are + * authored nowhere — see {@link computeI18nCoverage}. */ -function collectMetadataFormKeys(out: ExpectedKey[]): void { - for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { - const type = entry.type; - pushKey( - out, - ['metadataForms', type, 'label'], - 'metadataForm', - `Metadata form "${type}" label`, - inlineText((entry as any).label) ?? type, - ); - pushKey( - out, - ['metadataForms', type, 'description'], - 'metadataForm', - `Metadata form "${type}" description`, - inlineText((entry as any).description), - ); - } - for (const [type, form] of Object.entries(METADATA_FORM_REGISTRY)) { - const sections: any[] = [ - ...(Array.isArray((form as any)?.sections) ? (form as any).sections : []), - ...(Array.isArray((form as any)?.groups) ? (form as any).groups : []), - ]; - for (const section of sections) { - if (!section || typeof section !== 'object') continue; - const sectionName = normalizeMetadataSectionName(section); - if (sectionName) { - pushKey(out, ['metadataForms', type, 'sections', sectionName, 'label'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} label`, inlineText(section.label)); - pushKey(out, ['metadataForms', type, 'sections', sectionName, 'description'], 'metadataForm', `Metadata form ${type}.sections.${sectionName} description`, inlineText(section.description)); - } - if (Array.isArray(section.fields)) { - for (const child of section.fields) walkMetadataFormField(child, type, '', out); - } - } - } -} - -function walkMetadataFormField(field: any, type: string, parentPath: string, out: ExpectedKey[]): void { - if (!field || typeof field !== 'object') return; - const name = typeof field.field === 'string' ? field.field : undefined; - const path = name ? (parentPath ? `${parentPath}.${name}` : name) : parentPath; - if (path) { - // Platform form fields routinely omit `label` and let the renderer - // humanize the field path ("name" → "Name"). That derived text is the - // source string — the field is not unlabelled — so other locales still - // owe it a translation. Mirrors the extractor's seed value. - pushKey(out, ['metadataForms', type, 'fields', path, 'label'], 'metadataForm', `Metadata form ${type}.fields.${path} label`, inlineText(field.label) ?? humanizeFieldPath(path)); - pushKey(out, ['metadataForms', type, 'fields', path, 'helpText'], 'metadataForm', `Metadata form ${type}.fields.${path} helpText`, inlineText(field.helpText)); - pushKey(out, ['metadataForms', type, 'fields', path, 'placeholder'], 'metadataForm', `Metadata form ${type}.fields.${path} placeholder`, inlineText(field.placeholder)); - } - if (Array.isArray(field.fields)) { - for (const child of field.fields) walkMetadataFormField(child, type, path, out); - } -} - -function normalizeMetadataSectionName(section: any): string | undefined { - if (typeof section.name === 'string' && section.name.length > 0) return section.name; - if (typeof section.label !== 'string') return undefined; - return section.label - .toLowerCase() - .replace(/&/g, 'and') - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, ''); +function collectExpectedKeys(config: any): ExpectedKey[] { + return collectExpectedEntries(config).map((entry) => { + const source = COVERAGE_SOURCE[entry.source]; + return { + source, + path: entry.path, + displayKey: entry.path.join('.'), + context: describeEntry(entry, source), + inline: entry.inline, + }; + }); } // ─── Lookup ──────────────────────────────────────────────────────────── @@ -364,14 +265,32 @@ function lookupKey(data: TranslationData | undefined, path: string[]): string | * Compute a coverage report for a normalized stack config. */ export function computeI18nCoverage(config: any, opts: CoverageOptions = {}): CoverageReport { - const defaultLocale = opts.defaultLocale ?? 'en'; + // Locale selection, most specific first: an explicit caller override, then + // the project's own `i18n` block, then 'en'. Reading the config matters for a + // monolingual non-English project — forcing 'en' on a stack whose source + // strings are Chinese would report a language it never claimed to speak. + const declared = config?.i18n; + const declaredLocales: string[] = Array.isArray(declared?.supportedLocales) + ? declared.supportedLocales.filter((l: unknown): l is string => typeof l === 'string' && l.length > 0) + : []; + const defaultLocale = + opts.defaultLocale ?? + (typeof declared?.defaultLocale === 'string' && declared.defaultLocale.length > 0 + ? declared.defaultLocale + : 'en'); const bundles: TranslationBundle[] = Array.isArray(config?.translations) ? config.translations : []; const { merged, locales: discovered } = flattenBundles(bundles); + // A project only owes translations for locales it opted into. `supportedLocales` + // is that opt-in; without it the check falls back to the locales some bundle + // already exists for. Declare neither — the monolingual case — and the only + // active locale is the default one, which every inline label already satisfies, + // so the gate reports nothing rather than inventing a translation debt. let activeLocales: string[]; if (opts.locales && opts.locales.length > 0) { - const set = new Set([defaultLocale, ...opts.locales]); - activeLocales = Array.from(set); + activeLocales = Array.from(new Set([defaultLocale, ...opts.locales])); + } else if (declaredLocales.length > 0) { + activeLocales = Array.from(new Set([defaultLocale, ...declaredLocales, ...discovered])); } else if (discovered.length === 0) { activeLocales = [defaultLocale]; } else { diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 011e518727..de51f1e70f 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -3,14 +3,25 @@ /** * I18n Extractor * - * Companion to `i18n-coverage.ts`. Where coverage *detects* missing keys, - * extract *scaffolds* the bundle: it walks a normalized stack config and - * produces ready-to-edit `TranslationData` skeletons for every requested - * locale, pre-populated with the source labels from the schema for the - * default locale. + * Walks a normalized stack config and produces ready-to-edit `TranslationData` + * skeletons for every requested locale, pre-populated with the source labels + * from the schema for the default locale. * - * Walk surface (kept superset-aligned with the coverage detector plus the - * known coverage gap of object-nested `listViews` / inline `actions`): + * {@link collectExpectedEntries} is also the **single** definition of what is + * translatable at all: `i18n-coverage.ts` consumes it to decide what `os lint` + * gates, instead of keeping the parallel walk the two used to maintain. They + * had drifted — inline object `actions` and object-nested `listViews` were + * scaffoldable but ungated, which is how English approval buttons shipped into + * a zh-CN workspace unnoticed (#3370). Add a surface here and both sides get it. + * + * Two axes per entry, and they are not the same question: + * `sourceValue` — what extract seeds the skeleton with; may be a derived + * fallback (an object's own name, a humanized field path). + * `inline` — what the reader actually sees in the source locale; drives + * the coverage gate, so a string nobody authored is never + * reported as an untranslated one. + * + * Walk surface: * * objects..label * objects..pluralLabel @@ -60,8 +71,25 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; export interface ExpectedEntry { /** Lookup path expressed as an array of segments. */ path: string[]; - /** Source-of-truth string (typically the English literal on the schema). */ - sourceValue: string; + /** + * Value `os i18n extract` seeds the default-locale skeleton with — the + * English literal on the schema, or the fallback the renderer displays when + * the author omitted one (an object's own name, a humanized field path). + * + * `undefined` means there is nothing to scaffold: the key is recorded only so + * the coverage gate can notice a bundle that authors it anyway. + */ + sourceValue?: string; + /** + * The text a reader actually sees in the source locale. Drives the coverage + * gate: a key with no `inline` and no bundle entry has no text to translate + * at all, so demanding a translation for it would be noise (a *missing* label + * is `required/label`'s finding, not an i18n gap). + * + * Narrower than {@link sourceValue} wherever the seed is a derived fallback + * the author never wrote. + */ + inline?: string; /** What kind of metadata this entry was harvested from. */ source: | 'object' @@ -144,15 +172,60 @@ function pushViewEmptyState(out: ExpectedEntry[], viewPath: string[], view: any, } } +type EntryScope = Pick; + +/** Narrow to a usable source string; an empty string is not authored text. */ +function inlineText(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Record a key whose displayed text *is* its seed — the common case, where the + * author wrote the literal we scaffold from. + */ function pushEntry( out: ExpectedEntry[], path: string[], sourceValue: string | undefined, source: ExpectedEntry['source'], - extra?: Pick, + extra?: EntryScope, ): void { if (typeof sourceValue !== 'string') return; - out.push({ path, sourceValue, source, ...extra }); + out.push({ path, sourceValue, inline: sourceValue, source, ...extra }); +} + +/** + * Record a key whose seed is *derived* — an object's own name standing in for a + * missing label, a param's machine name rendered as its caption. The seed keeps + * the extracted skeleton usable, but `inline` stays unset so the coverage gate + * does not demand translations of a string nobody authored. + */ +function pushDerived( + out: ExpectedEntry[], + path: string[], + seed: string, + authored: string | undefined, + source: ExpectedEntry['source'], + extra?: EntryScope, +): void { + out.push({ path, sourceValue: seed, inline: authored, source, ...extra }); +} + +/** + * Record an optional authored string: seeds the skeleton when present, and + * still records the key when absent so the coverage gate notices a bundle that + * authors it without an inline counterpart. + */ +function pushOptional( + out: ExpectedEntry[], + path: string[], + value: unknown, + source: ExpectedEntry['source'], + extra?: EntryScope, +): void { + const text = inlineText(value); + if (text === undefined) out.push({ path, source, ...extra }); + else pushEntry(out, path, text, source, extra); } /** @@ -182,18 +255,14 @@ function pushActionParams( const pname = param.name ?? param.field; if (typeof pname !== 'string' || pname.length === 0) continue; const base = [...actionRoot, 'params', pname]; - const literalLabel = typeof param.label === 'string' ? param.label : undefined; + const literalLabel = inlineText(param.label); if (param.field) { - if (literalLabel) pushEntry(out, [...base, 'label'], literalLabel, kind, { objectName }); + pushOptional(out, [...base, 'label'], literalLabel, kind, { objectName }); } else { - pushEntry(out, [...base, 'label'], literalLabel ?? pname, kind, { objectName }); - } - if (typeof param.helpText === 'string' && param.helpText.length > 0) { - pushEntry(out, [...base, 'helpText'], param.helpText, kind, { objectName }); - } - if (typeof param.placeholder === 'string' && param.placeholder.length > 0) { - pushEntry(out, [...base, 'placeholder'], param.placeholder, kind, { objectName }); + pushDerived(out, [...base, 'label'], literalLabel ?? pname, literalLabel, kind, { objectName }); } + pushOptional(out, [...base, 'helpText'], param.helpText, kind, { objectName }); + pushOptional(out, [...base, 'placeholder'], param.placeholder, kind, { objectName }); if (Array.isArray(param.options)) { for (const opt of param.options) { if (opt && typeof opt === 'object' && 'value' in opt && typeof opt.label === 'string') { @@ -222,15 +291,9 @@ function pushActionResultDialog( const dialog = action?.resultDialog; if (!dialog || typeof dialog !== 'object') return; const base = [...actionRoot, 'resultDialog']; - if (typeof dialog.title === 'string' && dialog.title.length > 0) { - pushEntry(out, [...base, 'title'], dialog.title, kind, { objectName }); - } - if (typeof dialog.description === 'string' && dialog.description.length > 0) { - pushEntry(out, [...base, 'description'], dialog.description, kind, { objectName }); - } - if (typeof dialog.acknowledge === 'string' && dialog.acknowledge.length > 0) { - pushEntry(out, [...base, 'acknowledge'], dialog.acknowledge, kind, { objectName }); - } + pushOptional(out, [...base, 'title'], dialog.title, kind, { objectName }); + pushOptional(out, [...base, 'description'], dialog.description, kind, { objectName }); + pushOptional(out, [...base, 'acknowledge'], dialog.acknowledge, kind, { objectName }); if (Array.isArray(dialog.fields)) { for (const field of dialog.fields) { if (!field || typeof field !== 'object') continue; @@ -251,27 +314,25 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { if (!obj?.name) continue; const objectName = obj.name as string; - pushEntry(out, ['objects', objectName, 'label'], obj.label ?? objectName, 'object', { objectName }); - if (obj.pluralLabel) { - pushEntry(out, ['objects', objectName, 'pluralLabel'], obj.pluralLabel, 'object', { objectName }); - } - if (obj.description) { - pushEntry(out, ['objects', objectName, 'description'], obj.description, 'object', { objectName }); - } + pushDerived(out, ['objects', objectName, 'label'], obj.label ?? objectName, inlineText(obj.label), 'object', { objectName }); + pushOptional(out, ['objects', objectName, 'pluralLabel'], obj.pluralLabel, 'object', { objectName }); + pushOptional(out, ['objects', objectName, 'description'], obj.description, 'object', { objectName }); // Fields (always a record on normalized schemas) if (obj.fields && typeof obj.fields === 'object') { for (const [fieldName, raw] of Object.entries(obj.fields)) { const field = raw ?? {}; - pushEntry(out, ['objects', objectName, 'fields', fieldName, 'label'], field.label ?? fieldName, 'field', { objectName }); - - const help = field.help ?? field.description; - if (help) { - pushEntry(out, ['objects', objectName, 'fields', fieldName, 'help'], help, 'field', { objectName }); - } - if (field.placeholder) { - pushEntry(out, ['objects', objectName, 'fields', fieldName, 'placeholder'], field.placeholder, 'field', { objectName }); - } + pushDerived( + out, + ['objects', objectName, 'fields', fieldName, 'label'], + field.label ?? fieldName, + inlineText(field.label), + 'field', + { objectName }, + ); + + pushOptional(out, ['objects', objectName, 'fields', fieldName, 'help'], field.help ?? field.description, 'field', { objectName }); + pushOptional(out, ['objects', objectName, 'fields', fieldName, 'placeholder'], field.placeholder, 'field', { objectName }); // Options — accept either `{value, label}[]` arrays or a record map. const opts = field.options; @@ -307,10 +368,8 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { if (obj.listViews && typeof obj.listViews === 'object') { for (const [viewName, raw] of Object.entries(obj.listViews)) { const view = raw ?? {}; - pushEntry(out, ['objects', objectName, '_views', viewName, 'label'], view.label ?? viewName, 'view', { objectName }); - if (view.description) { - pushEntry(out, ['objects', objectName, '_views', viewName, 'description'], view.description, 'view', { objectName }); - } + pushDerived(out, ['objects', objectName, '_views', viewName, 'label'], view.label ?? viewName, inlineText(view.label), 'view', { objectName }); + pushOptional(out, ['objects', objectName, '_views', viewName, 'description'], view.description, 'view', { objectName }); pushViewEmptyState(out, ['objects', objectName, '_views', viewName], view, objectName); } } @@ -320,13 +379,10 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { for (const action of obj.actions) { if (!action?.name) continue; const aname = action.name as string; - pushEntry(out, ['objects', objectName, '_actions', aname, 'label'], action.label ?? aname, 'action', { objectName }); - if (action.confirmText) { - pushEntry(out, ['objects', objectName, '_actions', aname, 'confirmText'], action.confirmText, 'action', { objectName }); - } - if (action.successMessage) { - pushEntry(out, ['objects', objectName, '_actions', aname, 'successMessage'], action.successMessage, 'action', { objectName }); - } + const aroot = ['objects', objectName, '_actions', aname]; + pushDerived(out, [...aroot, 'label'], action.label ?? aname, inlineText(action.label), 'action', { objectName }); + pushOptional(out, [...aroot, 'confirmText'], action.confirmText, 'action', { objectName }); + pushOptional(out, [...aroot, 'successMessage'], action.successMessage, 'action', { objectName }); pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); } @@ -339,10 +395,8 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { if (!view?.name) continue; const objectName = viewObjectName(view); if (!objectName) continue; - pushEntry(out, ['objects', objectName, '_views', view.name, 'label'], view.label ?? view.name, 'view', { objectName }); - if (view.description) { - pushEntry(out, ['objects', objectName, '_views', view.name, 'description'], view.description, 'view', { objectName }); - } + pushDerived(out, ['objects', objectName, '_views', view.name, 'label'], view.label ?? view.name, inlineText(view.label), 'view', { objectName }); + pushOptional(out, ['objects', objectName, '_views', view.name, 'description'], view.description, 'view', { objectName }); pushViewEmptyState(out, ['objects', objectName, '_views', view.name], view, objectName); } @@ -355,13 +409,9 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { ? ['objects', objectName as string, '_actions', action.name] : ['globalActions', action.name]; const kind: ExpectedEntry['source'] = objectName ? 'action' : 'globalAction'; - pushEntry(out, [...root, 'label'], action.label ?? action.name, kind, { objectName }); - if (action.confirmText) { - pushEntry(out, [...root, 'confirmText'], action.confirmText, kind, { objectName }); - } - if (action.successMessage) { - pushEntry(out, [...root, 'successMessage'], action.successMessage, kind, { objectName }); - } + pushDerived(out, [...root, 'label'], action.label ?? action.name, inlineText(action.label), kind, { objectName }); + pushOptional(out, [...root, 'confirmText'], action.confirmText, kind, { objectName }); + pushOptional(out, [...root, 'successMessage'], action.successMessage, kind, { objectName }); pushActionParams(out, root, action, kind, objectName); pushActionResultDialog(out, root, action, kind, objectName); } @@ -469,10 +519,7 @@ function walkMetadataForms(out: ExpectedEntry[]): void { for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { const type = entry.type; pushEntry(out, ['metadataForms', type, 'label'], entry.label ?? type, 'metadataType', { metadataType: type }); - const desc = (entry as any).description; - if (typeof desc === 'string' && desc.length > 0) { - pushEntry(out, ['metadataForms', type, 'description'], desc, 'metadataType', { metadataType: type }); - } + pushOptional(out, ['metadataForms', type, 'description'], (entry as any).description, 'metadataType', { metadataType: type }); } // 2) Section + field labels for every registered form. @@ -484,11 +531,9 @@ function walkMetadataForms(out: ExpectedEntry[]): void { for (const section of sections) { if (!section || typeof section !== 'object') continue; const sectionName = normalizeSectionName(section); - if (sectionName && typeof section.label === 'string') { - pushEntry(out, ['metadataForms', type, 'sections', sectionName, 'label'], section.label, 'metadataFormSection', { metadataType: type }); - } - if (sectionName && typeof section.description === 'string' && section.description.length > 0) { - pushEntry(out, ['metadataForms', type, 'sections', sectionName, 'description'], section.description, 'metadataFormSection', { metadataType: type }); + if (sectionName) { + pushOptional(out, ['metadataForms', type, 'sections', sectionName, 'label'], section.label, 'metadataFormSection', { metadataType: type }); + pushOptional(out, ['metadataForms', type, 'sections', sectionName, 'description'], section.description, 'metadataFormSection', { metadataType: type }); } if (Array.isArray(section.fields)) { for (const child of section.fields) walkFormField(child, type, '', out); @@ -503,16 +548,12 @@ function walkFormField(field: any, type: string, parentPath: string, out: Expect const name = typeof field.field === 'string' ? field.field : undefined; const path = name ? (parentPath ? `${parentPath}.${name}` : name) : parentPath; if (path) { - const label = typeof field.label === 'string' && field.label.length > 0 - ? field.label - : humanizeFieldPath(path); - pushEntry(out, ['metadataForms', type, 'fields', path, 'label'], label, 'metadataFormField', { metadataType: type }); - if (typeof field.helpText === 'string' && field.helpText.length > 0) { - pushEntry(out, ['metadataForms', type, 'fields', path, 'helpText'], field.helpText, 'metadataFormField', { metadataType: type }); - } - if (typeof field.placeholder === 'string' && field.placeholder.length > 0) { - pushEntry(out, ['metadataForms', type, 'fields', path, 'placeholder'], field.placeholder, 'metadataFormField', { metadataType: type }); - } + // A form field that omits `label` is still labelled on screen — the + // renderer humanizes its path ("name" → "Name"). That derived text IS the + // source string, so other locales genuinely owe it a translation. + pushEntry(out, ['metadataForms', type, 'fields', path, 'label'], inlineText(field.label) ?? humanizeFieldPath(path), 'metadataFormField', { metadataType: type }); + pushOptional(out, ['metadataForms', type, 'fields', path, 'helpText'], field.helpText, 'metadataFormField', { metadataType: type }); + pushOptional(out, ['metadataForms', type, 'fields', path, 'placeholder'], field.placeholder, 'metadataFormField', { metadataType: type }); } if (Array.isArray(field.fields)) { for (const child of field.fields) walkFormField(child, type, path, out); @@ -617,7 +658,10 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext : [defaultLocale]; const fill: FillStrategy = opts.fill ?? 'empty'; - const allEntries = collectExpectedEntries(config); + // Seed-less entries exist only so the coverage gate can spot a bundle that + // authors a key the metadata never writes inline — there is nothing to + // scaffold from, so they never reach a generated skeleton. + const allEntries = collectExpectedEntries(config).filter((e) => e.sourceValue !== undefined); const entries = allEntries.filter((e) => passesFilter(e, opts.filter)); const existingBundles: TranslationBundle[] = Array.isArray(config?.translations) ? config.translations : []; @@ -630,6 +674,8 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext const data: TranslationData = {}; let count = 0; for (const entry of entries) { + // Guaranteed by the seed-less filter above; narrows for the branches below. + const seed = entry.sourceValue ?? ''; let value: string | undefined; // If a translation already exists for this locale, carry it through // verbatim so the generated file remains a complete, self-contained @@ -643,11 +689,11 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext } if (value === undefined) { if (locale === defaultLocale) { - value = entry.sourceValue; + value = seed; } else if (fill === 'default') { - value = entry.sourceValue; + value = seed; } else if (fill === 'todo') { - value = `[TODO] ${entry.sourceValue}`; + value = `[TODO] ${seed}`; } else { value = ''; } diff --git a/packages/cli/test/i18n-declared-surface-gate.test.ts b/packages/cli/test/i18n-declared-surface-gate.test.ts new file mode 100644 index 0000000000..bb45724a0e --- /dev/null +++ b/packages/cli/test/i18n-declared-surface-gate.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#3370 — "declared metadata is localizable" has to hold for the +// WHOLE declared surface, not just object and field labels. +// +// The leak that prompted this: `sys_approval_request` declares its decision +// actions INLINE on the object, and the coverage detector only ever walked the +// TOP-LEVEL `config.actions` array. So Approve / Reject / Reassign were +// extractable but ungated — they shipped English into a zh-CN workspace and no +// lint run could notice. Coverage now shares the extractor's walker, so any +// surface `os i18n extract` can scaffold is a surface `os lint` can gate. +// +// The other half of the contract, and the reason this file leads with it: a +// project that does not do i18n must stay at ZERO issues. The gate is opt-in by +// construction — it only checks locales the project itself declared, via an +// `i18n.supportedLocales` block or by shipping a bundle. Break that and every +// monolingual customer project starts failing lint for a feature it never asked +// for. + +import { describe, it, expect } from 'vitest'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { collectExpectedEntries } from '../src/utils/i18n-extract'; + +/** Only the user's own metadata; the platform metadata-form baseline is folded away by `os lint`. */ +const userIssues = (report: { issues: Array<{ source: string }> }) => + report.issues.filter((i) => i.source !== 'metadataForm'); + +/** An object that declares its actions inline — the `sys_approval_request` shape. */ +const approvalLike = { + name: 'sys_approval_request', + label: 'Approval Request', + fields: { status: { label: 'Status' } }, + actions: [ + { + name: 'approval_approve', + label: 'Approve', + successMessage: 'Approved.', + params: [{ name: 'comment', label: 'Comment' }], + }, + { + name: 'approval_reject', + label: 'Reject', + confirmText: 'Reject this request?', + }, + ], +}; + +describe('a project that does not do i18n', () => { + it('reports nothing — no bundles, no i18n block, labels authored inline', () => { + const report = computeI18nCoverage({ objects: [approvalLike] }); + + expect(report.locales).toEqual(['en']); + expect(userIssues(report)).toEqual([]); + }); + + it('stays silent when the source language is not English', () => { + // Labels authored in Chinese, no bundles, no declared locales. The inline + // label IS the source text whatever language it happens to be in — telling + // this project it "owes an en translation" would be pure noise. + const report = computeI18nCoverage({ + i18n: { defaultLocale: 'zh-CN', supportedLocales: ['zh-CN'] }, + objects: [ + { + name: 'kehu', + label: '客户', + fields: { name: { label: '名称' } }, + actions: [{ name: 'merge', label: '合并' }], + }, + ], + }); + + expect(report.defaultLocale).toBe('zh-CN'); + expect(userIssues(report)).toEqual([]); + }); +}); + +describe('declared action labels are gated (objectstack#3370)', () => { + // A zh-CN bundle that translates the object but forgets the decision actions + // — exactly the state that shipped English buttons into the approval drawer. + const configMissingActionLabels = { + objects: [approvalLike], + translations: [ + { + 'zh-CN': { + objects: { + sys_approval_request: { + label: '审批请求', + fields: { status: { label: '状态' } }, + }, + }, + }, + }, + ], + }; + + it('flags an inline-declared action label with no translation', () => { + const keys = computeI18nCoverage(configMissingActionLabels).issues.map((i) => i.key); + + expect(keys).toContain('objects.sys_approval_request._actions.approval_approve.label'); + expect(keys).toContain('objects.sys_approval_request._actions.approval_reject.label'); + }); + + it('flags the rest of an action\'s declared copy too', () => { + const keys = computeI18nCoverage(configMissingActionLabels).issues.map((i) => i.key); + + expect(keys).toContain('objects.sys_approval_request._actions.approval_approve.successMessage'); + expect(keys).toContain('objects.sys_approval_request._actions.approval_reject.confirmText'); + expect(keys).toContain('objects.sys_approval_request._actions.approval_approve.params.comment.label'); + }); + + it('attributes them to the action source so `os lint` does not fold them as platform noise', () => { + const actionIssues = computeI18nCoverage(configMissingActionLabels).issues.filter( + (i) => i.key.includes('_actions.approval_approve'), + ); + + expect(actionIssues.length).toBeGreaterThan(0); + for (const issue of actionIssues) expect(issue.source).toBe('action'); + }); + + it('goes quiet once the labels are translated', () => { + const report = computeI18nCoverage({ + objects: [approvalLike], + translations: [ + { + 'zh-CN': { + objects: { + sys_approval_request: { + label: '审批请求', + fields: { status: { label: '状态' } }, + _actions: { + approval_approve: { + label: '通过', + successMessage: '已通过。', + params: { comment: { label: '审批意见' } }, + }, + approval_reject: { label: '拒绝', confirmText: '拒绝该请求?' }, + }, + }, + }, + }, + }, + ], + }); + + expect(userIssues(report)).toEqual([]); + }); +}); + +describe('the i18n block declares which locales are gated', () => { + const config = { + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN', 'ja-JP'] }, + objects: [approvalLike], + }; + + it('checks every declared locale even when no bundle exists for it yet', () => { + // Without the block this project would be checked against 'en' alone (no + // bundles to discover), and a wholly untranslated zh-CN would pass. + const report = computeI18nCoverage(config); + + expect(report.locales.sort()).toEqual(['en', 'ja-JP', 'zh-CN']); + const zhKeys = report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key); + expect(zhKeys).toContain('objects.sys_approval_request._actions.approval_approve.label'); + }); + + it('keeps the default locale satisfied by the inline labels', () => { + const enIssues = userIssues(computeI18nCoverage(config)).filter((i) => i.locale === 'en'); + + expect(enIssues).toEqual([]); + }); + + it('promotes declared-locale gaps to errors under --i18n-strict', () => { + const relaxed = computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN'); + const strict = computeI18nCoverage(config, { strict: true }).issues.filter((i) => i.locale === 'zh-CN'); + + expect(relaxed.every((i) => i.severity === 'warning')).toBe(true); + expect(strict.every((i) => i.severity === 'error')).toBe(true); + }); +}); + +describe('the gated surface tracks the extractable surface', () => { + // One walker feeds both `os i18n extract` and the coverage gate. If someone + // re-forks them, a surface goes extractable-but-ungated again and this fails. + const kitchenSink: any = { + objects: [ + { + name: 'account', + label: 'Account', + description: 'Customer accounts', + fields: { name: { label: 'Name', help: 'Legal name', placeholder: 'Acme Inc.' } }, + listViews: { recent: { label: 'Recent', description: 'Last 30 days' } }, + actions: [ + { + name: 'merge', + label: 'Merge', + params: [{ name: 'target', label: 'Target', helpText: 'Account to merge into' }], + resultDialog: { title: 'Merged', acknowledge: 'Done' }, + }, + ], + }, + ], + apps: [{ name: 'sales', label: 'Sales', navigation: [{ id: 'home', label: 'Home' }] }], + dashboards: [{ name: 'pipeline', label: 'Pipeline', widgets: [{ id: 'won', title: 'Won' }] }], + pages: [{ name: 'about', label: 'About' }], + translations: [{ 'zh-CN': {} }], + }; + + it('gates every authored string the extractor can scaffold', () => { + const authored = collectExpectedEntries(kitchenSink) + .filter((e) => e.inline !== undefined && e.source !== 'metadataType' + && e.source !== 'metadataFormSection' && e.source !== 'metadataFormField') + .map((e) => e.path.join('.')); + const gated = new Set( + computeI18nCoverage(kitchenSink).issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key), + ); + + expect(authored.length).toBeGreaterThan(10); + expect(authored.filter((key) => !gated.has(key))).toEqual([]); + }); + + it('covers the surfaces the old detector walked past', () => { + const gated = new Set( + computeI18nCoverage(kitchenSink).issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key), + ); + + for (const key of [ + 'objects.account.description', + 'objects.account.fields.name.help', + 'objects.account.fields.name.placeholder', + 'objects.account._views.recent.label', + 'objects.account._actions.merge.label', + 'objects.account._actions.merge.params.target.label', + 'objects.account._actions.merge.resultDialog.title', + 'apps.sales.label', + 'apps.sales.navigation.home.label', + 'dashboards.pipeline.label', + 'dashboards.pipeline.widgets.won.title', + 'pages.about.label', + ]) { + expect({ key, gated: gated.has(key) }).toEqual({ key, gated: true }); + } + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 43cc323e87..5e1b620210 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -960,3 +960,81 @@ describe('translateObject system-field label fallback', () => { expect((out.fields as any)[0].label).toBe('所有者'); }); }); + +describe('translateObject inline actions (objectstack#3370)', () => { + // The `sys_approval_request` shape: decision actions declared inline on the + // object. The plugin ships `_actions` translations for them, but the object + // document used to go out with the English literals regardless of locale — + // so anything that was not the Console (which re-resolves labels client-side + // against its own bundle) rendered Approve / Reject in a zh-CN workspace. + const approvalRequest = { + name: 'sys_approval_request', + label: 'Approval Request', + actions: [ + { name: 'approval_approve', label: 'Approve', successMessage: 'Approved.' }, + { name: 'approval_reject', label: 'Reject', confirmText: 'Reject this request?' }, + { name: 'approval_remind', label: 'Send reminder' }, + ], + }; + + const bundle = { + 'zh-CN': { + objects: { + sys_approval_request: { + label: '审批请求', + _actions: { + approval_approve: { label: '通过', successMessage: '已通过。' }, + approval_reject: { label: '拒绝', confirmText: '拒绝该请求?' }, + }, + }, + }, + }, + }; + + it('translates inline action labels and copy', () => { + const out = translateObject(approvalRequest, bundle, { locale: 'zh-CN' }); + const actions = out.actions as any[]; + + expect(actions[0].label).toBe('通过'); + expect(actions[0].successMessage).toBe('已通过。'); + expect(actions[1].label).toBe('拒绝'); + expect(actions[1].confirmText).toBe('拒绝该请求?'); + }); + + it('falls back to the authored literal for an untranslated action', () => { + const out = translateObject(approvalRequest, bundle, { locale: 'zh-CN' }); + expect((out.actions as any[])[2].label).toBe('Send reminder'); + }); + + it('does not stamp a synthetic objectName onto inline actions', () => { + // The lookup needs the declaring object's name; the response should not + // grow a field the document never carried. + const out = translateObject(approvalRequest, bundle, { locale: 'zh-CN' }); + for (const action of out.actions as any[]) { + expect(Object.hasOwn(action, 'objectName')).toBe(false); + } + }); + + it('leaves the input document unmutated', () => { + translateObject(approvalRequest, bundle, { locale: 'zh-CN' }); + expect(approvalRequest.actions[0].label).toBe('Approve'); + }); + + it('honours an action that names its own object', () => { + const doc = { + name: 'sys_approval_request', + actions: [{ name: 'approval_approve', label: 'Approve', objectName: 'sys_approval_request' }], + }; + const out = translateObject(doc, bundle, { locale: 'zh-CN' }); + expect((out.actions as any[])[0].label).toBe('通过'); + expect((out.actions as any[])[0].objectName).toBe('sys_approval_request'); + }); + + it('passes objects through untouched when they declare no actions', () => { + const out = translateObject({ name: 'sys_approval_request', label: 'Approval Request' }, bundle, { + locale: 'zh-CN', + }); + expect(out.label).toBe('审批请求'); + expect(Object.hasOwn(out, 'actions')).toBe(false); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 8079a88ace..6bad3f161c 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -680,6 +680,8 @@ export interface ObjectLike { pluralLabel?: string; description?: string; fields?: Record | ObjectFieldLike[]; + /** Actions declared inline on the object (`Object.actions`). */ + actions?: ActionLike[]; } export interface ObjectFieldLike { @@ -784,14 +786,22 @@ function builtinSystemFieldLabel( /** * Apply the active locale to an object metadata document. Translates the - * object's `label` / `pluralLabel` / `description` and walks each field to - * translate its `label`, `help`, and per-option `label`s. The input document + * object's `label` / `pluralLabel` / `description`, walks each field to + * translate its `label`, `help`, and per-option `label`s, and walks any + * inline-declared `actions` through {@link translateAction}. The input document * is not mutated; a structural clone of the touched branches is returned. * * Field maps come in two shapes across the codebase: a `Record` * (preferred — the canonical authored shape) and an `Array` (some REST * responses flatten the record). Both are supported; the function returns the * same shape it was given. + * + * Actions were the long-standing hole here: `sys_approval_request` declares its + * decision actions inline, so an object document went out with English + * Approve / Reject buttons even though the plugin ships `_actions` translations + * for them. The Console papered over it by re-resolving labels client-side + * against a separately fetched bundle, which left every other consumer — mobile, + * plain HTTP, SDUI — rendering the source language (#3370). */ export function translateObject( doc: T, @@ -839,12 +849,30 @@ export function translateObject( fields = next; } + const actions = Array.isArray(doc.actions) + ? doc.actions.map((action) => { + if (!action || typeof action !== 'object' || !action.name) return action; + if (action.objectName) return translateAction(action, bundle, opts); + // An inline action carries no `objectName` of its own — it is addressed + // by the object that declares it, which is what `_actions` keys on. Scope + // it for the lookup, then hand back the shape the document came in with + // rather than stamping a synthetic field onto the response. + const { objectName: _scoped, ...translated } = translateAction( + { ...action, objectName }, + bundle, + opts, + ); + return translated as typeof action; + }) + : undefined; + return { ...doc, ...(label !== undefined ? { label } : {}), ...(pluralLabel !== undefined ? { pluralLabel } : {}), ...(description !== undefined ? { description } : {}), ...(fields !== undefined ? { fields } : {}), + ...(actions !== undefined ? { actions } : {}), }; } From 1ad79c2d178860586ddb4c7173fa5547080f9162 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:06:05 +0000 Subject: [PATCH 2/3] docs(ui): document config-driven locale selection and inline-action translation (#3370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-visible behaviours from the i18n gate work were undocumented: - `os lint` / `os i18n check` / `os i18n extract` read the stack's `i18n` block to decide which locales they gate, so translating is opt-in and a project that does not do i18n reports nothing — including one whose source language is not English. - An object document now translates the actions it declares inline, not just its own labels and fields. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW --- content/docs/ui/translations.mdx | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index ccd9ab1e41..1e0d25800d 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -78,7 +78,11 @@ export default defineStack({ The metadata types resolved per request are **object, view, action, app, dashboard, and page** — a field's labels are translated as part of its object -document. +document, and so are the labels of any actions the object declares inline +(#3370). Before that, an object document went out with its authored action +labels untouched: `sys_approval_request`'s Approve / Reject rendered in English +in a zh-CN workspace for every consumer except the Console, which happened to +re-resolve them client-side against its own copy of the bundle. **Page headers are keyed by page name (#3589).** A page's `page:header` @@ -151,6 +155,27 @@ A missing string in the **default** locale is an error; missing strings in other locales are warnings until you set `--strict` / `--threshold`. The Todo example ships a completeness test alongside its bundles — worth copying. +### Which locales get checked + +Your project decides, and the tooling never assumes. `os lint`, `os i18n check` +and `os i18n extract` read the `i18n` block above — `supportedLocales` is the +set they gate, `defaultLocale` the one that must be complete. Without that +block they fall back to whatever locales your bundles already cover, and +finally to `en`. + +The consequence worth stating plainly: **a project that does not do i18n +reports nothing.** No `i18n` block and no bundles means one active locale, the +default one, and your inline `label:` is already that locale's text — so there +is no gap to report and no need to reach for `--skip-i18n`. The same holds if +your source language isn't English: declare `defaultLocale: 'zh-CN'` and the +tooling stops asking for English you never promised. + +Translating is therefore opt-in, but once you opt in it covers the **whole** +declared surface — every row of the table above, including action labels +declared inline on an object. That surface used to be narrower than what +`os i18n extract` would scaffold, which is how untranslated approval buttons +shipped without any lint noticing (#3370). + The two gates answer different questions, and you want both. `os i18n check` asks *are the strings translated?* — a coverage number about human work. `os i18n extract --check` asks *are the generated bundles still what the schema From 565b8bb40bd5d7c64b5724b26fc75f569029c3ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:26:30 +0000 Subject: [PATCH 3/3] fix(cli): give metadata-forms emission its own flag instead of deriving it from --objects-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving it was wrong, and wrong in the quiet direction: `--objects-only` defaults to true, so the repo's own `pnpm check:i18n` went from verifying 8 bundles to 4 while still printing a green tick — the 803-key Studio metadata-form baseline stopped being drift-checked in all four locales. The two questions are orthogonal. `--objects-only` picks the sub-tree of the objects module; whether the companion `.metadata-forms.generated.ts` is written at all is now `--metadata-forms`, defaulting to on so the platform gate keeps its full coverage. A package that owns only its own objects passes `--no-metadata-forms` — the baseline is registry-driven and identical for every stack, so exactly one package should commit it, and plugin-approvals' `--check` was failing purely for demanding a second copy. Pins the emit set to the flags in a test, since a file leaving the emit set leaves the drift gate with no other visible symptom. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW --- .changeset/i18n-gate-declared-labels.md | 12 ++- packages/cli/src/commands/i18n/extract.ts | 20 +++-- .../test/i18n-extract-emitted-files.test.ts | 82 +++++++++++++++++++ .../scripts/i18n-extract.config.ts | 7 +- 4 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 packages/cli/test/i18n-extract-emitted-files.test.ts diff --git a/.changeset/i18n-gate-declared-labels.md b/.changeset/i18n-gate-declared-labels.md index 77fa6dd844..94df823bc4 100644 --- a/.changeset/i18n-gate-declared-labels.md +++ b/.changeset/i18n-gate-declared-labels.md @@ -44,7 +44,11 @@ fetched bundle; every other consumer — mobile, plain HTTP, SDUI — rendered t source language. It now runs inline actions through `translateAction`, without stamping a synthetic `objectName` onto the response. -Also fixes `os i18n extract --check` demanding `.metadata-forms.generated.ts` -files under `--objects-only` (the default), where a plain run writes none — the -drift gate failed on a tree that was in sync, which made it unusable as the CI -check the gate is meant to be. +Adds `os i18n extract --no-metadata-forms`. Whether the companion +`.metadata-forms.generated.ts` file is written was previously implicit: +every run emitted it, so `--check` demanded that file in packages that +deliberately do not commit one. The Studio metadata-form baseline is +registry-driven and identical for every stack, so exactly one package owns it +(`platform-objects`); a plugin translating only its own objects now opts out, +and its `--check` stops failing on a tree that is in sync. Defaults to emitting, +so `pnpm check:i18n` keeps covering all 8 platform bundles. diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index b90fce3eb9..d7e492fe2d 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -81,6 +81,12 @@ export default class I18nExtract extends Command { default: true, allowNo: true, }), + 'metadata-forms': Flags.boolean({ + description: + 'Also write .metadata-forms.generated.ts for the Studio metadata-form baseline (default). Pass --no-metadata-forms in a package that owns only its own objects — that baseline belongs to one package, not every plugin.', + default: true, + allowNo: true, + }), 'dry-run': Flags.boolean({ description: 'Print to stdout instead of writing to --out', default: false, @@ -141,12 +147,16 @@ export default class I18nExtract extends Command { metadataFormsCounts[locale] = countLeaves(result.bundles[locale]?.metadataForms); } const anyMetadataForms = Object.values(metadataFormsCounts).some((n) => n > 0); - // …but under --objects-only they are only ever *reported*, never written. - // Counting them as emitted files made `--check` demand - // `.metadata-forms.generated.ts` next to bundles a plain run does - // not produce, so the drift gate failed on a tree that was in fact in sync. + // Whether the companion `.metadata-forms.generated.ts` file is + // written is its own question, orthogonal to `--objects-only` (which only + // picks the sub-tree of the *objects* module). The Studio metadata-form + // baseline is registry-driven and identical for every stack, so exactly + // one package should own it — `platform-objects` does. A plugin that owns + // only its own objects passes `--no-metadata-forms`; without it, `--check` + // demands a baseline copy the package deliberately does not commit and + // fails on a tree that is in fact in sync. const emitsMetadataForms = (locale: string): boolean => - !objectsOnly && (metadataFormsCounts[locale] ?? 0) > 0; + flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0; if (flags.json) { console.log(JSON.stringify({ diff --git a/packages/cli/test/i18n-extract-emitted-files.test.ts b/packages/cli/test/i18n-extract-emitted-files.test.ts new file mode 100644 index 0000000000..7314a430fb --- /dev/null +++ b/packages/cli/test/i18n-extract-emitted-files.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Which files `os i18n extract` writes is also what `--check` verifies, so a +// file quietly dropping out of the emit set quietly drops out of the drift +// gate — the gate keeps printing a green tick over strictly less coverage. +// That is exactly what happened while building #3370: deriving metadata-forms +// emission from `--objects-only` took the repo's own `pnpm check:i18n` from 8 +// bundles to 4, silently un-checking the 803-key Studio baseline in four +// locales. These cases pin the emit set to the flags, so the next change to it +// has to be deliberate. +// +// `--objects-only` and `--metadata-forms` are orthogonal: the first picks the +// sub-tree of the *objects* module, the second decides whether the companion +// metadata-forms module is written at all. + +import { describe, it, expect } from 'vitest'; +import { extractTranslations } from '../src/utils/i18n-extract'; + +/** Mirror of the emit rule in `src/commands/i18n/extract.ts`. */ +function emittedFiles( + bundles: Record, + flags: { objectsOnly: boolean; metadataForms: boolean }, +): string[] { + const files: string[] = []; + for (const [locale, data] of Object.entries(bundles)) { + if (countLeaves(flags.objectsOnly ? data.objects : data) > 0) { + files.push(`${locale}.objects.generated.ts`); + } + if (flags.metadataForms && countLeaves(data.metadataForms) > 0) { + files.push(`${locale}.metadata-forms.generated.ts`); + } + } + return files.sort(); +} + +function countLeaves(obj: unknown): number { + if (!obj || typeof obj !== 'object') return 0; + let n = 0; + for (const v of Object.values(obj as Record)) { + if (typeof v === 'string') n += 1; + else if (v && typeof v === 'object') n += countLeaves(v); + } + return n; +} + +const config = { + objects: [{ name: 'account', label: 'Account', fields: { name: { label: 'Name' } } }], +}; + +describe('os i18n extract emit set', () => { + const result = extractTranslations(config, { defaultLocale: 'en', locales: ['zh-CN'] }); + + it('walks the platform metadata-form registry regardless of the stack config', () => { + // The baseline is registry-driven, so it is present to emit even for a + // stack that declares a single object. + expect(countLeaves(result.bundles['en']?.metadataForms)).toBeGreaterThan(100); + }); + + it('writes the metadata-forms companion by default', () => { + expect(emittedFiles(result.bundles, { objectsOnly: true, metadataForms: true })).toEqual([ + 'en.metadata-forms.generated.ts', + 'en.objects.generated.ts', + 'zh-CN.metadata-forms.generated.ts', + 'zh-CN.objects.generated.ts', + ]); + }); + + it('drops only the companion under --no-metadata-forms', () => { + expect(emittedFiles(result.bundles, { objectsOnly: true, metadataForms: false })).toEqual([ + 'en.objects.generated.ts', + 'zh-CN.objects.generated.ts', + ]); + }); + + it('keeps the companion under --no-objects-only — the two flags are independent', () => { + // Regression: metadata-forms emission was briefly derived from + // `--objects-only`, which silently shrank the drift gate. + expect(emittedFiles(result.bundles, { objectsOnly: false, metadataForms: true })).toContain( + 'en.metadata-forms.generated.ts', + ); + }); +}); diff --git a/packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts b/packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts index 50f5158894..337b52e11e 100644 --- a/packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts @@ -8,8 +8,13 @@ * strings were seeded from @objectstack/platform-objects.) * * os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/plugins/plugin-approvals/src/translations + * + * `--no-metadata-forms` because the Studio metadata-form baseline is owned by + * `@objectstack/platform-objects` — this plugin translates only the objects it + * owns, so it must not commit a second copy. Add `--check` to run the same + * command as a drift gate. */ import { defineStack } from '@objectstack/spec';