/** * 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 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 }); } });