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
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 06:10:42 +02:00
commit 000e47f9d2
11 changed files with 1103 additions and 26 deletions

View file

@ -17,7 +17,7 @@ Per-command flags, patterns, and feature lists live in `README.md` and `/config-
| `/config-audit tokens` | Prompt-cache-aware token hotspots, each tagged with its load pattern; cache-aware |
| `/config-audit manifest` | Ranked table of every token source + always-loaded subtotal |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only |
| `/config-audit optimize` | Mechanism-fit lens (procedure→skill, lifecycle→hook, path→rule, never→permission). Agent-driven, **not byte-stable**. `--subtract` adds the subtraction axis (what no longer earns its always-loaded rent, `BP-SUB-001`) — opt-in, proposes only; `--subtract --apply` executes the removals the operator picks |
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
| `/config-audit rollback` | Restore configuration from backup |
| `/config-audit plan` | Create action plan from findings |
@ -97,6 +97,8 @@ Coding style: scanners are zero-dependency Node ESM; new findings use the `CA-{S
**Subtraction floor (invariant).** `optimize --subtract` is the only lens that proposes removing config, so `scanners/lib/floor-exclusion.mjs` runs as a deterministic pre-step *before* the judge — a load-bearing block is never a candidate, and that guarantee must not be moved into the agent prompt. Two rules follow from it: (1) **staleness is not a deletion signal** — an outdated version pin inside a floor block is a `drift`/`CA-CML` dead-reference concern; (2) **tier 2 ≠ tier 3** — a compensatory block that keeps earning its place returns, and reporting it as dead weight is wrong even when the label matches. Norwegian keywords need the Unicode boundaries in `subtraction-prefilter.mjs`; JS `\b` is ASCII-only, so `/\bunngå\b/` silently never matches.
**Subtraction write path (invariant).** `--apply` routes through `scanners/lib/subtraction-write.mjs`, never through `fix-engine` or the `plan`/`implement` pipeline, and both exclusions are **measured**: the subtraction axis is absent from the orchestrated envelope, so `verifyFixes`' re-scan would mark every removal `verified` whether or not it happened (a success-shaped no-op), and the findings pipeline needs a finding code — which names a deterministic check, not a prose judgement. Three properties are load-bearing and each has a guard seen red against its own defect: removals are validated against the ORIGINAL content and applied in **descending** line order (an ascending pass shifts later spans out from under themselves); 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 coverage of every file about to be written is **asserted from the manifest** before a byte changes. The floor is *repeated* here, 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. The archive rule (`mv` to `_archive/`) is file-level and does not apply to a block excision — the timestamped backup is the recovery artifact, and inventing a second copy with no restorer behind it would be worse than none.
## Testing
```bash

View file

@ -264,7 +264,8 @@ Your team configuration changes over time. Track it:
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
| `/config-audit optimize --subtract` | **Subtraction lens** — the inverse question no other command asks: what no longer earns its always-loaded rent? Ranks CLAUDE.md blocks that correct general model *behaviour* rather than stating a local fact, split into **dead** (never missed) and **earned** (returns if the model stumbles), with the token payoff (`BP-SUB-001`). **Load-bearing local facts are excluded deterministically before the judge sees anything** — remotes, versions, paths, filenames, policy invariants and unresolvable entity names are never candidates, and an ordered list is treated as a contract. Opt-in, proposes only, never writes. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
| `/config-audit optimize --subtract` | **Subtraction lens** — the inverse question no other command asks: what no longer earns its always-loaded rent? Ranks CLAUDE.md blocks that correct general model *behaviour* rather than stating a local fact, split into **dead** (never missed) and **earned** (returns if the model stumbles), with the token payoff (`BP-SUB-001`). **Load-bearing local facts are excluded deterministically before the judge sees anything** — remotes, versions, paths, filenames, policy invariants and unresolvable entity names are never candidates, and an ordered list is treated as a contract. Opt-in and proposes only; add `--apply` to execute the removals you pick. Pair with `--global` to reach the user-level CLAUDE.md, where the always-loaded cost actually sits |
| `/config-audit optimize --subtract --apply` | **Execute approved removals.** You pick which blocks go by number; nothing is inferred. Every removal is checked against the file as it reads *now* — an approval that no longer matches is refused rather than applied to whatever moved into those lines — and the floor is re-asserted at write time, so a load-bearing block cannot be removed even by a hand-built approval. A dry run always precedes the write, the backup's manifest is verified to cover the file being written before a byte changes, and `/config-audit rollback` restores it. A removal targeting your machine-wide `~/.claude/CLAUDE.md` is **refused until you approve that scope explicitly** — it costs, and saves, in every project on every turn |
| `/config-audit fix` | Auto-fix deterministic issues with backup + verification |
| `/config-audit rollback` | Restore configuration from a previous backup |
| `/config-audit plan` | Generate prioritized action plan from audit findings |
@ -606,6 +607,15 @@ classification), or when it names a capitalized entity the mechanism cannot reso
dictionary. That last rule is a deliberate conservative default: it declines to decide and
keeps the block, paying in recall rather than risk.
The write half (`--apply`) keeps the same asymmetry. It is not a `fix` action and not a
`plan`/`implement` step, and both exclusions are measurements rather than preferences: the
subtraction axis never enters the orchestrated envelope, so `fix`'s re-scan verification would
report every removal as verified whether or not it happened — a success-shaped no-op — and the
findings pipeline expects a finding code, which by invariant names a deterministic check, not a
prose judgement. `scanners/lib/subtraction-write.mjs` owns the execution instead, and it
re-asserts the floor rather than trusting that the pre-filter already did: the veto stays where
it is, and is simply repeated as the last red line before the delete.
Granularity is the **leaf block** — one list item including its wrapped continuation lines, or
one paragraph — with two structural exceptions: a paragraph ending in `:` merges with the list
it introduces, and an *ordered* list is treated as a contract whose steps inherit floor from

View file

@ -35,7 +35,16 @@ opportunities" — that is a good result, not a failure.
Split `$ARGUMENTS` into a path (first non-flag argument; default: current working
directory) and flags. Recognized flags: `--global` (include the user `~/.claude`
cascade in discovery) and `--subtract` (add the subtraction axis, below).
cascade in discovery), `--subtract` (add the subtraction axis, below) and
`--apply` (execute approved removals — Step 7).
`--apply` only means anything alongside `--subtract`. If it is present without
it, say so and continue with the ordinary lens run:
```
`--apply` executes approved subtraction removals, so it needs `--subtract` too.
Running the ordinary lens; re-run with `--subtract --apply` to remove anything.
```
**`--subtract` — the inverse question.** Every other lens asks what to *add* or
*move*; this one asks what no longer earns its always-loaded rent. It is opt-in
@ -125,7 +134,72 @@ If the agent kept nothing from the candidates (all dropped) but there were
deterministic findings, show those; if it kept nothing at all, show the clean
result from Step 3.
### Step 6: Next steps
### Step 7: Apply approved removals (`--subtract --apply` only)
Skip this step entirely unless BOTH flags are present and the agent kept at
least one subtraction finding. Removal is the only thing this plugin does that
takes configuration away, so nothing here happens without a named choice.
**7a — show what is on the table, with honest sizing.** List the kept
subtraction findings numbered, each with its file, line span and first line of
text. Do not imply a bigger win than there is:
```
Removing all of these saves roughly {n} tokens per turn — on a typical
always-loaded CLAUDE.md that is around a fifth of the file, not most of it.
```
Ask which to remove: numbers, `all`, or `none`. `none` ends the command.
**7b — write the approval file.** With the **Write** tool, write the operator's
choice to `~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json`
(absolute path — a relative one resolves against the user's CWD). Take `file`,
`line`, `endLine` and `signalText` verbatim from the Step 3 payload; `text` must
be the `signalText` byte-for-byte, because the engine refuses a removal whose
text no longer matches the file:
```json
{ "sessionId": "{session-id}",
"removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
```
**7c — dry run first.** Always. It costs one call and proves the spans still
match before anything is written:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "<target-path>" --dry-run --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-dryrun.json 2>/dev/null; echo $?
```
Read the payload. Exit 3 is a real error (bad or unreadable approval file).
Report any `refused` entry with its `reason` before going further —
`block-mismatch` means the file changed since the scan (re-run the lens),
`floor` means the block is load-bearing and will never be removable.
**7d — the scope gate.** If the dry-run payload has `requiresApproval: true`,
show every line in `disclosures` verbatim and ask for an explicit go-ahead. This
is the machine-wide case (`~/.claude/CLAUDE.md`): the change costs — and saves —
in every project, on every turn, so it is not the same decision as editing the
CLAUDE.md in front of you. Without a clear yes, stop here.
**7e — apply.** Same command without `--dry-run`, adding `--approve-scope` only
if the operator gave that go-ahead in 7d:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/subtraction-write-cli.mjs --approved ~/.claude/config-audit/sessions/{session-id}/subtraction-approved.json --repo "<target-path>" --output-file ~/.claude/config-audit/sessions/{session-id}/subtraction-result.json 2>/dev/null; echo $?
```
**7f — report.** From the result payload, tell the user: what was removed (file
+ line span + the text, from `applied`), what was refused and why (`refused`
with `reason`), and how to undo it:
```
Backed up as {backupId} — `/config-audit rollback {backupId}` restores every
file exactly as it was.
```
Never report a run as successful when `counts.applied` is 0.
### Step 8: Next steps
End with context-sensitive next steps, explaining WHY each is useful:
@ -139,12 +213,20 @@ End with context-sensitive next steps, explaining WHY each is useful:
- This command is **agent-driven and not byte-stable** — its output is a
human-facing report, deliberately outside the deterministic snapshot suite.
- `--subtract` **proposes, never writes.** Nothing is deleted; act on a finding
via `/config-audit plan``/config-audit implement` (backup + rollback).
- `--subtract` **proposes; only `--apply` writes**, and only blocks the operator
named. Every removal is preceded by a backup whose manifest is verified to
cover the file being written, and `/config-audit rollback` restores it.
- **Removal is not a `fix` action and not a `plan`/`implement` step**, by
measurement rather than preference: the subtraction axis never enters the
orchestrated envelope, so `fix`'s re-scan verification would mark every
removal verified whether or not it happened, and the findings pipeline would
require a finding code — which names a deterministic check, not a prose
judgement. `subtraction-write-cli.mjs` owns the execution instead.
- The subtraction floor is deterministic and runs *before* the agent, so a
load-bearing block is never a candidate. It errs toward keeping: on a
well-maintained config this axis is mostly a no-op, and that is a good result.
- The deterministic half (CA-OPT-001) also rides in the normal orchestrated
audit; this command adds the prose-judgment half on top.
- No files are modified. To act on a finding, use `/config-audit plan`
`/config-audit implement` (backup + rollback) or edit by hand.
- Without `--apply`, no files are modified. To act on a mechanism-fit finding,
use `/config-audit plan``/config-audit implement` (backup + rollback) or
edit by hand.

View file

@ -0,0 +1,235 @@
/**
* subtraction-write the write half of `optimize --subtract` (§C6, chunk #63).
*
* This is the only path in the plugin that REMOVES configuration, so the split
* of labour matters more here than anywhere else: **the judgement is the
* agent's, the execution is deterministic.** Everything below is mechanical
* it verifies that the block it was told to remove is still exactly the block
* that is there, and refuses otherwise.
*
* ## Why this is not a `fix-engine` action
*
* Measured (#63): the subtraction axis appears nowhere in `scan-orchestrator`
* or `optimization-lens-scanner` it is computed inside `optimize-lens-cli`
* under `--subtract`. `fix-engine.verifyFixes()` marks a fix `verified` when
* the finding is absent from a re-scan, so a subtraction removal would be
* verified **whether or not the write happened**: a success-shaped no-op, the
* class that made `restoreBackup` silently do nothing before `parseManifest`
* learned the second manifest format. And `planFixes` keys on
* `finding.autoFixable` + `finding.title` from an envelope, neither of which an
* agent prose judgement has.
*
* Nor is it a `plan`/`implement` step: that pipeline runs on findings, and
* `finding-codes.mjs` declares exactly one `OPT` code for the deterministic
* check. Minting a code for a prose judgement breaks that module's invariant
* that a code names a deterministic CHECK.
*
* ## The floor, repeated rather than moved
*
* §C6 forbids migrating the floor into the write path. That forbids *moving*
* the veto, not *repeating* it: `subtraction-prefilter` still consults
* `floor-exclusion` before anything is ever proposed, and this module refuses a
* load-bearing block again as the last red line before an irreversible-by-
* reading delete. A caller that hand-builds an approval therefore cannot route
* around the floor.
*
* ## The archive question
*
* "`mv` to `_archive/`, never `rm`" is a FILE-level rule; nothing here deletes
* a file. The timestamped backup is the recovery artifact it holds the whole
* pre-removal file and `rollback` already restores it. The removed text also
* rides back in the payload so the caller can show and log it. A second archive
* copy with no restorer behind it would be worse than none.
*
* Zero external dependencies.
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { isLoadBearing } from './floor-exclusion.mjs';
import { createBackup } from './backup.mjs';
import { classifyWriteTarget, strongestGate } from './write-scope.mjs';
/** Why a removal did not happen. Every refusal carries exactly one of these. */
export const REFUSAL_REASONS = Object.freeze({
/** The file no longer reads the way the approval says it does. */
BLOCK_MISMATCH: 'block-mismatch',
/** `floor-exclusion` vetoes the block — never removable, at any layer. */
FLOOR: 'floor',
/** The target's scope class needs an explicit go-ahead that was not given. */
SCOPE_GATE: 'scope-gate',
/** The file could not be read, so it can be neither backed up nor excised. */
UNREADABLE: 'unreadable',
/** The backup does not cover a file the run was about to write. */
BACKUP_INCOMPLETE: 'backup-incomplete',
});
/** True for a line that is empty or whitespace only. */
const isBlank = (line) => line === undefined || /^\s*$/.test(line);
/**
* Remove approved blocks from one file's content.
*
* Pure: no filesystem, no clock. Every span is validated against the ORIGINAL
* content and the removals are then applied in descending line order, so an
* earlier removal cannot shift a later span out from under itself the shape
* that made `fix-engine` apply a file-rename before a fix that still addressed
* the old path, failing with ENOENT while the run exited 0.
*
* @param {string} content - The file as it is on disk right now.
* @param {Array<{line:number, endLine:number, text:string}>} removals
* @returns {{content: string, applied: object[], refused: object[]}}
*/
export function exciseBlocks(content, removals) {
const lines = content.split('\n');
const applied = [];
const refused = [];
for (const removal of removals) {
const { line, endLine } = removal;
const inRange =
Number.isInteger(line) && Number.isInteger(endLine) &&
line >= 1 && endLine >= line && endLine <= lines.length;
if (!inRange) {
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
continue;
}
// The pre-filter reports `text: block.text.trim()`, so compare trimmed —
// a raw slice comparison would refuse every genuine approval.
const actual = lines.slice(line - 1, endLine).join('\n');
if (actual.trim() !== String(removal.text ?? '').trim()) {
refused.push({ ...removal, reason: REFUSAL_REASONS.BLOCK_MISMATCH });
continue;
}
// Judged on what is actually in the file, not on what the caller claims is.
if (isLoadBearing(actual)) {
refused.push({ ...removal, reason: REFUSAL_REASONS.FLOOR });
continue;
}
applied.push({ ...removal, text: actual.trim() });
}
const descending = [...applied].sort((a, b) => b.line - a.line);
for (const { line, endLine } of descending) {
lines.splice(line - 1, endLine - line + 1);
// A leaf block sits between blank lines; removing it leaves two in a row.
if (line >= 2 && isBlank(lines[line - 2]) && isBlank(lines[line - 1])) {
lines.splice(line - 1, 1);
}
}
return { content: lines.join('\n'), applied, refused };
}
/**
* Apply an approved subtraction set to disk, behind the scope gate and a
* verified backup.
*
* The run is all-or-nothing across files: a target that cannot be read aborts
* the whole set rather than applying half of one operator decision.
*
* @param {Array<{file:string, line:number, endLine:number, text:string}>} removals
* @param {object} [opts]
* @param {string|null} [opts.repoRoot] - Repo root the session stands in.
* @param {boolean} [opts.approveScope=false] - Operator's explicit go-ahead for a `require-ok` target.
* @param {boolean} [opts.dryRun=false]
* @param {string} [opts.home] - Home override, for tests.
* @returns {Promise<object>} Verdict payload never throws for a refused write.
*/
export async function applySubtraction(removals, opts = {}) {
const { repoRoot = null, approveScope = false, dryRun = false, home } = opts;
const normalized = removals.map((r) => ({ ...r, file: resolve(r.file) }));
const files = [...new Set(normalized.map((r) => r.file))];
const classifyOpts = home ? { home } : {};
const targets = files.map((f) => classifyWriteTarget(f, repoRoot, classifyOpts));
const gate = strongestGate(targets);
const disclosures = [...new Set(targets.map((t) => t.disclosure).filter(Boolean))];
const base = {
gate,
requiresApproval: gate === 'require-ok',
disclosures,
targets,
dryRun,
backupId: null,
applied: [],
refused: [],
filesWritten: [],
};
// The gate is a verdict about a write, not a tool failure: the caller renders
// the disclosure and asks. Nothing is written, and nothing is exit 3 (#62).
if (gate === 'require-ok' && !approveScope) {
return {
...base,
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.SCOPE_GATE })),
};
}
const contents = new Map();
const unreadable = [];
for (const file of files) {
try {
contents.set(file, await readFile(file, 'utf-8'));
} catch {
unreadable.push(file);
}
}
if (unreadable.length > 0) {
return {
...base,
refused: normalized.map((r) => ({
...r,
reason: unreadable.includes(r.file)
? REFUSAL_REASONS.UNREADABLE
: REFUSAL_REASONS.BACKUP_INCOMPLETE,
})),
};
}
const perFile = new Map();
const applied = [];
const refused = [];
for (const file of files) {
const result = exciseBlocks(contents.get(file), normalized.filter((r) => r.file === file));
perFile.set(file, result);
applied.push(...result.applied.map((a) => ({ ...a, file })));
refused.push(...result.refused.map((r) => ({ ...r, file })));
}
const toWrite = files.filter((f) => perFile.get(f).applied.length > 0);
if (dryRun || toWrite.length === 0) {
return { ...base, applied, refused };
}
// `createBackup` skips a path that does not exist and still returns a
// manifest and an id, so "a backup was made" is not evidence that THIS file
// is recoverable (M-BUG-31's shape). Assert coverage before writing anything.
const backup = createBackup(toWrite);
const covered = new Set(backup.manifest.files.map((f) => f.originalPath));
const uncovered = toWrite.filter((f) => !covered.has(f));
if (uncovered.length > 0) {
return {
...base,
backupId: backup.backupId,
applied: [],
refused: normalized.map((r) => ({ ...r, reason: REFUSAL_REASONS.BACKUP_INCOMPLETE })),
};
}
const filesWritten = [];
for (const file of toWrite) {
await writeFile(file, perFile.get(file).content, 'utf-8');
filesWritten.push(file);
}
return { ...base, backupId: backup.backupId, applied, refused, filesWritten };
}

View file

@ -142,6 +142,29 @@ export const SCOPE_CLASSES = {
},
};
/**
* Gate strengths, weakest first. Lives here rather than in a caller: a second
* copy of this ordering would decide, independently, which gate a multi-target
* write shows the drift shape the class table itself exists to prevent.
*/
export const GATE_RANK = ['silent', 'disclose', 'require-ok'];
/**
* The strongest gate among already-classified targets. One `require-ok` target
* in a set drives the whole surface: a run that would write machine-wide config
* does not get to be quiet because most of its other targets are ordinary.
*
* @param {Array<{gate: string}>} targets
* @returns {string} The strongest gate, or 'silent' when there are no targets.
*/
export function strongestGate(targets) {
let worst = 'silent';
for (const t of targets) {
if (GATE_RANK.indexOf(t.gate) > GATE_RANK.indexOf(worst)) worst = t.gate;
}
return worst;
}
/**
* Classify a write target relative to the repo the session stands in.
*

View file

@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* subtraction-write CLI execute an APPROVED subtraction set (§C6, chunk #63).
*
* The one path in the plugin that removes configuration. It takes no judgement
* of its own: it is handed a set of blocks a human approved, and its whole job
* is to refuse anything that no longer matches, is load-bearing, or leaves the
* repo without an explicit go-ahead.
*
* Usage:
* node subtraction-write-cli.mjs --approved <path.json>
* [--repo <session-repo-root>]
* [--approve-scope] [--dry-run]
* [--output-file <path>] [--json]
*
* The approval file is written by MAIN CONTEXT, not by the lens agent
* `optimize.md` renders the candidates, the operator picks, and the command
* materializes the choice. That is where the decision actually happens, and it
* keeps this path off the unverified agent write surface
* ([[subagent-harness-blocks-report-writes]] lists optimize as open).
*
* { "sessionId": "...",
* "removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] }
*
* Exit codes: 0 = verdict, 3 = the CLI could not do its job (bad argv,
* unreadable or malformed approval file).
*
* A gated or refused removal is NOT exit 3. "This write leaves the repo" and
* "that block no longer looks like that" are verdicts about a write, and they
* ride in the payload a command cannot act on something that only ever
* reached stderr (#62, F3's class).
*
* Zero external dependencies.
*/
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { applySubtraction } from './lib/subtraction-write.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--json', '--dry-run', '--approve-scope'],
value: ['--approved', '--repo', '--output-file'],
};
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let approvedPath = null;
let repo = process.cwd();
let outputFile = null;
let jsonMode = false;
let dryRun = false;
let approveScope = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') jsonMode = true;
else if (args[i] === '--dry-run') dryRun = true;
else if (args[i] === '--approve-scope') approveScope = true;
else if (args[i] === '--approved') approvedPath = args[++i];
else if (args[i] === '--repo') repo = args[++i];
else if (args[i] === '--output-file') outputFile = args[++i];
}
if (!approvedPath) {
process.stderr.write('Error: --approved <path> is required\n');
process.exitCode = 3;
return;
}
let approval;
try {
approval = JSON.parse(await readFile(resolve(approvedPath), 'utf-8'));
} catch (err) {
process.stderr.write(`Error: could not read approval file: ${err.message}\n`);
process.exitCode = 3;
return;
}
// Malformed input is a tool error, not a verdict: an empty or wrong-shaped
// approval must not read as "nothing to remove, all done".
if (!Array.isArray(approval.removals) || approval.removals.length === 0) {
process.stderr.write('Error: approval file has no `removals` array\n');
process.exitCode = 3;
return;
}
for (const r of approval.removals) {
if (!r || typeof r.file !== 'string' || typeof r.text !== 'string') {
process.stderr.write('Error: every removal needs `file`, `line`, `endLine` and `text`\n');
process.exitCode = 3;
return;
}
}
const result = await applySubtraction(approval.removals, { repoRoot: repo, dryRun, approveScope });
const payload = {
meta: {
repo: resolve(repo),
sessionId: approval.sessionId || null,
approvedCount: approval.removals.length,
dryRun,
},
gate: result.gate,
requiresApproval: result.requiresApproval,
disclosures: result.disclosures,
targets: result.targets,
backupId: result.backupId,
filesWritten: result.filesWritten,
// The removed text travels back so the caller can show and log exactly what
// left the file. The backup is the recovery artifact; this is the receipt.
applied: result.applied,
refused: result.refused,
counts: {
applied: result.applied.length,
refused: result.refused.length,
filesWritten: result.filesWritten.length,
},
};
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const a of payload.applied) {
process.stdout.write(`${payload.meta.dryRun ? 'would-remove' : 'removed'}\t${a.file}:${a.line}-${a.endLine}\n`);
}
for (const r of payload.refused) {
process.stdout.write(`refused:${r.reason}\t${r.file}:${r.line}-${r.endLine}\n`);
}
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}

View file

@ -28,28 +28,11 @@
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { SCOPE_CLASSES, classifyWriteTarget } from './lib/write-scope.mjs';
import { SCOPE_CLASSES, classifyWriteTarget, strongestGate } from './lib/write-scope.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = { boolean: ['--json'], value: ['--target', '--repo', '--output-file'] };
/** Gate strengths, weakest first. The strongest one present drives the surface. */
const GATE_RANK = ['silent', 'disclose', 'require-ok'];
/**
* Pick the strongest gate among the classified targets.
*
* @param {Array<{gate: string}>} targets
* @returns {string} The strongest gate, or 'silent' when there are no targets.
*/
function strongestGate(targets) {
let worst = 'silent';
for (const t of targets) {
if (GATE_RANK.indexOf(t.gate) > GATE_RANK.indexOf(worst)) worst = t.gate;
}
return worst;
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;

View file

@ -0,0 +1,163 @@
/**
* 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.',
);
});

View file

@ -61,6 +61,7 @@ const GUARDED = [
{ cli: 'whats-active.mjs', argv: [], valueFlag: '--output-file' },
{ cli: 'self-audit.mjs', argv: [], valueFlag: null }, // no value-taking flag
{ cli: 'write-scope-cli.mjs', argv: ['--target', 'x'], valueFlag: '--output-file' },
{ cli: 'subtraction-write-cli.mjs', argv: ['--approved', 'x'], valueFlag: '--output-file' },
];
/**

View file

@ -0,0 +1,166 @@
/**
* 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 <path> 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);
});
});

View file

@ -0,0 +1,265 @@
/**
* SUB-WRITE engine the write half of `optimize --subtract` (#63).
*
* The judgement half is an agent's; this half must be byte-deterministic,
* because it is the only path in the plugin that REMOVES configuration. Each
* test below corresponds to a numbered prediction in
* `docs/subwrite-fasit.local.md` §3, written before the engine existed.
*
* Two measurements settled the design and are re-asserted here by construction:
* the subtraction axis is absent from the orchestrated envelope (so
* `fix-engine.verifyFixes` would have marked every removal `verified` whether
* or not it happened), and `OPT` declares exactly one finding code, for the
* deterministic check. This engine therefore stands on its own.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtemp, readFile, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { exciseBlocks, applySubtraction } from '../../scanners/lib/subtraction-write.mjs';
import { parseManifest } from '../../scanners/lib/backup.mjs';
// Every backup this file creates stays in a temp root — otherwise the suite
// writes into the operator's real ~/.claude/config-audit/backups, where
// cleanupOldBackups() would start deleting genuine backups past MAX_BACKUPS.
const TEST_BACKUP_ROOT = join(tmpdir(), `config-audit-subwrite-backups-${process.pid}`);
process.env.CONFIG_AUDIT_BACKUP_ROOT = TEST_BACKUP_ROOT;
const BLOCK_A = '- Always write tests before code, and never skip the failing step.';
const BLOCK_FLOOR = '- Push to `git.example.test` after every commit.';
const BLOCK_B = '- Be concise and avoid unnecessary explanation in your answers.';
/** Line numbers are 1-based: BLOCK_A = 5, BLOCK_FLOOR = 7, BLOCK_B = 9. */
const FIXTURE = [
'# Project', // 1
'', // 2
'## Rules', // 3
'', // 4
BLOCK_A, // 5
'', // 6
BLOCK_FLOOR, // 7
'', // 8
BLOCK_B, // 9
'', // 10
'## End', // 11
'', // 12
].join('\n');
let dir;
let repo;
let file;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'config-audit-subwrite-'));
// A repo root of its own, so classification lands on `in-repo` unless a test
// deliberately targets somewhere else.
repo = join(dir, 'repo');
await mkdir(join(repo, '.git'), { recursive: true });
file = join(repo, 'CLAUDE.md');
await writeFile(file, FIXTURE, 'utf-8');
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
/** An approval entry as `optimize.md` writes it from the lens payload. */
const removal = (line, endLine, text) => ({ file, line, endLine, text });
describe('exciseBlocks (pure)', () => {
it('P1 — removes a block whose text matches the file at line..endLine', () => {
const result = exciseBlocks(FIXTURE, [removal(5, 5, BLOCK_A)]);
assert.equal(result.refused.length, 0);
assert.equal(result.applied.length, 1);
assert.equal(result.applied[0].text, BLOCK_A);
assert.ok(!result.content.includes(BLOCK_A), 'block A must be gone');
assert.ok(result.content.includes(BLOCK_B), 'block B must survive');
assert.ok(result.content.includes(BLOCK_FLOOR), 'the floor block must survive');
});
it('P2 — refuses when the file no longer matches the approved text', () => {
const drifted = FIXTURE.replace(BLOCK_A, '- Always write tests before code, and never skip it.');
const result = exciseBlocks(drifted, [removal(5, 5, BLOCK_A)]);
assert.equal(result.applied.length, 0);
assert.equal(result.refused.length, 1);
assert.equal(result.refused[0].reason, 'block-mismatch');
assert.equal(result.content, drifted, 'the content must come back byte-identical');
});
it('P3 — refuses an out-of-range span without throwing', () => {
const result = exciseBlocks(FIXTURE, [removal(400, 402, BLOCK_A)]);
assert.equal(result.applied.length, 0);
assert.equal(result.refused[0].reason, 'block-mismatch');
assert.equal(result.content, FIXTURE);
});
it('P3 — a non-positive line is refused by the RANGE check, not by luck', () => {
// Measured (#63): with the range check disabled, the P3 case above stays
// green — `lines.slice(399, 402)` is empty, so the text check refuses it
// anyway and the test passes for the wrong reason. This is the case only
// the range check can catch: `slice(-1, 0)` is also empty, so an empty
// `text` MATCHES, and `splice(-1, 1)` then deletes the file's LAST line.
const result = exciseBlocks(FIXTURE, [removal(0, 0, '')]);
assert.equal(result.applied.length, 0);
assert.equal(result.refused[0].reason, 'block-mismatch');
assert.equal(result.content, FIXTURE, 'no line may be removed from the far end');
});
it('P4 — refuses a load-bearing block fed straight to the engine', () => {
// The pre-filter's veto never ran: this is the engine's own red line, so a
// caller that hand-builds an approval cannot route around the floor.
const result = exciseBlocks(FIXTURE, [removal(7, 7, BLOCK_FLOOR)]);
assert.equal(result.applied.length, 0);
assert.equal(result.refused.length, 1);
assert.equal(result.refused[0].reason, 'floor');
assert.ok(result.content.includes(BLOCK_FLOOR));
});
it('P5 — two blocks in one file: the second span is not shifted by the first removal', () => {
const result = exciseBlocks(FIXTURE, [removal(5, 5, BLOCK_A), removal(9, 9, BLOCK_B)]);
assert.equal(result.refused.length, 0);
assert.equal(result.applied.length, 2);
// Assert the exact surviving text, not merely "does not include": a naive
// ascending implementation removes block A and then whatever slid into
// lines 9..9, which is easy to mistake for success.
assert.equal(
result.content,
['# Project', '', '## Rules', '', BLOCK_FLOOR, '', '## End', ''].join('\n'),
);
});
it('P6 — collapses the double blank line a removal leaves at the seam', () => {
const result = exciseBlocks(FIXTURE, [removal(5, 5, BLOCK_A)]);
assert.ok(!/\n\n\n/.test(result.content), 'no run of two blank lines may survive');
});
it('P12 — a mismatch alongside a valid removal refuses only the mismatch', () => {
const result = exciseBlocks(FIXTURE, [
removal(5, 5, BLOCK_A),
removal(9, 9, '- Something that is not in this file at all.'),
]);
assert.equal(result.applied.length, 1);
assert.equal(result.refused.length, 1);
assert.equal(result.refused[0].reason, 'block-mismatch');
assert.ok(!result.content.includes(BLOCK_A));
assert.ok(result.content.includes(BLOCK_B), 'the refused block stays put');
});
});
describe('applySubtraction (filesystem + gate)', () => {
it('P1 — writes the file and reports the removed text', async () => {
const result = await applySubtraction([removal(5, 5, BLOCK_A)], { repoRoot: repo });
assert.equal(result.applied.length, 1);
assert.equal(result.filesWritten.length, 1);
const after = await readFile(file, 'utf-8');
assert.ok(!after.includes(BLOCK_A));
assert.ok(after.includes(BLOCK_B));
});
it('P7 — a dry run writes nothing and creates no backup', async () => {
const result = await applySubtraction([removal(5, 5, BLOCK_A)], {
repoRoot: repo,
dryRun: true,
});
assert.equal(result.dryRun, true);
assert.equal(result.backupId, null);
assert.equal(result.filesWritten.length, 0);
assert.equal(result.applied.length, 1, 'it still reports what WOULD be removed');
assert.equal(await readFile(file, 'utf-8'), FIXTURE, 'the file must be untouched');
});
it('P8 — a require-ok target without approval writes nothing, and that is exit-0 territory', async () => {
// `~/.claude/CLAUDE.md` under a fake home: the subtraction axis's primary
// target, and the one whose cost lands in every repo on every turn.
const home = join(dir, 'home');
const userConfig = join(home, '.claude');
await mkdir(userConfig, { recursive: true });
const userFile = join(userConfig, 'CLAUDE.md');
await writeFile(userFile, FIXTURE, 'utf-8');
const result = await applySubtraction(
[{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }],
{ repoRoot: repo, home },
);
assert.equal(result.gate, 'require-ok');
assert.equal(result.requiresApproval, true);
assert.ok(result.disclosures.length >= 1, 'the gate must say why, not just refuse');
assert.equal(result.applied.length, 0);
assert.equal(result.filesWritten.length, 0);
assert.equal(result.refused[0].reason, 'scope-gate');
assert.equal(await readFile(userFile, 'utf-8'), FIXTURE);
});
it('P9 — the same target proceeds once the scope is explicitly approved', async () => {
const home = join(dir, 'home');
const userConfig = join(home, '.claude');
await mkdir(userConfig, { recursive: true });
const userFile = join(userConfig, 'CLAUDE.md');
await writeFile(userFile, FIXTURE, 'utf-8');
const result = await applySubtraction(
[{ file: userFile, line: 5, endLine: 5, text: BLOCK_A }],
{ repoRoot: repo, home, approveScope: true },
);
assert.equal(result.requiresApproval, true, 'the gate still reports what it classified');
assert.equal(result.applied.length, 1);
assert.ok(!(await readFile(userFile, 'utf-8')).includes(BLOCK_A));
});
it('P10 — the backup must cover the file that is actually written, not merely exist', async () => {
const result = await applySubtraction([removal(5, 5, BLOCK_A)], { repoRoot: repo });
// createBackup() skips a path that does not exist and still returns a
// manifest and an id, so "a backup was made" is not evidence (M-BUG-31).
assert.ok(result.backupId, 'a backup id is expected');
const manifest = parseManifest(
await readFile(join(TEST_BACKUP_ROOT, result.backupId, 'manifest.yaml'), 'utf-8'),
);
const covered = manifest.files.map((f) => f.originalPath);
for (const written of result.filesWritten) {
assert.ok(covered.includes(written), `backup does not cover ${written}`);
}
// …and the copy holds the PRE-removal bytes, which is what makes rollback real.
const copy = manifest.files.find((f) => f.originalPath === file);
assert.equal(
await readFile(join(TEST_BACKUP_ROOT, result.backupId, 'files', copy.backupPath.replace('./files/', '')), 'utf-8'),
FIXTURE,
);
});
it('P10 — aborts before any write when the backup cannot cover a target', async () => {
// A target that vanishes between approval and write: createBackup() would
// skip it silently, so the engine must refuse rather than write unbacked.
const ghost = join(repo, 'GONE.md');
const result = await applySubtraction(
[removal(5, 5, BLOCK_A), { file: ghost, line: 1, endLine: 1, text: 'x' }],
{ repoRoot: repo },
);
assert.equal(result.filesWritten.length, 0, 'nothing may be written');
assert.equal(await readFile(file, 'utf-8'), FIXTURE, 'the healthy file must be untouched too');
assert.ok(result.refused.some((r) => r.reason === 'unreadable'));
});
it('refuses everything when nothing survives validation, and leaves no backup behind', async () => {
const result = await applySubtraction([removal(7, 7, BLOCK_FLOOR)], { repoRoot: repo });
assert.equal(result.applied.length, 0);
assert.equal(result.backupId, null, 'no backup for a run that writes nothing');
assert.equal(await readFile(file, 'utf-8'), FIXTURE);
});
});