config-audit/tests/scanners/subtraction-write-cli.test.mjs
Kjell Tore Guttormsen 000e47f9d2 feat(scanners): the subtraction axis can now remove what it proposes (SUB-WRITE)
`optimize --subtract` has only ever proposed. `--apply` executes the blocks the
operator picks, behind a backup whose coverage is verified and a scope gate the
engine enforces rather than describes.

The open design decision from plan §C6 was settled by two measurements, not by
taste. It is NOT a fix-engine action: the subtraction axis appears nowhere in
scan-orchestrator or optimization-lens-scanner, so verifyFixes' re-scan would
mark every removal `verified` whether or not it happened -- a success-shaped
no-op, the same shape that made restoreBackup silently do nothing. It is NOT a
plan/implement step either: that pipeline needs a finding code, and OPT declares
exactly one, for the deterministic check.

The approval artifact is written by main context, not by the lens agent. That is
where the operator's decision actually happens, and it keeps the feature off the
still-unmeasured agent write surface (M-BUG-18 lists optimize as open).

Three properties are load-bearing, and each was seen red against its own defect:
removals validate against the ORIGINAL content and apply in descending line
order; the range check is not redundant with the text check (`line: 0` makes
`slice(-1, 0)` empty, so an empty text MATCHES and `splice(-1, 1)` deletes the
file's last line); and createBackup skips a nonexistent path while still
returning an id, so manifest coverage is asserted before a byte changes.

Two guards were green on their own defect and were fixed after measuring:
`/\b80\s*%\b/` never matches "80% of the file" -- `%` is a non-word character, so
the trailing `\b` demands a word character next. And the caller-arm sweep passed
vacuously against HEAD, iterating an empty list; only the added non-emptiness
assertion caught it.

The floor is repeated, not moved: floor-exclusion still vetoes before anything is
proposed, and the engine refuses a load-bearing block again so a hand-built
approval cannot route around it. `mv` to `_archive/` is a file-level rule and
does not apply to a block excision -- the timestamped backup is the recovery
artifact, and a second copy with no restorer would be worse than none.

strongestGate moves into write-scope.mjs so the gate ordering has one owner.

Dogfooded DRY-RUN against the real ~/.claude/CLAUDE.md: 29 candidates, gate
refused all 29 with exit 0 until the scope was approved, then 29/29 spans
validated with nothing written. ~789 tokens, ~18% of the file -- corroborating
the #40 fasit's ~850, and well short of what a deletion feature is tempted to
promise.

Suite 1625 -> 1659/0. Frozen v5.0.0 and default-output baselines untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
2026-08-10 06:10:42 +02:00

166 lines
6.7 KiB
JavaScript

/**
* subtraction-write CLI — the exit-code contract and the payload the command
* template acts on (#63).
*
* The contract worth defending here is the one #62 settled: a gated write is a
* VERDICT, not a tool failure. Exit 3 means the CLI could not do its job; "this
* removal would touch your machine-wide config, approve it first" is an answer,
* and it has to arrive in the payload — a command that runs everything as
* `--output-file <path> 2>/dev/null` cannot act on anything that only reached
* stderr.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join, resolve, dirname } from 'node:path';
import { mkdtemp, readFile, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI = resolve(__dirname, '..', '..', 'scanners', 'subtraction-write-cli.mjs');
const BLOCK_A = '- Always write tests before code, and never skip the failing step.';
const FIXTURE = ['# Project', '', '## Rules', '', BLOCK_A, '', '## End', ''].join('\n');
let dir;
let repo;
let file;
let approvedPath;
let outPath;
let env;
/** Run the CLI with a home and backup root that are never the operator's. */
function run(argv) {
return new Promise((res) => {
const child = spawn(process.execPath, [CLI, ...argv], { cwd: repo, env });
let stderr = '';
let stdout = '';
child.stderr.on('data', (d) => { stderr += d; });
child.stdout.on('data', (d) => { stdout += d; });
child.on('close', (code) => res({ code, stdout, stderr }));
});
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'config-audit-subwrite-cli-'));
repo = join(dir, 'repo');
await mkdir(join(repo, '.git'), { recursive: true });
file = join(repo, 'CLAUDE.md');
await writeFile(file, FIXTURE, 'utf-8');
approvedPath = join(dir, 'approved.json');
outPath = join(dir, 'result.json');
env = {
...process.env,
HOME: join(dir, 'home'),
CONFIG_AUDIT_BACKUP_ROOT: join(dir, 'backups'),
};
await mkdir(join(dir, 'home', '.claude'), { recursive: true });
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
async function writeApproval(removals) {
await writeFile(approvedPath, JSON.stringify({ sessionId: 'test', removals }), 'utf-8');
}
describe('subtraction-write-cli', () => {
it('applies an approved removal and reports it in the payload', async () => {
await writeApproval([{ file, line: 5, endLine: 5, text: BLOCK_A }]);
const { code } = await run(['--approved', approvedPath, '--repo', repo, '--output-file', outPath]);
assert.equal(code, 0);
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
assert.equal(payload.counts.applied, 1);
assert.equal(payload.counts.filesWritten, 1);
assert.ok(payload.backupId, 'a verified backup must precede the write');
assert.equal(payload.applied[0].text, BLOCK_A, 'the receipt carries what left the file');
assert.ok(!(await readFile(file, 'utf-8')).includes(BLOCK_A));
});
it('gates a machine-wide target with exit 0 and a disclosure, writing nothing', async () => {
const userFile = join(dir, 'home', '.claude', 'CLAUDE.md');
await writeFile(userFile, FIXTURE, 'utf-8');
await writeApproval([{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }]);
const { code } = await run(['--approved', approvedPath, '--repo', repo, '--output-file', outPath]);
assert.equal(code, 0, 'a gated write is a verdict about a write, never exit 3 (#62)');
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
assert.equal(payload.gate, 'require-ok');
assert.equal(payload.requiresApproval, true);
assert.ok(
payload.disclosures.some((d) => /machine-wide/i.test(d)),
'the payload must carry WHY, not just that it refused',
);
assert.equal(payload.counts.applied, 0);
assert.equal(payload.counts.filesWritten, 0);
assert.equal(await readFile(userFile, 'utf-8'), FIXTURE);
});
it('proceeds on that same target with --approve-scope', async () => {
const userFile = join(dir, 'home', '.claude', 'CLAUDE.md');
await writeFile(userFile, FIXTURE, 'utf-8');
await writeApproval([{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }]);
const { code } = await run([
'--approved', approvedPath, '--repo', repo, '--approve-scope', '--output-file', outPath,
]);
assert.equal(code, 0);
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
assert.equal(payload.counts.applied, 1);
assert.ok(!(await readFile(userFile, 'utf-8')).includes(BLOCK_A));
});
it('--dry-run reports the removal and leaves the file alone', async () => {
await writeApproval([{ file, line: 5, endLine: 5, text: BLOCK_A }]);
const { code } = await run([
'--approved', approvedPath, '--repo', repo, '--dry-run', '--output-file', outPath,
]);
assert.equal(code, 0);
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
assert.equal(payload.meta.dryRun, true);
assert.equal(payload.counts.applied, 1);
assert.equal(payload.counts.filesWritten, 0);
assert.equal(payload.backupId, null);
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
});
it('a stale approval is refused in the payload, not as a tool error', async () => {
await writeApproval([{ file, line: 5, endLine: 5, text: '- A block that is not there.' }]);
const { code } = await run(['--approved', approvedPath, '--repo', repo, '--output-file', outPath]);
assert.equal(code, 0);
const payload = JSON.parse(await readFile(outPath, 'utf-8'));
assert.equal(payload.counts.applied, 0);
assert.equal(payload.refused[0].reason, 'block-mismatch');
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
});
it('exits 3 without --approved', async () => {
const { code, stderr } = await run(['--repo', repo]);
assert.equal(code, 3);
assert.match(stderr, /--approved/);
});
it('exits 3 on an approval file with no removals — an empty set is not "all done"', async () => {
await writeFile(approvedPath, JSON.stringify({ removals: [] }), 'utf-8');
const { code, stderr } = await run(['--approved', approvedPath, '--repo', repo]);
assert.equal(code, 3);
assert.match(stderr, /removals/);
});
it('exits 3 on a removal missing its text — an unverifiable approval is not a licence to delete', async () => {
await writeApproval([{ file, line: 5, endLine: 5 }]);
const { code, stderr } = await run(['--approved', approvedPath, '--repo', repo]);
assert.equal(code, 3);
assert.match(stderr, /text/);
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
});
});