From 7407563f0a09f8de1025f5febe0e777f643a20ff Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Mon, 20 Jul 2026 22:32:51 +1200 Subject: [PATCH] feat(flow-schema): add flow version diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add diffFlows(before, after): a structural, authoring-only diff between two flows for "what changed since the last publish?". Compares decoded Flow objects, so comment/key-order churn and the flow-level stamps publish rewrites (version, id, name, ui) never register — only added, removed, and edited nodes, and a moved entry. Each modified node carries field-level changes with dot-paths: top-level fields, per-exit (exits.no_answer), per-menu-option (options.1), and a synthetic entry pseudo-field when the start marker moves. Raw before/after values only; presentation (labels, colours) stays with consumers. TS-only, like mutate.ts — the engine never diffs, so no Rust twin. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/flow-schema/src/diff.ts | 175 +++++++++++++++++++++++++ packages/flow-schema/src/index.ts | 4 + packages/flow-schema/test/diff.test.ts | 155 ++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 packages/flow-schema/src/diff.ts create mode 100644 packages/flow-schema/test/diff.test.ts diff --git a/packages/flow-schema/src/diff.ts b/packages/flow-schema/src/diff.ts new file mode 100644 index 0000000..8d4e77d --- /dev/null +++ b/packages/flow-schema/src/diff.ts @@ -0,0 +1,175 @@ +// Structural diff between two flows — "what changed since the last +// publish?". Authoring-only, like `mutate.ts`: the engine never diffs two +// flows, so (unlike the model helpers) there is no Rust twin. A consumer +// (the platform editor's per-node change highlight) parses the last +// published version and the working draft, then asks which nodes were +// added, removed, or edited, and — for edits — which fields moved. +// +// The comparison is *structural*, not textual: it works on decoded `Flow` +// objects, so it ignores comment/key-order churn and the flow-level stamps +// publish rewrites (`id`, `name`, `version`, the opaque `ui` block). A node +// that is byte-different in YAML but identical in meaning reads as +// unchanged — which is exactly what an author wants to see. + +import type { Flow, Node } from './generated/model.js'; + +/** A node's fate between the two flows. */ +export type FlowDiffStatus = 'added' | 'removed' | 'modified' | 'unchanged'; + +/** + * One field that differs on a node present in both flows. `path` is a + * dot-path within the node — a top-level field (`prompt`, `timeout_secs`), + * a single exit (`exits.no_answer`), a single menu option (`options.1`), or + * the synthetic `entry` pseudo-field when this node gained or lost the + * start marker. `before`/`after` are the raw values, `undefined` when the + * field was absent on that side. + */ +export interface FieldChange { + path: string; + before: unknown; + after: unknown; +} + +export interface NodeDiff { + status: FlowDiffStatus; + /** The node's kind — from the draft, or from the published side when removed. */ + kind: string; + /** Field-level changes; empty unless `status === 'modified'`. */ + changes: FieldChange[]; +} + +export interface FlowDiff { + /** Every node id present in either flow, mapped to its diff. */ + nodes: Record; + /** Node ids only in the draft (sorted). */ + added: string[]; + /** Node ids only in the published version (sorted). */ + removed: string[]; + /** Node ids in both but structurally changed (sorted). */ + modified: string[]; + /** The entry node id moved between the two flows. */ + entryChanged: boolean; + entryBefore: string; + entryAfter: string; + /** True when anything differs — nodes added/removed/modified, or entry moved. */ + changed: boolean; +} + +// Fields that are string→string maps and read best diffed per key rather +// than as one opaque blob: a rewired exit or a relabelled menu option is a +// per-entry edit, so `exits.no_answer` / `options.1` beats "exits changed". +const MAP_FIELDS = ['exits', 'options'] as const; + +/** + * Structurally diff two flows. `before` is the baseline (typically the last + * published version), `after` the candidate (the working draft). Order of + * object keys and array-free churn does not affect the result. + */ +export function diffFlows(before: Flow, after: Flow): FlowDiff { + const nodes: Record = {}; + const added: string[] = []; + const removed: string[] = []; + const modified: string[] = []; + + const ids = new Set([...Object.keys(before.nodes), ...Object.keys(after.nodes)]); + for (const id of ids) { + const b = before.nodes[id]; + const a = after.nodes[id]; + if (!b && a) { + nodes[id] = { status: 'added', kind: a.kind, changes: [] }; + added.push(id); + } else if (b && !a) { + nodes[id] = { status: 'removed', kind: b.kind, changes: [] }; + removed.push(id); + } else if (b && a) { + const changes = diffNode(b, a); + // Moving the start marker onto/off this node is a change to the + // node's role even when its config is untouched. + const wasEntry = before.entry === id; + const isEntry = after.entry === id; + if (wasEntry !== isEntry) changes.push({ path: 'entry', before: wasEntry, after: isEntry }); + if (changes.length > 0) { + nodes[id] = { status: 'modified', kind: a.kind, changes }; + modified.push(id); + } else { + nodes[id] = { status: 'unchanged', kind: a.kind, changes: [] }; + } + } + } + + const entryChanged = before.entry !== after.entry; + return { + nodes, + added: added.sort(), + removed: removed.sort(), + modified: modified.sort(), + entryChanged, + entryBefore: before.entry, + entryAfter: after.entry, + changed: added.length > 0 || removed.length > 0 || modified.length > 0 || entryChanged, + }; +} + +// The field-level changes between two nodes sharing an id. Map-typed fields +// (`exits`, `options`) diff per key; every other field is compared whole. +function diffNode(before: Node, after: Node): FieldChange[] { + const changes: FieldChange[] = []; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of keys) { + const b = (before as Record)[key]; + const a = (after as Record)[key]; + if ((MAP_FIELDS as readonly string[]).includes(key)) { + changes.push(...diffMap(key, asRecord(b), asRecord(a))); + } else if (!deepEqual(b, a)) { + changes.push({ path: key, before: b, after: a }); + } + } + + // Stable, readable order: plain fields first (kind, prompt, …), then the + // per-key map entries, each group alphabetical. + return changes.sort((x, y) => x.path.localeCompare(y.path)); +} + +function diffMap( + field: string, + before: Record, + after: Record, +): FieldChange[] { + const changes: FieldChange[] = []; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const key of keys) { + if (!deepEqual(before[key], after[key])) { + changes.push({ path: `${field}.${key}`, before: before[key], after: after[key] }); + } + } + return changes; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +// Order-insensitive for object keys (YAML round-trips don't preserve key +// order and it carries no meaning), order-sensitive for arrays (a schedule's +// ranges and a flow's exceptions are ordered). +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; + const aArr = Array.isArray(a); + const bArr = Array.isArray(b); + if (aArr || bArr) { + if (!aArr || !bArr || a.length !== b.length) return false; + return a.every((x, i) => deepEqual(x, b[i])); + } + const ak = Object.keys(a); + const bk = Object.keys(b); + if (ak.length !== bk.length) return false; + return ak.every( + (k) => + Object.prototype.hasOwnProperty.call(b, k) && + deepEqual((a as Record)[k], (b as Record)[k]), + ); +} diff --git a/packages/flow-schema/src/index.ts b/packages/flow-schema/src/index.ts index 6700aa1..e0d1c48 100644 --- a/packages/flow-schema/src/index.ts +++ b/packages/flow-schema/src/index.ts @@ -26,6 +26,10 @@ import schema from './generated/schema.js'; export * from './model.js'; export { schema as flowV1Schema }; +// Structural diff between two flows (authoring-only, TS-only — like mutate.ts). +export type { FieldChange, FlowDiff, FlowDiffStatus, NodeDiff } from './diff.js'; +export { diffFlows } from './diff.js'; + // Issues, parsing, validation, and the combined gate. export type { Issue } from './issues.js'; export { MISSING_ASSET, hasErrors } from './issues.js'; diff --git a/packages/flow-schema/test/diff.test.ts b/packages/flow-schema/test/diff.test.ts new file mode 100644 index 0000000..b73aa2a --- /dev/null +++ b/packages/flow-schema/test/diff.test.ts @@ -0,0 +1,155 @@ +// diffFlows is structural, not textual: it compares decoded Flow objects, +// so comment/key-order churn and the flow-level stamps publish rewrites +// never register as changes — only added/removed nodes, real field edits, +// and a moved entry do. These tests pin exactly that boundary. + +import { describe, expect, it } from 'vitest'; + +import { checkFlow } from '../src/check.js'; +import { diffFlows } from '../src/diff.js'; +import type { Flow } from '../src/model.js'; + +const parse = (src: string): Flow => { + const flow = checkFlow(src).flow; + if (!flow) throw new Error('fixture did not parse'); + return flow; +}; + +const BASE = `schema_version: 1 +id: flow_1 +name: Test +entry: welcome +nodes: + welcome: + kind: greeting + prompt: Hi! + exits: { next: ring } + ring: + kind: ring + timeout_secs: 25 + exits: { no_answer: bye } + bye: + kind: hangup +`; + +describe('diffFlows', () => { + it('reports no change for the same document', () => { + const diff = diffFlows(parse(BASE), parse(BASE)); + expect(diff.changed).toBe(false); + expect(diff.added).toEqual([]); + expect(diff.removed).toEqual([]); + expect(diff.modified).toEqual([]); + expect(diff.nodes.ring.status).toBe('unchanged'); + }); + + it('ignores comment and key-order churn (structural, not textual)', () => { + const reordered = `# a fresh comment +schema_version: 1 +name: Test +id: flow_1 +entry: welcome +nodes: + bye: { kind: hangup } + ring: + exits: { no_answer: bye } + kind: ring + timeout_secs: 25 + welcome: + exits: { next: ring } + prompt: Hi! + kind: greeting +`; + expect(diffFlows(parse(BASE), parse(reordered)).changed).toBe(false); + }); + + it('ignores flow-level stamps (version) that publish rewrites', () => { + const stamped = BASE.replace('name: Test\n', 'name: Test\nversion: 7\n'); + expect(diffFlows(parse(BASE), parse(stamped)).changed).toBe(false); + }); + + it('flags an added node', () => { + const withNode = BASE.replace( + ' bye:\n kind: hangup\n', + ' bye:\n kind: hangup\n extra:\n kind: hangup\n', + ); + const diff = diffFlows(parse(BASE), parse(withNode)); + expect(diff.changed).toBe(true); + expect(diff.added).toEqual(['extra']); + expect(diff.nodes.extra.status).toBe('added'); + expect(diff.nodes.extra.kind).toBe('hangup'); + }); + + it('flags a removed node from the published side', () => { + const withNode = BASE.replace( + ' bye:\n kind: hangup\n', + ' bye:\n kind: hangup\n extra:\n kind: hangup\n', + ); + // Publish had `extra`; the draft dropped it. + const diff = diffFlows(parse(withNode), parse(BASE)); + expect(diff.removed).toEqual(['extra']); + expect(diff.nodes.extra.status).toBe('removed'); + expect(diff.nodes.extra.kind).toBe('hangup'); + }); + + it('reports a changed config field with before/after', () => { + const edited = BASE.replace('timeout_secs: 25', 'timeout_secs: 30'); + const diff = diffFlows(parse(BASE), parse(edited)); + expect(diff.modified).toEqual(['ring']); + expect(diff.nodes.ring.status).toBe('modified'); + expect(diff.nodes.ring.changes).toEqual([{ path: 'timeout_secs', before: 25, after: 30 }]); + }); + + it('reports a rewired exit per exit name', () => { + const edited = BASE.replace('no_answer: bye', 'no_answer: welcome'); + const diff = diffFlows(parse(BASE), parse(edited)); + expect(diff.nodes.ring.changes).toEqual([ + { path: 'exits.no_answer', before: 'bye', after: 'welcome' }, + ]); + }); + + it('reports a changed prompt', () => { + const edited = BASE.replace('prompt: Hi!', 'prompt: Hello there'); + const diff = diffFlows(parse(BASE), parse(edited)); + expect(diff.nodes.welcome.changes).toEqual([ + { path: 'prompt', before: 'Hi!', after: 'Hello there' }, + ]); + }); + + it('reports a relabelled menu option per digit', () => { + const menu = `schema_version: 1 +id: flow_1 +name: Test +entry: m +nodes: + m: + kind: menu + prompt: Pick + options: { "1": Sales, "2": Support } + exits: { "1": bye, "2": bye, no_input: bye, invalid: bye } + bye: { kind: hangup } +`; + const edited = menu.replace('"1": Sales', '"1": New Sales'); + const diff = diffFlows(parse(menu), parse(edited)); + expect(diff.nodes.m.changes).toEqual([ + { path: 'options.1', before: 'Sales', after: 'New Sales' }, + ]); + }); + + it('flags a moved entry as a change on both nodes', () => { + const edited = BASE.replace('entry: welcome', 'entry: ring'); + const diff = diffFlows(parse(BASE), parse(edited)); + expect(diff.entryChanged).toBe(true); + expect(diff.entryBefore).toBe('welcome'); + expect(diff.entryAfter).toBe('ring'); + expect(diff.modified).toEqual(['ring', 'welcome']); + expect(diff.nodes.welcome.changes).toContainEqual({ path: 'entry', before: true, after: false }); + expect(diff.nodes.ring.changes).toContainEqual({ path: 'entry', before: false, after: true }); + }); + + it('treats an added optional field as a change', () => { + // hangup gains a goodbye prompt. + const edited = BASE.replace(' bye:\n kind: hangup\n', ' bye:\n kind: hangup\n prompt: Bye now\n'); + const diff = diffFlows(parse(BASE), parse(edited)); + expect(diff.nodes.bye.changes).toEqual([{ path: 'prompt', before: undefined, after: 'Bye now' }]); + }); +});