/** * 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 { 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)); const nextKeys = new Set(Object.keys(next as Record)); // 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)[k]; if (!prevKeys.has(k)) { ops.push({ kind: 'set', path: [...path, k], value: nextVal }); } else { const prevVal = (prev as Record)[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 }); }