Files
vscode-cowriting-plugin-con…/plans/2026-06-11-f7-intra-diagram-mermaid-diff.md
T

39 KiB
Raw Blame History

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.tsparseFlowchart(source): FlowGraph, diffFlowchart(beforeSrc, currentSrc): string.
  • Create src/mermaidSequenceDiff.tsparseSequence(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):

// 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

// 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
// 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):

// src/mermaidFlowchartDiff.ts — stub, replaced in Tasks 2-3
export function diffFlowchart(_beforeSrc: string, currentSrc: string): string {
  return currentSrc;
}
// 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
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

// 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
// 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
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

// 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
// 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
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

// 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
// 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
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

// 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 #d29922210, 153, 34; removed #808080128, 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
// 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
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

// 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):

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):

/** 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):

      if (op.atomic) {
        inner = safe(op.block.raw); // the NEW block, whole (INV-23)
        badge = '<span class="cw-badge">changed</span>';
      } else {

with:

      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:

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
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)

/* 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
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)
  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:

  /** 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
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)

# 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]
  1. Run Cowriting: Show Track-Changes Preview (ctrl+alt+r). Pin the baseline (Cowriting: Pin Diff Baseline).
  2. Edit the diagram: change B[Parse]B[Parse+Lint], add B --> D[Validate], delete the B --> C edge and the C node.
  3. 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.
  4. Repeat with a sequenceDiagram: add a message (green rect), remove one (grey ghost rect), confirm a removed participant still appears.
  5. 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
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
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.