// lib/util/revision-guard.mjs // Pre-write backup -> mutate -> atomic write -> post-write validate -> // restore-on-fail orchestration for /trekrevise (v4.2). // // Extracted from commands/trekrevise.md so the rollback logic can be // unit-tested independently of the prompt-instruction file. The command // imports revisionGuard() and supplies the validator callback (one of // validateBrief / validatePlan / validateReview). // // Behavior: // 1. Compute sha256_before // 2. cp path path.local.bak (backup) // 3. readAndUpdate(path, mutator) (atomic) // 4. validator(path) — if validator says invalid, restore from bak // 5. delete bak on success; preserve bak + return rolled-back on failure // // Crash semantics: tmp+rename in atomicWriteMarkdown means a crash // between steps 2 and 3 leaves either the original (if rename hadn't // completed) or the new content (if rename had); bak file always reflects // the pre-revision state so manual recovery is possible. import { copyFileSync, unlinkSync, readFileSync, existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { readAndUpdate } from './markdown-write.mjs'; function sha256(path) { if (!existsSync(path)) return null; const buf = readFileSync(path); return createHash('sha256').update(buf).digest('hex'); } /** * Guard a markdown revision with pre-backup + post-validate + rollback. * * @param {string} path - markdown file to revise (in-place) * @param {Function} mutator - ({frontmatter, body}) => {frontmatter, body} * @param {Function} validator - (path) => {valid: bool, errors: [...], warnings: [...]} * @returns {{outcome: 'applied'|'rolled-back'|'mutator-failed', * validator_result, sha256_before, sha256_after, * bak_path?: string, error?: string}} */ export function revisionGuard(path, mutator, validator) { const sha256_before = sha256(path); if (sha256_before === null) { return { outcome: 'mutator-failed', error: `file does not exist: ${path}`, sha256_before: null, sha256_after: null }; } const bak = path + '.local.bak'; if (existsSync(bak)) { return { outcome: 'mutator-failed', error: `pre-existing backup at ${bak} — verify it is safe to overwrite, then delete it manually before re-running`, sha256_before, sha256_after: sha256_before, bak_path: bak, }; } copyFileSync(path, bak); let mutateResult; try { mutateResult = readAndUpdate(path, mutator); } catch (e) { // mutator threw — restore from bak, preserve original byte-identical copyFileSync(bak, path); unlinkSync(bak); return { outcome: 'mutator-failed', error: `mutator threw: ${e.message}`, sha256_before, sha256_after: sha256(path), }; } if (!mutateResult.valid) { copyFileSync(bak, path); unlinkSync(bak); return { outcome: 'mutator-failed', error: `mutator returned invalid result: ${(mutateResult.errors || []).map(e => e.code || e.message).join(', ')}`, sha256_before, sha256_after: sha256(path), }; } const validator_result = validator(path); const sha256_after_write = sha256(path); if (!validator_result.valid) { // Validator failed — restore from bak copyFileSync(bak, path); unlinkSync(bak); return { outcome: 'rolled-back', validator_result, sha256_before, sha256_after: sha256(path), }; } // Validator passed — keep new content, delete bak unlinkSync(bak); return { outcome: 'applied', validator_result, sha256_before, sha256_after: sha256_after_write, }; }