// tests/lib/synthesis-digest-schema.test.mjs // NW3 (S12) — digest-schema contract for the synthesis-agent. // // The synthesis-agent (agents/synthesis-agent.md) ingests the trekplan Phase-5/7 // exploration outputs and emits a trailing fenced ```json block: the findings // DIGEST main currently writes inline in Phase 7. This pins that digest's shape // so a delegated path could VALIDATE it (not merely parse it) and so the // measurement harness has a fixed quality contract to compare against. // // Load-bearing fields (what Phase 8 deep-planning consumes): task, // architecture_model, and the five synthesis arrays (reusable_code, // contradictions, risks, gaps, sources). Each source must be origin-tagged // codebase|research (Phase 7 rule 7: "track whether it came from codebase // analysis or external research"). Mirrors lib/review/findings-schema.mjs. // // When this test fails, fix the schema or the producer — do NOT relax the // assertion to hide drift. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { validateDigest, validateAgentOutput, extractDigestBlock, ORIGIN_VALUES, DIGEST_REQUIRED_FIELDS, } from '../../lib/plan/synthesis-digest-schema.mjs'; function wellFormed() { return { agent: 'synthesis-agent', task: 'Add a per-wave rate-limit guard to trekexecute headless launches', architecture_model: 'trekexecute spawns parallel headless waves via the Bash launcher template; ' + 'concurrency is currently unbounded per wave.', reusable_code: [ { ref: 'lib/util/result.mjs:33', note: 'issue() for structured errors' }, { ref: 'templates/headless-launch-template.md:10', note: 'wave dispatch site' }, ], contradictions: [ 'architecture-mapper says waves are sequential; dependency-tracer shows a parallel fan-out', ], risks: [ { risk: 'A rate-limit that blocks too aggressively starves long waves', severity: 'medium' }, ], gaps: ['No measured per-wave token ceiling exists yet — becomes an assumption'], sources: [ { finding: 'wave launcher lives in the template', origin: 'codebase' }, { finding: 'CC headless --max-budget-usd semantics', origin: 'research' }, ], }; } test('digest schema: a well-formed digest validates', () => { const r = validateDigest(wellFormed()); assert.equal(r.valid, true, JSON.stringify(r.errors)); assert.deepEqual(r.errors, []); }); test('digest schema: exposes the required-field + origin contracts', () => { assert.ok(Array.isArray(DIGEST_REQUIRED_FIELDS) && DIGEST_REQUIRED_FIELDS.length > 0); for (const f of ['task', 'architecture_model', 'reusable_code', 'contradictions', 'risks', 'gaps', 'sources']) { assert.ok(DIGEST_REQUIRED_FIELDS.includes(f), `required fields must include ${f}`); } assert.deepEqual([...ORIGIN_VALUES].sort(), ['codebase', 'research']); }); test('digest schema: rejects a non-object payload', () => { for (const bad of [null, 42, 'x', ['a']]) { const r = validateDigest(bad); assert.equal(r.valid, false); assert.ok(r.errors.some((e) => e.code === 'DIGEST_NOT_OBJECT'), `expected DIGEST_NOT_OBJECT for ${JSON.stringify(bad)}`); } }); test('digest schema: rejects missing/empty task', () => { for (const t of [undefined, '', 123]) { const d = wellFormed(); d.task = t; const r = validateDigest(d); assert.equal(r.valid, false); assert.ok(r.errors.some((e) => e.code === 'DIGEST_MISSING_TASK')); } }); test('digest schema: rejects missing/empty architecture_model', () => { const d = wellFormed(); delete d.architecture_model; const r = validateDigest(d); assert.equal(r.valid, false); assert.ok(r.errors.some((e) => e.code === 'DIGEST_MISSING_ARCHITECTURE')); }); test('digest schema: each synthesis array must be an array', () => { const cases = [ ['reusable_code', 'DIGEST_REUSABLE_NOT_ARRAY'], ['contradictions', 'DIGEST_CONTRADICTIONS_NOT_ARRAY'], ['risks', 'DIGEST_RISKS_NOT_ARRAY'], ['gaps', 'DIGEST_GAPS_NOT_ARRAY'], ['sources', 'DIGEST_SOURCES_NOT_ARRAY'], ]; for (const [field, code] of cases) { const d = wellFormed(); d[field] = { not: 'an array' }; const r = validateDigest(d); assert.equal(r.valid, false, `${field} as object should fail`); assert.ok(r.errors.some((e) => e.code === code), `expected ${code}`); } }); test('digest schema: empty synthesis arrays are valid (a clean digest can have no contradictions/gaps)', () => { const d = wellFormed(); d.contradictions = []; d.gaps = []; const r = validateDigest(d); assert.equal(r.valid, true, JSON.stringify(r.errors)); }); test('digest schema: a source with a non-enum origin is rejected', () => { const d = wellFormed(); d.sources = [{ finding: 'x', origin: 'guess' }]; const r = validateDigest(d); assert.equal(r.valid, false); assert.ok(r.errors.some((e) => e.code === 'DIGEST_SOURCE_BAD_ORIGIN')); }); test('digest schema: missing agent name is a warning, not an error', () => { const d = wellFormed(); delete d.agent; const r = validateDigest(d); assert.equal(r.valid, true, JSON.stringify(r.errors)); assert.ok(r.warnings.some((w) => w.code === 'DIGEST_MISSING_AGENT')); }); test('extractDigestBlock: pulls the LAST fenced json block from agent prose', () => { const text = 'Here is my synthesis.\n\n' + '```json\n{"stale": true}\n```\n\n' + 'Actually, the final digest:\n\n' + '```json\n' + JSON.stringify(wellFormed()) + '\n```\n'; const block = extractDigestBlock(text); assert.ok(block && JSON.parse(block).task, 'should extract the last json block'); assert.equal(JSON.parse(block).agent, 'synthesis-agent'); }); test('validateAgentOutput: no json fence → stable NO_JSON code', () => { const r = validateAgentOutput('just prose, no fence'); assert.equal(r.valid, false); assert.ok(r.errors.some((e) => e.code === 'DIGEST_NO_JSON_BLOCK')); }); test('validateAgentOutput: malformed json → stable PARSE code', () => { const r = validateAgentOutput('```json\n{ not valid }\n```'); assert.equal(r.valid, false); assert.ok(r.errors.some((e) => e.code === 'DIGEST_PARSE_ERROR')); }); test('validateAgentOutput: well-formed fenced digest validates end-to-end', () => { const r = validateAgentOutput('Synthesis complete.\n```json\n' + JSON.stringify(wellFormed()) + '\n```'); assert.equal(r.valid, true, JSON.stringify(r.errors)); });