// tests/lib/critic-diff.test.mjs // Step 2 of the converging-review-loops plan (decision 43): the operative line // map (`operativeLines` in plan-gate.mjs) and the deterministic line diff // (`lineDiff` in critic-diff.mjs) that rule A counts on. // // - The line map must not move the hash: its text is operativeText's output, // byte for byte, and three fixtures keep a fixed sha256 (taken at the start // point of this step, before operativeLines existed). // - lineDiff places hunks; a step inserted with boilerplate lines that duplicate // the steps around it is marked as the NEW copy, never the old one. // - The two architecture rules: critic-diff never imports plan-gate, and does // no I/O (no fs, no child_process). Checked on the real source (pass) and on // the source with a planted violation (fail), in the same run. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { createHash } from 'node:crypto'; import { readFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { operativeText, planHash, recordVerdict } from '../../lib/validators/plan-gate.mjs'; import * as gate from '../../lib/validators/plan-gate.mjs'; import { buildPlan, step } from '../helpers/plan-v18-builder.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const CRITIC_DIFF = join(HERE, '..', '..', 'lib', 'review', 'critic-diff.mjs'); // Loaded so that each new assertion fails on its own while the module is absent. let diffMod = {}; try { diffMod = await import(pathToFileURL(CRITIC_DIFF).href); } catch (e) { if (existsSync(CRITIC_DIFF)) throw e; } function operativeLines(text) { assert.equal(typeof gate.operativeLines, 'function', 'plan-gate.mjs exports operativeLines'); return gate.operativeLines(text); } function lineDiff(oldText, newText) { assert.equal(typeof diffMod.lineDiff, 'function', 'critic-diff.mjs exports lineDiff'); return diffMod.lineDiff(oldText, newText); } const sha = s => createHash('sha256').update(s).digest('hex'); const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }]; const lines = t => t.split('\n'); const lineNo = (t, pred) => lines(t).findIndex(pred) + 1; // ---- fixtures ---------------------------------------------------------------- /** A buildPlan() plan. */ const PLAN_A = buildPlan(); /** The same plan after a recorded verdict, plus a `## Revisions` section with a `### Step` under it. */ function planB() { const base = buildPlan(); const r = recordVerdict(base, { findings: [{ id: 'a'.repeat(40), file: 'plan.md', line: 70, rule_key: 'step-quality', severity: 'minor', text: 'nit', raised_by: ['plan-critic'] }], sources: BOTH, }, { reviewed: planHash(base), now: '2026-10-15T00:00:00.000Z' }); assert.equal(r.code, 0, r.message); return r.text + '\n## Revisions\n\n| # | Finding | Severity | Resolution |\n|---|---|---|---|\n| 1 | nit | minor | left |\n\n' + '### Step 3: A step placed under the log\n\n- **Files:** `src/late.mjs`\n'; } const PLAN_B = planB(); /** A triple blank line inside a fence, plus CRLF and line-end whitespace. */ const PLAN_C = '# Fence plan\r\n\r\n## Context\r\n\r\n```text\r\nalpha \r\n\r\n\r\n\r\nbeta\r\n```\r\n\r\nend \r\n'; // sha256 of operativeText at this step's start point (c9248bb), before operativeLines existed. const FIXED = { A: '458fe6763d59d3674e9bc882e326e06b81522b625e5443799a7f61099d356c55', B: '57f0c8c7e908014975e12b50e6c16aef2acaccf4e765047ffd4ef33017724475', C: 'c12b130fbc418ff5c0fe570c8bd26ecac69f0117d23fe7cfe93ce68af0e11a3f', }; // ---- operativeLines: the hash does not move ------------------------------------- for (const [name, text] of [['A', PLAN_A], ['B', PLAN_B], ['C', PLAN_C]]) { test(`operativeLines(${name}).text is operativeText byte for byte, at the fixed sha256`, () => { assert.equal(planHash(text), FIXED[name], 'planHash is unchanged from the start point'); const m = operativeLines(text); assert.equal(m.text, operativeText(text)); assert.equal(sha(m.text), FIXED[name]); }); } test('operativeLines: fullToOp and opToFull are inverse where both are set', () => { for (const text of [PLAN_A, PLAN_B, PLAN_C]) { const m = operativeLines(text); const opCount = m.text.split('\n').length - 1; for (let j = 1; j <= opCount; j++) { const i = m.opToFull[j]; if (i !== null) assert.equal(m.fullToOp[i], j, `opToFull[${j}] = ${i} maps back`); } for (let i = 1; i < m.fullToOp.length; i++) { const j = m.fullToOp[i]; if (j !== null) assert.equal(m.opToFull[j], i, `fullToOp[${i}] = ${j} maps back`); } } }); test('operativeLines: ### Step 2 at full line 70 maps to operative line 69 in buildPlan()', () => { const full = lineNo(PLAN_A, l => l.startsWith('### Step 2')); const op = lineNo(operativeText(PLAN_A), l => l.startsWith('### Step 2')); assert.equal(full, 70); assert.equal(op, 69); const m = operativeLines(PLAN_A); assert.equal(m.fullToOp[70], 69); assert.equal(m.opToFull[69], 70); // Every non-blank operative line is reached from the full line that carries its text. const opL = lines(m.text); const fullL = lines(PLAN_A); for (let j = 1; j < opL.length; j++) { if (opL[j - 1] === '') continue; const i = m.opToFull[j]; assert.notEqual(i, null, `operative line ${j} "${opL[j - 1]}" has a full line`); assert.equal(fullL[i - 1].replace(/[ \t]+$/, ''), opL[j - 1]); } }); test('operativeLines: the quality line maps to null', () => { const q = lineNo(PLAN_A, l => l.startsWith('> **Plan quality:')); assert.equal(q, 3); const m = operativeLines(PLAN_A); assert.equal(m.fullToOp[q], null); assert.equal(m.fullToOp[1], 1, '# Test plan stays line 1'); }); test('operativeLines: review-log lines map to null; a ### Step under the log stays mapped', () => { const m = operativeLines(PLAN_B); const fullL = lines(PLAN_B); const verdictAt = lineNo(PLAN_B, l => l === '## Critic Verdict'); const jsonAt = lineNo(PLAN_B, l => l === '```json'); const revAt = lineNo(PLAN_B, l => l === '## Revisions'); const tableAt = lineNo(PLAN_B, l => l === '| 1 | nit | minor | left |'); const lateAt = lineNo(PLAN_B, l => l.startsWith('### Step 3: A step placed under the log')); for (const i of [verdictAt, jsonAt, jsonAt + 1, revAt, tableAt]) { assert.ok(i > 0, `fixture line found (${i})`); assert.equal(m.fullToOp[i], null, `log line ${i} "${fullL[i - 1]}" maps to null`); } const opLate = lineNo(m.text, l => l.startsWith('### Step 3: A step placed under the log')); assert.ok(opLate > 0); assert.equal(m.fullToOp[lateAt], opLate); assert.equal(m.opToFull[opLate], lateAt); assert.equal(m.fullToOp[lateAt + 2], opLate + 2, 'the step\'s Files line under the log'); }); test('operativeLines: collapsed blanks (inside a fence, CRLF) map to null', () => { const m = operativeLines(PLAN_C); const alpha = lineNo(PLAN_C, l => l.startsWith('alpha')); const beta = lineNo(PLAN_C, l => l.startsWith('beta')); assert.equal(beta - alpha, 4, 'three blank lines between alpha and beta'); const opAlpha = lineNo(m.text, l => l === 'alpha'); assert.equal(m.fullToOp[alpha], opAlpha); assert.equal(m.fullToOp[beta], opAlpha + 2, 'one blank survives the collapse'); const blanks = [alpha + 1, alpha + 2, alpha + 3].map(i => m.fullToOp[i]); assert.equal(blanks.filter(x => x === null).length, 2, `two of three blanks collapse: ${JSON.stringify(blanks)}`); assert.deepEqual(blanks.filter(x => x !== null), [opAlpha + 1]); const end = lineNo(PLAN_C, l => l.startsWith('end')); assert.equal(m.opToFull[lineNo(m.text, l => l === 'end')], end); }); test('operativeLines: a log section right after a line with no blank keeps the rest of the map', () => { // Joining the kept sections puts a blank where the log section stood; no full // line carries it. The lines after it must still map. const text = '# P\n\n## Context\n\nalpha\n## Critic Verdict\n\n```json\n{}\n```\n## Tail\n\nomega\n'; const m = operativeLines(text); assert.equal(m.text, operativeText(text)); const omegaFull = lineNo(text, l => l === 'omega'); const omegaOp = lineNo(m.text, l => l === 'omega'); assert.equal(m.fullToOp[omegaFull], omegaOp); assert.equal(m.fullToOp[lineNo(text, l => l === '## Tail')], lineNo(m.text, l => l === '## Tail')); assert.equal(m.fullToOp[lineNo(text, l => l === '## Critic Verdict')], null); }); // ---- lineDiff ------------------------------------------------------------------ // Hunks are 1-based and inclusive; an empty side has end = start - 1. test('lineDiff: identical texts give empty inside and no hunks (CRLF normalized)', () => { const t = 'a\nb\nc\n'; for (const other of [t, 'a\r\nb\r\nc\r\n']) { const d = lineDiff(t, other); assert.equal(d.inside.size, 0); assert.deepEqual(d.hunks, []); assert.deepEqual(d.oldToNew.slice(1), [1, 2, 3]); } }); test('lineDiff: an insert', () => { const d = lineDiff('a\nb\nc\n', 'a\nb\nX\nc\n'); assert.deepEqual([...d.inside], [3]); assert.deepEqual(d.hunks, [{ oldStart: 3, oldEnd: 2, newStart: 3, newEnd: 3 }]); assert.deepEqual(d.oldToNew.slice(1), [1, 2, 4]); }); test('lineDiff: a delete', () => { const d = lineDiff('a\nb\nc\nd\n', 'a\nb\nd\n'); assert.deepEqual(d.hunks, [{ oldStart: 3, oldEnd: 3, newStart: 3, newEnd: 2 }]); assert.deepEqual(d.oldToNew.slice(1), [1, 2, null, 3]); }); test('lineDiff: a replace', () => { const d = lineDiff('a\nb\nc\n', 'a\nX\nY\nc\n'); assert.deepEqual([...d.inside].sort((x, y) => x - y), [2, 3]); assert.deepEqual(d.hunks, [{ oldStart: 2, oldEnd: 2, newStart: 2, newEnd: 3 }]); assert.deepEqual(d.oldToNew.slice(1), [1, null, 4]); }); test('lineDiff: two adjacent hunks stay two', () => { const d = lineDiff('a\nb\nc\nd\ne\n', 'a\nX\nc\nY\ne\n'); assert.deepEqual(d.hunks, [ { oldStart: 2, oldEnd: 2, newStart: 2, newEnd: 2 }, { oldStart: 4, oldEnd: 4, newStart: 4, newEnd: 4 }, ]); assert.deepEqual([...d.inside].sort((x, y) => x - y), [2, 4]); assert.deepEqual(d.oldToNew.slice(1), [1, null, 3, null, 5]); }); test('lineDiff: oldToNew follows an insert above', () => { const d = lineDiff('a\nb\nc\n', 'X\nY\na\nb\nc\n'); assert.deepEqual(d.oldToNew.slice(1), [3, 4, 5]); assert.deepEqual([...d.inside].sort((x, y) => x - y), [1, 2]); assert.deepEqual(d.hunks, [{ oldStart: 1, oldEnd: 0, newStart: 1, newEnd: 2 }]); }); test('lineDiff: a pure deletion marks both neighbours (one at the end of the text)', () => { const mid = lineDiff('a\nb\nc\nd\n', 'a\nd\n'); assert.deepEqual([...mid.inside].sort((x, y) => x - y), [1, 2]); const end = lineDiff('a\nb\nc\n', 'a\nb\n'); assert.deepEqual([...end.inside], [2]); assert.deepEqual(end.hunks, [{ oldStart: 3, oldEnd: 3, newStart: 3, newEnd: 2 }]); }); test('lineDiff: a step inserted with duplicated boilerplate marks the new copy, not the old one', () => { // Title and last line change too, so prefix/suffix trimming cannot do the work: // the boilerplate tail of step 1 and the inserted step 2 share a gap with no unique line. const s1 = step(1); const s2 = step(2); const s3 = step(3); const oldText = ['# Old title', '', s1, '', s3, '', 'old end', ''].join('\n'); const newText = ['# New title', '', s1, '', s2, '', s3, '', 'new end', ''].join('\n'); const d = lineDiff(oldText, newText); const nl = lines(newText); const s1Start = lineNo(newText, l => l.startsWith('### Step 1:')); const s2Start = lineNo(newText, l => l.startsWith('### Step 2:')); const s3Start = lineNo(newText, l => l.startsWith('### Step 3:')); const last = nl.length - 1; // the final '' after the trailing newline is not a line const expected = [1]; for (let i = s2Start; i < s3Start; i++) expected.push(i); expected.push(last); assert.deepEqual([...d.inside].sort((x, y) => x - y), expected); for (let i = s1Start; i < s2Start; i++) assert.ok(!d.inside.has(i), `step 1 line ${i} "${nl[i - 1]}" is not inside`); // Old step 1 keeps its place; old step 3 moves down by step 2 plus its blank. const oS1 = lineNo(oldText, l => l.startsWith('### Step 1:')); const oS3 = lineNo(oldText, l => l.startsWith('### Step 3:')); for (let o = oS1; o < oS3; o++) assert.equal(d.oldToNew[o], o, `old line ${o} unchanged`); const shift = s3Start - oS3; assert.equal(shift, s2Start - s1Start, 'step 2 has as many lines as step 1'); for (let o = oS3; o < oS3 + lines(s3).length; o++) assert.equal(d.oldToNew[o], o + shift); }); test('lineDiff: the same input twice gives deep-equal output', () => { const a = ['# T', '', step(1), '', step(3), ''].join('\n'); const b = ['# T', '', step(1), '', step(2), '', step(3), ''].join('\n'); assert.deepEqual(lineDiff(a, b), lineDiff(a, b)); assert.deepEqual(lineDiff(PLAN_A, operativeText(PLAN_B)), lineDiff(PLAN_A, operativeText(PLAN_B))); }); // ---- architecture rules ---------------------------------------------------------- /** Every `from '...'`, `import('...')` and `require('...')` specifier that breaks a rule. */ function importViolations(source) { const re = /\bfrom\s*(['"`])([^'"`]+)\1|\bimport\s*\(\s*(['"`])([^'"`]+)\3\s*\)|\brequire\s*\(\s*(['"`])([^'"`]+)\5\s*\)/g; const out = []; for (const m of String(source).matchAll(re)) { const spec = m[2] ?? m[4] ?? m[6]; if (/(^|\/)plan-gate\.mjs$/.test(spec) || /^(node:)?(fs|child_process)(\/|$)/.test(spec)) out.push(spec); } return out; } test('architecture rules: critic-diff imports neither plan-gate nor fs/child_process (planted violations caught)', () => { assert.ok(existsSync(CRITIC_DIFF), 'lib/review/critic-diff.mjs exists'); const src = readFileSync(CRITIC_DIFF, 'utf-8'); assert.deepEqual(importViolations(src), [], 'the real source passes'); const plantedGate = `${src}\nimport { planHash } from '../validators/plan-gate.mjs';\n`; assert.deepEqual(importViolations(plantedGate), ['../validators/plan-gate.mjs'], 'a planted plan-gate import fails'); const plantedFs = `${src}\nawait import('node:fs');\n`; assert.deepEqual(importViolations(plantedFs), ['node:fs'], 'a planted dynamic fs import fails'); assert.deepEqual(importViolations(src), [], 'the real source passes again'); });