From 487c020e01c48fa2a02b156fcba1c4a5c865af18 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:11:46 +0000 Subject: [PATCH 1/2] feat(lint): translation reference integrity + option-key validation (#3583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — though the spec already names the answer (`TranslationDiffStatus 'redundant'`, `redundantKeys`), both declared with no producer. That direction ships two failure modes, both in the HotCRM audit: bundles keyed to fields an object no longer declares, and option translations keyed by the display label or a variant spelling of the stored value (`direct-mail` for `direct_mail`, `planned` for `planning`). Neither breaks anything — the resolver finds nothing and renders the source string, so the screen looks translated while one field quietly is not. `validateTranslationReferences` walks every bundle in `stack.translations` against the stack it ships with — objects, fields, option values, views, actions and their params, sections, apps and nav ids, dashboards, widgets, header actions, global actions — wired into `validate`, `lint` and `compile`. All findings are warnings: an orphan key is inert, not broken. Cross-package objects keep the S3 rule: a registered platform object is skipped wholly (its fields are invisible to a stack lint), an unregistered platform-prefixed name is reported once on the object key, and no subtree is half-checked. `messages`, `validationMessages`, `settings`, `settingsCommon` and `metadataForms` are deliberately unjudged — their keys belong to code, plugins and the platform registry, so there is no universe to resolve against. Two example apps carried the exact bug class and are fixed at the producer: app-crm translated `account_id`/`contact_id`/`opportunity_id` (fields are `account`/`contact`/`opportunity`), `first_name`/`last_name` on a lead that declares `name`, two sections nothing declares, and app `crm` (it is `crm_app`); app-todo keyed every bundle to `task` while the object is `todo_task` — 92 strings that looked translated were unreachable, which the coverage ratchet now records. Its completeness test hard-coded the same wrong key, so it stayed green against a bundle no resolver could find; it now reads the key from `Task.name`. Verified: lint 492 / cli 673 tests pass; the rule reports zero findings across app-crm, app-showcase and app-todo after the fixes, having visited 274 field keys, 94 option keys and 5 app keys over 35 object nodes — a floor with substance, not an empty walk. Refs #3583 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS --- .../lint-translation-reference-integrity.md | 48 ++ .../docs/protocol/kernel/i18n-standard.mdx | 40 + ...metadata-reference-integrity-assessment.md | 2 +- .../src/translations/crm.translation.ts | 48 +- examples/app-todo/src/translations/en.ts | 2 +- examples/app-todo/src/translations/ja-JP.ts | 2 +- .../translation-completeness.test.ts | 17 +- examples/app-todo/src/translations/zh-CN.ts | 2 +- packages/cli/src/commands/compile.ts | 7 +- packages/cli/src/commands/lint.ts | 17 + packages/cli/src/commands/validate.ts | 7 + packages/lint/src/index.ts | 10 + .../validate-translation-references.test.ts | 453 +++++++++++ .../src/validate-translation-references.ts | 747 ++++++++++++++++++ scripts/i18n-coverage-baseline.json | 4 +- skills/objectstack-i18n/SKILL.md | 12 + 16 files changed, 1374 insertions(+), 44 deletions(-) create mode 100644 .changeset/lint-translation-reference-integrity.md create mode 100644 packages/lint/src/validate-translation-references.test.ts create mode 100644 packages/lint/src/validate-translation-references.ts 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 6691935d67..5069c252c5 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'; @@ -334,7 +335,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), @@ -342,6 +346,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 e19968c2f7..ef38d474af 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'; @@ -568,6 +569,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 a0c75ba440..22f90ecb97 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'; @@ -297,6 +298,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), @@ -304,6 +310,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..3541565a8b --- /dev/null +++ b/packages/lint/src/validate-translation-references.test.ts @@ -0,0 +1,453 @@ +// 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 — 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..2b32a78198 --- /dev/null +++ b/packages/lint/src/validate-translation-references.ts @@ -0,0 +1,747 @@ +// 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() }; +} + +/** The object a view binds to, across the three shapes views are 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); + } + for (const view of asArray(obj.views)) { + const viewName = strName(view.name); + if (viewName) facts.views.add(viewName); + } + // 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)) { + const objectName = viewObjectName(view); + if (!objectName) continue; + const facts = factsFor(objectName); + const viewName = strName(view.name); + if (viewName) facts.views.add(viewName); + + // Form sections carry an OPTIONAL `name` that exists purely for this lookup + // (`ui/view.zod.ts`: "Stable section identifier for i18n lookup"). A section + // without one cannot be translated at all, so it adds nothing here. + for (const formView of asArray(view.formViews)) { + for (const section of asArray(formView.sections)) { + const sectionName = strName(section.name); + if (sectionName) facts.sections.add(sectionName); + } + } + for (const section of asArray(view.sections)) { + const sectionName = strName(section.name); + if (sectionName) facts.sections.add(sectionName); + } + } + + // ── 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 2622fe39ac..b885576da3 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": 231, "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.*`) From 1d12b6200db50b12e0f4e16ab5c56f5d5863f8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:58:01 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(lint):=20a=20view=20record=20is=20a=20c?= =?UTF-8?q?ontainer=20=E2=80=94=20resolve=20`=5Fviews`=20keys=20against=20?= =?UTF-8?q?it=20(#3583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the new rule over HotCRM (~18k lines of shipped metadata) reported ~40 `_views` keys as orphans. Every one of them was correct; the rule was wrong, in two compounding ways: 1. A view record is a CONTAINER, not a view. The default list lives at `list` and the named tabs at `listViews.` / `formViews.`, each of which may also carry its own `name`. Collecting only the record's own `name` makes every named tab look undeclared. 2. The object binding lives INSIDE the container (`list.data.object`), not at the record root. The record-level lookup resolved to nothing on the canonical shape, so the whole record was skipped — which is why the universe was empty enough to report the app's entire view surface. The examples missed this because none of their bundles carried `_views` keys at all: the walk was exercised, but that one branch of the universe never was. A corpus with the keys present is what caught it — §6.1's point about a false-positive floor only meaning something when the walk is non-empty applies per branch, not per rule. `collectViewRecord` now registers each container under the object THAT container binds (falling back to the record, then to the default list), and accepts both the map key and the inner `name` — authors write either, and the key is what the console renders the tab from. Form sections are collected in the same pass, so `_sections` gets the same binding fix. Objects carrying views directly (`objects[].views`, `objects[].listViews`) go through one code path with the owner pinned. Three regression tests pin the HotCRM shape. Residual on HotCRM after the fix: 6 findings, all true — `apps.crm_enterprise.navigation.group_products` and `.group_analytics` across three locales, where the app declares group_sales / group_work / group_marketing / group_service / group_insights and neither translated id exists. Refs #3583 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS --- .../validate-translation-references.test.ts | 82 ++++++++++++++++ .../src/validate-translation-references.ts | 93 ++++++++++++++----- 2 files changed, 153 insertions(+), 22 deletions(-) diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 3541565a8b..6e0412aa4b 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -401,6 +401,88 @@ describe('validateTranslationReferences — namespaces deliberately not judged', }); }); +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 = { diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 2b32a78198..2433f9f1ed 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -197,7 +197,70 @@ function emptyFacts(): ObjectFacts { return { fields: new Map(), views: new Set(), actions: new Map(), sections: new Set() }; } -/** The object a view binds to, across the three shapes views are authored in. */ +/** + * 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) ?? @@ -269,10 +332,14 @@ function buildUniverse(stack: AnyRec): Universe { 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)) { - const viewName = strName(view.name); - if (viewName) facts.views.add(viewName); + 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); @@ -282,25 +349,7 @@ function buildUniverse(stack: AnyRec): Universe { // ── Stack-level views: `_views` names + form-section names ── for (const view of asArray(stack.views)) { - const objectName = viewObjectName(view); - if (!objectName) continue; - const facts = factsFor(objectName); - const viewName = strName(view.name); - if (viewName) facts.views.add(viewName); - - // Form sections carry an OPTIONAL `name` that exists purely for this lookup - // (`ui/view.zod.ts`: "Stable section identifier for i18n lookup"). A section - // without one cannot be translated at all, so it adds nothing here. - for (const formView of asArray(view.formViews)) { - for (const section of asArray(formView.sections)) { - const sectionName = strName(section.name); - if (sectionName) facts.sections.add(sectionName); - } - } - for (const section of asArray(view.sections)) { - const sectionName = strName(section.name); - if (sectionName) facts.sections.add(sectionName); - } + collectViewRecord(view, factsFor); } // ── Pages: `record:details` sections are the other `_sections` anchor ──