1035 lines
39 KiB
Markdown
1035 lines
39 KiB
Markdown
# 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<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} |