From 1454c7792adeb835ef6e096ecbd77416ad99ff9a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 16:31:09 -0700 Subject: [PATCH] feat(f7.1): flowchart node/edge diff + styling emission (#22) Co-Authored-By: Claude Opus 4.8 --- src/mermaidFlowchartDiff.ts | 60 ++++++++++++++++++++++++++++--- test/mermaidFlowchartDiff.test.ts | 47 ++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/mermaidFlowchartDiff.ts b/src/mermaidFlowchartDiff.ts index 0ac4a66..d4eef6b 100644 --- a/src/mermaidFlowchartDiff.ts +++ b/src/mermaidFlowchartDiff.ts @@ -95,8 +95,60 @@ export function parseFlowchart(source: string): FlowGraph { return { header: header || "flowchart TD", nodes, edges }; } -// Replaced in Task 3 with the real diff/emit. -export function diffFlowchart(_beforeSrc: string, currentSrc: string): string { - void CW_COLORS; - return currentSrc; +function nodeChanged(a: FlowNode, b: FlowNode): boolean { + return (a.label ?? "") !== (b.label ?? "") || (a.open ?? "") !== (b.open ?? ""); +} +const edgeKey = (e: FlowEdge): string => `${e.from} ${e.to}`; + +export function diffFlowchart(beforeSrc: string, currentSrc: string): string { + const before = parseFlowchart(beforeSrc); + const current = parseFlowchart(currentSrc); + + const addedNodes: string[] = []; + const changedNodes: string[] = []; + for (const [id, cur] of current.nodes) { + const prev = before.nodes.get(id); + if (!prev) addedNodes.push(id); + else if (nodeChanged(prev, cur)) changedNodes.push(id); + } + const removedNodes: FlowNode[] = []; + for (const [id, prev] of before.nodes) { + if (!current.nodes.has(id)) removedNodes.push(prev); + } + + const beforeEdgeKeys = new Set(before.edges.map(edgeKey)); + const currentEdgeKeys = new Set(current.edges.map(edgeKey)); + const addedEdgeIdx = current.edges.filter((e) => !beforeEdgeKeys.has(edgeKey(e))).map((e) => e.index); + const removedEdges = before.edges.filter((e) => !currentEdgeKeys.has(edgeKey(e))); + + // Ghost-edge indices follow the current edge count, in deterministic order. + let nextIdx = current.edges.length; + const ghostEdgeLines: string[] = []; + const ghostEdgeIdx: number[] = []; + for (const e of removedEdges) { + ghostEdgeLines.push(` ${e.from} -.-> ${e.to}`); + ghostEdgeIdx.push(nextIdx++); + } + + const out: string[] = [currentSrc.replace(/\s+$/, "")]; + + // Ghost removed nodes: re-inject their declaration (or bare id) so they appear. + for (const n of removedNodes) out.push(` ${n.decl ?? n.id}`); + out.push(...ghostEdgeLines); + + // classDefs (always emit the three; harmless if a class is unused). + out.push( + ` classDef cwAdded fill:${CW_COLORS.added}22,stroke:${CW_COLORS.added},stroke-width:2px;`, + ` classDef cwChanged fill:${CW_COLORS.changed}22,stroke:${CW_COLORS.changed},stroke-width:2px;`, + ` classDef cwRemoved fill:${CW_COLORS.removed}11,stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3,color:${CW_COLORS.removed};`, + ); + if (addedNodes.length) out.push(` class ${addedNodes.join(",")} cwAdded;`); + if (changedNodes.length) out.push(` class ${changedNodes.join(",")} cwChanged;`); + if (removedNodes.length) out.push(` class ${removedNodes.map((n) => n.id).join(",")} cwRemoved;`); + + for (const i of addedEdgeIdx) out.push(` linkStyle ${i} stroke:${CW_COLORS.added},stroke-width:2px;`); + for (const i of ghostEdgeIdx) + out.push(` linkStyle ${i} stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3;`); + + return out.join("\n"); } diff --git a/test/mermaidFlowchartDiff.test.ts b/test/mermaidFlowchartDiff.test.ts index 1faf5b1..027834c 100644 --- a/test/mermaidFlowchartDiff.test.ts +++ b/test/mermaidFlowchartDiff.test.ts @@ -35,3 +35,50 @@ describe("parseFlowchart", () => { expect(g.edges).toHaveLength(1); }); }); + +import { diffFlowchart } from "../src/mermaidFlowchartDiff"; + +describe("diffFlowchart", () => { + it("colors an added node with the cwAdded class", () => { + const before = "flowchart LR\n A-->B"; + const current = "flowchart LR\n A-->B\n B-->C"; + const out = diffFlowchart(before, current); + expect(out).toContain("classDef cwAdded"); + expect(out).toMatch(/class\s+C\s+cwAdded/); + }); + + it("colors a changed node (same id, new label) with cwChanged", () => { + const before = "flowchart LR\n A[Old]-->B"; + const current = "flowchart LR\n A[New]-->B"; + const out = diffFlowchart(before, current); + expect(out).toMatch(/class\s+A\s+cwChanged/); + }); + + it("styles an added edge via linkStyle on its current index", () => { + const before = "flowchart LR\n A-->B"; + const current = "flowchart LR\n A-->B\n A-->C"; + const out = diffFlowchart(before, current); + // edge index 1 (A-->C) is the added one + expect(out).toMatch(/linkStyle\s+1\s+stroke:#2ea043/); + }); + + it("ghosts a removed node in place with cwRemoved (re-injected decl)", () => { + const before = "flowchart LR\n A-->B\n B-->C[Gone]"; + const current = "flowchart LR\n A-->B"; + const out = diffFlowchart(before, current); + expect(out).toContain("C[Gone]"); // re-injected + expect(out).toMatch(/class\s+C\s+cwRemoved/); + }); + + it("is deterministic (same inputs → identical output)", () => { + const before = "flowchart LR\n A-->B"; + const current = "flowchart LR\n A-->B\n B-->C\n A-->D"; + expect(diffFlowchart(before, current)).toBe(diffFlowchart(before, current)); + }); + + it("preserves the original current source as the prefix", () => { + const current = "flowchart LR\n A-->B\n B-->C"; + const out = diffFlowchart("flowchart LR\n A-->B", current); + expect(out.startsWith(current)).toBe(true); + }); +});