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
187 lines
7.9 KiB
JavaScript
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.',
|
|
);
|
|
});
|