- 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)
189 lines
6.7 KiB
JavaScript
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');
|
|
});
|