diff --git a/src/translations/authored-text.ts b/src/translations/authored-text.ts new file mode 100644 index 0000000..ca2cc07 --- /dev/null +++ b/src/translations/authored-text.ts @@ -0,0 +1,784 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { expandViewContainer } from '@objectstack/spec'; +import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system'; + +/** + * The authored-text walk — the one place that decides what in this app's + * metadata is DISPLAY TEXT and, for each piece, which bundle key carries its + * translation. + * + * `src/translations/en.ts` builds the English bundle out of this walk, so `en` + * is DERIVED and cannot be hand-edited into disagreement with the source + * (there is no English literal anywhere to edit). `test/i18n-coverage.test.ts` + * turns the same walk into the CI gate: a declared label with no bundle key, + * and a bundle key with no source, both fail. + * + * ── The rule that makes this outlive the card ──────────────────────────── + * The walk does not enumerate the slots it knows about and skip the rest. It + * visits EVERY string leaf in the metadata and demands a verdict for each, + * looked up by NORMALISED PATH ([] for an array index, {} for a record key). + * A path with no verdict is a finding — `collectAuthoredText().unclassified` — + * and the gate fails naming it. So the next key somebody adds is a red test, + * not an unchecked string. Same idiom, and the same reason, as the + * `walks every field-bearing slot the metadata actually uses` tripwire in + * `test/metadata-bindings.test.ts`; read that file before changing this one. + * + * Three verdicts, and the middle one is the interesting half: + * + * - `translate` — display text WITH a bundle key. Goes into `en`, and + * `zh-CN` must carry it. + * - `untranslatable` — display text with NO bundle key anywhere in the + * platform's translation schema. Not silently dropped: + * each entry names why and where it is filed, the gate + * counts them, and an entry that stops matching anything + * fails as STALE so the list cannot rot into an excuse. + * - `machine` — not display text at all (a machine name, a CEL source, + * an icon, a colour, a filter token). + * + * ── What is translatable is the PLATFORM's answer, not ours ────────────── + * `TRANSLATABLE_METADATA_TYPES` is imported from `@objectstack/spec/system`, + * where it is documented as "derived from the dispatch table — never restate + * it". Measured on 17.2.0 it is exactly + * `{ view, action, object, app, dashboard, page }`. Every other metadata type + * this app ships — dataset, flow, job, hook, permission set, position, + * sharing rule — has no translator and no bundle group, so its display text is + * `untranslatable` by the platform's own declaration rather than by our + * judgement. `test/i18n-coverage.test.ts` pins the set, so the day the + * platform makes datasets translatable the pin goes red and this walk gets + * extended instead of quietly staying behind. + * + * ── Measured behaviour the key builders below depend on ────────────────── + * Run against `@objectstack/spec` 17.2.0's own resolvers, not assumed: + * + * translateObject label / pluralLabel / description; per field `label`, + * `help` and per-option `label`. NOTE the field shape: a + * field's authored `description` is NOT overwritten — + * `help` is ADDED beside it out of + * `objects..fields..help`. So `help` is the bundle + * slot for an authored field `description`, which is why + * the key builder below spells it that way. + * translateAction label / confirmText / successMessage only. `description` + * and `params.*` are declared by `TranslationItemSchema`, + * and `@objectstack/rest`'s `validateTranslationReferences` + * lints their KEYS — but `translateAction` applies neither. + * They are authored anyway, for the same reason the gantt's + * `viewMode` stays authored in `src/views/task.view.ts`: + * the key is the spec's own, it is served to API and MCP + * callers, and it starts rendering the moment the resolver + * is fixed. Filed upstream; see the PR body. + * translateApp app label / description, and each navigation node's + * label addressed by its stable `id`, at any tree depth. + * translateDashboard dashboard label / description, widget title / + * description / subCaption. + * translateView view label / description, keyed by the view's REGISTRY + * name — `expandViewContainer` is what produces it (the + * default `list` becomes `.default`), so it is + * imported here rather than transcribed. + * + * ── One half of the gate already exists upstream, as a WARNING ─────────── + * `@objectstack/rest` ships `validateTranslationReferences` + * (`translation-target-unknown` / `translation-option-key-unknown`), which + * reports a bundle key aimed at an object, field, option value, view, section, + * action, param, app, nav id, dashboard or widget that the stack does not + * declare. It is a warning, it does not cover `messages`, and it says nothing + * about the FORWARD direction (a declared label nobody translated). The gate + * here is the forward half plus the same reverse half as an ERROR. + */ + +// ─── Shapes ────────────────────────────────────────────────────────────── + +type Rec = Record; + +const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v); +const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined); + +/** The metadata this walk reads. Shaped so a synthetic stack can be fed in. */ +export interface TextStack { + readonly objects: readonly unknown[]; + readonly views: readonly unknown[]; + readonly apps: readonly unknown[]; + readonly dashboards: readonly unknown[]; + readonly actions: readonly unknown[]; + readonly datasets: readonly unknown[]; + readonly flows: readonly unknown[]; + readonly jobs: readonly unknown[]; + readonly hooks: readonly unknown[]; + readonly positions: readonly unknown[]; + readonly permissions: readonly unknown[]; + readonly sharingRules: readonly unknown[]; + readonly pages: readonly unknown[]; +} + +/** One authored string, with the verdict the table gave it. */ +export interface TextEntry { + /** Human-readable site, e.g. `object duly_task · fields.status.label`. */ + readonly where: string; + /** Normalised path, e.g. `object.fields{}.label`. */ + readonly path: string; + readonly text: string; + /** Bundle key path (`['objects','duly_task','label']`), or `undefined`. */ + readonly key?: readonly string[]; + /** Set on an `untranslatable` entry: why, and where it is filed. */ + readonly why?: string; +} + +export interface AuthoredTextWalk { + /** Display text WITH a bundle key. The English bundle is built from these. */ + readonly translatable: readonly TextEntry[]; + /** Display text with NO bundle key. Counted, named, never silently dropped. */ + readonly untranslatable: readonly TextEntry[]; + /** Strings the table calls machine values. Kept for the counters only. */ + readonly machine: readonly TextEntry[]; + /** Paths with no verdict — every one of these fails the gate. */ + readonly unclassified: readonly string[]; + /** `untranslatable` paths that matched nothing — a stale excuse. */ + readonly staleExemptions: readonly string[]; + /** Prose found inside a subtree declared opaque. Fails the gate. */ + readonly proseInOpaque: readonly string[]; +} + +// ─── The verdict table ─────────────────────────────────────────────────── + +interface KeyContext { + /** Concrete path segments below the surface root. */ + readonly path: readonly string[]; + /** The object holding this string. */ + readonly parent: Rec; + /** The surface's own identifiers (object name, view key, app name, …). */ + readonly ids: Rec; +} + +type Verdict = + | { readonly kind: 'translate'; readonly key: (ctx: KeyContext) => readonly string[] | undefined } + | { readonly kind: 'machine'; readonly why: string } + | { readonly kind: 'untranslatable'; readonly why: string }; + +const machine = (why: string): Verdict => ({ kind: 'machine', why }); +const untranslatable = (why: string): Verdict => ({ kind: 'untranslatable', why }); +const translate = (key: (ctx: KeyContext) => readonly string[] | undefined): Verdict => + ({ kind: 'translate', key }); + +const id = (ctx: KeyContext): string | undefined => str(ctx.ids.name); + +/** + * Subtrees the walk does not descend into, each with the reason. An opaque + * subtree holds machine values only — a field payload, a filter, a binding + * block. Choosing one is a decision, not a shortcut: it must be a VALUE bag, + * never a structural container that could later grow a label. The prose net + * below is the backstop for having chosen wrong. + */ +const OPAQUE: Readonly> = { + 'object.indexes': 'index definitions — field names and uniqueness scopes', + 'object.fields{}.summaryOperations': 'rollup wiring — object, field and function names', + 'object.enable': 'capability flags', + 'view.data': 'the view\'s data binding — provider and object name', + 'view.columns': 'column list — field paths', + 'view.sort': 'sort list — field paths and directions', + 'view.filter': 'filter rules — field paths, operators and machine values / date macros', + 'view.grouping': 'grouping fields', + 'view.kanban': 'kanban binding block — field paths', + 'view.calendar': 'calendar binding block — field paths', + 'view.gantt': 'gantt binding block — field paths and the view mode', + 'view.timeline': 'timeline binding block — field paths', + 'view.bulkActionDefs[].patch': 'the static update payload — stored field values', + 'app.branding': 'brand colours', + 'dashboard.header': 'header display flags', + 'dashboard.widgets[].layout': 'grid geometry', + 'dashboard.widgets[].chartConfig': 'chart type, colours and display flags', + 'dashboard.widgets[].options': 'renderer extras — sort key and direction', + 'dashboard.widgets[].filter': 'presentation-scope filter — field paths and date macros', + 'dataset.measures[].filter': 'measure filter — field paths, stored values and date macros', + 'flow.nodes[].config.fields': 'record payload — field names and `{template}` reads', + 'flow.nodes[].config.templateData': 'email render payload — `{{placeholder}}` values ' + + 'read off the record; the SENTENCES around them live in the email-template rows', + 'flow.nodes[].config.filter': 'record lookup filter — field names and `{template}` reads', + 'flow.nodes[].config.schedule': 'cron schedule', + 'flow.nodes[].config.timeRelative': 'time-relative trigger window — field, object and filter', + 'job.schedule': 'cron schedule', + 'job.retryPolicy': 'retry numbers', + 'permissionSet.objects': 'per-object CRUD scopes', + 'permissionSet.fieldPermissions': 'per-field read/write flags', + 'permissionSet.tabPermissions': 'per-app tab visibility', +}; + +/** + * Every normalised path this app's metadata produces, with its verdict. + * + * A path missing from here is NOT skipped — it lands in `unclassified` and the + * gate fails naming it. That default is the whole design: the table is the + * exception list, and the exception list is reviewed. + */ +const VERDICTS: Readonly> = { + // ── object ──────────────────────────────────────────────────────────── + 'object.name': machine('object API name'), + 'object.label': translate((c) => (id(c) ? ['objects', id(c)!, 'label'] : undefined)), + 'object.pluralLabel': translate((c) => (id(c) ? ['objects', id(c)!, 'pluralLabel'] : undefined)), + 'object.description': translate((c) => (id(c) ? ['objects', id(c)!, 'description'] : undefined)), + 'object.icon': machine('icon name'), + 'object.sharingModel': machine('security posture'), + 'object.datasource': machine('datasource name'), + 'object.nameField': machine('field name'), + 'object.highlightFields[]': machine('field names'), + 'object.fields{}.label': translate((c) => + id(c) ? ['objects', id(c)!, 'fields', c.path[1]!, 'label'] : undefined), + // The bundle slot for an authored field `description` is `help` — measured + // above on `translateObject`, which adds `help` beside the description. + 'object.fields{}.description': translate((c) => + id(c) ? ['objects', id(c)!, 'fields', c.path[1]!, 'help'] : undefined), + 'object.fields{}.options[].label': translate((c) => { + const value = str(c.parent.value); + return id(c) && value ? ['objects', id(c)!, 'fields', c.path[1]!, 'options', value] : undefined; + }), + 'object.fields{}.options[].value': machine('the stored option value — the option key itself'), + 'object.fields{}.options[].color': machine('option colour'), + 'object.fields{}.type': machine('field type'), + 'object.fields{}.reference': machine('lookup target object'), + 'object.fields{}.deleteBehavior': machine('referential action'), + 'object.fields{}.defaultValue': machine('default value or token'), + 'object.fields{}.defaultValue.dialect': machine('expression dialect'), + 'object.fields{}.defaultValue.source': machine('CEL source'), + // No bundle slot exists for a custom validation rule's message. Measured on + // `@objectstack/objectql` 17.2.0: a rule's `message` is put on the error + // verbatim. `messages['validation.field.*']` overrides the platform's + // BUILT-IN field catalog (which already ships zh-CN), not an authored rule. + 'object.validations[].message': untranslatable( + 'a custom validation rule\'s message is emitted verbatim — the bundle has no ' + + '`_validations` group and `messages.validation.field.*` addresses only the ' + + 'platform\'s built-in field catalog. Filed upstream; see the PR body.', + ), + 'object.validations[].name': machine('rule name'), + 'object.validations[].type': machine('rule kind'), + 'object.validations[].severity': machine('rule severity'), + 'object.validations[].events[]': machine('lifecycle events'), + 'object.validations[].condition.dialect': machine('expression dialect'), + 'object.validations[].condition.source': machine('CEL source'), + + // ── view (walked per expanded view item, so `list` and `listViews.*` + // normalise to the same paths) ──────────────────────────────────────── + 'view.label': translate((c) => { + const object = str(c.ids.object); + const key = str(c.ids.viewKey); + return object && key ? ['objects', object, '_views', key, 'label'] : undefined; + }), + 'view.description': translate((c) => { + const object = str(c.ids.object); + const key = str(c.ids.viewKey); + return object && key ? ['objects', object, '_views', key, 'description'] : undefined; + }), + 'view.type': machine('visualisation kind'), + 'view.inlineEdit': machine('editing flag'), + // An authored `bulkActionDefs` entry is NOT an action document: it never + // reaches `translateAction`, and there is no `_bulkActions` group. The + // authored comment in `src/views/task.view.ts` said as much before this + // bundle existed; it is now measured rather than assumed. + 'view.bulkActionDefs[].label': untranslatable(BULK_WHY('the toolbar button caption')), + 'view.bulkActionDefs[].confirmText': untranslatable(BULK_WHY('the confirmation prompt')), + 'view.bulkActionDefs[].confirmLabel': untranslatable(BULK_WHY('the confirm button caption')), + 'view.bulkActionDefs[].params[].label': untranslatable(BULK_WHY('a parameter\'s field label')), + 'view.bulkActionDefs[].params[].placeholder': untranslatable(BULK_WHY('a parameter\'s placeholder')), + 'view.bulkActionDefs[].params[].help': untranslatable(BULK_WHY('a parameter\'s help text')), + 'view.bulkActionDefs[].name': machine('bulk action name'), + 'view.bulkActionDefs[].icon': machine('icon name'), + 'view.bulkActionDefs[].operation': machine('data-plane operation'), + 'view.bulkActionDefs[].variant': machine('button variant'), + 'view.bulkActionDefs[].params[].name': machine('param name'), + 'view.bulkActionDefs[].params[].type': machine('param input type'), + 'view.bulkActionDefs[].visible.dialect': machine('expression dialect'), + 'view.bulkActionDefs[].visible.source': machine('CEL source'), + + // ── app ─────────────────────────────────────────────────────────────── + 'app.name': machine('app name'), + 'app.icon': machine('icon name'), + 'app.label': translate((c) => (id(c) ? ['apps', id(c)!, 'label'] : undefined)), + 'app.description': translate((c) => (id(c) ? ['apps', id(c)!, 'description'] : undefined)), + // `translateApp` addresses every navigation node by its stable `id`, at any + // depth, out of one flat `apps..navigation` map — so both the group and + // the child normalise to the same key shape. + 'app.navigation[].label': translate((c) => { + const nav = str(c.parent.id); + return id(c) && nav ? ['apps', id(c)!, 'navigation', nav, 'label'] : undefined; + }), + 'app.navigation[].children[].label': translate((c) => { + const nav = str(c.parent.id); + return id(c) && nav ? ['apps', id(c)!, 'navigation', nav, 'label'] : undefined; + }), + 'app.navigation[].id': machine('navigation node id — the translation key itself'), + 'app.navigation[].type': machine('navigation node kind'), + 'app.navigation[].icon': machine('icon name'), + 'app.navigation[].children[].id': machine('navigation node id — the translation key itself'), + 'app.navigation[].children[].type': machine('navigation node kind'), + 'app.navigation[].children[].icon': machine('icon name'), + 'app.navigation[].children[].objectName': machine('bound object'), + 'app.navigation[].children[].viewName': machine('bound view'), + 'app.navigation[].children[].dashboardName': machine('bound dashboard'), + + // ── dashboard ───────────────────────────────────────────────────────── + 'dashboard.name': machine('dashboard name'), + 'dashboard.label': translate((c) => (id(c) ? ['dashboards', id(c)!, 'label'] : undefined)), + 'dashboard.description': translate((c) => (id(c) ? ['dashboards', id(c)!, 'description'] : undefined)), + 'dashboard.widgets[].title': translate((c) => { + const widget = str(c.parent.id); + return id(c) && widget ? ['dashboards', id(c)!, 'widgets', widget, 'title'] : undefined; + }), + 'dashboard.widgets[].description': translate((c) => { + const widget = str(c.parent.id); + return id(c) && widget ? ['dashboards', id(c)!, 'widgets', widget, 'description'] : undefined; + }), + 'dashboard.widgets[].id': machine('widget id — the translation key itself'), + 'dashboard.widgets[].type': machine('widget kind'), + 'dashboard.widgets[].dataset': machine('bound dataset'), + 'dashboard.widgets[].dimensions[]': machine('dataset dimension names'), + 'dashboard.widgets[].values[]': machine('dataset measure names'), + 'dashboard.widgets[].colorVariant': machine('tile colour role'), + + // ── action ──────────────────────────────────────────────────────────── + // An object-bound action is addressed under its object; an object-less one + // under `globalActions`. `validateTranslationReferences` refuses the wrong + // one of the two explicitly, so the branch below is the platform's rule. + 'action.label': translate((c) => actionKey(c, 'label')), + 'action.description': translate((c) => actionKey(c, 'description')), + 'action.confirmText': translate((c) => actionKey(c, 'confirmText')), + 'action.successMessage': translate((c) => actionKey(c, 'successMessage')), + 'action.params[].label': translate((c) => actionParamKey(c, 'label')), + 'action.params[].helpText': translate((c) => actionParamKey(c, 'helpText')), + 'action.params[].placeholder': translate((c) => actionParamKey(c, 'placeholder')), + 'action.name': machine('action name'), + 'action.objectName': machine('bound object'), + 'action.icon': machine('icon name'), + 'action.type': machine('handler kind'), + 'action.target': machine('handler registration key'), + 'action.variant': machine('button variant'), + 'action.locations[]': machine('placement slots'), + 'action.requiredPermissions[]': machine('capability names'), + 'action.params[].name': machine('param name'), + 'action.params[].type': machine('param input type'), + 'action.visible.dialect': machine('expression dialect'), + 'action.visible.source': machine('CEL source'), + + // ── dataset — no translator, no bundle group ────────────────────────── + 'dataset.name': machine('dataset name'), + 'dataset.object': machine('base object'), + 'dataset.include[]': machine('join paths'), + 'dataset.label': untranslatable(DATASET_WHY()), + 'dataset.description': untranslatable(DATASET_WHY()), + 'dataset.dimensions[].label': untranslatable(DATASET_WHY()), + 'dataset.measures[].label': untranslatable(DATASET_WHY()), + 'dataset.dimensions[].name': machine('dimension name — bound by dashboards'), + 'dataset.dimensions[].field': machine('field path'), + 'dataset.dimensions[].type': machine('dimension kind'), + 'dataset.dimensions[].dateGranularity': machine('date bucket size'), + 'dataset.measures[].name': machine('measure name — bound by dashboards'), + 'dataset.measures[].field': machine('field path'), + 'dataset.measures[].aggregate': machine('aggregation function'), + + // ── flow — no translator, no bundle group ───────────────────────────── + 'flow.name': machine('flow name'), + 'flow.type': machine('trigger family'), + 'flow.status': machine('lifecycle state'), + 'flow.runAs': machine('execution identity'), + 'flow.label': untranslatable(FLOW_WHY()), + 'flow.description': untranslatable(FLOW_WHY()), + 'flow.nodes[].label': untranslatable(FLOW_WHY()), + 'flow.edges[].label': untranslatable(FLOW_WHY()), + // ── The user-facing half, and how #69 closed it ────────────────────── + // These nodes USED to carry an inline `title` / `message`, which reached a + // person in English in every locale and had no bundle key of any kind. #69 + // replaced them with a template reference plus a render payload, and an + // email template IS locale-resolved — `IEmailService` resolves + // `(name, locale)` and renders the row it picks, so the translation is a + // SIBLING ROW rather than a bundle key. Nothing here needs a bundle entry; + // what needs checking is that every template name has a row per supported + // locale, which `test/i18n-coverage.test.ts` asserts against the collection. + // + // This gate is what noticed the change: the two `untranslatable` verdicts + // that used to sit here failed as STALE the moment #69 merged, rather than + // sitting in the exemption list describing metadata that no longer exists. + 'flow.nodes[].config.template': machine( + 'email template NAME — resolved by `(name, locale)` against the ' + + '`emailTemplates` collection, whose per-locale rows carry the display text', + ), + 'flow.nodes[].id': machine('node id'), + 'flow.nodes[].type': machine('node kind'), + 'flow.nodes[].config.objectName': machine('bound object'), + 'flow.nodes[].config.outputVariable': machine('flow variable name'), + 'flow.nodes[].config.iteratorVariable': machine('loop variable name'), + 'flow.nodes[].config.collection': machine('`{template}` read'), + 'flow.nodes[].config.recipients': machine('`{template}` read'), + 'flow.nodes[].config.severity': machine('notification severity'), + 'flow.nodes[].config.topic': machine('notification topic key'), + 'flow.nodes[].config.sourceId': machine('`{template}` read'), + 'flow.nodes[].config.sourceObject': machine('bound object'), + 'flow.nodes[].config.triggerType': machine('trigger kind'), + 'flow.nodes[].config.condition.dialect': machine('expression dialect'), + 'flow.nodes[].config.condition.source': machine('CEL source'), + 'flow.edges[].id': machine('edge id'), + 'flow.edges[].type': machine('edge kind'), + 'flow.edges[].source': machine('node id'), + 'flow.edges[].target': machine('node id'), + 'flow.edges[].condition.dialect': machine('expression dialect'), + 'flow.edges[].condition.source': machine('CEL source'), + 'flow.variables[].name': machine('flow variable name'), + 'flow.variables[].type': machine('flow variable type'), + + // ── job / hook — operator-facing, no translator, no bundle group ────── + 'job.name': machine('job name'), + 'job.handler': machine('handler registration key'), + 'job.label': untranslatable(ADMIN_WHY('job')), + 'job.description': untranslatable(ADMIN_WHY('job')), + 'hook.name': machine('hook name'), + 'hook.object': machine('bound object'), + 'hook.handler': machine('handler registration key'), + 'hook.events[]': machine('lifecycle events'), + 'hook.onError': machine('failure posture'), + 'hook.label': untranslatable(ADMIN_WHY('hook')), + 'hook.description': untranslatable(ADMIN_WHY('hook')), + + // ── security — operator-facing, no translator, no bundle group ──────── + 'position.name': machine('position name'), + 'position.label': untranslatable(ADMIN_WHY('position')), + 'position.description': untranslatable(ADMIN_WHY('position')), + 'permissionSet.name': machine('permission set name'), + 'permissionSet.systemPermissions[]': machine('capability names'), + 'permissionSet.label': untranslatable(ADMIN_WHY('permission set')), + 'permissionSet.description': untranslatable(ADMIN_WHY('permission set')), +}; + +function BULK_WHY(what: string): string { + return `${what} of an authored \`bulkActionDefs\` entry. A bulk-action def is not an ` + + 'action DOCUMENT — it never reaches `translateAction`, and `TranslationDataSchema` ' + + 'has no group that addresses one — so this string renders in the source locale in ' + + 'every locale. The alternative shape (`bulkActions: [\'duly_task_complete\']`, ' + + 'promoting the row actions) is rejected in `src/views/task.view.ts` for a measured ' + + 'reason: N elevated action dispatches instead of one data-plane write. Filed ' + + 'upstream; see the PR body.'; +} + +function DATASET_WHY(): string { + return 'a dataset is not one of the platform\'s translatable metadata types ' + + '(`TRANSLATABLE_METADATA_TYPES`) and `TranslationDataSchema` has no `datasets` ' + + 'group, so a measure or dimension label reaches a chart axis in the source ' + + 'locale. Filed upstream; see the PR body.'; +} + +function FLOW_WHY(): string { + return 'a flow is not one of the platform\'s translatable metadata types and the ' + + '`flows` bundle group addresses only `label` and SCREEN copy — this app declares ' + + 'no screen node. Designer-facing text; see the PR body.'; +} + +function ADMIN_WHY(kind: string): string { + return `a ${kind} is not one of the platform's translatable metadata types and has no ` + + 'bundle group. Operator-facing text (Studio, the run log), not an end-user screen.'; +} + +function actionKey(ctx: KeyContext, leaf: string): readonly string[] | undefined { + const action = str(ctx.ids.name); + if (!action) return undefined; + const owner = str(ctx.ids.objectName); + return owner + ? ['objects', owner, '_actions', action, leaf] + : ['globalActions', action, leaf]; +} + +function actionParamKey(ctx: KeyContext, leaf: string): readonly string[] | undefined { + const param = str(ctx.parent.name); + const base = actionKey(ctx, 'params'); + return param && base ? [...base, param, leaf] : undefined; +} + +// ─── The walk ──────────────────────────────────────────────────────────── + +/** + * Record-valued paths whose keys are DATA rather than structure, collapsed to + * `{}` so one verdict covers every key. Anything not listed keeps its literal + * segment, which is what makes an unknown map show up as an unclassified path + * rather than being silently folded away. + */ +const RECORD_MAPS: ReadonlySet = new Set(['object.fields']); + +/** + * Containers that RE-ENTER the vocabulary they sit inside, so the subtree + * normalises onto the same paths as the outer one. A `loop` node's body holds + * nodes and edges of exactly the same kinds as the flow around it; giving them + * a second, `config.body`-prefixed copy of every verdict would be two tables to + * keep in step, and the second copy is the one that would rot. + */ +const REENTER: Readonly> = { + 'flow.nodes[].config.body': 'flow', +}; + +/** Two words of three-plus letters — the shape machine values do not have. */ +const PROSE = /[A-Za-z]{3,}\s+[A-Za-z]{3,}/; + +interface Surface { + readonly kind: string; + readonly where: string; + readonly node: Rec; + readonly ids: Rec; +} + +interface Sink { + readonly translatable: TextEntry[]; + readonly untranslatable: TextEntry[]; + readonly machine: TextEntry[]; + readonly unclassified: Set; + readonly usedExemptions: Set; + readonly proseInOpaque: string[]; +} + +const walkSurface = (surface: Surface, sink: Sink): void => { + const visit = (value: unknown, path: readonly string[], norm: string, parent: Rec): void => { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) visit(value[i], [...path, String(i)], `${norm}[]`, parent); + return; + } + if (isRec(value)) { + for (const [key, child] of Object.entries(value)) { + const nextNorm = RECORD_MAPS.has(norm) ? `${norm}{}` : `${norm}.${key}`; + const nextPath = [...path, key]; + const opaqueWhy = OPAQUE[nextNorm]; + if (opaqueWhy !== undefined) { + collectOpaque(child, `${surface.where} · ${nextPath.join('.')}`, sink); + continue; + } + visit(child, nextPath, REENTER[nextNorm] ?? nextNorm, value); + } + return; + } + if (typeof value !== 'string' || value.length === 0) return; + + const verdict = VERDICTS[norm]; + const where = `${surface.where} · ${path.join('.')}`; + if (!verdict) { + sink.unclassified.add(`${norm} (first seen at ${where} = ${JSON.stringify(value.slice(0, 60))})`); + return; + } + if (verdict.kind === 'machine') { + sink.machine.push({ where, path: norm, text: value }); + return; + } + if (verdict.kind === 'untranslatable') { + sink.usedExemptions.add(norm); + sink.untranslatable.push({ where, path: norm, text: value, why: verdict.why }); + return; + } + const key = verdict.key({ path: path.slice(1), parent, ids: surface.ids }); + if (!key) { + sink.unclassified.add(`${norm} (translatable, but its key could not be built at ${where})`); + return; + } + sink.translatable.push({ where, path: norm, text: value, key }); + }; + + visit(surface.node, [surface.kind], surface.kind, surface.node); +}; + +/** Inside an opaque subtree the only thing checked is that it holds no prose. */ +const collectOpaque = (value: unknown, where: string, sink: Sink): void => { + if (Array.isArray(value)) { + for (const item of value) collectOpaque(item, where, sink); + return; + } + if (isRec(value)) { + for (const [key, child] of Object.entries(value)) collectOpaque(child, `${where}.${key}`, sink); + return; + } + if (typeof value === 'string' && PROSE.test(value)) { + sink.proseInOpaque.push(`${where} = ${JSON.stringify(value.slice(0, 80))}`); + } +}; + +/** Every list view in a container, tagged with its object and REGISTRY key. */ +const viewSurfaces = (views: readonly unknown[]): Surface[] => { + const out: Surface[] = []; + for (const container of views as Rec[]) { + if (!isRec(container)) continue; + const list = isRec(container.list) ? container.list : undefined; + const object = str((isRec(list?.data) ? list!.data : {}).object) ?? ''; + // `expandViewContainer` is the platform's own naming — `listViews` keys in + // author order, then the default `list` as `.default`. Reading the + // names from it is what keeps `_views` keys from drifting out of step with + // the registry the resolver looks them up in. + const prefix = `${object}.`; + const strip = (name: unknown): string => { + const text = String(name ?? ''); + return text.startsWith(prefix) ? text.slice(prefix.length) : text; + }; + const listItems = (expandViewContainer(object, container) as unknown as Rec[]) + .filter((item) => item.viewKind === 'list'); + const namedKeys = listItems.filter((item) => !item.isDefault).map((item) => strip(item.name)); + const defaultKey = strip(listItems.find((item) => item.isDefault)?.name ?? 'default'); + const named = Object.entries(isRec(container.listViews) ? container.listViews : {}); + named.forEach(([authored, view], at) => { + if (!isRec(view)) return; + out.push({ + kind: 'view', + where: `view ${object} › listViews.${authored}`, + node: view, + ids: { object, viewKey: namedKeys[at] ?? authored }, + }); + }); + if (list) { + out.push({ + kind: 'view', + where: `view ${object} › list`, + node: list, + ids: { object, viewKey: defaultKey }, + }); + } + } + return out; +}; + +const simpleSurfaces = (kind: string, nodes: readonly unknown[]): Surface[] => + (nodes as Rec[]) + .filter(isRec) + .map((node) => ({ + kind, + where: `${kind} ${str(node.name) ?? '(unnamed)'}`, + node, + ids: node, + })); + +/** + * Walk every authored string in the stack and give each one a verdict. + * + * Deterministic and pure — the same stack in, the same walk out — which is + * what lets `test/i18n-coverage.test.ts` run it over synthetic metadata and + * prove the guard can fail. + */ +export const collectAuthoredText = (stack: TextStack): AuthoredTextWalk => { + const sink: Sink = { + translatable: [], + untranslatable: [], + machine: [], + unclassified: new Set(), + usedExemptions: new Set(), + proseInOpaque: [], + }; + + const surfaces: Surface[] = [ + ...simpleSurfaces('object', stack.objects), + ...viewSurfaces(stack.views), + ...simpleSurfaces('app', stack.apps), + ...simpleSurfaces('dashboard', stack.dashboards), + ...simpleSurfaces('action', stack.actions), + ...simpleSurfaces('dataset', stack.datasets), + ...simpleSurfaces('flow', stack.flows), + ...simpleSurfaces('job', stack.jobs), + ...simpleSurfaces('hook', stack.hooks), + ...simpleSurfaces('position', stack.positions), + ...simpleSurfaces('permissionSet', stack.permissions), + ...simpleSurfaces('sharingRule', stack.sharingRules), + ...simpleSurfaces('page', stack.pages), + ]; + for (const surface of surfaces) walkSurface(surface, sink); + + const declaredExemptions = Object.entries(VERDICTS) + .filter(([, v]) => v.kind === 'untranslatable') + .map(([path]) => path); + + return { + translatable: sink.translatable, + untranslatable: sink.untranslatable, + machine: sink.machine, + unclassified: [...sink.unclassified].sort(), + staleExemptions: declaredExemptions.filter((p) => !sink.usedExemptions.has(p)).sort(), + proseInOpaque: sink.proseInOpaque.sort(), + }; +}; + +/** + * How every collection in `objectstack.config.ts` is handled, and why. + * + * Three categories, and the middle one is the reason this map exists rather + * than being implied by what `TextStack` happens to list: a metadata type that + * localizes through a DIFFERENT mechanism must not look like a type the walk + * forgot. A silent skip and an unhandled key read identically at a glance, and + * only one of them is safe. + * + * - `walked` — its display text is bundle-keyed; the walk covers it. + * - `localizes by …` — real display text, localized by a mechanism that is + * not the translation bundle. Checked elsewhere, named + * here, never counted as untranslated. + * - anything else — carries no authored display text at all. + * + * `test/i18n-coverage.test.ts` fails when the config grows a key this map does + * not have, so a new collection is classified before it can ship strings the + * gate cannot see. + */ +export const COLLECTION_HANDLING: Readonly> = { + objects: 'walked', + views: 'walked', + apps: 'walked', + dashboards: 'walked', + actions: 'walked', + datasets: 'walked', + flows: 'walked', + jobs: 'walked', + hooks: 'walked', + positions: 'walked', + permissions: 'walked', + sharingRules: 'walked', + pages: 'walked', + // ⛔ NOT bundle-keyed, and deliberately not walked. `translation.zod.ts` + // mentions email templates nowhere, and `EmailTemplateDefinitionSchema`'s + // own `translations` key is `z.ZodNever`; the platform documents a template + // as "resolved by `(name, locale)`" and materializes one + // `sys_email_template` ROW PER LOCALE. So a template's translation is a + // SIBLING ROW with the same `name` and a different `locale`. Demanding a + // bundle key for a template subject would make this gate permanently and + // unfixably red. The equivalent question — "is there a translation for every + // supported locale" — is asked of the row shape instead, in + // `test/i18n-coverage.test.ts`. + emailTemplates: 'localizes by row: one `sys_email_template` per (name, locale)', + manifest: 'package registry metadata — `name` and `description` describe the PACKAGE, ' + + 'not a screen, and no bundle group addresses them', + data: 'seed rows — data a deployment replaces, not authored labels', + functions: 'handler registrations', + plugins: 'plugin instances', + requires: 'capability tokens', + mappings: 'field mappings — machine names', + i18n: 'the locale configuration these bundles serve', + translations: 'the bundles themselves', +}; + +/** The platform's translatable metadata types, re-exported for the pin test. */ +export const PLATFORM_TRANSLATABLE_TYPES: ReadonlySet = TRANSLATABLE_METADATA_TYPES; + +// ─── Bundle assembly ───────────────────────────────────────────────────── + +/** `['objects','duly_task','label']` → `objects.duly_task.label`. */ +export const keyPath = (key: readonly string[]): string => key.join('.'); + +/** + * Fold a list of `(key, text)` pairs into the nested `TranslationData` shape. + * Used for `en` (from the walk) and, in the gate, to compare `zh-CN`'s key set + * against it. + */ +export const foldIntoBundle = (entries: readonly TextEntry[]): Rec => { + const root: Rec = {}; + for (const entry of entries) { + if (!entry.key) continue; + let node = root; + for (const segment of entry.key.slice(0, -1)) { + const next = node[segment]; + if (isRec(next)) node = next; + else { + const created: Rec = {}; + node[segment] = created; + node = created; + } + } + node[entry.key[entry.key.length - 1]!] = entry.text; + } + return root; +}; + +/** Every dotted key a bundle carries, for set comparison in the gate. */ +export const bundleKeys = (data: unknown, trail: readonly string[] = []): string[] => { + if (typeof data === 'string') return [trail.join('.')]; + if (!isRec(data)) return []; + return Object.entries(data).flatMap(([key, child]) => bundleKeys(child, [...trail, key])); +}; diff --git a/src/translations/en.ts b/src/translations/en.ts new file mode 100644 index 0000000..f4d4146 --- /dev/null +++ b/src/translations/en.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTranslationBundle } from '@objectstack/spec'; + +import { dulyActions } from '../actions/index.js'; +import { dulyApps } from '../apps/index.js'; +import { dulyDashboards } from '../dashboards/index.js'; +import { dulyDatasets } from '../datasets/index.js'; +import { dulyFlows } from '../flows/index.js'; +import { dulyHooks } from '../hooks/index.js'; +import { dulyJobs } from '../jobs/index.js'; +import { dulyObjects } from '../objects/index.js'; +import { dulyPages } from '../pages/index.js'; +import { dulyPermissionSets, dulyPositions, dulySharingRules } from '../security/index.js'; +import { dulyViews } from '../views/index.js'; + +import { collectAuthoredText, foldIntoBundle } from './authored-text.js'; +import type { TextStack } from './authored-text.js'; + +/** + * The English bundle — GENERATED from the source metadata, never written. + * + * ── Why there is no `en` literal in this file ──────────────────────────── + * A hand-maintained `en` entry beats the source string silently: the bundle + * wins at render time, so the screen and the code disagree while every gate + * stays green. The usual answer is to generate a file and add a staleness + * check — but a checked-in generated file is only as good as whoever + * remembers to re-run the generator, and the failure it guards against is + * exactly the one nobody notices. + * + * So `en` is derived HERE, at config load, out of the same walk the coverage + * gate uses. There is no stored copy, so there is nothing to go stale and + * nothing a person could usefully hand-edit: changing an English string means + * changing the label in `src/objects/`, `src/views/` or `src/apps/`, which is + * the only place it was ever true. + * + * `test/i18n-coverage.test.ts` pins that property rather than trusting this + * comment: it runs `buildEnglishBundle` over SYNTHETIC metadata and asserts + * the output carries the synthetic strings. A hand-written bundle cannot pass + * that, so the derivation cannot be quietly replaced by a literal later. + * + * ── What this is NOT ───────────────────────────────────────────────────── + * It is not a translation of anything. `en` is the source language + * (AGENTS.md §8), and every value here is byte-identical to the authored + * label it came from. Its job is to be the KEY SET the translated locales are + * measured against. + */ + +/** The metadata the English bundle is derived from. */ +export const dulyTextStack: TextStack = { + objects: dulyObjects, + views: dulyViews, + apps: dulyApps, + dashboards: dulyDashboards, + actions: dulyActions, + datasets: dulyDatasets, + flows: dulyFlows, + jobs: dulyJobs, + hooks: dulyHooks, + positions: dulyPositions, + permissions: dulyPermissionSets, + sharingRules: dulySharingRules, + pages: dulyPages, +}; + +/** + * Fold a stack's authored text into one locale's worth of `TranslationData`. + * + * Exported so the gate can run it over synthetic metadata — the proof that the + * bundle is a function of the metadata and not a transcription of it. + */ +export const buildEnglishBundle = (stack: TextStack) => + // `defineTranslationBundle` PARSES — `TranslationDataSchema` is strict, so a + // key the walk built wrong (a retired group, a misspelt slot) is refused at + // config load rather than shipped and silently unread. That is the whole + // reason a derived bundle goes through the factory rather than being cast. + defineTranslationBundle({ en: foldIntoBundle(collectAuthoredText(stack).translatable) }); + +/** The English bundle for this app. */ +export const dulyEnglish = buildEnglishBundle(dulyTextStack); diff --git a/src/translations/index.ts b/src/translations/index.ts index 05c9dc1..21643ae 100644 --- a/src/translations/index.ts +++ b/src/translations/index.ts @@ -7,10 +7,32 @@ // its entry HERE and never edits the config. The config is the one file every // parallel task would otherwise collide on. // -// The collection is a named array rather than `Object.values(barrel)`: on an -// empty namespace `Object.values` has nothing to infer from and TypeScript -// resolves it against the keyed branch of `MetadataCollectionInput`, which -// makes `name` optional and fails the assignment. A named array is `never[]` -// while empty and infers correctly the moment something is pushed into it. +// ⚠ `defineStack({ translations })` takes an array of BUNDLES — each one a +// `locale → TranslationData` record — not an array of single-locale items. +// Measured on `@objectstack/runtime` 17.2.0, whose `loadTranslations` does +// `for (const [locale, data] of Object.entries(bundle))` on every element: +// pushing a `defineTranslation({ locale: 'zh-CN', … })` ITEM here would have +// the loader read `locale` and `objects` as two locale names and load neither. +// The array below therefore holds `{ en: … }` and `{ 'zh-CN': … }`. +// +// ⚠ `en` is DERIVED from the source metadata (see `en.ts`) — there is no +// English literal in this directory to hand-edit, which is the whole point: a +// hand-maintained `en` entry silently beats the source string and the served +// text drifts from the code under a green gate. `zh-CN` is hand-written, and +// `test/i18n-coverage.test.ts` holds its key set to `en`'s in both directions. + +import { dulyEnglish } from './en.js'; +import { dulyChinese } from './zh-CN.js'; + +export { dulyEnglish, dulyChinese }; +export { buildEnglishBundle, dulyTextStack } from './en.js'; +export { + collectAuthoredText, + bundleKeys, + foldIntoBundle, + keyPath, + PLATFORM_TRANSLATABLE_TYPES, +} from './authored-text.js'; +export type { AuthoredTextWalk, TextEntry, TextStack } from './authored-text.js'; -export const dulyTranslations = []; +export const dulyTranslations = [dulyEnglish, dulyChinese]; diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts new file mode 100644 index 0000000..0636454 --- /dev/null +++ b/src/translations/zh-CN.ts @@ -0,0 +1,529 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTranslationBundle } from '@objectstack/spec'; + +/** + * 简体中文 (zh-CN) — hand-written, unlike `en.ts`, which is generated. + * + * `test/i18n-coverage.test.ts` compares this file's key set against the key + * set the walk derives from the metadata, in BOTH directions: a declared label + * missing here fails, and a key here that no longer has a source fails. So a + * renamed field or a deleted view cannot leave a dead entry behind, and a new + * label cannot ship untranslated. + * + * ═══════════════════════════════════════════════════════════════════════════ + * TERMINOLOGY — decided here, not left to a translator + * ═══════════════════════════════════════════════════════════════════════════ + * + * Six words in this product carry meaning a literal translation loses. The + * decisions are recorded here because they have to be CONSISTENT across 230 + * keys, and because the reasoning is not recoverable from the English string + * alone. Sources: `docs/product/design-principles.md`, `docs/product/data-model.md` + * and the object file headers. + * + * **duty → 职责** (`duly_duty`) + * The recurring obligation itself — the RULE that produces tasks, attached to + * a person by their position. 职责 is the ordinary business word for exactly + * that (岗位职责), and it is a standing thing rather than a work item, which + * is the distinction the whole product rests on. + * ⛔ NOT 任务 — that is `duly_task`, one occurrence of this. + * ⛔ NOT 义务 — legal/moral obligation; this is an organisational one. + * + * **task → 任务** (`duly_task`) + * ONE dispatched occurrence of a duty, for one person, for one period. 任务 + * is unambiguous in Chinese and, paired with 职责 above, keeps the pair as + * distinct as "duty"/"task" is in English — which the design principle + * *"A duty is not a task"* requires. + * + * **period → 周期**, and the FIELD is 所属周期 + * The recurrence window a task belongs to (`2026-W34`, `2026-08`, `2026-Q3`). + * Bare 周期 beside 频率 ("frequency") reads as the cadence rather than the + * window, so `duly_task.period_key` is 所属周期 — "the period it belongs to". + * The anchor options stay 周期开始 / 周期结束, where the window reading is + * unambiguous. + * + * **standing → 常设** (`form: 'standing'`) + * A standing duty NEVER completes and NEVER generates a task — "keep the + * register current", "answer the duty phone". 常设 is the register of 常设 + * 机构 / 常设委员会: established permanently, by its nature not a thing that + * finishes. + * ⛔ NOT 长期 ("long-term") or 持续 ("ongoing") — both read as a task that + * runs for a long time, which is precisely the misreading that makes people + * look for the tick box. The product invariant is that there is none. + * + * **governed → 组织认定** (`source IN ('catalog','assigned')`) + * Work the ORGANISATION put on someone — from the role catalog, or assigned + * by a manager — as opposed to self-declared. 组织认定 says "the organisation + * established it", which is the property that makes a rate over it mean + * something. + * ⛔ NOT 纳入考核 ("counted towards assessment") — accurate about the metrics + * and wrong about the product: Duly scores nobody, and that word would import + * a performance-review frame the design deliberately refuses. + * + * **caliber → 口径** + * The internal name for what `source` decides: which population a number is + * computed over. 口径 is the standard Chinese term (统计口径) and is the right + * word if it ever surfaces. No user-facing label carries it today, so no key + * below uses it — recorded so the next translator does not invent a second + * word for it. + * + * Supporting choices, same reasoning, less contested: + * business unit → 部门 · role catalog → 岗位职责库 · assignment → 指派 · + * dispatch → 派发 · not moving / stagnation → 停滞 · grace → 宽限期 · + * lead time → 提前天数 · self-declared → 自行申报 · skip → 跳过. + * + * ── One label needed judgement rather than a swap (issue #18, round 2) ──── + * `due_offset_days.label` deliberately carries arithmetic inside display text + * — `Offset (days, 0 = anchor day)` — because the zero point is where a + * configurer otherwise makes an off-by-one no gate can see. The parenthetical + * has to survive as an EXPLANATION, so it is rendered + * 「偏移天数(0 = 锚点当天)」 rather than word-for-word; "anchor day" as a + * literal compound carries nothing in Chinese. Its help text keeps the two + * worked examples per anchor and is translated for sense, not structure. + */ +export const dulyChinese = defineTranslationBundle({ + 'zh-CN': { + objects: { + // ── duly_duty — 职责:产生任务的那条规则 ───────────────────────── + duly_duty: { + label: '职责', + pluralLabel: '职责', + description: '附着在某个人身上的常设义务:应做什么、多久一次、以及在每个周期内的最后期限。', + fields: { + name: { label: '职责' }, + description: { + label: '“做完”的标准', + help: '用负责人自己的话写下的验收标准。选填——没写不影响任何流程。', + }, + form: { + label: '形式', + options: { + recurring: '周期性', + one_off: '一次性', + // 常设:永不完成,因此永不产生任务。见文件头的术语说明。 + standing: '常设', + }, + }, + owner: { + label: '负责人', + help: '有且只有一个担责的人。“由团队负责”的工作等于没人负责。', + }, + business_unit: { + label: '部门', + help: '汇总口径的锚点。创建时取自负责人的岗位。', + }, + source: { + label: '来源', + options: { + catalog: '岗位职责库', + assigned: '主管指派', + self: '自行申报', + }, + }, + catalog_item: { + label: '职责库条目', + help: '当该职责由岗位职责库实例化而来时填写,以便职责库的修改可以重新下发。', + }, + frequency: { + label: '频率', + help: '周期性职责必填。常设职责禁止填写——它永不派发,频率对它没有意义(`standing_no_frequency`)。一次性职责会忽略此项,它只由人工派发一次。', + options: { + daily: '每日', + weekly: '每周', + fortnightly: '每两周', + monthly: '每月', + quarterly: '每季度', + semiannual: '每半年', + annual: '每年', + }, + }, + due_anchor: { + label: '到期日锚定于', + help: '把到期日锚定在周期内部。只有周期性职责需要;常设与一次性职责应留空(且禁止填写)。', + options: { + period_start: '周期开始', + period_end: '周期结束', + }, + }, + due_offset_days: { + label: '偏移天数(0 = 锚点当天)', + help: '相对锚点当天的天数,锚点当天即 0。锚定“周期开始”时:0 = 周期第一天,4 = 第五天。锚定“周期结束”时:0 = 周期最后一天,-3 = 最后一天往前三天。必须是整天,且不超过锚点前后各一年——超出这个范围的是笔误,不是排期。只有周期性职责有可供偏移的周期;常设与一次性职责应留空(且禁止填写)。', + }, + lead_days: { + label: '提前天数', + help: '任务提前多少天出现在负责人的清单里。到期当天才出现的任务,出现时就已经晚了。必须是整天,最多一年。只有周期性职责会带提前量派发;常设与一次性职责应留空(且禁止填写)。', + }, + grace_days: { + label: '宽限期(天)', + help: '到期后经过多少天,未完成的任务才算逾期。必须是整天,最多 30 天——逾期提醒只回溯 31 天,宽限期更长会让逾期第一天落在扫描窗口之外,提醒将永远不会触发。常设职责没有任务,因此没有意义;一次性职责的任务仍然适用。', + }, + timezone: { + label: '时区', + help: 'IANA 名称(例如 Europe/Berlin)。周期边界与到期日按此时区计算。', + }, + status: { + label: '状态', + options: { + active: '启用', + paused: '暂停', + retired: '停用', + }, + }, + effective_from: { label: '生效日期' }, + effective_to: { label: '失效日期' }, + last_dispatched_period: { + label: '最后派发周期', + help: '由系统写入。派发作业将其设为它最近一次创建过任务的周期。', + }, + }, + _views: { + mine: { label: '我的职责' }, + standing: { label: '常设职责' }, + catalog_tree: { label: '各团队应尽的职责' }, + default: { label: '全部职责' }, + }, + }, + + // ── duly_task — 任务:职责的一次派发 ──────────────────────────── + duly_task: { + label: '任务', + pluralLabel: '任务', + description: '职责的一次派发:由一个人在一个周期内承担。', + fields: { + subject: { + label: '任务', + help: '派发时从职责复制而来,因此改写职责的名称不会改写历史。', + }, + duty: { + label: '职责', + help: '对于从未建模成职责的一次性工作,此处为空。', + }, + owner: { label: '负责人' }, + business_unit: { + label: '部门', + help: '派发时从负责人处冗余写入,使汇总在此后的人员调动中依然成立。', + }, + assignment: { + label: '指派', + help: '当该任务来自主管的一次指派分发时填写。一次指派,N 个彼此独立的任务。', + }, + source: { + label: '来源', + options: { + catalog: '岗位职责库', + assigned: '主管指派', + self: '自行申报', + }, + }, + period_key: { + label: '所属周期', + help: '一次性任务没有周期,此处为空。', + }, + due_date: { label: '到期' }, + visible_from: { + label: '开始显示于', + help: '到期日减去职责的提前天数。在此之前任务已存在,但不会占用视线。', + }, + status: { + label: '状态', + options: { + open: '待办', + in_progress: '进行中', + done: '已完成', + skipped: '已跳过', + cancelled: '已取消', + }, + }, + skip_reason: { + label: '跳过原因', + help: '跳过是一种正当结果——“当时装置停车,没有任何东西需要申报”。记下原因,才不会让“跳过”变成“完成”的同义词。', + }, + completed_at: { label: '完成时间' }, + last_update_at: { label: '最后更新' }, + note: { + label: '备注', + help: '选填。完成任务从不要求填写——一道举证关卡会把 5 秒钟的勾选变成 5 分钟的差事,然后这份清单就没人用了。', + }, + }, + _views: { + my_week: { label: '我的本周' }, + late: { label: '逾期' }, + stalled: { label: '停滞' }, + calendar: { label: '日历' }, + board: { label: '看板' }, + schedule: { label: '排期' }, + recent: { label: '最近动态' }, + by_unit: { + label: '按部门', + description: '仅包含待办与进行中的工作。分组计数是在已加载的这一页上算出来的——按部门的权威口径在仪表板,这个视图用于浏览。', + }, + default: { label: '全部任务' }, + }, + _actions: { + duly_task_complete: { + label: '完成', + description: '把这个任务标记为已完成。一次点击,不问任何问题——撤销也只要一次点击。', + successMessage: '已完成。', + }, + duly_task_undo: { + label: '撤销', + description: '重新打开这个任务,完成时间会一并清除。', + successMessage: '已重新打开。', + }, + duly_task_skip: { + label: '跳过', + description: '跳过是一种正当结果——当时装置停车,没有任何东西需要申报。记下原因,才不会让“跳过”变成“完成”的同义词。', + successMessage: '已跳过。', + params: { + skip_reason: { + label: '跳过原因', + placeholder: '当时装置停车,没有任何东西需要申报', + helpText: '会保存在这个任务上。写得短没关系,留空不行。', + }, + }, + }, + }, + }, + + // ── duly_catalog_item — 岗位职责库条目 ────────────────────────── + duly_catalog_item: { + label: '职责库条目', + pluralLabel: '岗位职责库', + description: '附着在某个岗位上的职责模板。实例化到某个人身上,生成他的职责。', + fields: { + name: { label: '职责' }, + description: { label: '“做完”的标准' }, + position_code: { + label: '岗位', + help: '该职责所属的岗位。自由文本,因此客户可以先导入职责库,之后再在平台里建模岗位。', + }, + form: { + label: '形式', + options: { + recurring: '周期性', + one_off: '一次性', + standing: '常设', + }, + }, + frequency: { + label: '频率', + help: '周期性职责必填。常设职责禁止填写——它永不派发,频率对它没有意义(`standing_no_frequency`)。一次性职责会忽略此项,它只由人工派发一次。', + options: { + daily: '每日', + weekly: '每周', + fortnightly: '每两周', + monthly: '每月', + quarterly: '每季度', + semiannual: '每半年', + annual: '每年', + }, + }, + due_anchor: { + label: '到期日锚定于', + help: '把到期日锚定在周期内部。只有周期性职责需要;常设与一次性职责应留空(且禁止填写)。', + options: { + period_start: '周期开始', + period_end: '周期结束', + }, + }, + due_offset_days: { + label: '偏移天数(0 = 锚点当天)', + help: '相对锚点当天的天数,锚点当天即 0。锚定“周期开始”时:0 = 周期第一天,4 = 第五天。锚定“周期结束”时:0 = 周期最后一天,-3 = 最后一天往前三天。必须是整天,且不超过锚点前后各一年。只有周期性职责有可供偏移的周期;常设与一次性职责应留空(且禁止填写)。', + }, + lead_days: { + label: '提前天数', + help: '必须是整天,最多一年。只有周期性职责会带提前量派发;常设与一次性职责应留空(且禁止填写)。', + }, + grace_days: { + label: '宽限期(天)', + help: '必须是整天,最多 30 天——逾期提醒只回溯 31 天,宽限期更长就永远不会触发。常设职责没有任务,因此没有意义;一次性职责的任务仍然适用。', + }, + regulation_ref: { + label: '依据', + help: '这项职责所履行的条款、标准或制度。它是把一份检查清单变成一个可用于审计的答复的东西。', + }, + active: { label: '启用' }, + }, + _views: { + default: { label: '岗位职责库' }, + }, + _actions: { + duly_catalog_apply_to_people: { + label: '应用到人员', + description: '为选中的每个人创建这个岗位应尽的职责。可以放心重复执行——已经从职责库条目获得过职责的人会被跳过,不会重复创建。', + params: { + position_code: { + label: '岗位', + placeholder: 'plant_compliance_officer', + helpText: '与 duly_catalog_item.position_code 精确匹配。自由文本——这个岗位不需要事先在平台里建模。', + }, + users: { + label: '人员', + helpText: '每个人都会得到这个岗位职责库中每一条启用职责的独立副本。', + }, + }, + }, + }, + }, + + // ── duly_assignment — 指派:一件工作分发给若干人 ───────────────── + duly_assignment: { + label: '指派', + pluralLabel: '指派', + description: '一件交给若干人的工作,分发成彼此独立的任务。', + fields: { + subject: { label: '指派' }, + description: { label: '说明' }, + assigner: { label: '指派人' }, + assignees: { + label: '指派给', + help: '每一个名字都会变成一个彼此独立的任务。', + }, + due_date: { label: '到期' }, + status: { + label: '状态', + options: { + draft: '草稿', + dispatched: '已派发', + closed: '已关闭', + }, + }, + needs_collection: { + label: '全部完成后我需要跟进', + help: '只有勾选了这一项,指派人才会得到一个属于自己的任务。否则,指派工作的主管不会因此给自己添一份待办清单。', + }, + task_count: { label: '任务数' }, + }, + _views: { + sent_by_me: { label: '我发出的' }, + default: { label: '指派' }, + }, + }, + + // ── duly_log_entry — 工作日志:日历,而不是清单 ────────────────── + duly_log_entry: { + label: '工作记录', + pluralLabel: '工作日志', + description: '个人完成工作的记录。从不评分、从不排名、从不比较。', + fields: { + subject: { label: '你做了什么' }, + detail: { label: '详情' }, + owner: { label: '负责人' }, + logged_on: { label: '日期' }, + category: { + label: '类别', + options: { + coordination: '跨团队协作', + drafting: '起草/撰写', + incident: '突发/计划外', + meeting: '会议', + support: '支援他人', + other: '其他', + }, + }, + visibility: { + label: '可见范围', + help: '默认仅自己可见。一份让人有顾虑的日志,没有人会认真记。', + options: { + private: '仅自己', + manager: '我的主管', + }, + }, + related_task: { + label: '关联任务', + help: '选填。把一条记录挂到某个组织认定的职责上,同时不让这条记录进入它的任何计分。', + }, + }, + _views: { + default: { label: '工作日志' }, + }, + }, + }, + + apps: { + duly_app: { + // 产品名保持原样:Duly 是品牌,不翻译。 + label: 'Duly', + navigation: { + group_me: { label: '我的工作' }, + nav_my_week: { label: '我的本周' }, + nav_my_duties: { label: '我的职责' }, + nav_standing: { label: '常设职责' }, + nav_log: { label: '工作日志' }, + nav_board: { label: '看板' }, + group_team: { label: '团队' }, + nav_duty_health: { label: '职责健康度' }, + nav_late: { label: '逾期' }, + nav_stalled: { label: '停滞' }, + nav_assignments: { label: '指派' }, + nav_schedule: { label: '排期' }, + nav_recent: { label: '最近动态' }, + nav_by_unit: { label: '按部门' }, + group_setup: { label: '设置' }, + nav_catalog: { label: '岗位职责库' }, + nav_all_duties: { label: '全部职责' }, + nav_catalog_tree: { label: '各团队应尽的职责' }, + }, + }, + }, + + dashboards: { + duly_duty_health: { + label: '职责健康度', + description: '仅统计组织认定的职责——来自岗位职责库与主管指派的工作;自行申报的职责不计入这里的任何数字。逾期暂未展示:它取决于每条职责各自的宽限期,而本视图无法应用宽限期——“团队 → 逾期”列表是目前的替代答案,它不考虑宽限期。', + widgets: { + not_moving_14d: { + title: '停滞', + description: '超过 14 天没有任何动静的、组织认定的待办任务。仅统计组织认定的职责,自行申报的工作不计入。', + }, + not_moving_30d: { + title: '停滞超过 30 天', + description: '这是旁边那块指标的子集,不能与它相加。', + }, + oldest_touch: { + title: '最久未动的任务', + description: '最停滞的那个待办任务上一次有动静的时间——是一个日期,不是一个分数。', + }, + not_moving_by_unit: { + title: '停滞情况(按部门)', + description: '各部门超过 14 天没有动静的、组织认定的待办任务。部门按名称排序,绝不按数量排序。', + }, + coming_up: { + title: '即将到期', + description: '未来 14 天内到期的、组织认定的任务,按周分组。', + }, + }, + }, + }, + + globalActions: { + duly_catalog_apply: { + label: '应用岗位职责库', + description: '为选中的每个人创建这个岗位应尽的职责。可以放心重复执行——已经从职责库条目获得过职责的人会被跳过,不会重复创建。', + params: { + position_code: { + label: '岗位', + // 岗位代码是数据值,不翻译:它要与 duly_catalog_item.position_code 精确匹配。 + placeholder: 'plant_compliance_officer', + helpText: '与 duly_catalog_item.position_code 精确匹配。自由文本——这个岗位不需要事先在平台里建模。', + }, + users: { + label: '人员', + helpText: '每个人都会得到这个岗位职责库中每一条启用职责的独立副本。', + }, + }, + }, + duly_catalog_sync: { + label: '按职责库同步职责', + description: '把岗位职责库中的节奏变更重新下发到由它创建的职责上。负责人、状态、时区与生效区间不受影响;来自已停用职责库条目的职责只会被报告,绝不会被删除。', + params: { + position_code: { + label: '岗位', + placeholder: 'plant_compliance_officer', + helpText: '把同步限定在一个岗位。留空则同步每一条来自职责库的职责。', + }, + }, + }, + }, + }, +}); diff --git a/test/i18n-coverage.test.ts b/test/i18n-coverage.test.ts new file mode 100644 index 0000000..34e8363 --- /dev/null +++ b/test/i18n-coverage.test.ts @@ -0,0 +1,511 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { expandViewContainer } from '@objectstack/spec'; + +import stackConfig from '../objectstack.config.js'; +import { dulyViews } from '../src/views/index.js'; +import { + bundleKeys, + collectAuthoredText, + COLLECTION_HANDLING, + foldIntoBundle, + keyPath, + PLATFORM_TRANSLATABLE_TYPES, +} from '../src/translations/authored-text.js'; +import type { TextStack } from '../src/translations/authored-text.js'; +import { buildEnglishBundle, dulyTextStack, dulyEnglish } from '../src/translations/en.js'; +import { dulyChinese } from '../src/translations/zh-CN.js'; + +/** + * The i18n coverage gate. + * + * Two failures, both of them silent without this file: + * + * 1. **A declared label with no bundle key.** The screen renders in English + * inside a Chinese deployment, next to labels that did translate — which + * reads as a styling quirk, not as a missing translation. + * 2. **A bundle key with no source.** A renamed field or a deleted view + * leaves an entry behind that resolves nothing. `@objectstack/rest`'s + * `validateTranslationReferences` reports most of these as a WARNING + * (`translation-target-unknown`); here it is an error, and it also covers + * the shapes that lint does not look at. + * + * ── Why this is a walk and not a checklist ─────────────────────────────── + * `src/translations/authored-text.ts` visits every string leaf in the metadata + * and demands a verdict per normalised path. A path with no verdict is a + * finding, so the next key somebody adds is a red test rather than an + * unchecked string. That tripwire — not the key comparison — is the part of + * this file meant to outlive the card, and it is the same idiom as + * `walks every field-bearing slot the metadata actually uses` in + * `test/metadata-bindings.test.ts`. + * + * ── The guard can fail ─────────────────────────────────────────────────── + * Every rule below is exercised a second time against SYNTHETIC metadata at + * the bottom of this file. A guard that has never been observed failing is + * indistinguishable from a guard that cannot fail. + */ + +type Rec = Record; + +const SOURCE_LOCALE = 'en'; +const TARGET_LOCALE = 'zh-CN'; + +const walk = collectAuthoredText(dulyTextStack); +const derivedKeys = walk.translatable.map((entry) => keyPath(entry.key!)); +const englishKeys = new Set(bundleKeys((dulyEnglish as Rec)[SOURCE_LOCALE])); +const chineseKeys = new Set(bundleKeys((dulyChinese as Rec)[TARGET_LOCALE])); + +// ─── The gate, over this app's real metadata ───────────────────────────── + +describe('i18n coverage — every authored label is translated, every key has a source', () => { + it('every declared label the walk can address is carried by zh-CN', () => { + const missing = walk.translatable + .filter((entry) => !chineseKeys.has(keyPath(entry.key!))) + .map((entry) => `${keyPath(entry.key!)} ← ${entry.where} = ${JSON.stringify(entry.text)}`); + expect( + missing, + 'a declared label with no bundle key in zh-CN — it renders in English inside a ' + + 'Chinese deployment, beside labels that did translate', + ).toEqual([]); + }); + + it('every zh-CN key still has a source in the metadata', () => { + const sources = new Set(derivedKeys); + expect( + [...chineseKeys].filter((key) => !sources.has(key)).sort(), + 'a bundle key that names nothing the metadata declares — a rename or a deletion ' + + 'left it behind and it resolves nothing', + ).toEqual([]); + }); + + it('walks every string-bearing slot the metadata actually uses', () => { + // THE tripwire. A path the walk cannot classify would leave its string + // unchecked while everything stayed green — so fail here, naming the path, + // rather than there. + expect( + walk.unclassified, + 'a string-bearing path this walk has no verdict for — classify it in ' + + '`src/translations/authored-text.ts` (translate / untranslatable / machine) ' + + 'before trusting this gate', + ).toEqual([]); + }); + + it('declares no untranslatable exemption that matches nothing', () => { + // An exemption list that outlives what it excused is how "we know about + // that one" turns into an unreviewed hole. + expect( + walk.staleExemptions, + 'an `untranslatable` verdict that no longer matches any authored string — delete it', + ).toEqual([]); + }); + + it('finds no prose inside a subtree declared opaque', () => { + // The backstop for having declared the wrong subtree opaque: a value bag + // that grows a sentence is display text hiding behind a skipped walk. + expect( + walk.proseInOpaque, + 'human prose inside a subtree `OPAQUE` says holds machine values only — either it ' + + 'is display text (stop skipping the subtree) or the value is misplaced', + ).toEqual([]); + }); + + it('resolves keys on every translatable surface, not just the easy one', () => { + // A walk that silently stopped covering actions or nav would pass the two + // assertions above by having nothing to compare. These keep green from + // meaning vacuous. + const surfaces = { + objects: derivedKeys.filter((k) => k.startsWith('objects.')), + views: derivedKeys.filter((k) => k.includes('._views.')), + actions: derivedKeys.filter((k) => k.includes('._actions.')), + globalActions: derivedKeys.filter((k) => k.startsWith('globalActions.')), + apps: derivedKeys.filter((k) => k.startsWith('apps.')), + navigation: derivedKeys.filter((k) => k.includes('.navigation.')), + dashboards: derivedKeys.filter((k) => k.startsWith('dashboards.')), + widgets: derivedKeys.filter((k) => k.includes('.widgets.')), + options: derivedKeys.filter((k) => k.includes('.options.')), + help: derivedKeys.filter((k) => k.endsWith('.help')), + }; + for (const [surface, keys] of Object.entries(surfaces)) { + expect(keys.length, `no ${surface} key was derived at all — the walk is broken`) + .toBeGreaterThan(0); + } + // This app is ~230 addressable strings; a walk that collapsed to a handful + // is broken in a way the per-surface counts would not catch. + expect(derivedKeys.length, 'the walk derived implausibly few keys').toBeGreaterThan(180); + expect(new Set(derivedKeys).size, 'two authored strings claimed the same bundle key') + .toBe(derivedKeys.length); + }); + + it('keys every view by the name the REGISTRY uses, not by the authored key', () => { + // `_views.` is looked up by the view's registry name — the default + // `list` is `.default`, not `list`. Read off the platform's own + // expansion so the two cannot drift. + const expected = new Set(); + for (const container of dulyViews as Rec[]) { + const object = String(((container.list as Rec)?.data as Rec)?.object ?? ''); + if (!object) continue; + for (const item of expandViewContainer(object, container) as unknown as Rec[]) { + if (item.viewKind !== 'list') continue; + expected.add(`objects.${object}._views.${String(item.name).slice(object.length + 1)}.label`); + } + } + const derivedViewLabels = new Set(derivedKeys.filter((k) => k.endsWith('.label') && k.includes('._views.'))); + expect([...expected].sort(), 'a view the walk did not key, or keyed under the wrong name') + .toEqual([...derivedViewLabels].sort()); + }); +}); + +// ─── `en` is generated, and cannot quietly stop being ──────────────────── + +describe('the English bundle is derived from the metadata, never hand-written', () => { + it('is byte-for-byte the fold of the walk over this app\'s own metadata', () => { + expect( + (dulyEnglish as Rec)[SOURCE_LOCALE], + 'the shipped `en` bundle is not what the walk produces — someone put a literal in ' + + 'front of the derivation, and a hand-edited `en` beats the source string silently', + ).toEqual(foldIntoBundle(walk.translatable)); + }); + + it('carries exactly the source strings, unmodified', () => { + const wrong = walk.translatable + .filter((entry) => { + let node: unknown = (dulyEnglish as Rec)[SOURCE_LOCALE]; + for (const segment of entry.key!) node = (node as Rec | undefined)?.[segment]; + return node !== entry.text; + }) + .map((entry) => keyPath(entry.key!)); + expect(wrong, '`en` is the SOURCE language — every value must equal the authored label') + .toEqual([]); + }); + + it('produces a different bundle for different metadata — i.e. it is a function of it', () => { + // The property a hand-written `en` could not have. Two synthetic stacks, + // same shape, different strings: if the bundle is derived, the output + // follows the input. + const first = buildEnglishBundle(syntheticStack({ objectLabel: 'Widget' })) as Rec; + const second = buildEnglishBundle(syntheticStack({ objectLabel: 'Gadget' })) as Rec; + expect(((first.en as Rec).objects as Rec).syn_thing).toMatchObject({ label: 'Widget' }); + expect(((second.en as Rec).objects as Rec).syn_thing).toMatchObject({ label: 'Gadget' }); + }); + + it('keys `en` and `zh-CN` identically', () => { + expect([...englishKeys].sort()).toEqual([...chineseKeys].sort()); + }); +}); + +// ─── The stack actually ships what this file checks ────────────────────── + +describe('the bundles reach the stack', () => { + it('declares one bundle per supported locale, in the shape the loader reads', () => { + // `loadTranslations` does `Object.entries(bundle)` on every element of + // `translations`, so each element must be a `locale → data` RECORD. A + // single-locale `defineTranslation` item would be read as two locales + // named `locale` and `objects`, and neither would load. + const bundles = (stackConfig as { translations?: unknown[] }).translations ?? []; + expect(bundles.length, 'the translations barrel did not reach the stack').toBe(2); + const locales = bundles.flatMap((bundle) => Object.keys(bundle as Rec)).sort(); + const supported = [...((stackConfig as { i18n?: { supportedLocales?: string[] } }).i18n?.supportedLocales ?? [])].sort(); + expect(locales, 'the bundles do not cover exactly the locales the config advertises') + .toEqual(supported); + }); + + it('every metadata collection in the config is either walked or declared text-free', () => { + // Sibling tripwire to the slot one. A NEW collection must be classified + // before it can ship strings this gate does not see. The classification + // itself lives beside the walk (`COLLECTION_HANDLING`), so the reasoning + // and the walk cannot drift apart. + expect( + Object.keys(stackConfig as Rec).filter((key) => !(key in COLLECTION_HANDLING)).sort(), + 'a metadata collection this gate has never seen — classify it in ' + + '`COLLECTION_HANDLING` (walked, localizes by another mechanism, or text-free) ' + + 'before it ships strings this gate cannot see', + ).toEqual([]); + // The middle category is the one that must not read as an oversight: a + // type that localizes by another mechanism is NAMED, with the mechanism. + expect(COLLECTION_HANDLING.emailTemplates, 'the email-template skip must state its mechanism') + .toMatch(/localizes by row/); + }); + + it('pins the metadata types the PLATFORM says are translatable', () => { + // The anchor the walk's `untranslatable` verdicts rest on: a dataset + // measure label has no bundle key because the platform has no dataset + // translator, not because we decided so. When this set grows, that + // reasoning changes and the walk must be extended — so fail here. + expect( + [...PLATFORM_TRANSLATABLE_TYPES].sort(), + 'the platform\'s translatable metadata types changed — re-derive the ' + + '`untranslatable` verdicts in src/translations/authored-text.ts against the new set', + ).toEqual(['action', 'app', 'dashboard', 'object', 'page', 'view']); + }); +}); + +// ─── Display text no bundle can reach — named, counted, never silent ───── + +describe('untranslatable display text is declared rather than dropped', () => { + it('is exactly the set this repo has measured and filed', () => { + // Not a tolerance: an exact set. A NEW untranslatable string fails here + // and has to be argued for, and one that becomes translatable fails as a + // stale exemption in the gate above. + const paths = [...new Set(walk.untranslatable.map((entry) => entry.path))].sort(); + expect( + paths, + 'authored display text with no bundle key that this repo has not recorded — either ' + + 'it has a key (add it to the walk) or it is a platform gap worth filing', + ).toEqual([ + 'dataset.description', + 'dataset.dimensions[].label', + 'dataset.label', + 'dataset.measures[].label', + 'flow.description', + 'flow.edges[].label', + 'flow.label', + 'flow.nodes[].label', + 'hook.description', + 'hook.label', + 'job.description', + 'job.label', + 'object.validations[].message', + 'permissionSet.description', + 'permissionSet.label', + 'position.description', + 'position.label', + 'view.bulkActionDefs[].confirmLabel', + 'view.bulkActionDefs[].confirmText', + 'view.bulkActionDefs[].label', + 'view.bulkActionDefs[].params[].help', + 'view.bulkActionDefs[].params[].label', + 'view.bulkActionDefs[].params[].placeholder', + ]); + }); + + it('every one of them says why, so the list cannot decay into a shrug', () => { + const silent = walk.untranslatable.filter((entry) => !entry.why || entry.why.length < 40); + expect(silent.map((entry) => entry.path), 'an untranslatable verdict with no stated reason') + .toEqual([]); + }); + + it('counts the user-facing half, which is what a reader of the PR needs', () => { + // These three groups reach an END USER in English in a Chinese deployment. + // The rest (flow node labels, job/hook/position/permission-set text) is + // designer- or operator-facing. Asserted as counts so the PR body's + // numbers cannot drift from the code. + const count = (prefix: string): number => + walk.untranslatable.filter((entry) => entry.path.startsWith(prefix)).length; + expect(count('view.bulkActionDefs'), 'bulk-action toolbar copy').toBe(35); + expect(count('object.validations'), 'custom validation messages').toBe(11); + expect(count('dataset.'), 'dataset labels behind chart axes').toBe(26); + // Was 6 before #99 (#69) landed: three `notify` nodes' inline title and + // message. They now reference an email template, whose per-locale rows are + // checked below — so this is a gap that CLOSED, pinned at zero so it + // cannot silently reopen as inline copy. + expect(count('flow.nodes[].config.'), 'inline notification copy — closed by #69').toBe(0); + }); +}); + +// ─── Email templates — a locale is a SIBLING TEMPLATE, not a bundle key ── + +/** + * `EmailTemplateDefinitionSchema` carries its own `locale`, and the platform + * documents a template as "resolved by `(name, locale)`" — its `translations` + * key is `z.ZodNever`. So the coverage rule for templates is not "has a bundle + * key" but "has a sibling in every supported locale". Written now, against a + * collection that is still empty, so #69's templates are covered the moment + * they are wired in rather than after somebody remembers. + */ +interface TemplateLike { name?: unknown; locale?: unknown; subject?: unknown; bodyHtml?: unknown; bodyText?: unknown } + +const templateGaps = (templates: readonly TemplateLike[], locales: readonly string[]): string[] => { + const byName = new Map>(); + for (const template of templates) { + const name = typeof template?.name === 'string' ? template.name : ''; + if (!name) continue; + // `locale` has a schema default, so an omitted one is the default locale. + const locale = typeof template?.locale === 'string' ? template.locale : SOURCE_LOCALE; + const seen = byName.get(name) ?? new Set(); + seen.add(locale); + byName.set(name, seen); + } + const gaps: string[] = []; + for (const [name, seen] of byName) { + for (const locale of locales) { + if (!seen.has(locale)) gaps.push(`email template "${name}" has no ${locale} sibling`); + } + } + return gaps.sort(); +}; + +/** Literal authored words in a template row, with `{{holes}}` removed. */ +const literalWords = (template: TemplateLike & { bodyHtml?: unknown; bodyText?: unknown }): string => + [template.subject, template.bodyHtml, template.bodyText] + .map((value) => (typeof value === 'string' ? value : '')) + .join(' ') + .replace(/\{\{\{?[^}]*\}?\}\}/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +/** Names whose locale rows carry identical authored wording. */ +const sameWordsAcrossLocales = (templates: readonly TemplateLike[]): string[] => { + const byName = new Map(); + for (const template of templates) { + const name = typeof template?.name === 'string' ? template.name : ''; + if (!name) continue; + byName.set(name, [...(byName.get(name) ?? []), literalWords(template)]); + } + return [...byName] + .filter(([, words]) => words.length > 1 && words.some((w) => w.length > 0) + && new Set(words).size < words.length) + .map(([name]) => name) + .sort(); +}; + +describe('email templates carry a sibling per supported locale', () => { + const templates = ((stackConfig as { emailTemplates?: readonly TemplateLike[] }).emailTemplates ?? []); + const locales = (stackConfig as { i18n?: { supportedLocales?: string[] } }).i18n?.supportedLocales ?? []; + + it('every declared template resolves in every supported locale', () => { + expect( + templateGaps(templates, locales), + 'a template subject/body that reaches a person in the source language only — a ' + + 'template is resolved by (name, locale), so the translation is a sibling row', + ).toEqual([]); + }); + + it('is checking real rows, not passing on an empty collection', () => { + // Non-vacuity. #69 landed three template NAMES × two locales; if the + // collection ever empties, the assertion above would pass by having + // nothing to check, and this is what says so. + const names = new Set(templates.map((t) => String(t.name))); + expect(names.size, 'no email template reached the config — the check above is vacuous') + .toBeGreaterThan(0); + expect(templates.length, 'one row per (name, locale): three names across two locales') + .toBe(names.size * locales.length); + }); + + it('gives each locale row its own words, not a copy of the source row', () => { + // A sibling row that duplicates the source-locale wording is a row that + // exists and translates nothing — the row-shaped version of an English + // value pasted into `zh-CN`, and the shape a per-locale COUNT cannot see. + // + // Compared on LITERAL text only: `{{holes}}` are stripped first, because a + // field that is nothing but a placeholder is carrying record data rather + // than authored words. `subject: '{{{subject}}}'` is the same string in + // both rows on purpose — it renders the task's own subject line — and + // flagging it would be flagging the data. + expect(sameWordsAcrossLocales(templates), 'a locale row repeats the source row\'s wording') + .toEqual([]); + }); +}); + +// ─── The guard can fail (self-test on synthetic metadata) ──────────────── + +const emptyStack = (): TextStack => ({ + objects: [], views: [], apps: [], dashboards: [], actions: [], datasets: [], + flows: [], jobs: [], hooks: [], positions: [], permissions: [], sharingRules: [], + pages: [], +}); + +const syntheticStack = (opts: { objectLabel?: string; extraField?: Rec; opaqueProse?: boolean } = {}): TextStack => ({ + ...emptyStack(), + objects: [ + { + name: 'syn_thing', + label: opts.objectLabel ?? 'Widget', + pluralLabel: 'Widgets', + fields: { + status: { + name: 'status', + label: 'Status', + type: 'select', + options: [{ label: 'Open', value: 'open' }], + ...(opts.extraField ?? {}), + }, + }, + ...(opts.opaqueProse + ? { indexes: [{ name: 'syn_index', fields: ['status'], unique: 'a whole sentence hiding here' }] } + : {}), + }, + ], +}); + +describe('i18n coverage guard — the guard can fail (self-test on synthetic metadata)', () => { + it('derives a key for an ordinary label', () => { + const found = collectAuthoredText(syntheticStack()).translatable.map((e) => keyPath(e.key!)); + expect(found).toContain('objects.syn_thing.label'); + expect(found).toContain('objects.syn_thing.fields.status.label'); + expect(found).toContain('objects.syn_thing.fields.status.options.open'); + }); + + it('reports a string-bearing path it has no verdict for', () => { + const result = collectAuthoredText(syntheticStack({ extraField: { bannerText: 'Read me first' } })); + expect(result.unclassified.join('\n')) + .toContain('object.fields{}.bannerText'); + expect( + result.translatable.some((e) => e.text === 'Read me first'), + 'an unclassified string must NOT be silently translated', + ).toBe(false); + }); + + it('reports prose hiding inside an opaque subtree', () => { + const result = collectAuthoredText(syntheticStack({ opaqueProse: true })); + expect(result.proseInOpaque.join('\n')).toContain('a whole sentence hiding here'); + }); + + it('reports an exemption that matches nothing', () => { + // The empty stack matches no `untranslatable` path at all, so every + // declared exemption is stale — which is exactly the failure this catches + // when one authored string is deleted. + const result = collectAuthoredText(emptyStack()); + expect(result.staleExemptions.length, 'a stack with no authored text must make every exemption stale') + .toBeGreaterThan(10); + expect(result.staleExemptions).toContain('object.validations[].message'); + }); + + it('detects a missing translation, and an orphan key', () => { + const stack = syntheticStack(); + const source = new Set(collectAuthoredText(stack).translatable.map((e) => keyPath(e.key!))); + const partial = new Set(['objects.syn_thing.label', 'objects.syn_thing.fields.ghost.label']); + expect([...source].filter((k) => !partial.has(k)), 'the forward direction must find the gap') + .toContain('objects.syn_thing.fields.status.label'); + expect([...partial].filter((k) => !source.has(k)), 'the reverse direction must find the orphan') + .toEqual(['objects.syn_thing.fields.ghost.label']); + }); + + it('reports an email template with no sibling in a supported locale', () => { + expect( + templateGaps( + [ + { name: 'duly_task_due_soon', locale: 'en' }, + { name: 'duly_task_due_soon', locale: 'zh-CN' }, + { name: 'duly_task_overdue', locale: 'en' }, + ], + ['en', 'zh-CN'], + ), + ).toEqual(['email template "duly_task_overdue" has no zh-CN sibling']); + }); + + it('reports a locale row that copies the source row\'s wording', () => { + expect(sameWordsAcrossLocales([ + { name: 'duly.copied', locale: 'en', subject: 'Due soon', bodyText: 'Due {{due_date}}.' }, + { name: 'duly.copied', locale: 'zh-CN', subject: 'Due soon', bodyText: 'Due {{due_date}}.' }, + { name: 'duly.real', locale: 'en', subject: 'Due soon', bodyText: 'Due {{due_date}}.' }, + { name: 'duly.real', locale: 'zh-CN', subject: '即将到期', bodyText: '{{due_date}} 到期。' }, + ])).toEqual(['duly.copied']); + }); + + it('does not flag a field that is nothing but a placeholder', () => { + // `subject: '{{{subject}}}'` is identical in every locale by design. + expect(sameWordsAcrossLocales([ + { name: 'duly.holes', locale: 'en', subject: '{{{subject}}}', bodyText: 'Due {{due_date}}.' }, + { name: 'duly.holes', locale: 'zh-CN', subject: '{{{subject}}}', bodyText: '{{due_date}} 到期。' }, + ])).toEqual([]); + }); + + it('treats a template with no explicit locale as the source locale', () => { + // `locale` carries a schema default, so an omitted one is not "every + // locale" — it is `en`, and it still needs a zh-CN sibling. + expect(templateGaps([{ name: 'duly_welcome' }], ['en', 'zh-CN'])) + .toEqual(['email template "duly_welcome" has no zh-CN sibling']); + }); +});