ktg-plugin-marketplace/plugins/voyage/tests/lib/revision-guard.test.mjs
Kjell Tore Guttormsen dcf0c7ad02 feat(voyage): add markdown-write.mjs + revision-guard.mjs + forward-compat policy comments — v4.2 Step 1
- 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)
2026-05-09 12:48:40 +02:00

135 lines
5.2 KiB
JavaScript

// tests/lib/revision-guard.test.mjs
// Unit tests for lib/util/revision-guard.mjs (v4.2)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, copyFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { revisionGuard } from '../../lib/util/revision-guard.mjs';
import { atomicWriteMarkdown } from '../../lib/util/markdown-write.mjs';
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
const ALWAYS_VALID = () => ({ valid: true, errors: [], warnings: [] });
const ALWAYS_INVALID = () => ({ valid: false, errors: [{ code: 'TEST', message: 'forced fail' }], warnings: [] });
test('revisionGuard — validator-PASS commits revision and deletes bak', () => {
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n');
const r = revisionGuard(
path,
({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }),
ALWAYS_VALID,
);
assert.equal(r.outcome, 'applied');
assert.ok(!existsSync(path + '.local.bak'), 'bak should be deleted on success');
const text = readFileSync(path, 'utf-8');
assert.match(text, /revision: 1/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('revisionGuard — validator-FAIL rolls back to byte-identical pre-revision', () => {
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n');
const before = sha256(path);
const r = revisionGuard(
path,
({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }),
ALWAYS_INVALID,
);
assert.equal(r.outcome, 'rolled-back');
const after = sha256(path);
assert.equal(after, before, 'rollback must restore byte-identical content');
assert.ok(!existsSync(path + '.local.bak'), 'bak should be cleaned up after rollback');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('revisionGuard — pre-existing .local.bak aborts with operator guidance', () => {
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n');
const bak = path + '.local.bak';
writeFileSync(bak, 'stale backup from prior run');
const r = revisionGuard(path, ({ frontmatter, body }) => ({ frontmatter, body }), ALWAYS_VALID);
assert.equal(r.outcome, 'mutator-failed');
assert.match(r.error, /pre-existing backup/);
// Original file untouched, stale bak preserved for operator inspection
assert.equal(readFileSync(bak, 'utf-8'), 'stale backup from prior run');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('revisionGuard — mutator that throws restores original via bak', () => {
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n');
const before = sha256(path);
const r = revisionGuard(
path,
() => { throw new Error('boom'); },
ALWAYS_VALID,
);
assert.equal(r.outcome, 'mutator-failed');
assert.match(r.error, /boom/);
const after = sha256(path);
assert.equal(after, before, 'mutator-throw must preserve original');
assert.ok(!existsSync(path + '.local.bak'), 'bak cleaned up after mutator-throw');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('revisionGuard — mutator returns invalid object rejected before validator runs', () => {
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n');
const before = sha256(path);
let validatorCalled = false;
const r = revisionGuard(
path,
() => null, // not an object
() => { validatorCalled = true; return { valid: true, errors: [], warnings: [] }; },
);
assert.equal(r.outcome, 'mutator-failed');
assert.equal(validatorCalled, false, 'validator must not run if mutator returned invalid result');
const after = sha256(path);
assert.equal(after, before, 'invalid mutator must preserve original');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('revisionGuard — sha256 fields populated and stable', () => {
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n');
const before = sha256(path);
const r = revisionGuard(
path,
({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }),
ALWAYS_VALID,
);
assert.equal(r.sha256_before, before);
assert.equal(typeof r.sha256_after, 'string');
assert.notEqual(r.sha256_after, r.sha256_before, 'sha256 must change after applied revision');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});