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

@ -197,6 +197,35 @@ per-candidate `modelScope` appear **only** when the flag is passed, so a plain
`--subtract` run is byte-identical to the pre-flag payload (asserted on the
serialized bytes, since a key set to `undefined` passes a shallow key check).
**Backup/restore is one code path (invariant).** `scanners/rollback-cli.mjs` is the only entry to
the backup engine, and BOTH pipelines use it: `fix` backs up through `createBackup` directly,
`implement` Step 3 through `--create`. Before R1/R2 the two halves each hid the other's failure.
The engine verified every checksum before and after each write, but had no `process.argv` (16 CLIs
under `scanners/` had one, it did not), so `commands/rollback.md` restored as model prose — an ESM
`import` block a template cannot execute, `cp` offered underneath as the runnable path, and
"(checksum verified)" pre-rendered in the success output. `cp` establishes no checksum, so the
verification was a property of the template. Meanwhile `implement` hand-built its manifest in the
template while `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, where a renamed
key yields zero parsed files and a rollback that reports success having restored nothing. Four
properties are load-bearing. (1) **Neither half fixes alone**: a CLI over a prose format still
parses prose; a clean format with no runnable entry still cannot restore. (2) **A gated restore is
exit 1, not 3** — "this write leaves your project" is a verdict about a write that WAS examined and
rides in the payload, where a command running under `2>/dev/null` can act on it (F3's class); 3
stays reserved for argv errors and a backup id that resolves in neither root. (3) **`--created`
records, it does not copy** — no backup can hold a file that does not exist yet, so those paths go
into the manifest for `rollback` to report as left in place; `serializeManifest` emits the bare
`created:` key, which is why the pre-R2 `created: <timestamp>` (a VALUE, meaning the backup id)
never collides with it. (4) **`parseManifest`'s implement-format branch stays** even though nothing
writes that shape now — backups already on disk in it must remain restorable, the same reason
`getLegacyBackupDir()` is still read; its fixture changed meaning from "a stand-in for the
template's text" to "a golden sample of historical bytes". The output contract is guarded by
running the CLI: `tests/commands/backup-restore-contract.test.mjs` checks every field
`rollback.md` renders against a real payload, so a renamed key fails there instead of becoming a
confident sentence in front of a user who is already in trouble. Distinct from the scope gate,
which classifies *where* a restore lands — `rollback.md` still calls `write-scope-cli` before its
approval surface, and classifying is still not approving.
**Subtraction write path (invariant).** `--apply` routes through `scanners/lib/subtraction-write.mjs`, never through `fix-engine` or the `plan`/`implement` pipeline, and both exclusions are **measured**: the subtraction axis is absent from the orchestrated envelope, so `verifyFixes`' re-scan would mark every removal `verified` whether or not it happened (a success-shaped no-op), and the findings pipeline needs a finding code — which names a deterministic check, not a prose judgement. Three properties are load-bearing and each has a guard seen red against its own defect: removals are validated against the ORIGINAL content and applied in **descending** line order (an ascending pass shifts later spans out from under themselves); 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 coverage of every file about to be written is **asserted from the manifest** before a byte changes. The floor is *repeated* here, 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. The archive rule (`mv` to `_archive/`) is file-level and does not apply to a block excision — the timestamped backup is the recovery artifact, and inventing a second copy with no restorer behind it would be worse than none.
## Testing

View file

@ -304,7 +304,10 @@ every project on every turn.
That gate now runs inside the engines, not only in the command prose that wraps them. It covers
`/config-audit fix`, `/config-audit rollback`, `campaign export`, `--save-baseline`, and
`optimize --subtract --apply`. When a run is withheld, nothing has been written: you get the
`optimize --subtract --apply`. For rollback that is now literal: the restore runs through
`scanners/rollback-cli.mjs`, which verifies each file's checksum before and after writing it, so
"restored and verified" is something the run reports rather than something the output template
says. A restore that would land outside your project is refused with nothing written. When a run is withheld, nothing has been written: you get the
reason and the affected paths, and you re-run with your approval (`--approve-scope` on the CLIs).
Approval is always a separate act — classifying a target is not approving it. The plugin's own
bookkeeping (backups, session state, ledgers, and the report file you named with `--output-file`)
@ -447,6 +450,7 @@ All tools work standalone — no Claude Code session needed:
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal |
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
| **Backup / restore** | `node scanners/rollback-cli.mjs [--list] [--restore <id>] [--delete <id>] [--create --target <path> …] [--created <path>] [--dry-run] [--approve-scope] [--repo <root>] [--json] [--output-file path]` |
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |
| **Full scan** | `node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress]` |
@ -606,6 +610,7 @@ Shared modules used by all scanners — useful if you're reading the source or e
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
| `write-scope-cli.mjs` | CLI: classify write targets before an approval surface (`--target`, repeatable) |
| `rollback-cli.mjs` | CLI: the runnable entry to backup and restore (`--list` / `--create` / `--restore` / `--delete`). Both pipelines back up through this one code path, so the manifest format is never written or read by hand |
---

View file

@ -87,37 +87,32 @@ AskUserQuestion:
### Step 3: Create backup
Create backup silently, and **print the backup ID** — Step 6 has to tell the user
how to roll back, and a timestamp that only ever existed inside a command
substitution cannot be quoted later. Shell state does not survive to the next
block, so capture the printed value and substitute it literally from here on:
Create the backup through the code that owns the format. Pass one `--target` per
pre-existing file the plan will MODIFY, and one `--created` per file the plan
will CREATE — a backup cannot hold a file that does not exist yet, so those are
recorded rather than copied, and `/config-audit rollback` reads them back to tell
the user which files it is leaving behind.
```bash
BACKUP_ID=$(date +%Y%m%d_%H%M%S)
mkdir -p ~/.claude/config-audit/backups/"$BACKUP_ID"/files/ 2>/dev/null
echo "$BACKUP_ID"
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --create --target "<file-1>" --target "<file-2>" --created "<new-file-1>" --repo "$PWD" --output-file /tmp/config-audit-implement-backup.json 2>/dev/null; echo $?
```
Use the printed ID wherever `{backup-id}` appears below. Never invent or re-derive
it with a second `date` call — a run that straddles a second boundary would hand
the user a rollback ID that does not exist.
| Exit | Meaning |
|------|---------|
| 0 | every target was backed up |
| 1 | at least one target was not there — read `skipped[]` before continuing |
| 3 | the CLI could not run (show the stderr message); do not edit anything |
Copy each file to be modified. Generate `manifest.yaml` with checksums.
Read `/tmp/config-audit-implement-backup.json`. The payload's `backupId` is the
ID to quote from here on — **never re-derive it**. Shell state does not survive
to the next block, and a second `date` call that straddles a second boundary
would hand the user a rollback ID that does not exist. Use it wherever
`{backup-id}` appears below.
The manifest is what `/config-audit rollback` reads, so it MUST carry both lists:
```yaml
files: # pre-existing files this run will MODIFY
- backup: files/root/CLAUDE.md
original: /abs/path/CLAUDE.md
sha256: <sha256 of the pre-change content>
created: # files this run will CREATE (no backup can exist)
- /abs/path/.claude/rules/post-quality.md
```
Record every `create`-type action under `created:`. Rollback cannot restore a
file that never existed, but it must be able to tell the user which files it is
leaving behind — a half-restored target is only dangerous when it is silent.
On exit 1, name the `skipped[]` paths before doing anything else: the plan is
about to change files that have no backup behind them. If any skipped path is one
the plan MODIFIES (rather than creates), stop and report — that action cannot be
rolled back.
Tell the user: **"Backup created. Implementing actions..."**
@ -222,8 +217,10 @@ A full-file Write that names only two of the four silently deletes the other two
## Rollback
If the user requests rollback at any point:
1. Read `manifest.yaml` from backup
2. Restore each file and verify checksums
1. Run `/config-audit rollback {backup-id}` — it drives `scanners/rollback-cli.mjs`,
which verifies each checksum before and after writing. Do not restore by hand:
a copy performs neither check, and the result cannot be reported as verified.
2. Read the restore payload and report the per-file `status` it returns
3. **Report — do not delete — the files this run created.** Rollback restores from
backup, and no backup can exist for a file that did not exist before. Those
paths stay on disk; `/config-audit rollback` lists them under "Left in place"

View file

@ -10,6 +10,12 @@ model: sonnet
Restore configuration files from a previous backup. Without arguments, lists available backups. With a backup ID, restores files from that backup.
Every step below runs `scanners/rollback-cli.mjs`, which drives the rollback
engine: it verifies each file's checksum before AND after writing it, resolves
backups made under the pre-v2.2.0 root, and reports the files a restore cannot
undo. Never restore by copying files back by hand — a copy performs none of
those checks, and the result cannot honestly be reported as verified.
## Arguments
- `$ARGUMENTS` may contain a backup ID (format: `YYYYMMDD_HHMMSS`)
@ -19,14 +25,18 @@ Restore configuration files from a previous backup. Without arguments, lists ava
### List mode (no argument)
Parse flags and list available backups from `~/.claude/config-audit/backups/`:
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
ls -1 ~/.claude/config-audit/backups/
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?
```
Exit 0 = listed; 3 = the CLI could not do its job (show the stderr message).
Read `/tmp/config-audit-rollback-list.json` and render one row per entry in
`backups[]``{id}`, how many `{files}` it holds, and `{createdAt}`. An entry
whose `{legacy}` is true was made under the pre-v2.2.0 backup root; say so, since
its path differs from the one printed below.
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Available Backups
@ -40,16 +50,16 @@ ls -1 ~/.claude/config-audit/backups/
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
Use the Read tool on each backup's `manifest.yaml` (the list of changes captured at backup time) to extract the file list and timestamps.
If `{count}` is 0, say there are no backups yet and stop — do not offer a restore.
### Restore mode (with backup ID)
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
2. Classify the `original:` paths before showing them. A restore writes to the
absolute path recorded at backup time, which may be machine-wide even when the
backup was taken from a project — so the file list must be rendered as the
absolute originals, never shortened to a repo-relative-looking form that
implies the write stays local:
1. The `backups[]` entry for that ID (from the list payload above) carries the
`files[]` this restore would write. Classify those `{originalPath}` values
before showing them. A restore writes to the absolute path recorded at backup
time, which may be machine-wide even when the backup was taken from a project
— so the file list must be rendered as the absolute originals, never shortened
to a repo-relative-looking form that implies the write stays local:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<original-1>" --target "<original-2>" --repo "$PWD" --output-file /tmp/config-audit-rollback-scope.json 2>/dev/null; echo $?
@ -76,21 +86,45 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- "Cancel"
- "Yes — restore, including outside this project"
```
3. For each file in the list of changes:
a. Read the backup file from `~/.claude/config-audit/backups/{backup-id}/files/{safeName}`
b. Write to the original path
c. Verify the checksum matches the recorded value in the list of changes
4. Show result:
2. Run the restore. Classifying is not approving: add `--approve-scope` only
after the user has answered yes to the question above, and only then.
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --restore "<backup-id>" --repo "$PWD" --output-file /tmp/config-audit-rollback-restore.json 2>/dev/null; echo $?
```
Restored 3 files from backup 20260403_163045
- /abs/path/.claude/settings.json (checksum verified)
- /abs/path/hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
Append ` --approve-scope` to that command when the user approved a restore
that leaves this project. To preview without writing anything, append
` --dry-run` instead — a dry run reports what would happen and touches no file.
| Exit | Meaning |
|------|---------|
| 0 | every file restored |
| 1 | nothing was written — the restore needs approval it was not given |
| 2 | at least one file failed; read `failed[]` before saying anything else |
| 3 | the CLI could not run (bad ID, unreadable manifest) — show the stderr message |
3. Read `/tmp/config-audit-rollback-restore.json` and report what the run
actually did. Render one line per entry in `restored[]` and `failed[]`, each
showing its own `{status}` from the payload — never a fixed verification
phrase, because the outcome differs per file and only the payload knows it:
```
5. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. If the
manifest has a `created:` section (or `restoreBackup()` returns a non-empty
`createdNotRemoved`), list those paths and say plainly that they remain:
Restored 2 of 3 files from backup 20260403_163045
- /abs/path/.claude/settings.json — {status}
- /abs/path/hooks/hooks.json — {status}
- /abs/path/.claude/rules/typescript.md — {status}
```
When `{requiresApproval}` is true and nothing was restored, the run was
refused: list the `refused[]` paths, say plainly that no file was changed, and
offer to re-run with approval.
4. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. When
`createdNotRemoved` is non-empty, list those paths and say plainly that they
remain:
```
Left in place — created by implement, no backup exists:
- .claude/rules/post-quality.md
@ -102,29 +136,24 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
### Delete mode
If user says "delete" after listing, confirm and remove the backup directory.
If the user says "delete" after listing, confirm, then:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --delete "<backup-id>" --output-file /tmp/config-audit-rollback-delete.json 2>/dev/null; echo $?
```
Exit 0 = removed (`deleted` is true in the payload); 3 = no backup with that ID
in either root — show the stderr message rather than reporting a deletion.
## Implementation
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest, getBackupDir } from '../scanners/lib/backup.mjs';
```
`scanners/rollback-cli.mjs` is the only entry point. It reads
`~/.claude/config-audit/backups` and falls back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves; the
list payload flags those with `legacy: true`, and both roots are echoed in
`meta` so a report can name the one it used.
Both read `~/.claude/config-audit/backups` and fall back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves;
`listBackups()` flags those with `legacy: true`. Prefer this API over ad-hoc
`cp` — it verifies the checksum before and after each write.
Or via Bash:
```bash
# List backups
ls -1 ~/.claude/config-audit/backups/
# Read manifest
cat ~/.claude/config-audit/backups/{id}/manifest.yaml
# Restore (copy back)
cp ~/.claude/config-audit/backups/{id}/files/{safeName} {originalPath}
```
The same CLI creates backups (`--create --target <path> …`), which is how
`/config-audit implement` records what it is about to change. Backup and restore
therefore share one manifest format, owned by `scanners/lib/backup.mjs` — the
format is never written or read by hand.

View file

@ -60,6 +60,7 @@ everything because they sit on the *recovery* path — the surface that runs
exactly when the user is already in trouble, and the least-exercised one.
### R1 — the restore flow is model-executed prose; the code engine has no CLI entry
**CLOSED in #74** — `scanners/rollback-cli.mjs`. The measurement below is kept as written.
**Where:** `commands/rollback.md` §Implementation; `scanners/rollback-engine.mjs`.
**Measured:** 16 scanner CLIs carry a `process.argv` entry — `rollback-engine.mjs`
is not one of them. The template's "Implementation" shows an ESM `import` block a
@ -78,6 +79,7 @@ CLI here, the whole Q2 guard class is structurally blind to this command.
then covers it for free. The "(checksum verified)" line becomes payload-driven.
### R2 — implement's backup manifest is hand-built prose; the parser knows one frozen sample of it
**CLOSED in #74** — the real fix, not the minimum: `implement.md` Step 3 calls `rollback-cli.mjs --create`, so the prose format has no author left. The measurement below is kept as written.
**Where:** `commands/implement.md` Step 3 (mkdir/cp + hand-written
`manifest.yaml` with sha256 lines); `scanners/lib/backup.mjs` `parseManifest`;
`tests/scanners/rollback-paths.test.mjs:157-186`.

View file

@ -805,3 +805,71 @@ once the gate made it unreachable, along with the now-redundant `&& args[i + 1]`
Exit code is **3** by the exit-code contract: a malformed argument means the scanner never got
to do its job, which is categorically different from 0/1/2 — verdicts about a configuration
that *was* examined.
---
### rollback-cli — the recovery path gets a runnable entry (R1+R2, #74)
`scanners/rollback-cli.mjs` is the entry to `rollback-engine.mjs` and, via
`--create`, to `lib/backup.mjs`. It closes the two KRITISK rows of the Q3
severity table as one chunk, because neither half holds alone.
**R1 — the restore was model prose over a code engine.** Measured at the head of
the chunk: 16 files under `scanners/` carried a `process.argv` entry;
`rollback-engine.mjs` was not one of them, though it had verified every file's
checksum *before and after* each write since M-BUG-22 and reported
`createdNotRemoved` since M-BUG-25. `commands/rollback.md` §Implementation
showed an ESM `import` block a command template cannot execute and offered
ad-hoc `cp` underneath as the runnable alternative, then pre-rendered
"`(checksum verified)`" three times in the success output. `cp` establishes no
checksum, so the verification was a property of the template, not of the run —
on the one surface that runs when the user is already in trouble.
**R2 — the backup format had two authors, one of them prose.** The fix pipeline
backed up through `createBackup`; the implement pipeline hand-built its own —
`mkdir`, `cp`, a `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the
template. `parseManifest` grew a second branch for that format because the seam
had already failed silently once, and the fixture pinning it was **hand-written**
rather than derived from 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.
**Why one chunk.** Fix only R1 and the new CLI still parses a prose format. Fix
only R2 and the format is clean with no runnable entry behind it.
**Exit contract.** 0 done · 1 outstanding (a restore the scope gate will not
perform without `--approve-scope`, nothing written; or a backup that covered
fewer targets than it was given) · 2 at least one file failed · 3 the CLI could
not do its 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 running under `2>/dev/null` can act on it. That is F3's class,
avoided rather than repeated.
**`--created` records, it does not copy.** No backup can hold a file that does
not exist yet. Those paths go into the manifest so `rollback` can list what it is
leaving in place. `serializeManifest` emits the bare `created:` key; the pre-R2
implement format used `created: <timestamp>` with a VALUE, meaning the backup id,
and `parseManifest` tells them apart on exactly that — which is why the two never
collide in one file.
**The implement-format branch stays.** Nothing writes that shape any more, but
every backup implement made before this chunk is on disk in it and must remain
restorable — the same reasoning that keeps `getLegacyBackupDir()` readable. The
hand-written fixture in `tests/scanners/rollback-paths.test.mjs` therefore
changed meaning rather than becoming obsolete: it is now a golden sample of
historical bytes, which is a legitimate thing to write by hand, instead of a
stand-in for a template's own text, which is not.
**What replaced "(checksum verified)".** `tests/commands/backup-restore-contract.test.mjs`
runs the CLI and checks every field `rollback.md` renders against the real
payload. A renamed payload key now fails a test instead of turning into a
confident sentence. Seen red against its own defect by renaming `{status}`.
**A guard hole found by mutation.** The first version of the implement-side
assertion matched `--create` as a substring, and the same invocation carries
`--created` — so replacing the `--create` call with `--list` left the guard
green. It matches `--create(?![a-z])` now. Separately, mutating
`if (!requireValidArgs(...)) return;` into a bare call showed that
`requireValidArgs` sets exit 3 *by itself*: a CLI can report "I could not parse
my arguments" and still run the restore underneath. `rollback-cli.test.mjs`
asserts on the bytes for that case, not on the exit code.

View file

@ -238,10 +238,10 @@ async function main() {
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
}
// There is no rollback-cli.mjs — the restore path is the command, which
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
// one message a user reaches for after a bad fix is the worst place for
// a dead reference.
// The user-facing entry, not the CLI: `/config-audit rollback` renders
// the scope disclosures and asks before a restore that leaves the repo.
// `scanners/rollback-cli.mjs` exists now (R1) and is what the command
// runs, but naming it here would hand the user the ungated half.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
}
}

View file

@ -70,10 +70,19 @@ export function checksum(content) {
/**
* Create a backup of the specified files.
*
* `opts.created` records paths the caller is about to CREATE. No backup can
* hold a file that does not exist yet, so these are not copied they are
* written into the manifest so `rollback` can tell the user which files it is
* leaving behind. That list used to exist only in `commands/implement.md`,
* typed out by hand next to a manifest the template also typed out by hand;
* moving it here is what lets the template stop owning the format (R2).
*
* @param {string[]} files - Array of absolute file paths to back up
* @param {object} [opts]
* @param {string} [opts.backupId] - Override backup ID (for testing)
* @returns {{ backupId: string, backupPath: string, manifest: object }}
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
@ -83,9 +92,13 @@ export function createBackup(files, opts = {}) {
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
const skipped = [];
for (const file of files) {
if (!existsSync(file)) continue;
// A target that is not there is reported, never silently dropped: the
// caller asked for a backup of N files and must be able to learn it got
// fewer, before it edits anything.
if (!existsSync(file)) { skipped.push(file); continue; }
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
@ -106,6 +119,7 @@ export function createBackup(files, opts = {}) {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
created: [...(opts.created || [])],
};
// Write manifest as YAML-like format
@ -115,7 +129,7 @@ export function createBackup(files, opts = {}) {
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest };
return { backupId, backupPath, manifest, skipped };
}
/**
@ -133,6 +147,14 @@ function serializeManifest(manifest) {
yaml += ` checksum: "${f.checksum}"\n`;
yaml += ` size_bytes: ${f.sizeBytes}\n`;
}
// Emitted only when non-empty, and read back by `parseManifest`'s `created:`
// branch — the bare-key form, which is why the implement flow's
// `created: <timestamp>` (a VALUE, meaning the backup id) never collides
// with it.
if (manifest.created && manifest.created.length > 0) {
yaml += `created:\n`;
for (const c of manifest.created) yaml += ` - ${c}\n`;
}
return yaml;
}
@ -168,10 +190,13 @@ export function parseManifest(content) {
}
}
// Parse file entries — implement-flow format. `commands/implement.md` has the
// agent hand-build the backup dir, so real manifests on disk use unquoted
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
// restoreBackup a success-shaped no-op on every backup implement produced.
// Parse file entries — implement-flow format. Until R2, `commands/implement.md`
// had the agent hand-build the backup dir, so manifests written by that flow
// use unquoted `- backup:` / `original:` / `sha256:`. Reading only the engine
// format made restoreBackup a success-shaped no-op on every backup implement
// produced (M-BUG-25). The template no longer writes this format, but the
// branch stays: backups already on disk in it must remain restorable — the
// same reason `getLegacyBackupDir()` is still read.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {

208
scanners/rollback-cli.mjs Normal file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* Config-Audit Rollback CLI the runnable entry to the backup/restore engine.
*
* `rollback-engine.mjs` has verified checksums before AND after each write,
* resolved the pre-v2.2.0 backup root and reported `createdNotRemoved` since
* M-BUG-22/M-BUG-25. None of it was reachable: measured at the head of this
* chunk, 16 files under `scanners/` carried 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 template cannot execute, with ad-hoc `cp`
* offered underneath as the runnable alternative and "(checksum verified)"
* pre-rendered in the success output. `cp` establishes no checksum, so the
* verification was a property of the template rather than of the run (R1).
*
* `--create` lives here for the same reason `--restore` does. The implement
* pipeline used to build its backup by hand `mkdir`, `cp`, a `date`-derived
* id and a manifest typed out in the template while `parseManifest` knew one
* frozen sample of that format, pinned by a hand-written fixture rather than by
* the template's own text. One side of that contract was maintained by editing
* prose (R2). Now both sides are `lib/backup.mjs`.
*
* Usage:
* node rollback-cli.mjs [--list]
* node rollback-cli.mjs --restore <backup-id> [--dry-run] [--approve-scope]
* node rollback-cli.mjs --delete <backup-id>
* node rollback-cli.mjs --create --target <path> [--target <path> ...]
* [--created <path> ...] [--backup-id <id>]
* ... plus [--repo <session-root>] [--output-file <path>] [--json]
*
* Exit codes:
* 0 done, nothing owed
* 1 the run completed but something is outstanding a restore the scope
* gate will not perform without `--approve-scope` (nothing was written),
* or a backup that covered fewer targets than it was given
* 2 at least one file failed to restore (checksum mismatch or write error)
* 3 the CLI could not do its job malformed argv, or a backup id that
* resolves in neither root
*
* A gated restore is exit 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 can act on it. Anything that only reaches stderr is invisible to a
* command running under `2>/dev/null` (F3's class).
*
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { createBackup, getBackupDir, getLegacyBackupDir } from './lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from './rollback-engine.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--list', '--create', '--dry-run', '--approve-scope', '--json'],
value: ['--restore', '--delete', '--target', '--created', '--backup-id', '--repo', '--output-file'],
};
/** The mode flags, in the order a diagnostic should name them. */
const MODES = ['--list', '--create', '--restore', '--delete'];
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 3;
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const targets = [];
const created = [];
let mode = null;
let backupId = null;
let dryRun = false;
let approveScope = false;
let jsonMode = false;
let outputFile = null;
let overrideId = null;
// The session's root, never a path derived from the backup (#63). A restore
// writes to the ABSOLUTE originals recorded at backup time, so reading the
// root off those paths would call a machine-wide write "in-repo" and silence
// the strongest gate exactly where it matters.
let repoRoot = process.cwd();
const setMode = (flag) => {
if (mode !== null && mode !== flag) return false;
mode = flag;
return true;
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (MODES.includes(a)) {
if (!setMode(a)) {
// Silently letting the last one win is how a `--delete` rides along
// behind a `--list` the caller thought it was running.
fail(`only one mode may be given (${MODES.join(', ')}); saw "${mode}" and "${a}"`);
return;
}
if (a === '--restore' || a === '--delete') backupId = args[++i];
} else if (a === '--target') targets.push(args[++i]);
else if (a === '--created') created.push(args[++i]);
else if (a === '--backup-id') overrideId = args[++i];
else if (a === '--repo') repoRoot = args[++i];
else if (a === '--output-file') outputFile = args[++i];
else if (a === '--dry-run') dryRun = true;
else if (a === '--approve-scope') approveScope = true;
else if (a === '--json') jsonMode = true;
}
if (mode === null) mode = '--list';
const meta = {
mode: mode.slice(2),
backupRoot: getBackupDir(),
legacyBackupRoot: getLegacyBackupDir(),
repo: resolve(repoRoot),
};
let payload;
let lines = [];
if (mode === '--list') {
const { backups } = await listBackups();
payload = { meta, count: backups.length, backups };
lines = backups.map((b) => `${b.id}\t${b.files.length}\t${b.legacy ? 'legacy' : 'current'}`);
} else if (mode === '--create') {
if (targets.length === 0) {
fail('--create needs at least one --target');
return;
}
const result = createBackup(targets, {
...(overrideId ? { backupId: overrideId } : {}),
created,
});
payload = {
meta,
backupId: result.backupId,
backupPath: result.backupPath,
files: result.manifest.files,
created: result.manifest.created,
skipped: result.skipped,
};
lines = [`${result.backupId}\t${result.manifest.files.length}\t${result.skipped.length}`];
// A backup that covers fewer files than it was asked for is the state the
// caller must not mistake for a clean one: it is about to edit a file it
// cannot roll back.
if (result.skipped.length > 0) process.exitCode = 1;
} else if (mode === '--delete') {
const result = await deleteBackup(backupId);
if (!result.deleted) {
fail(result.error);
return;
}
payload = { meta, backupId, deleted: true, error: null };
lines = [`${backupId}\tdeleted`];
} else {
let result;
try {
result = await restoreBackup(backupId, { dryRun, approveScope, repoRoot });
} catch (err) {
// "Backup not found" and "unreadable manifest" are both the CLI failing to
// do its job, never a verdict about a restore that happened.
fail(err.message);
return;
}
payload = {
meta,
backupId,
dryRun,
gate: result.gate,
requiresApproval: result.requiresApproval,
disclosures: result.disclosures,
restored: result.restored,
failed: result.failed,
refused: result.refused,
createdNotRemoved: result.createdNotRemoved ?? [],
legacy: result.legacy ?? false,
};
lines = [
...result.restored.map((r) => `${r.status}\t${r.originalPath}`),
...result.failed.map((f) => `${f.status}\t${f.originalPath}`),
...result.refused.map((r) => `${r.status}\t${r.originalPath}`),
];
if (result.failed.length > 0) process.exitCode = 2;
else if (payload.requiresApproval && !approveScope && !dryRun) process.exitCode = 1;
}
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file — a command rendering this would
// otherwise show the user the raw payload (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const line of lines) process.stdout.write(`${line}\n`);
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}

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"