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
35 changes: 35 additions & 0 deletions .changeset/translation-refs-flows-leg.md
Original file line numberDiff line numberDiff line change
@@ -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.<name>.screens.<node_id>.fields.<field_name>` 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.<objectName>.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.
217 changes: 217 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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({
Expand Down
Loading
Loading