Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/lint-translation-reference-integrity.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Callout type="warn">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. | ? |

Expand Down
48 changes: 16 additions & 32 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
},
Expand All @@ -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: {
Expand All @@ -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' },
Expand All @@ -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' },
},
},
},
Expand All @@ -108,8 +100,6 @@ export const CrmTranslationBundle = defineTranslationBundle({
industry: { label: '行业' },
annual_revenue: { label: '年营收' },
website: { label: '官网' },
phone: { label: '电话' },
billing_address: { label: '账单地址' },
owner_id: { label: '负责人' },
},
},
Expand All @@ -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: {
Expand All @@ -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: '状态' },
Expand All @@ -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: '数据分析' },
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion examples/app-todo/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { TranslationData } from '@objectstack/spec/system';
*/
export const en: TranslationData = {
objects: {
task: {
todo_task: {
label: 'Task',
pluralLabel: 'Tasks',
fields: {
Expand Down
2 changes: 1 addition & 1 deletion examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { TranslationData } from '@objectstack/spec/system';
*/
export const jaJP: TranslationData = {
objects: {
task: {
todo_task: {
label: 'タスク',
pluralLabel: 'タスク',
fields: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,22 +38,22 @@ 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();
});

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();
}
Expand Down
2 changes: 1 addition & 1 deletion examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type TaskTranslation = StrictObjectTranslation<typeof Task>;
*/
export const zhCN: TranslationData = {
objects: {
task: {
todo_task: {
label: '任务',
pluralLabel: '任务',
fields: {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -336,14 +337,18 @@ 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<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
...validateNavAccess(result.data as Record<string, unknown>),
...validateTranslationReferences(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -569,6 +570,22 @@ export function lintConfig(config: any): LintIssue[] {
});
}

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

return issues;
}

Expand Down
Loading
Loading