feat(scanners): the recovery path is code you can run, not prose you can read

R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the
restore path, and neither closes alone.

R1: rollback-engine.mjs verified every checksum before AND after each write,
resolved the legacy backup root and reported createdNotRemoved — and none of it
was reachable. Measured: 16 files under scanners/ carry a process.argv entry;
the engine was not one of them. commands/rollback.md drove the restore as model
prose: an ESM import block a template cannot execute, ad-hoc `cp` offered
underneath as the runnable path, and "(checksum verified)" pre-rendered three
times in the success output. `cp` establishes no checksum, so the verification
was a property of the template rather than of the run — on the one surface that
runs when the user is already in trouble.

R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a
manifest typed out in the template) while parseManifest knew one frozen sample
of that format, pinned by a HAND-WRITTEN fixture instead of by the template's
own text. Rename a key and parseManifest returns zero files while rollback
reports success.

Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves
a clean format with no runnable entry.

- scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the
  existing engine, on the shared requireValidArgs gate. Exit 0 done, 1
  outstanding (gate refusal with nothing written, or a backup that covered fewer
  targets than given), 2 a file failed, 3 could not do the job. A gated restore
  is 1, not 3: "this write leaves your project" is a verdict about a write that
  WAS examined, and it rides in the payload where a command under 2>/dev/null
  can act on it.
- createBackup gains `created` (recorded, never copied — no backup can hold a
  file that does not exist) and `skipped`, so a backup covering fewer files than
  asked is no longer indistinguishable from a clean one.
- implement.md Step 3 and rollback.md now call the CLI. parseManifest's
  implement-format branch stays: nothing writes that shape now, but every backup
  made before this chunk is on disk in it.
- backup-restore-contract.test.mjs checks every field rollback.md renders
  against a payload produced by RUNNING the CLI. That is what replaced
  "(checksum verified)".

20 guards seen red against the original state before any production code, then
each against its own defect. Two holes that surfaced there were mine: the
implement assertion matched `--create` as a substring of `--created` and stayed
green when the call was removed; and mutating the argv gate showed
requireValidArgs sets exit 3 by itself, so a CLI can report that it could not
parse its arguments and still run the restore underneath — that case is now
asserted on the bytes.

Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded
through the templates' own command lines against a sandboxed HOME, including the
machine-wide arm: refused with the file unchanged, then restored under
--approve-scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
This commit is contained in:
Kjell Tore Guttormsen 2026-08-18 21:28:15 +02:00
commit 44b222859e
13 changed files with 1083 additions and 86 deletions

View file

@ -0,0 +1,227 @@
/**
* R1 + R2 the backup/restore data contract belongs to the code.
*
* Two templates used to own it in prose, and each half made the other's failure
* silent:
*
* R1 `commands/rollback.md` drove the restore itself. Its "Implementation"
* section showed an ESM `import` block a command template cannot execute and
* offered ad-hoc `cp` as the runnable alternative, then pre-rendered
* "`(checksum verified)`" in the success output. `cp` verifies nothing, so the
* claim was a property of the template, not of the run.
*
* R2 `commands/implement.md` Step 3 hand-built the backup: `mkdir`, `cp`, a
* `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the template. The
* parser on the other side (`parseManifest`) knew ONE frozen sample of that
* format, pinned by a HAND-WRITTEN fixture rather than by the template's own
* text the #63 shape on the data side. Rename a key in the template and
* `parseManifest` returns zero files while `rollback` reports success.
*
* The fix is one chunk because half of it does not hold: a CLI over a prose
* format still parses prose, and a clean format with no runnable entry still
* cannot restore. What this file asserts is that neither half came back.
*
* The last test is the one that replaces "(checksum verified)": every field the
* template renders is checked against a payload produced by RUNNING the CLI, so
* the output prose can only claim what the code actually reports.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawn } from 'node:child_process';
import { readFile, writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const COMMANDS_DIR = join(ROOT, 'commands');
const CLI = join(ROOT, 'scanners', 'rollback-cli.mjs');
const read = (name) => readFile(join(COMMANDS_DIR, name), 'utf-8');
/** Fenced blocks as `[{ lang, body }]`. */
function fences(content) {
return [...content.matchAll(/```(\w*)\n([\s\S]*?)```/g)].map((m) => ({ lang: m[1], body: m[2] }));
}
test('rollback.md drives the engine through the CLI, like every other command', async () => {
const content = await read('rollback.md');
assert.match(
content,
/node \$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/rollback-cli\.mjs/,
'rollback.md must call the rollback CLI at an anchored path. A relative path resolves\n' +
"against the user's working directory, not the plugin ([[plugin-root-is-the-cache]]).",
);
assert.match(
content,
/rollback-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/,
'The CLI must be invoked as `--output-file <path> 2>/dev/null` (ux-rules rule 2). A payload\n' +
'the command has to act on cannot ride on stdout or stderr.',
);
});
test('rollback.md no longer pre-renders a verification it did not perform', async () => {
const content = await read('rollback.md');
assert.doesNotMatch(
content,
/\(checksum verified\)/,
'The success block hard-codes "(checksum verified)" for every file. The runnable path in\n' +
'this template (`cp`) establishes no checksum at all, so the words came from the template\n' +
'rather than from the run. Render the per-file `status` the CLI returns instead.',
);
});
test('rollback.md offers no hand-rolled restore beside the engine', async () => {
const content = await read('rollback.md');
for (const { lang, body } of fences(content)) {
if (lang !== 'bash') continue;
assert.doesNotMatch(
body,
/^\s*cp\s+/m,
'A `cp` restore skips the checksum verification before and after each write that the\n' +
'engine performs. Two restore paths means the safe one is optional.',
);
}
assert.doesNotMatch(
content,
/import\s*\{[^}]*\}\s*from\s*['"][^'"]*rollback-engine\.mjs['"]/,
'A command template is not an ES module; an `import` block here is instructions the\n' +
'runtime never executes, which is what left `cp` as the only runnable path.',
);
});
test('implement.md Step 3 creates its backup through the code, not by hand', async () => {
const content = await read('implement.md');
// `--create(?![a-z])`, not `--create`: the same line carries `--created`, so a
// prefix match is satisfied by the very flag whose presence proves nothing
// about whether a backup is being MADE. Measured by mutating `--create` away
// and watching this assertion stay green.
assert.match(
content,
/node \$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/rollback-cli\.mjs[^\n]*--create(?![a-z])/,
'Step 3 must call `rollback-cli.mjs --create`. Backing up through the same code path the\n' +
'fix pipeline uses is what deletes the second copy of the backup policy.',
);
assert.match(
content,
/--created\b/,
'Files this run will CREATE have no backup and must be recorded so rollback can say what\n' +
'it is leaving behind. The list moved from hand-written YAML to a flag; it must still exist.',
);
});
test('implement.md hand-builds no manifest and invents no backup id', async () => {
const content = await read('implement.md');
for (const { body } of fences(content)) {
assert.doesNotMatch(
body,
/^\s*sha256:/m,
'Step 3 types out a manifest whose only reader is `parseManifest`. Every key here is a\n' +
'contract with code, maintained by hand on one side — the seam that already failed once\n' +
'(M-BUG-25) and whose fixture was hand-written rather than derived from this template.',
);
assert.doesNotMatch(
body,
/mkdir\s+-p\s+~\/\.claude\/config-audit\/backups/,
'The template builds the backup directory layout itself. The layout is `createBackup`\'s\n' +
'to define; a second definition in prose drifts away from it silently.',
);
assert.doesNotMatch(
body,
/BACKUP_ID=\$\(date/,
'A backup id derived by the template is a second id generator. `createBackup` returns the\n' +
'id it actually used; anything else can name a directory that does not exist.',
);
}
});
test('every field rollback.md renders is a field the CLI really emits', async () => {
// The replacement for "(checksum verified)": the output prose is checked
// against a payload produced by RUNNING the CLI. A renamed payload key turns
// the template's claim into a violation here rather than into a confident
// sentence in front of a user who is already in trouble.
//
// `K` is the only derived reference — a count the model computes from the
// classified targets, not a field any scanner emits.
const DERIVED = new Set(['K']);
const root = await mkdtemp(join(tmpdir(), 'ca-rbrender-'));
try {
const home = join(root, 'home');
const work = join(root, 'work');
const cwd = join(root, 'cwd');
await mkdir(join(home, '.claude'), { recursive: true });
await mkdir(work, { recursive: true });
await mkdir(cwd, { recursive: true });
const backupRoot = join(home, '.claude', 'config-audit', 'backups');
const env = {
...process.env,
HOME: home,
USERPROFILE: home,
CONFIG_AUDIT_BACKUP_ROOT: backupRoot,
CONFIG_AUDIT_LEGACY_BACKUP_ROOT: join(home, '.config-audit', 'backups'),
};
const runCli = (argv) => new Promise((res) => {
const child = spawn(process.execPath, [CLI, ...argv], { cwd, env });
child.stdout.on('data', () => {});
child.stderr.on('data', () => {});
child.on('close', (code) => res(code));
});
const target = join(work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const createOut = join(root, 'create.json');
await runCli([
'--create', '--target', target,
'--created', join(work, 'made-by-implement.md'),
'--repo', work, '--output-file', createOut,
]);
const created = JSON.parse(await readFile(createOut, 'utf-8'));
const listOut = join(root, 'list.json');
await runCli(['--list', '--output-file', listOut]);
const listed = JSON.parse(await readFile(listOut, 'utf-8'));
const restoreOut = join(root, 'restore.json');
await runCli([
'--restore', created.backupId, '--repo', work, '--dry-run', '--output-file', restoreOut,
]);
const restored = JSON.parse(await readFile(restoreOut, 'utf-8'));
const keys = new Set();
const walk = (node) => {
if (Array.isArray(node)) node.slice(0, 5).forEach(walk);
else if (node && typeof node === 'object') {
for (const k of Object.keys(node)) { keys.add(k); walk(node[k]); }
}
};
[created, listed, restored].forEach(walk);
const content = await read('rollback.md');
// Render fences only. A `bash` fence is a command, not a render contract,
// and `${CLAUDE_PLUGIN_ROOT}` would otherwise read as a payload field.
const refs = new Set();
for (const { lang, body } of fences(content)) {
if (lang === 'bash') continue;
for (const m of body.matchAll(/\{([a-zA-Z][a-zA-Z0-9_.]*)\}/g)) refs.add(m[1]);
}
assert.ok(refs.size > 0, 'no render reference found in rollback.md — the sweep certifies nothing');
const missing = [...refs].filter((r) => !DERIVED.has(r) && !keys.has(r.split('.').pop()));
assert.deepEqual(
missing,
[],
'rollback.md renders fields rollback-cli.mjs never emits:\n ' + missing.join('\n '),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -62,6 +62,9 @@ const GUARDED = [
{ 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' },
// R1. All three probes below exit at `requireValidArgs`, before any mode runs,
// so none of them reaches the operator's real backup root.
{ cli: 'rollback-cli.mjs', argv: [], valueFlag: '--output-file' },
];
/**

View file

@ -0,0 +1,395 @@
/**
* R1 the restore path gets a runnable entry.
*
* `rollback-engine.mjs` has always verified checksums before AND after each
* write, resolved the legacy backup root, and reported `createdNotRemoved`.
* None of it was reachable: measured at the head of this chunk, 16 files under
* `scanners/` carry a `process.argv` entry and the engine was not one of them.
* `commands/rollback.md` drove the restore as model prose an ESM `import`
* block a command template cannot execute, with ad-hoc `cp` offered as the
* runnable alternative and a pre-rendered "(checksum verified)" line under it.
* `cp` establishes no checksum, so the claim was rendered by the template
* rather than produced by the run.
*
* Two properties of this file are load-bearing:
*
* 1. **The unknown-flag control comes first.** `command-cli-contract.test.mjs`
* only trusts a CLI's silence about a flag once it has SEEN that CLI reject
* a flag which cannot exist. The same control is asserted here, at the
* source, so a future edit that moves argv parsing behind a required-arg
* check fails in the CLI's own test rather than silently making every flag
* pair downstream pass for the wrong reason.
*
* 2. **A refusal is asserted on the BYTES, never on the exit code alone.**
* The gate's whole job is that nothing was written; a test that reads only
* the verdict would pass against an engine that refused loudly and wrote
* anyway.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from 'node:fs/promises';
import { join, resolve, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { parseManifest } from '../../scanners/lib/backup.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI = resolve(__dirname, '..', '..', 'scanners', 'rollback-cli.mjs');
/**
* A sandbox that is a HOME as well as a backup root, so `user-scope`
* classification and backup resolution both stay inside the temp dir. The cwd
* is a third, empty directory: probing a writer runs the writer (#67), and a
* CLI that defaults a path to the working directory must be caught doing it.
*/
async function sandbox() {
const root = await mkdtemp(join(tmpdir(), 'ca-rbcli-'));
const home = join(root, 'home');
const work = join(root, 'work');
const cwd = join(root, 'cwd');
await mkdir(join(home, '.claude'), { recursive: true });
await mkdir(work, { recursive: true });
await mkdir(cwd, { recursive: true });
return {
root,
home,
work,
cwd,
backupRoot: join(home, '.claude', 'config-audit', 'backups'),
cleanup: () => rm(root, { recursive: true, force: true }),
};
}
async function run(sb, argv) {
const { code, stdout, stderr } = await new Promise((res) => {
const child = spawn(process.execPath, [CLI, ...argv], {
cwd: sb.cwd,
env: {
...process.env,
HOME: sb.home,
USERPROFILE: sb.home,
CONFIG_AUDIT_BACKUP_ROOT: sb.backupRoot,
CONFIG_AUDIT_LEGACY_BACKUP_ROOT: join(sb.home, '.config-audit', 'backups'),
},
});
let out = '';
let err = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('close', (c) => res({ code: c, stdout: out, stderr: err }));
});
return { code, stdout, stderr, cwdEntries: await readdir(sb.cwd) };
}
/** Run and read the `--output-file` payload back. */
async function runJson(sb, argv) {
const out = join(sb.root, `payload-${Math.random().toString(36).slice(2)}.json`);
const r = await run(sb, [...argv, '--output-file', out]);
return { ...r, payload: JSON.parse(await readFile(out, 'utf-8')), outFile: out };
}
/** Make a backup of `files` through the CLI itself and return its id. */
async function seedBackup(sb, files, created = []) {
const argv = ['--create'];
for (const f of files) argv.push('--target', f);
for (const c of created) argv.push('--created', c);
const { code, payload } = await runJson(sb, [...argv, '--repo', sb.work]);
assert.equal(code, 0, 'seeding a backup must succeed');
return payload.backupId;
}
test('rejects a flag that cannot exist (the control every downstream probe rests on)', async () => {
const sb = await sandbox();
try {
const { code, stderr } = await run(sb, ['--zzz-not-a-real-flag']);
assert.equal(code, 3, 'a malformed argv is exit 3, never a verdict');
assert.match(stderr, /unknown flag "--zzz-not-a-real-flag"/i);
} finally {
await sb.cleanup();
}
});
test('a malformed argv stops the run, it does not merely colour the exit code', async () => {
// Found by mutating the gate: `requireValidArgs` sets exit 3 by itself, so a
// caller that drops the `return` still LOOKS rejected while the mode runs to
// completion underneath. A restore that happened is not undone by the exit
// code that says it should not have.
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# current\n');
const { code } = await run(sb, ['--restore', id, '--repo', sb.work, '--zzz-not-a-real-flag']);
assert.equal(code, 3);
assert.equal(
await readFile(target, 'utf-8'),
'# current\n',
'the CLI restored a file while reporting that it could not parse its own arguments',
);
} finally {
await sb.cleanup();
}
});
test('two modes in one argv is an argument error, not a silent winner', async () => {
const sb = await sandbox();
try {
const { code, stderr } = await run(sb, ['--list', '--delete', 'x']);
assert.equal(code, 3);
assert.match(stderr, /one mode/i);
} finally {
await sb.cleanup();
}
});
test('list mode on an empty backup root answers zero, and writes nothing', async () => {
const sb = await sandbox();
try {
const { code, payload, cwdEntries } = await runJson(sb, ['--list']);
assert.equal(code, 0);
assert.equal(payload.meta.mode, 'list');
assert.deepEqual(payload.backups, []);
assert.equal(payload.count, 0);
assert.deepEqual(cwdEntries, [], 'the CLI must not default any path to the working directory');
} finally {
await sb.cleanup();
}
});
test('--output-file keeps the payload off stdout (ux-rules rule 1)', async () => {
const sb = await sandbox();
try {
const { stdout } = await runJson(sb, ['--list']);
assert.equal(stdout, '', 'a payload written to a file must not also reach the transcript');
} finally {
await sb.cleanup();
}
});
test('create mode backs up the real bytes and round-trips through parseManifest', async () => {
const sb = await sandbox();
try {
const a = join(sb.work, 'CLAUDE.md');
const b = join(sb.work, '.claude', 'settings.json');
await mkdir(dirname(b), { recursive: true });
await writeFile(a, '# original A\n');
await writeFile(b, '{"a":1}\n');
const willCreate = join(sb.work, '.claude', 'rules', 'new-rule.md');
const { code, payload } = await runJson(
sb,
['--create', '--target', a, '--target', b, '--created', willCreate, '--repo', sb.work],
);
assert.equal(code, 0);
assert.equal(payload.meta.mode, 'create');
assert.match(payload.backupId, /^\d{8}_\d{6}$/);
assert.equal(payload.files.length, 2);
assert.deepEqual(payload.created, [willCreate]);
assert.deepEqual(payload.skipped, []);
// R2: the data contract is owned by the code on BOTH sides. A manifest the
// engine writes must be one the engine's own parser reads back whole —
// including `created:`, which the hand-built template format carried and
// `createBackup` did not.
const manifest = parseManifest(
await readFile(join(payload.backupPath, 'manifest.yaml'), 'utf-8'),
);
assert.equal(manifest.backup_id, payload.backupId);
assert.equal(manifest.files.length, 2);
assert.deepEqual(manifest.created, [willCreate]);
assert.equal(manifest.files[0].originalPath, a);
} finally {
await sb.cleanup();
}
});
test('create mode reports a target it could not back up instead of counting it', async () => {
const sb = await sandbox();
try {
const real = join(sb.work, 'CLAUDE.md');
await writeFile(real, 'x\n');
const ghost = join(sb.work, 'not-there.md');
const { code, payload } = await runJson(
sb,
['--create', '--target', real, '--target', ghost, '--repo', sb.work],
);
assert.equal(code, 1, 'a backup that covers fewer files than asked is a warning, not a pass');
assert.deepEqual(payload.skipped, [ghost]);
assert.equal(payload.files.length, 1);
} finally {
await sb.cleanup();
}
});
test('restore writes the backed-up bytes back and verifies the checksum', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# clobbered\n');
const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.equal(code, 0);
assert.equal(payload.meta.mode, 'restore');
assert.equal(payload.backupId, id);
assert.deepEqual(payload.failed, []);
assert.equal(payload.restored.length, 1);
assert.equal(payload.restored[0].status, 'restored');
assert.equal(await readFile(target, 'utf-8'), '# original\n');
} finally {
await sb.cleanup();
}
});
test('a corrupted backup fails loudly and leaves the original alone', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# current\n');
// Corrupt the stored copy: the checksum in the manifest no longer matches.
const files = join(sb.backupRoot, id, 'files');
const [stored] = await readdir(files);
await writeFile(join(files, stored), '# tampered\n');
const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.equal(code, 2, 'a failed restore is a FAIL verdict, not a pass');
assert.deepEqual(payload.restored, []);
assert.equal(payload.failed[0].status, 'checksum-mismatch');
assert.equal(
await readFile(target, 'utf-8'),
'# current\n',
'a checksum mismatch must stop the write, not land tampered bytes on the original',
);
} finally {
await sb.cleanup();
}
});
test('a restore that leaves this project is refused until --approve-scope', async () => {
const sb = await sandbox();
try {
const target = join(sb.home, '.claude', 'CLAUDE.md');
await writeFile(target, '# machine-wide original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# machine-wide current\n');
const refused = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.equal(refused.code, 1, 'approval owed is a warning verdict, not a tool error');
assert.equal(refused.payload.requiresApproval, true);
assert.equal(refused.payload.gate, 'require-ok');
assert.ok(refused.payload.disclosures.length > 0, 'the refusal must carry words to render');
assert.deepEqual(refused.payload.restored, []);
assert.equal(refused.payload.refused[0].reason, 'scope-gate');
assert.equal(
await readFile(target, 'utf-8'),
'# machine-wide current\n',
'the gate refused and must therefore have written nothing',
);
const ok = await runJson(sb, ['--restore', id, '--repo', sb.work, '--approve-scope']);
assert.equal(ok.code, 0);
assert.equal(await readFile(target, 'utf-8'), '# machine-wide original\n');
} finally {
await sb.cleanup();
}
});
test('--dry-run reports what would happen and touches nothing', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# current\n');
const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work, '--dry-run']);
assert.equal(code, 0);
assert.equal(payload.dryRun, true);
assert.equal(payload.restored[0].status, 'dry-run');
assert.equal(await readFile(target, 'utf-8'), '# current\n', 'a dry run is not a write');
} finally {
await sb.cleanup();
}
});
test('restore reports the files it cannot undo', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const madeByImplement = join(sb.work, '.claude', 'rules', 'post-quality.md');
const id = await seedBackup(sb, [target], [madeByImplement]);
const { payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.deepEqual(
payload.createdNotRemoved,
[madeByImplement],
'a half-restored target is only dangerous when it is also silent',
);
} finally {
await sb.cleanup();
}
});
test('an unknown backup id is a tool error, never an empty success', async () => {
const sb = await sandbox();
try {
const { code, stderr } = await run(sb, ['--restore', '19700101_000000', '--repo', sb.work]);
assert.equal(code, 3);
assert.match(stderr, /not found/i);
} finally {
await sb.cleanup();
}
});
test('delete removes the backup directory, and refuses an id it cannot find', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
const { code, payload } = await runJson(sb, ['--delete', id]);
assert.equal(code, 0);
assert.equal(payload.deleted, true);
await assert.rejects(() => stat(join(sb.backupRoot, id)));
const missing = await run(sb, ['--delete', '19700101_000000']);
assert.equal(missing.code, 3);
} finally {
await sb.cleanup();
}
});
test('list mode sees a backup the CLI itself created', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
const { payload } = await runJson(sb, ['--list']);
assert.equal(payload.count, 1);
assert.equal(payload.backups[0].id, id);
assert.equal(payload.backups[0].files[0].originalPath, target);
} finally {
await sb.cleanup();
}
});

View file

@ -154,12 +154,21 @@ describe('listBackups / restoreBackup across both roots', () => {
// ========================================
// Manifest compatibility (M-BUG-25)
//
// The implement flow hand-builds its manifest (commands/implement.md tells the
// agent to mkdir + cp), so real backups on disk use `- backup:` / `original:` /
// `sha256:` while parseManifest only understood the engine's quoted
// `original_path:` / `backup_path:` / `checksum:`. Result: parseManifest
// The implement flow used to hand-build its manifest (commands/implement.md told
// the agent to mkdir + cp), so backups written by that flow use `- backup:` /
// `original:` / `sha256:` while parseManifest only understood the engine's
// quoted `original_path:` / `backup_path:` / `checksum:`. Result: parseManifest
// returned files: [] and restoreBackup reported success having restored
// nothing — a success-shaped no-op, the worst failure mode in the file.
//
// R2 removed the prose format at the SOURCE: Step 3 now calls
// `rollback-cli.mjs --create`, so nothing writes this shape any more. The
// fixture below therefore changed meaning rather than becoming obsolete — it
// pins a format that still exists ON DISK in every backup implement made before
// this chunk, and those must stay restorable. That is also why it is legitimately
// hand-written now: it is a golden sample of historical bytes, not a stand-in for
// a template's own text (the #63 objection that applied while the template was
// still authoring it).
// ========================================
const ENGINE_MANIFEST = `created_at: "2026-07-17T03:26:36.000Z"