feat(plan): the operative line map and a deterministic line diff for the critic loop [skip-docs]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c9248bbb52
commit
3a0c2d0c54
3 changed files with 506 additions and 0 deletions
169
lib/review/critic-diff.mjs
Normal file
169
lib/review/critic-diff.mjs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// lib/review/critic-diff.mjs
|
||||
// Rule A and the rule-B record for the plan gate (decision 43).
|
||||
//
|
||||
// Why: from round 2 on, the critic loop counts a new MAJOR that cites plan text
|
||||
// outside the diff since the last reviewed round as minor (rule A), so a loop of
|
||||
// critic rounds converges on what changed instead of re-litigating text that was
|
||||
// already reviewed. That needs a line diff between two operative plan texts
|
||||
// that is deterministic: the same two texts always give the same hunks, so the
|
||||
// same findings always give the same verdict.
|
||||
//
|
||||
// lineDiff(oldText, newText) -> { inside, oldToNew, hunks }
|
||||
// Lines are 1-based operative lines; CRLF is normalized first.
|
||||
// inside Set of every inserted or changed new line; for a pure-deletion
|
||||
// hunk also the new lines on both sides of the deletion point.
|
||||
// oldToNew oldToNew[o] = the new line an unchanged old line moved to, else
|
||||
// null (index 0 is null).
|
||||
// hunks [{oldStart, oldEnd, newStart, newEnd}], inclusive ranges in order;
|
||||
// an empty side has end = start - 1.
|
||||
// How: trim the common prefix and suffix; anchor on lines that occur exactly
|
||||
// once in both texts, keep the longest increasing subsequence of anchors and
|
||||
// recurse between them; a gap with no unique line uses an LCS table, ties
|
||||
// resolved toward matching the earliest old line.
|
||||
//
|
||||
// Pure: no I/O, and it never reads the plan gate (one dependency direction).
|
||||
//
|
||||
// Credit: patience-style anchoring (unique-line anchors kept by their longest
|
||||
// increasing subsequence), the idea behind Bram Cohen's patience diff; the code
|
||||
// here is written fresh.
|
||||
|
||||
const toLines = (text) => {
|
||||
const t = String(text ?? '').replace(/\r\n?/g, '\n');
|
||||
if (t === '') return [];
|
||||
const out = t.split('\n');
|
||||
if (out[out.length - 1] === '') out.pop();
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Indexes (into `items`) of a longest strictly increasing subsequence of items[k].b; earliest on ties. */
|
||||
function lisByB(items) {
|
||||
const tails = []; // tails[len-1] = index into items of the smallest tail
|
||||
const prev = new Array(items.length).fill(-1);
|
||||
for (let k = 0; k < items.length; k++) {
|
||||
const v = items[k].b;
|
||||
let lo = 0;
|
||||
let hi = tails.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (items[tails[mid]].b < v) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
if (lo > 0) prev[k] = tails[lo - 1];
|
||||
tails[lo] = k;
|
||||
}
|
||||
const out = [];
|
||||
for (let k = tails.length ? tails[tails.length - 1] : -1; k !== -1; k = prev[k]) out.push(k);
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/** LCS over a[aLo..aHi) x b[bLo..bHi); pushes matched [i, j] pairs in order. */
|
||||
function lcsGap(a, aLo, aHi, b, bLo, bHi, pairs) {
|
||||
const n = aHi - aLo;
|
||||
const m = bHi - bLo;
|
||||
const w = m + 1;
|
||||
// L[i*w + j] = LCS length of a[aLo+i..aHi) and b[bLo+j..bHi)
|
||||
const L = new Uint32Array((n + 1) * w);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
L[i * w + j] = a[aLo + i] === b[bLo + j]
|
||||
? L[(i + 1) * w + j + 1] + 1
|
||||
: Math.max(L[(i + 1) * w + j], L[i * w + j + 1]);
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[aLo + i] === b[bLo + j]) {
|
||||
pairs.push([aLo + i, bLo + j]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (L[i * w + j + 1] >= L[(i + 1) * w + j]) {
|
||||
j += 1; // keep old line i for the earliest match
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Match a[aLo..aHi) against b[bLo..bHi); pushes matched [i, j] pairs (0-based) in order. */
|
||||
function matchRange(a, aLo, aHi, b, bLo, bHi, pairs) {
|
||||
while (aLo < aHi && bLo < bHi && a[aLo] === b[bLo]) {
|
||||
pairs.push([aLo, bLo]);
|
||||
aLo += 1;
|
||||
bLo += 1;
|
||||
}
|
||||
const tail = [];
|
||||
while (aLo < aHi && bLo < bHi && a[aHi - 1] === b[bHi - 1]) {
|
||||
aHi -= 1;
|
||||
bHi -= 1;
|
||||
tail.push([aHi, bHi]);
|
||||
}
|
||||
if (aLo < aHi && bLo < bHi) {
|
||||
const count = new Map(); // line -> {na, nb, a, b}
|
||||
for (let i = aLo; i < aHi; i++) {
|
||||
const c = count.get(a[i]) ?? { na: 0, nb: 0, a: -1, b: -1 };
|
||||
c.na += 1;
|
||||
c.a = i;
|
||||
count.set(a[i], c);
|
||||
}
|
||||
for (let j = bLo; j < bHi; j++) {
|
||||
const c = count.get(b[j]);
|
||||
if (!c) continue;
|
||||
c.nb += 1;
|
||||
c.b = j;
|
||||
}
|
||||
const anchors = [];
|
||||
for (let i = aLo; i < aHi; i++) {
|
||||
const c = count.get(a[i]);
|
||||
if (c.na === 1 && c.nb === 1) anchors.push({ a: c.a, b: c.b });
|
||||
}
|
||||
if (anchors.length > 0) {
|
||||
let pa = aLo;
|
||||
let pb = bLo;
|
||||
for (const k of lisByB(anchors)) {
|
||||
const { a: ia, b: ib } = anchors[k];
|
||||
matchRange(a, pa, ia, b, pb, ib, pairs);
|
||||
pairs.push([ia, ib]);
|
||||
pa = ia + 1;
|
||||
pb = ib + 1;
|
||||
}
|
||||
matchRange(a, pa, aHi, b, pb, bHi, pairs);
|
||||
} else {
|
||||
lcsGap(a, aLo, aHi, b, bLo, bHi, pairs);
|
||||
}
|
||||
}
|
||||
for (let k = tail.length - 1; k >= 0; k--) pairs.push(tail[k]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic line diff of two operative texts.
|
||||
* @param {string} oldText
|
||||
* @param {string} newText
|
||||
* @returns {{inside: Set<number>, oldToNew: Array<number|null>, hunks: Array<{oldStart: number, oldEnd: number, newStart: number, newEnd: number}>}}
|
||||
*/
|
||||
export function lineDiff(oldText, newText) {
|
||||
const a = toLines(oldText);
|
||||
const b = toLines(newText);
|
||||
const pairs = [];
|
||||
matchRange(a, 0, a.length, b, 0, b.length, pairs);
|
||||
const oldToNew = new Array(a.length + 1).fill(null);
|
||||
for (const [i, j] of pairs) oldToNew[i + 1] = j + 1;
|
||||
const hunks = [];
|
||||
const inside = new Set();
|
||||
let pi = -1;
|
||||
let pj = -1;
|
||||
for (const [qi, qj] of [...pairs, [a.length, b.length]]) {
|
||||
if (qi > pi + 1 || qj > pj + 1) {
|
||||
const h = { oldStart: pi + 2, oldEnd: qi, newStart: pj + 2, newEnd: qj };
|
||||
hunks.push(h);
|
||||
if (h.newEnd >= h.newStart) {
|
||||
for (let n = h.newStart; n <= h.newEnd; n++) inside.add(n);
|
||||
} else {
|
||||
if (h.newStart - 1 >= 1) inside.add(h.newStart - 1);
|
||||
if (h.newStart <= b.length) inside.add(h.newStart);
|
||||
}
|
||||
}
|
||||
pi = qi;
|
||||
pj = qj;
|
||||
}
|
||||
return { inside, oldToNew, hunks };
|
||||
}
|
||||
|
|
@ -100,6 +100,47 @@ export function operativeText(text) {
|
|||
.trim() + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* The operative text with its line map, built from the same transforms as
|
||||
* `operativeText` (whose output it returns unchanged).
|
||||
* @returns {{text: string, fullToOp: Array<number|null>, opToFull: Array<number|null>}}
|
||||
* 1-based both ways (index 0 is null). fullToOp[i] is null for a review-log
|
||||
* line, the quality line or a collapsed blank; opToFull[j] is null for an
|
||||
* operative line no file line carries (a blank left where a log section stood).
|
||||
*/
|
||||
export function operativeLines(text) {
|
||||
const src = norm(text);
|
||||
const full = src.split('\n');
|
||||
const excluded = new Set();
|
||||
for (const s of splitSections(src, 2)) {
|
||||
if (!isLogTitle(s.title)) continue;
|
||||
const n = logPart(s.text).log.split('\n').length;
|
||||
for (let i = s.start; i < s.start + n; i++) excluded.add(i);
|
||||
}
|
||||
full.forEach((l, i) => { if (QUALITY_LINE.test(l)) excluded.add(i); });
|
||||
const op = operativeText(text);
|
||||
const opL = op.split('\n');
|
||||
const opCount = opL.length - 1; // the text ends with '\n'; the last split element is not a line
|
||||
const fullToOp = new Array(full.length + 1).fill(null);
|
||||
const opToFull = new Array(opCount + 1).fill(null);
|
||||
let j = 0;
|
||||
for (let i = 0; i < full.length && j < opCount; i++) {
|
||||
if (excluded.has(i)) continue;
|
||||
let l = full[i].replace(/[ \t]+$/, '');
|
||||
if (j === 0) l = l.replace(/^\s+/, '');
|
||||
// Only blanks lose their partner, so a non-blank kept line facing an operative
|
||||
// blank means that blank has no file line: step past it.
|
||||
while (l !== '' && opL[j] === '' && j < opCount - 1) j += 1;
|
||||
const want = opL[j];
|
||||
if (l === want || (j === opCount - 1 && l.trimEnd() === want)) {
|
||||
fullToOp[i + 1] = j + 1;
|
||||
opToFull[j + 1] = i + 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return { text: op, fullToOp, opToFull };
|
||||
}
|
||||
|
||||
export function planHash(text) {
|
||||
return createHash('sha256').update(operativeText(text)).digest('hex');
|
||||
}
|
||||
|
|
|
|||
296
tests/lib/critic-diff.test.mjs
Normal file
296
tests/lib/critic-diff.test.mjs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue