/** * 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} 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 }; }