Skip to content

Commit 4bd7215

Browse files
fix(lint): the translation-reference rules judge a locale pack's picklists face (#24474)
Fixes #23566 Clause-②: no ## What changed `packages/lint/src/validate-translation-references.ts` now reads a locale pack's `picklists` face. Until now nothing read it, so a wrong key passed `os lint --strict` and was dropped silently at runtime. Two rules already covered the same mistakes elsewhere, and this change extends both of them to the new face. No rule id is added, and each finding carries the severity of its field-level twin (the triage ruling: "the two existing rules … extend to the picklist face, at their field-level twins' severity. No new rule id."): | key | verdict | severity | |---|---|---| | `picklists.NAME` naming a list that no package in the stack declares | `translation-target-unknown`, with "Did you mean" from the declared list names; fix line: "Match the key to a picklist's `name`, or drop it." | error (same as the field orphan) | | `picklists.NAME.options.KEY` where KEY is not a value of that list | `translation-option-key-unknown`, with the field twin's two arms: a key spelled as the DISPLAY LABEL is told the value to rename it to, and any other key gets the nearest declared value | warning (same as a field option key) | **What a key resolves against.** The rule uses the same set the runtime uses. `lookupPicklistOption` (`@objectstack/spec/system`) is called with the list's name and each served option's value read as text. For a picklist-bound field, the served options are the list's own `options` plus every `picklistExtensions` entry for it (`PicklistServedFieldSchema`). So the universe is the declaration's options together with the options every extension adds. Lists and extensions are read from the stack in hand and from every `packages[].manifest` body. Both keys have the `concat` disposition, which places them inside the ADR-0130 D4 assembled body, the same reasoning the other sibling-package folds use. When both the stack and a sibling declare a list, the declaration being judged keeps the slot (the dashboards' decision). Extensions are a union. **What is not judged.** If the stack only *extends* a list and no package of the stack declares it, the `picklists.NAME` subtree is skipped wholly. The owner's options cannot be seen from here. This is rung 2b of the object ladder (`objectExtensions`) applied to a list. `os explain translation-target-unknown` and `os explain translation-option-key-unknown` (`rule-explanations.ts`) now name the face, and the module header documents it. ## Deviation from the field twin (one, deliberate) The field twin's option-key fix line ends with "Declared values: …". The picklist fix line does not. It stays one line of at most 200 characters, the shape #22161 settled on, and the verdict's "Did you mean" names the nearest value. The field twin's hint is unchanged. ## Tests (at head `d237990699`, after merging `origin/main` `26501ae24f`) - New describe block `the picklist face (#23566)` in `validate-translation-references.test.ts`, one pin per leg: - Leg A: `….picklists.salutation.options.mister`, `translation-option-key-unknown`, warning. The test asserts the same rule and severity on the field twin. - Leg B: `….picklists.not_a_list`, `translation-target-unknown`, error. The near-miss `salutaton` gets `Did you mean "salutation"?`. The test asserts the field twin's orphan severity. - Also covered: the display-label arm; a lit control; a value an extension adds (accepted) next to one nobody adds (refused); a list and an extension that live in a sibling package; and the extended-only skip. - The new message and fix arms are added to the #22161 one-line-verdict and one-line-fix rosters and to the `os explain` fact pins. - `pnpm --filter @objectstack/lint test`: 145 files and 6871 tests passed, lock VERDICT command-exit 0. `pnpm --filter @objectstack/lint typecheck`: VERDICT command-exit 0. - `pnpm --filter @objectstack/spec test:repo`: 69 files and 1134 tests passed, VERDICT command-exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 commands, and I ran all of them. Reconciled with `--ran` (each line `CMD :: exit N`): 62 exit 0 and 2 NOT MEASURED. `check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3 with PREREQUISITE NOT MET, because they need a whole-workspace `pnpm build` that I did not run locally. CI builds the full tree for them. - Consumer sweep: no example in this repository declares or translates `picklists` (`git grep -n picklists -- examples` returns 0 hits), so no shipped stack starts failing. HotCRM keeps its local guard until this ships (see the card's consumer note). ## Acceptance notes - `content/docs/protocol/kernel/i18n-standard.mdx`: the table of resolvable keys has no `picklists.{list}` / `picklists.{list}.options.{value}` rows. The file is outside this card's claimed surface, so it is left for a docs follow-up. Carrier: none named. - Boundary: suppose a field references a list (`picklist: NAME`) owned by a dependency outside the artifact. A `picklists.NAME` translation key for that list is reported as `translation-target-unknown`. This matches the object twin's verdict for an object contributed by another installed package. It does not match the CLI's `picklist-reference-unverified` notice for the reference itself. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01fafde commit 4bd7215

4 files changed

Lines changed: 323 additions & 7 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the translation-reference rules now judge a locale pack's `picklists` face (#23566)
6+
7+
Clause-②: no
8+
9+
Before this change a locale pack's `picklists` entries were never checked. A key that named nothing passed `os lint --strict`, and at runtime the resolver simply never found it, so the label rendered in the source locale. The two existing rules now cover this face, each at the severity its field-level twin already has. No new rule id is added:
10+
11+
- `picklists.<name>` for a list that no package in the stack declares is `translation-target-unknown` (error). When a declared name is close, the finding offers it as "Did you mean".
12+
- `picklists.<name>.options.<key>`, where the key is not a value of the list or of a `picklistExtensions` entry for it, is `translation-option-key-unknown` (warning). A key spelled as an option's display label is told which value to rename it to.
13+
14+
A list the stack only extends through `picklistExtensions`, and that no package of the stack declares, is skipped: its owner's options cannot be seen from here. A list declared by a sibling package of the same artifact resolves, and its option keys are judged. `os explain translation-target-unknown` and `os explain translation-option-key-unknown` name the new face.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3188,7 +3188,8 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = {
31883188
'(`manifest.navigationContributions`), the contributed ids alone, because the app\'s own ' +
31893189
'entries are declared and translated by the package that owns it. `dashboards.DASH` a ' +
31903190
'dashboard, `.widgets.ID` a widget `id` on it, and `.actions.URL` a header action\'s ' +
3191-
'`actionUrl`, not its label.',
3191+
'`actionUrl`, not its label. `picklists.LIST` a picklist\'s `name` that a package of this ' +
3192+
'stack declares.',
31923193
// The terminal toast keys are held to `FLOW_TERMINAL_MESSAGE_KEYS` by the rule's test.
31933194
'`flows.FLOW` a flow\'s machine `name`; `.successMessage` / `.errorMessage` a toast the flow ' +
31943195
'itself authors (a bundle cannot add one); `.screens.NODE` the `id` of a `type: \'screen\'` ' +
@@ -3202,8 +3203,9 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = {
32023203
'and `.acknowledge` an action that declares a `resultDialog` at all; ' +
32033204
'`resultDialog.fields.PATH` the literal `path` of a declared dialog field, dots included. A ' +
32043205
'bundle translates what the action declares and cannot add an outcome or a dialog.',
3205-
'Not judged: a platform object in the registry, and an object this stack only extends through ' +
3206-
'`objectExtensions[]` — the owner\'s field set is not visible from here, so the whole subtree ' +
3206+
'Not judged: a platform object in the registry, an object this stack only extends through ' +
3207+
'`objectExtensions[]`, and a picklist it only extends through `picklistExtensions[]` — the ' +
3208+
'owner\'s declaration is not visible from here, so the whole subtree ' +
32073209
'is skipped (a platform-prefixed name that no platform package, official plugin or cloud ' +
32083210
'runtime object registers is reported on the object key alone). `messages`, ' +
32093211
'`validationMessages`, `settings`, `settingsCommon` and `metadataForms` ' +
@@ -3222,11 +3224,12 @@ const TRANSLATION_OPTION_KEY_UNKNOWN_EXPLANATION: RuleExplanation = {
32223224
'spelling (`direct_mail`, not `Direct Mail` or `direct-mail`). A key that matches an ' +
32233225
'option\'s label is reported with the value to rename it to; any other key with the nearest ' +
32243226
'declared value.',
3225-
'Judged on three surfaces, each against the one option list its resolver walks: an object ' +
3227+
'Judged on four surfaces, each against the one option list its resolver walks: an object ' +
32263228
'field\'s `options`; an action param\'s inline `options` (a param that references a `field` ' +
32273229
'and declares no inline list takes the field\'s list when the dialog renders, so its keys are ' +
3228-
'not judged); and a flow screen field\'s own `options`. A map under something that declares ' +
3229-
'no options at all is read by nothing.',
3230+
'not judged); a flow screen field\'s own `options`; and a picklist\'s `options` together with ' +
3231+
'those its `picklistExtensions[]` entries add, which every field referencing the list ' +
3232+
'inherits. A map under something that declares no options at all is read by nothing.',
32303233
'A warning, where `translation-target-unknown` is an error: a mis-keyed option names ' +
32313234
'something real, so its fix is a rename, not a deletion.',
32323235
],

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3738,6 +3738,143 @@ describe('validateTranslationReferences — keyed children of an action entry (#
37383738
});
37393739
});
37403740

3741+
/**
3742+
* [#23566] The picklist face: `picklists.<name>` and
3743+
* `picklists.<name>.options.<value>` (`TranslationDataSchema.picklists`), read
3744+
* at runtime by `lookupPicklistOption` with the list's name and each served
3745+
* option's value. The two legs the card measured on HotCRM, each pinned at its
3746+
* field-level twin's severity, plus the controls that keep the face from
3747+
* over-stating: a lit key, an extension's added value, a sibling package's list
3748+
* and a list this stack only extends.
3749+
*/
3750+
describe('validateTranslationReferences — the picklist face (#23566)', () => {
3751+
const salutation = {
3752+
name: 'salutation',
3753+
label: 'Salutation',
3754+
options: [
3755+
{ value: 'mr', label: 'Mr.' },
3756+
{ value: 'ms', label: 'Ms.' },
3757+
],
3758+
};
3759+
const run = (picklists: Record<string, unknown>, extra: Record<string, unknown> = {}) =>
3760+
validateTranslationReferences({ picklists: [salutation], ...extra, translations: [{ 'zh-CN': { picklists } }] });
3761+
3762+
it('leg A: an option key that is not a value of the list is translation-option-key-unknown, at the field twin\'s warning', () => {
3763+
const findings = run({ salutation: { label: '称谓', options: { mr: '先生', mister: '先生' } } });
3764+
expect(findings.map((f) => [f.rule, f.severity, f.path, f.where])).toEqual([
3765+
[
3766+
TRANSLATION_OPTION_KEY_UNKNOWN,
3767+
'warning',
3768+
'translations[0]["zh-CN"].picklists.salutation.options.mister',
3769+
'locale "zh-CN" · picklist "salutation"',
3770+
],
3771+
]);
3772+
expect(findings[0].message).toBe(
3773+
'Option translation is keyed by "mister", which is not a value declared by picklist "salutation", so ' +
3774+
'the option renders untranslated.',
3775+
);
3776+
expect(findings[0].hint).toBe(
3777+
'Option keys are the stored `value` of the list or of a `picklistExtensions` entry for it, not the ' +
3778+
'label and not a variant spelling.',
3779+
);
3780+
// The field twin, same mistake, same rule and severity: the face is the twin's, not a stricter one.
3781+
const twin = validateTranslationReferences({
3782+
objects: [{ name: 'crm_contact', fields: { salutation: { type: 'select', options: salutation.options } } }],
3783+
translations: [{ 'zh-CN': { objects: { crm_contact: { fields: { salutation: { options: { mister: '先生' } } } } } } }],
3784+
});
3785+
expect(twin.map((f) => [f.rule, f.severity])).toEqual([[findings[0].rule, findings[0].severity]]);
3786+
});
3787+
3788+
it('names the nearest declared value, and a key spelled as the label gets the value to rename it to', () => {
3789+
expect(run({ salutation: { options: { mrs: 'x' } } }).map((f) => f.message)).toEqual([
3790+
'Option translation is keyed by "mrs", which is not a value declared by picklist "salutation", so the ' +
3791+
'option renders untranslated. Did you mean "mr"?',
3792+
]);
3793+
expect(run({ salutation: { options: { 'Ms.': '女士' } } }).map((f) => [f.rule, f.message, f.hint])).toEqual([
3794+
[
3795+
TRANSLATION_OPTION_KEY_UNKNOWN,
3796+
'Option translation is keyed by the DISPLAY LABEL "Ms." instead of the stored value "ms", so the ' +
3797+
'resolver never finds it and the option renders untranslated.',
3798+
'Rename the key to "ms".',
3799+
],
3800+
]);
3801+
});
3802+
3803+
it('leg B: a list no package declares is translation-target-unknown, at the field twin\'s error', () => {
3804+
const findings = run({ not_a_list: { options: { mr: '先生' } }, salutaton: { label: '称谓', options: {} } });
3805+
expect(findings.map((f) => [f.rule, f.severity, f.path, f.where, f.message, f.hint])).toEqual([
3806+
[
3807+
TRANSLATION_TARGET_UNKNOWN,
3808+
'error',
3809+
'translations[0]["zh-CN"].picklists.not_a_list',
3810+
'locale "zh-CN" · picklist "not_a_list"',
3811+
'Translations are keyed to picklist "not_a_list", which no package in this stack declares, so every ' +
3812+
'option of it renders its source-locale label.',
3813+
"Match the key to a picklist's `name`, or drop it.",
3814+
],
3815+
[
3816+
TRANSLATION_TARGET_UNKNOWN,
3817+
'error',
3818+
'translations[0]["zh-CN"].picklists.salutaton',
3819+
'locale "zh-CN" · picklist "salutaton"',
3820+
'Translations are keyed to picklist "salutaton", which no package in this stack declares, so every ' +
3821+
'option of it renders its source-locale label. Did you mean "salutation"?',
3822+
"Match the key to a picklist's `name`, or drop it.",
3823+
],
3824+
]);
3825+
// The field twin's orphan carries the same severity.
3826+
const twin = validateTranslationReferences({
3827+
objects: [{ name: 'crm_contact', fields: { salutation: { type: 'select', options: salutation.options } } }],
3828+
translations: [{ 'zh-CN': { objects: { crm_contact: { fields: { salutaton: { label: 'x' } } } } } }],
3829+
});
3830+
expect(twin.map((f) => [f.rule, f.severity])).toEqual([[TRANSLATION_TARGET_UNKNOWN, 'error']]);
3831+
});
3832+
3833+
it('lit control: every value of the list, and its label, resolve', () => {
3834+
expect(run({ salutation: { label: '称谓', options: { mr: '先生', ms: '女士' } } })).toEqual([]);
3835+
});
3836+
3837+
it('a value a `picklistExtensions` entry adds is a key of the list, a value nobody adds is not', () => {
3838+
const extensions = { picklistExtensions: [{ extend: 'salutation', options: [{ value: 'dr', label: 'Dr.' }] }] };
3839+
expect(run({ salutation: { options: { dr: '博士' } } }, extensions)).toEqual([]);
3840+
expect(run({ salutation: { options: { prof: '教授' } } }, extensions).map((f) => [f.rule, f.path])).toEqual([
3841+
[TRANSLATION_OPTION_KEY_UNKNOWN, 'translations[0]["zh-CN"].picklists.salutation.options.prof'],
3842+
]);
3843+
// The extension's label is diagnosed like the list's own.
3844+
expect(run({ salutation: { options: { 'Dr.': '博士' } } }, extensions).map((f) => f.hint)).toEqual([
3845+
'Rename the key to "dr".',
3846+
]);
3847+
});
3848+
3849+
it('a list a SIBLING package of the artifact declares resolves, and its options are judged', () => {
3850+
const stack = {
3851+
packages: [{ manifest: { id: 'com.example.core', picklists: [salutation] } }],
3852+
translations: [{ 'zh-CN': { picklists: { salutation: { options: { mr: '先生', mister: '先生' } } } } }],
3853+
};
3854+
expect(validateTranslationReferences(stack).map((f) => [f.rule, f.path])).toEqual([
3855+
[TRANSLATION_OPTION_KEY_UNKNOWN, 'translations[0]["zh-CN"].picklists.salutation.options.mister'],
3856+
]);
3857+
// …and a sibling's extension adds its value too.
3858+
const extended = {
3859+
...stack,
3860+
packages: [
3861+
...stack.packages,
3862+
{ manifest: { id: 'com.example.addon', picklistExtensions: [{ extend: 'salutation', options: [{ value: 'mister', label: 'Mister' }] }] } },
3863+
],
3864+
};
3865+
expect(validateTranslationReferences(extended)).toEqual([]);
3866+
});
3867+
3868+
it('a list this stack only EXTENDS, declared by no package of it, is skipped wholly (rung 2b, for a list)', () => {
3869+
expect(
3870+
validateTranslationReferences({
3871+
picklistExtensions: [{ extend: 'industry', options: [{ value: 'healthcare', label: 'Healthcare' }] }],
3872+
translations: [{ 'zh-CN': { picklists: { industry: { options: { healthcare: '医疗', technology: '科技' } } } } }],
3873+
}),
3874+
).toEqual([]);
3875+
});
3876+
});
3877+
37413878
describe('[#22161] one-line verdicts — translation-target-unknown, translation-option-key-unknown', () => {
37423879
const CONVERTED = [TRANSLATION_TARGET_UNKNOWN, TRANSLATION_OPTION_KEY_UNKNOWN];
37433880

@@ -3776,6 +3913,7 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
37763913
'declares no `resultDialog`, so nothing reads this label',
37773914
'`resultDialog.fields` does not declare',
37783915
'declares no `fields` (it renders the whole response as JSON)',
3916+
'Translations are keyed to picklist "',
37793917
];
37803918
const OPTION_ARMS = [
37813919
'which declares no `options` at all (field type',
@@ -3785,6 +3923,7 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
37853923
'which is not a value declared by parameter "',
37863924
'Option translations are keyed under screen field "',
37873925
'which is not a value declared by screen field "',
3926+
'which is not a value declared by picklist "',
37883927
];
37893928

37903929
it('refuses a result field under a dialog that declares no `fields` (the one arm no case above fires)', () => {
@@ -3861,13 +4000,16 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
38614000
'`objectExtensions[]`',
38624001
'official plugin or cloud runtime object',
38634002
'`metadataForms`',
4003+
'`picklists.LIST`',
4004+
'a picklist it only extends through `picklistExtensions[]`',
38644005
],
38654006
[TRANSLATION_OPTION_KEY_UNKNOWN]: [
38664007
'stored `value`',
38674008
'`1` for a number value and `true` for a boolean',
38684009
'`direct-mail`',
38694010
'inline `options`',
38704011
"screen field's own `options`",
4012+
'its `picklistExtensions[]` entries add',
38714013
'a rename, not a deletion',
38724014
],
38734015
};
@@ -3922,6 +4064,7 @@ describe('[#22161] one-line fixes — translation-target-unknown', () => {
39224064
"Match the key to an outcome the action's `outcomeMessages` declares",
39234065
'Move the `resultDialog` translations under the action',
39244066
"Match the key to a declared field's literal `path`",
4067+
"Match the key to a picklist's `name`",
39254068
];
39264069

39274070
it('every `fix:` line the cases above fired is one line of at most 200 characters', () => {

0 commit comments

Comments
 (0)