296 lines
14 KiB
JavaScript
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');
|
|
});
|