voyage/tests/lib/critic-diff.test.mjs
2026-10-15 16:49:21 +02:00

296 lines
14 KiB
JavaScript

// 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');
});