'use strict'; /** * loop-host-contract.test.cjs — behavioral tests for gen-loop-host-contract.cjs. * * ADR-894 phase 3a-impl-2. * Uses node:test + node:assert/strict. * NO source-grep: tests use in-memory fixtures and real workflow files. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const fc = require('fast-check'); const { cleanup } = require('./helpers.cjs'); const { parseLoopHostBlock, crossCheckRoles, crossCheckRoleFamilies, assertPointsCoverage, buildContract, serializeContract, normalizeLineEndings, STEP_WORKFLOWS, CANONICAL_POINTS, EXPECTED_POINTS_BY_STEP, ROLE_TO_AGENT, ROLE_FAMILY, } = require('../scripts/gen-loop-host-contract.cjs'); const { LOOP_HOST_CONTRACT } = require('../msd-core/bin/lib/loop-host-contract.cjs'); const ROOT = path.resolve(__dirname, '..'); const CONTRACT_PATH = path.join(ROOT, 'msd-core', 'bin', 'lib', 'loop-host-contract.cjs'); // ─── Helper: write a temporary workflows directory ──────────────────────────── function makeTempWorkflowsDir(files) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lhc-test-')); for (const [name, content] of Object.entries(files)) { fs.writeFileSync(path.join(tmpDir, name), content, 'utf8'); } return tmpDir; } // ─── Minimal valid workflow content templates ───────────────────────────────── function makeWorkflow(step, points, roles, produces, consumes, extraContent) { const pointsList = points.join(', '); const rolesList = roles.join(', '); const producesList = produces.join(', '); const consumesList = consumes.join(', '); return ( '\n' + (extraContent || '') ); } // Minimal valid set of 5 workflows matching the canonical contract function makeValidWorkflowFiles() { return { 'discuss-phase.md': makeWorkflow('discuss', ['discuss:pre', 'discuss:post'], ['orchestrator'], ['CONTEXT.md'], []), 'plan-phase.md': makeWorkflow( 'plan', ['plan:pre', 'plan:post'], ['researcher', 'planner', 'checker'], ['PLAN.md'], ['CONTEXT.md'], // Cross-check content: must contain the agent names 'msd-phase-researcher msd-planner msd-plan-checker', ), 'quick.md': [ 'PLAN_PRE_HOOKS_JSON=$(msd_run loop render-hooks plan:pre --raw)', 'For each active entry where `kind == "contribution"` and `into == "planner"`: inject.', '', ].join('\n'), 'execute-phase.md': makeWorkflow( 'execute', ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], ['executor', 'verifier'], ['SUMMARY.md'], ['PLAN.md'], 'msd-executor msd-verifier', ), 'verify-work.md': makeWorkflow('verify', ['verify:pre', 'verify:post'], ['orchestrator'], ['UAT.md'], ['SUMMARY.md']), 'ship.md': makeWorkflow('ship', ['ship:pre', 'ship:post'], ['orchestrator'], [], ['UAT.md']), }; } // ─── 1. parseLoopHostBlock ──────────────────────────────────────────────────── describe('parseLoopHostBlock', () => { test('parses a valid block correctly', () => { const content = makeWorkflow( 'plan', ['plan:pre', 'plan:post'], ['researcher', 'planner', 'checker'], ['PLAN.md'], ['CONTEXT.md'], ); const result = parseLoopHostBlock(content, 'plan-phase.md'); assert.strictEqual(result.step, 'plan'); assert.deepEqual(result.points, ['plan:pre', 'plan:post']); assert.deepEqual(result.agentRoles, ['researcher', 'planner', 'checker']); assert.deepEqual(result.coreArtifacts.produces, ['PLAN.md']); assert.deepEqual(result.coreArtifacts.consumes, ['CONTEXT.md']); }); test('parses empty produces field as empty array', () => { const content = makeWorkflow( 'ship', ['ship:pre', 'ship:post'], ['orchestrator'], [], ['UAT.md'], ); const result = parseLoopHostBlock(content, 'ship.md'); assert.deepEqual(result.coreArtifacts.produces, []); assert.deepEqual(result.coreArtifacts.consumes, ['UAT.md']); }); test('parses empty consumes field as empty array', () => { const content = makeWorkflow( 'discuss', ['discuss:pre', 'discuss:post'], ['orchestrator'], ['CONTEXT.md'], [], ); const result = parseLoopHostBlock(content, 'discuss-phase.md'); assert.deepEqual(result.coreArtifacts.consumes, []); assert.deepEqual(result.coreArtifacts.produces, ['CONTEXT.md']); }); test('throws when block is missing', () => { assert.throws( () => parseLoopHostBlock('no block here\nhello', 'test.md'), /missing.*msd:loop-host/, ); }); test('throws when step field is missing from block', () => { const content = '\n'; assert.throws( () => parseLoopHostBlock(content, 'test.md'), /missing required field "step"/, ); }); test('throws when points field is empty', () => { const content = '\n'; assert.throws( () => parseLoopHostBlock(content, 'test.md'), /"points" must have at least one value/, ); }); test('parses multi-value fields with spaces correctly', () => { const content = makeWorkflow( 'execute', ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], ['executor', 'verifier'], ['SUMMARY.md'], ['PLAN.md'], ); const result = parseLoopHostBlock(content, 'execute-phase.md'); assert.deepEqual(result.points, ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']); assert.deepEqual(result.agentRoles, ['executor', 'verifier']); }); }); // ─── 2. crossCheckRoles ─────────────────────────────────────────────────────── describe('crossCheckRoles', () => { test('passes for orchestrator-only roles (no agent file needed)', () => { const errors = crossCheckRoles('anything', ['orchestrator'], 'discuss-phase.md'); assert.deepEqual(errors, []); }); test('passes when agent name is present in content', () => { const content = 'Agent(subagent_type="msd-planner") Agent(subagent_type="msd-phase-researcher") msd-plan-checker'; const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); assert.deepEqual(errors, []); }); test('fails when declared role has no agent reference in content', () => { const content = 'msd-phase-researcher msd-plan-checker'; // planner missing const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error for missing planner'); assert.match(errors[0], /planner.*msd-planner/); }); test('fails when declared role is unknown (not in ROLE_TO_AGENT)', () => { const content = 'msd-executor msd-verifier'; const errors = crossCheckRoles(content, ['executor', 'nonexistent-role'], 'execute-phase.md'); assert.ok(errors.some((e) => e.includes('nonexistent-role') && e.includes('ROLE_TO_AGENT'))); }); }); // ─── 2b. crossCheckRoleFamilies (#4740) ────────────────────────────────────── describe('crossCheckRoleFamilies (#4740)', () => { test('accepts the execution family at execute', () => { const errors = crossCheckRoleFamilies('execute', ['executor', 'verifier'], 'execute-phase.md'); assert.deepEqual(errors, []); }); test('accepts orchestrator at discuss', () => { const errors = crossCheckRoleFamilies('discuss', ['orchestrator'], 'discuss-phase.md'); assert.deepEqual(errors, []); }); test('accepts the planning family at plan', () => { const errors = crossCheckRoleFamilies('plan', ['researcher', 'planner', 'checker'], 'plan-phase.md'); assert.deepEqual(errors, []); }); test('accepts orchestrator at verify and ship', () => { assert.deepEqual(crossCheckRoleFamilies('verify', ['orchestrator'], 'verify-work.md'), []); assert.deepEqual(crossCheckRoleFamilies('ship', ['orchestrator'], 'ship.md'), []); }); test('a strict subset of the family is legal', () => { const errors = crossCheckRoleFamilies('execute', ['executor'], 'execute-phase.md'); assert.deepEqual(errors, []); }); test('rejects orchestrator added to execute', () => { const errors = crossCheckRoleFamilies('execute', ['executor', 'verifier', 'orchestrator'], 'execute-phase.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error'); assert.match(errors[0], /orchestrator/); assert.match(errors[0], /execute/); }); test('rejects an execution role at an orchestration step', () => { const errors = crossCheckRoleFamilies('discuss', ['orchestrator', 'executor'], 'discuss-phase.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error'); assert.match(errors[0], /executor/); }); test('rejects an execution role at plan', () => { const errors = crossCheckRoleFamilies('plan', ['planner', 'executor'], 'plan-phase.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error'); assert.match(errors[0], /executor/); }); test('rejects a planning role at execute', () => { const errors = crossCheckRoleFamilies('execute', ['executor', 'planner'], 'execute-phase.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error'); assert.match(errors[0], /planner/); }); test('rejects a role with no family', () => { const errors = crossCheckRoleFamilies('execute', ['executer'], 'execute-phase.md'); assert.ok(errors.length >= 1, 'expected at least 1 error'); assert.ok(errors.some((e) => e.includes('executer'))); }); test('fails closed on an unknown step', () => { const errors = crossCheckRoleFamilies('audit', ['orchestrator'], 'audit.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error for unknown step'); assert.match(errors[0], /audit/); }); test('rejects a role whose capitalization does not match', () => { const errors = crossCheckRoleFamilies('execute', ['Orchestrator'], 'execute-phase.md'); assert.ok(errors.length >= 1, 'capitalized role must not silently match'); assert.ok(errors.some((e) => e.includes('Orchestrator'))); }); test('a duplicated in-family role is not an error', () => { const errors = crossCheckRoleFamilies('execute', ['executor', 'executor'], 'execute-phase.md'); assert.deepEqual(errors, []); }); test('reports every offending role, not just the first', () => { const errors = crossCheckRoleFamilies('execute', ['executor', 'orchestrator', 'planner'], 'execute-phase.md'); assert.strictEqual(errors.length, 2, 'expected one error per offending role'); const combined = errors.join('\n'); assert.ok(combined.includes('orchestrator')); assert.ok(combined.includes('planner')); }); test('is pure and does not mutate its arguments', () => { // Deliberately NOT alphabetically sorted — an in-place agentRoles.sort() // must be caught by THIS test, not merely coincide with already-sorted // input (#4740 review finding). const agentRoles = ['verifier', 'executor', 'orchestrator']; const snapshot = agentRoles.slice(); const first = crossCheckRoleFamilies('execute', agentRoles, 'execute-phase.md'); assert.deepEqual(agentRoles, snapshot, 'input array must not be mutated'); const second = crossCheckRoleFamilies('execute', agentRoles, 'execute-phase.md'); assert.deepEqual(first, second, 'repeated calls must produce identical results'); assert.deepEqual(agentRoles, snapshot, 'input array must still not be mutated after second call'); }); test('ROLE_FAMILY and ROLE_TO_AGENT cover the exact same role-name domain (#4740)', () => { // Generative-fix-divergence guard: ROLE_FAMILY and ROLE_TO_AGENT are parallel // constants over the same role-name domain (every non-orchestrator role, plus // orchestrator which is family-only). Exact set equality both directions — // not a subset check — so adding a role to only one map is caught here. const familyKeys = new Set(Object.keys(ROLE_FAMILY)); const expectedKeys = new Set([...Object.keys(ROLE_TO_AGENT), 'orchestrator']); assert.deepEqual( [...familyKeys].sort(), [...expectedKeys].sort(), 'ROLE_FAMILY keys must equal ROLE_TO_AGENT keys plus "orchestrator", exactly', ); }); test('property: in-family subsets pass, any foreign role fails', () => { const ROLE_FAMILY_LOCAL = { orchestrator: 'orchestration', researcher: 'planning', planner: 'planning', checker: 'planning', executor: 'execution', verifier: 'execution', }; const familyRoles = { orchestration: ['orchestrator'], planning: ['researcher', 'planner', 'checker'], execution: ['executor', 'verifier'], }; const stepsByFamily = { discuss: 'orchestration', plan: 'planning', execute: 'execution', verify: 'orchestration', ship: 'orchestration', }; const steps = Object.keys(stepsByFamily); fc.assert( fc.property( fc.constantFrom(...steps), fc.subarray(Object.keys(ROLE_FAMILY_LOCAL), { minLength: 1 }), (step, roles) => { const family = stepsByFamily[step]; const inFamilySubset = familyRoles[family]; const errors = crossCheckRoleFamilies(step, inFamilySubset, 'x.md'); assert.deepEqual(errors, []); const hasForeign = roles.some((r) => ROLE_FAMILY_LOCAL[r] !== family); const errors2 = crossCheckRoleFamilies(step, roles, 'x.md'); if (hasForeign) { assert.ok(errors2.length >= 1); } else { assert.deepEqual(errors2, []); } }, ), { seed: 4740, numRuns: 200 }, ); }); }); // ─── 3. assertPointsCoverage ───────────────────────────────────────────────── describe('assertPointsCoverage', () => { test('passes when all 12 canonical points are covered', () => { const entries = [ { step: 'discuss', points: ['discuss:pre', 'discuss:post'] }, { step: 'plan', points: ['plan:pre', 'plan:post'] }, { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.deepEqual(errors, []); }); test('fails when a canonical point is missing', () => { const entries = [ { step: 'discuss', points: ['discuss:pre'] }, // missing discuss:post { step: 'plan', points: ['plan:pre', 'plan:post'] }, { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.ok(errors.some((e) => e.includes('discuss:post') && e.includes('not declared'))); }); test('fails when an unknown point is declared', () => { const entries = [ { step: 'discuss', points: ['discuss:pre', 'discuss:post', 'discuss:extra'] }, { step: 'plan', points: ['plan:pre', 'plan:post'] }, { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.ok(errors.some((e) => e.includes('discuss:extra') && e.includes('not in the canonical'))); }); test('fails when a point is declared twice', () => { const entries = [ { step: 'discuss', points: ['discuss:pre', 'discuss:post'] }, { step: 'plan', points: ['plan:pre', 'plan:post', 'discuss:pre'] }, // duplicate { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.ok(errors.some((e) => e.includes('discuss:pre') && e.includes('more than once'))); }); }); // ─── 4. buildContract — from real workflows ─────────────────────────────────── describe('buildContract from real workflows', () => { test('produces a contract matching the inline LOOP_HOST_CONTRACT shape', () => { const contract = buildContract(); // reads real msd-core/workflows/ // Must be an array of 5 entries assert.strictEqual(contract.length, 5, 'contract must have 5 step entries'); // Each entry must have step, points, agentRoles, coreArtifacts for (const entry of contract) { assert.ok(typeof entry.step === 'string', 'entry.step must be a string'); assert.ok(Array.isArray(entry.points), 'entry.points must be an array'); assert.ok(Array.isArray(entry.agentRoles), 'entry.agentRoles must be an array'); assert.ok(typeof entry.coreArtifacts === 'object', 'entry.coreArtifacts must be an object'); assert.ok(Array.isArray(entry.coreArtifacts.produces), 'entry.coreArtifacts.produces must be an array'); assert.ok(Array.isArray(entry.coreArtifacts.consumes), 'entry.coreArtifacts.consumes must be an array'); } // Verify exact match with the committed loop-host-contract.cjs assert.deepEqual(contract, LOOP_HOST_CONTRACT, 'built contract must match committed LOOP_HOST_CONTRACT'); }); test('covers exactly the 12 canonical points', () => { const contract = buildContract(); const allPoints = contract.flatMap((e) => e.points); assert.strictEqual(allPoints.length, 12, 'must cover exactly 12 points'); const pointSet = new Set(allPoints); assert.strictEqual(pointSet.size, 12, 'all 12 points must be distinct'); for (const p of CANONICAL_POINTS) { assert.ok(pointSet.has(p), 'canonical point "' + p + '" must be declared'); } }); test('discuss step has orchestrator role and produces CONTEXT.md', () => { const contract = buildContract(); const discuss = contract.find((e) => e.step === 'discuss'); assert.ok(discuss, 'discuss step must be present'); assert.deepEqual(discuss.agentRoles, ['orchestrator']); assert.deepEqual(discuss.coreArtifacts.produces, ['CONTEXT.md']); assert.deepEqual(discuss.coreArtifacts.consumes, []); }); test('plan step has researcher/planner/checker roles and produces PLAN.md', () => { const contract = buildContract(); const plan = contract.find((e) => e.step === 'plan'); assert.ok(plan, 'plan step must be present'); assert.deepEqual(plan.agentRoles, ['researcher', 'planner', 'checker']); assert.deepEqual(plan.coreArtifacts.produces, ['PLAN.md']); assert.deepEqual(plan.coreArtifacts.consumes, ['CONTEXT.md']); }); test('execute step has executor/verifier roles and 4 points', () => { const contract = buildContract(); const execute = contract.find((e) => e.step === 'execute'); assert.ok(execute, 'execute step must be present'); assert.deepEqual(execute.agentRoles, ['executor', 'verifier']); assert.deepEqual(execute.points, ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post']); assert.deepEqual(execute.coreArtifacts.produces, ['SUMMARY.md']); assert.deepEqual(execute.coreArtifacts.consumes, ['PLAN.md']); }); test('verify step has orchestrator role and produces UAT.md', () => { const contract = buildContract(); const verify = contract.find((e) => e.step === 'verify'); assert.ok(verify, 'verify step must be present'); assert.deepEqual(verify.agentRoles, ['orchestrator']); assert.deepEqual(verify.coreArtifacts.produces, ['UAT.md']); assert.deepEqual(verify.coreArtifacts.consumes, ['SUMMARY.md']); }); test('ship step has orchestrator role and empty produces', () => { const contract = buildContract(); const ship = contract.find((e) => e.step === 'ship'); assert.ok(ship, 'ship step must be present'); assert.deepEqual(ship.agentRoles, ['orchestrator']); assert.deepEqual(ship.coreArtifacts.produces, []); assert.deepEqual(ship.coreArtifacts.consumes, ['UAT.md']); }); test('the shipped workflows declare no cross-family role (#4740)', () => { // Pins something real about THIS change, distinct from the per-step field // assertions above: running crossCheckRoleFamilies directly against every // entry of the REAL contract returns zero errors for every step, not merely // that buildContract() didn't throw (which every other test in this describe // block already exercises incidentally). const contract = buildContract(); for (const entry of contract) { const errors = crossCheckRoleFamilies(entry.step, entry.agentRoles, entry.step + '.md'); assert.deepEqual(errors, [], 'step "' + entry.step + '" must declare no cross-family role'); } }); }); // ─── 5. cross-check rejects nonexistent agent-role ─────────────────────────── describe('buildContract cross-check drift guard', () => { test('rejects a block declaring a nonexistent agent-role', () => { // Build a temporary workflows dir where plan-phase.md declares a role // that has no corresponding agent reference in the file content. const files = makeValidWorkflowFiles(); // Override plan-phase.md to declare a "phantom" role with no agent reference files['plan-phase.md'] = '\n' + // Include real agents but NOT the phantom role's agent (phantom is not in ROLE_TO_AGENT) 'msd-phase-researcher msd-planner msd-plan-checker\n'; const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /ROLE_TO_AGENT|no entry/, ); } finally { cleanup(tmpDir); } }); test('rejects a block declaring a role whose agent is absent from the workflow', () => { // plan-phase.md declares 'researcher' but does NOT mention msd-phase-researcher const files = makeValidWorkflowFiles(); files['plan-phase.md'] = '\n' + // Only planner and checker present, researcher's agent is absent 'msd-planner msd-plan-checker\n'; const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /msd-phase-researcher.*not referenced|researcher.*msd-phase-researcher/, ); } finally { cleanup(tmpDir); } }); }); // ─── 6. --check: CRLF-agnostic + committed-file staleness guard ────────────── describe('normalizeLineEndings', () => { test('normalizeLineEndings strips CR characters', () => { const crlf = 'line1\r\nline2\r\nline3'; const lf = 'line1\nline2\nline3'; assert.strictEqual(normalizeLineEndings(crlf), lf); assert.strictEqual(normalizeLineEndings(lf), lf); }); // The committed-loop-host-contract.cjs freshness guard moved to // `npm run lint:generated-sync` (gen-loop-host-contract.cjs --check), where it // runs against the committed file in both local and CI lint lanes — instead of // being masked by msd-test's `npm run build` leg regenerating the artifact. }); // ─── 7. STEP_WORKFLOWS and CANONICAL_POINTS exported constants ──────────────── describe('module exports', () => { test('STEP_WORKFLOWS has 5 entries in pipeline order', () => { assert.strictEqual(STEP_WORKFLOWS.length, 5); assert.strictEqual(STEP_WORKFLOWS[0].step, 'discuss'); assert.strictEqual(STEP_WORKFLOWS[1].step, 'plan'); assert.strictEqual(STEP_WORKFLOWS[2].step, 'execute'); assert.strictEqual(STEP_WORKFLOWS[3].step, 'verify'); assert.strictEqual(STEP_WORKFLOWS[4].step, 'ship'); }); test('CANONICAL_POINTS has exactly 12 entries', () => { assert.strictEqual(CANONICAL_POINTS.length, 12); }); test('ROLE_TO_AGENT covers all non-orchestrator roles', () => { // All non-orchestrator roles from the real contract const allRoles = new Set( LOOP_HOST_CONTRACT.flatMap((e) => e.agentRoles).filter((r) => r !== 'orchestrator'), ); for (const role of allRoles) { assert.ok( ROLE_TO_AGENT[role] !== undefined, 'ROLE_TO_AGENT must cover non-orchestrator role "' + role + '"', ); } }); test('EXPECTED_POINTS_BY_STEP covers all 5 steps', () => { assert.ok(EXPECTED_POINTS_BY_STEP, 'EXPECTED_POINTS_BY_STEP must be exported'); assert.strictEqual(Object.keys(EXPECTED_POINTS_BY_STEP).length, 5); assert.ok(Array.isArray(EXPECTED_POINTS_BY_STEP.execute)); assert.strictEqual(EXPECTED_POINTS_BY_STEP.execute.length, 4); }); }); // ─── #3778 — quick.md is auxiliary without becoming a 6th serialized step ──── describe('Quick auxiliary loop-host contract (#3778)', () => { function makeQuickWorkflow({ includePoint = true, includeKind = true, into = 'planner' } = {}) { const target = into === null ? '' : ` and \`into == "${into}"\``; return [ includePoint ? 'PLAN_PRE_HOOKS_JSON=$(msd_run loop render-hooks plan:pre --raw)' : '', includeKind ? `For each active entry where \`kind == "contribution"\`${target}: inject.` : '', '', ].join('\n'); } test('Quick is a generator-owned plan auxiliary host with its own contribution expectation', () => { const plan = STEP_WORKFLOWS.find((workflow) => workflow.step === 'plan'); assert.deepEqual(plan.auxiliaryHosts, [ { file: 'quick.md', point: 'plan:pre', kinds: ['contribution'], into: 'planner' }, ]); const files = makeValidWorkflowFiles(); files['quick.md'] = makeQuickWorkflow(); const tmpDir = makeTempWorkflowsDir(files); try { const contract = buildContract(tmpDir); assert.strictEqual(contract.length, 5, 'auxiliary hosts must not create a sixth serialized step'); assert.strictEqual( new Set(contract.flatMap((entry) => entry.points)).size, 12, 'auxiliary hosts must not create a duplicate lifecycle point', ); } finally { cleanup(tmpDir); } }); test('buildContract rejects Quick without its plan:pre render seam', () => { const files = makeValidWorkflowFiles(); files['quick.md'] = makeQuickWorkflow({ includePoint: false }); const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /quick\.md: auxiliary host missing expected point "plan:pre"/, ); } finally { cleanup(tmpDir); } }); test('buildContract rejects Quick without its contribution dispatch', () => { const files = makeValidWorkflowFiles(); files['quick.md'] = makeQuickWorkflow({ includeKind: false }); const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /quick\.md: auxiliary host point "plan:pre" missing expected kind "contribution"/, ); } finally { cleanup(tmpDir); } }); test('buildContract rejects a Quick contribution dispatch targeting the wrong role', () => { const files = makeValidWorkflowFiles(); files['quick.md'] = makeQuickWorkflow({ into: 'orchestrator' }); const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /quick\.md: auxiliary host point "plan:pre" missing expected kind "contribution"/, ); } finally { cleanup(tmpDir); } }); test('buildContract rejects a Quick contribution dispatch without its planner target', () => { const files = makeValidWorkflowFiles(); files['quick.md'] = makeQuickWorkflow({ into: null }); const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /quick\.md: auxiliary host point "plan:pre" missing expected kind "contribution"/, ); } finally { cleanup(tmpDir); } }); }); // ─── 8. Regression: FIX 1 — per-step point ownership ──────────────────────── describe('assertPointsCoverage per-step ownership (FIX 1)', () => { test('fails when two steps swap a point (discuss declares plan:pre, plan declares discuss:pre)', () => { const entries = [ { step: 'discuss', points: ['discuss:post', 'plan:pre'] }, // wrong: has plan:pre instead of discuss:pre { step: 'plan', points: ['discuss:pre', 'plan:post'] }, // wrong: has discuss:pre instead of plan:pre { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.ok(errors.length > 0, 'expected per-step ownership errors'); const combined = errors.join('\n'); // Both steps should be named in the errors assert.ok(combined.includes('discuss'), 'error must mention discuss step'); assert.ok(combined.includes('plan'), 'error must mention plan step'); }); test('fails when a step is missing one of its own points', () => { const entries = [ { step: 'discuss', points: ['discuss:pre'] }, // missing discuss:post { step: 'plan', points: ['plan:pre', 'plan:post'] }, { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.ok(errors.length > 0, 'expected ownership error for missing point'); assert.ok( errors.some((e) => e.includes('discuss') && e.includes('expected')), 'error must name the step and expected points', ); }); test('fails when a step has an extra point beyond its own', () => { const entries = [ { step: 'discuss', points: ['discuss:pre', 'discuss:post', 'plan:pre'] }, // extra: plan:pre { step: 'plan', points: ['plan:post'] }, // missing: plan:pre { step: 'execute', points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'] }, { step: 'verify', points: ['verify:pre', 'verify:post'] }, { step: 'ship', points: ['ship:pre', 'ship:post'] }, ]; const errors = assertPointsCoverage(entries); assert.ok(errors.length > 0, 'expected ownership errors for extra and missing points'); }); test('buildContract with swapped points across steps throws a per-step ownership error', () => { const files = makeValidWorkflowFiles(); // discuss declares plan:pre instead of discuss:pre, plan declares discuss:pre instead of plan:pre files['discuss-phase.md'] = makeWorkflow('discuss', ['discuss:post', 'plan:pre'], ['orchestrator'], ['CONTEXT.md'], []); files['plan-phase.md'] = makeWorkflow( 'plan', ['discuss:pre', 'plan:post'], ['researcher', 'planner', 'checker'], ['PLAN.md'], ['CONTEXT.md'], 'msd-phase-researcher msd-planner msd-plan-checker', ); const tmpDir = makeTempWorkflowsDir(files); let cleaned = false; try { assert.throws( () => buildContract(tmpDir), /step.*discuss.*expected|step.*plan.*expected/, ); } finally { if (!cleaned) { cleanup(tmpDir); cleaned = true; } } }); }); // ─── 9. Regression: FIX 2 — multiple blocks + duplicate keys ───────────────── describe('parseLoopHostBlock multiple-block and duplicate-key detection (FIX 2)', () => { test('throws when a file has two msd:loop-host marker blocks', () => { const block = '\n'; const content = block + '\nSome prose.\n\n' + block; assert.throws( () => parseLoopHostBlock(content, 'discuss-phase.md'), /expected exactly one msd:loop-host marker block, found 2/, ); }); test('throws when a block has a duplicate "points" key', () => { const content = '\n'; assert.throws( () => parseLoopHostBlock(content, 'discuss-phase.md'), /duplicate key 'points' in msd:loop-host marker/, ); }); test('throws when a block has a duplicate "step" key', () => { const content = '\n'; assert.throws( () => parseLoopHostBlock(content, 'discuss-phase.md'), /duplicate key 'step' in msd:loop-host marker/, ); }); test('buildContract with a two-block file throws with "found 2" error', () => { const files = makeValidWorkflowFiles(); const singleBlock = '\n'; files['discuss-phase.md'] = singleBlock + '\nDoc example:\n\n' + singleBlock; const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /found 2/, ); } finally { cleanup(tmpDir); } }); }); // ─── 10. Regression: FIX 3 — word-boundary agent cross-check ───────────────── describe('crossCheckRoles word-boundary match (FIX 3)', () => { test('msd-plan-checker-v2 does NOT satisfy required msd-plan-checker reference', () => { // Content has msd-plan-checker-v2 but NOT bare msd-plan-checker const content = 'Agent("msd-phase-researcher") Agent("msd-planner") msd-plan-checker-v2'; const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); assert.strictEqual(errors.length, 1, 'expected exactly 1 error for checker missing bare reference'); assert.match(errors[0], /msd-plan-checker/); }); test('msd-plan-checker (bare) still satisfies the checker role', () => { const content = 'Agent("msd-phase-researcher") Agent("msd-planner") msd-plan-checker something-else'; const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); assert.deepEqual(errors, []); }); test('msd-plan-checker immediately followed by newline satisfies the checker role', () => { const content = 'msd-phase-researcher\nmsd-planner\nmsd-plan-checker\n'; const errors = crossCheckRoles(content, ['researcher', 'planner', 'checker'], 'plan-phase.md'); assert.deepEqual(errors, []); }); test('buildContract rejects workflow referencing only -v2 agent variant', () => { const files = makeValidWorkflowFiles(); // plan-phase.md refers to msd-plan-checker-v2 but not msd-plan-checker files['plan-phase.md'] = '\n' + 'msd-phase-researcher msd-planner msd-plan-checker-v2\n'; const tmpDir = makeTempWorkflowsDir(files); try { assert.throws( () => buildContract(tmpDir), /msd-plan-checker.*not referenced|checker.*msd-plan-checker/, ); } finally { cleanup(tmpDir); } }); }); // ─── 11. Regression: FIX 4 — --check detects header/body tampering ─────────── describe('--check full-content comparison (FIX 4)', () => { test('committed file with tampered header is detected as stale', () => { const contract = buildContract(); const live = serializeContract(contract); // Tamper: replace the DO-NOT-EDIT line with something else const tampered = live.replace( ' * DO NOT EDIT BY HAND. Run: node scripts/gen-loop-host-contract.cjs --write', ' * TAMPERED HEADER LINE', ); assert.notStrictEqual( normalizeLineEndings(tampered), normalizeLineEndings(live), 'tampered content must differ from live content (staleness detected)', ); }); test('committed file with tampered body JSON is detected as stale', () => { const contract = buildContract(); const live = serializeContract(contract); // Tamper: add a phantom step name const tampered = live.replace('"step": "discuss"', '"step": "discuss-tampered"'); assert.notStrictEqual( normalizeLineEndings(tampered), normalizeLineEndings(live), 'tampered body must differ from live content (staleness detected)', ); }); test('un-tampered committed file passes full-content comparison', () => { const contract = buildContract(); const live = serializeContract(contract); const committed = fs.readFileSync(CONTRACT_PATH, 'utf8'); assert.strictEqual( normalizeLineEndings(committed), normalizeLineEndings(live), 'committed file must match live serialization exactly (full-content comparison)', ); }); }); // ─── 12. Regression: FIX 5 — temp-dir cleanup in existing cross-check tests ── // (cleanup is handled via try/finally in each test above that creates temp dirs; // this suite documents and verifies the makeTempWorkflowsDir helper itself) describe('temp-dir lifecycle', () => { test('makeTempWorkflowsDir creates a directory that can be cleaned up', () => { const files = { 'dummy.md': '' }; const tmpDir = makeTempWorkflowsDir(files); assert.ok(fs.existsSync(tmpDir), 'temp dir must exist after creation'); cleanup(tmpDir); assert.ok(!fs.existsSync(tmpDir), 'temp dir must not exist after cleanup'); }); });