Skip to content

Commit b434304

Browse files
hotlongclaude
andauthored
test(lint): loop fixtures 改拼 canonical 的 iteratorVariable 并上 schema pin (#5700) (#6103)
三处 pre-existing fixture 用 `itemVar` 绑定循环项,而 `LoopConfigSchema` 是 strictObject、声明键为 `iteratorVariable`,`itemVar` 既非声明键也非 alias,会被 报成 `unrecognized_keys`(#4001)。region 收集只读 `config.body`,所以这些 fixture 照常下潜、断言照常通过 —— 规则本身没问题,但 fixture 描述的是作者写不出来的 `loop`,等于把覆盖面讲得比实际强(#4966 在 trigger 描述符上的同类发现,下沉一层)。 改动: - `loopBodyFlow()` 共享 helper、#5383 嵌套用例、#5482 嵌套用例三处改拼 canonical; - body 节点补上 `FlowNodeSchema` 必需的 `label`(#5695 对自带 fixture 的同样处理); - 把三处 loop config 提成具名常量并各自 pin `LoopConfigSchema` 全绿。 嵌套用例为什么要各自 pin(实测,非推断):`FlowNodeSchema.config` 声明为 `z.record(z.string(), z.unknown())` 开放记录,外层 parse 会整块接受内层容器的 config —— 只 pin 外壳时,内层 `loop` 写 `itemVar` 外壳 pin 依然全绿。该盲区已作为 一条断言写进测试,免得后来者把顶层一条 pin 误当成整棵树的覆盖。 顺带的容器键位审计(本单 durable 半边):文件内 3 处 `parallel` fixture 全部只用 已声明键,0 处 `try_catch` fixture;审计断言按 key 级门槛(`refusedKeys`)落, 沿用 `validate-security-posture.test.ts` 已写下的「拒绝的 KEY 与拒绝的 VALUE 是 两回事」判据 —— 全文件 full-`FlowSchema`-green 是 #5700 明确另案的更大改动。 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce1155c commit b434304

1 file changed

Lines changed: 190 additions & 49 deletions

File tree

packages/lint/src/lint-flow-patterns.test.ts

Lines changed: 190 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { TimeRelativeTriggerSchema, LoopConfigSchema, FlowSchema } from '@objectstack/spec/automation';
4+
import { TimeRelativeTriggerSchema, LoopConfigSchema, ParallelConfigSchema, FlowSchema } from '@objectstack/spec/automation';
55
import { AUTHORING_RULES } from './authoring-rules.js';
66
import {
77
lintFlowPatterns,
@@ -51,6 +51,35 @@ const flow = (condition: unknown, triggerType = 'record-after-update') => ({
5151
}],
5252
});
5353

54+
/**
55+
* The keys a container schema REFUSED on a fixture — `[]` when the shape spells
56+
* only declared keys, whatever the schema thinks of their VALUES (#5700).
57+
*
58+
* The criterion is deliberately key-level, and the same one
59+
* `validate-security-posture.test.ts` writes down for its reachability guard: a
60+
* rejected KEY and a rejected VALUE are different facts. A key the schema does
61+
* not declare is not an authoring surface at all — a fixture spelling one
62+
* describes a container no author can save, which is exactly the defect #5700
63+
* catalogued. A rejected value is a fixture choice these rules are often
64+
* entitled to make.
65+
*
66+
* So the container audits below use this, NOT flat `safeParse` success: most of
67+
* this file's fixtures are hand-written raw literals that omit the `label`
68+
* `FlowNodeSchema` requires on every node, and demanding full-parse green would
69+
* either delete that coverage or drag in a file-wide re-write #5700 explicitly
70+
* defers. Where a fixture IS fully authorable, it is pinned with full green
71+
* instead (see the `LoopConfigSchema` pins).
72+
*/
73+
function refusedKeys(result: {
74+
success: boolean;
75+
error?: { issues: readonly { code: string; path: readonly PropertyKey[]; message: string }[] };
76+
}): string[] {
77+
if (result.success) return [];
78+
return (result.error?.issues ?? [])
79+
.filter((i) => i.code === 'unrecognized_keys')
80+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
81+
}
82+
5483
describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => {
5584
it('flags record-change date-EQUALITY against a time function', () => {
5685
const fnds = lintFlowPatterns(flow('end_date == daysFromNow(60)'));
@@ -620,21 +649,24 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
620649
// container's config physically contains its body, and the walk visits the
621650
// body in its own right, so a rule that pushed per hit would double-report.
622651
it('reports ONCE for a flow whose regions hold several data nodes', () => {
652+
const fanConfig = {
653+
branches: [
654+
{ name: 'writes', nodes: [{ id: 'w1', type: 'update_record', label: 'Write', config: { objectName: 'a' } }], edges: [] },
655+
{ name: 'deletes', nodes: [{ id: 'w2', type: 'delete_record', label: 'Delete', config: { objectName: 'b' } }], edges: [] },
656+
],
657+
};
658+
// #5700 container audit — the `parallel` twin of the `loop` pin above. This
659+
// block already declares `name`/`nodes`/`edges` and labels its branch nodes,
660+
// so it clears the strongest bar available: full `safeParse` green.
661+
expect(ParallelConfigSchema.safeParse(fanConfig).success).toBe(true);
662+
623663
const fnds = lintFlowPatterns({
624664
flows: [{
625665
name: 'nightly_sweep',
626666
type: 'schedule',
627667
nodes: [
628668
{ id: 'start', type: 'start', config: { triggerType: 'schedule', cron: '0 8 * * *' } },
629-
{
630-
id: 'fan', type: 'parallel',
631-
config: {
632-
branches: [
633-
{ name: 'writes', nodes: [{ id: 'w1', type: 'update_record', label: 'Write', config: { objectName: 'a' } }], edges: [] },
634-
{ name: 'deletes', nodes: [{ id: 'w2', type: 'delete_record', label: 'Delete', config: { objectName: 'b' } }], edges: [] },
635-
],
636-
},
637-
},
669+
{ id: 'fan', type: 'parallel', config: fanConfig },
638670
],
639671
edges: [{ id: 'e1', source: 'start', target: 'fan' }],
640672
}],
@@ -1090,6 +1122,17 @@ describe('flow-inert-node-condition (#4414)', () => {
10901122
* future flattening of the walk fails here instead of going quiet again.
10911123
*/
10921124

1125+
/**
1126+
* The `loop` container `loopBodyFlow` builds, factored out (#5700) so the pin
1127+
* below reads the REAL config object every case in this family descends into,
1128+
* rather than a retyped copy that can drift away from it silently.
1129+
*/
1130+
const loopLeadsConfig = (body: { nodes: unknown[]; edges: unknown[] }) => ({
1131+
collection: '{vars.leads}',
1132+
iteratorVariable: 'lead',
1133+
body,
1134+
});
1135+
10931136
/** A scheduled sweep: `loop` over leads, with `body` holding the per-item graph. */
10941137
function loopBodyFlow(body: { nodes: unknown[]; edges: unknown[] }) {
10951138
return {
@@ -1100,7 +1143,7 @@ function loopBodyFlow(body: { nodes: unknown[]; edges: unknown[] }) {
11001143
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
11011144
{
11021145
id: 'loop_leads', type: 'loop', label: 'Loop Leads',
1103-
config: { collection: '{vars.leads}', itemVar: 'lead', body },
1146+
config: loopLeadsConfig(body),
11041147
},
11051148
{ id: 'end', type: 'end' },
11061149
],
@@ -1112,6 +1155,71 @@ function loopBodyFlow(body: { nodes: unknown[]; edges: unknown[] }) {
11121155
};
11131156
}
11141157

1158+
/**
1159+
* #5700 — the container every case below nests its per-item graph inside must be
1160+
* a shape the schema ACCEPTS, or those cases prove their rule against metadata
1161+
* no author can write. That is the #4966 trap (the `TimeRelativeTriggerSchema`
1162+
* pin near the top of this file) one container down, and it was not hypothetical
1163+
* here: this helper bound the item with `itemVar` until #5700. `LoopConfigSchema`
1164+
* is a `strictObject` whose declared key is `iteratorVariable`, so `itemVar` is
1165+
* reported as an `unrecognized_key` rather than dropped (#4001) — while region
1166+
* collection reads only `config.body`, so every assertion downstream kept passing
1167+
* and nothing went red.
1168+
*
1169+
* The near-miss direction is already pinned once, in the #5633 block above
1170+
* (`itemVar` really is refused, so this family's pins have teeth); it is not
1171+
* repeated here.
1172+
*
1173+
* Full `safeParse` green rather than the key-level `refusedKeys` bar, because
1174+
* what the rules built on this helper judge is a VALUE verdict about a node that
1175+
* must really be REACHABLE inside a really-authorable container — so the body
1176+
* has to parse too, `label` and all.
1177+
*
1178+
* What is pinned is the container SHELL plus one representative body: the body
1179+
* is this helper's parameter, and the bodies the call sites pass are raw
1180+
* literals that omit the node `label` `FlowNodeSchema` requires. Bringing this
1181+
* whole file to full-flow-parse green is the separate, much broader change
1182+
* #5700 defers by name.
1183+
*/
1184+
describe('#5700 — the shared loop container is a shape an author can actually write', () => {
1185+
it('pins loopBodyFlow’s `loop` config against LoopConfigSchema', () => {
1186+
const parsed = LoopConfigSchema.safeParse(loopLeadsConfig({
1187+
nodes: [{ id: 'enroll', type: 'create_record', label: 'Enroll', config: { objectName: 'campaign_member' } }],
1188+
edges: [],
1189+
}));
1190+
expect(parsed.success).toBe(true);
1191+
});
1192+
1193+
/**
1194+
* Why the two nested cases below carry their OWN pins instead of leaning on
1195+
* this one. `FlowRegionSchema.nodes` is an array of `FlowNodeSchema`, whose
1196+
* `config` is declared `z.record(z.string(), z.unknown())` — an OPEN record.
1197+
* So a nested container's config is accepted wholesale by the enclosing parse:
1198+
* this shell pin stays GREEN while an inner `loop` spells `itemVar`. Measured,
1199+
* not assumed — the assertion below is that blindness, stated as a fact, so a
1200+
* future reader does not mistake one pin at the top for coverage of the tree.
1201+
*/
1202+
it('does NOT see into a nested container’s config — the inner pins are load-bearing', () => {
1203+
const shellWithBadInnerLoop = LoopConfigSchema.safeParse(loopLeadsConfig({
1204+
nodes: [{
1205+
id: 'loop_touchpoints', type: 'loop', label: 'Loop Touchpoints',
1206+
config: {
1207+
collection: '{lead.touchpoints}', itemVar: 'tp',
1208+
body: { nodes: [{ id: 'reset', type: 'update_record', label: 'Reset', config: {} }], edges: [] },
1209+
},
1210+
}],
1211+
edges: [],
1212+
}));
1213+
expect(shellWithBadInnerLoop.success).toBe(true);
1214+
1215+
// …whereas the very same inner config, parsed as the container it is, is refused.
1216+
expect(refusedKeys(LoopConfigSchema.safeParse({
1217+
collection: '{lead.touchpoints}', itemVar: 'tp',
1218+
body: { nodes: [{ id: 'reset', type: 'update_record', label: 'Reset', config: {} }], edges: [] },
1219+
})).join(' ')).toMatch(/Unrecognized key\(s\) on this loop container config: `itemVar`/);
1220+
});
1221+
});
1222+
11151223
describe('#5383 — flow-inert-node-condition descends into a loop body', () => {
11161224
// The shipped shape, reduced: a per-item gate inside a sweep, whose predicate
11171225
// was written on the node instead of its out-edges.
@@ -1155,18 +1263,29 @@ describe('#5383 — flow-inert-node-condition descends into a loop body', () =>
11551263
expect(fnds[0].where).not.toContain('loop');
11561264
});
11571265

1266+
/**
1267+
* The INNER container, named so the pin below reads the object the fixture
1268+
* really descends into. The planted defect is the decision's inert
1269+
* `config.condition` — everything else about this shape is authorable, and
1270+
* has to be, or the case proves the descent against a `loop` the schema
1271+
* refuses (#5700; the enclosing shell pin cannot see in here — `FlowNodeSchema`
1272+
* declares `config` as an open record).
1273+
*/
1274+
const nestedTouchpointsLoop = {
1275+
collection: '{lead.touchpoints}', iteratorVariable: 'tp',
1276+
body: {
1277+
nodes: [{ id: 'check_recent', type: 'decision', label: 'Check Recent', config: { condition: 'tp.age_days < 7' } }],
1278+
edges: [],
1279+
},
1280+
};
1281+
1282+
it('pins that inner loop container against LoopConfigSchema (#5700)', () => {
1283+
expect(LoopConfigSchema.safeParse(nestedTouchpointsLoop).success).toBe(true);
1284+
});
1285+
11581286
it('descends a loop nested inside a loop — same depth semantics as the engine', () => {
11591287
const fnds = lintFlowPatterns(loopBodyFlow({
1160-
nodes: [{
1161-
id: 'loop_touchpoints', type: 'loop',
1162-
config: {
1163-
collection: '{lead.touchpoints}', itemVar: 'tp',
1164-
body: {
1165-
nodes: [{ id: 'check_recent', type: 'decision', config: { condition: 'tp.age_days < 7' } }],
1166-
edges: [],
1167-
},
1168-
},
1169-
}],
1288+
nodes: [{ id: 'loop_touchpoints', type: 'loop', label: 'Loop Touchpoints', config: nestedTouchpointsLoop }],
11701289
edges: [],
11711290
})).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION);
11721291
expect(fnds).toHaveLength(1);
@@ -1177,21 +1296,25 @@ describe('#5383 — flow-inert-node-condition descends into a loop body', () =>
11771296
});
11781297

11791298
it('descends a parallel branch too — the scope names the branch index', () => {
1299+
const fanConfig = {
1300+
branches: [
1301+
{ name: 'owner', nodes: [{ id: 'gate', type: 'decision', config: { condition: 'a == b' } }], edges: [] },
1302+
{ name: 'watchers', nodes: [{ id: 'ping', type: 'notify', config: { title: 'Hi {record.name}' } }], edges: [] },
1303+
],
1304+
};
1305+
// #5700 container audit, key-level bar (see `refusedKeys`): every key here is
1306+
// one `ParallelConfigSchema` declares. The branch nodes omit the `label`
1307+
// `FlowNodeSchema` requires, which is the file-wide convention #5700 defers —
1308+
// a rejected VALUE, not a key that is no authoring surface at all.
1309+
expect(refusedKeys(ParallelConfigSchema.safeParse(fanConfig))).toEqual([]);
1310+
11801311
const fnds = lintFlowPatterns({
11811312
flows: [{
11821313
name: 'fan_out',
11831314
runAs: 'system',
11841315
nodes: [
11851316
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
1186-
{
1187-
id: 'fan', type: 'parallel',
1188-
config: {
1189-
branches: [
1190-
{ name: 'owner', nodes: [{ id: 'gate', type: 'decision', config: { condition: 'a == b' } }], edges: [] },
1191-
{ name: 'watchers', nodes: [{ id: 'ping', type: 'notify', config: { title: 'Hi {record.name}' } }], edges: [] },
1192-
],
1193-
},
1194-
},
1317+
{ id: 'fan', type: 'parallel', config: fanConfig },
11951318
],
11961319
edges: [{ id: 'e1', source: 'start', target: 'fan' }],
11971320
}],
@@ -1281,21 +1404,25 @@ describe('#5383 — the branch-routing family reads the region’s own edges', (
12811404
{ id: 'g2', source: 'gate', target: 'y', isDefault: true },
12821405
],
12831406
});
1407+
const fanConfig = {
1408+
branches: [
1409+
{ name: 'a', ...branch('lead.score > 50') },
1410+
{ name: 'b', ...branch('lead.score > 90') },
1411+
],
1412+
};
1413+
// #5700 container audit, key-level bar (see `refusedKeys`). Covers the branch
1414+
// EDGES too: `condition` and `isDefault` are the keys this case turns on, and
1415+
// both are declared — so what it pins is real edge vocabulary, not a shape
1416+
// the schema would refuse.
1417+
expect(refusedKeys(ParallelConfigSchema.safeParse(fanConfig))).toEqual([]);
1418+
12841419
const fnds = lintFlowPatterns({
12851420
flows: [{
12861421
name: 'twin_regions',
12871422
runAs: 'system',
12881423
nodes: [
12891424
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
1290-
{
1291-
id: 'fan', type: 'parallel',
1292-
config: {
1293-
branches: [
1294-
{ name: 'a', ...branch('lead.score > 50') },
1295-
{ name: 'b', ...branch('lead.score > 90') },
1296-
],
1297-
},
1298-
},
1425+
{ id: 'fan', type: 'parallel', config: fanConfig },
12991426
],
13001427
edges: [{ id: 'e1', source: 'start', target: 'fan' }],
13011428
}],
@@ -1510,19 +1637,33 @@ describe('lintFlowPatterns — unbounded bulk write (#5482)', () => {
15101637
expect(fnds[0].message).toContain("every row of 'campaign_member' is deleted");
15111638
});
15121639

1513-
it('flags an update_record two regions deep', () => {
1514-
const fnds = lintFlowPatterns(loopBodyFlow({
1640+
/**
1641+
* The INNER container for the two-regions-deep case, named so the pin below
1642+
* reads the object the fixture descends into (#5700). The planted defect is
1643+
* the body node's unfiltered `multi: true`; the container around it has to
1644+
* be authorable or the case proves this rule against a `loop` the schema
1645+
* refuses — and the enclosing shell pin cannot see in here, because
1646+
* `FlowNodeSchema` declares `config` as an open record.
1647+
*/
1648+
const nestedTouchpointsResetLoop = {
1649+
collection: '{lead.touchpoints}', iteratorVariable: 'tp',
1650+
body: {
15151651
nodes: [{
1516-
id: 'loop_touchpoints', type: 'loop', label: 'Loop Touchpoints',
1517-
config: {
1518-
collection: '{lead.touchpoints}', itemVar: 'tp',
1519-
body: {
1520-
nodes: [{ id: 'reset', type: 'update_record', config: { objectName: 'touchpoint', fields: { done: false }, multi: true } }],
1521-
edges: [],
1522-
},
1523-
},
1652+
id: 'reset', type: 'update_record', label: 'Reset Touchpoints',
1653+
config: { objectName: 'touchpoint', fields: { done: false }, multi: true },
15241654
}],
15251655
edges: [],
1656+
},
1657+
};
1658+
1659+
it('pins that inner loop container against LoopConfigSchema (#5700)', () => {
1660+
expect(LoopConfigSchema.safeParse(nestedTouchpointsResetLoop).success).toBe(true);
1661+
});
1662+
1663+
it('flags an update_record two regions deep', () => {
1664+
const fnds = lintFlowPatterns(loopBodyFlow({
1665+
nodes: [{ id: 'loop_touchpoints', type: 'loop', label: 'Loop Touchpoints', config: nestedTouchpointsResetLoop }],
1666+
edges: [],
15261667
}));
15271668
expect(fnds).toHaveLength(1);
15281669
expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED);

0 commit comments

Comments
 (0)