`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
235 lines
9 KiB
JavaScript
235 lines
9 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).
|
|
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 };
|
|
}
|