- lib/util/markdown-write.mjs: serializeFrontmatter (subset matches frontmatter.mjs parser), atomicWriteMarkdown (single tmp+rename, body bytes verbatim), readAndUpdate (read+mutate+write). - lib/util/revision-guard.mjs: revisionGuard(path, mutator, validator) — backup -> mutate -> validate -> restore-on-fail. Extracted from /trekrevise prompt so rollback can be unit-tested. - 12 tests for markdown-write, including 6-key source_annotations round-trip + walk-all-fixtures regression. - 6 tests for revision-guard: applied/rolled-back/mutator-failed/sha256 stability/pre-existing-bak abort. - Forward-compat policy comments in 3 validators (brief/plan/review) — non-functional pin against future strict-key refactors. Pass: 508/510 (was 490; +18 net from v4.2 Step 1, 2 skipped Docker)
110 lines
3.6 KiB
JavaScript
110 lines
3.6 KiB
JavaScript
// 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,
|
|
};
|
|
}
|