ktg-plugin-marketplace/plugins/voyage/tests/lib/markdown-write.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

189 lines
6.7 KiB
JavaScript

// 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');
});