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
242 lines
9.5 KiB
JavaScript
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 };
|
|
}
|