`write-scope.mjs` has existed since M-BUG-41, but only one writer ever called it. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate; 21 command templates, 17 mention a write, 5 call `write-scope-cli`. Five templates paraphrasing one policy is the shape that put the lever table in five copies (#61) — one level up. The defect was never "8 ungated writers = 8 bugs". Four of them write the plugin's own bookkeeping and must STAY ungated: a gate that fires on every run gets switched off, and then it guards nothing. The defect is that nothing declared WHICH, so the question was answered by reading, and answered differently each time it was asked. `tests/lib/write-gate-coverage.test.mjs` makes the answer structural: every writer either imports the gate or holds an EXEMPT entry naming where the bytes land. Seen RED against today's tree before the fix (4 ungated writers), and each of its four assertions was separately seen red against its own defect. Two premises in the plan text were falsified by measuring them first: - `scan-orchestrator` was carried as "plugin-managed, legitimately exempt". `--save-baseline` derives its path from the SCAN TARGET, so `--global` lands `~/.claude/.config-audit-baseline.json` — user-scope, require-ok. It is gated. `lib/baseline.mjs` is the genuinely exempt one. - the first sweep scored 9 writers with a regex that could not match `writeFileSync(`, so `lib/backup.mjs` — a real writer — read as clean. The guard covers sync and async forms, strips comments before matching, and asserts non-emptiness so a regex that stops matching cannot make every other assertion vacuously green (#63, #64). Gated: fix-engine, rollback-engine, campaign-export-cli, scan-orchestrator. All five call sites share ONE reduction, `evaluateWriteTargets` — four copies of classify/strongestGate/dedup is the drift this exists to prevent. `campaign export` still DISCLOSES rather than refuses: cross-repo is by design there, and tightening it into a refusal would break the feature. A dry run is still not a write, so it is never gated (#63). A refusal is a verdict about a config that WAS examined, so it rides in the payload and keeps the 0/1/2 exit contract (#62) — and the verdict now reaches the success payload too, since stderr is discarded by `2>/dev/null` (F3's class). commands/fix.md carries `--approve-scope` from the answer the user gives, with the rule stated where it can be read: classifying is not approving. Dogfooded end to end: a target outside the session root refuses with zero bytes written, then applies under `--approve-scope`. Suite 1703 -> 1707/0. Frozen v5.0.0 + default-output snapshots: 0 changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pkn22uGCgk6QZA738zNmHL
135 lines
5.3 KiB
JavaScript
135 lines
5.3 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
import { writeFile, readFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir, homedir } from 'node:os';
|
|
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
|
|
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
|
|
|
|
// Keep every backup this file creates inside a temp root. Without this the
|
|
// suite writes into the operator's real ~/.claude/config-audit/backups, where
|
|
// cleanupOldBackups() would start deleting genuine backups past MAX_BACKUPS.
|
|
const TEST_BACKUP_ROOT = join(tmpdir(), `config-audit-rb-root-${process.pid}`);
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = TEST_BACKUP_ROOT;
|
|
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = join(TEST_BACKUP_ROOT, 'legacy');
|
|
|
|
/** Create a temp file and back it up, returning paths and content. */
|
|
async function setupTestBackup() {
|
|
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
|
|
const testFile = join(tmpDir, 'test-settings.json');
|
|
const originalContent = '{"original": true, "key": "value"}';
|
|
writeFileSync(testFile, originalContent);
|
|
|
|
const backup = createBackup([testFile]);
|
|
|
|
// Now modify the file to simulate a change
|
|
writeFileSync(testFile, '{"modified": true}');
|
|
|
|
return { tmpDir, testFile, originalContent, backup };
|
|
}
|
|
|
|
describe('listBackups', () => {
|
|
it('returns an array of backups', async () => {
|
|
const result = await listBackups();
|
|
assert.ok(Array.isArray(result.backups), 'Should return backups array');
|
|
});
|
|
|
|
it('backups are sorted newest first', async () => {
|
|
const result = await listBackups();
|
|
if (result.backups.length >= 2) {
|
|
assert.ok(result.backups[0].id >= result.backups[1].id, 'First backup should be newer');
|
|
}
|
|
});
|
|
|
|
it('each backup has required fields', async () => {
|
|
const { tmpDir, backup } = await setupTestBackup();
|
|
try {
|
|
const result = await listBackups();
|
|
const found = result.backups.find(b => b.id === backup.backupId);
|
|
assert.ok(found, 'Should find our test backup');
|
|
assert.ok(found.id, 'Backup should have id');
|
|
assert.ok(found.createdAt, 'Backup should have createdAt');
|
|
assert.ok(Array.isArray(found.files), 'Backup should have files array');
|
|
assert.ok(found.files.length > 0, 'Backup should have at least one file');
|
|
assert.ok(found.files[0].originalPath, 'File entry should have originalPath');
|
|
assert.ok(found.files[0].checksum, 'File entry should have checksum');
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('restoreBackup', () => {
|
|
let tmpDir, testFile, originalContent, backup;
|
|
|
|
beforeEach(async () => {
|
|
({ tmpDir, testFile, originalContent, backup } = await setupTestBackup());
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
// Cleanup our test backup
|
|
try { await deleteBackup(backup.backupId); } catch {}
|
|
});
|
|
|
|
it('restores files to original content', async () => {
|
|
const result = await restoreBackup(backup.backupId, { repoRoot: tmpDir });
|
|
assert.ok(result.restored.length > 0, 'Should restore at least one file');
|
|
assert.strictEqual(result.failed.length, 0, 'No failures');
|
|
|
|
const restoredContent = await readFile(testFile, 'utf-8');
|
|
assert.strictEqual(restoredContent, originalContent, 'Content should match original');
|
|
});
|
|
|
|
it('verifies checksums after restore', async () => {
|
|
const result = await restoreBackup(backup.backupId, { verify: true, repoRoot: tmpDir });
|
|
for (const r of result.restored) {
|
|
assert.strictEqual(r.status, 'restored');
|
|
}
|
|
});
|
|
|
|
it('dry-run returns plan without writing', async () => {
|
|
const result = await restoreBackup(backup.backupId, { dryRun: true, repoRoot: tmpDir });
|
|
assert.ok(result.restored.length > 0);
|
|
for (const r of result.restored) {
|
|
assert.strictEqual(r.status, 'dry-run');
|
|
}
|
|
|
|
// File should still be modified
|
|
const content = await readFile(testFile, 'utf-8');
|
|
assert.strictEqual(content, '{"modified": true}', 'File should not be restored in dry-run');
|
|
});
|
|
|
|
it('throws for invalid backup-id', async () => {
|
|
await assert.rejects(
|
|
() => restoreBackup('nonexistent_99999999_999999'),
|
|
{ message: /Backup not found/ },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('deleteBackup', () => {
|
|
it('deletes an existing backup', async () => {
|
|
const { tmpDir, backup } = await setupTestBackup();
|
|
try {
|
|
const result = await deleteBackup(backup.backupId);
|
|
assert.strictEqual(result.deleted, true);
|
|
|
|
// Verify it's gone from the list
|
|
const list = await listBackups();
|
|
const found = list.backups.find(b => b.id === backup.backupId);
|
|
assert.ok(!found, 'Deleted backup should not appear in list');
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns error for nonexistent backup', async () => {
|
|
const result = await deleteBackup('nonexistent_99999999_999999');
|
|
assert.strictEqual(result.deleted, false);
|
|
assert.ok(result.error, 'Should have error message');
|
|
});
|
|
});
|