`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
267 lines
11 KiB
JavaScript
267 lines
11 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
import { readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { tmpdir, homedir } from 'node:os';
|
|
import { resolve } from 'node:path';
|
|
import {
|
|
createBackup,
|
|
getBackupDir,
|
|
getLegacyBackupDir,
|
|
parseManifest,
|
|
} from '../../scanners/lib/backup.mjs';
|
|
import { listBackups, restoreBackup } from '../../scanners/rollback-engine.mjs';
|
|
|
|
// ========================================
|
|
// Backup root — canonical vs legacy (M-BUG-22)
|
|
//
|
|
// Dogfood step 4 found the rollback engine reading ~/.config-audit/backups
|
|
// while every command, agent and doc writes to ~/.claude/config-audit/backups.
|
|
// listBackups() saw 0 of the 4 real backups on the operator's machine and
|
|
// restoreBackup() threw "Backup not found" for a backup that was right there.
|
|
// ========================================
|
|
|
|
const CANONICAL = join(homedir(), '.claude', 'config-audit', 'backups');
|
|
const LEGACY = join(homedir(), '.config-audit', 'backups');
|
|
|
|
/** Unique temp dir per test, cleaned up by the caller. */
|
|
function tempRoot(tag) {
|
|
const d = join(tmpdir(), `ca-rbpaths-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
mkdirSync(d, { recursive: true });
|
|
return d;
|
|
}
|
|
|
|
describe('backup root resolution', () => {
|
|
const saved = {};
|
|
|
|
beforeEach(() => {
|
|
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
|
|
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
|
|
delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
|
|
delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
|
|
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
|
|
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
|
|
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
|
|
});
|
|
|
|
it('getBackupDir() defaults to the canonical ~/.claude/config-audit/backups', () => {
|
|
assert.strictEqual(getBackupDir(), CANONICAL);
|
|
});
|
|
|
|
it('getLegacyBackupDir() exposes the pre-v2.2.0 path', () => {
|
|
assert.strictEqual(getLegacyBackupDir(), LEGACY);
|
|
});
|
|
|
|
it('the two roots are distinct (guards against a copy-paste fix)', () => {
|
|
assert.notStrictEqual(getBackupDir(), getLegacyBackupDir());
|
|
});
|
|
|
|
it('getBackupDir() honours CONFIG_AUDIT_BACKUP_ROOT so tests stay hermetic', () => {
|
|
const root = tempRoot('env');
|
|
try {
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
|
|
assert.strictEqual(getBackupDir(), root);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('createBackup() writes under the overridden root, not the real home (M-BUG-24)', () => {
|
|
const root = tempRoot('write');
|
|
const work = tempRoot('work');
|
|
try {
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
|
|
const target = join(work, 'settings.json');
|
|
writeFileSync(target, '{"original": true}');
|
|
|
|
const { backupPath } = createBackup([target]);
|
|
|
|
assert.ok(backupPath.startsWith(root), `backup landed outside the override: ${backupPath}`);
|
|
assert.ok(!backupPath.startsWith(homedir() + '/.config-audit'), 'must not write to the real home');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('listBackups / restoreBackup across both roots', () => {
|
|
const saved = {};
|
|
let canonical, legacy, work;
|
|
|
|
beforeEach(() => {
|
|
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
|
|
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
|
|
canonical = tempRoot('canon');
|
|
legacy = tempRoot('leg');
|
|
work = tempRoot('work');
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
|
|
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = legacy;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
|
|
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
|
|
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
|
|
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
|
|
for (const d of [canonical, legacy, work]) rmSync(d, { recursive: true, force: true });
|
|
});
|
|
|
|
it('lists a backup that lives in the canonical root', async () => {
|
|
const target = join(work, 'settings.json');
|
|
writeFileSync(target, '{"original": true}');
|
|
const { backupId } = createBackup([target]);
|
|
|
|
const { backups } = await listBackups();
|
|
assert.ok(backups.some(b => b.id === backupId), 'canonical backup must be listed');
|
|
});
|
|
|
|
it('still lists backups left behind in the legacy root', async () => {
|
|
// Seed a legacy-root backup by pointing the writer at it temporarily.
|
|
const target = join(work, 'legacy.json');
|
|
writeFileSync(target, '{"legacy": true}');
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
|
|
const { backupId } = createBackup([target]);
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
|
|
|
|
const { backups } = await listBackups();
|
|
const found = backups.find(b => b.id === backupId);
|
|
assert.ok(found, 'a pre-v2.2.0 backup must not become invisible after the path fix');
|
|
assert.strictEqual(found.legacy, true, 'legacy backups should be flagged as such');
|
|
});
|
|
|
|
it('restores a backup that only exists in the legacy root', async () => {
|
|
const target = join(work, 'legacy-restore.json');
|
|
writeFileSync(target, '{"original": true}');
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
|
|
const { backupId } = createBackup([target]);
|
|
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
|
|
|
|
writeFileSync(target, '{"modified": true}');
|
|
const result = await restoreBackup(backupId, { repoRoot: work });
|
|
|
|
assert.strictEqual(result.failed.length, 0, 'no failures expected');
|
|
assert.strictEqual(result.restored.length, 1, 'the legacy backup should resolve');
|
|
assert.strictEqual(await readFile(target, 'utf-8'), '{"original": true}');
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// 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
|
|
// returned files: [] and restoreBackup reported success having restored
|
|
// nothing — a success-shaped no-op, the worst failure mode in the file.
|
|
// ========================================
|
|
|
|
const ENGINE_MANIFEST = `created_at: "2026-07-17T03:26:36.000Z"
|
|
backup_id: "20260717_032636"
|
|
files:
|
|
- original_path: "/tmp/x/CLAUDE.md"
|
|
backup_path: "./files/_tmp_x_CLAUDE.md"
|
|
checksum: "6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12"
|
|
size_bytes: 42
|
|
`;
|
|
|
|
const IMPLEMENT_MANIFEST = `session: 20260717_step3impl
|
|
created: 20260717_032636
|
|
target_root: /tmp/x
|
|
files:
|
|
- backup: files/project-claude/settings.local.json
|
|
original: /tmp/x/.claude/settings.local.json
|
|
sha256: f03f45699568218df0dce447d82954eeece34264f47783a1a2ff6b10cdc43ef9
|
|
- backup: files/nested-claude/settings.local.json
|
|
original: /tmp/x/posts/2026-01-23-ralph-wiggum/.claude/settings.local.json
|
|
sha256: 39a3723441892ce1cf987508fb448b6a656b65c8d194d1640c1915cd9098d3c6
|
|
- backup: files/root/CLAUDE.md
|
|
original: /tmp/x/CLAUDE.md
|
|
sha256: 6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12
|
|
`;
|
|
|
|
describe('parseManifest format compatibility', () => {
|
|
it('still parses the engine format unchanged', () => {
|
|
const m = parseManifest(ENGINE_MANIFEST);
|
|
assert.strictEqual(m.backup_id, '20260717_032636');
|
|
assert.strictEqual(m.files.length, 1);
|
|
assert.strictEqual(m.files[0].originalPath, '/tmp/x/CLAUDE.md');
|
|
assert.strictEqual(m.files[0].backupPath, './files/_tmp_x_CLAUDE.md');
|
|
assert.strictEqual(m.files[0].sizeBytes, 42);
|
|
});
|
|
|
|
it('parses the implement-flow format written by the agent', () => {
|
|
const m = parseManifest(IMPLEMENT_MANIFEST);
|
|
assert.strictEqual(m.files.length, 3, 'all three entries must be recognised');
|
|
assert.strictEqual(m.files[0].originalPath, '/tmp/x/.claude/settings.local.json');
|
|
assert.strictEqual(m.files[0].backupPath, 'files/project-claude/settings.local.json');
|
|
assert.strictEqual(
|
|
m.files[2].checksum,
|
|
'6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12',
|
|
);
|
|
});
|
|
|
|
it('picks up the implement-flow backup id from `created:`', () => {
|
|
const m = parseManifest(IMPLEMENT_MANIFEST);
|
|
assert.strictEqual(m.backup_id, '20260717_032636');
|
|
});
|
|
|
|
it('never returns a silent empty file list for a manifest that has entries', () => {
|
|
for (const [label, content] of [['engine', ENGINE_MANIFEST], ['implement', IMPLEMENT_MANIFEST]]) {
|
|
assert.ok(parseManifest(content).files.length > 0, `${label} manifest parsed to zero files`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Files created by implement are not restorable (B1 / M-BUG-26)
|
|
//
|
|
// A backup records only files that already existed. Rollback therefore cannot
|
|
// remove what implement CREATED. That is acceptable; doing it silently is not.
|
|
// ========================================
|
|
|
|
describe('created-file reporting', () => {
|
|
it('surfaces manifest `created:` entries so rollback can tell the user what it cannot undo', () => {
|
|
const manifest = IMPLEMENT_MANIFEST + `created:
|
|
- /tmp/x/.claude/rules/post-quality.md
|
|
- /tmp/x/guidelines/posting-rhythm.md
|
|
`;
|
|
const m = parseManifest(manifest);
|
|
assert.deepStrictEqual(m.created, [
|
|
'/tmp/x/.claude/rules/post-quality.md',
|
|
'/tmp/x/guidelines/posting-rhythm.md',
|
|
]);
|
|
});
|
|
|
|
it('defaults `created` to an empty array when the manifest has no such section', () => {
|
|
assert.deepStrictEqual(parseManifest(ENGINE_MANIFEST).created, []);
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Session-dir hooks (M-BUG-23)
|
|
//
|
|
// Both hooks watched ~/.config-audit/sessions, which does not exist on the
|
|
// operator's machine — sessions live in ~/.claude/config-audit/sessions. The
|
|
// hooks exited 0 every time, so "check for active sessions" never fired.
|
|
// Shape test: same layer as tests/commands/implement-log-append.test.mjs.
|
|
// ========================================
|
|
|
|
describe('session hooks point at the canonical sessions dir', () => {
|
|
for (const hook of ['session-start.mjs', 'stop-session-reminder.mjs']) {
|
|
it(`${hook} resolves ~/.claude/config-audit/sessions`, () => {
|
|
const src = readFileSync(resolve(import.meta.dirname, '../../hooks/scripts', hook), 'utf-8');
|
|
assert.match(
|
|
src,
|
|
/join\(\s*homedir\(\)\s*,\s*'\.claude'\s*,\s*'config-audit'\s*,\s*'sessions'\s*\)/,
|
|
`${hook} must watch the canonical sessions dir`,
|
|
);
|
|
});
|
|
}
|
|
});
|