/** * SUB-WRITE caller arm — `optimize --subtract --apply` (#63). * * #45/#46/#47 all taught the same lesson: fixing a CLI does not fix the command * that reads its payload. A gate the engine enforces is worth nothing to the * user if the template never renders the disclosure, and a refusal the payload * reports is invisible if the template only ever prints successes. * * This arm is deliberately NOT folded into `write-scope-gate-shape.test.mjs`. * Those five commands classify their targets with `write-scope-cli.mjs` and * then honour the answer in prose; this one hands its targets to a CLI that * refuses the write itself. Requiring it to ALSO call `write-scope-cli.mjs` * would classify the same paths twice, which is the copy the class table exists * to prevent. */ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFile, readdir } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands'); const optimizeMd = async () => await readFile(resolve(COMMANDS_DIR, 'optimize.md'), 'utf-8'); /** * Completeness derived from the catalog rather than from a literal: it is the * NEXT command to drive the removal CLI that is at risk, not this one (#57/#62 * — a hand-maintained sweep list is a premise, not a measurement). */ async function commandsDrivingTheRemovalCli() { const out = []; for (const name of await readdir(COMMANDS_DIR)) { if (!name.endsWith('.md')) continue; const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8'); if (content.includes('subtraction-write-cli.mjs')) out.push({ name, content }); } return out; } test('the removal CLI has at least one caller — otherwise this whole arm is vacuous', async () => { const callers = await commandsDrivingTheRemovalCli(); assert.ok( callers.length >= 1, 'No command drives subtraction-write-cli.mjs. Every assertion below would pass over an\n' + 'empty list, which is how a caller-arm guard goes green on a feature nobody can reach.', ); }); test('every caller anchors the CLI and keeps its payload off the screen', async () => { for (const { name, content } of await commandsDrivingTheRemovalCli()) { assert.match( content, /\$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/subtraction-write-cli\.mjs/, `${name} must anchor the CLI at \${CLAUDE_PLUGIN_ROOT} — a relative path resolves against\n` + "the user's working directory, and this one deletes configuration.", ); assert.match( content, /subtraction-write-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/, `${name} must invoke it as \`--output-file 2>/dev/null\` (ux-rules rule 2).`, ); } }); test('every caller dry-runs before it writes', async () => { for (const { name, content } of await commandsDrivingTheRemovalCli()) { assert.match( content, /--dry-run/, `${name} writes without proving the spans still match first. A stale approval is the\n` + 'expected case here — the file may have been edited since the scan.', ); } }); test('every caller surfaces the scope gate in the user\'s words', async () => { for (const { name, content } of await commandsDrivingTheRemovalCli()) { assert.match( content, /requiresApproval/, `${name} must branch on \`requiresApproval\`. The engine refuses the write, but a template\n` + 'that never asks leaves the user staring at a run that did nothing.', ); assert.match( content, /disclosures/, `${name} must render the payload's \`disclosures[]\` verbatim. Wording paraphrased per\n` + 'command is a policy copy that drifts.', ); // Whitespace-tolerant: markdown wraps, and a bare space would let line // length decide green/red (#62, [[guard-can-be-green-on-its-own-defect]]). assert.match( content, /every\s+project/, `${name} must say, in words, that a machine-wide removal costs and saves in every project.\n` + 'The class name alone is vocabulary the user has not been taught.', ); } }); test('every caller reports refusals with their reason, not just successes', async () => { for (const { name, content } of await commandsDrivingTheRemovalCli()) { assert.match( content, /refused/, `${name} must report the payload's \`refused\` entries. A removal silently dropped reads\n` + 'as a removal that happened.', ); for (const reason of ['block-mismatch', 'floor']) { assert.ok( content.includes(reason), `${name} must explain \`${reason}\` — the two reasons a user can actually act on. One\n` + 'means re-run the scan, the other means the block is load-bearing and never goes.', ); } } }); test('every caller tells the user how to undo the removal', async () => { for (const { name, content } of await commandsDrivingTheRemovalCli()) { assert.match( content, /backupId/, `${name} must surface the backup id from the payload.`, ); assert.match( content, /config-audit\s+rollback/, `${name} must name the command that restores the file. A backup nobody is told about is\n` + 'not a safety net.', ); } }); test('the subtraction copy does not oversell the saving', async () => { // Measured in the #40 fasit: ~1 400 deletable tokens, ~850 after tier-2 // earn-backs, against a ~4 300-token file — a fifth, not most of it. Copy // that implies more is a defect of this feature, not a rounding difference. const content = await optimizeMd(); // No trailing `\b` after the percent alternative: `%` is a non-word // character, so `\b` there demands a word character NEXT — and "80% of the // file" has a space. Measured green against exactly that mutation before the // anchor was dropped; the same ASCII-only `\b` trap as `/\bunngå\b/`. assert.doesNotMatch( content, /most\s+of\s+(?:the|your)\s+(?:file|config)|majority\s+of\s+(?:the|your)\s+file|\b(?:[5-9]\d|100)\s*%/i, 'optimize.md implies the subtraction axis removes most of a CLAUDE.md. The measured figure\n' + 'is around a fifth, and the honest number is the whole point of a deletion feature.', ); }); test('optimize.md still says what runs without --apply', async () => { const content = await optimizeMd(); assert.match( content, /Without\s+`--apply`,\s+no\s+files\s+are\s+modified/, 'The default must stay stated: `--subtract` alone proposes. A reader who skims the flag\n' + 'list needs to know which half of the axis writes.', ); });