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)
This commit is contained in:
Kjell Tore Guttormsen 2026-05-09 12:48:40 +02:00
commit dcf0c7ad02
7 changed files with 584 additions and 0 deletions

View file

@ -0,0 +1,189 @@
// tests/lib/markdown-write.test.mjs
// Unit tests for lib/util/markdown-write.mjs (v4.2)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
serializeFrontmatter,
atomicWriteMarkdown,
readAndUpdate,
} from '../../lib/util/markdown-write.mjs';
import { parseFrontmatter, parseDocument } from '../../lib/util/frontmatter.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES_ROOT = resolve(__dirname, '..', 'fixtures');
test('serializeFrontmatter — empty object returns empty string', () => {
assert.equal(serializeFrontmatter({}), '');
});
test('serializeFrontmatter — round-trip fidelity for scalars + arrays + list-of-dicts', () => {
const obj = {
name: 'voyage-test',
revision: 0,
enabled: true,
notes: null,
tags: ['alpha', 'beta', 'gamma'],
findings: [
{ id: 'a', severity: 'major' },
{ id: 'b', severity: 'minor' },
],
};
const yaml = serializeFrontmatter(obj);
const reparsed = parseFrontmatter(yaml).parsed;
assert.deepEqual(reparsed, obj);
});
test('serializeFrontmatter — block-style YAML for arrays (no flow style)', () => {
const yaml = serializeFrontmatter({ tags: ['a', 'b'] });
assert.ok(!yaml.includes('[a, b]'), 'flow-style array forbidden');
assert.ok(yaml.includes('tags:\n - a\n - b'), 'block-style required');
});
test('serializeFrontmatter — strings with colons are quoted', () => {
const yaml = serializeFrontmatter({ task: 'Re-architect: phase 2' });
assert.match(yaml, /task: ".*Re-architect.*phase 2.*"/);
const reparsed = parseFrontmatter(yaml).parsed;
assert.equal(reparsed.task, 'Re-architect: phase 2');
});
test('serializeFrontmatter — integer revision: 0 emitted unquoted', () => {
const yaml = serializeFrontmatter({ revision: 0 });
assert.equal(yaml, 'revision: 0');
});
test('serializeFrontmatter — round-trips 6-key source_annotations dict (v4.2 schema)', () => {
const obj = {
revision: 1,
source_annotations: [
{
id: 'ANN-0001',
target_artifact: 'plan.md',
target_anchor: 'step-3',
intent: 'change',
comment: 'Reorder before step 4',
timestamp: '2026-05-09T10:00:00Z',
},
{
id: 'ANN-0002',
target_artifact: 'plan.md',
target_anchor: 'step-7',
intent: 'fix',
comment: 'typo in heading',
timestamp: '2026-05-09T10:05:00Z',
},
],
annotation_digest: 'abc123def4567890',
};
const yaml = serializeFrontmatter(obj);
const reparsed = parseFrontmatter(yaml).parsed;
assert.deepEqual(reparsed, obj, '6-key list-of-dict must round-trip');
});
test('atomicWriteMarkdown — writes file with frontmatter + body', () => {
const dir = mkdtempSync(join(tmpdir(), 'mdw-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Title\n\nBody.\n');
const text = readFileSync(path, 'utf-8');
assert.match(text, /^---\nplan_version: "?1\.7"?\nrevision: 0\n---\n# Title\n\nBody\.\n$/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('atomicWriteMarkdown — leaves no .tmp orphan after success', () => {
const dir = mkdtempSync(join(tmpdir(), 'mdw-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { ok: true }, 'body');
assert.ok(existsSync(path));
assert.ok(!existsSync(path + '.tmp'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('atomicWriteMarkdown — overwrites existing file atomically', () => {
const dir = mkdtempSync(join(tmpdir(), 'mdw-test-'));
try {
const path = join(dir, 'plan.md');
writeFileSync(path, 'old content');
atomicWriteMarkdown(path, { new: true }, 'new body\n');
const text = readFileSync(path, 'utf-8');
assert.match(text, /new: true/);
assert.match(text, /new body/);
assert.ok(!text.includes('old content'));
assert.ok(!existsSync(path + '.tmp'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('atomicWriteMarkdown — preserves body bytes verbatim', () => {
const dir = mkdtempSync(join(tmpdir(), 'mdw-test-'));
try {
const path = join(dir, 'plan.md');
const body = '# Title\n\n- item with `code`\n\n```yaml\nmanifest:\n expected_paths:\n - foo\n```\n\nTrailing text.';
atomicWriteMarkdown(path, { v: 1 }, body);
const text = readFileSync(path, 'utf-8');
const split = text.split('---\n');
const recoveredBody = split.slice(2).join('---\n');
assert.equal(recoveredBody, body);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('readAndUpdate — round-trips frontmatter + body via mutator', () => {
const dir = mkdtempSync(join(tmpdir(), 'mdw-test-'));
try {
const path = join(dir, 'plan.md');
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Original\nBody.\n');
const result = readAndUpdate(path, ({ frontmatter, body }) => ({
frontmatter: { ...frontmatter, revision: 1 },
body,
}));
assert.equal(result.valid, true);
const re = parseDocument(readFileSync(path, 'utf-8'));
assert.equal(re.parsed.frontmatter.revision, 1);
assert.match(re.parsed.body, /# Original/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// Round-trip ALL existing fixture frontmatters (per risk-assessor C3).
// Walk tests/fixtures/**, parse + serialize + parse, assert deep-equal.
function walkMd(root, out = []) {
if (!existsSync(root)) return out;
for (const entry of readdirSync(root)) {
const p = join(root, entry);
const st = statSync(p);
if (st.isDirectory()) walkMd(p, out);
else if (entry.endsWith('.md')) out.push(p);
}
return out;
}
test('serializeFrontmatter — round-trips ALL existing fixture frontmatters', () => {
const fixtures = walkMd(FIXTURES_ROOT);
let checked = 0;
for (const path of fixtures) {
const text = readFileSync(path, 'utf-8');
const parsed = parseDocument(text);
if (!parsed.valid) continue; // some fixtures are intentionally malformed
const fm = parsed.parsed.frontmatter;
if (!fm || Object.keys(fm).length === 0) continue;
const yaml = serializeFrontmatter(fm);
const reparsed = parseFrontmatter(yaml);
if (!reparsed.valid) continue; // skip malformed-on-purpose fixtures
assert.deepEqual(reparsed.parsed, fm, `round-trip failed for fixture: ${path}`);
checked++;
}
assert.ok(checked > 0, 'expected to round-trip at least one fixture');
});

View file

@ -0,0 +1,135 @@
// 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 });
}
});