diff --git a/.changeset/21264-result-dialog-leaves.md b/.changeset/21264-result-dialog-leaves.md new file mode 100644 index 00000000000..fbdf7194748 --- /dev/null +++ b/.changeset/21264-result-dialog-leaves.md @@ -0,0 +1,15 @@ +--- +'@objectstack/lint': minor +--- + +`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`. + +Clause-②: no (narrowing) + + + +**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. + +**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. + +**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. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 274dd89cb4f..aa5b3475c05 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -1036,6 +1036,10 @@ name, the package's own record is the one a field's options, an action's params, a dashboard's widgets and header actions, or a flow's screens are judged against; every other name the two declare adds up. +A row that starts `{action}.` holds at both action addresses, +`objects.{object}._actions.{action}` and `globalActions.{action}`: the keys +beneath an action entry are judged the same way under either. + | Key | Resolves when it names | |:---|:---| | `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. | `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 | | `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 | | `objects.{object}._actions.{action}` | an action bound to that object: one in its `actions`, or a stack-level action whose `objectName` is that object | -| `objects.{object}._actions.{action}.params.{param}` | a param's `name` on that action | -| `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 | +| `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` | +| `{action}.params.{param}` | a param's `name` on that action | +| `{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 | +| `{action}.outcomeMessages.{outcome}` | an outcome the action's own `outcomeMessages` declares | +| `{action}.resultDialog.fields.{path}` | the literal `path` of a field the action's `resultDialog.fields[]` declares — dots included, so `client.secret` is one key | +| `{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 | | `apps.{app}` | an app declared, or one a `navigationContributions` entry anywhere in the artifact contributes into — including an app owned outside the artifact | | `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 | | `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | a dashboard declared / a widget `id` on it / a header action's `actionUrl` on it | diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 088257eb019..df3e5712f4b 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -3082,6 +3082,8 @@ describe('validateTranslationReferences — object-branch coverage vs the schema * `params.` was the only one judged; `outcomeMessages.` (#21095) * and `resultDialog.fields.` joined the shape with no leg following, so a * key the action does not declare parsed, linted clean and was read by nothing. + * The dialog's prose leaves followed in #21264: under an action with no + * `resultDialog` they are read by nothing either. */ describe('validateTranslationReferences — keyed children of an action entry (#21216)', () => { /** @@ -3228,6 +3230,53 @@ describe('validateTranslationReferences — keyed children of an action entry (# }); }); + /** + * #21264 — the dialog's prose leaves. `resolveActionResultDialog` returns + * before any lookup when the action declares no `resultDialog`, so these are + * judged on that one fact; under a declared dialog every leaf is read, even + * one the dialog does not author itself (`mint_token` declares no `title`). + */ + describe('resultDialog.title / .description / .acknowledge', () => { + it('refuses each leaf under `_actions.` when the action declares no `resultDialog`, at the code and level `params` uses', () => { + const findings = validateTranslationReferences( + actionStack( + onEnv({ + plain_env: { + label: '普通', + resultDialog: { title: '无对话框', description: '无说明', acknowledge: '知道了', fields: { token: '令牌' } }, + }, + }), + ), + ); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.title`], + [TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.description`], + [TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.acknowledge`], + [TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.resultDialog.fields.token`], + ]); + }); + + it('refuses a leaf under `globalActions.` when the object-less action declares no `resultDialog`', () => { + const findings = validateTranslationReferences( + actionStack({ globalActions: { check_updates: { resultDialog: { acknowledge: '好的' } } } }), + ); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', `${GLOBAL}.check_updates.resultDialog.acknowledge`], + ]); + }); + + it('accepts every leaf under a declared dialog at both addresses, one the dialog does not author included (the control)', () => { + expect( + validateTranslationReferences( + actionStack({ + ...onEnv({ rotate_secret: { resultDialog: { title: '密钥', description: '请保存', acknowledge: '已保存' } } }), + globalActions: { mint_token: { resultDialog: { title: '令牌', description: '请保存', acknowledge: '已保存' } } }, + }), + ), + ).toEqual([]); + }); + }); + describe('params..options.', () => { it('warns on an option key a declared param does not declare, as the field `options` leg does', () => { const findings = validateTranslationReferences( @@ -3304,13 +3353,19 @@ describe('validateTranslationReferences — keyed children of an action entry (# }; type Address = 'bound' | 'global'; - /** Which declared action a ghost is written under, per address. */ - const HOST: Record<'outcome' | 'dialog', Record> = { + type Host = 'outcome' | 'dialog' | 'noDialog'; + /** + * Which declared action a ghost is written under, per address. `noDialog` + * names an action that declares no `resultDialog`: the dialog's leaves + * reference the declaration of the dialog itself (#21264). + */ + const HOST: Record> = { outcome: { bound: 'archive_env', global: 'check_updates' }, dialog: { bound: 'rotate_secret', global: 'mint_token' }, + noDialog: { bound: 'plain_env', global: 'check_updates' }, }; type Coverage = - | { kind: 'reference-checked'; host: 'outcome' | 'dialog'; ghost: Record; suffix: string; rule: string } + | { kind: 'reference-checked'; host: Host; ghost: Record; suffix: string; rule: string } | { kind: 'leaf-copy' | 'container'; why: string }; const COVERAGE: Record = { @@ -3342,10 +3397,31 @@ describe('validateTranslationReferences — keyed children of an action entry (# suffix: '.params.mode.options.ghost_value', rule: TRANSLATION_OPTION_KEY_UNKNOWN, }, - resultDialog: { kind: 'container', why: 'a fixed-key node; its keyed child is `resultDialog.fields`' }, - 'resultDialog.title': { kind: 'leaf-copy', why: 'prose' }, - 'resultDialog.description': { kind: 'leaf-copy', why: 'prose' }, - 'resultDialog.acknowledge': { kind: 'leaf-copy', why: 'prose' }, + resultDialog: { + kind: 'container', + why: 'a fixed-key node; its leaves and its keyed child `resultDialog.fields` are each judged below', + }, + 'resultDialog.title': { + kind: 'reference-checked', + host: 'noDialog', + ghost: { resultDialog: { title: 'x' } }, + suffix: '.resultDialog.title', + rule: TRANSLATION_TARGET_UNKNOWN, + }, + 'resultDialog.description': { + kind: 'reference-checked', + host: 'noDialog', + ghost: { resultDialog: { description: 'x' } }, + suffix: '.resultDialog.description', + rule: TRANSLATION_TARGET_UNKNOWN, + }, + 'resultDialog.acknowledge': { + kind: 'reference-checked', + host: 'noDialog', + ghost: { resultDialog: { acknowledge: 'x' } }, + suffix: '.resultDialog.acknowledge', + rule: TRANSLATION_TARGET_UNKNOWN, + }, 'resultDialog.fields': { kind: 'reference-checked', host: 'dialog', diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 7e2860d37da..de2dc9a541a 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -1119,9 +1119,9 @@ function buildUniverse(stack: AnyRec): Universe { // // ⛔ A name-only fold is wrong here for a reason peculiar to this rung: the // stored RECORD is itself read downstream — `checkActionEntry` judges - // `params.`, `outcomeMessages.` and `resultDialog.fields.` - // off it — so folding a bare name would resolve the action key and then - // report every one of those keyed children as an orphan. + // `params.`, `outcomeMessages.`, `resultDialog.fields.` + // and the dialog's own leaves off it — so folding a bare name would resolve + // the action key and then report every one of those children as an orphan. // // ⚠️ And the OWNER is read from the record too, which is what keeps the // widening honest: an action a sibling binds to an object joins that object's @@ -1534,6 +1534,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind } // _actions.[.params.[.options.] | .outcomeMessages. + // | .resultDialog.(title|description|acknowledge) // | .resultDialog.fields.] — see `checkActionEntry` for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) { const actionPath = `${objPath}._actions.${actionName}`; @@ -1842,25 +1843,34 @@ interface ActionEntryContext { } /** - * Every KEYED child of one action's translation entry, judged against the - * declaration the resolver reads it through (#21216). + * Every child of one action's translation entry that hangs off something the + * action declares, judged against that declaration through the resolver that + * reads it (#21216, #21264). * * Four groups under an action entry are records keyed by a name the action * itself declares, and `translateAction` (`@objectstack/spec/system`, the * resolver objectui's `useObjectLabel` mirrors) walks the DECLARED side of each, * never the bundle's keys — so a key the declaration does not carry is read by - * nothing, and its copy silently never shows: - * - * | bundle key | declared by | read by | - * |------------------------------------|--------------------------------------|---------------------------------| - * | `params.` | `params[].name` (`field` fallback) | `translateActionParams` | - * | `params..options.` | that param's inline `options[].value`| `translateActionParams` | - * | `outcomeMessages.` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` | - * | `resultDialog.fields.` | `resultDialog.fields[].path` | `resolveActionResultDialog` | + * nothing, and its copy silently never shows. The result dialog's own leaves + * are prose, but they hang off a node the action may not declare at all, and + * `resolveActionResultDialog` returns before reading any of them when it + * declares none: + * + * | bundle key | declared by | read by | + * |-------------------------------------|--------------------------------------|---------------------------------| + * | `params.` | `params[].name` (`field` fallback) | `translateActionParams` | + * | `params..options.` | that param's inline `options[].value`| `translateActionParams` | + * | `outcomeMessages.` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` | + * | `resultDialog.fields.` | `resultDialog.fields[].path` | `resolveActionResultDialog` | + * | `resultDialog.title` / `.description` / `.acknowledge` | the action's `resultDialog`, present at all | `resolveActionResultDialog` | + * + * A dialog leaf under a DECLARED dialog is read whether or not the dialog + * authors that leaf itself — the resolver overlays a translated `title` onto a + * dialog that declares none — so declaring the dialog is the whole condition. * * The leaf keys beside them (`label`, `description`, `confirmText`, - * `successMessage`, `resultDialog.title` …) are prose with no identifier to - * resolve, as on the object branch. + * `successMessage`) are prose on a node that resolved, read whenever the + * action is, as on the object branch. * * ⛔ One vocabulary: a key naming a target the action does not declare is * `translation-target-unknown` at `params`' severity; an OPTION key keyed off @@ -1871,6 +1881,7 @@ interface ActionEntryContext { function checkActionEntry(findings: TranslationRefFinding[], ctx: ActionEntryContext): void { checkActionParams(findings, ctx); checkActionOutcomeMessages(findings, ctx); + checkActionResultDialogLeaves(findings, ctx); checkActionResultDialogFields(findings, ctx); } @@ -2038,6 +2049,41 @@ function checkActionOutcomeMessages(findings: TranslationRefFinding[], ctx: Acti } } +/** The result dialog's prose leaves — every key `ActionResultDialogTranslationSchema` declares beside `fields`. */ +const RESULT_DIALOG_LEAVES = ['title', 'description', 'acknowledge'] as const; + +/** + * `resultDialog.title` / `.description` / `.acknowledge` — prose, but prose + * for a dialog the action must declare. + * + * `resolveActionResultDialog` returns the action's own `resultDialog` untouched + * when it has none, before any lookup, so under an action that declares no + * `resultDialog` these leaves are read by nothing: the bundle cannot add a + * dialog, only translate one. Under a declared dialog every leaf is read, even + * one the dialog does not author itself, so nothing further is judged there. + */ +function checkActionResultDialogLeaves(findings: TranslationRefFinding[], ctx: ActionEntryContext): void { + if (isRec(ctx.action.resultDialog)) return; + const rawDialog = asRecord(isRec(ctx.rawAction) ? ctx.rawAction.resultDialog : undefined); + + for (const leaf of RESULT_DIALOG_LEAVES) { + if (rawDialog[leaf] === undefined) continue; + findings.push({ + severity: TRANSLATION_TARGET_UNKNOWN_SEVERITY, + rule: TRANSLATION_TARGET_UNKNOWN, + where: `${ctx.where} · result dialog "${leaf}"`, + path: `${ctx.path}.resultDialog.${leaf}`, + message: + `Translations carry a result dialog \`${leaf}\`, but ${ctx.subject} declares no ` + + `\`resultDialog\`, so nothing reads this copy: a bundle translates a dialog the ` + + `action declares and cannot add one.`, + hint: + `Move the \`resultDialog\` translations under the action that declares the dialog, ` + + `declare the \`resultDialog\` on this action first if it should show one, or drop them.`, + }); + } +} + /** * `resultDialog.fields.` — keyed by the LITERAL `path` of a field the * action's `resultDialog.fields[]` declares (dots included: `"client.secret"`