Found by review after the SUB-WRITE commit, and both defects were in the template rather than the engine every prediction in the fasit was about. `--repo` is what a write target is classified AGAINST. The template passed the SCAN target, and under `--global` that target IS ~/.claude -- so ~/.claude/CLAUDE.md matched `in-repo` and the gate went `silent`. Measured against the real config: gate silent, scopeClass in-repo, 29 removals applied with no approval asked. That is the same silent downgrade #62 measured for a naive .git-upward walk, arriving through a different door, on the one target this chunk was sequenced behind M-BUG-41 to protect. Every other gated template already passed `--repo "$PWD"`; this one was the only outlier. The dry run also could not validate the machine-wide case -- the case that is mandatory in v1. The gate returned before any file was read, so a dry run there reported 29 scope-gate refusals and zero checked spans, and the first run able to find a stale approval would have been the one that writes. A gate guards a WRITE, and a dry run is not one: `requiresApproval` and the disclosures are still reported, so the operator is still asked. The new caller-arm guard was itself red against the corrected template, matching prose that merely NAMES the CLI. Narrowed to lines that invoke it. Guards seen red against the original defects: `--repo "<target-path>"` red, `--repo` omitted red, gate-blocks-dry-run red. Re-dogfooded as the template now calls it: require-ok / user-scope / 29 spans validated / 0 files written. Suite 1659 -> 1662/0. Frozen baselines untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|