diff --git a/.changeset/lint-translation-reference-integrity.md b/.changeset/lint-translation-reference-integrity.md new file mode 100644 index 0000000000..51fb7d27e1 --- /dev/null +++ b/.changeset/lint-translation-reference-integrity.md @@ -0,0 +1,48 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +feat(lint): translation-bundle reference integrity + option-key validation (#3583) + +The i18n gate only ever ran forward: `os i18n check` asks which keys the +metadata expects that no bundle carries. Nothing asked the reverse — which keys +a bundle carries that no metadata claims — even though the spec already names +the answer (`TranslationDiffStatus 'redundant'`, `TranslationCoverageResult.redundantKeys`, +both declared with no producer). + +That direction ships two failure modes, both found in the HotCRM audit: bundles +keyed to fields an object no longer declares (a rename that left the translation +behind), and select-option translations keyed by the option's **display label** +or a variant spelling of its value (`direct-mail` for `direct_mail`, `planned` +for `planning`). Neither breaks anything — which is the problem. The resolver +finds nothing and renders the source string, so the screen looks translated and +one field or one picklist value quietly does not. + +New rule `validateTranslationReferences` walks every bundle in +`stack.translations` against the stack it ships with, wired into `os validate`, +`os lint`, and `os compile`: + +| Key | Must name | +|---|---| +| `objects.{object}` | an object this stack defines, or a platform object | +| `objects.{object}.fields.{field}` | a field that object declares | +| `objects.{object}.fields.{field}.options.{key}` | an option's stored `value` | +| `objects.{object}._views` / `._actions` / `._sections` / `._actions.*.params` | a view `name` / bound action / `fieldGroups[].key` or named section / param `name` | +| `apps.{app}` / `.navigation.{id}` | an app `name` / navigation item `id` | +| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | dashboard `name` / widget `id` / header `actionUrl` | +| `globalActions.{action}` | an action with no `objectName` | + +Every finding is a **warning** (`translation-target-unknown`, +`translation-option-key-unknown`): an orphan key is inert, not broken, and the +severity should say so. Diagnostics carry the declared names to choose from, +name the stored value when a key turns out to be the display label, and suggest +a namespace-segment match (`task` → `todo_task`) that edit distance alone misses. + +Cross-package objects follow the existing ladder: a registered platform object +is skipped wholly (its fields are not visible from a stack lint), a +platform-prefixed name no package registers is reported once on the object key, +and the subtree is never half-checked. `messages`, `validationMessages`, +`settings`, `settingsCommon` and `metadataForms` are deliberately not judged — +their keys are owned by application code, plugins, and the platform's own +metadata-type registry, so no enumerable universe exists to resolve against. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index ca7a3e5efc..6e764d841d 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -763,6 +763,46 @@ Output: ... ``` +### Orphan Keys and Option Keys + +`os i18n check` runs in one direction — which keys the metadata expects that no +bundle carries. The reverse direction (keys a bundle carries that no metadata +claims) is checked by `os validate`, `os lint`, and `os compile`, which walk +every bundle against the stack it ships with: + +| Key | Must name | +|:---|:---| +| `objects.{object}` | an object this stack defines, or a platform object | +| `objects.{object}.fields.{field}` | a field that object declares | +| `objects.{object}.fields.{field}.options.{key}` | an option's stored **`value`** | +| `objects.{object}._views.{view}` | a view's `name` | +| `objects.{object}._actions.{action}` | an action bound to that object | +| `objects.{object}._actions.{action}.params.{param}` | a param's `name` | +| `objects.{object}._sections.{section}` | a `fieldGroups[].key`, or a section with a `name` | +| `apps.{app}` / `apps.{app}.navigation.{id}` | an app's `name` / a navigation item's `id` | +| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | the dashboard's `name` / a widget `id` / a header `actionUrl` | +| `globalActions.{action}` | an action with **no** `objectName` | + +An unresolvable key is a **warning**, not an error: nothing crashes, the string +simply renders in its source locale while every neighbouring label resolves — so +the app looks translated and one field does not. + +The option case is worth spelling out. Option maps are keyed by the option's +stored `value`, never by its display label: + +```typescript +// source: Field.select({ options: [{ value: 'direct_mail', label: 'Direct Mail' }] }) + +options: { direct_mail: '直邮' } // ✅ keyed by value +options: { 'Direct Mail': '直邮' } // ❌ keyed by the label — never resolves +options: { 'direct-mail': '直邮' } // ❌ a variant spelling of the value +``` + +`messages`, `validationMessages`, `settings`, `settingsCommon` and +`metadataForms` are **not** checked: their keys are owned by application code, +plugins, and the platform's own metadata-type registry rather than by this +stack's metadata, so there is no set of legal names to resolve against. + ### Translation Service Integration diff --git a/docs/audits/2026-07-app-metadata-reference-integrity-assessment.md b/docs/audits/2026-07-app-metadata-reference-integrity-assessment.md index a930d01d4c..74706724c3 100644 --- a/docs/audits/2026-07-app-metadata-reference-integrity-assessment.md +++ b/docs/audits/2026-07-app-metadata-reference-integrity-assessment.md @@ -90,7 +90,7 @@ Effort legend: **S** ≤ ~1 day · **M** 2–4 days · **L** ≥ 1 week. Every r | Rule | Covers | Notes | Effort | |---|---|---|---| | R5 `validate-nav-access` | 8 | `buildAccessMatrix` × app nav (own, non-platform objects only): nav-exposed object where no permission set grants read → **warning** (grants may come from another package's set, `adminScope`, or the superuser wildcard — advisory is the honest ceiling, per §2.1 S4 precedent). Optionally also "app hidden by `tabPermissions` for every set". First actual lint consumer of `buildAccessMatrix`. | M | -| R6 `validate-translation-references` | 9 | The reverse/orphan direction the spec already names (`'redundant'`): walk bundle keys → objects/fields/views/actions must exist; option sub-keys must be option **values** (flag a key that matches a label or is an edit-distance near-miss of a value — the `direct-mail` vs `direct_mail`, `planned` vs `planning` class, with did-you-mean hints). Pure `(stack) => Finding[]`, so it belongs in `@objectstack/lint`, not the CLI util. **warning** (orphan keys are inert, not broken). | M/L (key-shape diversity: both `TranslationDataSchema` and the object-first `AppTranslationBundleSchema`) | +| R6 `validate-translation-references` | 9 | **Landed 2026-07-28.** The reverse/orphan direction the spec already names (`'redundant'`): bundle keys → objects/fields/views/actions/sections/action-params, apps + nav ids, dashboards + widget ids + header `actionUrl`, `globalActions`; option sub-keys must be option **values** (a key that matches a display label is named as such; near-misses get did-you-mean, plus a namespace-segment pass so `task` suggests `todo_task`). **warning** throughout (orphan keys are inert, not broken). Cross-package objects follow the §4 ladder with S3 intact: a registered platform object is skipped WHOLLY, since its fields are not visible from here. Scope note: `TranslationDataSchema` only — the object-first `AppTranslationBundleSchema` is the `translation` METADATA TYPE, not `stack.translations`, so its keys are skipped rather than reported; `messages`/`validationMessages`/`settings`/`settingsCommon`/`metadataForms` are deliberately unjudged (their keys are owned by code, plugins and the platform registry, so there is no stack-enumerable universe). | M/L → shipped M | | R7 `validate-ai-references` | 6 (the resolvable subset) | `agent.skills` → stack.skills; `skill.tools` (wildcard-aware) → stack.tools; `agent.tools[].name` → actions/flows by declared type. Kernel-provided skills/tools (`ask`/`build` register theirs at runtime) need either inclusion in the Phase-1 registry or S2 advisory severity — resolve with D2. `knowledge.indexes`/`sources` is **blocked on D1** — do not lint a namespace that has no definition site; that would institutionalise the gap. | M + D1/D2 | | R8 (Tier-B candidate) option-value literals in metadata | — | kanban group values, `ChartConfig.colors` keys, filter literals vs. select options. High false-positive surface (filters legitimately hold dynamic/user values) — per the ADR-0078 sharing-rule lesson, this gets a verification note *before* it becomes a rule, or it doesn't ship. | ? | diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index edacfce59c..2c30a4d761 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -19,8 +19,6 @@ export const CrmTranslationBundle = defineTranslationBundle({ industry: { label: 'Industry' }, annual_revenue: { label: 'Annual Revenue' }, website: { label: 'Website' }, - phone: { label: 'Phone' }, - billing_address: { label: 'Billing Address' }, owner_id: { label: 'Account Owner' }, }, }, @@ -30,10 +28,9 @@ export const CrmTranslationBundle = defineTranslationBundle({ fields: { first_name: { label: 'First Name' }, last_name: { label: 'Last Name' }, + full_name: { label: 'Full Name' }, email: { label: 'Email' }, - phone: { label: 'Phone' }, - title: { label: 'Job Title' }, - account_id: { label: 'Account' }, + account: { label: 'Account' }, }, }, crm_opportunity: { @@ -48,17 +45,12 @@ export const CrmTranslationBundle = defineTranslationBundle({ discount_percent: { label: 'Discount (%)' }, owner_id: { label: 'Owner' }, }, - _sections: { - deal_info: { label: 'Deal Information' }, - finance: { label: 'Financial Details' }, - }, }, crm_lead: { label: 'Lead', pluralLabel: 'Leads', fields: { - first_name: { label: 'First Name' }, - last_name: { label: 'Last Name' }, + name: { label: 'Lead Name' }, email: { label: 'Email' }, company: { label: 'Company' }, status: { label: 'Status' }, @@ -74,18 +66,18 @@ export const CrmTranslationBundle = defineTranslationBundle({ type: { label: 'Activity Type' }, status: { label: 'Status' }, due_date: { label: 'Due Date' }, - contact_id: { label: 'Contact' }, - opportunity_id: { label: 'Opportunity' }, + contact: { label: 'Contact' }, + opportunity: { label: 'Opportunity' }, }, }, }, apps: { - crm: { + crm_app: { label: 'CRM', description: 'Customer Relationship Management', navigation: { - sales: { label: 'Sales' }, - admin: { label: 'Administration' }, + group_sales: { label: 'Sales' }, + group_analytics: { label: 'Analytics' }, }, }, }, @@ -108,8 +100,6 @@ export const CrmTranslationBundle = defineTranslationBundle({ industry: { label: '行业' }, annual_revenue: { label: '年营收' }, website: { label: '官网' }, - phone: { label: '电话' }, - billing_address: { label: '账单地址' }, owner_id: { label: '负责人' }, }, }, @@ -119,10 +109,9 @@ export const CrmTranslationBundle = defineTranslationBundle({ fields: { first_name: { label: '名' }, last_name: { label: '姓' }, + full_name: { label: '姓名' }, email: { label: '邮箱' }, - phone: { label: '电话' }, - title: { label: '职位' }, - account_id: { label: '所属客户' }, + account: { label: '所属客户' }, }, }, crm_opportunity: { @@ -137,17 +126,12 @@ export const CrmTranslationBundle = defineTranslationBundle({ discount_percent: { label: '折扣 (%)' }, owner_id: { label: '负责人' }, }, - _sections: { - deal_info: { label: '商机信息' }, - finance: { label: '财务明细' }, - }, }, crm_lead: { label: '线索', pluralLabel: '线索列表', fields: { - first_name: { label: '名' }, - last_name: { label: '姓' }, + name: { label: '线索名称' }, email: { label: '邮箱' }, company: { label: '公司' }, status: { label: '状态' }, @@ -163,18 +147,18 @@ export const CrmTranslationBundle = defineTranslationBundle({ type: { label: '活动类型' }, status: { label: '状态' }, due_date: { label: '截止日期' }, - contact_id: { label: '联系人' }, - opportunity_id: { label: '商机' }, + contact: { label: '联系人' }, + opportunity: { label: '商机' }, }, }, }, apps: { - crm: { + crm_app: { label: '客户管理', description: '客户关系管理系统', navigation: { - sales: { label: '销售管理' }, - admin: { label: '系统管理' }, + group_sales: { label: '销售管理' }, + group_analytics: { label: '数据分析' }, }, }, }, diff --git a/examples/app-todo/src/translations/en.ts b/examples/app-todo/src/translations/en.ts index ff6e98c38d..3750764219 100644 --- a/examples/app-todo/src/translations/en.ts +++ b/examples/app-todo/src/translations/en.ts @@ -10,7 +10,7 @@ import type { TranslationData } from '@objectstack/spec/system'; */ export const en: TranslationData = { objects: { - task: { + todo_task: { label: 'Task', pluralLabel: 'Tasks', fields: { diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index dab4d8b7f6..ca5a06d59f 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -9,7 +9,7 @@ import type { TranslationData } from '@objectstack/spec/system'; */ export const jaJP: TranslationData = { objects: { - task: { + todo_task: { label: 'タスク', pluralLabel: 'タスク', fields: { diff --git a/examples/app-todo/src/translations/translation-completeness.test.ts b/examples/app-todo/src/translations/translation-completeness.test.ts index b24959fcfe..3947ea1b54 100644 --- a/examples/app-todo/src/translations/translation-completeness.test.ts +++ b/examples/app-todo/src/translations/translation-completeness.test.ts @@ -12,8 +12,15 @@ import type { TranslationData } from '@objectstack/spec/system'; * * Validates that every field and every select option in the Task object * definition has a corresponding translation in each locale. + * + * The object key comes from `Task.name`, not a literal: this suite used to + * hard-code `objects.task` while the object is `todo_task`, so it stayed green + * against a bundle the resolver could never find — a completeness test that + * agreed with the bundle instead of with the metadata (issue #3583). */ +const objectName = Task.name; + const fieldNames = Object.keys(Task.fields); const selectFields = Object.entries(Task.fields) @@ -31,14 +38,14 @@ describe.each([ ['ja-JP', jaJP], ] as [string, TranslationData][])('%s translation completeness', (locale, t) => { - it('should have task object translation', () => { - expect(t.objects?.task).toBeDefined(); - expect(t.objects?.task?.label).toBeTruthy(); + it(`should have "${objectName}" object translation`, () => { + expect(t.objects?.[objectName]).toBeDefined(); + expect(t.objects?.[objectName]?.label).toBeTruthy(); }); it.each(fieldNames)('field: %s', (name) => { expect( - t.objects?.task?.fields?.[name]?.label, + t.objects?.[objectName]?.fields?.[name]?.label, `[${locale}] Missing label for field "${name}"`, ).toBeTruthy(); }); @@ -46,7 +53,7 @@ describe.each([ it.each(selectFields)('options: $name', ({ name, values }) => { for (const v of values) { expect( - t.objects?.task?.fields?.[name]?.options?.[v], + t.objects?.[objectName]?.fields?.[name]?.options?.[v], `[${locale}] Missing option "${v}" for field "${name}"`, ).toBeTruthy(); } diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 44fd647b65..913bb18622 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -13,7 +13,7 @@ type TaskTranslation = StrictObjectTranslation; */ export const zhCN: TranslationData = { objects: { - task: { + todo_task: { label: '任务', pluralLabel: '任务', fields: { diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 8abd41f606..f6373a702f 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -16,6 +16,7 @@ import { validateFilterTokens } from '@objectstack/lint'; import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint'; import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint'; import { validateNavAccess } from '@objectstack/lint'; +import { validateTranslationReferences } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; import { validateReadonlyFlowWrites } from '@objectstack/lint'; @@ -336,7 +337,10 @@ export default class Compile extends Command { // All plain strings in the schema, so a name resolving to nothing // ships and fails silently. Errors fail the build; the // platform-prefixed-but-unregistered case is advisory (a third-party - // package may still provide it). + // package may still provide it). Translation bundles are checked in + // the reverse direction (keys naming metadata that does not exist, + // option keys written as the display label) — advisory throughout, + // since an orphan key is inert rather than broken. if (!flags.json) printStep('Checking object & action references (#3583)...'); const refFindings = [ ...validateObjectReferences(result.data as Record), @@ -344,6 +348,7 @@ export default class Compile extends Command { ...validatePageFieldBindings(result.data as Record), ...validateChartBindings(result.data as Record), ...validateNavAccess(result.data as Record), + ...validateTranslationReferences(result.data as Record), ]; const refErrors = refFindings.filter((f) => f.severity === 'error'); const refWarnings = refFindings.filter((f) => f.severity === 'warning'); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index e6dc27bb22..bb7d4014b8 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -13,6 +13,7 @@ import { validateRecordTitle, validateSemanticRoles, validateCapabilityReference import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint'; import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint'; import { validateNavAccess } from '@objectstack/lint'; +import { validateTranslationReferences } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -569,6 +570,22 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Translation-bundle references & option keys (issue #3583) ── + // The i18n gate only ever asked "which expected keys are missing?". This is + // the reverse the spec already names (`TranslationDiffStatus 'redundant'`): + // keys pointing at metadata that no longer exists, and select-option keys + // written as the display label instead of the stored value. Advisory — an + // orphan key is inert, it just leaves one string untranslated. + for (const t of validateTranslationReferences(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 6aeac8e226..b5e40a38bd 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -17,6 +17,7 @@ import { validateFilterTokens } from '@objectstack/lint'; import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint'; import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint'; import { validateNavAccess } from '@objectstack/lint'; +import { validateTranslationReferences } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; import { validateCapabilityReferences } from '@objectstack/lint'; @@ -303,6 +304,11 @@ export default class Validate extends Command { // parses, ships, and fails silently at runtime. An unprefixed miss is // a typo (error); a platform-prefixed name no known package registers // is advisory (a third-party package may still provide it). + // Translation bundles get the same treatment in reverse: a key naming + // a field/view/action/section that no longer exists — or an option + // keyed by its display label instead of its stored value — resolves + // to nothing and renders the source string (advisory: inert, not + // broken). if (!flags.json) printStep('Checking object & action references (#3583)...'); const refFindings = [ ...validateObjectReferences(result.data as Record), @@ -310,6 +316,7 @@ export default class Validate extends Command { ...validatePageFieldBindings(result.data as Record), ...validateChartBindings(result.data as Record), ...validateNavAccess(result.data as Record), + ...validateTranslationReferences(result.data as Record), ]; const refErrors = refFindings.filter((f) => f.severity === 'error'); const refWarnings = refFindings.filter((f) => f.severity === 'warning'); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index fff95f4495..ceb547e9b8 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -211,4 +211,14 @@ export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart export { validateNavAccess, NAV_OBJECT_UNGRANTED } from './validate-nav-access.js'; export type { NavAccessFinding, NavAccessSeverity } from './validate-nav-access.js'; +export { + validateTranslationReferences, + TRANSLATION_TARGET_UNKNOWN, + TRANSLATION_OPTION_KEY_UNKNOWN, +} from './validate-translation-references.js'; +export type { + TranslationRefFinding, + TranslationRefSeverity, +} from './validate-translation-references.js'; + export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js'; diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts new file mode 100644 index 0000000000..6e0412aa4b --- /dev/null +++ b/packages/lint/src/validate-translation-references.test.ts @@ -0,0 +1,535 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateTranslationReferences, + TRANSLATION_TARGET_UNKNOWN, + TRANSLATION_OPTION_KEY_UNKNOWN, +} from './validate-translation-references.js'; + +/** A stack shaped like the HotCRM lead surface: fields, options, a view, an action. */ +const leadStack = (translations: unknown[]) => ({ + objects: [ + { + name: 'crm_lead', + label: 'Lead', + fields: { + name: { type: 'text', label: 'Name' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'planning', label: 'Planning' }, + { value: 'working', label: 'Working' }, + ], + }, + source: { + type: 'select', + label: 'Source', + options: [ + { value: 'direct_mail', label: 'Direct Mail' }, + { value: 'web', label: 'Web' }, + ], + }, + }, + fieldGroups: [{ key: 'basics', label: 'Basics' }], + actions: [{ name: 'convert_lead', label: 'Convert', params: [{ name: 'owner', type: 'lookup' }] }], + }, + ], + views: [{ name: 'open_leads', objectName: 'crm_lead', label: 'Open Leads' }], + translations, +}); + +describe('validateTranslationReferences — orphan keys', () => { + it('flags a bundle keyed to a field the object does not declare', () => { + // The HotCRM instance: `assigned_to`, `budget`, `image_url` outlived the + // fields they were written for. + const findings = validateTranslationReferences( + leadStack([ + { + 'zh-CN': { + objects: { + crm_lead: { label: '线索', fields: { assigned_to: { label: '负责人' } } }, + }, + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_lead.fields.assigned_to'); + expect(findings[0].hint).toContain('Declared fields: name, source, status.'); + }); + + it('accepts every key that resolves — fields, options, views, actions, sections', () => { + const findings = validateTranslationReferences( + leadStack([ + { + 'zh-CN': { + objects: { + crm_lead: { + label: '线索', + fields: { + name: { label: '名称' }, + status: { label: '状态', options: { planning: '计划中', working: '进行中' } }, + }, + _views: { open_leads: { label: '未关闭线索' } }, + _actions: { convert_lead: { label: '转换', params: { owner: { label: '负责人' } } } }, + _sections: { basics: { label: '基础信息' } }, + }, + }, + }, + }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('does not flag implicit audit/system fields', () => { + const findings = validateTranslationReferences( + leadStack([ + { en: { objects: { crm_lead: { label: 'Lead', fields: { created_at: { label: 'Created' } } } } } }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('flags an unresolved view / action / section by name', () => { + const findings = validateTranslationReferences( + leadStack([ + { + en: { + objects: { + crm_lead: { + label: 'Lead', + _views: { all_leads: { label: 'All Leads' } }, + _actions: { mass_update: { label: 'Mass Update' } }, + _sections: { deal_info: { label: 'Deal Information' } }, + }, + }, + }, + }, + ]), + ); + expect(findings.map((f) => f.path)).toEqual([ + 'translations[0].en.objects.crm_lead._views.all_leads', + 'translations[0].en.objects.crm_lead._sections.deal_info', + 'translations[0].en.objects.crm_lead._actions.mass_update', + ]); + expect(findings.every((f) => f.severity === 'warning')).toBe(true); + }); + + it('flags an action parameter the action does not declare', () => { + const findings = validateTranslationReferences( + leadStack([ + { + en: { + objects: { + crm_lead: { + label: 'Lead', + _actions: { convert_lead: { label: 'Convert', params: { assignee: { label: 'Assignee' } } } }, + }, + }, + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._actions.convert_lead.params.assignee'); + expect(findings[0].hint).toContain('Declared params: owner.'); + }); +}); + +describe('validateTranslationReferences — option keys', () => { + it('flags an option key that is a near-miss of the stored value', () => { + // The HotCRM instance: `direct-mail` for the value `direct_mail`. + const findings = validateTranslationReferences( + leadStack([ + { + 'zh-CN': { + objects: { + crm_lead: { + label: '线索', + fields: { source: { label: '来源', options: { 'direct-mail': '直邮' } } }, + }, + }, + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_OPTION_KEY_UNKNOWN); + expect(findings[0].path).toBe( + 'translations[0]["zh-CN"].objects.crm_lead.fields.source.options.direct-mail', + ); + expect(findings[0].message).toContain('Did you mean "direct_mail"?'); + expect(findings[0].hint).toContain('Declared values: direct_mail, web.'); + }); + + it('flags an option key that is a value from a different vocabulary', () => { + // The other HotCRM instance: `planned` where the field's value is `planning`. + const findings = validateTranslationReferences( + leadStack([ + { + 'zh-CN': { + objects: { + crm_lead: { label: '线索', fields: { status: { label: '状态', options: { planned: '计划中' } } } }, + }, + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_OPTION_KEY_UNKNOWN); + expect(findings[0].hint).toContain('Declared values: planning, working.'); + }); + + it('names the value when the key is the display label', () => { + const findings = validateTranslationReferences( + leadStack([ + { + 'zh-CN': { + objects: { + crm_lead: { label: '线索', fields: { source: { label: '来源', options: { 'Direct Mail': '直邮' } } } }, + }, + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('keyed by the DISPLAY LABEL'); + expect(findings[0].hint).toBe('Rename the key to "direct_mail".'); + }); + + it('flags an option map on a field that declares no options', () => { + const findings = validateTranslationReferences( + leadStack([ + { + en: { + objects: { + crm_lead: { label: 'Lead', fields: { name: { label: 'Name', options: { a: 'A' } } } }, + }, + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_OPTION_KEY_UNKNOWN); + expect(findings[0].message).toContain('declares no `options` at all'); + }); + + it('accepts the legacy option shapes the extractor also tolerates', () => { + const stack = { + objects: [ + { + name: 'crm_lead', + fields: { + record_map: { type: 'select', options: { open: 'Open', closed: 'Closed' } }, + bare_values: { type: 'select', options: ['open', 'closed'] }, + }, + }, + ], + translations: [ + { + en: { + objects: { + crm_lead: { + label: 'Lead', + fields: { + record_map: { options: { open: 'Open' } }, + bare_values: { options: { closed: 'Closed' } }, + }, + }, + }, + }, + }, + ], + }; + expect(validateTranslationReferences(stack)).toEqual([]); + }); +}); + +describe('validateTranslationReferences — cross-package objects (§4 ladder)', () => { + const bundleFor = (objectName: string) => [ + { + 'zh-CN': { + objects: { + [objectName]: { label: '用户', fields: { some_field_we_cannot_see: { label: '字段' } } }, + }, + }, + }, + ]; + + it('skips a registered platform object wholly — its fields are not visible from here', () => { + expect( + validateTranslationReferences({ objects: [], translations: bundleFor('sys_user') }), + ).toEqual([]); + }); + + it('warns on a platform-prefixed name no package registers', () => { + const findings = validateTranslationReferences({ + objects: [], + translations: bundleFor('sys_approval_process'), + }); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].message).toContain('platform namespace'); + // The object key is reported once; its subtree is not half-checked. + expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.sys_approval_process'); + }); + + it('warns once on an unprefixed unknown object, without walking its subtree', () => { + const findings = validateTranslationReferences({ + objects: [{ name: 'todo_task', fields: { title: { type: 'text' } } }], + translations: bundleFor('task'), + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.task'); + expect(findings[0].message).toContain('Did you mean "todo_task"?'); + }); +}); + +describe('validateTranslationReferences — apps, dashboards, global actions', () => { + const stack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + actions: [ + { name: 'export_csv', label: 'Export' }, + { name: 'convert_lead', label: 'Convert', objectName: 'crm_lead' }, + ], + apps: [ + { + name: 'crm_app', + navigation: [ + { id: 'group_sales', type: 'group', children: [{ id: 'nav_leads', type: 'object', objectName: 'crm_lead' }] }, + ], + }, + ], + dashboards: [ + { + name: 'pipeline_dashboard', + widgets: [{ id: 'pipeline_by_stage' }], + header: { actions: [{ label: 'Refresh', actionUrl: '/refresh' }] }, + }, + ], + }; + + it('accepts app / navigation / dashboard / widget / header-action keys that resolve', () => { + const findings = validateTranslationReferences({ + ...stack, + translations: [ + { + en: { + apps: { crm_app: { label: 'CRM', navigation: { group_sales: { label: 'Sales' } } } }, + dashboards: { + pipeline_dashboard: { + label: 'Pipeline', + widgets: { pipeline_by_stage: { title: 'By Stage' } }, + actions: { '/refresh': { label: 'Refresh' } }, + }, + }, + globalActions: { export_csv: { label: 'Export' } }, + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('flags an app name, a navigation id, and a widget id that do not resolve', () => { + const findings = validateTranslationReferences({ + ...stack, + translations: [ + { + en: { + apps: { crm: { label: 'CRM' } }, + dashboards: { pipeline_dashboard: { widgets: { revenue_gauge: { title: 'Revenue' } } } }, + }, + }, + ], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'translations[0].en.apps.crm', + 'translations[0].en.dashboards.pipeline_dashboard.widgets.revenue_gauge', + ]); + expect(findings[0].message).toContain('Did you mean "crm_app"?'); + }); + + it('tells an object-bound action filed under globalActions where it belongs', () => { + const findings = validateTranslationReferences({ + ...stack, + translations: [{ en: { globalActions: { convert_lead: { label: 'Convert' } } } }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('objects.crm_lead._actions.convert_lead'); + expect(findings[0].hint).toContain('Move these keys under'); + }); +}); + +describe('validateTranslationReferences — namespaces deliberately not judged', () => { + it('ignores messages, validationMessages, settings, metadataForms and settingsCommon', () => { + const findings = validateTranslationReferences({ + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + translations: [ + { + en: { + messages: { 'crm.lead.convert.success': 'Converted.' }, + validationMessages: { discount_limit: 'Too much.' }, + settings: { mail: { title: 'Mail', keys: { from: { label: 'From' } } } }, + metadataForms: { object: { label: 'Object' } }, + settingsCommon: { sourceLabels: { env: 'Environment' } }, + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('ignores an object-first bundle shape rather than reporting its keys', () => { + // `o.` is the `translation` METADATA TYPE, not `stack.translations`. + const findings = validateTranslationReferences({ + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + translations: [{ o: { crm_ghost: { label: 'Ghost' } }, _globalOptions: { currency: { usd: 'USD' } } }], + }); + expect(findings).toEqual([]); + }); + + it('returns nothing for a stack with no translations at all', () => { + expect(validateTranslationReferences({ objects: [{ name: 'crm_lead' }] })).toEqual([]); + expect(validateTranslationReferences({})).toEqual([]); + expect(validateTranslationReferences(null as unknown as Record)).toEqual([]); + }); +}); + +describe('validateTranslationReferences — the canonical view-record shape', () => { + // Reduced from HotCRM: a view record is a CONTAINER whose object binding + // lives inside `list.data.object`, with the named tabs under `listViews`. + // Reading `view.name` / `view.data.object` at the record root resolves + // nothing here, drops the record, and reports every view key the app ships — + // ~40 correct keys on the real corpus. + const leadViews = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + list: { + type: 'grid', + name: 'all_leads', + data: { provider: 'object', object: 'crm_lead' }, + }, + listViews: { + my_leads: { name: 'my_leads', type: 'grid', data: { provider: 'object', object: 'crm_lead' } }, + kanban_by_status: { name: 'kanban_by_status', type: 'kanban' }, + }, + formViews: { + default: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [{ name: 'contact_info', label: 'Contact Info' }], + }, + }, + }, + ], + }; + + it('resolves the default list, every named tab, and a form section', () => { + const findings = validateTranslationReferences({ + ...leadViews, + translations: [ + { + en: { + objects: { + crm_lead: { + label: 'Lead', + _views: { + all_leads: { label: 'All Leads' }, + my_leads: { label: 'My Leads' }, + kanban_by_status: { label: 'By Status' }, + default: { label: 'Default Form' }, + }, + _sections: { contact_info: { label: 'Contact' } }, + }, + }, + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('still flags a view key no container declares', () => { + const findings = validateTranslationReferences({ + ...leadViews, + translations: [ + { en: { objects: { crm_lead: { label: 'Lead', _views: { hot_leads: { label: 'Hot' } } } } } }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.hot_leads'); + expect(findings[0].hint).toContain('all_leads'); + }); + + it('resolves views embedded on the object itself', () => { + const findings = validateTranslationReferences({ + objects: [ + { + name: 'crm_lead', + fields: { name: { type: 'text' } }, + listViews: { recent: { name: 'recent', type: 'grid' } }, + }, + ], + translations: [{ en: { objects: { crm_lead: { label: 'Lead', _views: { recent: { label: 'Recent' } } } } } }], + }); + expect(findings).toEqual([]); + }); +}); + +describe('validateTranslationReferences — section anchors', () => { + it('resolves a section named on a form view or a record:details page', () => { + const stack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + name: 'lead_form', + objectName: 'crm_lead', + formViews: { default: { sections: [{ name: 'contact_info', label: 'Contact Info' }] } }, + }, + ], + pages: [ + { + name: 'lead_detail', + object: 'crm_lead', + regions: [ + { + components: [ + { type: 'record:details', properties: { sections: [{ name: 'timeline', label: 'Timeline' }] } }, + ], + }, + ], + }, + ], + translations: [ + { + en: { + objects: { + crm_lead: { + label: 'Lead', + _sections: { contact_info: { label: 'Contact' }, timeline: { label: 'Timeline' } }, + }, + }, + }, + }, + ], + }; + expect(validateTranslationReferences(stack)).toEqual([]); + }); + + it('explains that an unnamed section cannot be translated at all', () => { + const findings = validateTranslationReferences({ + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [{ name: 'lead_form', objectName: 'crm_lead', formViews: { default: { sections: [{ label: 'Deal' }] } } }], + translations: [{ en: { objects: { crm_lead: { label: 'Lead', _sections: { deal_info: { label: 'Deal' } } } } } }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('declares no named section at all'); + }); +}); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts new file mode 100644 index 0000000000..2433f9f1ed --- /dev/null +++ b/packages/lint/src/validate-translation-references.ts @@ -0,0 +1,796 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0072 — reference resolvability] Translation-bundle reference integrity + * and option-key validation (issue #3583, assessment R6). + * + * The i18n gate has always run in ONE direction: `computeI18nCoverage` asks + * "which keys does the metadata expect that no bundle carries?" Nothing asks the + * reverse — "which keys does a bundle carry that no metadata claims?" — even + * though the spec already names the answer: `TranslationDiffStatus 'redundant'` + * and `TranslationCoverageResult.redundantKeys` are declared and have no + * producer. + * + * The HotCRM audit found that direction shipping broken metadata: + * + * - bundles keyed to fields the object never declares (`assigned_to`, + * `budget`, `image_url`) — usually a rename that moved the field and left + * the translation behind; + * - select-option translations keyed by the option's DISPLAY LABEL instead of + * its stored value, or by a near-miss of the value (`direct-mail` for + * `direct_mail`, `planned` for `planning`). + * + * Both fail the same way: the resolver looks up the key it derives from the + * metadata, finds nothing, and renders the untranslated source string. The app + * looks *translated* — every other label on the screen resolves — so the hole is + * invisible until a reader in that locale hits the one field or one picklist + * value that stayed English. + * + * ── Severity ───────────────────────────────────────────────────────────── + * + * All findings are **warnings**. An orphan key is inert, not broken: it costs a + * few bytes and one untranslated string, and nothing crashes. That is a weaker + * failure than the dead references `validate-object-references` / + * `validate-action-name-refs` report as errors, and the severity should say so + * (ADR-0072 D1 — a linter that over-states is a linter authors stop reading). + * + * ── What this rule deliberately does NOT check ─────────────────────────── + * + * - `messages`, `validationMessages`, `settings`, `settingsCommon` — keyed by + * free-form message ids / namespaces owned by code and plugins, not by + * stack metadata. There is no enumerable universe to resolve against, so a + * rule here would be guessing. + * - `metadataForms` — keyed by the platform's own metadata-type registry, not + * by this stack. Owned by the platform packages; a stack translating them is + * correct, not orphaned. + * - leaf attribute names (`labl:` instead of `label:`) — Zod strips unknown + * keys at parse, so they never reach a consumer with a value; that is a + * schema-shape concern, not a reference. + * - the object-first `AppTranslationBundle` (`o.` …) — that shape is + * the `translation` METADATA TYPE (records persisted through the metadata + * store), not `stack.translations`, which is `TranslationBundle[]` + * (locale → `TranslationData`). Its keys are simply not visited here: an + * unrecognised top-level namespace is skipped, never reported. + * + * ── Cross-package objects ──────────────────────────────────────────────── + * + * A stack legitimately translates objects it does not define — `sys_user`'s + * labels are exactly the kind of thing an app localizes. Resolution follows the + * §4 ladder of the assessment, with the field-level S3 rule intact: + * + * 1. own object → check its fields/views/actions/sections + * 2. platform object in the registry → skip WHOLLY (we cannot see its + * fields, so we cannot judge them) + * 3. platform-prefixed, not in registry → warn on the object key only + * 4. unresolved, unprefixed → warn on the object key only + */ + +import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from '@objectstack/spec/system'; +import { walkPageComponents } from './page-walk.js'; + +export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown'; +export const TRANSLATION_OPTION_KEY_UNKNOWN = 'translation-option-key-unknown'; + +export type TranslationRefSeverity = 'warning'; + +export interface TranslationRefFinding { + /** Always `warning` — an orphan translation key is inert, not broken. */ + severity: TranslationRefSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `locale "zh-CN" · object "crm_lead"`. */ + where: string; + /** Config path, e.g. `translations[0]["zh-CN"].objects.crm_lead.fields.campaign`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** Coerce a collection (array or name-keyed map) to an array of records, + * injecting `name` from the map key — mirrors the sibling authoring lints so + * the rule works on both the parsed (array) and normalized (map) stack shapes. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (isRec(v)) return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); + return []; +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function distance(a: string, b: string): number { + const m = a.length; + const n = b.length; + if (m === 0) return n; + if (n === 0) return m; + let prev = Array.from({ length: n + 1 }, (_, j) => j); + for (let i = 1; i <= m; i++) { + const curr = [i, ...new Array(n).fill(0)]; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); + } + prev = curr; + } + return prev[n]; +} + +/** + * "Did you mean?" over the known names — Levenshtein-bounded, plus a namespace + * pass the distance metric cannot see. + * + * A stack prefixes its object names (`todo_task`, `crm_lead`), and the orphan + * key is routinely the bare noun the author had in mind (`task`). That is 5 + * edits from `todo_task` — far outside the typo bound, and exactly the case + * where the suggestion is most useful — so a candidate that differs only by a + * snake_case namespace segment is offered before falling back to edit distance. + */ +function suggest(target: string, known: Iterable): string { + const names = [...known]; + const segmentMatch = names.find( + (candidate) => candidate.endsWith(`_${target}`) || candidate.startsWith(`${target}_`), + ); + if (segmentMatch) return ` Did you mean "${segmentMatch}"?`; + + let best: string | undefined; + let bestScore = Infinity; + for (const candidate of names) { + const d = distance(target, candidate); + if (d < bestScore) { + bestScore = d; + best = candidate; + } + } + const limit = Math.max(2, Math.floor(target.length / 3)); + return best && bestScore <= limit ? ` Did you mean "${best}"?` : ''; +} + +/** At most `max` names, sorted, for the "known values are …" tail of a hint. */ +function listNames(names: Iterable, max = 12): string { + const all = [...names].sort(); + if (all.length === 0) return ''; + const shown = all.slice(0, max).join(', '); + return all.length > max ? `${shown}, … (${all.length} total)` : shown; +} + +/** + * Audit/system fields every object carries implicitly. A bundle translating + * them is authoring against a real field, so they must not read as orphans. + * Mirrors `validate-page-field-bindings`' list. + */ +const SYSTEM_FIELDS: ReadonlySet = new Set([ + 'id', '_id', 'name', + 'created_at', 'created_by', 'updated_at', 'updated_by', + 'owner_id', 'organization_id', 'tenant_id', 'user_id', + 'is_deleted', 'deleted_at', 'space', +]); + +/** Everything a bundle may legally name under one object. */ +interface ObjectFacts { + fields: Map; + views: Set; + actions: Map; + sections: Set; +} + +interface Universe { + objects: Map; + /** App name → every navigation item id declared by that app. */ + apps: Map>; + dashboards: Map; actions: Set }>; + /** Object-less actions — the ones `globalActions.*` may name. */ + globalActions: Map; + /** Action name → owning object, so a misfiled `globalActions` key can say where it belongs. */ + actionOwners: Map; +} + +function emptyFacts(): ObjectFacts { + return { fields: new Map(), views: new Set(), actions: new Map(), sections: new Set() }; +} + +/** + * Register everything ONE view record contributes: the `_views` names it makes + * legal, and the `_sections` names its form views declare — each under the + * object that container actually binds. + * + * Two things about the real shape make this more than "read `view.name`", and + * both were learned from the HotCRM corpus (~18k lines of shipped metadata), + * where a first pass reported ~40 correct keys as orphans: + * + * 1. A view record is a CONTAINER, not a view. The default list sits at + * `list`; the named tabs at `listViews.` and `formViews.`, each + * of which may also carry its own `name`. Both the map key and the inner + * `name` are accepted — authors write either, and the key is what the + * console renders the tab from. + * 2. The object binding lives INSIDE the container (`list.data.object`), not + * at the record root. A record-level lookup alone resolves to nothing on + * the canonical shape, which silently drops the whole record — a rule that + * then reports every view key the app ships. + */ +function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => ObjectFacts): void { + const recordObject = viewObjectName(view); + const bindingOf = (container: AnyRec): string | undefined => + viewObjectName(container) ?? recordObject; + + const addView = (objectName: string | undefined, name: string | undefined) => { + if (objectName && name) factsFor(objectName).views.add(name); + }; + + const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined; + if (isRec(view.list)) addView(listBinding, strName(view.list.name)); + addView(recordObject ?? listBinding, strName(view.name)); + + for (const key of ['listViews', 'formViews'] as const) { + const container = view[key]; + if (!isRec(container)) continue; + for (const [subKey, sub] of Object.entries(container)) { + if (!isRec(sub)) continue; + const binding = bindingOf(sub) ?? listBinding; + addView(binding, subKey); + addView(binding, strName(sub.name)); + + // Form sections carry an OPTIONAL `name` that exists purely for the + // `_sections` lookup (`ui/view.zod.ts`: "Stable section identifier for + // i18n lookup"). A section without one cannot be translated at all, so + // it contributes nothing here. + if (binding) { + for (const section of asArray(sub.sections)) { + const sectionName = strName(section.name); + if (sectionName) factsFor(binding).sections.add(sectionName); + } + } + } + } + + const sectionBinding = recordObject ?? listBinding; + if (sectionBinding) { + for (const section of asArray(view.sections)) { + const sectionName = strName(section.name); + if (sectionName) factsFor(sectionBinding).sections.add(sectionName); + } + } +} + +/** The object a view (or one of its containers) binds to, across the shapes it is authored in. */ +function viewObjectName(view: AnyRec): string | undefined { + return ( + strName(view.objectName) ?? + strName(view.object) ?? + (isRec(view.data) ? strName(view.data.object) : undefined) + ); +} + +/** + * Declared select options for a field, or `undefined` when the field declares + * none at all. Handles the canonical `{value,label}[]` shape plus the two + * legacy shapes the extractor also tolerates (bare `string[]`, and a + * `value → label` record). + */ +function readOptions(field: AnyRec): { values: Set; byLabel: Map } | undefined { + const raw = field.options; + const values = new Set(); + const byLabel = new Map(); + if (Array.isArray(raw)) { + for (const opt of raw) { + if (typeof opt === 'string') { + values.add(opt); + continue; + } + if (!isRec(opt)) continue; + const value = strName(opt.value); + if (!value) continue; + values.add(value); + const label = strName(opt.label); + if (label) byLabel.set(label.toLowerCase(), value); + } + } else if (isRec(raw)) { + for (const [value, label] of Object.entries(raw)) { + values.add(value); + if (typeof label === 'string' && label.length > 0) byLabel.set(label.toLowerCase(), value); + } + } else { + return undefined; + } + return values.size > 0 ? { values, byLabel } : undefined; +} + +/** + * Collect every name a translation bundle may resolve against. Built once per + * run: the same universe answers all bundles and all locales. + */ +function buildUniverse(stack: AnyRec): Universe { + const objects = new Map(); + const factsFor = (name: string): ObjectFacts => { + let facts = objects.get(name); + if (!facts) { + facts = emptyFacts(); + objects.set(name, facts); + } + return facts; + }; + + // ── Objects: fields, embedded actions/views, fieldGroups (the `_sections` anchor) ── + for (const obj of asArray(stack.objects)) { + const objectName = strName(obj.name); + if (!objectName) continue; + const facts = factsFor(objectName); + + for (const field of asArray(obj.fields)) { + const fieldName = strName(field.name); + if (fieldName) facts.fields.set(fieldName, field); + } + for (const action of asArray(obj.actions)) { + const actionName = strName(action.name); + if (actionName) facts.actions.set(actionName, action); + } + // An object can carry views directly, including the `objects[].listViews` + // container the chart rule also walks. `{ ...view, object: objectName }` + // pins the binding: an embedded view inherits its owner, and nothing here + // depends on the container repeating it. + for (const view of asArray(obj.views)) { + collectViewRecord({ ...view, object: strName(view.object) ?? objectName }, factsFor); + } + collectViewRecord({ object: objectName, listViews: obj.listViews }, factsFor); + // ADR-0085: `fieldGroups[].key` is the i18n anchor for `_sections`. + for (const group of asArray(obj.fieldGroups)) { + const key = strName(group.key) ?? strName(group.name); + if (key) facts.sections.add(key); + } + } + + // ── Stack-level views: `_views` names + form-section names ── + for (const view of asArray(stack.views)) { + collectViewRecord(view, factsFor); + } + + // ── Pages: `record:details` sections are the other `_sections` anchor ── + const pages = asArray(stack.pages); + for (let pi = 0; pi < pages.length; pi++) { + for (const walked of walkPageComponents(pages[pi], `pages[${pi}]`)) { + if (!walked.objectName) continue; + const props = isRec(walked.component.properties) ? walked.component.properties : undefined; + if (!props) continue; + for (const section of asArray(props.sections)) { + const sectionName = strName(section.name); + if (sectionName) factsFor(walked.objectName).sections.add(sectionName); + } + } + } + + // ── Actions: object-bound ones join their object; the rest are global ── + const globalActions = new Map(); + const actionOwners = new Map(); + for (const action of asArray(stack.actions)) { + const actionName = strName(action.name); + if (!actionName) continue; + const owner = strName(action.objectName) ?? strName(action.object); + if (owner) { + factsFor(owner).actions.set(actionName, action); + actionOwners.set(actionName, owner); + } else { + globalActions.set(actionName, action); + } + } + for (const [objectName, facts] of objects) { + for (const actionName of facts.actions.keys()) { + if (!actionOwners.has(actionName)) actionOwners.set(actionName, objectName); + } + } + + // ── Apps: navigation item ids (`apps..navigation..label`) ── + const apps = new Map>(); + for (const app of asArray(stack.apps)) { + const appName = strName(app.name); + if (!appName) continue; + const navIds = apps.get(appName) ?? new Set(); + const walkNav = (items: unknown) => { + for (const item of asArray(items)) { + const id = strName(item.id); + if (id) navIds.add(id); + if (item.children) walkNav(item.children); + } + }; + walkNav(app.navigation); + for (const area of asArray(app.areas)) { + const areaId = strName(area.id); + if (areaId) navIds.add(areaId); + walkNav(area.navigation); + } + apps.set(appName, navIds); + } + + // ── Dashboards: widget ids + header action urls ── + const dashboards = new Map; actions: Set }>(); + for (const dash of asArray(stack.dashboards)) { + const dashName = strName(dash.name); + if (!dashName) continue; + const widgets = new Set(); + for (const widget of asArray(dash.widgets)) { + const id = strName(widget.id) ?? strName(widget.name); + if (id) widgets.add(id); + } + const actions = new Set(); + const headerActions = [ + ...asArray(isRec(dash.header) ? dash.header.actions : undefined), + ...asArray(dash.actions), + ]; + for (const action of headerActions) { + const key = strName(action.actionUrl) ?? strName(action.url) ?? strName(action.name); + if (key) actions.add(key); + } + dashboards.set(dashName, { widgets, actions }); + } + + return { objects, apps, dashboards, globalActions, actionOwners }; +} + +/** Quote a locale for the config path — BCP-47 tags carry `-`. */ +function localePath(bundleIndex: number, locale: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(locale) + ? `translations[${bundleIndex}].${locale}` + : `translations[${bundleIndex}]["${locale}"]`; +} + +/** + * Validate every reference a translation bundle makes against the metadata it + * claims to translate. Returns findings (empty = clean). + */ +export function validateTranslationReferences(stack: AnyRec): TranslationRefFinding[] { + const findings: TranslationRefFinding[] = []; + if (!isRec(stack)) return findings; + + const bundles = Array.isArray(stack.translations) ? stack.translations : []; + if (bundles.length === 0) return findings; + + const universe = buildUniverse(stack); + + const orphan = (where: string, path: string, message: string, hint: string) => { + findings.push({ severity: 'warning', rule: TRANSLATION_TARGET_UNKNOWN, where, path, message, hint }); + }; + + for (let bi = 0; bi < bundles.length; bi++) { + const bundle = bundles[bi]; + if (!isRec(bundle)) continue; + + for (const [locale, rawData] of Object.entries(bundle)) { + if (!isRec(rawData)) continue; + const base = localePath(bi, locale); + const inLocale = `locale "${locale}"`; + + // ── objects..… ────────────────────────────────────────────── + for (const [objectName, rawNode] of Object.entries(asRecord(rawData.objects))) { + if (!isRec(rawNode)) continue; + const objPath = `${base}.objects.${objectName}`; + const facts = universe.objects.get(objectName); + + if (!facts) { + // Not this stack's object. Platform objects are translated by their + // owning package but a stack may override them, so a registered + // platform name is legitimate — and unreadable from here, which is + // why the whole subtree is skipped rather than half-checked. + if (isPlatformProvidedObjectName(objectName)) continue; + orphan( + `${inLocale} · object "${objectName}"`, + objPath, + hasPlatformObjectPrefix(objectName) + ? `Translations are keyed to "${objectName}", which carries a platform namespace ` + + `prefix but is registered by no platform package, official plugin, or cloud ` + + `runtime object — and this stack does not define it either. Nothing resolves ` + + `these keys.` + suggest(objectName, universe.objects.keys()) + : `Translations are keyed to "${objectName}", which no object in this stack ` + + `defines. The resolver looks up keys derived from the metadata, so this whole ` + + `subtree is dead weight — every label it carries renders untranslated.` + + suggest(objectName, universe.objects.keys()), + `Rename the key to the object it was written for, drop it, or ignore this if the ` + + `object is contributed by another installed package.` + + (universe.objects.size > 0 ? ` Defined objects: ${listNames(universe.objects.keys())}.` : ''), + ); + continue; + } + + // fields.[.options.] + for (const [fieldName, rawField] of Object.entries(asRecord(rawNode.fields))) { + const fieldPath = `${objPath}.fields.${fieldName}`; + const field = facts.fields.get(fieldName); + if (!field) { + if (SYSTEM_FIELDS.has(fieldName)) continue; + orphan( + `${inLocale} · object "${objectName}" · field "${fieldName}"`, + fieldPath, + `Translations are keyed to field "${fieldName}", which object "${objectName}" ` + + `does not declare. The label renders untranslated in this locale — and because ` + + `every neighbouring field DOES resolve, the hole reads as a styling quirk ` + + `rather than a missing translation.` + suggest(fieldName, facts.fields.keys()), + `Point the key at a declared field, or drop it if the field was removed or renamed.` + + (facts.fields.size > 0 ? ` Declared fields: ${listNames(facts.fields.keys())}.` : ''), + ); + continue; + } + if (!isRec(rawField)) continue; + checkOptionKeys(findings, { + optionMap: rawField.options, + field, + fieldName, + objectName, + path: `${fieldPath}.options`, + where: `${inLocale} · object "${objectName}" · field "${fieldName}"`, + }); + } + + // _views. + for (const viewName of Object.keys(asRecord(rawNode._views))) { + if (facts.views.has(viewName)) continue; + orphan( + `${inLocale} · object "${objectName}" · view "${viewName}"`, + `${objPath}._views.${viewName}`, + `Translations are keyed to view "${viewName}", which no view of object ` + + `"${objectName}" declares. The view tab keeps its source-locale label.` + + suggest(viewName, facts.views), + `Match the key to the view's \`name\` (not its label), or drop it.` + + (facts.views.size > 0 ? ` Declared views: ${listNames(facts.views)}.` : ''), + ); + } + + // _sections. + for (const sectionName of Object.keys(asRecord(rawNode._sections))) { + if (facts.sections.has(sectionName)) continue; + orphan( + `${inLocale} · object "${objectName}" · section "${sectionName}"`, + `${objPath}._sections.${sectionName}`, + `Translations are keyed to section "${sectionName}", which nothing on object ` + + `"${objectName}" declares — no \`fieldGroups[].key\`, no named form-view section, ` + + `no named \`record:details\` section. The section heading stays in the source locale.` + + suggest(sectionName, facts.sections), + `Sections are translatable only through a STABLE NAME: give the group/section a ` + + `\`key\`/\`name\` and use it here, or drop the translation.` + + (facts.sections.size > 0 + ? ` Declared sections: ${listNames(facts.sections)}.` + : ` Object "${objectName}" declares no named section at all.`), + ); + } + + // _actions.[.params.] + for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) { + const actionPath = `${objPath}._actions.${actionName}`; + const action = facts.actions.get(actionName); + if (!action) { + orphan( + `${inLocale} · object "${objectName}" · action "${actionName}"`, + actionPath, + `Translations are keyed to action "${actionName}", which is defined by neither ` + + `object "${objectName}"'s \`actions\` nor a \`stack.actions\` entry bound to it. ` + + `The button keeps its source-locale label.` + suggest(actionName, facts.actions.keys()), + `Match the key to a defined action name, move it under the object that owns the ` + + `action, or drop it.` + + (facts.actions.size > 0 ? ` Actions on this object: ${listNames(facts.actions.keys())}.` : ''), + ); + continue; + } + checkActionParams(findings, { + rawAction, + action, + path: actionPath, + where: `${inLocale} · object "${objectName}" · action "${actionName}"`, + subject: `action "${actionName}"`, + }); + } + } + + // ── globalActions. ────────────────────────────────────────── + for (const [actionName, rawAction] of Object.entries(asRecord(rawData.globalActions))) { + const actionPath = `${base}.globalActions.${actionName}`; + const action = universe.globalActions.get(actionName); + if (!action) { + const owner = universe.actionOwners.get(actionName); + orphan( + `${inLocale} · global action "${actionName}"`, + actionPath, + owner + ? `Action "${actionName}" is bound to object "${owner}", so the resolver looks it ` + + `up under \`objects.${owner}._actions.${actionName}\` — never under ` + + `\`globalActions\`, which is only consulted for object-less actions. This key ` + + `is never read.` + : `Translations are keyed to global action "${actionName}", which no object-less ` + + `action in this stack defines. The button keeps its source-locale label.` + + suggest(actionName, universe.globalActions.keys()), + owner + ? `Move these keys under \`objects.${owner}._actions.${actionName}\`.` + : `Match the key to an object-less action's name, or drop it.` + + (universe.globalActions.size > 0 + ? ` Object-less actions: ${listNames(universe.globalActions.keys())}.` + : ''), + ); + continue; + } + checkActionParams(findings, { + rawAction, + action, + path: actionPath, + where: `${inLocale} · global action "${actionName}"`, + subject: `action "${actionName}"`, + }); + } + + // ── apps.[.navigation.] ───────────────────────────────── + for (const [appName, rawApp] of Object.entries(asRecord(rawData.apps))) { + const appPath = `${base}.apps.${appName}`; + const navIds = universe.apps.get(appName); + if (!navIds) { + orphan( + `${inLocale} · app "${appName}"`, + appPath, + `Translations are keyed to app "${appName}", which this stack does not define. ` + + `The app launcher shows the source-locale label.` + suggest(appName, universe.apps.keys()), + `Match the key to an app's \`name\`, or drop it.` + + (universe.apps.size > 0 ? ` Defined apps: ${listNames(universe.apps.keys())}.` : ''), + ); + continue; + } + if (!isRec(rawApp)) continue; + for (const navId of Object.keys(asRecord(rawApp.navigation))) { + if (navIds.has(navId)) continue; + orphan( + `${inLocale} · app "${appName}" · navigation "${navId}"`, + `${appPath}.navigation.${navId}`, + `Translations are keyed to navigation item "${navId}", which app "${appName}" ` + + `does not declare. The menu entry keeps its source-locale label.` + + suggest(navId, navIds), + `Match the key to the navigation item's \`id\`, or drop it.` + + (navIds.size > 0 ? ` Declared navigation ids: ${listNames(navIds)}.` : ''), + ); + } + } + + // ── dashboards.[.widgets. | .actions.] ───────────── + for (const [dashName, rawDash] of Object.entries(asRecord(rawData.dashboards))) { + const dashPath = `${base}.dashboards.${dashName}`; + const dash = universe.dashboards.get(dashName); + if (!dash) { + orphan( + `${inLocale} · dashboard "${dashName}"`, + dashPath, + `Translations are keyed to dashboard "${dashName}", which this stack does not ` + + `define. The dashboard title stays in the source locale.` + + suggest(dashName, universe.dashboards.keys()), + `Match the key to a dashboard's \`name\`, or drop it.` + + (universe.dashboards.size > 0 ? ` Defined dashboards: ${listNames(universe.dashboards.keys())}.` : ''), + ); + continue; + } + if (!isRec(rawDash)) continue; + for (const widgetId of Object.keys(asRecord(rawDash.widgets))) { + if (dash.widgets.has(widgetId)) continue; + orphan( + `${inLocale} · dashboard "${dashName}" · widget "${widgetId}"`, + `${dashPath}.widgets.${widgetId}`, + `Translations are keyed to widget "${widgetId}", which dashboard "${dashName}" ` + + `does not declare. The widget title stays in the source locale.` + + suggest(widgetId, dash.widgets), + `Match the key to the widget's \`id\`, or drop it.` + + (dash.widgets.size > 0 ? ` Declared widget ids: ${listNames(dash.widgets)}.` : ''), + ); + } + for (const actionKey of Object.keys(asRecord(rawDash.actions))) { + if (dash.actions.has(actionKey)) continue; + orphan( + `${inLocale} · dashboard "${dashName}" · action "${actionKey}"`, + `${dashPath}.actions.${actionKey}`, + `Translations are keyed to header action "${actionKey}", which dashboard ` + + `"${dashName}" does not declare. The button keeps its source-locale label.` + + suggest(actionKey, dash.actions), + `Header-action translations are keyed by the action's \`actionUrl\`, not its label.` + + (dash.actions.size > 0 ? ` Declared header actions: ${listNames(dash.actions)}.` : ''), + ); + } + } + } + } + + return findings; +} + +/** `v` as a record of sub-nodes, or an empty record when absent/malformed. */ +function asRecord(v: unknown): Record { + return isRec(v) ? v : {}; +} + +/** + * Option translations are keyed by the option's STORED VALUE. Keying them by + * the display label — or by a near-miss of the value — is the second half of + * issue #3583's option-key class: the map parses, ships, and never resolves. + */ +function checkOptionKeys( + findings: TranslationRefFinding[], + ctx: { + optionMap: unknown; + field: AnyRec; + fieldName: string; + objectName: string; + path: string; + where: string; + }, +): void { + const optionKeys = Object.keys(asRecord(ctx.optionMap)); + if (optionKeys.length === 0) return; + + const declared = readOptions(ctx.field); + if (!declared) { + findings.push({ + severity: 'warning', + rule: TRANSLATION_OPTION_KEY_UNKNOWN, + where: ctx.where, + path: ctx.path, + message: + `Option translations are keyed under field "${ctx.fieldName}" of object ` + + `"${ctx.objectName}", which declares no \`options\` at all (field type ` + + `"${strName(ctx.field.type) ?? 'unknown'}"). Nothing reads this map.`, + hint: + `Declare the options on the field, move the translations to the field that owns ` + + `them, or drop them.`, + }); + return; + } + + for (const key of optionKeys) { + if (declared.values.has(key)) continue; + const byLabel = declared.byLabel.get(key.toLowerCase()); + findings.push({ + severity: 'warning', + rule: TRANSLATION_OPTION_KEY_UNKNOWN, + where: ctx.where, + path: `${ctx.path}.${key}`, + message: byLabel + ? `Option translation is keyed by the DISPLAY LABEL "${key}" instead of the stored ` + + `value "${byLabel}". The resolver looks the option up by value, so this entry is ` + + `never found and the option renders with its source-locale label.` + : `Option translation is keyed by "${key}", which is not one of the values declared ` + + `by field "${ctx.objectName}.${ctx.fieldName}". The option renders untranslated.` + + suggest(key, declared.values), + hint: byLabel + ? `Rename the key to "${byLabel}".` + : `Option keys are the stored \`value\`, not the label and not a variant spelling ` + + `(\`direct_mail\`, not \`direct-mail\`). Declared values: ${listNames(declared.values)}.`, + }); + } +} + +/** Action-parameter translations are keyed by the param's `name`. */ +function checkActionParams( + findings: TranslationRefFinding[], + ctx: { rawAction: unknown; action: AnyRec; path: string; where: string; subject: string }, +): void { + const rawParams = Object.keys(asRecord(isRec(ctx.rawAction) ? ctx.rawAction.params : undefined)); + if (rawParams.length === 0) return; + + const declared = new Set(); + for (const param of asArray(ctx.action.params)) { + const name = strName(param.name) ?? strName(param.field); + if (name) declared.add(name); + } + + for (const paramName of rawParams) { + if (declared.has(paramName)) continue; + findings.push({ + severity: 'warning', + rule: TRANSLATION_TARGET_UNKNOWN, + where: `${ctx.where} · param "${paramName}"`, + path: `${ctx.path}.params.${paramName}`, + message: + `Translations are keyed to parameter "${paramName}", which ${ctx.subject} does not ` + + `declare. The parameter's label and help text render untranslated in the action dialog.` + + suggest(paramName, declared), + hint: + `Match the key to a declared param \`name\`, or drop it.` + + (declared.size > 0 ? ` Declared params: ${listNames(declared)}.` : ''), + }); + } +} diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index d97f47561a..4a6ee9e498 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,7 +1,7 @@ { - "examples/app-crm/objectstack.config.ts": 97, + "examples/app-crm/objectstack.config.ts": 89, "examples/app-showcase/objectstack.config.ts": 456, - "examples/app-todo/objectstack.config.ts": 212, + "examples/app-todo/objectstack.config.ts": 120, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0, diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index d9f85faae2..add6fc6aad 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -288,6 +288,18 @@ For the exact Zod shape (and any field that may have been added since), read > **Critical:** Object names and field keys in translation bundles **must** match the `snake_case` names defined in your Object and Field schemas. +Option sub-keys are the option's stored **`value`**, never its display label: +for `options: [{ value: 'direct_mail', label: 'Direct Mail' }]` write +`options: { direct_mail: '直邮' }` — both `'Direct Mail'` and `'direct-mail'` +parse, ship, and resolve to nothing. + +`os validate` / `os lint` / `os compile` check this direction and report it as +warnings (`translation-target-unknown`, `translation-option-key-unknown`): a key +naming an object, field, view, action, param, section, app, nav item, dashboard +or widget that does not exist is listed alongside the names that do. A bundle +keyed to something since renamed still parses — the label just renders silently +in its source locale while every neighbouring label resolves. + --- ## Secondary Format: AppTranslationBundle (`o.*`)