Files
brain/tools/test-rollback.mjs
T

187 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
// tools/test-rollback.mjs — Rollback planner + executor for the LLM-first router overhaul.
//
// Plan: docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md Task 1.
// Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md §13 (rollback).
//
// Two responsibilities:
// 1. planRollback() — pure, returns a description of what rollback does (testable)
// 2. dryRun() / execRollback() — CLI entry points
//
// Safety:
// - execFileSync (no shell, no command injection)
// - Entry-point guard uses resolve() (Windows + Cyrillic paths safe, per quirk #103)
// - episodes-*.jsonl and observer/notes/* are PRESERVED, never reverted (G5/G6)
// - Parser stays forward-compatible to schema v4 after rollback (G5, Task 15)
import {
existsSync,
copyFileSync,
readdirSync,
rmSync,
mkdirSync,
statSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ARCHIVE = 'docs/archive/llm-bootstrap-2026-05';
/**
* Pure description of the rollback plan.
* Used by tools/test-rollback.test.mjs and as the source of truth for the CLI.
*/
export function planRollback() {
return {
gitTag: 'brain-pre-llm-bootstrap',
gitStrategy: 'git checkout brain-pre-llm-bootstrap -- <tracked paths>',
userLevelRestores: [
{
from: `${ARCHIVE}/settings-snapshot/user-settings.json.pre-overhaul`,
to: '~/.claude/settings.json',
},
{ from: `${ARCHIVE}/user-hooks/*`, to: '~/.claude/hooks/' },
],
flagStrategy: 'restore-snapshot-delete-new',
preserve: [
'docs/observer/episodes-*.jsonl',
'docs/observer/notes/*',
],
parserNote:
'после отката parser остаётся forward-compatible к v4 эпизодам (read-only graceful skip) — Task 15 (G5)',
};
}
/**
* Dry-run: verify rollback artefacts exist and surface missing ones.
* Returns true if rollback is ready, false otherwise.
*/
export function dryRun() {
const plan = planRollback();
let ok = true;
const baseSnap = `${ARCHIVE}/settings-snapshot/user-settings.json.pre-overhaul`;
if (!existsSync(baseSnap)) {
console.error('MISSING snapshot:', baseSnap);
ok = false;
}
const projSnap = `${ARCHIVE}/settings-snapshot/project-settings.json.pre-overhaul`;
if (!existsSync(projSnap)) {
console.error('MISSING snapshot:', projSnap);
ok = false;
}
const hooksDir = `${ARCHIVE}/user-hooks`;
if (!existsSync(hooksDir) || readdirSync(hooksDir).length === 0) {
console.error('MISSING or empty hooks snapshot:', hooksDir);
ok = false;
}
const nodesSnap = `${ARCHIVE}/nodes-yaml-archive/nodes.yaml.pre-overhaul`;
if (!existsSync(nodesSnap)) {
console.error('MISSING snapshot:', nodesSnap);
ok = false;
}
try {
execFileSync('git', ['rev-parse', plan.gitTag], { stdio: 'pipe' });
} catch {
console.error('MISSING git tag:', plan.gitTag);
ok = false;
}
console.log(ok ? '[dry-run] OK — rollback ready' : '[dry-run] FAIL — see above');
return ok;
}
/**
* Execute rollback of user-level state + runtime flags.
* Git-tracked rollback is left to the operator (separate manual step in ROLLBACK.md)
* to keep destructive `git checkout` explicit.
*/
export function execRollback() {
const home = homedir();
// 1. user settings.json
const usFrom = `${ARCHIVE}/settings-snapshot/user-settings.json.pre-overhaul`;
if (existsSync(usFrom)) {
copyFileSync(usFrom, join(home, '.claude', 'settings.json'));
console.log('[execute] restored ~/.claude/settings.json');
} else {
console.error('[execute] SKIP user settings — snapshot missing');
}
// 2. user hooks (full directory restore — wipe new hooks, restore snapshot)
const hooksSrc = `${ARCHIVE}/user-hooks`;
const hooksDst = join(home, '.claude', 'hooks');
if (existsSync(hooksSrc)) {
if (!existsSync(hooksDst)) mkdirSync(hooksDst, { recursive: true });
// wipe current
for (const f of readdirSync(hooksDst)) {
const fp = join(hooksDst, f);
if (statSync(fp).isFile()) rmSync(fp);
}
// restore snapshot
let count = 0;
for (const f of readdirSync(hooksSrc)) {
const sp = join(hooksSrc, f);
if (statSync(sp).isFile()) {
copyFileSync(sp, join(hooksDst, f));
count++;
}
}
console.log(`[execute] restored ~/.claude/hooks/ (${count} files)`);
} else {
console.error('[execute] SKIP user hooks — snapshot missing');
}
// 3. runtime flags: delete *-mode.json files not present in snapshot, restore snapshot files
const runtimeDir = join(home, '.claude', 'runtime');
const snapDir = `${ARCHIVE}/runtime-flags-snapshot`;
if (existsSync(runtimeDir)) {
const snapFlags = existsSync(snapDir) ? readdirSync(snapDir) : [];
let deleted = 0;
for (const f of readdirSync(runtimeDir).filter((x) => x.endsWith('-mode.json'))) {
if (!snapFlags.includes(f)) {
rmSync(join(runtimeDir, f));
deleted++;
}
}
let restored = 0;
for (const f of snapFlags) {
copyFileSync(join(snapDir, f), join(runtimeDir, f));
restored++;
}
console.log(
`[execute] runtime flags: deleted ${deleted} new, restored ${restored} from snapshot`,
);
} else {
console.error('[execute] SKIP runtime flags — ~/.claude/runtime/ missing');
}
console.log(
'[execute] user-level + flags restored. ' +
'Now run: git checkout brain-pre-llm-bootstrap -- . && npm install',
);
}
// Entry-point guard — Cyrillic-safe (quirk #103: import.meta.url === argv[1] fails on RU paths).
const argv1 = process.argv[1] ? resolve(process.argv[1]) : '';
const here = fileURLToPath(import.meta.url);
const isMain = argv1 && argv1 === here;
if (isMain) {
const mode = process.argv[2];
if (mode === '--dry-run') {
process.exit(dryRun() ? 0 : 1);
} else if (mode === '--execute') {
execRollback();
} else {
console.log('usage: node tools/test-rollback.mjs --dry-run | --execute');
console.log('');
console.log(' --dry-run verify rollback artefacts are in place; exit 0 if ready');
console.log(' --execute restore user-level state + runtime flags from snapshot');
console.log(' (run "git checkout brain-pre-llm-bootstrap -- ." separately)');
}
}