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:
parent
8dc3090080
commit
dcf0c7ad02
7 changed files with 584 additions and 0 deletions
189
plugins/voyage/tests/lib/markdown-write.test.mjs
Normal file
189
plugins/voyage/tests/lib/markdown-write.test.mjs
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue