- 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)
129 lines
4.8 KiB
JavaScript
129 lines
4.8 KiB
JavaScript
// lib/util/markdown-write.mjs
|
|
// Markdown frontmatter serializer + atomic markdown writer.
|
|
//
|
|
// Companion to lib/util/frontmatter.mjs (parser-only) and lib/util/atomic-write.mjs
|
|
// (JSON-only). Together they enable the /trekrevise in-place revision loop
|
|
// (v4.2): read existing artifact -> mutate frontmatter+body -> atomic write.
|
|
//
|
|
// Subset constraint mirrors the parser at lib/util/frontmatter.mjs:
|
|
// - Scalars: string, integer, float, boolean, null
|
|
// - Arrays of scalars (block-style only — no flow-style [a, b])
|
|
// - Arrays of dicts, one level deep (block-style only)
|
|
// Anything outside this subset is silently dropped or quoted as a string.
|
|
//
|
|
// Why no js-yaml: zero-deps invariant. Templates emit only this subset.
|
|
|
|
import { writeFileSync, renameSync, unlinkSync, readFileSync } from 'node:fs';
|
|
import { splitFrontmatter, parseDocument } from './frontmatter.mjs';
|
|
|
|
const SPECIAL_CHARS = /[:#\[\]{},&*!|>'"%@`]|^\s|\s$/;
|
|
|
|
function needsQuote(s) {
|
|
if (s === '' || s === 'null' || s === '~' || s === 'true' || s === 'false') return true;
|
|
if (s === '[]' || s === '{}') return true;
|
|
if (/^-?\d+(\.\d+)?$/.test(s)) return true;
|
|
if (SPECIAL_CHARS.test(s)) return true;
|
|
return false;
|
|
}
|
|
|
|
function serializeScalar(v) {
|
|
if (v === null || v === undefined) return 'null';
|
|
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
if (typeof v === 'number') return String(v);
|
|
if (typeof v === 'string') {
|
|
if (needsQuote(v)) {
|
|
const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
|
return `"${escaped}"`;
|
|
}
|
|
return v;
|
|
}
|
|
return JSON.stringify(v);
|
|
}
|
|
|
|
/**
|
|
* Serialize a JS object to YAML frontmatter (subset only).
|
|
* Returns the YAML body without --- delimiters.
|
|
*/
|
|
export function serializeFrontmatter(obj) {
|
|
if (obj === null || obj === undefined || typeof obj !== 'object') return '';
|
|
const lines = [];
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (value === undefined) continue;
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) {
|
|
lines.push(`${key}: []`);
|
|
continue;
|
|
}
|
|
lines.push(`${key}:`);
|
|
for (const item of value) {
|
|
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
// Dict in list — block style, one level deep
|
|
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
|
|
if (entries.length === 0) {
|
|
lines.push(` - {}`);
|
|
continue;
|
|
}
|
|
const [firstK, firstV] = entries[0];
|
|
lines.push(` - ${firstK}: ${serializeScalar(firstV)}`);
|
|
for (let i = 1; i < entries.length; i++) {
|
|
const [k, v] = entries[i];
|
|
lines.push(` ${k}: ${serializeScalar(v)}`);
|
|
}
|
|
} else {
|
|
lines.push(` - ${serializeScalar(item)}`);
|
|
}
|
|
}
|
|
} else if (value !== null && typeof value === 'object') {
|
|
// Single-level dict — emit as multi-line key: \n subkey: value
|
|
lines.push(`${key}:`);
|
|
for (const [k, v] of Object.entries(value)) {
|
|
if (v === undefined) continue;
|
|
lines.push(` ${k}: ${serializeScalar(v)}`);
|
|
}
|
|
} else {
|
|
lines.push(`${key}: ${serializeScalar(value)}`);
|
|
}
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Atomically write a markdown file with frontmatter + body.
|
|
* Reconstructs as: ---\n{serialized}\n---\n{body}
|
|
* Single writeFileSync + renameSync for crash-safety. Body bytes preserved verbatim.
|
|
*
|
|
* @param {string} path - destination path
|
|
* @param {object} frontmatter - object to serialize as YAML frontmatter
|
|
* @param {string} body - markdown body, bytes-verbatim (no normalization)
|
|
*/
|
|
export function atomicWriteMarkdown(path, frontmatter, body) {
|
|
const yaml = serializeFrontmatter(frontmatter);
|
|
const content = `---\n${yaml}\n---\n${body}`;
|
|
const tmp = path + '.tmp';
|
|
try {
|
|
writeFileSync(tmp, content);
|
|
renameSync(tmp, path);
|
|
} catch (e) {
|
|
try { unlinkSync(tmp); } catch { /* tmp already gone */ }
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read + parse + mutate + write atomically.
|
|
* mutator receives { frontmatter, body }, returns new { frontmatter, body }.
|
|
*
|
|
* @returns {Result} from parseDocument; if invalid, no write happens.
|
|
*/
|
|
export function readAndUpdate(path, mutator) {
|
|
const text = readFileSync(path, 'utf-8');
|
|
const doc = parseDocument(text);
|
|
if (!doc.valid) return doc;
|
|
const { frontmatter, body } = doc.parsed;
|
|
const next = mutator({ frontmatter, body });
|
|
if (!next || typeof next !== 'object') {
|
|
return { valid: false, errors: [{ code: 'MD_WRITE_MUTATOR_INVALID', message: 'mutator must return { frontmatter, body }' }], warnings: [], parsed: null };
|
|
}
|
|
atomicWriteMarkdown(path, next.frontmatter || {}, next.body || '');
|
|
return { valid: true, errors: [], warnings: [], parsed: next };
|
|
}
|