From bfd9c2b25af47d31f6a41d662af8e42812d3d96b Mon Sep 17 00:00:00 2001 From: anandpant <109482096+anandpant@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:47:54 +0000 Subject: [PATCH] feat(diagram-core): canonical sequence diagram contract and family registry (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - `diagram-core` registers `sequence` as a canonical family with its own Effect Schema contract, `SequenceDiagram`: ordered participants and chronologically ordered messages, a `call`/`return` message type, and style defaults. It also adds `getSequenceValidationIssues` (duplicate ids, unknown participants, self messages, lifeline id collisions), `parseSequenceDiagram`, and two maintained fixtures. - `IntermediateDiagram` is now explicitly the node/edge graph IR for flowchart and mindmap (`GRAPH_DIAGRAM_TYPES`), so a `type: "sequence"` graph is rejected. - `CanonicalDiagramSchema`, `parseCanonicalDiagram`, and `validateCanonicalDiagram` cover every family. A `satisfies Record` map makes a registry entry without a contract fail to compile. - `renderSequenceDiagram` takes the core `SequenceDiagram` and validates it with core, so the renderer's own input interface and render-time checks are deleted. `renderDiagram` dispatches any canonical diagram to its family renderer. - `GenerationWorkspace` accepts any canonical diagram, and a new `Diagram Types/Sequence` Storybook entry renders both sequence fixtures. - Code Mode's sequence build renders through the core contract. Adopting core validation there comes in the next PR in this stack. ## Why #280 asks for one typed sequence contract shared by every entry point. This PR adds that contract and moves the renderer and UI onto it. The next PR moves generation and Code Mode validation onto it and deletes their duplicate shapes. ## Verification - `pnpm nx affected -t typecheck,test --base=origin/main` (12 projects) - `pnpm run check` (Oxlint, Oxfmt, lint-rule tests), `tools/project-graph.test.ts` - `pnpm nx build-storybook diagram-ui` Refs #280 --- .oxlintrc.json | 4 +- .../bound-labels.browser.test.tsx | 1 + .../src/lib/code-mode/effect-runtime.test.ts | 1 + .../agent/src/lib/code-mode/runtime.ts | 5 +- packages/diagram/core/README.md | 7 + packages/diagram/core/src/diagram.ts | 52 ++++ packages/diagram/core/src/fixtures.ts | 11 +- packages/diagram/core/src/index.ts | 2 + packages/diagram/core/src/intermediate.ts | 7 +- packages/diagram/core/src/types.ts | 13 +- .../diagram/core/src/types/sequence.test.ts | 103 ++++++++ packages/diagram/core/src/types/sequence.ts | 223 ++++++++++++++++++ .../excalidraw/src/lib/convert.test.ts | 3 + .../src/diagram-types/sequence.test.ts | 58 +++-- packages/diagram/renderer/src/diagram.ts | 15 ++ packages/diagram/renderer/src/index.ts | 1 + packages/diagram/renderer/src/scene.test.ts | 1 + packages/diagram/renderer/src/sequence.ts | 70 ++---- .../flowchart-validation-panel.tsx | 14 +- .../generation-workspace.test.tsx | 14 +- .../generation-workspace.tsx | 35 +-- .../ui/src/diagram-types/sequence.stories.tsx | 27 +++ tools/project-graph.test.ts | 2 + 23 files changed, 569 insertions(+), 100 deletions(-) create mode 100644 packages/diagram/core/src/diagram.ts create mode 100644 packages/diagram/core/src/types/sequence.test.ts create mode 100644 packages/diagram/core/src/types/sequence.ts create mode 100644 packages/diagram/renderer/src/diagram.ts create mode 100644 packages/diagram/ui/src/diagram-types/sequence.stories.tsx diff --git a/.oxlintrc.json b/.oxlintrc.json index d57e5979..7aaf6173 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -280,10 +280,12 @@ { // Diagram-core's schema boundary files may use stable Effect Schema. "files": [ + "packages/diagram/core/src/diagram.ts", "packages/diagram/core/src/intermediate.ts", "packages/diagram/core/src/types/flowchart.ts", "packages/diagram/core/src/types/flowchart.test.ts", - "packages/diagram/core/src/types/mindmap.ts" + "packages/diagram/core/src/types/mindmap.ts", + "packages/diagram/core/src/types/sequence.ts" ], "rules": { "eslint/no-restricted-imports": [ diff --git a/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx b/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx index 1c96e854..8e4732a3 100644 --- a/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx +++ b/apps/excalidraw/src/components/excalidraw-workspace/bound-labels.browser.test.tsx @@ -153,6 +153,7 @@ const agentCanvas: CanvasSpec = { }; const sequence = renderSequenceDiagram({ + type: "sequence", id: "long-participants", title: "Long participants", participants: [ diff --git a/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts b/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts index df3250a8..2dfa8f8f 100644 --- a/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts +++ b/packages/diagram/agent/src/lib/code-mode/effect-runtime.test.ts @@ -316,6 +316,7 @@ layer(runtimeLayer)("Code Mode Effect workflow", (it) => { it.effect("preserves sequence stroke styles through scene encoding", () => Effect.gen(function* () { const rendered = renderSequenceDiagram({ + type: "sequence", id: "return-message", title: "Return message", participants: [ diff --git a/packages/diagram/agent/src/lib/code-mode/runtime.ts b/packages/diagram/agent/src/lib/code-mode/runtime.ts index 5bd2227c..39915b38 100644 --- a/packages/diagram/agent/src/lib/code-mode/runtime.ts +++ b/packages/diagram/agent/src/lib/code-mode/runtime.ts @@ -7,6 +7,8 @@ import { getCanvasValidationIssues, getFlowchartValidationIssues, parseMindmapDiagram, + parseSequenceDiagram, + sequenceLifelineId, validateFlowchartDiagram, type FlowchartDiagram, type FlowchartValidationIssueRef, @@ -23,7 +25,6 @@ import { renderIntermediateDiagram, renderSequenceDiagram, isStructurallyValidSequenceLifeline, - sequenceLifelineId, type RenderedDiagramScene, type ScenePoint, } from "@sketchi/diagram-renderer"; @@ -2272,7 +2273,7 @@ const buildSequenceDiagramWorkflow = Effect.fn("codeMode.buildSequenceDiagram.wo } const scene = yield* Effect.try({ - try: () => renderSequenceDiagram(normalizedSpec), + try: () => renderSequenceDiagram(parseSequenceDiagram({ ...normalizedSpec, type: "sequence" })), catch: (cause) => new BuildSequenceDiagramFailure({ status: "render_failed", diff --git a/packages/diagram/core/README.md b/packages/diagram/core/README.md index ca99f50b..d48e070e 100644 --- a/packages/diagram/core/README.md +++ b/packages/diagram/core/README.md @@ -7,10 +7,17 @@ flowchart LR Fixtures["fixtures"] --> Registry["diagram type registry"] Registry --> Flowchart["flowchart contract"] Registry --> Mindmap["mindmap contract"] + Registry --> Sequence["sequence contract"] Flowchart --> Consumers["generation, rendering, UI"] Mindmap --> Consumers + Sequence --> Consumers ``` +Flowchart and mindmap share the node/edge `IntermediateDiagram` graph. +Sequence diagrams keep their own `SequenceDiagram` contract: ordered +participants and chronologically ordered messages, never a graph. +`CanonicalDiagramSchema` and `parseCanonicalDiagram` accept any family. + | Owns | Does not own | | ------------------------------- | --------------------------- | | diagram type registry | model calls or prompts | diff --git a/packages/diagram/core/src/diagram.ts b/packages/diagram/core/src/diagram.ts new file mode 100644 index 00000000..8ef89c36 --- /dev/null +++ b/packages/diagram/core/src/diagram.ts @@ -0,0 +1,52 @@ +import { Schema } from "effect"; + +import { + type FlowchartDiagram, + FlowchartDiagramSchema, + validateFlowchartDiagram, +} from "./types/flowchart.js"; +import { + type MindmapDiagram, + MindmapDiagramSchema, + validateMindmapDiagram, +} from "./types/mindmap.js"; +import { + type SequenceDiagram, + SequenceDiagramSchema, + validateSequenceDiagram, +} from "./types/sequence.js"; +import type { DiagramTypeValue } from "./types.js"; + +/** One diagram of any canonical family, discriminated by `type`. */ +export type CanonicalDiagram = FlowchartDiagram | MindmapDiagram | SequenceDiagram; + +// A family added to DIAGRAM_TYPES without a contract here fails to compile. +const familySchemas = { + flowchart: FlowchartDiagramSchema, + mindmap: MindmapDiagramSchema, + sequence: SequenceDiagramSchema, +} satisfies Record; + +export const CanonicalDiagramSchema = Schema.Union([ + familySchemas.flowchart, + familySchemas.mindmap, + familySchemas.sequence, +]); + +/** Apply the family's semantic validation to an already decoded diagram. */ +export function validateCanonicalDiagram(diagram: CanonicalDiagram): CanonicalDiagram { + switch (diagram.type) { + case "flowchart": + return validateFlowchartDiagram(diagram); + case "mindmap": + return validateMindmapDiagram(diagram); + case "sequence": + return validateSequenceDiagram(diagram); + } +} + +export function parseCanonicalDiagram(input: unknown): CanonicalDiagram { + return validateCanonicalDiagram( + Schema.decodeUnknownSync(CanonicalDiagramSchema, { errors: "all" })(input), + ); +} diff --git a/packages/diagram/core/src/fixtures.ts b/packages/diagram/core/src/fixtures.ts index 37aeb4d4..7cccb098 100644 --- a/packages/diagram/core/src/fixtures.ts +++ b/packages/diagram/core/src/fixtures.ts @@ -1,8 +1,11 @@ import { flowchartFixture } from "./types/flowchart.js"; import { mindmapFixture } from "./types/mindmap.js"; -import { parseIntermediateDiagram } from "./intermediate.js"; +import { sequenceFixture } from "./types/sequence.js"; +import type { CanonicalDiagram } from "./diagram.js"; -export const diagramFixtures = [ - parseIntermediateDiagram(flowchartFixture), - parseIntermediateDiagram(mindmapFixture), +/** One maintained fixture per canonical family. */ +export const diagramFixtures: readonly CanonicalDiagram[] = [ + flowchartFixture, + mindmapFixture, + sequenceFixture, ]; diff --git a/packages/diagram/core/src/index.ts b/packages/diagram/core/src/index.ts index 93e73a94..de02b83c 100644 --- a/packages/diagram/core/src/index.ts +++ b/packages/diagram/core/src/index.ts @@ -3,8 +3,10 @@ export * from "./fixtures.js"; export * from "./icon.js"; export * from "./intermediate.js"; export * from "./canvas.js"; +export * from "./diagram.js"; export * from "./types/mindmap.js"; export * from "./types/flowchart.js"; +export * from "./types/sequence.js"; export * from "./segments.js"; export * from "./hash.js"; export * from "./text-metrics.js"; diff --git a/packages/diagram/core/src/intermediate.ts b/packages/diagram/core/src/intermediate.ts index 7279327b..133bf837 100644 --- a/packages/diagram/core/src/intermediate.ts +++ b/packages/diagram/core/src/intermediate.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from "effect"; import { DIAGRAM_ICON_SLUG_MAX_LENGTH, DIAGRAM_ICON_SLUG_PATTERN } from "./icon.js"; -import { DIAGRAM_TYPES } from "./types.js"; +import { DIAGRAM_TYPES, GRAPH_DIAGRAM_TYPES } from "./types.js"; const NonEmptyString = Schema.NonEmptyString; const Metadata = Schema.Record(Schema.String, Schema.Unknown); @@ -27,10 +27,13 @@ function withDefault(schema: S, value: S["Encoded"]) { } export const DiagramTypeSchema = Schema.Literals(DIAGRAM_TYPES); +/** Sequence diagrams have their own contract; only graph families use nodes and edges. */ +export const GraphDiagramTypeSchema = Schema.Literals(GRAPH_DIAGRAM_TYPES); export const LayoutDirectionSchema = Schema.Literals(["TB", "BT", "LR", "RL"]); export const EdgeRoutingSchema = Schema.Literals(["straight", "orthogonal", "curved"]); export type DiagramType = typeof DiagramTypeSchema.Type; +export type GraphDiagramType = typeof GraphDiagramTypeSchema.Type; export type LayoutDirection = typeof LayoutDirectionSchema.Type; export type EdgeRouting = typeof EdgeRoutingSchema.Type; @@ -83,7 +86,7 @@ export const DiagramLayoutSchema = DiagramLayout; export class IntermediateDiagram extends Schema.Class("IntermediateDiagram")({ id: NonEmptyString, title: NonEmptyString, - type: withDefault(DiagramTypeSchema, "flowchart"), + type: withDefault(GraphDiagramTypeSchema, "flowchart"), nodes: Schema.Array(DiagramNode).pipe(Schema.mutable).check(Schema.isMinLength(1)), edges: withDefault(Schema.Array(DiagramEdge).pipe(Schema.mutable), []), layout: withDefault(DiagramLayout, { diff --git a/packages/diagram/core/src/types.ts b/packages/diagram/core/src/types.ts index e3ac707d..28e33174 100644 --- a/packages/diagram/core/src/types.ts +++ b/packages/diagram/core/src/types.ts @@ -1,3 +1,14 @@ -export const DIAGRAM_TYPES = ["flowchart", "mindmap"] as const; +/** + * Every canonical diagram family. A family listed here must complete each + * pipeline stage in docs/diagram-families.md; type-structure.test.ts and the + * family pipeline test fail when one is missing. + */ +export const DIAGRAM_TYPES = ["flowchart", "mindmap", "sequence"] as const; export type DiagramTypeValue = (typeof DIAGRAM_TYPES)[number]; + +/** Families expressed as the node/edge `IntermediateDiagram` graph. */ +export const GRAPH_DIAGRAM_TYPES = [ + "flowchart", + "mindmap", +] as const satisfies readonly DiagramTypeValue[]; diff --git a/packages/diagram/core/src/types/sequence.test.ts b/packages/diagram/core/src/types/sequence.test.ts new file mode 100644 index 00000000..03dfe69b --- /dev/null +++ b/packages/diagram/core/src/types/sequence.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { parseCanonicalDiagram } from "../diagram"; +import { parseIntermediateDiagram, SKETCHI_DIAGRAM_STYLE } from "../intermediate"; +import { + type SequenceDiagram, + SequenceValidationError, + apiRequestSequence, + getSequenceValidationIssues, + parseSequenceDiagram, + sequenceDiagramType, + sequenceFixture, + sequenceLifelineId, +} from "./sequence"; + +function issueCodes(diagram: SequenceDiagram) { + return getSequenceValidationIssues(diagram).map((issue) => [issue.code, issue.path]); +} + +describe("Sequence diagram type", () => { + it("keeps participant and chronological message order in the typed fixture", () => { + expect(sequenceFixture.type).toBe(sequenceDiagramType); + expect(parseCanonicalDiagram(sequenceFixture)).toEqual(sequenceFixture); + expect(sequenceFixture.participants.map((participant) => participant.id)).toEqual([ + "customer", + "store", + "payments", + ]); + expect(sequenceFixture.messages.map((message) => message.id)).toEqual([ + "checkout", + "charge", + "charged", + "receipt", + ]); + expect(apiRequestSequence.messages.filter((message) => message.type === "return")).toHaveLength( + 3, + ); + }); + + it("defaults messages and the Sketchi style without a separate authoring shape", () => { + const diagram = parseSequenceDiagram({ + id: "lonely", + title: "Lonely participant", + type: "sequence", + participants: [{ id: "only", label: "Only" }], + }); + expect(diagram.messages).toEqual([]); + expect(diagram.style).toEqual(SKETCHI_DIAGRAM_STYLE); + }); + + it("reports every broken participant and message reference with a repair hint", () => { + expect( + issueCodes({ + ...sequenceFixture, + participants: [...sequenceFixture.participants, { id: "store", label: "Second store" }], + messages: [ + ...sequenceFixture.messages, + { id: "charge", source: "ghost", target: "store", label: "Unknown sender" }, + { id: "loop", source: "store", target: "nowhere", label: "Unknown target" }, + { id: "self", source: "store", target: "store", label: "Self message" }, + ], + }), + ).toEqual([ + ["duplicate_participant_id", "participants.[3].id"], + ["duplicate_message_id", "messages.[4].id"], + ["missing_message_source", "messages.[4].source"], + ["missing_message_target", "messages.[5].target"], + ["self_message", "messages.[6]"], + ]); + }); + + it("rejects participant ids that collide with generated lifelines", () => { + const input: SequenceDiagram = { + ...sequenceFixture, + participants: [ + { id: "api", label: "API" }, + { id: sequenceLifelineId("api"), label: "Worker" }, + ], + messages: [], + }; + expect(issueCodes(input)).toEqual([["lifeline_id_collision", "participants.[1].id"]]); + expect(() => parseSequenceDiagram(input)).toThrow(SequenceValidationError); + }); + + it("is not a node/edge graph: the graph IR refuses the sequence type", () => { + expect(() => + parseIntermediateDiagram({ + id: "sequence-as-graph", + title: "Sequence as graph", + type: "sequence", + nodes: [{ id: "a", label: "A" }], + }), + ).toThrow(/Expected "flowchart" \| "mindmap"/u); + expect(() => + parseSequenceDiagram({ + id: "graph-as-sequence", + title: "Graph as sequence", + type: "sequence", + nodes: [{ id: "a", label: "A" }], + }), + ).toThrow(/participants/u); + }); +}); diff --git a/packages/diagram/core/src/types/sequence.ts b/packages/diagram/core/src/types/sequence.ts new file mode 100644 index 00000000..072dbf29 --- /dev/null +++ b/packages/diagram/core/src/types/sequence.ts @@ -0,0 +1,223 @@ +import { Effect, Schema } from "effect"; + +import { DiagramStyle, DiagramValidationError, SKETCHI_DIAGRAM_STYLE } from "../intermediate.js"; + +export const sequenceDiagramType = "sequence" as const; + +/** A call ("message") or a response to an earlier call ("return"). */ +export const SEQUENCE_MESSAGE_TYPES = ["message", "return"] as const; +export const SEQUENCE_MESSAGE_STYLES = ["solid", "dashed"] as const; + +/** Lifelines are addressed as `:lifeline` in rendered scenes. */ +export const SEQUENCE_LIFELINE_SUFFIX = ":lifeline"; + +export function sequenceLifelineId(participantId: string): string { + return `${participantId}${SEQUENCE_LIFELINE_SUFFIX}`; +} + +export const SequenceMessageTypeSchema = Schema.Literals(SEQUENCE_MESSAGE_TYPES); +export const SequenceMessageStyleSchema = Schema.Literals(SEQUENCE_MESSAGE_STYLES); +export type SequenceMessageType = typeof SequenceMessageTypeSchema.Type; +export type SequenceMessageStyle = typeof SequenceMessageStyleSchema.Type; + +export class SequenceParticipant extends Schema.Class("SequenceParticipant")({ + id: Schema.NonEmptyString, + label: Schema.NonEmptyString, + kind: Schema.optionalKey(Schema.NonEmptyString), +}) {} +export const SequenceParticipantSchema = SequenceParticipant; + +/** One message row. Array order is chronological order. */ +export class SequenceMessage extends Schema.Class("SequenceMessage")({ + id: Schema.NonEmptyString, + source: Schema.NonEmptyString, + target: Schema.NonEmptyString, + label: Schema.NonEmptyString, + type: Schema.optionalKey(SequenceMessageTypeSchema), + style: Schema.optionalKey(SequenceMessageStyleSchema), +}) {} +export const SequenceMessageSchema = SequenceMessage; + +/** + * The canonical sequence diagram: ordered participants (left to right) and + * chronologically ordered messages between their lifelines. Every surface that + * accepts, generates, renders, or evaluates a sequence diagram uses this shape. + */ +export class SequenceDiagram extends Schema.Class("SequenceDiagram")({ + id: Schema.NonEmptyString, + title: Schema.NonEmptyString, + type: Schema.Literal(sequenceDiagramType), + participants: Schema.Array(SequenceParticipant).pipe( + Schema.mutable, + Schema.check(Schema.isMinLength(1)), + ), + messages: Schema.Array(SequenceMessage).pipe( + Schema.mutable, + Schema.withDecodingDefault(Effect.succeed([])), + ), + style: DiagramStyle.pipe( + Schema.withDecodingDefault(Effect.succeed({ ...SKETCHI_DIAGRAM_STYLE })), + ), +}) {} +export const SequenceDiagramSchema = SequenceDiagram; + +export const SequenceValidationIssueCodeSchema = Schema.Literals([ + "duplicate_participant_id", + "lifeline_id_collision", + "duplicate_message_id", + "missing_message_source", + "missing_message_target", + "self_message", +]); +export type SequenceValidationIssueCode = typeof SequenceValidationIssueCodeSchema.Type; + +export class SequenceValidationIssue extends Schema.Class( + "SequenceValidationIssue", +)({ + code: SequenceValidationIssueCodeSchema, + /** Dotted path into the diagram, for example `participants.[1].id`. */ + path: Schema.NonEmptyString, + message: Schema.NonEmptyString, + hint: Schema.NonEmptyString, +}) {} + +export function getSequenceValidationIssues(diagram: SequenceDiagram): SequenceValidationIssue[] { + const issues: SequenceValidationIssue[] = []; + const participantIndexById = new Map(); + diagram.participants.forEach((participant, index) => { + if (participantIndexById.has(participant.id)) { + issues.push({ + code: "duplicate_participant_id", + path: `participants.[${index}].id`, + message: `Participant id "${participant.id}" is duplicated.`, + hint: "Give every participant a unique stable id and update message references.", + }); + return; + } + participantIndexById.set(participant.id, index); + }); + + for (const participant of diagram.participants) { + const lifelineId = sequenceLifelineId(participant.id); + const collisionIndex = participantIndexById.get(lifelineId); + if (collisionIndex !== undefined) { + issues.push({ + code: "lifeline_id_collision", + path: `participants.[${collisionIndex}].id`, + message: `Participant id "${lifelineId}" collides with the generated lifeline for "${participant.id}".`, + hint: `Rename the participant so its id does not equal another participant id followed by ${SEQUENCE_LIFELINE_SUFFIX}.`, + }); + } + } + + const messageIds = new Set(); + diagram.messages.forEach((message, index) => { + if (messageIds.has(message.id)) { + issues.push({ + code: "duplicate_message_id", + path: `messages.[${index}].id`, + message: `Message id "${message.id}" is duplicated.`, + hint: "Give every message a unique id or omit message ids to generate them deterministically.", + }); + } + messageIds.add(message.id); + if (!participantIndexById.has(message.source)) { + issues.push({ + code: "missing_message_source", + path: `messages.[${index}].source`, + message: `Message source "${message.source}" is not a participant.`, + hint: "Use the id of a participant declared in participants.", + }); + } + if (!participantIndexById.has(message.target)) { + issues.push({ + code: "missing_message_target", + path: `messages.[${index}].target`, + message: `Message target "${message.target}" is not a participant.`, + hint: "Use the id of a participant declared in participants.", + }); + } + if (message.source === message.target) { + issues.push({ + code: "self_message", + path: `messages.[${index}]`, + message: `Message "${message.id}" is self-referential.`, + hint: "Choose a different target participant; self messages are not supported.", + }); + } + }); + return issues; +} + +export class SequenceValidationError extends DiagramValidationError { + constructor(readonly issues: readonly SequenceValidationIssue[]) { + super(issues[0]?.message ?? "Sequence diagram failed semantic validation."); + this.name = "SequenceValidationError"; + } +} + +export function validateSequenceDiagram(diagram: SequenceDiagram): SequenceDiagram { + const issues = getSequenceValidationIssues(diagram); + if (issues.length > 0) { + throw new SequenceValidationError(issues); + } + return diagram; +} + +export function parseSequenceDiagram(input: unknown): SequenceDiagram { + return validateSequenceDiagram( + Schema.decodeUnknownSync(SequenceDiagram, { errors: "all" })(input), + ); +} + +export const sequenceFixture = parseSequenceDiagram({ + id: "checkout-sequence", + title: "Checkout sequence", + type: sequenceDiagramType, + participants: [ + { id: "customer", label: "Customer" }, + { id: "store", label: "Store" }, + { id: "payments", label: "Payments" }, + ], + messages: [ + { id: "checkout", source: "customer", target: "store", label: "Submit checkout" }, + { id: "charge", source: "store", target: "payments", label: "Charge card" }, + { + id: "charged", + source: "payments", + target: "store", + label: "Payment approved", + type: "return", + }, + { + id: "receipt", + source: "store", + target: "customer", + label: "Order receipt", + type: "return", + }, + ], +}); + +/** A request path with a nested call, a fire-and-forget event, and returns. */ +export const apiRequestSequence = parseSequenceDiagram({ + id: "api-request-sequence", + title: "API request with cache miss", + type: sequenceDiagramType, + participants: [ + { id: "browser", label: "Browser" }, + { id: "api", label: "API Worker" }, + { id: "cache", label: "Cache" }, + { id: "database", label: "Database" }, + { id: "analytics", label: "Analytics" }, + ], + messages: [ + { id: "request", source: "browser", target: "api", label: "GET /orders" }, + { id: "lookup", source: "api", target: "cache", label: "Look up orders" }, + { id: "miss", source: "cache", target: "api", label: "Cache miss", type: "return" }, + { id: "query", source: "api", target: "database", label: "Query orders" }, + { id: "rows", source: "database", target: "api", label: "Order rows", type: "return" }, + { id: "track", source: "api", target: "analytics", label: "Track request" }, + { id: "response", source: "api", target: "browser", label: "200 OK", type: "return" }, + ], +}); diff --git a/packages/diagram/excalidraw/src/lib/convert.test.ts b/packages/diagram/excalidraw/src/lib/convert.test.ts index b779e8f9..7e682f7e 100644 --- a/packages/diagram/excalidraw/src/lib/convert.test.ts +++ b/packages/diagram/excalidraw/src/lib/convert.test.ts @@ -22,6 +22,7 @@ import { describe("authored node geometry", () => { it("exports a sequence with a three-line participant label without growing shapes", () => { const scene = renderSequenceDiagram({ + type: "sequence", id: "multiline-sequence", title: "Multiline sequence", participants: [ @@ -1733,6 +1734,7 @@ describe("convertSceneToExcalidraw", () => { it("accepts sequence messages that cross intermediate lifelines", () => { const scene = convertSceneToExcalidraw( renderSequenceDiagram({ + type: "sequence", id: "cross-lifeline", title: "Cross lifeline", participants: [ @@ -1821,6 +1823,7 @@ describe("bound label first paint", () => { it("wraps sequence participant headers inside the header", () => { const sequence = convertSceneToExcalidraw( renderSequenceDiagram({ + type: "sequence", id: "long-participants", title: "Long participants", participants: [ diff --git a/packages/diagram/renderer/src/diagram-types/sequence.test.ts b/packages/diagram/renderer/src/diagram-types/sequence.test.ts index 2add8953..5edf63ff 100644 --- a/packages/diagram/renderer/src/diagram-types/sequence.test.ts +++ b/packages/diagram/renderer/src/diagram-types/sequence.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; -import { getCanvasValidationIssues } from "@sketchi/diagram-core"; +import { + type SequenceDiagram, + SequenceValidationError, + getCanvasValidationIssues, + parseSequenceDiagram, + sequenceFixture, +} from "@sketchi/diagram-core"; +import { renderDiagram } from "../diagram"; import { renderSequenceDiagram } from "../sequence"; -const sequence = { +const sequence = parseSequenceDiagram({ id: "checkout-sequence", title: "Checkout sequence", + type: "sequence", participants: [ { id: "customer", label: "Customer" }, { id: "store", label: "Store" }, @@ -17,9 +25,23 @@ const sequence = { { id: "receipt", source: "payments", target: "customer", label: "Receipt" }, ], style: { accentColor: "#000000", backgroundColor: "#ffffff" }, -} as const; +}); + +function unvalidated(overrides: Partial): SequenceDiagram { + return { ...sequence, ...overrides }; +} describe("sequence diagram renderer", () => { + it("renders the canonical fixture through the family dispatch", () => { + const scene = renderDiagram(sequenceFixture); + expect(scene).toEqual(renderSequenceDiagram(sequenceFixture)); + expect(scene.diagramId).toBe("checkout-sequence"); + expect(scene.elements.filter((element) => element.type === "arrow")).toHaveLength( + sequenceFixture.messages.length, + ); + expect(getCanvasValidationIssues(scene)).toEqual([]); + }); + it("fits three-line participant labels before positioning lifelines and messages", () => { const scene = renderSequenceDiagram({ ...sequence, @@ -66,25 +88,27 @@ describe("sequence diagram renderer", () => { ).toHaveLength(3); }); - it("rejects self messages cleanly", () => { + it("rejects self messages with the core validation error", () => { expect(() => - renderSequenceDiagram({ - ...sequence, - messages: [{ id: "self", source: "store", target: "store", label: "Retry" }], - }), - ).toThrow(/cannot target its source/); + renderSequenceDiagram( + unvalidated({ + messages: [{ id: "self", source: "store", target: "store", label: "Retry" }], + }), + ), + ).toThrow(SequenceValidationError); }); it("rejects participant ids that collide with generated lifelines", () => { expect(() => - renderSequenceDiagram({ - ...sequence, - participants: [ - { id: "api", label: "API" }, - { id: "api:lifeline", label: "Worker" }, - ], - messages: [], - }), + renderSequenceDiagram( + unvalidated({ + participants: [ + { id: "api", label: "API" }, + { id: "api:lifeline", label: "Worker" }, + ], + messages: [], + }), + ), ).toThrow(/collides with the generated lifeline/); }); }); diff --git a/packages/diagram/renderer/src/diagram.ts b/packages/diagram/renderer/src/diagram.ts new file mode 100644 index 00000000..e020f10a --- /dev/null +++ b/packages/diagram/renderer/src/diagram.ts @@ -0,0 +1,15 @@ +import type { CanonicalDiagram } from "@sketchi/diagram-core"; + +import { type RenderedDiagramScene, renderIntermediateDiagram } from "./scene.js"; +import { renderSequenceDiagram } from "./sequence.js"; + +/** Render any canonical diagram with its family's renderer. */ +export function renderDiagram(diagram: CanonicalDiagram): RenderedDiagramScene { + switch (diagram.type) { + case "flowchart": + case "mindmap": + return renderIntermediateDiagram(diagram); + case "sequence": + return renderSequenceDiagram(diagram); + } +} diff --git a/packages/diagram/renderer/src/index.ts b/packages/diagram/renderer/src/index.ts index 984fe065..032c9147 100644 --- a/packages/diagram/renderer/src/index.ts +++ b/packages/diagram/renderer/src/index.ts @@ -1,2 +1,3 @@ +export * from "./diagram.js"; export * from "./scene.js"; export * from "./sequence.js"; diff --git a/packages/diagram/renderer/src/scene.test.ts b/packages/diagram/renderer/src/scene.test.ts index b0dd3172..a0ea907a 100644 --- a/packages/diagram/renderer/src/scene.test.ts +++ b/packages/diagram/renderer/src/scene.test.ts @@ -18,6 +18,7 @@ describe("geometry bounds regressions", () => { renderIntermediateDiagram(mindmapFixture), renderIntermediateDiagram(pharmaBatchDispositionFlowchart), renderSequenceDiagram({ + type: "sequence", id: "sequence-label-fit", title: "Sequence label fit", participants: [ diff --git a/packages/diagram/renderer/src/sequence.ts b/packages/diagram/renderer/src/sequence.ts index 215fb07e..cec7f8ce 100644 --- a/packages/diagram/renderer/src/sequence.ts +++ b/packages/diagram/renderer/src/sequence.ts @@ -1,4 +1,11 @@ -import { CANVAS_SPEC_VERSION, wrapTextToWidth } from "@sketchi/diagram-core"; +import { + CANVAS_SPEC_VERSION, + SEQUENCE_LIFELINE_SUFFIX, + type SequenceDiagram, + sequenceLifelineId, + validateSequenceDiagram, + wrapTextToWidth, +} from "@sketchi/diagram-core"; import type { ArrowSceneElement, NodeSceneElement, @@ -6,32 +13,6 @@ import type { TextSceneElement, } from "./scene.js"; -export interface SequenceParticipant { - readonly id: string; - readonly label: string; - readonly kind?: string | undefined; -} - -export interface SequenceMessage { - readonly id: string; - readonly source: string; - readonly target: string; - readonly label: string; - readonly type?: string | undefined; - readonly style?: string | undefined; -} - -export interface SequenceDiagramInput { - readonly id: string; - readonly title: string; - readonly participants: readonly SequenceParticipant[]; - readonly messages: readonly SequenceMessage[]; - readonly style: { - readonly accentColor: string; - readonly backgroundColor: string; - }; -} - const PADDING = 48; const HEADER_WIDTH = 180; const HEADER_HEIGHT = 72; @@ -48,10 +29,6 @@ const LAYOUT_ALIGNMENT_EPSILON = 0.01; export const SEQUENCE_LIFELINE_ROLE = "sequence-lifeline"; -export function sequenceLifelineId(participantId: string): string { - return `${participantId}:lifeline`; -} - interface SequenceLifelineStructureNode { readonly type: "node"; readonly id: string; @@ -77,7 +54,7 @@ export function isStructurallyValidSequenceLifeline( scene: SequenceLifelineStructureScene, element: SequenceLifelineStructureNode, ): boolean { - const suffix = ":lifeline"; + const suffix = SEQUENCE_LIFELINE_SUFFIX; if ( !element.nodeId.endsWith(suffix) || element.id !== `node:${element.nodeId}` || @@ -108,18 +85,12 @@ export function isStructurallyValidSequenceLifeline( ); } -/** Render a validated semantic sequence specification without graph normalization. */ -export function renderSequenceDiagram(input: SequenceDiagramInput): RenderedDiagramScene { - const participantIds = new Set(input.participants.map((participant) => participant.id)); - for (const participant of input.participants) { - const generatedLifelineId = sequenceLifelineId(participant.id); - if (participantIds.has(generatedLifelineId)) { - throw new Error( - `Sequence participant "${generatedLifelineId}" collides with the generated lifeline for "${participant.id}".`, - ); - } - } - +/** + * Render a canonical sequence diagram: participants become ordered header + * columns with lifelines, and messages become rows in chronological order. + */ +export function renderSequenceDiagram(diagram: SequenceDiagram): RenderedDiagramScene { + const input = validateSequenceDiagram(diagram); const columnStep = HEADER_WIDTH + PARTICIPANT_GAP; const headerLabelById = new Map( input.participants.map((participant) => [ @@ -193,14 +164,9 @@ export function renderSequenceDiagram(input: SequenceDiagramInput): RenderedDiag }); const messageArrows: ArrowSceneElement[] = input.messages.map((message, index) => { - const sourceX = centerXByParticipant.get(message.source); - const targetX = centerXByParticipant.get(message.target); - if (sourceX === undefined || targetX === undefined) { - throw new Error(`Sequence message "${message.id}" references an unknown participant.`); - } - if (sourceX === targetX) { - throw new Error(`Sequence message "${message.id}" cannot target its source participant.`); - } + // Validation guarantees both participants exist and differ. + const sourceX = centerXByParticipant.get(message.source) ?? 0; + const targetX = centerXByParticipant.get(message.target) ?? 0; const y = firstMessageY + index * MESSAGE_GAP; const direction = targetX > sourceX ? 1 : -1; return { diff --git a/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx b/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx index eb291726..133542b7 100644 --- a/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx +++ b/packages/diagram/ui/src/components/flowchart-validation-panel/flowchart-validation-panel.tsx @@ -1,15 +1,21 @@ export interface FlowchartValidationPanelProps { edgeCount: number; + /** Plural noun for edgeCount; sequence diagrams count messages. */ + edgeNoun?: string; intermediateMessage: string; nodeCount: number; + /** Plural noun for nodeCount; sequence diagrams count participants. */ + nodeNoun?: string; realSceneIssueCount: number; realSceneMessage: string; } export function FlowchartValidationPanel({ edgeCount, + edgeNoun = "edges", intermediateMessage, nodeCount, + nodeNoun = "nodes", realSceneIssueCount, realSceneMessage, }: FlowchartValidationPanelProps) { @@ -18,8 +24,12 @@ export function FlowchartValidationPanel({ return (
- {nodeCount} nodes - {edgeCount} edges + + {nodeCount} {nodeNoun} + + + {edgeCount} {edgeNoun} +
{intermediateMessage} diff --git a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx index b7232a98..e65df10b 100644 --- a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx +++ b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.test.tsx @@ -6,14 +6,14 @@ vi.mock("@excalidraw/excalidraw", () => ({ })); import * as renderer from "@sketchi/diagram-renderer"; -import { flowchartFixture } from "@sketchi/diagram-core"; +import { flowchartFixture, sequenceFixture } from "@sketchi/diagram-core"; import { GenerationWorkspace } from "./generation-workspace"; afterEach(() => vi.restoreAllMocks()); describe("GenerationWorkspace", () => { it("reuses layout and validation when only status changes", () => { - const renderDiagram = vi.spyOn(renderer, "renderIntermediateDiagram"); + const renderDiagram = vi.spyOn(renderer, "renderDiagram"); const { rerender } = render(); rerender(); expect(renderDiagram).toHaveBeenCalledTimes(1); @@ -30,4 +30,14 @@ describe("GenerationWorkspace", () => { expect(screen.getByText("Validated flowchart IR")).toBeTruthy(); expect(screen.getByText("All arrows are bound")).toBeTruthy(); }); + + it("renders a sequence diagram with participant and message counts", () => { + render(); + + expect(screen.getByRole("heading", { name: "Checkout sequence" })).toBeTruthy(); + expect(screen.getByText("3 participants")).toBeTruthy(); + expect(screen.getByText("4 messages")).toBeTruthy(); + expect(screen.getByText("Validated sequence IR")).toBeTruthy(); + expect(screen.getByText("All arrows are bound")).toBeTruthy(); + }); }); diff --git a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx index ee551991..6fa7d9bd 100644 --- a/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx +++ b/packages/diagram/ui/src/components/generation-workspace/generation-workspace.tsx @@ -1,18 +1,14 @@ import { useMemo } from "react"; -import { - type IntermediateDiagram, - parseFlowchartDiagram, - validateIntermediateDiagram, -} from "@sketchi/diagram-core"; +import { type CanonicalDiagram, validateCanonicalDiagram } from "@sketchi/diagram-core"; import { convertSceneToExcalidraw, validateExcalidrawScene } from "@sketchi/diagram-excalidraw"; -import { renderIntermediateDiagram } from "@sketchi/diagram-renderer"; +import { renderDiagram } from "@sketchi/diagram-renderer"; import { DiagramPreview } from "../diagram-preview/index.js"; import { FlowchartValidationPanel } from "../flowchart-validation-panel/index.js"; export interface GenerationWorkspaceProps { - diagram: IntermediateDiagram; + diagram: CanonicalDiagram; status?: "idle" | "generating" | "ready" | "error"; } @@ -23,22 +19,28 @@ const statusLabels = { error: "Needs attention", }; +function diagramCounts(diagram: CanonicalDiagram) { + return diagram.type === "sequence" + ? { + nodeCount: diagram.participants.length, + nodeNoun: "participants", + edgeCount: diagram.messages.length, + edgeNoun: "messages", + } + : { nodeCount: diagram.nodes.length, edgeCount: diagram.edges.length }; +} + export function GenerationWorkspace({ diagram, status = "ready" }: GenerationWorkspaceProps) { const { validationMessage, scene, realSceneIssueCount, realSceneMessage } = useMemo(() => { - let validationMessage = "Validated diagram IR"; + let validationMessage = `Validated ${diagram.type} IR`; try { - if (diagram.type === "flowchart") { - parseFlowchartDiagram(diagram); - validationMessage = "Validated flowchart IR"; - } else { - validateIntermediateDiagram(diagram); - } + validateCanonicalDiagram(diagram); } catch (error) { validationMessage = error instanceof Error ? error.message : "Diagram validation failed"; } - const scene = renderIntermediateDiagram(diagram); + const scene = renderDiagram(diagram); const realSceneValidation = validateExcalidrawScene(convertSceneToExcalidraw(scene)); const realSceneIssueCount = realSceneValidation.issues.length; const realSceneMessage = @@ -64,9 +66,8 @@ export function GenerationWorkspace({ diagram, status = "ready" }: GenerationWor diff --git a/packages/diagram/ui/src/diagram-types/sequence.stories.tsx b/packages/diagram/ui/src/diagram-types/sequence.stories.tsx new file mode 100644 index 00000000..70c4a110 --- /dev/null +++ b/packages/diagram/ui/src/diagram-types/sequence.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { apiRequestSequence, sequenceFixture } from "@sketchi/diagram-core"; + +import { GenerationWorkspace } from "../components/generation-workspace"; +import "../styles.css"; + +const meta = { + title: "Diagram Types/Sequence", + component: GenerationWorkspace, + args: { + diagram: sequenceFixture, + status: "ready", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Ready: Story = {}; + +export const ApiRequestWithCacheMiss: Story = { + args: { + diagram: apiRequestSequence, + }, +}; diff --git a/tools/project-graph.test.ts b/tools/project-graph.test.ts index 2f058c27..55ec9b06 100644 --- a/tools/project-graph.test.ts +++ b/tools/project-graph.test.ts @@ -51,10 +51,12 @@ const effectPureProjectRoots = [ ]; const effectMigrationReadyProjectRoots: string[] = []; const effectSchemaBoundaryFiles = new Set([ + "packages/diagram/core/src/diagram.ts", "packages/diagram/core/src/intermediate.ts", "packages/diagram/core/src/types/flowchart.ts", "packages/diagram/core/src/types/flowchart.test.ts", "packages/diagram/core/src/types/mindmap.ts", + "packages/diagram/core/src/types/sequence.ts", ]); const frameworkNativeProjectRoots = [ "apps/excalidraw",