Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const agentCanvas: CanvasSpec = {
};

const sequence = renderSequenceDiagram({
type: "sequence",
id: "long-participants",
title: "Long participants",
participants: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
5 changes: 3 additions & 2 deletions packages/diagram/agent/src/lib/code-mode/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
getCanvasValidationIssues,
getFlowchartValidationIssues,
parseMindmapDiagram,
parseSequenceDiagram,
sequenceLifelineId,
validateFlowchartDiagram,
type FlowchartDiagram,
type FlowchartValidationIssueRef,
Expand All @@ -23,7 +25,6 @@ import {
renderIntermediateDiagram,
renderSequenceDiagram,
isStructurallyValidSequenceLifeline,
sequenceLifelineId,
type RenderedDiagramScene,
type ScenePoint,
} from "@sketchi/diagram-renderer";
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/diagram/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
52 changes: 52 additions & 0 deletions packages/diagram/core/src/diagram.ts
Original file line number Diff line number Diff line change
@@ -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<DiagramTypeValue, Schema.Top>;

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),
);
}
11 changes: 7 additions & 4 deletions packages/diagram/core/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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,
];
2 changes: 2 additions & 0 deletions packages/diagram/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 5 additions & 2 deletions packages/diagram/core/src/intermediate.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -27,10 +27,13 @@ function withDefault<S extends Schema.Top>(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;

Expand Down Expand Up @@ -83,7 +86,7 @@ export const DiagramLayoutSchema = DiagramLayout;
export class IntermediateDiagram extends Schema.Class<IntermediateDiagram>("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, {
Expand Down
13 changes: 12 additions & 1 deletion packages/diagram/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
103 changes: 103 additions & 0 deletions packages/diagram/core/src/types/sequence.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading