Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-11-f7-intra-diagram-mermaid-diff.md
T
2026-06-11 16:28:53 -07:00

1035 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 25):
```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<string, FlowNode>;
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<string, FlowNode>, 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<string, FlowNode>();
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<string>();
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('<span class="cw-badge">changed</span>');
});
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('<span class="cw-badge">changed</span>');
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 = '<span class="cw-badge">changed</span>';
} 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 = '<span class="cw-badge">changed</span>';
}
} else {
```
Add the legend constant near the top-level helpers:
```ts
const MERMAID_LEGEND =
'<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' +
'<span class="cw-leg cw-leg-rem">removed</span>' +
"</div>";
```
- [ ] **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 <branch>
```
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.