Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions packages/flow-schema/src/diff.ts
Original file line number Diff line number Diff line change
@@ -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<string, NodeDiff>;
/** 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<string, NodeDiff> = {};
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<string, unknown>)[key];
const a = (after as Record<string, unknown>)[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<string, unknown>,
after: Record<string, unknown>,
): 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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

// 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<string, unknown>)[k], (b as Record<string, unknown>)[k]),
);
}
4 changes: 4 additions & 0 deletions packages/flow-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
155 changes: 155 additions & 0 deletions packages/flow-schema/test/diff.test.ts
Original file line number Diff line number Diff line change
@@ -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' }]);
});
});
Loading