Skip to content

Commit b9a79ef

Browse files
feat(spec,lint): flows translation face gains approvals.NODE.label for an approval node's step label (#24943)
Closes #24915 Clause-②: yes (widening) ## What changes `TranslationDataSchema.flows.FLOW` carried `label`, `successMessage`, `errorMessage`, `screens.NODE` (screen nodes) and `refusals.NODE` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name. HotCRM measured this: `flows.opportunity_approval.screens.manager_review.title` is refused `translation-target-unknown`, because `manager_review` is an `approval` node and not a screen. - **Spec** (`packages/spec/src/system/translation.zod.ts`): `flows.FLOW.approvals.NODE.label` is a declared key. It is addressed by the `approval` node's `FlowNode.id` and overlays that node's own `label`. The entry is closed (`strictObject`). `title`, `name`, `heading` and `stepLabel` are renames onto `label`, and `approval` on the flow level is a rename onto `approvals`. - **`objectstack validate`** (`packages/lint/src/validate-translation-references.ts`): the translation-reference check collects the flow's `approval` node ids at any depth, using the same `walkFlowNodes` universe, so regions are included. An `approvals.NODE` key over an approval node resolves. Over anything else it is refused `translation-target-unknown`, with the existing diagnosis style: - a node of another type: "...declares as a `decision` node, not an `approval`, so nothing resolves this key." - a `screen` node: "...declares as a `screen` node; its heading is keyed `screens.NODE.title`." - an unknown id: "...declares no node for, so the step keeps its source-locale label.", followed by "Did you mean" over the approval nodes. - **Wrong-group guidance**: a `screens.NODE` or `refusals.NODE` key written over an approval node now says "...declares as an `approval` node; its step label is keyed `approvals.NODE.label`." The old message only said the node is not a screen. It also read "as a `approval` node". ## Why this spelling - **Group `approvals`, keyed by node id**: this follows the grammar the face already has. `screens` addresses `screen` nodes and `refusals` addresses refusing `end` nodes, each by `FlowNode.id`, the identifier the approvals service already holds (`sys_approval_request` stores it as `flow_node_id`). - **Leaf `label`, nothing else.** I read `ApprovalNodeConfigSchema` (`packages/spec/src/automation/approval.zod.ts`). Its keys are `approvers`, `behavior`, `minApprovals`, `lockRecord`, `approvalStatusField`, `onEmptyApprovers`, `fallbackApprovers`, `decisionOutputs`, `escalation` and `maxRevisions`. None of them is a heading, instructions text or a comment prompt. The one person-facing string an approval step carries is `FlowNode.label`. `plugin-approvals` copies that label onto `current_step_label` when a request opens (`approval-service.ts`, `labelOrMachineName(input.nodeLabel, ...)`). A screen spells its leaf `title` because it overlays `ScreenConfigSchema.title`. This leaf overlays the node label, so it is spelled `label`, and `title` is an alias onto it. - **Plain string, not a `{{ }}` text slot**: the node label is plain text that is copied, not rendered through `renderTextSlot`. This is the same as the flow's own `label`. - **Out of scope, on purpose**: a typed `decisionOutputs[]` entry's `label` (a field label in the decision dialog). It stays an `owed` row in the family pin, with its reason updated. It is a separate surface that needs its own reader. ## Tests Measured at head `606faa06ee` (merged with `origin/main`). - Repro first: commit `ebb460a714` added the tests before the fix. - **Reverse verification**: after the fix was committed, I restored `translation.zod.ts` and `validate-translation-references.ts` to the base `81e6edd463` (tree only, `git restore --source`). The mutation was confirmed on disk (`approvals: z.record` count 0, `APPROVAL_NODE_TYPE` count 0). I rebuilt spec and ran the suites: - spec: `6 failed | 158 passed`. The four new approvals cases failed, and so did the two face-pin cases ("every keyed row ... exists in the face", "every face key is filled"). - lint: `6 failed | 180 passed`. The five new refusal/redirect cases failed, and so did the one-line-verdict arm coverage. - Restored with `git checkout HEAD -- PATHS`. `git diff HEAD` was empty and `git status` was clean. I rebuilt spec, and `ablation-dist-preflight` found the marker present in 20 built files. - **Controls**: - screen and refusal keys of the same flow still resolve beside an approval key; - a `screens` key over a `decision` node keeps its own diagnosis; - an approval key on a non-approval node is refused by name; - a key the approval entry does not declare (`instructions`) is refused at parse. - **Family pin** (`flows-translation-face.test.ts`): `node.label` is now `keyed` to both `screens.NODE.title` and `approvals.NODE.label` (new `also` field), so the face-key-filled check accounts for the new key. - **Package runs**: - `pnpm --filter @objectstack/spec test`: 703 files, 21365 passed. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/lint test`: 151 files, 7002 passed (after the merge). - `pnpm --filter @objectstack/lint typecheck`: OK. - **Generated artifacts and data checks**: - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts are up to date. These were regenerated with `--fix`: `content/docs/references/system/translation.mdx`, the strictness-ledger counts and the liveness state counts. - `pnpm check:i18n`: OK, and `check:nul-bytes`: OK. - **Derived gates** (`dispatch-gates --commands`, reconciled with `--ran`): 113 derived, 113 run, all `exit 0`, 0 NOT-MEASURED (a derived zero: every family recorded an exit code), re-run on the merged head `606faa06ee`. - **eslint, narrowed to the 5 touched source and test files** (`--no-inline-config --format json`): 5 files, 0 errors, 0 warnings. All 5 are inside the config (`--print-config` resolves each). The config declares no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. ## Liveness `packages/spec/liveness/translation.json` gains a `flows.approvals` row (drilled to `label`) with status `planned`, not `live`. The reader that renders the step label in the reader's locale is the `plugin-approvals` half of #24863, which this PR does not touch. It is a contract-first split, the same way `screens` and the toasts landed. #24863 remains open. ## Review tier `packages/spec` is a published schema, so this PR owes a `CONTRACT_REVIEW_TIER` review of record before it enters the queue (pm-dispatch `references/contract-review.md`). It touches no governed surface (`.claude/**`, `docs/adr/**`, `AGENTS.md`, workflows, `skills/**`), so no Tier S or Tier H landing rule applies. ## Acceptance notes - The CLI i18n extractor (`packages/cli/src/utils/i18n-extract.ts`) does not emit `approvals.NODE.label` slots in a skeleton. Asking apps to translate a key with no reader would be a declared-but-not-honoured demand. That demand belongs with the reader in #24863. `check:i18n-walk-parity` is green with this diff. - The flow-level alias `steps: 'screens'` is left as it is. An author who writes `steps` for an approval step is told it renames to `screens`, and validate then points them at `approvals`. - No `flowApprovalLabelKey` resolver helper is added. With no reader it would be an export with no consumer. The reader card can add it next to `flowRefusalMessageKey`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea08ce5 commit b9a79ef

10 files changed

Lines changed: 334 additions & 34 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): the `flows` translation face gains `approvals.<node_id>.label`, the step label of an `approval` node
7+
8+
Clause-②: yes (widening)
9+
10+
- **Before.** `TranslationDataSchema.flows.<flow>` carried `label`, `successMessage`, `errorMessage`, `screens.<node_id>` (screen nodes) and `refusals.<node_id>` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name: `approvals` was refused as an unknown key, and a `screens.<node_id>.title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen.
11+
- **Now.** `flows.<flow>.approvals.<node_id>.label` is a declared key, addressed by the `approval` node's `id` (at any depth, regions included) and overlaying that node's own `label`, the step name an approval request shows. The entry is closed: `title`, `name`, `heading` and `stepLabel` are named as renames onto `label`, and `approval` as the rename onto the `approvals` group.
12+
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over an `approval` node and refuses it, as `translation-target-unknown`, over an unknown node or a node of another type (a `screen` node is pointed at its `screens.<node_id>.title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals.<node_id>.label` instead of being told only that the node is not a screen.
13+
- **Not yet rendered.** This is the spec half: the key parses and validates, and the approvals service does not read it yet, so an approval request still shows the authored step label. Its liveness row is `planned` until that reader lands.
14+
- **Unchanged.** Every existing `flows` key, and every verdict on a key over a node that is not an approval node.

‎content/docs/references/system/translation.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ Translation data for a single object
262262
| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
263263
| **screens** | `Record<string, { title?: string; description?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
264264
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
265+
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |
265266

266267
### Nested Shape: `PlatformTranslationData.metadataForms[string]`
267268

@@ -464,6 +465,7 @@ Translation data for a single object
464465
| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
465466
| **screens** | `Record<string, { title?: string; description?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
466467
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
468+
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |
467469

468470
### Nested Shape: `TranslationData.metadataForms[string]`
469471

@@ -626,6 +628,7 @@ Translation data for a single object
626628
| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
627629
| **screens** | `Record<string, { title?: string; description?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
628630
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
631+
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |
629632

630633
### Nested Shape: `TranslationItem.metadataForms[string]`
631634

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 375 |
22+
| `system/` | 376 |

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

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,6 +2230,123 @@ describe('validateTranslationReferences — flow refusals (#22450)', () => {
22302230
});
22312231
});
22322232

2233+
describe('validateTranslationReferences — flow approval steps (`approvals.<node_id>.label`)', () => {
2234+
/**
2235+
* HotCRM's measured shape: an approval flow whose step is an `approval`
2236+
* node, beside a screen, a refusing `end` and a `decision`, so each verdict
2237+
* below is judged against the same metadata as the clean run and differs
2238+
* only in the bundle.
2239+
*/
2240+
const approvalStack = {
2241+
flows: [
2242+
{
2243+
name: 'opportunity_approval',
2244+
type: 'autolaunched',
2245+
nodes: [
2246+
{ id: 'start', type: 'start', label: 'Start' },
2247+
{ id: 'gate', type: 'decision', label: 'Large deal?' },
2248+
{ id: 'collect', type: 'screen', label: 'Collect', config: { fields: [{ name: 'note' }] } },
2249+
{
2250+
id: 'manager_review',
2251+
type: 'approval',
2252+
label: 'Sales Manager Review',
2253+
config: { approvers: [{ type: 'manager' }] },
2254+
},
2255+
{ id: 'refuse', type: 'end', label: 'Refused', config: { outcome: 'refused', message: 'Refused.' } },
2256+
],
2257+
},
2258+
],
2259+
};
2260+
const bundle = (flows: unknown) => ({ ...approvalStack, translations: [{ 'zh-CN': { flows } }] });
2261+
2262+
it('reports nothing for the label of an `approval` node — the key the step has no other home for', () => {
2263+
expect(
2264+
validateTranslationReferences(bundle({ opportunity_approval: { approvals: { manager_review: { label: '销售经理审批' } } } })),
2265+
).toEqual([]);
2266+
});
2267+
2268+
it('controls: the screen and refusal keys of the same flow still resolve beside it', () => {
2269+
expect(
2270+
validateTranslationReferences(
2271+
bundle({
2272+
opportunity_approval: {
2273+
screens: { collect: { title: '收集', fields: { note: { label: '备注' } } } },
2274+
refusals: { refuse: { message: '已拒绝。' } },
2275+
approvals: { manager_review: { label: '销售经理审批' } },
2276+
},
2277+
}),
2278+
),
2279+
).toEqual([]);
2280+
});
2281+
2282+
it('refuses an approval key on a node of another type, by name', () => {
2283+
const findings = validateTranslationReferences(bundle({ opportunity_approval: { approvals: { gate: { label: 'x' } } } }));
2284+
expect(findings).toHaveLength(1);
2285+
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
2286+
expect(findings[0].severity).toBe('error');
2287+
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.gate');
2288+
expect(findings[0].message).toBe(
2289+
'Translations are keyed to approval step "gate", which flow "opportunity_approval" declares as a ' +
2290+
'`decision` node, not an `approval`, so nothing resolves this key.',
2291+
);
2292+
expect(findings[0].hint).toBe('Approval-step translations are keyed by the `id` of an `approval` node.');
2293+
});
2294+
2295+
it('refuses an approval key on a screen node, pointing at the screen group', () => {
2296+
const findings = validateTranslationReferences(bundle({ opportunity_approval: { approvals: { collect: { label: 'x' } } } }));
2297+
expect(findings).toHaveLength(1);
2298+
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.collect');
2299+
expect(findings[0].message).toBe(
2300+
'Translations are keyed to approval step "collect", which flow "opportunity_approval" declares as a ' +
2301+
'`screen` node; its heading is keyed `screens.collect.title`.',
2302+
);
2303+
});
2304+
2305+
it('refuses an approval key over an UNKNOWN node, with the nearest approval node offered', () => {
2306+
const findings = validateTranslationReferences(
2307+
bundle({ opportunity_approval: { approvals: { manager_reveiw: { label: 'x' } } } }),
2308+
);
2309+
expect(findings).toHaveLength(1);
2310+
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
2311+
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.manager_reveiw');
2312+
expect(findings[0].message).toContain(
2313+
'Translations are keyed to approval step "manager_reveiw", which flow "opportunity_approval" declares no ' +
2314+
'node for, so the step keeps its source-locale label.',
2315+
);
2316+
expect(findings[0].message).toContain('Did you mean "manager_review"?');
2317+
});
2318+
2319+
it('points a `screens` key written over an approval node at the `approvals` group (the measured HotCRM key)', () => {
2320+
const findings = validateTranslationReferences(
2321+
bundle({ opportunity_approval: { screens: { manager_review: { title: '销售经理审批' } } } }),
2322+
);
2323+
expect(findings).toHaveLength(1);
2324+
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
2325+
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.screens.manager_review');
2326+
expect(findings[0].message).toBe(
2327+
'Translations are keyed to screen "manager_review", which flow "opportunity_approval" declares as an ' +
2328+
'`approval` node; its step label is keyed `approvals.manager_review.label`.',
2329+
);
2330+
});
2331+
2332+
it('points a `refusals` key written over an approval node at the `approvals` group', () => {
2333+
const findings = validateTranslationReferences(
2334+
bundle({ opportunity_approval: { refusals: { manager_review: { message: 'x' } } } }),
2335+
);
2336+
expect(findings).toHaveLength(1);
2337+
expect(findings[0].message).toBe(
2338+
'Translations are keyed to refusal "manager_review", which flow "opportunity_approval" declares as an ' +
2339+
'`approval` node; its step label is keyed `approvals.manager_review.label`.',
2340+
);
2341+
});
2342+
2343+
it('control: a `screens` key over a non-approval, non-screen node keeps its own diagnosis', () => {
2344+
const findings = validateTranslationReferences(bundle({ opportunity_approval: { screens: { gate: { title: 'x' } } } }));
2345+
expect(findings).toHaveLength(1);
2346+
expect(findings[0].message).toContain('declares as a `decision` node, not a `screen`');
2347+
});
2348+
});
2349+
22332350
describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => {
22342351
/**
22352352
* hotclm's measured shape, trimmed: a screen flow that authors a completion
@@ -3906,6 +4023,10 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
39064023
'`end` node that completes',
39074024
'node, not a refusing `end`',
39084025
'a node id flow',
4026+
'node, not an `approval`',
4027+
'`screen` node; its heading is keyed',
4028+
'declares no node for, so the step keeps',
4029+
'`approval` node; its step label is keyed',
39094030
'Translations are keyed to parameter "',
39104031
'does not declare in `outcomeMessages`',
39114032
'declares no `outcomeMessages` at all',

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

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@
116116
* are each an orphan, with its own diagnosis. The message's `{{ }}` holes are
117117
* the schema's to judge (`TranslationDataSchema`, the one text-slot judge).
118118
*
119+
* `flows.<name>.approvals.<node_id>.label` is judged the same way: the node id
120+
* must name an `approval` node (`APPROVAL_NODE_TYPE`), at any depth. A node of
121+
* another type and an id the flow does not declare are each an orphan, with
122+
* its own diagnosis — a `screen` node pointed at its `screens.<node_id>.title`.
123+
* The reverse slip, a `screens` or `refusals` key written over an approval
124+
* node, is pointed at `approvals.<node_id>.label` rather than told only that
125+
* the node is not a screen.
126+
*
119127
* `flows.<name>.successMessage` / `.errorMessage` (#22507), the terminal
120128
* toasts, are judged against the flow itself: `translateFlow` overlays a toast
121129
* only where the flow AUTHORS one, so a translated toast over a flow that
@@ -198,7 +206,7 @@
198206
*/
199207

200208
import { expandViewContainer } from '@objectstack/spec';
201-
import { flowScreenFieldOptionKey } from '@objectstack/spec/automation';
209+
import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
202210
import {
203211
FLOW_TERMINAL_MESSAGE_KEYS,
204212
hasPlatformObjectPrefix,
@@ -394,6 +402,11 @@ interface FlowFacts {
394402
* renders no message, so a key over one is an orphan.
395403
*/
396404
refusals: Set<string>;
405+
/**
406+
* The ids of the `approval` nodes — the nodes a
407+
* `flows.<name>.approvals.<node_id>` key addresses, at any depth.
408+
*/
409+
approvals: Set<string>;
397410
/**
398411
* [#22507] The terminal toasts the flow AUTHORS (`successMessage` /
399412
* `errorMessage` declared as a string) — the only ones a
@@ -1423,6 +1436,7 @@ function buildUniverse(stack: AnyRec): Universe {
14231436
if (!ownDeclaration && flows.has(flowName)) return;
14241437
const screens = new Map<string, ScreenFacts>();
14251438
const refusals = new Set<string>();
1439+
const approvals = new Set<string>();
14261440
const otherNodes = new Map<string, string>();
14271441
const messages = new Set<string>(FLOW_TERMINAL_MESSAGE_KEYS.filter((key) => typeof flow[key] === 'string'));
14281442
for (const { node } of walkFlowNodes(flow, '')) {
@@ -1436,6 +1450,7 @@ function buildUniverse(stack: AnyRec): Universe {
14361450
if (nodeType === END_NODE_TYPE && isRec(node.config) && node.config.outcome === REFUSED_OUTCOME) {
14371451
refusals.add(nodeId);
14381452
}
1453+
if (nodeType === APPROVAL_NODE_TYPE) approvals.add(nodeId);
14391454
continue;
14401455
}
14411456
const config = isRec(node.config) ? node.config : undefined;
@@ -1455,7 +1470,7 @@ function buildUniverse(stack: AnyRec): Universe {
14551470
hasDescription: typeof config?.description === 'string' && config.description.length > 0,
14561471
});
14571472
}
1458-
flows.set(flowName, { screens, refusals, messages, otherNodes });
1473+
flows.set(flowName, { screens, refusals, approvals, messages, otherNodes });
14591474
};
14601475
for (const flow of recordsOf(stack.flows)) collectFlowRecord(flow, { ownDeclaration: true });
14611476
for (const flow of artifactProvidedRecords(stack, 'flows')) {
@@ -1923,12 +1938,14 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
19231938
orphan(
19241939
`${inLocale} · flow "${flowName}" · screen "${nodeId}"`,
19251940
screenPath,
1926-
otherType
1927-
? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
1928-
`declares as a \`${otherType}\` node, not a \`screen\`, so nothing resolves these keys.`
1929-
: `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
1930-
`declares no screen node for, so the wizard step keeps its source-locale heading.` +
1931-
suggest(nodeId, flow.screens.keys()),
1941+
otherType === APPROVAL_NODE_TYPE
1942+
? approvalRedirect('screen', nodeId, flowName)
1943+
: otherType
1944+
? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
1945+
`declares as a \`${otherType}\` node, not a \`screen\`, so nothing resolves these keys.`
1946+
: `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
1947+
`declares no screen node for, so the wizard step keeps its source-locale heading.` +
1948+
suggest(nodeId, flow.screens.keys()),
19321949
`Screen translations are keyed by the node's \`id\` (the client's ` +
19331950
`\`ScreenSpec.nodeId\`), not its \`label\`.`,
19341951
);
@@ -1985,27 +2002,59 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
19852002
orphan(
19862003
`${inLocale} · flow "${flowName}" · refusal "${nodeId}"`,
19872004
`${flowPath}.refusals.${nodeId}`,
1988-
nodeType === END_NODE_TYPE
1989-
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as an ` +
1990-
`\`end\` node that completes, not one declaring \`outcome: 'refused'\`, so nothing ` +
1991-
`resolves this key.`
1992-
: nodeType
1993-
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` +
1994-
`\`${nodeType}\` node, not a refusing \`end\`, so nothing resolves this key.`
1995-
: `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` +
1996-
`declare, so the refusal keeps its source-locale message.` +
1997-
suggest(nodeId, flow.refusals),
2005+
nodeType === APPROVAL_NODE_TYPE
2006+
? approvalRedirect('refusal', nodeId, flowName)
2007+
: nodeType === END_NODE_TYPE
2008+
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as an ` +
2009+
`\`end\` node that completes, not one declaring \`outcome: 'refused'\`, so nothing ` +
2010+
`resolves this key.`
2011+
: nodeType
2012+
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` +
2013+
`\`${nodeType}\` node, not a refusing \`end\`, so nothing resolves this key.`
2014+
: `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` +
2015+
`declare, so the refusal keeps its source-locale message.` +
2016+
suggest(nodeId, flow.refusals),
19982017
`Refusal translations are keyed by the \`id\` of an \`end\` node declaring ` +
19992018
`\`outcome: 'refused'\`.`,
20002019
);
20012020
}
2021+
// ── flows.<name>.approvals.<node_id> ──────────────────────────────
2022+
for (const nodeId of Object.keys(asRecord(rawFlow.approvals))) {
2023+
if (flow.approvals.has(nodeId)) continue;
2024+
const nodeType = flow.screens.has(nodeId) ? SCREEN_NODE_TYPE : flow.otherNodes.get(nodeId);
2025+
orphan(
2026+
`${inLocale} · flow "${flowName}" · approval step "${nodeId}"`,
2027+
`${flowPath}.approvals.${nodeId}`,
2028+
nodeType === SCREEN_NODE_TYPE
2029+
? `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares as a ` +
2030+
`\`screen\` node; its heading is keyed \`screens.${nodeId}.title\`.`
2031+
: nodeType
2032+
? `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares as a ` +
2033+
`\`${nodeType}\` node, not an \`approval\`, so nothing resolves this key.`
2034+
: `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares no ` +
2035+
`node for, so the step keeps its source-locale label.` +
2036+
suggest(nodeId, flow.approvals),
2037+
`Approval-step translations are keyed by the \`id\` of an \`approval\` node.`,
2038+
);
2039+
}
20022040
}
20032041
}
20042042
}
20052043

20062044
return findings;
20072045
}
20082046

2047+
/**
2048+
* The verdict for a `screens` / `refusals` key written over an `approval`
2049+
* node: the step's one translatable string is its label, under `approvals`.
2050+
*/
2051+
function approvalRedirect(group: 'screen' | 'refusal', nodeId: string, flowName: string): string {
2052+
return (
2053+
`Translations are keyed to ${group} "${nodeId}", which flow "${flowName}" declares as an ` +
2054+
`\`approval\` node; its step label is keyed \`approvals.${nodeId}.label\`.`
2055+
);
2056+
}
2057+
20092058
/** `v` as a record of sub-nodes, or an empty record when absent/malformed. */
20102059
function asRecord(v: unknown): Record<string, unknown> {
20112060
return isRec(v) ? v : {};

‎packages/spec/liveness/state-counts/translation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `translation` | 29 | 0 | 0 | 0 | 0 | 29 |
15+
| `translation` | 29 | 0 | 0 | 0 | 1 | 30 |

0 commit comments

Comments
 (0)