config-audit/scanners/lib/subtraction-write.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

242 lines
9.5 KiB
JavaScript

/**
* 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).
//
// `!dryRun` is load-bearing. The gate guards a WRITE, and a dry run is not
// one — refusing it early bought nothing and cost the dry run its whole
// purpose on the machine-wide target, which is the mandatory v1 case: the
// operator would approve a removal whose spans had never been checked, and
// the first run able to discover a stale approval would be the one that
// writes. `requiresApproval` is reported either way, so the caller still asks.
if (gate === 'require-ok' && !approveScope && !dryRun) {
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 };
}