config-audit/tests/commands/subtraction-write-shape.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

163 lines
6.6 KiB
JavaScript

/**
* 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 <path> 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.',
);
});