Skip to content

Commit 42d9fe8

Browse files
feat(spec,cli,lint): translate a form section entry's label, placeholder and helpText from _sections.SECTION.fields.FIELD (#25620)
Fixes #25459 Clause-②: yes (widening) Metadata-protocol: added ## What A form section entry (`{ field, label, placeholder, helpText }` in a form view's `sections[].fields`) overrides the object field's copy for that form. A translation pack had no key for those three strings, and `translateView` never read `config.sections[].fields[]`, so a public form's entry overrides stayed in the authoring language beside translated headings and field labels. The address, kept to the one `_sections` convention: ``` objects.OBJECT._sections.SECTION.fields.FIELD.label objects.OBJECT._sections.SECTION.fields.FIELD.placeholder objects.OBJECT._sections.SECTION.fields.FIELD.helpText ``` `SECTION` is the section's `name` and `FIELD` the entry's own `field`. ## Landing (as the PM measured, confirmed) - `packages/spec/src/system/translation.zod.ts`: `_sections.SECTION` gains a strict `fields` record of `{ label, placeholder, helpText }` (`help` / `hint` / `tooltip` are refused with the rename to `helpText`; `entries` is refused with the rename to `fields`). - `packages/spec/src/system/i18n-resolver.ts`: `translateFormSections` now calls a new `translateFormSectionEntries` for each named section. The rules are the section rules, one level down: only copy the entry authors is translated, a missing key keeps the authored string, and an entry string that diverged from the packaged section's entry with the same `field` keeps its value (ADR-0029 D9.2a, through the existing `packagedPart` + `valueOverridesPackagedBase`, no second comparison). No address: a section without a `name`, a bare field-name entry, and a composite entry's sub-`fields`. - `packages/cli/src/utils/i18n-extract.ts` (`domain:server` rider, scaffold only): `walkObjectSections` emits the key for every entry string a form view's named section authors, from the same three form-view anchors the heading walk reads (`view.sections`, `form.sections`, `formViews.*.sections`), in the `section` bucket. `i18n-coverage.ts` needs no hunk: coverage is computed from that same walker, so `os i18n check` / `os lint` see the keys with no other change (pinned). - `packages/lint/src/validate-translation-references.ts`: the `_sections` facts gain each form-view section's entry `field`s, and `translation-target-unknown` reports a `_sections.SECTION.fields.FIELD` key no form-view section of that name declares as an entry object. `validate-translatable-sections.ts` is unchanged (see Acceptance notes). - `packages/rest/src/public-form-section-i18n.test.ts` (`domain:server` rider, one door pin): `GET /forms/:slug` with `Accept-Language: zh-CN` serves the translated entry label, placeholder and helpText when the pack has them, and the authored copy when it has none or under `en`. ## Tests (real readings, head 37a9e0f) - `packages/spec`: full `--project local` suite in three chunks: `src/system` 56 files / 1948 tests; `src/ui` and the root files 187 / 6603; every other directory 429 / 12167 (+1 todo). All passed. That includes `i18n-resolver.form-sections.test.ts` (new block: one pin per key, `label`, `placeholder` and `helpText`, beside the existing section pins; also missing key, authored-only, nameless section, explicit override vs packaged base, entry added after the fact, no input mutation) and `translation.test.ts` (accepts the node; strict; `help` goes to `helpText`). - `packages/lint`: full suite 158 files / 7238 tests passed (new case: an entry key that resolves is accepted; a key under a `fieldGroups` section and one for a bare field-name entry are reported as `error`). - `packages/cli` (unit tier, narrowed): every unit test file that imports the extractor or coverage module, 33 files / 384 tests passed, including the new `test/i18n-form-section-entry-coverage.test.ts` and the named `i18n-form-title-coverage` and `i18n-bulk-action-coverage`. The full unit tier needs more than the container's foreground cap; the integration tier is CI's. - `packages/rest`: the five `translateView`-reaching suites, 5 files / 128 tests passed (`public-form-section-i18n` 17, 3 of them new). - `packages/metadata-protocol`: it calls `translateView` in `src/protocol.ts` but has no translation test of its own. Its read path is exercised by the rest suites above over the real `ObjectStackProtocolImplementation`. - Typecheck: `spec`, `lint`, `cli` and `rest` exit 0. - Ablation, run once on committed HEAD with `scripts/ablation-replace.mjs`: the entry overlay's guard `if (!Array.isArray(entries)) return entries;` was replaced with `if (entries !== undefined) return entries;` (anchor 1 to 0, blob 5942acf to 1b8de47c). Result: 5 of 23 in `i18n-resolver.form-sections.test.ts` failed (the label, placeholder and helpText pins, authored-only, and override). Restored, blob back to HEAD, `git diff HEAD` empty. The suite reads `src/`, so the ablation needed no rebuild. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 117 commands on this diff. Exit codes were captured before any pipe and reconciled with `--ran`: 117 accounted for, 116 run with exit 0, 1 NOT MEASURED, 0 unrun. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3). It needs the whole workspace built, which is CI's job. - Run a second time after clearing a missing prerequisite, then exit 0: - `check-plugin-teardown-shape --self-test`: its pinned fixture commit was absent from this shallow clone. - `check:skill-examples`: needed `@objectstack/client-react` rebuilt. - `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`: needed `@objectstack/cli` built. - `check:query-options-erasure`: a repo-wide eslint ratchet. It ran detached until it finished. - `pnpm --filter @objectstack/spec check:generated` flagged two stale artifacts. `--fix` regenerated exactly those two, and they are committed: `content/docs/references/system/translation.mdx` and the `system/` strictness-ledger count (377 to 378 sites, from the one new strict shape). ## Acceptance notes - The resolver follows the section precedent for an inline locale map: an entry string authored as `{ en, 'zh-CN' }` is authored copy and takes the catalog when the pack has a key. Not measured: whether the console renders such a map on an entry today. The crash that blocked it in objectui is closed, but this session did not run the console. - `validate-translatable-sections` still warns only about a nameless section that has a heading. A nameless section with no heading, whose entries author override copy, has no address and gets no warning. Left as is, because extending that rule is outside this card's ask. - Sub-`fields` of a composite or repeater entry are not addressed. Their names are relative to the parent entry, so keying them under the section would collide. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PkEdTSFyHwijHNXRdNTARE --- _Generated by [Claude Code](https://claude.ai/code/session_01PkEdTSFyHwijHNXRdNTARE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc10e74 commit 42d9fe8

12 files changed

Lines changed: 658 additions & 22 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/lint': minor
5+
---
6+
7+
feat(spec,cli,lint): a form section entry's `label`, `placeholder` and `helpText` are translatable at `objects.<object>._sections.<section>.fields.<field>`
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
12+
A form section entry (`{ field, label, placeholder, helpText }` in a form view's `sections[].fields`) can override the object field's copy for that form. Before this change a translation pack had no key for those three strings, so a form's entry overrides stayed in the authoring language for every reader, the anonymous `GET /forms/:slug` visitor included.
13+
14+
- **`@objectstack/spec`:** the `_sections.<section>` node of an object translation gains `fields`, keyed by the entry's `field`, each carrying `label`, `placeholder` and `helpText` (strict; `help`, `hint` and `tooltip` are refused with the rename to `helpText`). `translateView` overlays them onto a served form view's `config.sections[].fields[]` under the section rules: the key is the section's `name` and the entry's `field`, only copy the entry authors is translated, a missing key keeps the authored string, and a string edited on top of the packaged view keeps the edit (ADR-0029 D9.2a). An entry in a section with no `name`, and a bare field-name entry, have no key.
15+
- **`@objectstack/cli`:** `os i18n extract` scaffolds the key for every entry string a form view's named section authors, and `os i18n check` / `os lint` coverage report a locale that leaves one untranslated, in the `section` bucket.
16+
- **`@objectstack/lint`:** `translation-target-unknown` reports a `_sections.<section>.fields.<field>` key that no form view section of that name declares as an entry object.
17+
18+
Example (`zh-CN` bundle):
19+
20+
```ts
21+
objects: {
22+
crm_case: {
23+
_sections: {
24+
submitter: {
25+
label: '关于您',
26+
fields: { contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' } },
27+
},
28+
},
29+
},
30+
}
31+
```

‎content/docs/references/system/translation.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Translation data for a single object
9393
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
9494
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
9595
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
96-
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
96+
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
9797
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
9898
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |
9999

@@ -136,6 +136,7 @@ Translation data for a single field
136136
| :--- | :--- | :--- | :--- |
137137
| **label** | `string` | optional | Translated section label |
138138
| **description** | `string` | optional | Translated section description |
139+
| **fields** | `Record<string, { label?: string; placeholder?: string; helpText?: string }>` | optional | Form section entry translations keyed by the entry's `field` |
139140

140141
### Nested Shape: `ObjectTranslationData._tabs[string]`
141142

@@ -194,7 +195,7 @@ Translation data for a single object
194195
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
195196
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
196197
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
197-
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
198+
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
198199
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
199200
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |
200201

@@ -397,7 +398,7 @@ Translation data for a single object
397398
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
398399
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
399400
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
400-
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
401+
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
401402
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
402403
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |
403404

@@ -560,7 +561,7 @@ Translation data for a single object
560561
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
561562
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
562563
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
563-
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
564+
| **_sections** | `Record<string, { label?: string; description?: string; fields?: Record<string, object> }>` | optional | Section translations keyed by section name |
564565
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
565566
| **_validations** | `Record<string, { message?: string }>` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) |
566567

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 377 |
22+
| `system/` | 378 |

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
* objects.<name>.fields.<field>.placeholder
7070
* objects.<name>.fields.<field>.options.<value>
7171
* objects.<name>._sections.<section>.label
72+
* objects.<name>._sections.<section>.fields.<field>.{label,placeholder,helpText}
7273
* objects.<name>._views.<view>.label
7374
* objects.<name>._views.<view>.description
7475
* objects.<name>._views.<view>.emptyState.title / .message
@@ -953,12 +954,61 @@ function addSectionList(index: SectionIndex, sections: unknown, objectName: unkn
953954
}
954955
}
955956

957+
/**
958+
* object name → section name → entry `field` → the override copy that entry
959+
* authors, RAW — the `objects.<o>._sections.<s>.fields.<f>.*` keys.
960+
*/
961+
type SectionEntryIndex = Map<string, Map<string, Map<string, Partial<Record<SectionEntryAttr, AuthoredLabel>>>>>;
962+
963+
/** The override copy a form section entry carries (`FormFieldSchema`). */
964+
type SectionEntryAttr = 'label' | 'placeholder' | 'helpText';
965+
const SECTION_ENTRY_ATTRS: readonly SectionEntryAttr[] = ['label', 'placeholder', 'helpText'];
966+
967+
/**
968+
* Read the override copy of a FORM VIEW's section entries into the index —
969+
* the `label`, `placeholder` and `helpText` an entry object authors over the
970+
* object field it renders, addressed under its section's `name` by its own
971+
* `field`, which is where `translateView` reads them.
972+
*
973+
* Form views only: a `record:details` section and a `fieldGroups` group have
974+
* no entry overrides the resolver reads. A nameless section has no address,
975+
* and a bare field-name entry authors no copy, so neither contributes. One
976+
* string, one key: the first authored value wins, as for a heading.
977+
*/
978+
function addSectionEntries(index: SectionEntryIndex, sections: unknown, objectName: unknown): void {
979+
if (!Array.isArray(sections)) return;
980+
if (typeof objectName !== 'string' || objectName.length === 0) return;
981+
for (const section of sections) {
982+
if (!section || typeof section !== 'object') continue;
983+
const s = section as Record<string, unknown>;
984+
if (typeof s.name !== 'string' || s.name.length === 0 || !Array.isArray(s.fields)) continue;
985+
for (const raw of s.fields) {
986+
if (!raw || typeof raw !== 'object') continue;
987+
const entry = raw as Record<string, unknown>;
988+
if (typeof entry.field !== 'string' || entry.field.length === 0) continue;
989+
let byObject = index.get(objectName);
990+
if (!byObject) index.set(objectName, (byObject = new Map()));
991+
let bySection = byObject.get(s.name);
992+
if (!bySection) byObject.set(s.name, (bySection = new Map()));
993+
let copy = bySection.get(entry.field);
994+
if (!copy) bySection.set(entry.field, (copy = {}));
995+
for (const attr of SECTION_ENTRY_ATTRS) {
996+
const authored = asAuthoredLabel(entry[attr]);
997+
if (copy[attr] === undefined && authored !== undefined) copy[attr] = authored;
998+
}
999+
}
1000+
}
1001+
}
1002+
9561003
/**
9571004
* Emit `objects.<object>._sections.<section>.label` for every section the
958-
* stack renders, from both authoring surfaces.
1005+
* stack renders, from both authoring surfaces, and
1006+
* `objects.<object>._sections.<section>.fields.<field>.{label,placeholder,helpText}`
1007+
* for the override copy a form view's section entries author.
9591008
*/
9601009
function walkObjectSections(config: any, out: ExpectedEntry[]): void {
9611010
const index: SectionIndex = new Map();
1011+
const entries: SectionEntryIndex = new Map();
9621012

9631013
// (a) `fieldGroups` × field `group` membership. `deriveFieldGroupLayout` is
9641014
// the shared derivation the renderers themselves consume, so "which
@@ -991,13 +1041,18 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
9911041
for (const view of views) {
9921042
const containerObject = viewObjectName(view);
9931043
addSectionList(index, view?.sections, containerObject);
1044+
addSectionEntries(entries, view?.sections, containerObject);
9941045
if (view?.form && typeof view.form === 'object') {
995-
addSectionList(index, view.form.sections, viewObjectName(view.form) ?? containerObject);
1046+
const formObject = viewObjectName(view.form) ?? containerObject;
1047+
addSectionList(index, view.form.sections, formObject);
1048+
addSectionEntries(entries, view.form.sections, formObject);
9961049
}
9971050
if (view?.formViews && typeof view.formViews === 'object') {
9981051
for (const form of Object.values<any>(view.formViews)) {
9991052
if (!form || typeof form !== 'object') continue;
1000-
addSectionList(index, form.sections, viewObjectName(form) ?? containerObject);
1053+
const formObject = viewObjectName(form) ?? containerObject;
1054+
addSectionList(index, form.sections, formObject);
1055+
addSectionEntries(entries, form.sections, formObject);
10011056
}
10021057
}
10031058
}
@@ -1045,6 +1100,25 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
10451100
);
10461101
}
10471102
}
1103+
1104+
// Authored copy only (`pushEntry`): the resolver translates an entry string
1105+
// only where the entry authors one, so a key for a string nobody wrote is
1106+
// one nothing reads.
1107+
for (const [objectName, sections] of entries) {
1108+
for (const [sectionName, fields] of sections) {
1109+
for (const [fieldName, copy] of fields) {
1110+
for (const attr of SECTION_ENTRY_ATTRS) {
1111+
pushEntry(
1112+
out,
1113+
['objects', objectName, '_sections', sectionName, 'fields', fieldName, attr],
1114+
copy[attr],
1115+
'section',
1116+
{ objectName },
1117+
);
1118+
}
1119+
}
1120+
}
1121+
}
10481122
}
10491123

10501124
// ─── Object filter-preset tabs (`objects.<o>._tabs.<tab>.label`) ───────
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A form section entry's override copy — the `label`, `placeholder` and
4+
// `helpText` an entry object authors over the object field it renders — is
5+
// translated through `objects.<object>._sections.<section>.fields.<field>`
6+
// (`translateView` overlays it onto the served section's entries). The walker
7+
// emits that slot for every authored string of a form view's named sections,
8+
// so `os i18n extract` scaffolds it and the coverage gate reports a locale
9+
// that leaves one untranslated.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
13+
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
14+
import { ObjectTranslationDataSchema } from '@objectstack/spec/system';
15+
16+
const entryKeys = (config: any) =>
17+
collectExpectedEntries(config)
18+
.map((e) => e.path.join('.'))
19+
.filter((p) => p.includes('._sections.') && p.includes('.fields.'));
20+
21+
const caseForm = (sections: unknown[]) => ({
22+
type: 'simple',
23+
data: { provider: 'object', object: 'crm_case' },
24+
sections,
25+
});
26+
27+
const config = (translations: unknown[] = []) => ({
28+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
29+
objects: [{
30+
name: 'crm_case',
31+
label: 'Case',
32+
fields: {
33+
contact_name: { type: 'text', label: 'Contact name' },
34+
contact_email: { type: 'email', label: 'Contact email' },
35+
subject: { type: 'text', label: 'Subject' },
36+
},
37+
}],
38+
views: [
39+
{
40+
list: { type: 'grid', name: 'all_cases', label: 'All Cases', data: { provider: 'object', object: 'crm_case' } },
41+
formViews: {
42+
support: caseForm([
43+
{
44+
name: 'submitter',
45+
label: 'About you',
46+
fields: [
47+
{ field: 'contact_name', label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID' },
48+
{ field: 'contact_email', label: 'Your email' },
49+
'subject',
50+
],
51+
},
52+
// No `name`: its entries have no address.
53+
{ label: 'Details', fields: [{ field: 'subject', label: 'What happened' }] },
54+
]),
55+
},
56+
},
57+
],
58+
pages: [{
59+
name: 'case_record',
60+
type: 'record',
61+
object: 'crm_case',
62+
regions: [{
63+
name: 'main',
64+
components: [{
65+
type: 'record:details',
66+
properties: { sections: [{ name: 'page_only', label: 'Page', fields: [{ field: 'subject', label: 'Page label' }] }] },
67+
}],
68+
}],
69+
}],
70+
translations,
71+
});
72+
73+
describe('i18n walker — a form section entry\'s copy, `_sections.<section>.fields.<field>`', () => {
74+
it('emits one slot per AUTHORED entry string of a named form-view section', () => {
75+
expect(entryKeys(config()).sort()).toEqual([
76+
'objects.crm_case._sections.submitter.fields.contact_email.label',
77+
'objects.crm_case._sections.submitter.fields.contact_name.helpText',
78+
'objects.crm_case._sections.submitter.fields.contact_name.label',
79+
'objects.crm_case._sections.submitter.fields.contact_name.placeholder',
80+
]);
81+
});
82+
83+
it('seeds each slot with the authored string, under the `section` bucket', () => {
84+
const at = (p: string) => collectExpectedEntries(config()).find((e) => e.path.join('.') === p);
85+
expect(at('objects.crm_case._sections.submitter.fields.contact_name.placeholder'))
86+
.toMatchObject({ sourceValue: 'Jane Doe', inline: 'Jane Doe', source: 'section', objectName: 'crm_case' });
87+
});
88+
89+
it('`os i18n extract` writes a skeleton the translation schema accepts', () => {
90+
const node: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.objects?.crm_case;
91+
expect(node?._sections?.submitter?.fields?.contact_name).toEqual({
92+
label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID',
93+
});
94+
const parsed = ObjectTranslationDataSchema.safeParse(node);
95+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
96+
});
97+
98+
it('the coverage gate reports an authored entry string a supported locale leaves untranslated', () => {
99+
const missing = computeI18nCoverage(config([{ 'zh-CN': { objects: { crm_case: { label: '工单' } } } }]))
100+
.issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.fields.') && i.key.includes('._sections.'))
101+
.map((i) => i.key)
102+
.sort();
103+
expect(missing).toEqual(entryKeys(config()).sort());
104+
const covered = computeI18nCoverage(config([{
105+
'zh-CN': {
106+
objects: {
107+
crm_case: {
108+
label: '工单',
109+
_sections: {
110+
submitter: {
111+
fields: {
112+
contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' },
113+
contact_email: { label: '您的邮箱' },
114+
},
115+
},
116+
},
117+
},
118+
},
119+
},
120+
}])).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.fields.') && i.key.includes('._sections.'));
121+
expect(covered).toEqual([]);
122+
});
123+
});

‎packages/lint/src/validate-translation-references.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,42 @@ describe('validateTranslationReferences — orphan keys', () => {
144144
expect(findings).toEqual([]);
145145
});
146146

147+
it('accepts a form section entry\'s copy keyed by its `field`, and flags an entry no section of that name declares', () => {
148+
const stack = (sections: unknown) => ({
149+
...leadStack([
150+
{
151+
'zh-CN': {
152+
objects: {
153+
crm_lead: {
154+
label: '线索',
155+
_sections: {
156+
contact: { label: '联系方式', fields: { name: { label: '您的姓名', placeholder: '张三', helpText: '全名' } } },
157+
basics: { fields: { name: { label: 'x' } } },
158+
},
159+
},
160+
},
161+
},
162+
},
163+
]),
164+
views: [{
165+
name: 'lead_forms',
166+
objectName: 'crm_lead',
167+
formViews: { web: { type: 'simple', data: { provider: 'object', object: 'crm_lead' }, sections } },
168+
}],
169+
});
170+
const findings = validateTranslationReferences(stack([
171+
{ name: 'contact', label: 'Contact', fields: [{ field: 'name', label: 'Your name', placeholder: 'Jane', helpText: 'Full' }, 'status'] },
172+
]));
173+
// `contact.fields.name` resolves; `basics` is a `fieldGroups` group, which has no entries.
174+
expect(findings.map((f) => f.path)).toEqual([
175+
'translations[0]["zh-CN"].objects.crm_lead._sections.basics.fields.name',
176+
]);
177+
expect(findings[0].severity).toBe('error');
178+
// A bare field-name entry authors no copy, so its `field` addresses nothing.
179+
const bare = validateTranslationReferences(stack([{ name: 'contact', label: 'Contact', fields: ['name'] }]));
180+
expect(bare.map((f) => f.path)).toContain('translations[0]["zh-CN"].objects.crm_lead._sections.contact.fields.name');
181+
});
182+
147183
it('does not flag implicit audit/system fields', () => {
148184
const findings = validateTranslationReferences(
149185
leadStack([

0 commit comments

Comments
 (0)