Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/21264-result-dialog-leaves.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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. -->

**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.
12 changes: 10 additions & 2 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
90 changes: 83 additions & 7 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3082,6 +3082,8 @@ describe('validateTranslationReferences — object-branch coverage vs the schema
* `params.<name>` was the only one judged; `outcomeMessages.<outcome>` (#21095)
* and `resultDialog.fields.<path>` 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)', () => {
/**
Expand Down Expand Up @@ -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.<name>` 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.<name>` 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.<name>.options.<value>', () => {
it('warns on an option key a declared param does not declare, as the field `options` leg does', () => {
const findings = validateTranslationReferences(
Expand Down Expand Up @@ -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<Address, string>> = {
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<Host, Record<Address, string>> = {
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<string, unknown>; suffix: string; rule: string }
| { kind: 'reference-checked'; host: Host; ghost: Record<string, unknown>; suffix: string; rule: string }
| { kind: 'leaf-copy' | 'container'; why: string };

const COVERAGE: Record<string, Coverage> = {
Expand Down Expand Up @@ -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',
Expand Down
76 changes: 61 additions & 15 deletions packages/lint/src/validate-translation-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`, `outcomeMessages.<outcome>` and `resultDialog.fields.<path>`
// 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.<name>`, `outcomeMessages.<outcome>`, `resultDialog.fields.<path>`
// 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
Expand Down Expand Up @@ -1534,6 +1534,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
}

// _actions.<name>[.params.<name>[.options.<value>] | .outcomeMessages.<outcome>
// | .resultDialog.(title|description|acknowledge)
// | .resultDialog.fields.<path>] — see `checkActionEntry`
for (const [actionName, rawAction] of Object.entries(asRecord(rawNode._actions))) {
const actionPath = `${objPath}._actions.${actionName}`;
Expand Down Expand Up @@ -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.<name>` | `params[].name` (`field` fallback) | `translateActionParams` |
* | `params.<name>.options.<value>` | that param's inline `options[].value`| `translateActionParams` |
* | `outcomeMessages.<outcome>` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` |
* | `resultDialog.fields.<path>` | `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.<name>` | `params[].name` (`field` fallback) | `translateActionParams` |
* | `params.<name>.options.<value>` | that param's inline `options[].value`| `translateActionParams` |
* | `outcomeMessages.<outcome>` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` |
* | `resultDialog.fields.<path>` | `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
Expand All @@ -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);
}

Expand Down Expand Up @@ -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.<path>` — keyed by the LITERAL `path` of a field the
* action's `resultDialog.fields[]` declares (dots included: `"client.secret"`
Expand Down
Loading