Skip to content

Commit 2343099

Browse files
authored
feat(lint): translation reference integrity + option-key validation (#3583) (#3806)
Adds `validateTranslationReferences` — the reverse direction of the i18n gate the spec already named (`TranslationDiffStatus 'redundant'`) but never built. Walks every bundle in `stack.translations` against the stack it ships with: objects, fields, option values, views, actions and 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. Fixed two example apps that carried the same bug class (app-crm field/app key drift; app-todo keyed every bundle to `task` while the object is `todo_task`, hiding 92 unreachable strings, with a completeness test that hard-coded the same wrong key). A run over the HotCRM corpus caught a rule bug before merge: a view record is a container whose object binding lives at `list.data.object`, so `_views` keys resolved against nothing and ~40 correct keys were reported. Fixed with three regression tests; HotCRM residual is 6 findings, all true. Refs #3583
1 parent 0045682 commit 2343099

16 files changed

Lines changed: 1505 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
@@ -801,6 +801,46 @@ Output:
801801
...
802802
```
803803

804+
### Orphan Keys and Option Keys
805+
806+
`os i18n check` runs in one direction — which keys the metadata expects that no
807+
bundle carries. The reverse direction (keys a bundle carries that no metadata
808+
claims) is checked by `os validate`, `os lint`, and `os compile`, which walk
809+
every bundle against the stack it ships with:
810+
811+
| Key | Must name |
812+
|:---|:---|
813+
| `objects.{object}` | an object this stack defines, or a platform object |
814+
| `objects.{object}.fields.{field}` | a field that object declares |
815+
| `objects.{object}.fields.{field}.options.{key}` | an option's stored **`value`** |
816+
| `objects.{object}._views.{view}` | a view's `name` |
817+
| `objects.{object}._actions.{action}` | an action bound to that object |
818+
| `objects.{object}._actions.{action}.params.{param}` | a param's `name` |
819+
| `objects.{object}._sections.{section}` | a `fieldGroups[].key`, or a section with a `name` |
820+
| `apps.{app}` / `apps.{app}.navigation.{id}` | an app's `name` / a navigation item's `id` |
821+
| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | the dashboard's `name` / a widget `id` / a header `actionUrl` |
822+
| `globalActions.{action}` | an action with **no** `objectName` |
823+
824+
An unresolvable key is a **warning**, not an error: nothing crashes, the string
825+
simply renders in its source locale while every neighbouring label resolves — so
826+
the app looks translated and one field does not.
827+
828+
The option case is worth spelling out. Option maps are keyed by the option's
829+
stored `value`, never by its display label:
830+
831+
```typescript
832+
// source: Field.select({ options: [{ value: 'direct_mail', label: 'Direct Mail' }] })
833+
834+
options: { direct_mail: '直邮' } // ✅ keyed by value
835+
options: { 'Direct Mail': '直邮' } // ❌ keyed by the label — never resolves
836+
options: { 'direct-mail': '直邮' } // ❌ a variant spelling of the value
837+
```
838+
839+
`messages`, `validationMessages`, `settings`, `settingsCommon` and
840+
`metadataForms` are **not** checked: their keys are owned by application code,
841+
plugins, and the platform's own metadata-type registry rather than by this
842+
stack's metadata, so there is no set of legal names to resolve against.
843+
804844
### Translation Service Integration
805845

806846
<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';
@@ -336,14 +337,18 @@ export default class Compile extends Command {
336337
// All plain strings in the schema, so a name resolving to nothing
337338
// ships and fails silently. Errors fail the build; the
338339
// platform-prefixed-but-unregistered case is advisory (a third-party
339-
// package may still provide it).
340+
// package may still provide it). Translation bundles are checked in
341+
// the reverse direction (keys naming metadata that does not exist,
342+
// option keys written as the display label) — advisory throughout,
343+
// since an orphan key is inert rather than broken.
340344
if (!flags.json) printStep('Checking object & action references (#3583)...');
341345
const refFindings = [
342346
...validateObjectReferences(result.data as Record<string, unknown>),
343347
...validateActionNameRefs(result.data as Record<string, unknown>),
344348
...validatePageFieldBindings(result.data as Record<string, unknown>),
345349
...validateChartBindings(result.data as Record<string, unknown>),
346350
...validateNavAccess(result.data as Record<string, unknown>),
351+
...validateTranslationReferences(result.data as Record<string, unknown>),
347352
];
348353
const refErrors = refFindings.filter((f) => f.severity === 'error');
349354
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';
@@ -569,6 +570,22 @@ export function lintConfig(config: any): LintIssue[] {
569570
});
570571
}
571572

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

0 commit comments

Comments
 (0)