Skip to content

Commit 1371dc9

Browse files
feat(lint): os validate refuses an action translation's resultDialog title / description / acknowledge when the action declares no resultDialog (#21304)
Fixes #21264 Clause-②: no (narrowing) ## What changed `validateTranslationReferences` (`packages/lint/src/validate-translation-references.ts`) is the rule behind `translation-target-unknown`, which `os validate`, `os build` and `os lint` all run. Its action-entry walk (`checkActionEntry`) now judges the result dialog's prose leaves, `resultDialog.title`, `resultDialog.description` and `resultDialog.acknowledge`, at both action addresses, `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`. | bundle key | judged against | code · level | the runtime read it mirrors | |---|---|---|---| | `resultDialog.title` / `.description` / `.acknowledge` | whether the action declares a `resultDialog` at all | `translation-target-unknown` · error (**new**) | `resolveActionResultDialog` | | `resultDialog.fields.PATH` | each literal `resultDialog.fields[].path` | `translation-target-unknown` · error (unchanged) | `resolveActionResultDialog` | - One finding per leaf, at the level the walk already uses for an undeclared target. No second vocabulary. - Under a declared dialog every leaf passes, including a leaf the dialog does not set itself. The resolver overlays a translated `title` onto a dialog that declares no `title`, so declaring the dialog is the whole condition. - The action-entry coverage pin (`coverage vs the action translation shape`) moves the three leaves from `leaf-copy` to `reference-checked`. Each is backed by a ghost that must report at both addresses, under a new host `noDialog` (an action that declares no `resultDialog`). `resultDialog` itself stays a `container`. - Docs: the rule table in `content/docs/protocol/kernel/i18n-standard.mdx` gains every keyed child of an action entry the walk judges: `params.NAME.options.VALUE`, `outcomeMessages.OUTCOME`, `resultDialog.fields.PATH` and this card's leaves. A lead sentence says these rows hold at both action addresses. The `globalActions` row states only what `os validate` does today: it resolves when it names an action with no `objectName`, and a key naming a bound action is an error whose message names the `objects.OWNER._actions.ACTION` key to write instead, OWNER being the action's own `objectName`. It says nothing about where the copy is read from; both resolvers still fall back to `globalActions.ACTION` for a bound action until #21261 lands. - Changeset: `.changeset/21264-result-dialog-leaves.md`, `@objectstack/lint` `minor`, with `Clause-②: no (narrowing)`, a BREAKING banner heading its "What changes for a project" paragraph, and one ADR-0087 disposition, `not-required (no-migration-prescription)`, with its reason in writing. ## Why `resolveActionResultDialog` (`packages/spec/src/system/i18n-resolver.ts`) returns the action's own `resultDialog` untouched when the action declares none, before any lookup. So a dialog leaf under such an action is read by nothing, while `os validate` passed it clean. A bundle can translate a dialog the action declares. It cannot add one. ## Measured All readings below are on this branch, `objectstack-ai/objectstack`. The base is `origin/main` `4e6dc2338a`, and HEAD is `46b40dc845`. **The door, `os validate --json`, before and after.** The probe stack has a bound action `plain_probe` with no `resultDialog`. Its bundle carries `title`, `description` and `acknowledge` under `resultDialog`, plus a `params.ghost` control. A bound action `dialog_probe` declares a dialog with no `title`, and its bundle carries all three leaves. An object-less `plain_global` with no dialog carries `resultDialog.title`. An object-less `dialog_global` declares a dialog and carries all three leaves. | key | base `4e6dc2338a` | HEAD `46b40dc845` | |---|---|---| | `..._actions.plain_probe.params.ghost` (control) | error | error | | `..._actions.plain_probe.resultDialog.title` / `.description` / `.acknowledge` | no finding | 3 errors | | `globalActions.plain_global.resultDialog.title` | no finding | error | | `..._actions.dialog_probe.resultDialog.*` (declared dialog) | no finding | no finding | | `globalActions.dialog_global.resultDialog.*` (declared dialog) | no finding | no finding | Before: 1 error. After: 5 errors, and both runs exit 1. **The runtime read.** `translateAction` (`@objectstack/spec/system`, dist), on the same bundle in `zh-CN`: - `plain_probe` and `plain_global` return no `resultDialog`, so the leaves are never read. - `dialog_probe` returns `title` `令牌已生成`, `description` `请妥善保存` and `acknowledge` `已保存`, though its own dialog sets no `title`. The field label `token` is `令牌`. - `dialog_global` returns `已生成`, `全局说明` and `好的`. So the control is a real read. **Census.** - `os validate --json` on each example app at HEAD: `app-crm` exit 0, `app-todo` exit 0, `app-showcase` exit 0, `app-multi-package` exit 0. That is 0 findings on any `resultDialog` path. - The rule run over `@objectstack/platform-objects`' 50 objects and its shipped four-locale bundle (dist): the bundle carries 108 dialog leaf keys, from 9 actions × 3 leaves × 4 locales. All 108 sit under a declaring action. There are 0 findings on a `resultDialog` path, and 16 findings in total, the same total #21216's dev read. - `git grep` over the 98 tracked translation-bundle files: `resultDialog` appears only in `platform-objects`' four locale files. No example bundle carries one. **Ablation.** Each leg is committed first, then mutated through `scripts/ablation-replace.mjs`, which uses a literal anchor, proves the mutation is on disk by blob hash, and restores with `git checkout HEAD`. The test imports the rule from `src` by a relative path, so no `dist` build sits between the mutation and the run. - Leg 1 drops the new leg's call, `checkActionResultDialogLeaves(findings, ctx);`. Blob `de2dc9a541a9` becomes `8133e71705ce`. 3 tests fail and 149 pass: both new refusals, and the coverage pin's backing test. Restored: blob equals HEAD and `git diff HEAD` is empty. - Leg 2 drops the declared-dialog guard, `if (isRec(ctx.action.resultDialog)) return;`. Blob `de2dc9a541a9` becomes `1892f4a64d54`. 4 tests fail and 148 pass: this card's declared-dialog control, the `resultDialog.fields` control (`accepts every declared path…`), `accepts the real instance of every keyed group…`, and the existing dotted-path refusal. Each of the four carries a translated leaf under a declared dialog. Restored the same way. - The direction is the usual one: the tests turned red. ## Tests (at HEAD `46b40dc845`) - `pnpm --filter @objectstack/lint test`: 119 files and 5518 tests pass. - `pnpm --filter @objectstack/lint typecheck`: exit 0, including `check:test-typecheck`. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 243 files and 3439 tests pass. The `integration` layer is left to CI, because this diff touches no spawn entry. - Filter direction: downstream (`...@objectstack/lint`), narrowed to `@objectstack/cli`, the `os validate` door the dispatch names. No other importer owes tests, because `@objectstack/lint`'s export surface is byte-unchanged: the new function is module-private. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths) derives 88 commands, and all 88 exit 0. `--ran` reconciles 88 derived, 88 run, 0 NOT-MEASURED, with every exit code recorded. - Two gates first answered `PREREQUISITE NOT MET` (exit 3): `check:skill-examples` (no `client-react` dist) and `check:dual-build-cjs-loads` (8 packages with no dist). They passed once those packages were built. - origin/main moved to `5a9292e6f6` during the run, and only `.github/workflows/ci.yml` changed among the derivation's inputs. A derivation from a tree at `5a9292e6f6`, given this diff's 4 paths, yields the same 88 commands. - ESLint, narrowed and measured. The population, read from `eslint.config.mjs`: only `ts`, `tsx`, `mts`, `cts`, `js`, `jsx`, `mjs` and `cjs` globs are linted, so of this diff's 4 paths the 2 `.ts` files are the population. Running `eslint --no-inline-config --format json` on them gives 2 files, 0 errors and 0 warnings. Invariance: the config enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is left to CI. - Round 2 (head `b82d990cb9`: the changeset and the docs row only; code and tests unchanged since `46b40dc845`): `node scripts/check-adr-0087-registration.mjs --base origin/main` exits 1 with the arm corrected and no marker (signals BREAKING and clause-②-narrowing), and exits 0 with the marker. `node scripts/check-changeset-no-major.mjs --base origin/main` exits 0. With this body passed as the `pull_request` event (`--event`), it also exits 0 and its level axis reads `no (narrowing)`, a BREAKING change that ships `minor` during the launch window. The 40 families `dispatch-gates.mjs` derives for the `.mdx` path all exit 0, as do the changeset path's `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:objectui-changeset`, `check:pm-changeset-deadline-census` and `check:published-files`. ## Acceptance notes - **`skills/objectstack-i18n/SKILL.md` (Tier H, `domain:skills`, a separate PR, not edited here).** It would need three changes: - The `_actions.{action_name}` row of its sub-key table (`:164`) lists `params.{param_name}` and `resultDialog` only. It should also list `params.{param}.options.{value}`, `outcomeMessages.{outcome}`, `resultDialog.title` / `.description` / `.acknowledge` and `resultDialog.fields.{path}`. - The orphan paragraph (`:198`-`:202`) enumerates what `translation-target-unknown` names. It should add an action's undeclared outcome, an undeclared result-dialog field, and a result dialog the action does not declare. - `globalActions` (`:170`) should state what `os validate` does: a key naming a bound action is an error whose message names the object-scoped key to write instead. - The `Clause-②` line reads `no (narrowing)`: this diff narrows what `os validate` accepts and widens nothing. Spelled that way, `check:adr-0087-registration` asks for a disposition. PR #21258's landed changeset carries the same bare `yes` this PR first copied; that is left to the dispatching seat. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 393ae87 commit 1371dc9

4 files changed

Lines changed: 169 additions & 24 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`os validate`, `os build` and `os lint` now check an action translation's result-dialog copy against whether the action declares a `resultDialog`, under both `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`.
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, export or stored value moves: TranslationDataSchema parses these three keys exactly as before, so objectstack migrate meta has nothing to convert, and the runtime publish door never runs this rule on a translation write (the member's runtime types default to flow). The refusal is a lint finding at the authoring doors that names the key; its remedy is the author's choice between moving the copy, declaring the dialog or deleting the copy, not a mechanical rewrite of one shape into another. -->
10+
11+
**What is refused.** `resultDialog.title`, `resultDialog.description` and `resultDialog.acknowledge` under an action that declares no `resultDialog` are now `translation-target-unknown` errors, one per key: the code and level an undeclared `params`, `outcomeMessages` or `resultDialog.fields` key already gets. `translateAction` returns no dialog for such an action, so the copy is never read. Before this, only `resultDialog.fields.PATH` was checked under the dialog, and these three keys passed.
12+
13+
**What still passes.** The same three keys under an action that declares a `resultDialog` are read and pass, whether or not the dialog sets that text itself.
14+
15+
**BREAKING** — an accept-set narrowing at the `os validate`, `os build` and `os lint` doors, shipped as `minor` under the launch-window convention. **What changes for a project.** A bundle that carries one of these keys under an action with no `resultDialog` now fails `os validate` with exit 1 instead of passing, and `os build` refuses it. The fix is to move the keys under the action that declares the dialog, declare the `resultDialog` on the action if it should show one, or delete the keys. 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 these keys, so none of their exit codes change.

‎content/docs/protocol/kernel/i18n-standard.mdx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,10 @@ name, the package's own record is the one a field's options, an action's params,
10361036
a dashboard's widgets and header actions, or a flow's screens are judged against;
10371037
every other name the two declare adds up.
10381038

1039+
A row that starts `{action}.` holds at both action addresses,
1040+
`objects.{object}._actions.{action}` and `globalActions.{action}`: the keys
1041+
beneath an action entry are judged the same way under either.
1042+
10391043
| Key | Resolves when it names |
10401044
|:---|:---|
10411045
| `objects.{object}` | an object declared by this stack **or a sibling package of the same artifact** — its keys are then judged row by row below; **or** a platform object, or an object an `objectExtensions[]` entry extends that no package of the artifact declares — both skipped whole, because the owner's fields are not visible from here |
@@ -1046,8 +1050,12 @@ every other name the two declare adds up.
10461050
| `objects.{object}._tabs.{tab}` | a filter-preset tab's `name` in `interfaceConfig.userFilters.tabs` on a page over that object (`interfaceConfig.source`, else the page's `object`) — a list view's own `tabs` has no renderer and is not resolvable |
10471051
| `objects.{object}._validations.{rule}` | a validation rule's `name` in that object's `validations[]` or in an `objectExtensions[]` entry aimed at it — a `conditional` rule's `then` / `otherwise` branch included |
10481052
| `objects.{object}._actions.{action}` | an action bound to that object: one in its `actions`, or a stack-level action whose `objectName` is that object |
1049-
| `objects.{object}._actions.{action}.params.{param}` | a param's `name` on that action |
1050-
| `globalActions.{action}` | an action with **no** `objectName` — a key naming a bound action is reported with the `objects.{owner}._actions` key the resolver reads instead |
1053+
| `globalActions.{action}` | an action with **no** `objectName` — a key naming a bound action is an error whose message names the `objects.{owner}._actions.{action}` key to write instead, `{owner}` being the action's own `objectName` |
1054+
| `{action}.params.{param}` | a param's `name` on that action |
1055+
| `{action}.params.{param}.options.{value}` | an option's stored **`value`** in that param's inline `options` — a warning, as on a field. A param that references a `field` and declares no inline `options` takes the field's list when the dialog renders, so its option keys are not judged |
1056+
| `{action}.outcomeMessages.{outcome}` | an outcome the action's own `outcomeMessages` declares |
1057+
| `{action}.resultDialog.fields.{path}` | the literal `path` of a field the action's `resultDialog.fields[]` declares — dots included, so `client.secret` is one key |
1058+
| `{action}.resultDialog.title` / `.description` / `.acknowledge` | an action that declares a `resultDialog`, whether or not the dialog sets that text itself — for an action that declares none, nothing reads the dialog's copy |
10511059
| `apps.{app}` | an app declared, or one a `navigationContributions` entry anywhere in the artifact contributes into — including an app owned outside the artifact |
10521060
| `apps.{app}.navigation.{id}` | a navigation item `id` the app declares (nested `children` and `areas[]` included) or one contributed into it; under an app that is only contributed into, the contributed ids alone |
10531061
| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | a dashboard declared / a widget `id` on it / a header action's `actionUrl` on it |

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

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3082,6 +3082,8 @@ describe('validateTranslationReferences — object-branch coverage vs the schema
30823082
* `params.<name>` was the only one judged; `outcomeMessages.<outcome>` (#21095)
30833083
* and `resultDialog.fields.<path>` joined the shape with no leg following, so a
30843084
* key the action does not declare parsed, linted clean and was read by nothing.
3085+
* The dialog's prose leaves followed in #21264: under an action with no
3086+
* `resultDialog` they are read by nothing either.
30853087
*/
30863088
describe('validateTranslationReferences — keyed children of an action entry (#21216)', () => {
30873089
/**
@@ -3228,6 +3230,53 @@ describe('validateTranslationReferences — keyed children of an action entry (#
32283230
});
32293231
});
32303232

3233+
/**
3234+
* #21264 — the dialog's prose leaves. `resolveActionResultDialog` returns
3235+
* before any lookup when the action declares no `resultDialog`, so these are
3236+
* judged on that one fact; under a declared dialog every leaf is read, even
3237+
* one the dialog does not author itself (`mint_token` declares no `title`).
3238+
*/
3239+
describe('resultDialog.title / .description / .acknowledge', () => {
3240+
it('refuses each leaf under `_actions.<name>` when the action declares no `resultDialog`, at the code and level `params` uses', () => {
3241+
const findings = validateTranslationReferences(
3242+
actionStack(
3243+
onEnv({
3244+
plain_env: {
3245+
label: '普通',
3246+
resultDialog: { title: '无对话框', description: '无说明', acknowledge: '知道了', fields: { token: '令牌' } },
3247+
},
3248+
}),
3249+
),
3250+
);
3251+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3252+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.title`],
3253+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.description`],
3254+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.acknowledge`],
3255+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.fields.token`],
3256+
]);
3257+
});
3258+
3259+
it('refuses a leaf under `globalActions.<name>` when the object-less action declares no `resultDialog`', () => {
3260+
const findings = validateTranslationReferences(
3261+
actionStack({ globalActions: { check_updates: { resultDialog: { acknowledge: '好的' } } } }),
3262+
);
3263+
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
3264+
[TRANSLATION_TARGET_UNKNOWN, 'error', `${GLOBAL}.check_updates.resultDialog.acknowledge`],
3265+
]);
3266+
});
3267+
3268+
it('accepts every leaf under a declared dialog at both addresses, one the dialog does not author included (the control)', () => {
3269+
expect(
3270+
validateTranslationReferences(
3271+
actionStack({
3272+
...onEnv({ rotate_secret: { resultDialog: { title: '密钥', description: '请保存', acknowledge: '已保存' } } }),
3273+
globalActions: { mint_token: { resultDialog: { title: '令牌', description: '请保存', acknowledge: '已保存' } } },
3274+
}),
3275+
),
3276+
).toEqual([]);
3277+
});
3278+
});
3279+
32313280
describe('params.<name>.options.<value>', () => {
32323281
it('warns on an option key a declared param does not declare, as the field `options` leg does', () => {
32333282
const findings = validateTranslationReferences(
@@ -3304,13 +3353,19 @@ describe('validateTranslationReferences — keyed children of an action entry (#
33043353
};
33053354

33063355
type Address = 'bound' | 'global';
3307-
/** Which declared action a ghost is written under, per address. */
3308-
const HOST: Record<'outcome' | 'dialog', Record<Address, string>> = {
3356+
type Host = 'outcome' | 'dialog' | 'noDialog';
3357+
/**
3358+
* Which declared action a ghost is written under, per address. `noDialog`
3359+
* names an action that declares no `resultDialog`: the dialog's leaves
3360+
* reference the declaration of the dialog itself (#21264).
3361+
*/
3362+
const HOST: Record<Host, Record<Address, string>> = {
33093363
outcome: { bound: 'archive_env', global: 'check_updates' },
33103364
dialog: { bound: 'rotate_secret', global: 'mint_token' },
3365+
noDialog: { bound: 'plain_env', global: 'check_updates' },
33113366
};
33123367
type Coverage =
3313-
| { kind: 'reference-checked'; host: 'outcome' | 'dialog'; ghost: Record<string, unknown>; suffix: string; rule: string }
3368+
| { kind: 'reference-checked'; host: Host; ghost: Record<string, unknown>; suffix: string; rule: string }
33143369
| { kind: 'leaf-copy' | 'container'; why: string };
33153370

33163371
const COVERAGE: Record<string, Coverage> = {
@@ -3342,10 +3397,31 @@ describe('validateTranslationReferences — keyed children of an action entry (#
33423397
suffix: '.params.mode.options.ghost_value',
33433398
rule: TRANSLATION_OPTION_KEY_UNKNOWN,
33443399
},
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' },
3400+
resultDialog: {
3401+
kind: 'container',
3402+
why: 'a fixed-key node; its leaves and its keyed child `resultDialog.fields` are each judged below',
3403+
},
3404+
'resultDialog.title': {
3405+
kind: 'reference-checked',
3406+
host: 'noDialog',
3407+
ghost: { resultDialog: { title: 'x' } },
3408+
suffix: '.resultDialog.title',
3409+
rule: TRANSLATION_TARGET_UNKNOWN,
3410+
},
3411+
'resultDialog.description': {
3412+
kind: 'reference-checked',
3413+
host: 'noDialog',
3414+
ghost: { resultDialog: { description: 'x' } },
3415+
suffix: '.resultDialog.description',
3416+
rule: TRANSLATION_TARGET_UNKNOWN,
3417+
},
3418+
'resultDialog.acknowledge': {
3419+
kind: 'reference-checked',
3420+
host: 'noDialog',
3421+
ghost: { resultDialog: { acknowledge: 'x' } },
3422+
suffix: '.resultDialog.acknowledge',
3423+
rule: TRANSLATION_TARGET_UNKNOWN,
3424+
},
33493425
'resultDialog.fields': {
33503426
kind: 'reference-checked',
33513427
host: 'dialog',

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

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,9 +1119,9 @@ function buildUniverse(stack: AnyRec): Universe {
11191119
//
11201120
// ⛔ A name-only fold is wrong here for a reason peculiar to this rung: the
11211121
// stored RECORD is itself read downstream — `checkActionEntry` judges
1122-
// `params.<name>`, `outcomeMessages.<outcome>` and `resultDialog.fields.<path>`
1123-
// off it — so folding a bare name would resolve the action key and then
1124-
// report every one of those keyed children as an orphan.
1122+
// `params.<name>`, `outcomeMessages.<outcome>`, `resultDialog.fields.<path>`
1123+
// and the dialog's own leaves off it — so folding a bare name would resolve
1124+
// the action key and then report every one of those children as an orphan.
11251125
//
11261126
// ⚠️ And the OWNER is read from the record too, which is what keeps the
11271127
// widening honest: an action a sibling binds to an object joins that object's
@@ -1534,6 +1534,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
15341534
}
15351535

15361536
// _actions.<name>[.params.<name>[.options.<value>] | .outcomeMessages.<outcome>
1537+
// | .resultDialog.(title|description|acknowledge)
15371538
// | .resultDialog.fields.<path>] — see `checkActionEntry`
15381539
for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) {
15391540
const actionPath = `${objPath}._actions.${actionName}`;
@@ -1842,25 +1843,34 @@ interface ActionEntryContext {
18421843
}
18431844

18441845
/**
1845-
* Every KEYED child of one action's translation entry, judged against the
1846-
* declaration the resolver reads it through (#21216).
1846+
* Every child of one action's translation entry that hangs off something the
1847+
* action declares, judged against that declaration through the resolver that
1848+
* reads it (#21216, #21264).
18471849
*
18481850
* Four groups under an action entry are records keyed by a name the action
18491851
* itself declares, and `translateAction` (`@objectstack/spec/system`, the
18501852
* resolver objectui's `useObjectLabel` mirrors) walks the DECLARED side of each,
18511853
* never the bundle's keys — so a key the declaration does not carry is read by
1852-
* nothing, and its copy silently never shows:
1853-
*
1854-
* | bundle key | declared by | read by |
1855-
* |------------------------------------|--------------------------------------|---------------------------------|
1856-
* | `params.<name>` | `params[].name` (`field` fallback) | `translateActionParams` |
1857-
* | `params.<name>.options.<value>` | that param's inline `options[].value`| `translateActionParams` |
1858-
* | `outcomeMessages.<outcome>` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` |
1859-
* | `resultDialog.fields.<path>` | `resultDialog.fields[].path` | `resolveActionResultDialog` |
1854+
* nothing, and its copy silently never shows. The result dialog's own leaves
1855+
* are prose, but they hang off a node the action may not declare at all, and
1856+
* `resolveActionResultDialog` returns before reading any of them when it
1857+
* declares none:
1858+
*
1859+
* | bundle key | declared by | read by |
1860+
* |-------------------------------------|--------------------------------------|---------------------------------|
1861+
* | `params.<name>` | `params[].name` (`field` fallback) | `translateActionParams` |
1862+
* | `params.<name>.options.<value>` | that param's inline `options[].value`| `translateActionParams` |
1863+
* | `outcomeMessages.<outcome>` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` |
1864+
* | `resultDialog.fields.<path>` | `resultDialog.fields[].path` | `resolveActionResultDialog` |
1865+
* | `resultDialog.title` / `.description` / `.acknowledge` | the action's `resultDialog`, present at all | `resolveActionResultDialog` |
1866+
*
1867+
* A dialog leaf under a DECLARED dialog is read whether or not the dialog
1868+
* authors that leaf itself — the resolver overlays a translated `title` onto a
1869+
* dialog that declares none — so declaring the dialog is the whole condition.
18601870
*
18611871
* The leaf keys beside them (`label`, `description`, `confirmText`,
1862-
* `successMessage`, `resultDialog.title` …) are prose with no identifier to
1863-
* resolve, as on the object branch.
1872+
* `successMessage`) are prose on a node that resolved, read whenever the
1873+
* action is, as on the object branch.
18641874
*
18651875
* ⛔ One vocabulary: a key naming a target the action does not declare is
18661876
* `translation-target-unknown` at `params`' severity; an OPTION key keyed off
@@ -1871,6 +1881,7 @@ interface ActionEntryContext {
18711881
function checkActionEntry(findings: TranslationRefFinding[], ctx: ActionEntryContext): void {
18721882
checkActionParams(findings, ctx);
18731883
checkActionOutcomeMessages(findings, ctx);
1884+
checkActionResultDialogLeaves(findings, ctx);
18741885
checkActionResultDialogFields(findings, ctx);
18751886
}
18761887

@@ -2038,6 +2049,41 @@ function checkActionOutcomeMessages(findings: TranslationRefFinding[], ctx: Acti
20382049
}
20392050
}
20402051

2052+
/** The result dialog's prose leaves — every key `ActionResultDialogTranslationSchema` declares beside `fields`. */
2053+
const RESULT_DIALOG_LEAVES = ['title', 'description', 'acknowledge'] as const;
2054+
2055+
/**
2056+
* `resultDialog.title` / `.description` / `.acknowledge` — prose, but prose
2057+
* for a dialog the action must declare.
2058+
*
2059+
* `resolveActionResultDialog` returns the action's own `resultDialog` untouched
2060+
* when it has none, before any lookup, so under an action that declares no
2061+
* `resultDialog` these leaves are read by nothing: the bundle cannot add a
2062+
* dialog, only translate one. Under a declared dialog every leaf is read, even
2063+
* one the dialog does not author itself, so nothing further is judged there.
2064+
*/
2065+
function checkActionResultDialogLeaves(findings: TranslationRefFinding[], ctx: ActionEntryContext): void {
2066+
if (isRec(ctx.action.resultDialog)) return;
2067+
const rawDialog = asRecord(isRec(ctx.rawAction) ? ctx.rawAction.resultDialog : undefined);
2068+
2069+
for (const leaf of RESULT_DIALOG_LEAVES) {
2070+
if (rawDialog[leaf] === undefined) continue;
2071+
findings.push({
2072+
severity: TRANSLATION_TARGET_UNKNOWN_SEVERITY,
2073+
rule: TRANSLATION_TARGET_UNKNOWN,
2074+
where: `${ctx.where} · result dialog "${leaf}"`,
2075+
path: `${ctx.path}.resultDialog.${leaf}`,
2076+
message:
2077+
`Translations carry a result dialog \`${leaf}\`, but ${ctx.subject} declares no ` +
2078+
`\`resultDialog\`, so nothing reads this copy: a bundle translates a dialog the ` +
2079+
`action declares and cannot add one.`,
2080+
hint:
2081+
`Move the \`resultDialog\` translations under the action that declares the dialog, ` +
2082+
`declare the \`resultDialog\` on this action first if it should show one, or drop them.`,
2083+
});
2084+
}
2085+
}
2086+
20412087
/**
20422088
* `resultDialog.fields.<path>` — keyed by the LITERAL `path` of a field the
20432089
* action's `resultDialog.fields[]` declares (dots included: `"client.secret"`

0 commit comments

Comments
 (0)