Skip to content

Commit ee42f00

Browse files
feat(lint): os validate judges every keyed child of an action translation entry against its declaration (#21258)
Fixes #21216 Clause-②: yes ## What changed `validateTranslationReferences` (`packages/lint/src/validate-translation-references.ts`) is the rule behind `translation-target-unknown`, and `os validate`, `os build` and `os lint` all run it. It now judges every keyed child of an action's translation entry against what the action declares. Both addresses are covered: `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`. Before this change the walk (`checkActionParams`) judged `params.NAME` and nothing else. | bundle key | judged against | code · level | the runtime read it mirrors | |---|---|---|---| | `params.NAME` | `params[].name`, falling back to `field` | `translation-target-unknown` · error (unchanged) | `translateActionParams` | | `outcomeMessages.OUTCOME` | the keys of the action's `outcomeMessages` | `translation-target-unknown` · error (**new**) | `resolveActionOutcomeMessages` | | `resultDialog.fields.PATH` | each literal `resultDialog.fields[].path` | `translation-target-unknown` · error (**new**) | `resolveActionResultDialog`, and objectui's `actionResultDialog` | | `params.NAME.options.VALUE` | a declared param's inline `options[].value` | `translation-option-key-unknown` · warning (**new**) | `translateActionParams`, and objectui's `actionParamOptionLabel` | - **No second vocabulary.** Undeclared outcomes and result-field paths reuse `params`' own code and severity. Option keys reuse the code and severity a field's `options` key already gets (`checkOptionKeys`), including the "keyed by the display label, rename it to the value" diagnosis. The module's Severity note explains why option keys stay warnings: such a key names something real, and the fix is a rename. - **Where the declared sets come from (runtime read sites, on `packages/spec/src/system/i18n-resolver.ts`):** - `resolveActionOutcomeMessages` loops over `Object.keys(action.outcomeMessages)`. When the action declares no `outcomeMessages`, it returns `undefined` before reading any bundle key. So for such an action every bundle outcome is unread, and the lint refuses each one. - `resolveActionResultDialog` maps over the declared `fields[]` and looks up `fields[field.path]`. With no `resultDialog` it reads nothing. With a dialog that declares no `fields`, it reads no label. objectui's `useObjectLabel.actionResultDialog` uses the same keys. - Param options are judged only against an **inline** `options` array. The spec resolver overlays only that array. objectui resolves `param.options` first and falls back to the referenced field's options, so an authored array wins. A field-backed param with no inline array gets its list from the field when the dialog renders. This rule does not re-derive that inheritance, so those keys are deliberately left unjudged. - A result-field path is one literal key, dots included, so the finding's config path brackets it: `resultDialog.fields["client.ghost"]`. `localePath` now uses the same `memberPath` helper, and its output is byte-identical. - **Coverage pin.** The test file gets a twin of the object-branch coverage pin. It reads the action translation shape off `ObjectTranslationDataSchema` and `TranslationDataSchema` (both addresses), down into `params.NAME.*` and `resultDialog.*`, and holds that key set equal to a classification ledger. Every `reference-checked` key needs a ghost that reports at both addresses. When the next keyed group joins the shape with no leg, this pin fails. That gap is how `outcomeMessages` shipped unjudged. ## Measured at the public door (`os validate --json`) The probe stack is a `defineStack` app with `delete_probe` (bound, declares outcome `archived`, params `reason`, and `mode` with inline option `fast`), `reveal_probe` (bound, `resultDialog.fields[].path` = `client.secret`), `check_probe` (object-less, outcome `up_to_date`) and `reveal_global` (object-less, path `token`). Its `zh-CN` bundle carries each declared key and one undeclared sibling. The CLI closure was built at each tree. | key in the bundle | before, `be5a83cf` (clean `main`) | after, `ac847edd` / `dd39cf54` | |---|---|---| | `_actions.reveal_probe.resultDialog.fields["client.ghost"]` | no finding | `translation-target-unknown` error | | `globalActions.reveal_global.resultDialog.fields.ghost_token` | no finding | `translation-target-unknown` error | | `_actions.delete_probe.outcomeMessages.restored` | no finding | `translation-target-unknown` error | | `globalActions.check_probe.outcomeMessages.stale` | no finding | `translation-target-unknown` error | | `_actions.delete_probe.params.mode.options.slow` | no finding | `translation-option-key-unknown` warning | | `_actions.delete_probe.params.not_a_param` (control: the door is reached) | `translation-target-unknown` error | unchanged | | declared `archived`, `up_to_date`, `client.secret`, `token`, `fast`, `reason` (controls) | no finding | no finding | ## Consumers whose verdict could move (direction: downstream of `@objectstack/lint`) - `os validate --json` over the four example apps, after the change: `app-crm` exit 0, `app-todo` exit 0, `app-showcase` exit 0, `app-multi-package` exit 0. Each has **0** findings on an `outcomeMessages`, `resultDialog.fields` or `params.NAME.options` path, and 0 translation-rule findings at all. These legs judged nothing before this change, so any finding on those paths would have been new. - The bundle `@objectstack/platform-objects` ships (`SetupAppTranslations`: 4 locales, 50 objects) was run through the rule. It carries 64 `resultDialog.fields` keys and 44 param-option keys, and none of them is flagged. It carries 0 outcome keys. Docs and `skills/**` hold no translation bundle with these keys. No published bundle needed a fix. ## Tests All runs are at `ac847edd`. The lint source in `dd39cf54` is byte-identical; that commit adds only the changeset. - `pnpm --filter @objectstack/lint test`: 119 files / **5517 passed**. - `pnpm --filter @objectstack/lint typecheck` (tsc plus `check:test-typecheck`): exit 0. The test-layer ledger holds 2 files / 6 errors, unchanged. - `pnpm --filter @objectstack/cli exec vitest run --project unit`, at `dd39cf54`, in two shards: 122 files / 1861 passed, then 121 files / 1578 passed. The `integration` tier is left to CI, because no integration-tier file or spawn entry is touched. - New in `validate-translation-references.test.ts`: 15 cases. An undeclared outcome is refused at `_actions.ACTION` and at `globalActions.ACTION`. Declared outcomes pass at both addresses (the control). An action with no `outcomeMessages` has every outcome refused. The same three shapes cover `resultDialog.fields`, including the bracketed dotted path. Param options cover a value miss, a label key, a param with neither options nor field, the field-backed skip, and an undeclared param reported once. The coverage pin adds 3 cases. **Reverse verification** (committed first, then mutated through `scripts/ablation-replace.mjs` in WRAP mode, with a restore trap; every restore was proven by blob hash equal to `HEAD` and an empty `git diff HEAD`): - Leg A drops the `outcomeMessages` and `resultDialog.fields` calls: **7 failed** / 142 passed. Those are the 6 refusal cases plus the coverage pin's "backs every reference-checked claim". The 2 control cases stay green. - Leg B disables the param-options call: **3 failed** / 146 passed. Those are the 2 option warnings plus the coverage pin. - The direction is turned red, as expected. The suite reads `src` through a relative import, so no `dist/` leg applies. The door probe's `@objectstack/lint` dist was checked with `scripts/ablation-dist-preflight.mjs @objectstack/lint checkActionResultDialogFields`: the marker is present in 4 built files and the tree is clean. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 60 families from the merge base `be5a83cf`, and all 60 were run at `dd39cf54`. `--ran` reports **60 run, 0 NOT-MEASURED, 0 UNRUN**, and every exit is 0. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 unrelated packages had no `dist/`). After a turbo build of those 8, it was re-run and exited 0. The derivation flagged a stale tree: `origin/main` is 4 commits ahead, and `scripts/check-route-envelope.mjs` and `scripts/engine-double-contract.pinned.json` changed upstream. Neither family reads the files this PR edits. The 6 value-bearing CI-only invocations (shard attestation, test completeness, `check-issue-citations --census`) are NOT MEASURED here because they are CI-only. ESLint was narrowed to the two edited files. Their population comes from eslint's own config: `--print-config` resolves a config for each. The JSON output reports 2 files, 0 errors and 0 warnings. Type-aware linting is not enabled (`eslint.config.mjs` sets no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. ## Acceptance notes Both of these were measured at the door with a second probe and reported to the PM rather than fixed here. - A bound action's `globalActions.ACTION` key is refused with "This key is never read". `translateAction` (via `lookupActionField`) and objectui's `actionSuffixes` both fall back to `globalActions.ACTION` for a bound action, and that probe's label resolves. So the lint's message and the resolvers disagree, and this PR touches neither side. - `_actions.ACTION.resultDialog.title`, `.description` and `.acknowledge` on an action that declares no `resultDialog` pass silently, and nothing reads them. These are leaf copy on an undeclared node, not a keyed child, so the coverage pin classifies `resultDialog` as a container and this PR leaves them unjudged. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7923c8e commit ee42f00

3 files changed

Lines changed: 604 additions & 22 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`os validate`, `os build` and `os lint` now check every keyed child of an action's translation entry against what the action declares, under both `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`. Before this, only `params.NAME` was checked.
6+
7+
Clause-②: yes
8+
9+
**What is refused.** Two keys are new `translation-target-unknown` errors, the same code and level an undeclared `params` key already gets:
10+
11+
- `outcomeMessages.OUTCOME` when the action's own `outcomeMessages` does not declare that outcome, or declares no `outcomeMessages` at all. `translateAction` overlays only the outcomes the action declares, so such copy is never shown.
12+
- `resultDialog.fields.PATH` when no entry of the action's `resultDialog.fields[]` has that literal `path`, or the action declares no `resultDialog` or a dialog with no `fields`. The label lookup is keyed by each declared field's `path`, dots included, so such a label is never shown. The finding's config path quotes a dotted key as one member (`resultDialog.fields["client.ghost"]`).
13+
14+
**What is warned.** `params.NAME.options.VALUE` under a declared param is judged against that param's inline `options[].value`. It is a `translation-option-key-unknown` warning, the code and level a field's `options` key already gets, and it names the stored value when the key is a display label. An options map under a param with no inline options and no `field` is warned once, because nothing reads it. A field-backed param with no inline `options` inherits its list from the field when the dialog renders, so its option keys are not judged.
15+
16+
**What changes for a project.** A bundle that carries one of the refused keys now fails `os validate` with exit 1 instead of passing, and `os build` refuses it. The fix is to rename the key to a declared outcome or result-field `path`, declare the outcome or field on the action first, or delete the key. The option-key warning changes an exit code only under `--strict`. The four example apps (`app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) and the bundle shipped with `@objectstack/platform-objects` produce no finding on any of these keys, so none of their exit codes change.

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

Lines changed: 339 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest';
44
// The contract the object branch's coverage is pinned against (#13835) — see
55
// the coverage describe block at the foot of this file. `@objectstack/spec` is
66
// already a runtime dependency of this package, so the pin adds no edge.
7-
import { ObjectTranslationDataSchema } from '@objectstack/spec/system';
7+
import { ObjectTranslationDataSchema, TranslationDataSchema } from '@objectstack/spec/system';
88
// The schema the #18441 fold is SIZED against — see the surface pin in that
99
// block. Same package, already a runtime dependency, so no new edge either.
1010
import { ObjectExtensionSchema } from '@objectstack/spec/data';
@@ -3073,3 +3073,341 @@ describe('validateTranslationReferences — object-branch coverage vs the schema
30733073
expect(findings).toEqual([]);
30743074
});
30753075
});
3076+
3077+
/**
3078+
* #21216 — every KEYED child of an action's translation entry, judged against
3079+
* the declaration `translateAction` reads it through, at both addresses
3080+
* (`objects.<obj>._actions.<name>` and `globalActions.<name>`).
3081+
*
3082+
* `params.<name>` was the only one judged; `outcomeMessages.<outcome>` (#21095)
3083+
* and `resultDialog.fields.<path>` joined the shape with no leg following, so a
3084+
* key the action does not declare parsed, linted clean and was read by nothing.
3085+
*/
3086+
describe('validateTranslationReferences — keyed children of an action entry (#21216)', () => {
3087+
/**
3088+
* Spec-legal actions, one per keyed group, bound and object-less. Kept apart
3089+
* because `outcomeMessages` is refused beside `resultDialog`.
3090+
*/
3091+
const actionStack = (bundle: Record<string, unknown>) => ({
3092+
objects: [{ name: 'crm_env', label: 'Env', fields: { name: { type: 'text', label: 'Name' } } }],
3093+
actions: [
3094+
{
3095+
name: 'archive_env',
3096+
label: 'Archive',
3097+
objectName: 'crm_env',
3098+
type: 'script',
3099+
target: 'archiveEnv',
3100+
outcomeMessages: { archived: 'Archived.', already_archived: 'Already archived.' },
3101+
params: [
3102+
{
3103+
name: 'mode',
3104+
label: 'Mode',
3105+
type: 'select',
3106+
options: [
3107+
{ value: 'soft', label: 'Soft' },
3108+
{ value: 'hard', label: 'Hard delete' },
3109+
],
3110+
},
3111+
{ field: 'name' },
3112+
{ name: 'reason', label: 'Reason', type: 'text' },
3113+
],
3114+
},
3115+
{
3116+
name: 'rotate_secret',
3117+
label: 'Rotate',
3118+
objectName: 'crm_env',
3119+
type: 'api',
3120+
target: '/api/v1/rotate',
3121+
resultDialog: { title: 'Secret', fields: [{ path: 'client.secret', label: 'Secret' }, { path: 'token' }] },
3122+
},
3123+
{ name: 'plain_env', label: 'Plain', objectName: 'crm_env', type: 'script', target: 'plainEnv' },
3124+
{
3125+
name: 'check_updates',
3126+
label: 'Check',
3127+
type: 'api',
3128+
target: '/api/v1/updates',
3129+
outcomeMessages: { up_to_date: 'Up to date.' },
3130+
},
3131+
{
3132+
name: 'mint_token',
3133+
label: 'Mint',
3134+
type: 'api',
3135+
target: '/api/v1/mint',
3136+
resultDialog: { fields: [{ path: 'token' }] },
3137+
},
3138+
],
3139+
translations: [{ 'zh-CN': bundle }],
3140+
});
3141+
const onEnv = (actions: Record<string, unknown>) => ({ objects: { crm_env: { _actions: actions } } });
3142+
const ENV = 'translations[0]["zh-CN"].objects.crm_env._actions';
3143+
const GLOBAL = 'translations[0]["zh-CN"].globalActions';
3144+
3145+
describe('outcomeMessages.<outcome>', () => {
3146+
it('refuses an outcome `_actions.<name>` does not declare, at the code and level `params` uses', () => {
3147+
const findings = validateTranslationReferences(
3148+
actionStack(onEnv({ archive_env: { outcomeMessages: { archived: '已归档。', restored: '已恢复。' } } })),
3149+
);
3150+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3151+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.archive_env.outcomeMessages.restored`],
3152+
]);
3153+
});
3154+
3155+
it('refuses an outcome `globalActions.<name>` does not declare', () => {
3156+
const findings = validateTranslationReferences(
3157+
actionStack({ globalActions: { check_updates: { outcomeMessages: { up_to_date: '已是最新。', stale: '已过期。' } } } }),
3158+
);
3159+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3160+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${GLOBAL}.check_updates.outcomeMessages.stale`],
3161+
]);
3162+
});
3163+
3164+
it('accepts every outcome the action declares, at both addresses (the control)', () => {
3165+
expect(
3166+
validateTranslationReferences(
3167+
actionStack({
3168+
...onEnv({ archive_env: { outcomeMessages: { archived: '已归档。', already_archived: '早已归档。' } } }),
3169+
globalActions: { check_updates: { outcomeMessages: { up_to_date: '已是最新。' } } },
3170+
}),
3171+
),
3172+
).toEqual([]);
3173+
});
3174+
3175+
it('refuses every outcome on an action that declares no `outcomeMessages` — nothing reads the map', () => {
3176+
const findings = validateTranslationReferences(
3177+
actionStack(onEnv({ plain_env: { outcomeMessages: { archived: '已归档。' } } })),
3178+
);
3179+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3180+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.outcomeMessages.archived`],
3181+
]);
3182+
});
3183+
});
3184+
3185+
describe('resultDialog.fields.<path>', () => {
3186+
it('refuses a result field `_actions.<name>` does not declare, quoting a dotted path as ONE key', () => {
3187+
const findings = validateTranslationReferences(
3188+
actionStack(
3189+
onEnv({
3190+
rotate_secret: {
3191+
resultDialog: { title: '密钥', fields: { 'client.secret': '密钥', token: '令牌', 'client.ghost': '幽灵' } },
3192+
},
3193+
}),
3194+
),
3195+
);
3196+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3197+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.rotate_secret.resultDialog.fields["client.ghost"]`],
3198+
]);
3199+
});
3200+
3201+
it('refuses a result field `globalActions.<name>` does not declare', () => {
3202+
const findings = validateTranslationReferences(
3203+
actionStack({ globalActions: { mint_token: { resultDialog: { fields: { token: '令牌', ghost_token: '幽灵' } } } } }),
3204+
);
3205+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3206+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${GLOBAL}.mint_token.resultDialog.fields.ghost_token`],
3207+
]);
3208+
});
3209+
3210+
it('accepts every declared path, at both addresses (the control)', () => {
3211+
expect(
3212+
validateTranslationReferences(
3213+
actionStack({
3214+
...onEnv({ rotate_secret: { resultDialog: { fields: { 'client.secret': '密钥', token: '令牌' } } } }),
3215+
globalActions: { mint_token: { resultDialog: { title: '令牌', fields: { token: '令牌' } } } },
3216+
}),
3217+
),
3218+
).toEqual([]);
3219+
});
3220+
3221+
it('refuses a result field on an action that declares no `resultDialog`', () => {
3222+
const findings = validateTranslationReferences(
3223+
actionStack(onEnv({ plain_env: { resultDialog: { fields: { token: '令牌' } } } })),
3224+
);
3225+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3226+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.fields.token`],
3227+
]);
3228+
});
3229+
});
3230+
3231+
describe('params.<name>.options.<value>', () => {
3232+
it('warns on an option key a declared param does not declare, as the field `options` leg does', () => {
3233+
const findings = validateTranslationReferences(
3234+
actionStack(
3235+
onEnv({
3236+
archive_env: { params: { mode: { options: { soft: '软', hard: '硬', 'Hard delete': '彻底删除', purge: '清除' } } } },
3237+
}),
3238+
),
3239+
);
3240+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3241+
[TRANSLATION_OPTION_KEY_UNKNOWN, 'warning', `${ENV}.archive_env.params.mode.options.Hard delete`],
3242+
[TRANSLATION_OPTION_KEY_UNKNOWN, 'warning', `${ENV}.archive_env.params.mode.options.purge`],
3243+
]);
3244+
});
3245+
3246+
it('warns once on an options map under a param that declares no options and references no field', () => {
3247+
const findings = validateTranslationReferences(
3248+
actionStack(onEnv({ archive_env: { params: { reason: { label: '原因', options: { a: 'A' } } } } })),
3249+
);
3250+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3251+
[TRANSLATION_OPTION_KEY_UNKNOWN, 'warning', `${ENV}.archive_env.params.reason.options`],
3252+
]);
3253+
});
3254+
3255+
it('leaves a FIELD-BACKED param with no inline options unjudged — its list is inherited at render time', () => {
3256+
expect(
3257+
validateTranslationReferences(
3258+
actionStack(onEnv({ archive_env: { params: { name: { label: '名称', options: { anything: '任意' } } } } })),
3259+
),
3260+
).toEqual([]);
3261+
});
3262+
3263+
it('judges an undeclared param once, as the param — never its option keys as well', () => {
3264+
const findings = validateTranslationReferences(
3265+
actionStack(onEnv({ archive_env: { params: { ghost: { options: { soft: '软' } } } } })),
3266+
);
3267+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3268+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.archive_env.params.ghost`],
3269+
]);
3270+
});
3271+
});
3272+
3273+
/**
3274+
* The action-entry twin of the #13835 object-branch pin above: every key the
3275+
* action translation shape declares — down into `params.<name>.*` and
3276+
* `resultDialog.*` — classified, and every `reference-checked` claim backed
3277+
* by a ghost that must report at BOTH addresses. A keyed group that joins the
3278+
* shape with no leg now fails here, which is how `outcomeMessages` went
3279+
* unjudged.
3280+
*/
3281+
describe('coverage vs the action translation shape', () => {
3282+
type ShapeNode = {
3283+
_zod: { def: { type: string; innerType?: ShapeNode; valueType?: ShapeNode } };
3284+
shape?: Record<string, ShapeNode>;
3285+
};
3286+
/** Unwrap optional / record wrappers down to the object shape they hold. */
3287+
const objectShapeOf = (schema: unknown): Record<string, ShapeNode> => {
3288+
let node = schema as ShapeNode;
3289+
while (!node.shape) {
3290+
const def = node._zod.def;
3291+
const next = def.type === 'record' ? def.valueType : def.innerType;
3292+
if (!next) throw new Error(`no object shape under a "${def.type}" node`);
3293+
node = next;
3294+
}
3295+
return node.shape;
3296+
};
3297+
const entryKeys = (address: unknown): string[] => {
3298+
const entry = objectShapeOf(address);
3299+
return [
3300+
...Object.keys(entry),
3301+
...Object.keys(objectShapeOf(entry.params)).map((k) => `params.*.${k}`),
3302+
...Object.keys(objectShapeOf(entry.resultDialog)).map((k) => `resultDialog.${k}`),
3303+
].sort();
3304+
};
3305+
3306+
type Address = 'bound' | 'global';
3307+
/** Which declared action a ghost is written under, per address. */
3308+
const HOST: Record<'outcome' | 'dialog', Record<Address, string>> = {
3309+
outcome: { bound: 'archive_env', global: 'check_updates' },
3310+
dialog: { bound: 'rotate_secret', global: 'mint_token' },
3311+
};
3312+
type Coverage =
3313+
| { kind: 'reference-checked'; host: 'outcome' | 'dialog'; ghost: Record<string, unknown>; suffix: string; rule: string }
3314+
| { kind: 'leaf-copy' | 'container'; why: string };
3315+
3316+
const COVERAGE: Record<string, Coverage> = {
3317+
label: { kind: 'leaf-copy', why: 'prose' },
3318+
description: { kind: 'leaf-copy', why: 'prose' },
3319+
confirmText: { kind: 'leaf-copy', why: 'prose' },
3320+
successMessage: { kind: 'leaf-copy', why: 'prose' },
3321+
outcomeMessages: {
3322+
kind: 'reference-checked',
3323+
host: 'outcome',
3324+
ghost: { outcomeMessages: { ghost_outcome: 'x' } },
3325+
suffix: '.outcomeMessages.ghost_outcome',
3326+
rule: TRANSLATION_TARGET_UNKNOWN,
3327+
},
3328+
params: {
3329+
kind: 'reference-checked',
3330+
host: 'outcome',
3331+
ghost: { params: { ghost_param: { label: 'x' } } },
3332+
suffix: '.params.ghost_param',
3333+
rule: TRANSLATION_TARGET_UNKNOWN,
3334+
},
3335+
'params.*.label': { kind: 'leaf-copy', why: 'prose' },
3336+
'params.*.helpText': { kind: 'leaf-copy', why: 'prose' },
3337+
'params.*.placeholder': { kind: 'leaf-copy', why: 'prose' },
3338+
'params.*.options': {
3339+
kind: 'reference-checked',
3340+
host: 'outcome',
3341+
ghost: { params: { mode: { options: { ghost_value: 'x' } } } },
3342+
suffix: '.params.mode.options.ghost_value',
3343+
rule: TRANSLATION_OPTION_KEY_UNKNOWN,
3344+
},
3345+
resultDialog: { kind: 'container', why: 'a fixed-key node; its keyed child is `resultDialog.fields`' },
3346+
'resultDialog.title': { kind: 'leaf-copy', why: 'prose' },
3347+
'resultDialog.description': { kind: 'leaf-copy', why: 'prose' },
3348+
'resultDialog.acknowledge': { kind: 'leaf-copy', why: 'prose' },
3349+
'resultDialog.fields': {
3350+
kind: 'reference-checked',
3351+
host: 'dialog',
3352+
ghost: { resultDialog: { fields: { ghost_path: 'x' } } },
3353+
suffix: '.resultDialog.fields.ghost_path',
3354+
rule: TRANSLATION_TARGET_UNKNOWN,
3355+
},
3356+
};
3357+
3358+
// The global action needs a param with inline options for the options ghost.
3359+
const coverageStack = (bundle: Record<string, unknown>) => {
3360+
const stack = actionStack(bundle);
3361+
const check = stack.actions.find((a) => a.name === 'check_updates') as Record<string, unknown>;
3362+
check.params = [{ name: 'mode', label: 'Mode', type: 'select', options: [{ value: 'soft', label: 'Soft' }] }];
3363+
return stack;
3364+
};
3365+
const bundleAt = (address: Address, actionName: string, node: Record<string, unknown>) =>
3366+
address === 'bound' ? onEnv({ [actionName]: node }) : { globalActions: { [actionName]: node } };
3367+
3368+
it('classifies every key the action translation shape declares, at both addresses', () => {
3369+
const bound = entryKeys((ObjectTranslationDataSchema as unknown as { shape: Record<string, unknown> }).shape._actions);
3370+
const global = entryKeys((TranslationDataSchema as unknown as { shape: Record<string, unknown> }).shape.globalActions);
3371+
expect(global).toEqual(bound);
3372+
expect(Object.keys(COVERAGE).sort()).toEqual(bound);
3373+
});
3374+
3375+
it('backs every `reference-checked` claim with a leg that reports at both addresses', () => {
3376+
for (const [key, coverage] of Object.entries(COVERAGE)) {
3377+
if (coverage.kind !== 'reference-checked') continue;
3378+
for (const address of ['bound', 'global'] as const) {
3379+
const actionName = HOST[coverage.host][address];
3380+
const findings = validateTranslationReferences(
3381+
coverageStack(bundleAt(address, actionName, coverage.ghost)),
3382+
);
3383+
const prefix = address === 'bound' ? `${ENV}.${actionName}` : `${GLOBAL}.${actionName}`;
3384+
expect(findings.map((f) => f.path), `"${key}" has no working leg at the ${address} address`).toEqual([
3385+
`${prefix}${coverage.suffix}`,
3386+
]);
3387+
expect(findings[0].rule).toBe(coverage.rule);
3388+
}
3389+
}
3390+
});
3391+
3392+
it('accepts the real instance of every keyed group at both addresses at once', () => {
3393+
const real = (address: Address): Record<string, unknown> => {
3394+
const outcome = HOST.outcome[address];
3395+
const dialog = HOST.dialog[address];
3396+
return {
3397+
[outcome]: {
3398+
label: 'x',
3399+
description: 'x',
3400+
confirmText: 'x',
3401+
successMessage: 'x',
3402+
outcomeMessages: address === 'bound' ? { archived: 'x' } : { up_to_date: 'x' },
3403+
params: { mode: { label: 'x', helpText: 'x', placeholder: 'x', options: { soft: 'x' } } },
3404+
},
3405+
[dialog]: { resultDialog: { title: 'x', description: 'x', acknowledge: 'x', fields: { token: 'x' } } },
3406+
};
3407+
};
3408+
expect(
3409+
validateTranslationReferences(coverageStack({ ...onEnv(real('bound')), globalActions: real('global') })),
3410+
).toEqual([]);
3411+
});
3412+
});
3413+
});

0 commit comments

Comments
 (0)