/** * 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 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); }); });