config-audit/tests/commands/subtraction-write-shape.test.mjs
Kjell Tore Guttormsen 33bfd5ff5b fix(commands): the removal gate was classified against the wrong root
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
2026-08-10 06:15:25 +02:00

187 lines
7.9 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('no caller classifies the write against the path it scanned', async () => {
// Measured (#63): `--repo "<target-path>"` under `--global` hands the CLI
// `~/.claude` as the session root, so `~/.claude/CLAUDE.md` classifies
// `in-repo` and the gate drops to `silent` — 29 removals applied with no
// approval asked. `--repo` is what a target is classified AGAINST; it is the
// session's own root. Every other gated template already passes `$PWD`.
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
for (const line of content.split('\n')) {
// Invocations only. Prose that merely names the CLI (the Notes section
// explaining why removal is not a `fix` action) carries no argv, and
// matching it made this guard red against its own fixed template.
if (!/^node\s.*subtraction-write-cli\.mjs/.test(line.trim())) continue;
const repoArg = line.match(/--repo\s+("[^"]*"|\S+)/);
assert.ok(repoArg, `${name} must pass --repo explicitly on every removal-CLI invocation.`);
assert.equal(
repoArg[1],
'"$PWD"',
`${name} passes ${repoArg[1]} as --repo. Anything but the session root can classify a\n` +
'machine-wide target as in-repo and silently downgrade the strongest gate on this axis.',
);
}
}
});
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.',
);
});