/** * SUB-WRITE engine — the write half of `optimize --subtract` (#63). * * The judgement half is an agent's; this half must be byte-deterministic, * because it is the only path in the plugin that REMOVES configuration. Each * test below corresponds to a numbered prediction in * `docs/subwrite-fasit.local.md` §3, written before the engine existed. * * Two measurements settled the design and are re-asserted here by construction: * the subtraction axis is absent from the orchestrated envelope (so * `fix-engine.verifyFixes` would have marked every removal `verified` whether * or not it happened), and `OPT` declares exactly one finding code, for the * deterministic check. This engine therefore stands on its own. */ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { mkdtemp, readFile, writeFile, rm, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { exciseBlocks, applySubtraction } from '../../scanners/lib/subtraction-write.mjs'; import { parseManifest } from '../../scanners/lib/backup.mjs'; // Every backup this file creates stays in a temp root — otherwise the suite // writes into the operator's real ~/.claude/config-audit/backups, where // cleanupOldBackups() would start deleting genuine backups past MAX_BACKUPS. const TEST_BACKUP_ROOT = join(tmpdir(), `config-audit-subwrite-backups-${process.pid}`); process.env.CONFIG_AUDIT_BACKUP_ROOT = TEST_BACKUP_ROOT; const BLOCK_A = '- Always write tests before code, and never skip the failing step.'; const BLOCK_FLOOR = '- Push to `git.example.test` after every commit.'; const BLOCK_B = '- Be concise and avoid unnecessary explanation in your answers.'; /** Line numbers are 1-based: BLOCK_A = 5, BLOCK_FLOOR = 7, BLOCK_B = 9. */ const FIXTURE = [ '# Project', // 1 '', // 2 '## Rules', // 3 '', // 4 BLOCK_A, // 5 '', // 6 BLOCK_FLOOR, // 7 '', // 8 BLOCK_B, // 9 '', // 10 '## End', // 11 '', // 12 ].join('\n'); let dir; let repo; let file; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'config-audit-subwrite-')); // A repo root of its own, so classification lands on `in-repo` unless a test // deliberately targets somewhere else. repo = join(dir, 'repo'); await mkdir(join(repo, '.git'), { recursive: true }); file = join(repo, 'CLAUDE.md'); await writeFile(file, FIXTURE, 'utf-8'); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); /** An approval entry as `optimize.md` writes it from the lens payload. */ const removal = (line, endLine, text) => ({ file, line, endLine, text }); describe('exciseBlocks (pure)', () => { it('P1 — removes a block whose text matches the file at line..endLine', () => { const result = exciseBlocks(FIXTURE, [removal(5, 5, BLOCK_A)]); assert.equal(result.refused.length, 0); assert.equal(result.applied.length, 1); assert.equal(result.applied[0].text, BLOCK_A); assert.ok(!result.content.includes(BLOCK_A), 'block A must be gone'); assert.ok(result.content.includes(BLOCK_B), 'block B must survive'); assert.ok(result.content.includes(BLOCK_FLOOR), 'the floor block must survive'); }); it('P2 — refuses when the file no longer matches the approved text', () => { const drifted = FIXTURE.replace(BLOCK_A, '- Always write tests before code, and never skip it.'); const result = exciseBlocks(drifted, [removal(5, 5, BLOCK_A)]); assert.equal(result.applied.length, 0); assert.equal(result.refused.length, 1); assert.equal(result.refused[0].reason, 'block-mismatch'); assert.equal(result.content, drifted, 'the content must come back byte-identical'); }); it('P3 — refuses an out-of-range span without throwing', () => { const result = exciseBlocks(FIXTURE, [removal(400, 402, BLOCK_A)]); assert.equal(result.applied.length, 0); assert.equal(result.refused[0].reason, 'block-mismatch'); assert.equal(result.content, FIXTURE); }); it('P3 — a non-positive line is refused by the RANGE check, not by luck', () => { // Measured (#63): with the range check disabled, the P3 case above stays // green — `lines.slice(399, 402)` is empty, so the text check refuses it // anyway and the test passes for the wrong reason. This is the case only // the range check can catch: `slice(-1, 0)` is also empty, so an empty // `text` MATCHES, and `splice(-1, 1)` then deletes the file's LAST line. const result = exciseBlocks(FIXTURE, [removal(0, 0, '')]); assert.equal(result.applied.length, 0); assert.equal(result.refused[0].reason, 'block-mismatch'); assert.equal(result.content, FIXTURE, 'no line may be removed from the far end'); }); it('P4 — refuses a load-bearing block fed straight to the engine', () => { // The pre-filter's veto never ran: this is the engine's own red line, so a // caller that hand-builds an approval cannot route around the floor. const result = exciseBlocks(FIXTURE, [removal(7, 7, BLOCK_FLOOR)]); assert.equal(result.applied.length, 0); assert.equal(result.refused.length, 1); assert.equal(result.refused[0].reason, 'floor'); assert.ok(result.content.includes(BLOCK_FLOOR)); }); it('P5 — two blocks in one file: the second span is not shifted by the first removal', () => { const result = exciseBlocks(FIXTURE, [removal(5, 5, BLOCK_A), removal(9, 9, BLOCK_B)]); assert.equal(result.refused.length, 0); assert.equal(result.applied.length, 2); // Assert the exact surviving text, not merely "does not include": a naive // ascending implementation removes block A and then whatever slid into // lines 9..9, which is easy to mistake for success. assert.equal( result.content, ['# Project', '', '## Rules', '', BLOCK_FLOOR, '', '## End', ''].join('\n'), ); }); it('P6 — collapses the double blank line a removal leaves at the seam', () => { const result = exciseBlocks(FIXTURE, [removal(5, 5, BLOCK_A)]); assert.ok(!/\n\n\n/.test(result.content), 'no run of two blank lines may survive'); }); it('P12 — a mismatch alongside a valid removal refuses only the mismatch', () => { const result = exciseBlocks(FIXTURE, [ removal(5, 5, BLOCK_A), removal(9, 9, '- Something that is not in this file at all.'), ]); assert.equal(result.applied.length, 1); assert.equal(result.refused.length, 1); assert.equal(result.refused[0].reason, 'block-mismatch'); assert.ok(!result.content.includes(BLOCK_A)); assert.ok(result.content.includes(BLOCK_B), 'the refused block stays put'); }); }); describe('applySubtraction (filesystem + gate)', () => { it('P1 — writes the file and reports the removed text', async () => { const result = await applySubtraction([removal(5, 5, BLOCK_A)], { repoRoot: repo }); assert.equal(result.applied.length, 1); assert.equal(result.filesWritten.length, 1); const after = await readFile(file, 'utf-8'); assert.ok(!after.includes(BLOCK_A)); assert.ok(after.includes(BLOCK_B)); }); it('P7 — a dry run writes nothing and creates no backup', async () => { const result = await applySubtraction([removal(5, 5, BLOCK_A)], { repoRoot: repo, dryRun: true, }); assert.equal(result.dryRun, true); assert.equal(result.backupId, null); assert.equal(result.filesWritten.length, 0); assert.equal(result.applied.length, 1, 'it still reports what WOULD be removed'); assert.equal(await readFile(file, 'utf-8'), FIXTURE, 'the file must be untouched'); }); it('P8 — a require-ok target without approval writes nothing, and that is exit-0 territory', async () => { // `~/.claude/CLAUDE.md` under a fake home: the subtraction axis's primary // target, and the one whose cost lands in every repo on every turn. const home = join(dir, 'home'); const userConfig = join(home, '.claude'); await mkdir(userConfig, { recursive: true }); const userFile = join(userConfig, 'CLAUDE.md'); await writeFile(userFile, FIXTURE, 'utf-8'); const result = await applySubtraction( [{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }], { repoRoot: repo, home }, ); assert.equal(result.gate, 'require-ok'); assert.equal(result.requiresApproval, true); assert.ok(result.disclosures.length >= 1, 'the gate must say why, not just refuse'); assert.equal(result.applied.length, 0); assert.equal(result.filesWritten.length, 0); assert.equal(result.refused[0].reason, 'scope-gate'); assert.equal(await readFile(userFile, 'utf-8'), FIXTURE); }); it('P8b — a dry run on a require-ok target validates the spans AND still reports the gate', async () => { // The gate guards a WRITE. A dry run is not one, so refusing it early // bought nothing and cost the thing the dry run exists for: on the // machine-wide target — the mandatory v1 case — the operator would have // been asked to approve a removal whose spans had never been checked, and // the first run that could discover a stale approval would be the one that // writes. const home = join(dir, 'home'); const userConfig = join(home, '.claude'); await mkdir(userConfig, { recursive: true }); const userFile = join(userConfig, 'CLAUDE.md'); await writeFile(userFile, FIXTURE, 'utf-8'); const result = await applySubtraction( [ { file: userFile, line: 5, endLine: 5, text: BLOCK_A }, { file: userFile, line: 9, endLine: 9, text: '- Not in this file.' }, ], { repoRoot: repo, home, dryRun: true }, ); assert.equal(result.requiresApproval, true, 'the gate must still be reported'); assert.ok(result.disclosures.length >= 1); assert.equal(result.applied.length, 1, 'the valid span is validated, not refused unseen'); assert.equal(result.refused[0].reason, 'block-mismatch', 'the stale one is found HERE, not at write time'); assert.equal(result.filesWritten.length, 0); assert.equal(result.backupId, null); assert.equal(await readFile(userFile, 'utf-8'), FIXTURE, 'a dry run writes nothing, gate or no gate'); }); it('P9 — the same target proceeds once the scope is explicitly approved', async () => { const home = join(dir, 'home'); const userConfig = join(home, '.claude'); await mkdir(userConfig, { recursive: true }); const userFile = join(userConfig, 'CLAUDE.md'); await writeFile(userFile, FIXTURE, 'utf-8'); const result = await applySubtraction( [{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }], { repoRoot: repo, home, approveScope: true }, ); assert.equal(result.requiresApproval, true, 'the gate still reports what it classified'); assert.equal(result.applied.length, 1); assert.ok(!(await readFile(userFile, 'utf-8')).includes(BLOCK_A)); }); it('P10 — the backup must cover the file that is actually written, not merely exist', async () => { const result = await applySubtraction([removal(5, 5, BLOCK_A)], { repoRoot: repo }); // createBackup() skips a path that does not exist and still returns a // manifest and an id, so "a backup was made" is not evidence (M-BUG-31). assert.ok(result.backupId, 'a backup id is expected'); const manifest = parseManifest( await readFile(join(TEST_BACKUP_ROOT, result.backupId, 'manifest.yaml'), 'utf-8'), ); const covered = manifest.files.map((f) => f.originalPath); for (const written of result.filesWritten) { assert.ok(covered.includes(written), `backup does not cover ${written}`); } // …and the copy holds the PRE-removal bytes, which is what makes rollback real. const copy = manifest.files.find((f) => f.originalPath === file); assert.equal( await readFile(join(TEST_BACKUP_ROOT, result.backupId, 'files', copy.backupPath.replace('./files/', '')), 'utf-8'), FIXTURE, ); }); it('P10 — aborts before any write when the backup cannot cover a target', async () => { // A target that vanishes between approval and write: createBackup() would // skip it silently, so the engine must refuse rather than write unbacked. const ghost = join(repo, 'GONE.md'); const result = await applySubtraction( [removal(5, 5, BLOCK_A), { file: ghost, line: 1, endLine: 1, text: 'x' }], { repoRoot: repo }, ); assert.equal(result.filesWritten.length, 0, 'nothing may be written'); assert.equal(await readFile(file, 'utf-8'), FIXTURE, 'the healthy file must be untouched too'); assert.ok(result.refused.some((r) => r.reason === 'unreadable')); }); it('refuses everything when nothing survives validation, and leaves no backup behind', async () => { const result = await applySubtraction([removal(7, 7, BLOCK_FLOOR)], { repoRoot: repo }); assert.equal(result.applied.length, 0); assert.equal(result.backupId, null, 'no backup for a run that writes nothing'); assert.equal(await readFile(file, 'utf-8'), FIXTURE); }); });