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:
parent
1543830c52
commit
000e47f9d2
11 changed files with 1103 additions and 26 deletions
163
tests/commands/subtraction-write-shape.test.mjs
Normal file
163
tests/commands/subtraction-write-shape.test.mjs
Normal 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.',
|
||||
);
|
||||
});
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
166
tests/scanners/subtraction-write-cli.test.mjs
Normal file
166
tests/scanners/subtraction-write-cli.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
265
tests/scanners/subtraction-write.test.mjs
Normal file
265
tests/scanners/subtraction-write.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue