Skip to content

Commit 487c020

Browse files
committed
feat(lint): translation 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 — 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS
1 parent fb90784 commit 487c020

16 files changed

Lines changed: 1374 additions & 44 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(lint): translation-bundle reference integrity + option-key validation (#3583)
7+
8+
The i18n gate only ever ran forward: `os i18n check` asks which keys the
9+
metadata expects that no bundle carries. Nothing asked the reverse — which keys
10+
a bundle carries that no metadata claims — even though the spec already names
11+
the answer (`TranslationDiffStatus 'redundant'`, `TranslationCoverageResult.redundantKeys`,
12+
both declared with no producer).
13+
14+
That direction ships two failure modes, both found in the HotCRM audit: bundles
15+
keyed to fields an object no longer declares (a rename that left the translation
16+
behind), and select-option translations keyed by the option's **display label**
17+
or a variant spelling of its value (`direct-mail` for `direct_mail`, `planned`
18+
for `planning`). Neither breaks anything — which is the problem. The resolver
19+
finds nothing and renders the source string, so the screen looks translated and
20+
one field or one picklist value quietly does not.
21+
22+
New rule `validateTranslationReferences` walks every bundle in
23+
`stack.translations` against the stack it ships with, wired into `os validate`,
24+
`os lint`, and `os compile`:
25+
26+
| Key | Must name |
27+
|---|---|
28+
| `objects.{object}` | an object this stack defines, or a platform object |
29+
| `objects.{object}.fields.{field}` | a field that object declares |
30+
| `objects.{object}.fields.{field}.options.{key}` | an option's stored `value` |
31+
| `objects.{object}._views` / `._actions` / `._sections` / `._actions.*.params` | a view `name` / bound action / `fieldGroups[].key` or named section / param `name` |
32+
| `apps.{app}` / `.navigation.{id}` | an app `name` / navigation item `id` |
33+
| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | dashboard `name` / widget `id` / header `actionUrl` |
34+
| `globalActions.{action}` | an action with no `objectName` |
35+
36+
Every finding is a **warning** (`translation-target-unknown`,
37+
`translation-option-key-unknown`): an orphan key is inert, not broken, and the
38+
severity should say so. Diagnostics carry the declared names to choose from,
39+
name the stored value when a key turns out to be the display label, and suggest
40+
a namespace-segment match (`task``todo_task`) that edit distance alone misses.
41+
42+
Cross-package objects follow the existing ladder: a registered platform object
43+
is skipped wholly (its fields are not visible from a stack lint), a
44+
platform-prefixed name no package registers is reported once on the object key,
45+
and the subtree is never half-checked. `messages`, `validationMessages`,
46+
`settings`, `settingsCommon` and `metadataForms` are deliberately not judged —
47+
their keys are owned by application code, plugins, and the platform's own
48+
metadata-type registry, so no enumerable universe exists to resolve against.

content/docs/protocol/kernel/i18n-standard.mdx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,46 @@ Output:
763763
...
764764
```
765765

766+
### Orphan Keys and Option Keys
767+
768+
`os i18n check` runs in one direction — which keys the metadata expects that no
769+
bundle carries. The reverse direction (keys a bundle carries that no metadata
770+
claims) is checked by `os validate`, `os lint`, and `os compile`, which walk
771+
every bundle against the stack it ships with:
772+
773+
| Key | Must name |
774+
|:---|:---|
775+
| `objects.{object}` | an object this stack defines, or a platform object |
776+
| `objects.{object}.fields.{field}` | a field that object declares |
777+
| `objects.{object}.fields.{field}.options.{key}` | an option's stored **`value`** |
778+
| `objects.{object}._views.{view}` | a view's `name` |
779+
| `objects.{object}._actions.{action}` | an action bound to that object |
780+
| `objects.{object}._actions.{action}.params.{param}` | a param's `name` |
781+
| `objects.{object}._sections.{section}` | a `fieldGroups[].key`, or a section with a `name` |
782+
| `apps.{app}` / `apps.{app}.navigation.{id}` | an app's `name` / a navigation item's `id` |
783+
| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | the dashboard's `name` / a widget `id` / a header `actionUrl` |
784+
| `globalActions.{action}` | an action with **no** `objectName` |
785+
786+
An unresolvable key is a **warning**, not an error: nothing crashes, the string
787+
simply renders in its source locale while every neighbouring label resolves — so
788+
the app looks translated and one field does not.
789+
790+
The option case is worth spelling out. Option maps are keyed by the option's
791+
stored `value`, never by its display label:
792+
793+
```typescript
794+
// source: Field.select({ options: [{ value: 'direct_mail', label: 'Direct Mail' }] })
795+
796+
options: { direct_mail: '直邮' } // ✅ keyed by value
797+
options: { 'Direct Mail': '直邮' } // ❌ keyed by the label — never resolves
798+
options: { 'direct-mail': '直邮' } // ❌ a variant spelling of the value
799+
```
800+
801+
`messages`, `validationMessages`, `settings`, `settingsCommon` and
802+
`metadataForms` are **not** checked: their keys are owned by application code,
803+
plugins, and the platform's own metadata-type registry rather than by this
804+
stack's metadata, so there is no set of legal names to resolve against.
805+
766806
### Translation Service Integration
767807

768808
<Callout type="warn">

docs/audits/2026-07-app-metadata-reference-integrity-assessment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Effort legend: **S** ≤ ~1 day · **M** 2–4 days · **L** ≥ 1 week. Every r
9090
| Rule | Covers | Notes | Effort |
9191
|---|---|---|---|
9292
| 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 |
93-
| 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`) |
93+
| 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 |
9494
| 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 |
9595
| 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. | ? |
9696

examples/app-crm/src/translations/crm.translation.ts

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ export const CrmTranslationBundle = defineTranslationBundle({
1919
industry: { label: 'Industry' },
2020
annual_revenue: { label: 'Annual Revenue' },
2121
website: { label: 'Website' },
22-
phone: { label: 'Phone' },
23-
billing_address: { label: 'Billing Address' },
2422
owner_id: { label: 'Account Owner' },
2523
},
2624
},
@@ -30,10 +28,9 @@ export const CrmTranslationBundle = defineTranslationBundle({
3028
fields: {
3129
first_name: { label: 'First Name' },
3230
last_name: { label: 'Last Name' },
31+
full_name: { label: 'Full Name' },
3332
email: { label: 'Email' },
34-
phone: { label: 'Phone' },
35-
title: { label: 'Job Title' },
36-
account_id: { label: 'Account' },
33+
account: { label: 'Account' },
3734
},
3835
},
3936
crm_opportunity: {
@@ -48,17 +45,12 @@ export const CrmTranslationBundle = defineTranslationBundle({
4845
discount_percent: { label: 'Discount (%)' },
4946
owner_id: { label: 'Owner' },
5047
},
51-
_sections: {
52-
deal_info: { label: 'Deal Information' },
53-
finance: { label: 'Financial Details' },
54-
},
5548
},
5649
crm_lead: {
5750
label: 'Lead',
5851
pluralLabel: 'Leads',
5952
fields: {
60-
first_name: { label: 'First Name' },
61-
last_name: { label: 'Last Name' },
53+
name: { label: 'Lead Name' },
6254
email: { label: 'Email' },
6355
company: { label: 'Company' },
6456
status: { label: 'Status' },
@@ -74,18 +66,18 @@ export const CrmTranslationBundle = defineTranslationBundle({
7466
type: { label: 'Activity Type' },
7567
status: { label: 'Status' },
7668
due_date: { label: 'Due Date' },
77-
contact_id: { label: 'Contact' },
78-
opportunity_id: { label: 'Opportunity' },
69+
contact: { label: 'Contact' },
70+
opportunity: { label: 'Opportunity' },
7971
},
8072
},
8173
},
8274
apps: {
83-
crm: {
75+
crm_app: {
8476
label: 'CRM',
8577
description: 'Customer Relationship Management',
8678
navigation: {
87-
sales: { label: 'Sales' },
88-
admin: { label: 'Administration' },
79+
group_sales: { label: 'Sales' },
80+
group_analytics: { label: 'Analytics' },
8981
},
9082
},
9183
},
@@ -108,8 +100,6 @@ export const CrmTranslationBundle = defineTranslationBundle({
108100
industry: { label: '行业' },
109101
annual_revenue: { label: '年营收' },
110102
website: { label: '官网' },
111-
phone: { label: '电话' },
112-
billing_address: { label: '账单地址' },
113103
owner_id: { label: '负责人' },
114104
},
115105
},
@@ -119,10 +109,9 @@ export const CrmTranslationBundle = defineTranslationBundle({
119109
fields: {
120110
first_name: { label: '名' },
121111
last_name: { label: '姓' },
112+
full_name: { label: '姓名' },
122113
email: { label: '邮箱' },
123-
phone: { label: '电话' },
124-
title: { label: '职位' },
125-
account_id: { label: '所属客户' },
114+
account: { label: '所属客户' },
126115
},
127116
},
128117
crm_opportunity: {
@@ -137,17 +126,12 @@ export const CrmTranslationBundle = defineTranslationBundle({
137126
discount_percent: { label: '折扣 (%)' },
138127
owner_id: { label: '负责人' },
139128
},
140-
_sections: {
141-
deal_info: { label: '商机信息' },
142-
finance: { label: '财务明细' },
143-
},
144129
},
145130
crm_lead: {
146131
label: '线索',
147132
pluralLabel: '线索列表',
148133
fields: {
149-
first_name: { label: '名' },
150-
last_name: { label: '姓' },
134+
name: { label: '线索名称' },
151135
email: { label: '邮箱' },
152136
company: { label: '公司' },
153137
status: { label: '状态' },
@@ -163,18 +147,18 @@ export const CrmTranslationBundle = defineTranslationBundle({
163147
type: { label: '活动类型' },
164148
status: { label: '状态' },
165149
due_date: { label: '截止日期' },
166-
contact_id: { label: '联系人' },
167-
opportunity_id: { label: '商机' },
150+
contact: { label: '联系人' },
151+
opportunity: { label: '商机' },
168152
},
169153
},
170154
},
171155
apps: {
172-
crm: {
156+
crm_app: {
173157
label: '客户管理',
174158
description: '客户关系管理系统',
175159
navigation: {
176-
sales: { label: '销售管理' },
177-
admin: { label: '系统管理' },
160+
group_sales: { label: '销售管理' },
161+
group_analytics: { label: '数据分析' },
178162
},
179163
},
180164
},

examples/app-todo/src/translations/en.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { TranslationData } from '@objectstack/spec/system';
1010
*/
1111
export const en: TranslationData = {
1212
objects: {
13-
task: {
13+
todo_task: {
1414
label: 'Task',
1515
pluralLabel: 'Tasks',
1616
fields: {

examples/app-todo/src/translations/ja-JP.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { TranslationData } from '@objectstack/spec/system';
99
*/
1010
export const jaJP: TranslationData = {
1111
objects: {
12-
task: {
12+
todo_task: {
1313
label: 'タスク',
1414
pluralLabel: 'タスク',
1515
fields: {

examples/app-todo/src/translations/translation-completeness.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,15 @@ import type { TranslationData } from '@objectstack/spec/system';
1212
*
1313
* Validates that every field and every select option in the Task object
1414
* definition has a corresponding translation in each locale.
15+
*
16+
* The object key comes from `Task.name`, not a literal: this suite used to
17+
* hard-code `objects.task` while the object is `todo_task`, so it stayed green
18+
* against a bundle the resolver could never find — a completeness test that
19+
* agreed with the bundle instead of with the metadata (issue #3583).
1520
*/
1621

22+
const objectName = Task.name;
23+
1724
const fieldNames = Object.keys(Task.fields);
1825

1926
const selectFields = Object.entries(Task.fields)
@@ -31,22 +38,22 @@ describe.each([
3138
['ja-JP', jaJP],
3239
] as [string, TranslationData][])('%s translation completeness', (locale, t) => {
3340

34-
it('should have task object translation', () => {
35-
expect(t.objects?.task).toBeDefined();
36-
expect(t.objects?.task?.label).toBeTruthy();
41+
it(`should have "${objectName}" object translation`, () => {
42+
expect(t.objects?.[objectName]).toBeDefined();
43+
expect(t.objects?.[objectName]?.label).toBeTruthy();
3744
});
3845

3946
it.each(fieldNames)('field: %s', (name) => {
4047
expect(
41-
t.objects?.task?.fields?.[name]?.label,
48+
t.objects?.[objectName]?.fields?.[name]?.label,
4249
`[${locale}] Missing label for field "${name}"`,
4350
).toBeTruthy();
4451
});
4552

4653
it.each(selectFields)('options: $name', ({ name, values }) => {
4754
for (const v of values) {
4855
expect(
49-
t.objects?.task?.fields?.[name]?.options?.[v],
56+
t.objects?.[objectName]?.fields?.[name]?.options?.[v],
5057
`[${locale}] Missing option "${v}" for field "${name}"`,
5158
).toBeTruthy();
5259
}

examples/app-todo/src/translations/zh-CN.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type TaskTranslation = StrictObjectTranslation<typeof Task>;
1313
*/
1414
export const zhCN: TranslationData = {
1515
objects: {
16-
task: {
16+
todo_task: {
1717
label: '任务',
1818
pluralLabel: '任务',
1919
fields: {

packages/cli/src/commands/compile.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { validateFilterTokens } from '@objectstack/lint';
1616
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
1717
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
1818
import { validateNavAccess } from '@objectstack/lint';
19+
import { validateTranslationReferences } from '@objectstack/lint';
1920
import { validateResponsiveStyles } from '@objectstack/lint';
2021
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
2122
import { validateReadonlyFlowWrites } from '@objectstack/lint';
@@ -334,14 +335,18 @@ export default class Compile extends Command {
334335
// All plain strings in the schema, so a name resolving to nothing
335336
// ships and fails silently. Errors fail the build; the
336337
// platform-prefixed-but-unregistered case is advisory (a third-party
337-
// package may still provide it).
338+
// package may still provide it). Translation bundles are checked in
339+
// the reverse direction (keys naming metadata that does not exist,
340+
// option keys written as the display label) — advisory throughout,
341+
// since an orphan key is inert rather than broken.
338342
if (!flags.json) printStep('Checking object & action references (#3583)...');
339343
const refFindings = [
340344
...validateObjectReferences(result.data as Record<string, unknown>),
341345
...validateActionNameRefs(result.data as Record<string, unknown>),
342346
...validatePageFieldBindings(result.data as Record<string, unknown>),
343347
...validateChartBindings(result.data as Record<string, unknown>),
344348
...validateNavAccess(result.data as Record<string, unknown>),
349+
...validateTranslationReferences(result.data as Record<string, unknown>),
345350
];
346351
const refErrors = refFindings.filter((f) => f.severity === 'error');
347352
const refWarnings = refFindings.filter((f) => f.severity === 'warning');

packages/cli/src/commands/lint.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { validateRecordTitle, validateSemanticRoles, validateCapabilityReference
1313
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
1414
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
1515
import { validateNavAccess } from '@objectstack/lint';
16+
import { validateTranslationReferences } from '@objectstack/lint';
1617
import { collectAndLintDocs } from '../utils/collect-docs.js';
1718
import { scoreMetadata } from '../lint/score.js';
1819
import { runMetadataEval } from '../lint/metadata-eval.js';
@@ -568,6 +569,22 @@ export function lintConfig(config: any): LintIssue[] {
568569
});
569570
}
570571

572+
// ── Translation-bundle references & option keys (issue #3583) ──
573+
// The i18n gate only ever asked "which expected keys are missing?". This is
574+
// the reverse the spec already names (`TranslationDiffStatus 'redundant'`):
575+
// keys pointing at metadata that no longer exists, and select-option keys
576+
// written as the display label instead of the stored value. Advisory — an
577+
// orphan key is inert, it just leaves one string untranslated.
578+
for (const t of validateTranslationReferences(config)) {
579+
issues.push({
580+
severity: t.severity,
581+
rule: t.rule,
582+
message: `${t.where}: ${t.message}`,
583+
path: t.path,
584+
fix: t.hint,
585+
});
586+
}
587+
571588
return issues;
572589
}
573590

0 commit comments

Comments
 (0)