@@ -5,6 +5,7 @@ import { z } from 'zod';
55import {
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
1921const node = ( id : string , type = 'assignment' ) => ( { id, type, label : id } ) ;
2022const edge = ( id : string , source : string , target : string ) => ( { id, source, target } ) ;
@@ -266,3 +268,190 @@ describe('validateControlFlow', () => {
266268 ) . toThrow ( / t r y _ c a t c h ' t c ' t r y / ) ;
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 ( / s i n g l e - e n t r y / ) ;
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