import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { composeProfile } from '../composeCommand'; import { referenceRule } from '@beehexa/hexasync-template-validate'; import { duplicatesRule } from '@beehexa/hexasync-template-validate'; import { analyzeWorkflow, flowRule } from '@beehexa/hexasync-template-validate'; import { harvestPullerTaskIds, isNewKindPuller, } from '@beehexa/hexasync-template-validate'; import { buildMermaid } from '@beehexa/hexasync-template-report-render'; import { ComponentFlow } from '@beehexa/hexasync-template-compose'; import { ValidationContext } from '@beehexa/hexasync-template-validate'; import { withSource } from './withSource'; let tmp = ''; afterEach(() => { if (tmp && fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true }); tmp = ''; }); function write(p: string, content: string) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); } const ctx = (tokenForm: any): ValidationContext => ({ tokenForm, variables: {} }) as unknown as ValidationContext; // Canonical clause D: a harvested taskId must be a static hyphenated GUID. const G_T1 = '11111111-1111-4111-8111-111111111111'; const G_T2 = '22222222-2222-4222-8222-222222222222'; const G_TNEW = '33333333-3333-4333-8333-333333333333'; const G_GHOST = '44444444-4444-4444-8444-444444444444'; const G_OBJ = '55555555-5555-4555-8555-555555555555'; const G_ASSOC = '66666666-6666-4666-8666-666666666666'; // --------------------------------------------------------------------------- // 7.1 — shared harvest resolver (unions new-kind + old) // --------------------------------------------------------------------------- describe('harvestPullerTaskIds (Story 7.1)', () => { it('isNewKindPuller detects a non-empty steps array only', () => { expect(isNewKindPuller({ steps: [{ key: 'A' }] })).toBe(true); expect(isNewKindPuller({ steps: [] })).toBe(false); expect(isNewKindPuller({ pullSteps: [{ key: 'A' }] })).toBe(false); }); it('harvests SAVE_TASK_DATA / UPDATE_TASK_DATA_STATUS static taskIds across phases', () => { const cnf = { pullers: [ { id: 'P', steps: [ { key: 'save', displayType: 'SAVE_TASK_DATA', data: { taskId: G_T1 }, }, { key: 'dyn', displayType: 'SAVE_TASK_DATA', data: { taskId: '{{x}}' }, }, ], finalSteps: [ { key: 'sweep', displayType: 'UPDATE_TASK_DATA_STATUS', data: { taskId: G_T2 }, }, ], }, ], }; const h = harvestPullerTaskIds(cnf); expect([...h.taskIds].sort()).toEqual([G_T1, G_T2].sort()); // dynamic {{x}} skipped expect(h.ownerByTask.get(G_T1)?.has('P')).toBe(true); }); // --- canonical cross-repo contract (core.api PullerTaskHarvest.BuildMap) --- it('reads step type PROVIDER-FIRST (`provider ?? displayType`), exact-case, no `type` (cells C/F6)', () => { // (a) provider-only step is harvested expect([ ...harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [{ provider: 'SAVE_TASK_DATA', data: { taskId: G_T1 } }], }, ], }).taskIds, ]).toEqual([G_T1]); // (b) displayType-only step is still harvested expect([ ...harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [{ displayType: 'SAVE_TASK_DATA', data: { taskId: G_T1 } }], }, ], }).taskIds, ]).toEqual([G_T1]); // (c) provider wins when the two fields disagree expect([ ...harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [ { provider: 'SAVE_TASK_DATA', displayType: 'API', data: { taskId: G_T1 }, }, ], }, ], }).taskIds, ]).toEqual([G_T1]); expect( harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [ { provider: 'API', displayType: 'SAVE_TASK_DATA', data: { taskId: G_T1 }, }, ], }, ], }).taskIds.size, ).toBe(0); // (d) a bare `type` field is NOT read, and matching is case-sensitive expect( harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [{ type: 'SAVE_TASK_DATA', data: { taskId: G_T1 } }], }, ], }).taskIds.size, ).toBe(0); expect( harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [{ provider: 'save_task_data', data: { taskId: G_T1 } }], }, ], }).taskIds.size, ).toBe(0); }); it('gates harvest on a non-empty `steps` array — a SAVE step in finalSteps alone is NOT harvested (cell A)', () => { const h = harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [], finalSteps: [{ provider: 'SAVE_TASK_DATA', data: { taskId: G_T1 } }], }, ], }); expect(h.taskIds.size).toBe(0); }); it('skips a non-GUID taskId (canonical clause D)', () => { const h = harvestPullerTaskIds({ pullers: [ { id: 'P', steps: [ { provider: 'SAVE_TASK_DATA', data: { taskId: 'order-sync' } }, // non-GUID → skipped { provider: 'SAVE_TASK_DATA', data: { taskId: G_T1 } }, ], }, ], }); expect([...h.taskIds]).toEqual([G_T1]); }); it('counts a DIRECT_INDEX write in a new-kind phase as a write target (REF-1 suppressed)', () => { /** * ⛔ Epic 4 close review, 2026-08-12. `collectDataWriteTargets` scanned three of a puller's SIX stages — * `beforePullSteps` / `pullSteps` / `afterPullSteps` — while the pushers loop beside it had gained `finalSteps` * during this epic's stage sweep. So a steps-based puller that fed a task from `steps` / `onErrorSteps` / * `finalSteps` contributed NO write target, and REF-1 reported the fed task as having no puller at all: a * confident FALSE finding in the editor and in the compose report. * * `harvestPullerTaskIds` hid it. That helper DOES walk the new-kind phases, so a `SAVE_TASK_DATA` step suppressed * REF-1 correctly (the test below) — and only a write step of a different KIND in the same phase reached the gap. */ const tf = { pullers: [ { id: 'P', steps: [ { key: 'index', displayType: 'DIRECT_INDEX', data: { dataPath: '$', objectId: G_TNEW }, }, ], }, ], objects: [{ id: G_TNEW }], // A STALE association left by migration is what makes REF-1 reachable at all: the rule fires only when // `objectAssociations[tid].puller.id` names a missing puller. My first version of this test omitted the map // entirely and passed with the fix REVERTED — vacuous, and caught by mutating the fix rather than by running it. objectAssociations: { [G_TNEW]: { puller: { id: 'STALE_MISSING' } } }, }; const ref1 = referenceRule.run(ctx(tf)).filter((i) => i.ruleId === 'REF-1'); expect(ref1).toHaveLength(0); }); it('still reports REF-1 when NOTHING feeds the task, so the fix did not blanket-suppress', () => { // The other half: widening a suppression set is how a rule stops working. A task with no puller and no write // step must still be flagged. const tf = { pullers: [{ id: 'P', steps: [{ key: 'noop', displayType: 'REQUEST' }] }], objects: [{ id: G_TNEW }], objectAssociations: { [G_TNEW]: { puller: { id: 'STALE_MISSING' } } }, }; const ref1 = referenceRule.run(ctx(tf)).filter((i) => i.ruleId === 'REF-1'); expect(ref1.length).toBeGreaterThan(0); }); it('unions new-kind harvested tasks with old-kind objectAssociation puller edge (REF-1 suppressed)', () => { // T_NEW is owned ONLY by a new-kind puller (no objectAssociation.puller). // A stale objectAssociation.puller.id after migration must NOT be REF-1 flagged. const tf = { pullers: [ { id: 'P', steps: [ { key: 'save', displayType: 'SAVE_TASK_DATA', data: { taskId: G_TNEW }, }, ], }, ], objects: [{ id: G_TNEW }], objectAssociations: { [G_TNEW]: { puller: { id: 'STALE_MISSING' } }, }, }; const ref1 = referenceRule.run(ctx(tf)).filter((i) => i.ruleId === 'REF-1'); expect(ref1).toHaveLength(0); // harvested union suppresses the stale edge }); }); // --------------------------------------------------------------------------- // 7.2 — REF-16 // --------------------------------------------------------------------------- describe('REF-16 (Story 7.2)', () => { it('flags a SAVE_TASK_DATA taskId that names no existing task', () => { const tf = { pullers: [ { id: 'P', steps: [ { key: 'save', displayType: 'SAVE_TASK_DATA', data: { taskId: G_GHOST }, }, ], }, ], objects: [{ id: 'REAL' }], }; const ref16 = referenceRule .run(ctx(tf)) .filter((i) => i.ruleId === 'REF-16'); expect(ref16).toHaveLength(1); expect(ref16[0].severity).toBe('high'); expect(ref16[0].message).toContain(G_GHOST); }); it('passes when every harvested taskId resolves (object or objectAssociation key)', () => { const tf = { pullers: [ { id: 'P', steps: [ { key: 'a', displayType: 'SAVE_TASK_DATA', data: { taskId: G_OBJ }, }, { key: 'b', displayType: 'UPDATE_TASK_DATA_STATUS', data: { taskId: G_ASSOC, statusToAdd: ['Synced'] }, }, ], }, ], objects: [{ id: G_OBJ }], objectAssociations: { [G_ASSOC]: { table: { id: 'TBL' } } }, tables: [{ id: 'TBL' }], }; const ref16 = referenceRule .run(ctx(tf)) .filter((i) => i.ruleId === 'REF-16'); expect(ref16).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // 7.3 — FLOW-3/4 walk new-kind phases + onError / Exception.fromStep edges // --------------------------------------------------------------------------- describe('FLOW-3/4 over new-kind phases (Story 7.3)', () => { it('onError recovery + Exception.fromStep make handlers reachable (no false FLOW-3/4)', () => { // combined graph: steps + onErrorSteps + finalSteps const combined = [ { key: 'PULL', rootStep: true, next: 'SAVE', onError: 'RECOVER' }, { key: 'SAVE', displayType: 'SAVE_TASK_DATA', data: { taskId: 'T' }, next: null, }, { key: 'RECOVER', next: null }, // onErrorSteps head (reached via onError) { key: 'EXC', displayType: 'EXCEPTION', fromStep: 'PULL' }, // finalSteps ]; const ids = analyzeWorkflow(combined, { newKind: true, extraEntryKeys: ['RECOVER', 'EXC'], }).map((f) => f.ruleId); expect(ids).not.toContain('FLOW-3'); expect(ids).not.toContain('FLOW-4'); }); it('flowRule integrates the new-kind puller (combined) graph', () => { const tf = { pullers: [ { id: 'P', maxIterations: 50, steps: [{ key: 'A', rootStep: true, next: 'MISSING' }], }, ], }; const f4 = flowRule.run(ctx(tf)).filter((i) => i.ruleId === 'FLOW-4'); expect(f4).toHaveLength(1); expect(f4[0].message).toContain('MISSING'); }); }); // --------------------------------------------------------------------------- // 7.4 — FLOW-5 refined (guarded-escapable-bounded loop is VALID) // --------------------------------------------------------------------------- describe('FLOW-5 refined (Story 7.4)', () => { const pagination = [ { key: 'PULL', rootStep: true, next: 'SAVE' }, { key: 'SAVE', displayType: 'SAVE_TASK_DATA', data: { taskId: 'T' }, next: 'CHECK', }, { key: 'CHECK', displayType: 'IF', data: { if: 'hasNext', then: 'PULL' } }, // else absent → exit ]; it('accepts a guarded, escapable, bounded pagination loop (no FLOW-5)', () => { const ids = analyzeWorkflow(pagination, { newKind: true, hasMaxIterations: true, }).map((f) => f.ruleId); expect(ids).not.toContain('FLOW-5'); }); it('flags the same loop when maxIterations is not declared', () => { const ids = analyzeWorkflow(pagination, { newKind: true, hasMaxIterations: false, }).map((f) => f.ruleId); expect(ids).toContain('FLOW-5'); }); it('flags an unguarded (unconditional) back-edge', () => { const unbounded = [ { key: 'A', rootStep: true, next: 'B' }, { key: 'B', next: 'A' }, // plain next cycle, no conditional guard ]; const found = analyzeWorkflow(unbounded, { newKind: true, hasMaxIterations: true, }).find((f) => f.ruleId === 'FLOW-5'); expect(found).toBeTruthy(); expect(found!.message).toContain('not guarded'); }); /** * Old-kind still reports EVERY back-edge — but at `low`, and without claiming the loop is broken (2026-08-11). * * The contrast with the case above is the point of the pair: new-kind fires only when its guard/exit/bound analysis * finds something missing, so it keeps `high`; old-kind has no such analysis and fires on correct documents too. The * assertion that used to read *"byte-identical"* is what pinned the old wording in place. */ it('old-kind reports ANY back-edge, at LOW and without claiming a defect', () => { const found = analyzeWorkflow([ { key: 'A', rootStep: true, next: 'B' }, { key: 'B', next: 'A' }, ]).find((f) => f.ruleId === 'FLOW-5'); expect(found?.severity).toBe('low'); expect(found?.message).toContain('loops back through step "A"'); expect(found?.message).not.toContain('infinite loop'); }); /** * And the SHIPPED path carries it — `flowRule.run`, not `analyzeWorkflow`. * * `emit` stamped one family-wide `why` on every finding, so a per-finding one is only real if the rule passes it * through. Asserted here because the report and the editor read the issue, never the finding: the wording could be * corrected in `analyzeWorkflow` and still reach Jazz as *"does not execute as authored"*. */ it('the emitted ISSUE keeps the low finding’s own `why`, not the family line', () => { const looping = { pullers: [ { id: 'P', pullSteps: [ { key: 'A', rootStep: true, next: 'B' }, { key: 'B', next: 'A' }, ], }, ], }; const issue = flowRule.run(ctx(looping)).find((i) => i.ruleId === 'FLOW-5'); expect(issue?.severity).toBe('low'); expect(issue?.why).toBe( 'a loop with no exit would never terminate — this one may well be fine', ); }); }); // --------------------------------------------------------------------------- // 7.5 — FLOW-7 (sweep-in-finalSteps + bad Exception.fromStep) // --------------------------------------------------------------------------- describe('FLOW-7 (Story 7.5)', () => { it('flags an UPDATE_TASK_DATA_STATUS removal sweep placed in finalSteps', () => { const tf = { pullers: [ { id: 'P', maxIterations: 10, steps: [{ key: 'PULL', rootStep: true, next: null }], finalSteps: [ { key: 'SWEEP', displayType: 'UPDATE_TASK_DATA_STATUS', statusToAdd: ['Removed'], data: { taskId: 'T' }, }, ], }, ], }; const f7 = flowRule.run(ctx(tf)).filter((i) => i.ruleId === 'FLOW-7'); expect(f7.some((i) => i.message.includes('finalSteps'))).toBe(true); }); it('flags an Exception step whose fromStep names no existing step', () => { const combined = [ { key: 'PULL', rootStep: true, next: null }, { key: 'EXC', displayType: 'EXCEPTION', fromStep: 'NOPE' }, ]; const f7 = analyzeWorkflow(combined, { newKind: true, extraEntryKeys: ['EXC'], }).filter((f) => f.ruleId === 'FLOW-7'); expect(f7).toHaveLength(1); expect(f7[0].message).toContain('NOPE'); }); }); // --------------------------------------------------------------------------- // Epic 7 review — advisory-precision fixes (F1–F4) // --------------------------------------------------------------------------- describe('FLOW advisory-precision fixes (Epic 7 review F1–F4)', () => { // F1 — a guard-AT-TOP loop (guard IF → body → back to guard) has a non-IF back-edge // owner (TRANSFORM), but the IF on the cycle gates + exits it, so it is NOT flagged. it('F1: does not flag a valid guard-at-top pagination loop', () => { const guardAtTop = [ { key: 'CHECK', rootStep: true, displayType: 'IF', data: { if: 'hasNext', then: 'PULL' }, }, // else absent → exit { key: 'PULL', next: 'TRANSFORM' }, { key: 'TRANSFORM', next: 'CHECK' }, // back-edge owner = TRANSFORM, not the guard ]; const ids = analyzeWorkflow(guardAtTop, { newKind: true, hasMaxIterations: true, }).map((f) => f.ruleId); expect(ids).not.toContain('FLOW-5'); }); // F2 — a guard authored as `{type:'IF'}` (not displayType) must be recognized: its // then-edge is built (so the back-edge/cycle is visible) AND it counts as a guard. it('F2: recognizes a `{type:"IF"}` guard (edge built + treated as guarded)', () => { const typeGuard = [ { key: 'PULL', rootStep: true, next: 'CHECK' }, { key: 'CHECK', type: 'IF', data: { if: 'hasNext', then: 'PULL' } }, ]; // Unbounded: the cycle is only visible if the `type:'IF'` then-edge is built, and the // reason must be the missing bound (guard recognized), never "not guarded". const f5 = analyzeWorkflow(typeGuard, { newKind: true, hasMaxIterations: false, }).find((f) => f.ruleId === 'FLOW-5'); expect(f5).toBeTruthy(); expect(f5!.message).toContain('no maxIterations'); expect(f5!.message).not.toContain('not guarded'); // Bounded: recognized guard → no FLOW-5. const bounded = analyzeWorkflow(typeGuard, { newKind: true, hasMaxIterations: true, }).map((f) => f.ruleId); expect(bounded).not.toContain('FLOW-5'); }); // F3 — a maxIterations on an unrelated step in another phase must NOT bound a cycle. it('F3: an unrelated-phase maxIterations does not satisfy a bounded-cycle check', () => { const tf = { pullers: [ { id: 'P', steps: [ { key: 'PULL', rootStep: true, next: 'CHECK' }, { key: 'CHECK', displayType: 'IF', data: { if: 'hasNext', then: 'PULL' }, }, // guarded+escapable, but unbounded ], finalSteps: [{ key: 'CLEAN', maxIterations: 5 }], // unrelated bound }, ], }; const f5 = flowRule.run(ctx(tf)).filter((i) => i.ruleId === 'FLOW-5'); expect(f5).toHaveLength(1); expect(f5[0].message).toContain('no maxIterations'); }); it('F3: a puller-level OR on-cycle maxIterations DOES satisfy the bound', () => { const pullerLevel = { pullers: [ { id: 'P', maxIterations: 5, // puller-level bound steps: [ { key: 'PULL', rootStep: true, next: 'CHECK' }, { key: 'CHECK', displayType: 'IF', data: { if: 'hasNext', then: 'PULL' }, }, ], }, ], }; expect( flowRule.run(ctx(pullerLevel)).filter((i) => i.ruleId === 'FLOW-5'), ).toHaveLength(0); const onCycle = { pullers: [ { id: 'P', steps: [ { key: 'PULL', rootStep: true, next: 'CHECK' }, { key: 'CHECK', displayType: 'IF', maxIterations: 5, // bound on a step ON the cycle data: { if: 'hasNext', then: 'PULL' }, }, ], }, ], }; expect( flowRule.run(ctx(onCycle)).filter((i) => i.ruleId === 'FLOW-5'), ).toHaveLength(0); }); // F4 — a multi-headed onErrorSteps/finalSteps must not yield spurious FLOW-3 orphans. it('F4: multi-headed onErrorSteps/finalSteps produce no spurious FLOW-3 orphan', () => { const tf = { pullers: [ { id: 'P', maxIterations: 10, steps: [{ key: 'PULL', rootStep: true, next: null }], onErrorSteps: [{ key: 'E1' }, { key: 'E2' }], // two independent recovery steps finalSteps: [{ key: 'CLEAN1' }, { key: 'CLEAN2' }], // two independent cleanups }, ], }; const f3 = flowRule.run(ctx(tf)).filter((i) => i.ruleId === 'FLOW-3'); expect(f3).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // 7.6 — REF-13 + DUP-3 walk new-kind phases // --------------------------------------------------------------------------- describe('REF-13 / DUP-3 over new-kind phases (Story 7.6)', () => { it('REF-13 flags a missing object reference in a new-kind step', () => { const tf = { pullers: [ { id: 'P', steps: [ { key: 'DI', data: { objectId: 'MISSING_OBJ', dataPath: '$' } }, ], }, ], objects: [{ id: 'REAL' }], }; const ref13 = referenceRule .run(ctx(tf)) .filter((i) => i.ruleId === 'REF-13'); expect(ref13.some((i) => i.message.includes('MISSING_OBJ'))).toBe(true); }); it('DUP-3 flags duplicate step keys within new-kind onErrorSteps', () => { const tf = { pullers: [ { id: 'P', steps: [{ key: 'A' }], onErrorSteps: [{ key: 'E' }, { key: 'E' }], }, ], }; const dup3 = duplicatesRule .run(ctx(tf)) .filter((i) => i.ruleId === 'DUP-3'); expect(dup3).toHaveLength(1); expect(dup3[0].severity).toBe('critical'); }); }); // --------------------------------------------------------------------------- // 7.7 — Mermaid renders a new-kind puller (not blank) // --------------------------------------------------------------------------- describe('buildMermaid for a new-kind puller (Story 7.7)', () => { it('renders step nodes + IF/onError/Exception edges (not empty)', () => { const flow: ComponentFlow = { kind: 'puller', newKind: true, phases: [ { name: 'steps', steps: [ { key: 'PULL', label: 'Pull', next: 'CHECK', rootStep: true, onError: 'RECOVER', }, { key: 'CHECK', label: 'Has next?', rootStep: false, stepType: 'IF', data: { if: 'hasNext', then: 'PULL' }, }, ], }, { name: 'onErrorSteps', steps: [ { key: 'RECOVER', label: 'Recover', rootStep: false, next: null }, ], }, { name: 'finalSteps', steps: [ { key: 'EXC', label: 'On exception', rootStep: false, stepType: 'EXCEPTION', fromStep: 'PULL', }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); expect(mmd.startsWith('flowchart LR')).toBe(true); expect(mmd).toContain( 'pullers__id_FIXTURE__steps__key_PULL["Pull · PULL"]', ); /** * A subgraph is addressed like everything else — `pullers:id_FIXTURE:onErrorSteps` — and titled by the stage's * registered PROSE label, where the retired emitter used `sg_` and the raw key. `finalSteps` being drawn at * all is the point of Story 4.5's H2: it was missing from this repo's pusher stage lists entirely. */ expect(mmd).toContain( 'subgraph pullers__id_FIXTURE__onErrorSteps["On error"]', ); expect(mmd).toContain('subgraph pullers__id_FIXTURE__finalSteps["Final"]'); expect(mmd).toContain('|"YES"|'); // IF then branch, via the step's `data:if` diamond expect(mmd).toContain('|"onError"|'); // onError edge expect(mmd).toContain('|"always"|'); // the `final` stage runs whichever way the sequence went expect(mmd).not.toContain('empty["No steps"]'); /** * ⚠️ **CROSS-STAGE ROUTES DO NOT RESOLVE — deferred D.19, and this fixture is the evidence.** * * `onError: RECOVER` names a step in `onErrorSteps`, and `EXC`'s `fromStep: PULL` names one in `steps`. The shared * model resolves a route only against its OWN stage (`routes.ts` / `routeGraph.ts` both keep a per-stage `known` * set), so: * * * the `onError` route ends at an unresolved terminus labelled `RECOVER` — asserted below, because a phantom node * beside the real one is the visible symptom and must not be able to appear or vanish unnoticed; * * the `Exception` back-edge is **not drawn at all**, which is why this case no longer asserts `|"Exception"|`. * The retired emitter drew it (its own note: *"`flowChart.ts:199-201` emits `fromStep --|"Exception"|-> this`"*), * so this is a real edge the report LOST in the swap, recorded rather than quietly dropped. * * `routes.ts` justifies the scoping as *"an exception handler pointing outside its own stage is a document error"*, * and the runtime says otherwise: `StepIteratorTests.cs:720` describes exactly this shape — *"step-a throws → * onError routes to raise-a (Error step, fromStep=\"step-a\")"* — and `ErrorExecutor` looks the captured error up by * step key across the run, not within a stage. A recovery stage naming the step it recovers from is the CANONICAL * shape, not an error. * * Not fixed here: route scope is the shared model's, and widening it re-baselines the golden flows and the * extension's diagrams alongside the report — its own story, not a side-effect of this one. Nothing shipped is * mis-drawn meanwhile: 0 corpus files use `onError:` or `fromStep`, measured 2026-08-11. * * `RECOVER` is not stranded either way — the stage rule draws `sequence → error` into it, asserted above. */ expect(mmd).toContain( 'pullers__id_FIXTURE__steps__key_PULL__onError__unknown(["RECOVER"])', ); expect(mmd).not.toContain('|"Exception"|'); }); it('old-kind puller flow is unchanged (no new-kind edges)', () => { const flow: ComponentFlow = { kind: 'puller', phases: [ { name: 'pullSteps', steps: [ { key: 'A', label: 'A', next: 'B', rootStep: true }, { key: 'B', label: 'B', next: null, rootStep: false }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); expect(mmd).toContain( 'pullers__id_FIXTURE__pullSteps__key_A --> pullers__id_FIXTURE__pullSteps__key_B', ); expect(mmd).not.toContain('|"onError"|'); }); }); // --------------------------------------------------------------------------- // Report stays advisory (exit 0) end-to-end // --------------------------------------------------------------------------- describe('new-kind puller — report is advisory and renders a chart', () => { it('composes, writes the report, and the merge diagram charts the new-kind puller', async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'newkind-')); write(path.join(tmp, 'proj/partials/main.yaml'), `externals: []\n`); write( path.join(tmp, 'proj/partials/P.yaml'), [ 'objects:', ' - id: T', 'pullers:', ' - id: P', ' maxIterations: 100', ' steps:', ' - key: PULL', ' rootStep: true', ' next: SAVE', ' - key: SAVE', ' displayType: SAVE_TASK_DATA', ' next: CHECK', ' data:', ' taskId: T', ' - key: CHECK', ' displayType: IF', ' data:', ' if: hasNext', ' then: PULL', '', ].join('\n'), ); // composeProfile never throws / never sets a non-zero exit — advisory report only. await expect( composeProfile(path.join(tmp, 'proj'), false), ).resolves.not.toThrow(); expect(fs.existsSync(path.join(tmp, 'proj/output.yaml'))).toBe(true); expect( fs.existsSync(path.join(tmp, 'proj/output.validation.report.md')), ).toBe(true); const md = fs.readFileSync( path.join(tmp, 'proj/output.validation.report.md'), 'utf8', ); // guarded-escapable-bounded loop → no FLOW-5, no REF-16 (T exists) expect(md).not.toContain('REF-16'); }); });