Skip to content

Commit f0964e6

Browse files
committed
feat(spec)!: strict unknown keys on automation control-flow + state-machine (#4001 批 10)
11 strip sites closed across two files: - control-flow.zod.ts (5): FlowRegion / Loop / ParallelBranch / Parallel / TryCatch - state-machine.zod.ts (6): ActionRef / GuardRef / Transition / StateNode (+.meta) / StateMachine Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
1 parent 19cb31c commit f0964e6

7 files changed

Lines changed: 837 additions & 138 deletions

File tree

packages/services/service-automation/src/builtin/parse-config.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,23 @@
3434
* silence the contract for a node forever, the same suppression shape #3863
3535
* closed for the other refuse-to-execute guards.
3636
*
37-
* Unknown keys are NOT this seam's job: Zod's default `.strip()` drops them
38-
* silently here, and `registerFlow()` rejects them loudly at registration
39-
* (the tightened #4059 check). Type + `required` live here; key membership
40-
* lives there.
37+
* Key membership is not this seam's JOB, but since #4001 批 10 it is no longer
38+
* this seam's blind spot either. It used to be both: zod's default `.strip()`
39+
* dropped an unknown key silently here, so the sentence "key membership lives
40+
* at `registerFlow()`" described a division of labour AND a hole — anything
41+
* reaching this parse by another route got no key check at all. The three
42+
* ADR-0031 control-flow contracts (`LoopConfigSchema`, `ParallelConfigSchema`,
43+
* `TryCatchConfigSchema`) are now `strictObject`, so an unknown key here
44+
* refuses like any other contract violation.
45+
*
46+
* In engine-run flows that refusal should never fire: `validateNodeConfigKeys`
47+
* rejects the same key at registration, earlier and with the descriptor's own
48+
* prescriptions, and a flow that fails registration never reaches execution.
49+
* That ordering is the point — the loud door stays the first one. What
50+
* changed is only that the second door stopped being open.
51+
*
52+
* Type + `required` still live here; key membership still belongs at
53+
* registration.
4154
*/
4255

4356
import { refuseNode } from '../guard-refusal.js';

packages/spec/src/automation/control-flow.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { z } from 'zod';
55
import {
66
LoopConfigSchema,
77
ParallelConfigSchema,
8+
ParallelBranchSchema,
89
TryCatchConfigSchema,
910
FlowRegionSchema,
1011
analyzeRegion,
@@ -15,6 +16,7 @@ import {
1516
PARALLEL_NODE_TYPE,
1617
TRY_CATCH_NODE_TYPE,
1718
} from './control-flow.zod';
19+
import { findClosestMatches } from '../shared/suggestions.zod';
1820

1921
const node = (id: string, type = 'assignment') => ({ id, type, label: id });
2022
const edge = (id: string, source: string, target: string) => ({ id, source, target });
@@ -266,3 +268,190 @@ describe('validateControlFlow', () => {
266268
).toThrow(/try_catch 'tc' try/);
267269
});
268270
});
271+
272+
// ─── [#4001 批 10] unknown keys are rejected, not stripped ──────────────────
273+
274+
describe('[#4001] control-flow strictness — per shape', () => {
275+
it('FlowRegion: `name` and `label` get wrong-layer prescriptions, not renames', () => {
276+
for (const [key, expected] of [
277+
['name', 'A region is not named'],
278+
['label', 'A region is not labelled'],
279+
] as const) {
280+
const result = FlowRegionSchema.safeParse({ nodes: [node('a')], [key]: 'body' });
281+
expect(result.success, `region.${key} must be refused`).toBe(false);
282+
expect(result.error!.issues[0]!.message).toContain(expected);
283+
// Neither has a canonical spelling on a region, so neither may be
284+
// renamed — suggesting one would point at a key this schema rejects.
285+
expect(result.error!.issues[0]!.message).not.toContain('Did you mean');
286+
}
287+
});
288+
289+
// This is the entry that had to be MEASURED rather than curated by taste:
290+
// the bare edit-distance fallback answers `itemVariable` with
291+
// `indexVariable`, which binds the loop INDEX where the author wanted the
292+
// ITEM — a silently-wrong loop, prescribed by this campaign's own helper.
293+
// The control below re-runs the raw suggester so the alias cannot be
294+
// "cleaned up" as redundant: delete it and the wrong answer comes straight
295+
// back.
296+
it('Loop: `itemVariable` is aliased to `iteratorVariable`, overruling a WRONG edit-distance hit', () => {
297+
const LOOP_KEYS = ['collection', 'iteratorVariable', 'indexVariable', 'maxIterations', 'body'];
298+
const bare = findClosestMatches('itemVariable', LOOP_KEYS, Math.max(2, Math.floor('itemVariable'.length / 3)), 1);
299+
expect(bare, 'the raw suggester still gets this wrong — that is why the alias exists').toEqual(['indexVariable']);
300+
301+
const result = LoopConfigSchema.safeParse({ collection: '{tasks}', itemVariable: 'task' });
302+
expect(result.success).toBe(false);
303+
expect(result.error!.issues[0]!.message).toContain('`itemVariable` → `iteratorVariable`');
304+
expect(result.error!.issues[0]!.message).not.toContain('indexVariable');
305+
});
306+
307+
it('Loop: `flowName` is pointed at the `map` node, which is where it is real', () => {
308+
const result = LoopConfigSchema.safeParse({ collection: '{tasks}', flowName: 'per_item' });
309+
expect(result.success).toBe(false);
310+
const message = result.error!.issues[0]!.message;
311+
expect(message).toContain('`map` node');
312+
expect(message).toContain('config.body');
313+
});
314+
315+
it('Loop: a plain typo still rides the edit-distance fallback', () => {
316+
const result = LoopConfigSchema.safeParse({ collection: '{x}', maxIteration: 5 });
317+
expect(result.success).toBe(false);
318+
expect(result.error!.issues[0]!.message).toContain('`maxIteration` → `maxIterations`');
319+
});
320+
321+
it('ParallelBranch: `label` → `name`, the word every NODE beside it uses', () => {
322+
// `FlowNodeSchema.label` is REQUIRED on every element of the `nodes[]`
323+
// array in the same literal, so borrowing it is an author's reasonable
324+
// guess — and 5 edits away, so only a named alias reaches it.
325+
const result = ParallelBranchSchema.safeParse({ label: 'Left', nodes: [node('a')] });
326+
expect(result.success).toBe(false);
327+
expect(result.error!.issues[0]!.message).toContain('`label` → `name`');
328+
// The declared spelling is untouched.
329+
expect(() => ParallelBranchSchema.parse({ name: 'Left', nodes: [node('a')] })).not.toThrow();
330+
});
331+
332+
it('Parallel: the two join spellings get DISTINCT prescriptions, not one repeated twice', () => {
333+
const result = ParallelConfigSchema.safeParse({
334+
branches: [{ nodes: [node('a')] }, { nodes: [node('b')] }],
335+
join: 'all',
336+
joinGateway: 'j1',
337+
});
338+
expect(result.success).toBe(false);
339+
const bullets = result.error!.issues[0]!.message.split('\n').filter((l) => l.trim().startsWith('•'));
340+
expect(bullets).toHaveLength(2);
341+
// `guidance` emits one bullet per key verbatim, so a shared string would
342+
// print the same paragraph twice and read as a bug in the error itself.
343+
expect(bullets[0]).not.toBe(bullets[1]);
344+
expect(bullets[0]).toContain('joins IMPLICITLY');
345+
expect(bullets[1]).toContain('BPMN');
346+
});
347+
348+
it('TryCatch: `finally` is answered with WHERE the always-run steps go', () => {
349+
const result = TryCatchConfigSchema.safeParse({
350+
try: { nodes: [node('t')] },
351+
finally: { nodes: [node('f')] },
352+
});
353+
expect(result.success).toBe(false);
354+
const message = result.error!.issues[0]!.message;
355+
expect(message).toContain('There is no `finally` region');
356+
// A prescription that only said "no such key" would leave the author
357+
// stuck; the construct really does have a place for those steps.
358+
expect(message).toContain('AFTER this container');
359+
});
360+
361+
it('every legal shape this file already documented still parses', () => {
362+
// Anti-vacuity for the whole block: strictness that also refused the
363+
// declared spellings would make every assertion above pass for the wrong
364+
// reason. These are the showcase/app-todo shapes, verbatim in structure.
365+
expect(() => LoopConfigSchema.parse({
366+
collection: '{tasksToRemind}', iteratorVariable: 'task', indexVariable: 'i',
367+
maxIterations: 500, body: { nodes: [node('w', 'noop')], edges: [] },
368+
})).not.toThrow();
369+
expect(() => ParallelConfigSchema.parse({
370+
branches: [{ name: 'A', nodes: [node('a')] }, { name: 'B', nodes: [node('b')] }],
371+
})).not.toThrow();
372+
expect(() => TryCatchConfigSchema.parse({
373+
try: { nodes: [node('t')] }, catch: { nodes: [node('c')] },
374+
errorVariable: '$error', retry: { maxRetries: 3, backoffMs: 500 },
375+
})).not.toThrow();
376+
});
377+
});
378+
379+
// The sibling-guard question the batch was dispatched to answer: does closing
380+
// the key gate collide with `validateControlFlow`, which has validated these
381+
// same regions structurally since ADR-0031?
382+
//
383+
// It does not, and the reason is that they answer different questions. The
384+
// schema rejects undeclared KEYS; the analysis rejects malformed STRUCTURE
385+
// (single-entry / single-exit / acyclic), which no key check can decide. They
386+
// meet at exactly one seam — `validateControlFlow` `safeParse`s each region
387+
// slot before analyzing it, so from #4001 that parse is also where an
388+
// undeclared region key surfaces. Nothing was duplicated and nothing was
389+
// removed: the guard's structural prose is untouched, and it simply stopped
390+
// silently repairing its own input before judging it.
391+
describe('[#4001] validateControlFlow and the key gate do not fight', () => {
392+
const flowWith = (cfg: Record<string, unknown>, type = LOOP_NODE_TYPE) =>
393+
({ nodes: [{ ...node('c1', type), config: cfg }] } as never);
394+
395+
it('STRUCTURE errors are still reported by the guard, in the guard\'s own words', () => {
396+
// Two entries / two exits — a key gate cannot see this, and the message
397+
// must stay the analysis\'s, not the schema\'s.
398+
expect(() => validateControlFlow(flowWith({
399+
collection: '{items}', body: { nodes: [node('a'), node('b')], edges: [] },
400+
}))).toThrow(/single-entry/);
401+
});
402+
403+
it('KEY errors now surface through that same guard, carrying the schema\'s prose', () => {
404+
let message = '';
405+
try {
406+
validateControlFlow(flowWith({
407+
collection: '{items}', body: { nodes: [node('a')], edges: [], name: 'inner' },
408+
}));
409+
} catch (e) { message = (e as Error).message; }
410+
411+
// The guard's own framing (which region, which container) …
412+
expect(message).toContain("loop 'c1' body");
413+
expect(message).toContain('invalid region');
414+
// … wrapping the schema's prescription, rather than replacing it.
415+
expect(message).toContain('A region is not named');
416+
});
417+
418+
it('a PARALLEL BRANCH keeps its `name` — the slot picks the branch schema, and now it must', () => {
419+
// `regionSlotsOf` parses `branches[]` as `ParallelBranchSchema` and every
420+
// other slot as `FlowRegionSchema`. That used to be a fidelity choice (the
421+
// region schema would have STRIPPED `name`); with both strict it is a
422+
// correctness one — the region schema would REJECT a legal branch. Pinned
423+
// because the two schemas now differ by a rejection, not by a silent drop.
424+
expect(() => validateControlFlow(flowWith({
425+
branches: [
426+
{ name: 'left', nodes: [node('a')], edges: [] },
427+
{ name: 'right', nodes: [node('b')], edges: [] },
428+
],
429+
}, PARALLEL_NODE_TYPE))).not.toThrow();
430+
431+
// …and the region schema really would have refused it — the anti-vacuity
432+
// half, so this test cannot pass because `name` became universally legal.
433+
expect(FlowRegionSchema.safeParse({ name: 'left', nodes: [node('a')], edges: [] }).success).toBe(false);
434+
});
435+
436+
it('the guard still ignores legacy flat-graph loops (no region to key-check)', () => {
437+
expect(() => validateControlFlow(flowWith({ collection: '{items}', iteratorVariable: 'x' }))).not.toThrow();
438+
});
439+
440+
it('nested regions are key-checked at depth, like the structural check (#4389)', () => {
441+
let message = '';
442+
try {
443+
validateControlFlow(flowWith({
444+
collection: '{outer}',
445+
body: {
446+
nodes: [{
447+
...node('inner', TRY_CATCH_NODE_TYPE),
448+
config: { try: { nodes: [node('x')], edges: [], label: 'oops' } },
449+
}],
450+
edges: [],
451+
},
452+
}));
453+
} catch (e) { message = (e as Error).message; }
454+
expect(message).toContain("loop 'c1' body → try_catch 'inner' try");
455+
expect(message).toContain('A region is not labelled');
456+
});
457+
});

0 commit comments

Comments
 (0)