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:
parent
b35ff449e8
commit
44b222859e
13 changed files with 1083 additions and 86 deletions
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
208
scanners/rollback-cli.mjs
Normal 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;
|
||||
}
|
||||
227
tests/commands/backup-restore-contract.test.mjs
Normal file
227
tests/commands/backup-restore-contract.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
395
tests/scanners/rollback-cli.test.mjs
Normal file
395
tests/scanners/rollback-cli.test.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue