diff --git a/.changeset/translation-refs-flows-leg.md b/.changeset/translation-refs-flows-leg.md new file mode 100644 index 0000000000..358e5c8ee1 --- /dev/null +++ b/.changeset/translation-refs-flows-leg.md @@ -0,0 +1,35 @@ +--- +'@objectstack/lint': patch +--- + +`validate-translation-references` now checks the `flows` group — an authored key naming a +flow, screen node or screen field that does not exist warns instead of resolving to nothing + +The rule walked `objects`, `globalActions`, `apps` and `dashboards`; an unrecognised +top-level namespace is skipped and never reported, and `flows` was one of them. So a +bundle keyed to `flows..screens..fields.` parsed, shipped, and +silently resolved to nothing — the wizard rendering its source-locale string while every +other label on the screen was translated, which is the exact failure this rule exists for, +one namespace over. + +All three levels are exact-match identifiers with an enumerable universe, so the leg +mirrors the `dashboards` → `widgets` leg one level further: flow → `Flow.name`, screen → +`FlowNode.id` on `type: 'screen'` nodes, field → `ScreenFieldConfig.name`. Findings are +`warning`, like every other finding in this rule (ADR-0072 D1 — an orphan key is inert, +not broken), and each names the declared universe it resolved against. + +Two shape facts the collector respects, both measured against the schemas rather than +assumed — either one read the obvious way would have made the leg a false-positive +generator: + +- **Screen nodes nest.** A screen inside an ADR-0031 region (`loop.config.body`, + `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a real screen the + runner pauses on, so the universe is collected through `walkFlowNodes` rather than the + flat `flow.nodes`. +- **`ScreenConfigSchema` has two mutually exclusive shapes.** An object-form screen + (`config.objectName`) renders that object's own create/edit form and declares no + `config.fields`; its input labels resolve through `objects..fields.*`, so a + field key there is reported with that redirect rather than a bare "not declared". + +A key naming a node that exists but is not a `screen` is diagnosed as the wrong node type, +not as a missing node. diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 108f0f5725..da849d4560 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -373,6 +373,223 @@ describe('validateTranslationReferences — apps, dashboards, global actions', ( }); }); +describe('validateTranslationReferences — flows (#7646 / #11287)', () => { + /** + * One stack, shared by every case below — the clean run and the three + * orphan runs differ ONLY in the bundle, so "no findings" is a verdict about + * this metadata rather than a rule that never reached it. + */ + const flowStack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' }, owner: { type: 'lookup' } } }], + flows: [ + { + name: 'lead_conversion', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'gate', type: 'decision', label: 'Qualified?' }, + { + id: 'details', + type: 'screen', + label: 'Details', + config: { + title: 'Conversion Details', + fields: [ + { name: 'opportunity_name', label: 'Opportunity Name' }, + { name: 'close_date', label: 'Close Date' }, + ], + }, + }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'gate' }, + { id: 'e2', source: 'gate', target: 'details' }, + ], + }, + ], + }; + + const bundle = (flows: unknown) => ({ ...flowStack, translations: [{ 'zh-CN': { flows } }] }); + + /** + * The universe the collector actually reached, read back out of the rule's + * OWN hint — the tail `listNames()` prints. + * + * This is the assertion the leg exists for. A universe collector that + * silently reaches nothing reports nothing, which is indistinguishable from + * a leg that looked and found no orphans; here the collected names come back + * through the production code path, so a collector that reached nothing + * prints an empty tail and fails. + */ + const enumeratedUniverse = (hint: string, lead: string): string[] => { + const matched = new RegExp(`${lead}: ([^.]*)\\.`).exec(hint); + return matched ? matched[1].split(', ').filter(Boolean) : []; + }; + + it('reports nothing when the flow, the screen node and the field all resolve', () => { + const findings = validateTranslationReferences( + bundle({ + lead_conversion: { + label: '线索转换', + screens: { + details: { + title: '转换详情', + fields: { + opportunity_name: { label: '商机名称', placeholder: '请输入' }, + close_date: { label: '预计成交日期' }, + }, + }, + }, + }, + }), + ); + expect(findings).toEqual([]); + }); + + it('fires on an orphan at level 1 — the flow name — and enumerates a non-empty flow universe', () => { + const findings = validateTranslationReferences( + bundle({ lead_conversions: { label: 'x', screens: { details: { title: 'y' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.lead_conversions'); + expect(findings[0].message).toContain('Did you mean "lead_conversion"?'); + // ⭐ the collector reached a real flow, so the zero above is a reading. + expect(enumeratedUniverse(findings[0].hint, 'Defined flows')).toEqual(['lead_conversion']); + }); + + it('fires on an orphan at level 2 — the screen node id — and enumerates a non-empty screen universe', () => { + const findings = validateTranslationReferences( + bundle({ lead_conversion: { label: '线索转换', screens: { detail: { title: 'y' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.lead_conversion.screens.detail'); + expect(findings[0].message).toContain('Did you mean "details"?'); + expect(findings[0].hint).toContain('ScreenSpec.nodeId'); + expect(enumeratedUniverse(findings[0].hint, 'Declared screen node ids')).toEqual(['details']); + }); + + it('fires on an orphan at level 3 — the screen field name — and enumerates a non-empty field universe', () => { + const findings = validateTranslationReferences( + bundle({ + lead_conversion: { + screens: { details: { title: '转换详情', fields: { opportunity: { label: 'x' } } } }, + }, + }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe( + 'translations[0]["zh-CN"].flows.lead_conversion.screens.details.fields.opportunity', + ); + expect(findings[0].message).toContain('Did you mean "opportunity_name"?'); + expect(enumeratedUniverse(findings[0].hint, 'Declared screen field names')).toEqual([ + 'close_date', + 'opportunity_name', + ]); + }); + + it('diagnoses a key on a real node of the wrong type as such, not as a missing node', () => { + const findings = validateTranslationReferences( + bundle({ lead_conversion: { screens: { gate: { title: 'x' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('declares as a `decision` node, not a `screen`'); + }); + + it('resolves a screen nested in an ADR-0031 region — the runner pauses on it, so its keys are not orphans', () => { + const nestedStack = { + flows: [ + { + name: 'lead_conversion', + type: 'screen', + nodes: [ + { + id: 'per_lead', + type: 'loop', + label: 'Each Lead', + config: { + collection: '{leads}', + body: { + nodes: [ + { + id: 'nested_screen', + type: 'screen', + label: 'Confirm', + config: { fields: [{ name: 'confirmed', label: 'Confirmed' }] }, + }, + ], + }, + }, + }, + ], + }, + ], + }; + const nestedBundle = (screens: unknown) => ({ + ...nestedStack, + translations: [{ 'zh-CN': { flows: { lead_conversion: { screens } } } }], + }); + + expect( + validateTranslationReferences( + nestedBundle({ nested_screen: { title: '确认', fields: { confirmed: { label: '已确认' } } } }), + ), + ).toEqual([]); + + // …and the same nested universe still judges: one letter off and it fires, + // so the green above is a resolution rather than an unreached subtree. + const findings = validateTranslationReferences( + nestedBundle({ nested_screen: { fields: { confirme: { label: '已确认' } } } }), + ); + expect(findings).toHaveLength(1); + expect(enumeratedUniverse(findings[0].hint, 'Declared screen field names')).toEqual(['confirmed']); + }); + + it('redirects a field key on an object-form screen to the objects group', () => { + const findings = validateTranslationReferences({ + ...flowStack, + flows: [ + { + name: 'lead_conversion', + type: 'screen', + nodes: [ + { id: 'edit_lead', type: 'screen', label: 'Edit', config: { objectName: 'crm_lead', mode: 'edit' } }, + ], + }, + ], + translations: [ + { + 'zh-CN': { + flows: { lead_conversion: { screens: { edit_lead: { title: '编辑', fields: { owner: { label: '负责人' } } } } } }, + }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].message).toContain('OBJECT-FORM screen'); + expect(findings[0].hint).toContain('objects.crm_lead.fields.owner'); + }); + + it('accepts a flow map keyed by name, the normalized stack shape', () => { + const findings = validateTranslationReferences({ + flows: { + lead_conversion: { + type: 'screen', + nodes: [{ id: 'details', type: 'screen', label: 'D', config: { fields: [{ name: 'opportunity_name' }] } }], + }, + }, + translations: [ + { 'zh-CN': { flows: { lead_conversion: { screens: { details: { fields: { opportunity_name: { label: 'x' } } } } } } } }, + ], + }); + expect(findings).toEqual([]); + }); +}); + describe('validateTranslationReferences — namespaces deliberately not judged', () => { it('ignores messages, validationMessages, settings, metadataForms and settingsCommon', () => { const findings = validateTranslationReferences({ diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index f76b88747f..ea0b60827f 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -52,6 +52,39 @@ * (locale → `TranslationData`). Its keys are simply not visited here: an * unrecognised top-level namespace is skipped, never reported. * + * ── Flows: what the three levels resolve against ───────────────────────── + * + * `flows..screens..fields.` (#7646 / #11287) is + * three exact-match identifiers deep, and all three have an enumerable + * universe, so the leg mirrors `dashboards` → `widgets` one level further: + * + * | level | key | declared by | + * |--------|---------------------------|------------------------------------| + * | flow | `Flow.name` | `flow.zod.ts` (required machine name) | + * | screen | `FlowNode.id` | `flow.zod.ts`, `type: 'screen'` nodes | + * | field | `ScreenFieldConfig.name` | `builtin-node-config.zod.ts` (`config.fields[]`) | + * + * Two shape facts the collector must respect, both measured rather than + * assumed — either one, read the obvious way, turns this leg into a + * false-positive generator: + * + * 1. **Screen nodes NEST.** `FlowNode.config` carries ADR-0031 regions + * (`loop.config.body`, `parallel.config.branches[].nodes`, + * `try_catch.config.try`/`.catch`), each holding a full node array. A + * screen in one is a real screen — the runner pauses on it and the client + * holds its `nodeId` — so the universe is collected with `walkFlowNodes`, + * not off the flat `flow.nodes`. + * 2. **`ScreenConfigSchema` has two mutually exclusive shapes.** A FLAT + * screen declares `config.fields[]`; an OBJECT-FORM screen + * (`config.objectName`) renders that object's whole create/edit form and + * declares no `fields` at all. Its input labels resolve through + * `objects..fields.*`, so a field key on one is an orphan — + * reported with that redirect rather than a bare "not declared". + * + * `flows..label` and `.screens..title` are leaf copy on a node that + * resolved, so they need no check of their own — the schema closes the leaf + * vocabulary (`.strict()`), which is a shape concern, not a reference. + * * ── Cross-package objects ──────────────────────────────────────────────── * * A stack legitimately translates objects it does not define — `sys_user`'s @@ -67,10 +100,22 @@ import { expandViewContainer } from '@objectstack/spec'; import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from '@objectstack/spec/system'; +import { walkFlowNodes } from './flow-walk.js'; import { walkPageComponents } from './page-walk.js'; import { SYSTEM_FIELDS } from './system-fields.js'; import { viewObjectName } from './view-walk.js'; +/** + * The `FlowNode.type` a `flows..screens.*` key addresses. + * + * A local const, the same way `i18n-resolver.ts` keeps its own `SCREEN_NODE_TYPE` + * beside the resolver that reads it: `'screen'` is a member of the open + * `FlowNodeAction` seed set (ADR-0018 removed the enum gate on `FlowNode.type`), + * and neither that enum nor `FLOW_BUILTIN_NODE_TYPES` exposes "the screen one" + * by name to import. + */ +const SCREEN_NODE_TYPE = 'screen'; + export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown'; export const TRANSLATION_OPTION_KEY_UNKNOWN = 'translation-option-key-unknown'; @@ -187,11 +232,38 @@ interface ObjectFacts { sections: Set; } +/** Everything a `flows..screens.` key may legally name. */ +interface ScreenFacts { + /** `config.fields[].name` — the flat screen's declared inputs. */ + fields: Set; + /** + * `config.objectName` when this is an OBJECT-FORM screen. Its inputs are the + * object's own create/edit form, not `config.fields`, so a field key here is + * an orphan that belongs under `objects..fields.*` — the same + * "say where it belongs" redirect a misfiled `globalActions` key gets. + */ + objectName?: string; +} + +/** Everything a `flows..…` key may legally name under one flow. */ +interface FlowFacts { + /** Screen node id → that screen's facts. Keyed by `FlowNode.id`. */ + screens: Map; + /** + * Every NON-screen node id → its `type`. The `screens` group addresses screen + * nodes only, so a key naming a real `decision` node is still an orphan — but + * one whose diagnosis is "wrong node type", not "no such node". + */ + otherNodes: Map; +} + interface Universe { objects: Map; /** App name → every navigation item id declared by that app. */ apps: Map>; dashboards: Map; actions: Set }>; + /** Flow name (`Flow.name`) → its screen nodes and their declared fields. */ + flows: Map; /** Object-less actions — the ones `globalActions.*` may name. */ globalActions: Map; /** Action name → owning object, so a misfiled `globalActions` key can say where it belongs. */ @@ -565,7 +637,42 @@ function buildUniverse(stack: AnyRec): Universe { dashboards.set(dashName, { widgets, actions }); } - return { objects, apps, dashboards, globalActions, actionOwners }; + // ── Flows: screen node ids + the field names each screen declares ── + // + // Nodes are collected through `walkFlowNodes`, NOT `flow.nodes` directly: a + // screen inside an ADR-0031 region (`loop.config.body`, + // `parallel.config.branches[].nodes`, `try_catch.config.try`/`.catch`) is a + // real screen the runner pauses on and hands the client a `ScreenSpec.nodeId` + // for, so its translation key resolves. Reading the flat array would leave + // every nested screen out of the universe and report each of its keys as an + // orphan — a warning-severity false positive, which is exactly the + // over-stating ADR-0072 D1 forbids. + const flows = new Map(); + for (const flow of asArray(stack.flows)) { + const flowName = strName(flow.name); + if (!flowName) continue; + const screens = new Map(); + const otherNodes = new Map(); + for (const { node } of walkFlowNodes(flow, '')) { + const nodeId = strName(node.id); + if (!nodeId) continue; + const nodeType = strName(node.type); + if (nodeType !== SCREEN_NODE_TYPE) { + if (nodeType && !otherNodes.has(nodeId)) otherNodes.set(nodeId, nodeType); + continue; + } + const config = isRec(node.config) ? node.config : undefined; + const fields = new Set(); + for (const field of asArray(config?.fields)) { + const name = strName(field.name); + if (name) fields.add(name); + } + screens.set(nodeId, { fields, objectName: strName(config?.objectName) }); + } + flows.set(flowName, { screens, otherNodes }); + } + + return { objects, apps, dashboards, flows, globalActions, actionOwners }; } /** Quote a locale for the config path — BCP-47 tags carry `-`. */ @@ -827,6 +934,73 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind ); } } + + // ── flows.[.screens.[.fields.]] ──────── + for (const [flowName, rawFlow] of Object.entries(asRecord(rawData.flows))) { + const flowPath = `${base}.flows.${flowName}`; + const flow = universe.flows.get(flowName); + if (!flow) { + orphan( + `${inLocale} · flow "${flowName}"`, + flowPath, + `Translations are keyed to flow "${flowName}", which this stack does not define. ` + + `The wizard renders its source-locale label and headings.` + + suggest(flowName, universe.flows.keys()), + `Match the key to a flow's \`name\` (the machine name, not its label), or drop it.` + + (universe.flows.size > 0 ? ` Defined flows: ${listNames(universe.flows.keys())}.` : ''), + ); + continue; + } + if (!isRec(rawFlow)) continue; + for (const [nodeId, rawScreen] of Object.entries(asRecord(rawFlow.screens))) { + const screenPath = `${flowPath}.screens.${nodeId}`; + const screen = flow.screens.get(nodeId); + if (!screen) { + const otherType = flow.otherNodes.get(nodeId); + orphan( + `${inLocale} · flow "${flowName}" · screen "${nodeId}"`, + screenPath, + otherType + ? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` + + `declares as a \`${otherType}\` node, not a \`screen\`. Only screen nodes ` + + `render a heading and fields for a user to read, so nothing resolves these keys.` + : `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` + + `declares no screen node for. The wizard step keeps its source-locale heading.` + + suggest(nodeId, flow.screens.keys()), + `Screen translations are keyed by the node's \`id\` (the client's ` + + `\`ScreenSpec.nodeId\`), not its \`label\`.` + + (flow.screens.size > 0 + ? ` Declared screen node ids: ${listNames(flow.screens.keys())}.` + : ` Flow "${flowName}" declares no \`type: 'screen'\` node at all.`), + ); + continue; + } + if (!isRec(rawScreen)) continue; + for (const fieldName of Object.keys(asRecord(rawScreen.fields))) { + if (screen.fields.has(fieldName)) continue; + const objectForm = screen.fields.size === 0 ? screen.objectName : undefined; + orphan( + `${inLocale} · flow "${flowName}" · screen "${nodeId}" · field "${fieldName}"`, + `${screenPath}.fields.${fieldName}`, + objectForm + ? `Translations are keyed to screen field "${fieldName}", but screen "${nodeId}" ` + + `is an OBJECT-FORM screen (\`config.objectName: "${objectForm}"\`) — it renders ` + + `that object's own create/edit form, so its input labels come from ` + + `\`objects.${objectForm}.fields.*\` and nothing reads a field key here.` + : `Translations are keyed to screen field "${fieldName}", which screen "${nodeId}" ` + + `of flow "${flowName}" does not declare. The input keeps its source-locale ` + + `label while every neighbouring field on the same screen resolves.` + + suggest(fieldName, screen.fields), + objectForm + ? `Move this copy under \`objects.${objectForm}.fields.${fieldName}\`, or drop it.` + : `Match the key to a \`config.fields[].name\` on that screen, or drop it.` + + (screen.fields.size > 0 + ? ` Declared screen field names: ${listNames(screen.fields)}.` + : ` Screen "${nodeId}" declares no \`config.fields\` at all.`), + ); + } + } + } } }