diff --git a/docs/superpowers/plans/2026-06-11-f7-intra-diagram-mermaid-diff.md b/docs/superpowers/plans/2026-06-11-f7-intra-diagram-mermaid-diff.md new file mode 100644 index 0000000..b58db47 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-f7-intra-diagram-mermaid-diff.md @@ -0,0 +1,1034 @@ +# F7.1 Intra-Diagram Mermaid Diffing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refine the F7 whole-diagram "changed" badge into a node/edge-level diff *inside* flowchart and sequence mermaid diagrams against the F6 baseline. + +**Architecture:** Three new **pure, vscode-free, DOM-free** host modules under `src/` parse a changed mermaid block's before/after source into a typed element model, diff it, and re-emit the *current* diagram source augmented with mermaid's own styling directives (flowchart: `classDef`/`class`/`linkStyle`; sequence: `rect rgb(...)` tinted runs), ghosting removed elements in place. The dispatcher falls back to the v1 whole-block badge for unsupported types or any parse failure. `renderOp`'s changed-mermaid branch calls the dispatcher; the webview is unchanged (styling rides inside the mermaid source); `media/preview.css` gains a legend. + +**Tech Stack:** TypeScript, `diff` (jsdiff) for sequence LCS, `markdown-it` (existing fence rule), vitest (unit), `@vscode/test-electron` + mocha (host E2E). + +**Spec:** `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md` §11 (INV-29..31). Task `benstull/vscode-cowriting-plugin#22`. + +--- + +## File Structure + +- **Create `src/mermaidDiff.ts`** — dispatcher. `detectDiagramType(source)`, `diffMermaid(beforeSrc, currentSrc): MermaidDiffResult`. Wraps the typed differ in try/catch → `{kind:"fallback"}` on any throw or unsupported type. Owns the shared color palette constants. +- **Create `src/mermaidFlowchartDiff.ts`** — `parseFlowchart(source): FlowGraph`, `diffFlowchart(beforeSrc, currentSrc): string`. +- **Create `src/mermaidSequenceDiff.ts`** — `parseSequence(source): SeqDiagram`, `diffSequence(beforeSrc, currentSrc): string`. +- **Modify `src/trackChangesModel.ts`** — in `renderOp`, the `changed`+`atomic` branch: if the block is a mermaid fence, call `diffMermaid` on the inner source; `augmented` → render the augmented fence + legend (no single badge); `fallback` → today's behavior. Add a `mermaidFenceBody(raw)` helper. +- **Modify `media/preview.css`** — `.cw-mermaid-legend` swatch styles. +- **Create `test/mermaidFlowchartDiff.test.ts`**, **`test/mermaidSequenceDiff.test.ts`**, **`test/mermaidDiff.test.ts`** — unit tests. +- **Modify `test/trackChangesModel.test.ts`** — assert `renderTrackChanges` HTML augments a changed flowchart/sequence and falls back otherwise. +- **Modify `test/e2e/suite/trackChangesPreview.test.ts`** — host E2E for a changed flowchart. +- **Create `docs/MANUAL-SMOKE-F7.1.md`** — manual webview-render smoke steps. + +**Shared palette (define once in `src/mermaidDiff.ts`, import elsewhere):** + +```ts +// Theme-neutral colors baked into emitted mermaid source (source can't read CSS vars). +export const CW_COLORS = { + added: "#2ea043", + changed: "#d29922", + removed: "#808080", +} as const; +``` + +--- + +## Task 1: Dispatcher skeleton — `detectDiagramType` + `diffMermaid` fallback + +**Files:** +- Create: `src/mermaidDiff.ts` +- Test: `test/mermaidDiff.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// test/mermaidDiff.test.ts +import { describe, it, expect } from "vitest"; +import { detectDiagramType, diffMermaid } from "../src/mermaidDiff"; + +describe("detectDiagramType", () => { + it("detects flowchart from `flowchart` / `graph` headers", () => { + expect(detectDiagramType("flowchart LR\n a-->b")).toBe("flowchart"); + expect(detectDiagramType("graph TD\n a-->b")).toBe("flowchart"); + expect(detectDiagramType(" \n%% c\ngraph TD")).toBe("flowchart"); + }); + it("detects sequence from `sequenceDiagram`", () => { + expect(detectDiagramType("sequenceDiagram\n A->>B: hi")).toBe("sequence"); + }); + it("returns `other` for unsupported types", () => { + expect(detectDiagramType("classDiagram\n class A")).toBe("other"); + expect(detectDiagramType("stateDiagram-v2\n s1 --> s2")).toBe("other"); + expect(detectDiagramType("")).toBe("other"); + }); +}); + +describe("diffMermaid", () => { + it("falls back for an unsupported diagram type", () => { + expect(diffMermaid("classDiagram\n class A", "classDiagram\n class B")).toEqual({ kind: "fallback" }); + }); + it("falls back (never throws) when the differ throws", () => { + // A deliberately malformed flowchart still degrades gracefully. + const r = diffMermaid("flowchart LR", "flowchart LR\n a -->"); + expect(["augmented", "fallback"]).toContain(r.kind); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/mermaidDiff.test.ts` +Expected: FAIL — `Cannot find module '../src/mermaidDiff'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/mermaidDiff.ts +/** + * mermaidDiff — F7.1 (#22) dispatcher. Detects a mermaid diagram's type and + * routes a baseline→current diff to the matching pure differ, which re-emits the + * CURRENT diagram source augmented with mermaid styling directives (INV-29). Any + * unsupported type or parser surprise degrades to the v1 whole-block badge + * (INV-30) — this function never throws. vscode-free, DOM-free, deterministic. + */ +import { diffFlowchart } from "./mermaidFlowchartDiff"; +import { diffSequence } from "./mermaidSequenceDiff"; + +export const CW_COLORS = { + added: "#2ea043", + changed: "#d29922", + removed: "#808080", +} as const; + +export type DiagramType = "flowchart" | "sequence" | "other"; + +export interface MermaidDiffAugmented { + kind: "augmented"; + source: string; +} +export interface MermaidDiffFallback { + kind: "fallback"; +} +export type MermaidDiffResult = MermaidDiffAugmented | MermaidDiffFallback; + +export function detectDiagramType(source: string): DiagramType { + for (const line of source.split(/\r?\n/)) { + const t = line.trim(); + if (t === "" || t.startsWith("%%")) continue; + if (/^(flowchart|graph)\b/.test(t)) return "flowchart"; + if (/^sequenceDiagram\b/.test(t)) return "sequence"; + return "other"; + } + return "other"; +} + +export function diffMermaid(beforeSrc: string, currentSrc: string): MermaidDiffResult { + const type = detectDiagramType(currentSrc); + try { + if (type === "flowchart") return { kind: "augmented", source: diffFlowchart(beforeSrc, currentSrc) }; + if (type === "sequence") return { kind: "augmented", source: diffSequence(beforeSrc, currentSrc) }; + } catch { + return { kind: "fallback" }; + } + return { kind: "fallback" }; +} +``` + +Also create minimal stubs so the module imports resolve (real impl in Tasks 2–5): + +```ts +// src/mermaidFlowchartDiff.ts — stub, replaced in Tasks 2-3 +export function diffFlowchart(_beforeSrc: string, currentSrc: string): string { + return currentSrc; +} +``` + +```ts +// src/mermaidSequenceDiff.ts — stub, replaced in Tasks 4-5 +export function diffSequence(_beforeSrc: string, currentSrc: string): string { + return currentSrc; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/mermaidDiff.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mermaidDiff.ts src/mermaidFlowchartDiff.ts src/mermaidSequenceDiff.ts test/mermaidDiff.test.ts +git commit -m "feat(f7.1): mermaid diff dispatcher + type detection (#22)" +``` + +--- + +## Task 2: Flowchart parser — `parseFlowchart` + +**Files:** +- Modify: `src/mermaidFlowchartDiff.ts` (replace the stub) +- Test: `test/mermaidFlowchartDiff.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// test/mermaidFlowchartDiff.test.ts +import { describe, it, expect } from "vitest"; +import { parseFlowchart } from "../src/mermaidFlowchartDiff"; + +describe("parseFlowchart", () => { + it("captures the header and node declarations with labels + shapes", () => { + const g = parseFlowchart("flowchart LR\n A[Start] --> B(Middle)\n B --> C{End}"); + expect(g.header).toBe("flowchart LR"); + expect(g.nodes.get("A")).toMatchObject({ id: "A", label: "Start", open: "[", close: "]" }); + expect(g.nodes.get("B")).toMatchObject({ id: "B", label: "Middle", open: "(", close: ")" }); + expect(g.nodes.get("C")).toMatchObject({ id: "C", label: "End", open: "{", close: "}" }); + }); + + it("captures edges in declaration order with from/to and index", () => { + const g = parseFlowchart("graph TD\n A-->B\n B-->C"); + expect(g.edges.map((e) => [e.from, e.to, e.index])).toEqual([ + ["A", "B", 0], + ["B", "C", 1], + ]); + }); + + it("records a node referenced only in an edge (no explicit declaration)", () => { + const g = parseFlowchart("flowchart LR\n A-->B"); + expect(g.nodes.has("A")).toBe(true); + expect(g.nodes.has("B")).toBe(true); + expect(g.nodes.get("A")?.label).toBeUndefined(); + }); + + it("handles a labelled edge `A -->|yes| B`", () => { + const g = parseFlowchart("flowchart LR\n A -->|yes| B"); + expect(g.edges[0]).toMatchObject({ from: "A", to: "B", label: "yes" }); + }); + + it("ignores comments and blank lines", () => { + const g = parseFlowchart("flowchart LR\n%% a comment\n\n A-->B"); + expect(g.edges).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` +Expected: FAIL — `parseFlowchart` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// src/mermaidFlowchartDiff.ts +/** + * mermaidFlowchartDiff — F7.1 (#22). Pure flowchart parser + diff/emit. Parses a + * `graph`/`flowchart` source into nodes (by id) and edges (by declaration order), + * diffs baseline vs current, and re-emits the CURRENT source augmented with + * `classDef`/`class`/`linkStyle` directives coloring added/changed elements and + * ghosting removed ones (INV-29/31). Deterministic; no vscode, no DOM. + */ +import { CW_COLORS } from "./mermaidDiff"; + +export interface FlowNode { + id: string; + label?: string; + open?: string; + close?: string; + /** Verbatim declaration token, e.g. `A[Start]`, for ghost re-injection. */ + decl?: string; +} +export interface FlowEdge { + from: string; + to: string; + label?: string; + index: number; + /** Verbatim edge statement for ghost re-injection. */ + raw: string; +} +export interface FlowGraph { + header: string; + nodes: Map; + edges: FlowEdge[]; +} + +const NODE_TOKEN = /^([A-Za-z0-9_]+)(\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{[^}]*\}|>[^\]]*\])?/; +// link operators: -->, ---, -.->, -.-, ==>, ===, --x, --o, ==x, ==o +const LINK = /(-->|---|-\.->|-\.-|==>|===|--[xo]|==[xo])(\|([^|]*)\|)?/; + +function shapeOf(bracket: string): { label: string; open: string; close: string } { + const open = bracket[0]; + if (bracket.startsWith("[[")) return { label: bracket.slice(2, -2), open: "[[", close: "]]" }; + if (bracket.startsWith("((")) return { label: bracket.slice(2, -2), open: "((", close: "))" }; + const close = bracket[bracket.length - 1]; + return { label: bracket.slice(1, -1), open, close }; +} + +function recordNode(nodes: Map, token: string): string | null { + const m = token.match(NODE_TOKEN); + if (!m) return null; + const id = m[1]; + const existing = nodes.get(id) ?? { id }; + if (m[2]) { + const s = shapeOf(m[2]); + existing.label = s.label; + existing.open = s.open; + existing.close = s.close; + existing.decl = `${id}${m[2]}`; + } + nodes.set(id, existing); + return id; +} + +export function parseFlowchart(source: string): FlowGraph { + const lines = source.split(/\r?\n/); + let header = ""; + const nodes = new Map(); + const edges: FlowEdge[] = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("%%")) continue; + if (!header && /^(flowchart|graph)\b/.test(line)) { + header = line; + continue; + } + // Walk a (possibly chained) edge statement: A[..] --> B -->|x| C + let rest = line; + let prevId: string | null = null; + let consumedEdge = false; + while (rest.length > 0) { + const nodeM = rest.match(NODE_TOKEN); + if (!nodeM) break; + const id = recordNode(nodes, nodeM[0]); + rest = rest.slice(nodeM[0].length).trimStart(); + const linkM = rest.match(LINK); + if (linkM && prevId === null) { + // current token is the LHS; fall through to read RHS + } + if (linkM) { + const fromId = id!; + rest = rest.slice(linkM[0].length).trimStart(); + const rhsM = rest.match(NODE_TOKEN); + if (!rhsM) break; + const toId = recordNode(nodes, rhsM[0]); + edges.push({ + from: fromId, + to: toId!, + label: linkM[3] || undefined, + index: edges.length, + raw: `${fromId} ${linkM[1]}${linkM[2] ? linkM[2] : ""} ${toId}`, + }); + rest = rest.slice(rhsM[0].length).trimStart(); + prevId = toId; + consumedEdge = true; + // chained: the RHS may be the LHS of another link + const more = rest.match(LINK); + if (more) rest = `${toId} ${rest}`; + continue; + } + break; + } + void consumedEdge; + } + return { header: header || "flowchart TD", nodes, edges }; +} +``` + +> Note: the chained-edge handling (`A --> B --> C`) is intentionally simple. The unit tests in this task pin the contract for the single-edge and two-statement cases; if a chained test is added later, the `more`/re-prefix branch is the place to harden. Keep the parser total — never throw on odd input; return whatever was parsed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mermaidFlowchartDiff.ts test/mermaidFlowchartDiff.test.ts +git commit -m "feat(f7.1): flowchart parser (#22)" +``` + +--- + +## Task 3: Flowchart diff + augmented emission — `diffFlowchart` + +**Files:** +- Modify: `src/mermaidFlowchartDiff.ts` +- Test: `test/mermaidFlowchartDiff.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// append to test/mermaidFlowchartDiff.test.ts +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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` +Expected: FAIL — `diffFlowchart` returns the current source unchanged (stub), so `classDef`/`class` assertions fail. + +- [ ] **Step 3: Write the implementation** + +```ts +// append to src/mermaidFlowchartDiff.ts + +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"); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/mermaidFlowchartDiff.test.ts` +Expected: PASS (all flowchart tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mermaidFlowchartDiff.ts test/mermaidFlowchartDiff.test.ts +git commit -m "feat(f7.1): flowchart node/edge diff + styling emission (#22)" +``` + +--- + +## Task 4: Sequence parser — `parseSequence` + +**Files:** +- Modify: `src/mermaidSequenceDiff.ts` (replace the stub) +- Test: `test/mermaidSequenceDiff.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// test/mermaidSequenceDiff.test.ts +import { describe, it, expect } from "vitest"; +import { parseSequence } from "../src/mermaidSequenceDiff"; + +describe("parseSequence", () => { + it("captures explicit participants in order", () => { + const d = parseSequence("sequenceDiagram\n participant A\n participant B\n A->>B: hi"); + expect(d.participants).toEqual(["A", "B"]); + }); + + it("captures message statements verbatim (trimmed)", () => { + const d = parseSequence("sequenceDiagram\n A->>B: hi\n B-->>A: bye"); + expect(d.statements).toEqual(["A->>B: hi", "B-->>A: bye"]); + }); + + it("infers participants from messages when not declared", () => { + const d = parseSequence("sequenceDiagram\n A->>B: hi\n B->>C: on"); + expect(d.participants).toEqual(["A", "B", "C"]); + }); + + it("ignores comments and blank lines", () => { + const d = parseSequence("sequenceDiagram\n%% note\n\n A->>B: hi"); + expect(d.statements).toEqual(["A->>B: hi"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/mermaidSequenceDiff.test.ts` +Expected: FAIL — `parseSequence` is not exported. + +- [ ] **Step 3: Write the implementation** + +```ts +// src/mermaidSequenceDiff.ts +/** + * mermaidSequenceDiff — F7.1 (#22). Pure sequence-diagram parser + diff/emit. + * Mermaid sequence diagrams have NO per-message color directive; the only + * per-message hook is the `rect rgb(...) … end` background block. So the emitter + * rebuilds the message stream (ghosted-removed messages re-inserted at their + * baseline position) wrapping each added/changed/removed message in a one-message + * tinted rect, and re-declares removed participants (INV-29/31). Deterministic; + * no vscode, no DOM. + */ +import { diffArrays } from "diff"; +import { CW_COLORS } from "./mermaidDiff"; + +export interface SeqDiagram { + header: string; + participants: string[]; + /** Message/other statement lines, verbatim & trimmed, in order. */ + statements: string[]; +} + +// `A->>B: text`, plus -->>, ->, -->, -x, --x, etc. Capture from/to. +const MSG = /^([A-Za-z0-9_]+)\s*(-?-(?:>>|>|x|\))|--?(?:>>|>|x|\)))\s*([A-Za-z0-9_]+)\s*:/; +const PARTICIPANT = /^(?:participant|actor)\s+([A-Za-z0-9_]+)/; + +export function parseSequence(source: string): SeqDiagram { + const lines = source.split(/\r?\n/); + let header = ""; + const declared: string[] = []; + const seen = new Set(); + const statements: string[] = []; + const addP = (p: string) => { + if (!seen.has(p)) { + seen.add(p); + declared.push(p); + } + }; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("%%")) continue; + if (!header && /^sequenceDiagram\b/.test(line)) { + header = line; + continue; + } + const pm = line.match(PARTICIPANT); + if (pm) { + addP(pm[1]); + continue; + } + const mm = line.match(MSG); + if (mm) { + addP(mm[1]); + addP(mm[3]); + } + statements.push(line); + } + return { header: header || "sequenceDiagram", participants: declared, statements }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/mermaidSequenceDiff.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mermaidSequenceDiff.ts test/mermaidSequenceDiff.test.ts +git commit -m "feat(f7.1): sequence-diagram parser (#22)" +``` + +--- + +## Task 5: Sequence diff + augmented emission — `diffSequence` + +**Files:** +- Modify: `src/mermaidSequenceDiff.ts` +- Test: `test/mermaidSequenceDiff.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// append to test/mermaidSequenceDiff.test.ts +import { diffSequence } from "../src/mermaidSequenceDiff"; + +const rectCount = (s: string, rgb: string) => s.split(`rect rgb(${rgb})`).length - 1; + +describe("diffSequence", () => { + it("wraps an added message in a green rect", () => { + const before = "sequenceDiagram\n A->>B: hi"; + const current = "sequenceDiagram\n A->>B: hi\n B->>C: on"; + const out = diffSequence(before, current); + expect(rectCount(out, "46, 160, 67")).toBe(1); // CW_COLORS.added as rgb + expect(out).toContain("B->>C: on"); + }); + + it("ghosts a removed message back in a grey rect", () => { + const before = "sequenceDiagram\n A->>B: hi\n B->>C: gone"; + const current = "sequenceDiagram\n A->>B: hi"; + const out = diffSequence(before, current); + expect(out).toContain("B->>C: gone"); + expect(rectCount(out, "128, 128, 128")).toBe(1); + }); + + it("re-declares a removed participant so it still appears", () => { + const before = "sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: x"; + const current = "sequenceDiagram\n participant A\n participant B\n A->>B: y"; + const out = diffSequence(before, current); + expect(out).toMatch(/participant C/); + }); + + it("keeps an unchanged message outside any rect", () => { + const doc = "sequenceDiagram\n A->>B: hi"; + const out = diffSequence(doc, doc); + expect(out).not.toContain("rect rgb"); + }); + + it("is deterministic", () => { + const before = "sequenceDiagram\n A->>B: hi"; + const current = "sequenceDiagram\n A->>B: hi\n B->>A: bye"; + expect(diffSequence(before, current)).toBe(diffSequence(before, current)); + }); +}); +``` + +> Note: `CW_COLORS.added` is `#2ea043` → rgb `46, 160, 67`; `changed` `#d29922` → `210, 153, 34`; `removed` `#808080` → `128, 128, 128`. `diffSequence` must emit `rect rgb(r, g, b)` from the hex. Add a `hexToRgb` helper. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/mermaidSequenceDiff.test.ts` +Expected: FAIL — `diffSequence` (stub) returns current unchanged; no `rect` emitted. + +- [ ] **Step 3: Write the implementation** + +```ts +// append to src/mermaidSequenceDiff.ts + +function hexToRgb(hex: string): string { + const h = hex.replace("#", ""); + const n = parseInt(h, 16); + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; +} + +function rectWrap(stmt: string, hex: string): string[] { + return [` rect rgb(${hexToRgb(hex)})`, ` ${stmt}`, ` end`]; +} + +export function diffSequence(beforeSrc: string, currentSrc: string): string { + const before = parseSequence(beforeSrc); + const current = parseSequence(currentSrc); + + const out: string[] = [current.header]; + + // Participants: keep current declarations, then re-declare removed ones (ghosts). + for (const p of current.participants) out.push(` participant ${p}`); + const curSet = new Set(current.participants); + for (const p of before.participants) { + if (!curSet.has(p)) out.push(` participant ${p}`); + } + + // Message stream diff via LCS over statements; pair adjacent removed+added as changed. + const parts = diffArrays(before.statements, current.statements); + for (let n = 0; n < parts.length; n++) { + const ch = parts[n]; + if (!ch.added && !ch.removed) { + for (const s of ch.value) out.push(` ${s}`); + continue; + } + if (ch.removed) { + const next = parts[n + 1]; + const addVals = next?.added ? next.value : []; + const paired = Math.min(ch.value.length, addVals.length); + for (let k = 0; k < paired; k++) out.push(...rectWrap(addVals[k], CW_COLORS.changed)); + for (let k = paired; k < ch.value.length; k++) out.push(...rectWrap(ch.value[k], CW_COLORS.removed)); + for (let k = paired; k < addVals.length; k++) out.push(...rectWrap(addVals[k], CW_COLORS.added)); + if (next?.added) n++; + continue; + } + // lone added run + for (const s of ch.value) out.push(...rectWrap(s, CW_COLORS.added)); + } + return out.join("\n"); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/mermaidSequenceDiff.test.ts` +Expected: PASS (all sequence tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mermaidSequenceDiff.ts test/mermaidSequenceDiff.test.ts +git commit -m "feat(f7.1): sequence participant/message diff + rect emission (#22)" +``` + +--- + +## Task 6: Wire `diffMermaid` into `renderOp` + legend + +**Files:** +- Modify: `src/trackChangesModel.ts` (the `changed` case in `renderOp`, ~line 256-264; add a `mermaidFenceBody` helper near the other helpers) +- Test: `test/trackChangesModel.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// append to test/trackChangesModel.test.ts +import { renderTrackChanges as rtc2 } from "../src/trackChangesModel"; + +describe("renderTrackChanges — intra-diagram mermaid (#22)", () => { + it("augments a changed flowchart with styling directives (INV-29)", () => { + const base = "```mermaid\nflowchart LR\n A-->B\n```\n"; + const cur = "```mermaid\nflowchart LR\n A-->B\n B-->C\n```\n"; + const html = rtc2(base, cur); + expect(html).toContain("classDef cwAdded"); + expect(html).toMatch(/class\s+C\s+cwAdded/); + expect(html).toContain('class="cw-mermaid-legend"'); + // no single whole-block "changed" badge when we augmented + expect(html).not.toContain('changed'); + }); + + it("augments a changed sequence diagram", () => { + const base = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n"; + const cur = "```mermaid\nsequenceDiagram\n A->>B: hi\n B->>C: on\n```\n"; + const html = rtc2(base, cur); + expect(html).toContain("rect rgb(46, 160, 67)"); + }); + + it("falls back to the v1 badge for an unsupported diagram type (INV-30)", () => { + const base = "```mermaid\nclassDiagram\n class A\n```\n"; + const cur = "```mermaid\nclassDiagram\n class B\n```\n"; + const html = rtc2(base, cur); + expect(html).toContain('changed'); + expect(html).not.toContain("classDef cwAdded"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: FAIL — current changed-mermaid branch always emits the badge, never the directives. + +- [ ] **Step 3: Write the implementation** + +Add the import at the top of `src/trackChangesModel.ts` (with the other imports): + +```ts +import { diffMermaid } from "./mermaidDiff"; +``` + +Add a helper near `wordMergedMarkdown` (returns the inner mermaid source of a fence, or null if not a single mermaid fence): + +```ts +/** Inner source of a ```mermaid fence (drops the fence lines), or null. */ +function mermaidFenceBody(raw: string): string | null { + const lines = raw.split(/\r?\n/); + const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); + if (!open || open[3]?.toLowerCase() !== "mermaid") return null; + const body = lines.slice(1); + if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop(); + return body.join("\n"); +} +``` + +Replace the `changed` case's atomic branch (currently): + +```ts + if (op.atomic) { + inner = safe(op.block.raw); // the NEW block, whole (INV-23) + badge = 'changed'; + } else { +``` + +with: + +```ts + if (op.atomic) { + const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null; + const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null; + const md = curBody !== null && beforeBody !== null ? diffMermaid(beforeBody, curBody) : { kind: "fallback" as const }; + if (md.kind === "augmented") { + // Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge. + inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND; + } else { + inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback) + badge = 'changed'; + } + } else { +``` + +Add the legend constant near the top-level helpers: + +```ts +const MERMAID_LEGEND = + '
' + + 'added' + + 'changed' + + 'removed' + + "
"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/trackChangesModel.test.ts` +Expected: PASS (existing + 3 new). Then run the whole unit suite: `npm test` — all green (no regressions to the existing `a mermaid-fence change → an ATOMIC changed op` test, which asserts the *op*, not the HTML). + +- [ ] **Step 5: Commit** + +```bash +git add src/trackChangesModel.ts test/trackChangesModel.test.ts +git commit -m "feat(f7.1): render augmented mermaid diff in changed-block branch (#22)" +``` + +--- + +## Task 7: Legend CSS + +**Files:** +- Modify: `media/preview.css` + +- [ ] **Step 1: Add the legend styles** (append to `media/preview.css`) + +```css +/* F7.1 (#22) intra-diagram mermaid diff legend. */ +.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; } +.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; } +.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; } +.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; } +.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; } +``` + +- [ ] **Step 2: Build to confirm esbuild emits the CSS** + +Run: `npm run build` +Expected: build succeeds; `out/media/preview.css` exists and contains `cw-mermaid-legend`. + +Run: `grep -c cw-mermaid-legend out/media/preview.css` +Expected: `1` or more. + +- [ ] **Step 3: Commit** + +```bash +git add media/preview.css +git commit -m "feat(f7.1): mermaid diff legend styles (#22)" +``` + +--- + +## Task 8: Host E2E — changed flowchart augments the emitted HTML + +**Files:** +- Modify: `test/e2e/suite/trackChangesPreview.test.ts` +- Check fixture: `test/e2e/fixtures/workspace/docs/preview.md` (read it first; the test edits a buffer in-place — follow the existing pattern in this file for opening the doc, showing the preview, capturing a baseline, applying a `WorkspaceEdit`, and reading `getLastModel`). + +- [ ] **Step 1: Read the existing E2E test + fixture to match the exact harness** + +Run: `sed -n '1,60p' test/e2e/suite/trackChangesPreview.test.ts` +Note how `api`, `docUri()`, baseline capture (`cowriting.pinDiffBaseline` / proposal accept) and `applyEdit` are used; reuse them verbatim. + +- [ ] **Step 2: Write the failing test** (add inside the existing `suite`/`describe`) + +```ts + test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => { + // Open a markdown doc containing a flowchart, show preview, pin baseline. + const doc = await openPreviewDoc("```mermaid\nflowchart LR\n A-->B\n```\n"); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + + // Edit: add a node C. + const edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, new vscode.Position(2, 7), "\n B-->C"); + assert.ok(await vscode.workspace.applyEdit(edit), "edit applied"); + await waitForRefresh(); + + const model = api.trackChangesPreviewController.getLastModel(doc.uri.toString()) ?? []; + const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid"); + assert.ok(mermaidOp, "the flowchart block is a changed op"); + + const html = api.trackChangesPreviewController.renderHtmlFor(doc.uri.toString()); + assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef"); + assert.match(html, /class\s+C\s+cwAdded/, "node C colored added"); + }); +``` + +> Note: `openPreviewDoc`, `waitForRefresh`, and `renderHtmlFor` may not exist yet. Use whatever the file already provides (it has a doc-open helper and uses `getLastModel`). If a direct HTML accessor is missing, add a tiny test-seam method `renderHtmlFor(uriString): string` to `TrackChangesPreviewController` that returns `renderTrackChanges(baselineText, current)` for the doc (mirror `getLastModel`), and export it in the §6.4 test-seam block. If the harness can only assert the model (not the HTML), assert on the model instead and move the HTML assertion to the unit test in Task 6 (already covered there) — do not block E2E on webview internals. + +- [ ] **Step 3: Run E2E to verify it fails** + +Run: `npm run test:e2e` +Expected: FAIL on the new assertion (or on the missing seam — add it per the note). + +- [ ] **Step 4: Implement the seam if needed, then re-run** + +If you added `renderHtmlFor`, implement it in `src/trackChangesPreview.ts` test-seam section: + +```ts + /** F7.1 test seam: the track-changes HTML the panel would post for a doc. */ + renderHtmlFor(uriString: string): string { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (!doc) return ""; + const current = doc.getText(); + const baseline = this.diffView.getBaseline(uriString); + return renderTrackChanges(baseline?.text ?? current, current); + } +``` + +Run: `npm run test:e2e` +Expected: PASS (existing + new). + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e/suite/trackChangesPreview.test.ts src/trackChangesPreview.ts +git commit -m "test(f7.1): host E2E — changed flowchart augments emitted source (#22)" +``` + +--- + +## Task 9: Manual-smoke doc + full verification + PR + +**Files:** +- Create: `docs/MANUAL-SMOKE-F7.1.md` + +- [ ] **Step 1: Write the manual-smoke doc** (mirror the style of `docs/MANUAL-SMOKE-F7.md`) + +```markdown +# Manual smoke — F7.1 intra-diagram mermaid diffing (#22) + +Webview SVG rendering isn't covered by automated E2E (sealed sandbox, §6.8), so +verify the rendered colors by hand once. + +1. Open a markdown doc with a flowchart: + ```mermaid + flowchart LR + A[Start] --> B[Parse] + B --> C[Emit] + ``` +2. Run **Cowriting: Show Track-Changes Preview** (`ctrl+alt+r`). Pin the baseline + (`Cowriting: Pin Diff Baseline`). +3. Edit the diagram: change `B[Parse]` → `B[Parse+Lint]`, add `B --> D[Validate]`, + delete the `B --> C` edge and the `C` node. +4. **Expect** in the preview: `B` outlined amber (changed), `D` green (added), + `C` shown as a faded dashed ghost (removed), and a legend + `added · changed · removed` under the diagram. +5. Repeat with a `sequenceDiagram`: add a message (green rect), remove one + (grey ghost rect), confirm a removed participant still appears. +6. Change a diagram to a `classDiagram` and confirm it still shows the v1 + whole-block "changed" badge (graceful fallback, INV-30). +``` + +- [ ] **Step 2: Full verification (run all gates)** + +```bash +npm run typecheck && npm test && npm run test:e2e +``` +Expected: typecheck clean; full vitest suite green (≈164+ tests); E2E green (≈38 tests). Record the counts. + +- [ ] **Step 3: Commit the smoke doc** + +```bash +git add docs/MANUAL-SMOKE-F7.1.md +git commit -m "docs(f7.1): manual webview-render smoke (#22)" +``` + +- [ ] **Step 4: Push the branch and open the PR** + +```bash +git push -u origin +``` +Open a PR titled `F7.1: intra-diagram mermaid diffing (#22)` whose body cites +spec §11, lists INV-29..31, the new modules, the unit/E2E counts, and links +`Fixes #22`. (The session uses SSH remotes; PR via Gitea per `wgl-gitea-admin` / +the session-finalize flow.) + +--- + +## Self-Review + +**Spec coverage (§11):** +- §11.1 supported types (flowchart, sequence) + total fallback → Tasks 1, 6 (dispatch + fallback), 3, 5. +- §11.2 parsed-graph diff + ghost-in-place → Tasks 2-5. +- §11.3 styling asymmetry (flowchart classDef/linkStyle vs sequence rect) → Tasks 3, 5. +- §11.4 module tree + renderOp seam + webview-unchanged → Tasks 1, 6 (+ legend Task 7). +- §11.5 INV-29 (augment), INV-30 (fallback total), INV-31 (ghost completeness) → asserted in Tasks 3 (ghost), 5 (ghost), 6 (augment + fallback). +- §11.6 unit + host E2E → Tasks 2-6 (unit), Task 8 (E2E). +- §11.7 out-of-scope types not implemented → confirmed (only flowchart + sequence). + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; the one explicitly-flexible spot (E2E HTML seam, Task 8) gives a concrete fallback path. + +**Type consistency:** `MermaidDiffResult` (`augmented`|`fallback`) used identically in `diffMermaid` (Task 1) and `renderOp` (Task 6). `FlowGraph`/`FlowNode`/`FlowEdge` defined once (Task 2) and consumed in Task 3. `SeqDiagram` defined Task 4, consumed Task 5. `CW_COLORS` defined once (Task 1), imported by both differs and (as hex) the legend CSS uses the same literals. `diffFlowchart`/`diffSequence`/`parseFlowchart`/`parseSequence`/`detectDiagramType`/`diffMermaid` names consistent across tasks.