Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5714261d1 | |||
| 44b222859e |
16 changed files with 1372 additions and 89 deletions
60
CLAUDE.md
60
CLAUDE.md
|
|
@ -83,6 +83,37 @@ Finding ID format: `CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA
|
|||
|
||||
**`{NNN}` names the CHECK, never the emission position (invariant).** `scanners/lib/finding-codes.mjs` is the single authority: every `finding()` call passes a `code`, and an undeclared or missing one **throws** — there is no counter fallback, because a fallback lets a half-converted scanner ship IDs that look valid. Adding a check takes the next free number for that scanner, never the next source-order position; removing one moves its key to `RETIRED_CODES` and its number is never reissued. IDs are therefore **not unique per finding** — one check failing in three files emits three findings sharing an ID, and `(id, file, line)` is the instance key that `fix-engine` verification uses. Frozen `v5.0.0` baselines mask IDs (`tests/helpers/mask-finding-ids.mjs`) instead of re-deriving them; the check→number pairs are pinned exhaustively in `tests/lib/finding-codes.test.mjs`.
|
||||
|
||||
**A file's contract cannot exceed its tools (invariant).** `agents/verifier-agent.md` said both
|
||||
"Append to: implementation-log.md" (§Output Format) and "never modifies any files" (§Read-Only
|
||||
Guarantee) while granting only `Read, Glob, Grep`. The failure mode is not a blocked write — it is
|
||||
the agent improvising a full-file `Write` on the log it *shares* with the parallel implementer
|
||||
agents, which is the exact defect `implement-log-append.test.mjs` exists to prevent, entering
|
||||
through the one file that test does not read. `tests/agents/agent-write-contract.test.mjs` asserts
|
||||
the blanket invariant over the whole catalogue rather than a fact about one file: an agent whose
|
||||
`tools:` grant no write capability (`Write`/`Edit`/`NotebookEdit`/`Bash`) must instruct no file
|
||||
write **and** state positively that it returns its findings inline. Both sides are read from the
|
||||
file, so stripping `Write` from any agent whose body still instructs a write turns it red, and the
|
||||
sweep asserts a write-tool-less agent exists so the invariant cannot pass vacuously. Measured
|
||||
2026-08-20: **1 of 7** agents carried the defect; the other six all hold `Write`. The orchestrator
|
||||
half was already correct — `implement.md` Step 5 appends with Bash `>>` and tells the agent not to
|
||||
write — so this was a one-way fix in the agent file, not a two-sided one.
|
||||
|
||||
**Frontmatter contracts are derived, never listed (invariant).** `.claude/rules/agent-development.md`
|
||||
and `.claude/rules/command-development.md` state which keys an agent/command MUST carry and that
|
||||
agent colors are unique; nothing enforced any of it, and the only test that read agent frontmatter
|
||||
checked `name:` against a hand-written 3-of-7 list. `tests/agents/frontmatter-contract.test.mjs`
|
||||
takes nothing by hand: required keys are parsed from each rule's own fenced `yaml` block, the files swept
|
||||
are resolved from each rule's own `paths:`, and the plugin name is read from
|
||||
`.claude-plugin/plugin.json` — add a key to a rule and it is enforced on the next run with no test
|
||||
edit, because a hand-kept list of what to sweep is a premise, not a measurement. The repo was
|
||||
already compliant (7/7 agents, 21/21 commands, 0 duplicate colors), so a green first run proves
|
||||
nothing: every arm was seen red against a temporarily introduced defect **one at a time**,
|
||||
including emptying a rule's yaml block to show the derivation is not vacuous. The color **enum** is
|
||||
deliberately *not* guarded — the official subagent docs list `red/blue/green/yellow/purple/orange/
|
||||
pink/cyan` (no `magenta`) while issue #19292 lists `magenta` but neither `purple` nor `orange`, and
|
||||
this plugin ships both `magenta` and `orange`; pinning an unsettled set in a guard would encode an
|
||||
unverified premise rather than measure one.
|
||||
|
||||
## Conventions
|
||||
|
||||
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
|
||||
|
|
@ -197,6 +228,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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,15 @@ Checking for secrets...
|
|||
|
||||
## Output Format
|
||||
|
||||
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
|
||||
Return the report below as your final message. Do NOT write it to a file: this
|
||||
agent is read-only by design (`tools: Read, Glob, Grep`) and has no write tool,
|
||||
so a write instruction here would be a contract it cannot keep.
|
||||
|
||||
The orchestrator appends what you return to
|
||||
`~/.claude/config-audit/sessions/{session-id}/implementation-log.md` itself,
|
||||
with Bash `>>` (`commands/implement.md` Step 5) — never the Write tool. That log
|
||||
is shared with the implementer agents running in parallel, and a full-file Write
|
||||
on it silently clobbers their entries.
|
||||
|
||||
```markdown
|
||||
## Verification Report
|
||||
|
|
@ -243,8 +251,8 @@ Optional: Generate before/after comparison:
|
|||
|
||||
This agent:
|
||||
- Only uses Read, Glob, Grep tools
|
||||
- Never modifies any files
|
||||
- Reports findings without taking action
|
||||
- Never modifies any files, including the shared implementation log
|
||||
- Reports findings without taking action — every result is returned inline
|
||||
- Safe to run multiple times
|
||||
|
||||
## Model policy
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
109
tests/agents/agent-write-contract.test.mjs
Normal file
109
tests/agents/agent-write-contract.test.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* R4 — an agent file must not carry a contract its `tools:` cannot keep.
|
||||
*
|
||||
* `agents/verifier-agent.md` said both things at once: §Output Format
|
||||
* ("Append to: implementation-log.md") and §Read-Only Guarantee ("never
|
||||
* modifies any files"), while its frontmatter granted only Read/Glob/Grep.
|
||||
* Which instruction wins is nondeterministic, and the failure mode is not
|
||||
* "the write fails" — it is the agent improvising a full-file Write on the
|
||||
* SHARED implementation log, clobbering parallel implementer entries. That is
|
||||
* exactly the defect `implement-log-append.test.mjs` exists to prevent,
|
||||
* entering through the one file that test does not read.
|
||||
*
|
||||
* The guard is the blanket invariant over the whole agents/ catalogue, not a
|
||||
* statement about verifier-agent: any agent whose tools grant no write
|
||||
* capability must (a) instruct no file write, and (b) say positively that it
|
||||
* returns its findings inline. Both sides are read from the file — the tools
|
||||
* list AND the body — so removing a write tool from any agent whose body still
|
||||
* instructs a write turns this red.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const AGENTS_DIR = resolve(ROOT, 'agents');
|
||||
|
||||
/** Tools that can put bytes on disk. Bash counts: `>>` is a write. */
|
||||
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'Bash']);
|
||||
|
||||
/**
|
||||
* Directive lines that tell the agent to put its output in a file.
|
||||
* Anchored to line start so prose ABOUT writing ("do not write it to a file")
|
||||
* is not caught — the defect is an instruction, not a mention.
|
||||
*/
|
||||
const WRITE_DIRECTIVE_RE =
|
||||
/^(?:\*\*)?(?:Append|Write|Save|Output|Persist)\b(?![^\n]*\bnot\b)[^\n]*?(?:\bto\b|`[^`\n]+\.(?:md|ya?ml|json)`)/mi;
|
||||
|
||||
/** The positive half: the file must say the findings come back inline. */
|
||||
const RETURN_INLINE_RE =
|
||||
/\breturn\b[^.]{0,120}?\b(?:as\s+your\s+final\s+message|inline)\b/i;
|
||||
|
||||
function frontmatterOf(content) {
|
||||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function bodyOf(content) {
|
||||
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
|
||||
return m ? content.slice(m[0].length) : content;
|
||||
}
|
||||
|
||||
function toolsOf(frontmatter) {
|
||||
const m = frontmatter.match(/^tools:\s*(.+)$/m);
|
||||
if (!m) return [];
|
||||
return (m[1].match(/[A-Za-z_][A-Za-z0-9_]*/g) || []);
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const names = (await readdir(AGENTS_DIR)).filter((n) => n.endsWith('.md')).sort();
|
||||
return Promise.all(
|
||||
names.map(async (name) => {
|
||||
const content = await readFile(resolve(AGENTS_DIR, name), 'utf-8');
|
||||
const frontmatter = frontmatterOf(content);
|
||||
const tools = toolsOf(frontmatter);
|
||||
return {
|
||||
name,
|
||||
body: bodyOf(content),
|
||||
tools,
|
||||
canWrite: tools.some((t) => WRITE_TOOLS.has(t)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test('R4 sweep is not vacuous: the agents catalogue is read and at least one agent has no write tool', async () => {
|
||||
const agents = await loadAgents();
|
||||
assert.ok(agents.length >= 7,
|
||||
`expected the agents/ catalogue to be swept, got ${agents.length} files`);
|
||||
assert.ok(agents.every((a) => a.tools.length > 0),
|
||||
`every agent must declare tools:, missing in ${agents.filter((a) => !a.tools.length).map((a) => a.name).join(', ')}`);
|
||||
const writeless = agents.filter((a) => !a.canWrite);
|
||||
assert.ok(writeless.length >= 1,
|
||||
'no write-tool-less agent found — the invariant below would be vacuously green');
|
||||
});
|
||||
|
||||
test('R4: no agent instructs a file write its tools cannot perform', async () => {
|
||||
const agents = await loadAgents();
|
||||
for (const agent of agents.filter((a) => !a.canWrite)) {
|
||||
const offending = agent.body.match(WRITE_DIRECTIVE_RE);
|
||||
assert.ok(
|
||||
offending === null,
|
||||
`${agent.name} grants no write tool (tools: ${agent.tools.join(', ')}) but instructs a write: ${JSON.stringify(offending && offending[0])}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('R4: a write-tool-less agent states positively that it returns findings inline', async () => {
|
||||
const agents = await loadAgents();
|
||||
for (const agent of agents.filter((a) => !a.canWrite)) {
|
||||
assert.ok(
|
||||
RETURN_INLINE_RE.test(agent.body),
|
||||
`${agent.name} has no write tool, so it must say its findings are returned as its final message`,
|
||||
);
|
||||
}
|
||||
});
|
||||
138
tests/agents/frontmatter-contract.test.mjs
Normal file
138
tests/agents/frontmatter-contract.test.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* R6 — the two "Required Frontmatter" contracts are enforced by something.
|
||||
*
|
||||
* `.claude/rules/agent-development.md` and `.claude/rules/command-development.md`
|
||||
* write down which frontmatter keys an agent/command MUST carry, and that agent
|
||||
* colors must be unique within the plugin. Nothing enforced any of it: the only
|
||||
* test that looked at agent frontmatter (`agent-prompt-shape`) asserted `name:`
|
||||
* on a HAND-WRITTEN list of 3 of the 7 agents. Everything was compliant and
|
||||
* unwatched — the state in which a rule becomes fiction one file at a time.
|
||||
*
|
||||
* Nothing here is hand-maintained, because a hand-kept list of what to sweep is
|
||||
* a premise, not a measurement (the #57 shape):
|
||||
* - the required KEYS are parsed out of each rule's own ```yaml block;
|
||||
* - the files swept are resolved from each rule's own `paths:` frontmatter;
|
||||
* - the plugin name is read from `.claude-plugin/plugin.json`.
|
||||
* Add a key to a rule and it is enforced on the next run, with no test edit.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const RULES_DIR = resolve(ROOT, '.claude', 'rules');
|
||||
|
||||
function frontmatterOf(content) {
|
||||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/** Top-level keys of a frontmatter block, in source order. */
|
||||
function topLevelKeys(frontmatter) {
|
||||
return frontmatter
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^([A-Za-z][A-Za-z0-9_-]*):/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
}
|
||||
|
||||
function valueOf(frontmatter, key) {
|
||||
const m = frontmatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'));
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule file is the contract. Read the required keys out of the ```yaml block
|
||||
* under "## Required Frontmatter", and the swept directory out of `paths:`.
|
||||
*/
|
||||
async function loadRule(fileName) {
|
||||
const content = await readFile(resolve(RULES_DIR, fileName), 'utf-8');
|
||||
const paths = valueOf(frontmatterOf(content), 'paths');
|
||||
const fence = content.match(/##\s+Required Frontmatter[\s\S]*?```ya?ml\r?\n([\s\S]*?)```/);
|
||||
const requiredKeys = fence ? topLevelKeys(fence[1]).filter((k) => k !== '---') : [];
|
||||
const dir = paths ? paths.split('/')[0] : null;
|
||||
return { fileName, paths, dir, requiredKeys };
|
||||
}
|
||||
|
||||
async function loadTargets(rule) {
|
||||
const dirAbs = resolve(ROOT, rule.dir);
|
||||
const names = (await readdir(dirAbs)).filter((n) => n.endsWith('.md')).sort();
|
||||
return Promise.all(
|
||||
names.map(async (name) => {
|
||||
const frontmatter = frontmatterOf(await readFile(resolve(dirAbs, name), 'utf-8'));
|
||||
return { name, frontmatter, keys: topLevelKeys(frontmatter) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const AGENT_RULE = 'agent-development.md';
|
||||
const COMMAND_RULE = 'command-development.md';
|
||||
|
||||
test('R6 derivation is not vacuous: both rules yield required keys and a non-empty file set', async () => {
|
||||
for (const fileName of [AGENT_RULE, COMMAND_RULE]) {
|
||||
const rule = await loadRule(fileName);
|
||||
assert.ok(rule.requiredKeys.length > 0,
|
||||
`${fileName}: no required keys parsed from its "Required Frontmatter" yaml block — every assertion below would be vacuously green`);
|
||||
assert.ok(rule.dir, `${fileName}: no paths: frontmatter to resolve a file set from`);
|
||||
const targets = await loadTargets(rule);
|
||||
assert.ok(targets.length > 0,
|
||||
`${fileName}: paths: ${rule.paths} resolved to 0 files — the sweep would prove nothing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('R6: every agent and command carries the keys its rule requires, non-empty', async () => {
|
||||
for (const fileName of [AGENT_RULE, COMMAND_RULE]) {
|
||||
const rule = await loadRule(fileName);
|
||||
const targets = await loadTargets(rule);
|
||||
for (const target of targets) {
|
||||
for (const key of rule.requiredKeys) {
|
||||
assert.ok(target.keys.includes(key),
|
||||
`${rule.dir}/${target.name} is missing required frontmatter key "${key}" (required by .claude/rules/${fileName}; ${targets.length} files swept)`);
|
||||
const value = valueOf(target.frontmatter, key);
|
||||
assert.ok(value !== null && value !== '',
|
||||
`${rule.dir}/${target.name} has an empty "${key}" — a present-but-empty key satisfies no contract`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('R6: agent colors are unique within the plugin', async () => {
|
||||
const rule = await loadRule(AGENT_RULE);
|
||||
const targets = await loadTargets(rule);
|
||||
const seen = new Map();
|
||||
for (const target of targets) {
|
||||
const color = valueOf(target.frontmatter, 'color');
|
||||
if (seen.has(color)) {
|
||||
assert.fail(`duplicate agent color "${color}": ${seen.get(color)} and ${target.name} (.claude/rules/${AGENT_RULE}: "Color must be unique within the plugin")`);
|
||||
}
|
||||
seen.set(color, target.name);
|
||||
}
|
||||
assert.equal(seen.size, targets.length, `expected ${targets.length} distinct colors, got ${seen.size}`);
|
||||
});
|
||||
|
||||
test('R6: agent names are kebab-case with the -agent suffix', async () => {
|
||||
const rule = await loadRule(AGENT_RULE);
|
||||
for (const target of await loadTargets(rule)) {
|
||||
const name = valueOf(target.frontmatter, 'name');
|
||||
assert.match(name, /^[a-z0-9]+(?:-[a-z0-9]+)*-agent$/,
|
||||
`agents/${target.name}: name "${name}" must be kebab-case with an -agent suffix`);
|
||||
}
|
||||
});
|
||||
|
||||
test('R6: command names are plugin:action, or the bare plugin name for the router', async () => {
|
||||
const plugin = JSON.parse(await readFile(resolve(ROOT, '.claude-plugin', 'plugin.json'), 'utf-8')).name;
|
||||
const rule = await loadRule(COMMAND_RULE);
|
||||
const targets = await loadTargets(rule);
|
||||
const routers = [];
|
||||
for (const target of targets) {
|
||||
const name = valueOf(target.frontmatter, 'name');
|
||||
if (name === plugin) { routers.push(target.name); continue; }
|
||||
assert.match(name, new RegExp(`^${plugin}:[a-z0-9]+(?:-[a-z0-9]+)*$`),
|
||||
`commands/${target.name}: name "${name}" must be "${plugin}:action" (or the bare "${plugin}" router)`);
|
||||
}
|
||||
assert.equal(routers.length, 1, `expected exactly one bare-"${plugin}" router command, got ${routers.length}: ${routers.join(', ')}`);
|
||||
});
|
||||
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