maestro/src/bridge/yaml-patch.ts
2026-06-03 05:08:00 +00:00

170 lines
6.1 KiB
TypeScript

/**
* YAML source-preserving patch helper.
*
* Problem: `yaml.stringify(obj)` completely re-serializes a document, destroying
* the source formatting (block literal vs folded, inline vs block arrays, blank
* lines, comments, key order). This changes `instruction: |` to `instruction: >`,
* which actually alters runtime behavior because folded style collapses newlines.
*
* Solution: parseDocument(originalText) -> deep-diff doc.toJS() vs newBody ->
* apply only differing paths via Document#setIn / Document#deleteIn. Untouched
* regions preserve their original source exactly.
*
* Newly-added subtrees go through Document#createNode with the existing
* lineWidth: 120 convention.
*/
import { parseDocument, stringify, type Document } from 'yaml';
import { logger } from '../logger.js';
const LINE_WIDTH = 120;
type Path = (string | number)[];
/**
* Recursively walk `prev` and `next` (both plain JS values), collecting paths
* where the two differ. For differing paths we emit either a set (with the new
* value) or a delete.
*
* Rules:
* - For objects (plain dicts) we compare by key. Keys removed in `next` become
* deletes; keys added in `next` become sets; common keys recurse.
* - For arrays of equal length we recurse element-by-element by index. This
* lets us do minimal edits inside one movement without re-serializing the
* entire `movements:` sequence (which would flatten its inline arrays and
* block-literal instructions).
* - For arrays of differing length we replace the whole array at that path.
* Element-wise alignment across inserts/deletes is ambiguous without a
* stable identity field, so we bail to wholesale replacement and accept
* the one-time formatting loss for the mutated sequence.
* - For primitives (string/number/bool/null) we compare via strict equality.
*/
export type DiffOp =
| { kind: 'set'; path: Path; value: unknown }
| { kind: 'delete'; path: Path };
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k])) return false;
}
return true;
}
return false;
}
export function diff(prev: unknown, next: unknown, path: Path = []): DiffOp[] {
if (deepEqual(prev, next)) return [];
// If the shapes differ (object<->array<->scalar) replace wholesale.
const prevIsObj = isPlainObject(prev);
const nextIsObj = isPlainObject(next);
const prevIsArr = Array.isArray(prev);
const nextIsArr = Array.isArray(next);
if (prevIsObj && nextIsObj) {
const ops: DiffOp[] = [];
const prevKeys = new Set(Object.keys(prev as Record<string, unknown>));
const nextKeys = new Set(Object.keys(next as Record<string, unknown>));
// deletions
for (const k of prevKeys) {
if (!nextKeys.has(k)) {
ops.push({ kind: 'delete', path: [...path, k] });
}
}
// additions + recursive diffs
for (const k of nextKeys) {
const nextVal = (next as Record<string, unknown>)[k];
if (!prevKeys.has(k)) {
ops.push({ kind: 'set', path: [...path, k], value: nextVal });
} else {
const prevVal = (prev as Record<string, unknown>)[k];
ops.push(...diff(prevVal, nextVal, [...path, k]));
}
}
return ops;
}
if (prevIsArr && nextIsArr) {
const prevArr = prev as unknown[];
const nextArr = next as unknown[];
if (prevArr.length !== nextArr.length) {
// Length change: bail to wholesale replacement. We can't reliably align
// items across insertions/deletions without a stable identity field.
return [{ kind: 'set', path, value: next }];
}
// Equal length: recurse element-wise so untouched items keep formatting.
const ops: DiffOp[] = [];
for (let i = 0; i < prevArr.length; i++) {
ops.push(...diff(prevArr[i], nextArr[i], [...path, i]));
}
return ops;
}
// Shape change or primitive mismatch: replace.
return [{ kind: 'set', path, value: next }];
}
/**
* Apply a list of diff ops to a Document in place. New subtrees are wrapped via
* doc.createNode so they follow our stringify options (lineWidth etc.).
*/
export function applyOps(doc: Document, ops: DiffOp[]): void {
for (const op of ops) {
if (op.kind === 'delete') {
doc.deleteIn(op.path);
} else {
// createNode respects schema + options; we don't pass extra options
// because per-field block style for untouched content comes from the
// original Document and we only call createNode for NEW subtrees.
const node = doc.createNode(op.value);
doc.setIn(op.path, node);
}
}
}
/**
* Re-serialize `body` onto the formatting of `originalText`, preserving block
* styles / inline arrays / blank lines / comments for untouched regions.
*
* If the original text fails to parse cleanly (errors array non-empty) we fall
* back to a plain stringify and log a warning.
*/
export function patchYaml(originalText: string, body: unknown): string {
let doc: Document;
try {
doc = parseDocument(originalText);
} catch (e) {
logger.warn(`[yaml-patch] parseDocument threw, falling back to stringify err=${e}`);
return stringify(body, { lineWidth: LINE_WIDTH });
}
if (doc.errors && doc.errors.length > 0) {
logger.warn(
`[yaml-patch] original document has parse errors, falling back to stringify count=${doc.errors.length}`,
);
return stringify(body, { lineWidth: LINE_WIDTH });
}
const prev = doc.toJS();
const ops = diff(prev, body);
applyOps(doc, ops);
return doc.toString({ lineWidth: LINE_WIDTH });
}