diff --git a/README.md b/README.md index db3a115..bb823d9 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config ## Packages -| Package | What it is | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | +| Package | What it is | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | +| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | +| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | ## Conventions diff --git a/packages/core/graph/projection/AGENTS.md b/packages/core/graph/projection/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/projection/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/projection/CLAUDE.md b/packages/core/graph/projection/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/projection/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md new file mode 100644 index 0000000..e979c29 --- /dev/null +++ b/packages/core/graph/projection/README.md @@ -0,0 +1,63 @@ +# @exadev/process-graph-projection + +Projections of the process model from `@exadev/process-graph-schema` into each notation. Every projection is a pure function of the model plus the extra input its notation needs, and none of them reads or preserves layout. `src/` imports nothing Node-only. + +| Function | Extra input | Result | +| -------------------------------- | ------------------------ | --------------------------------------------------------------- | +| `handoffs(model)` | none | Transitions between elements whose actors differ. | +| `toFlowchart(model)` | none | Nodes (process, decision, terminator, bar) and guarded edges. | +| `toSwimlane(model)` | none | The flowchart plus lanes by actor, unassigned nodes, hand-offs. | +| `toStateMachine(model, subject)` | a subject entity | States and transitions with decisions folded into guards. | +| `toSequence(model, scenario)` | a path of transition ids | Lifelines in order of first appearance and messages. | + +```ts +import { handoffs, toSequence } from "@exadev/process-graph-projection"; + +handoffs(model); // [{ transition: "t2", from: "customer", to: "shop" }] +toSequence(model, ["t1", "t2"]); +``` + +## Hand-offs are derived + +A transition between two elements with different actors is a hand-off, never stored. Elements without an actor never produce one. + +## Swimlane lanes + +`toSwimlane` has a lane for every declared role or system, in declaration order, with empty `nodes` when nothing is assigned to it, so a participant that has not started yet is still visible. Entities are not actors and get no lane. An actor that a runtime model names without declaring still gets a lane, after the declared ones, and `validate` reports it. + +## State machine scope + +`toStateMachine(model, subject)` keeps the activities whose `affects` lists the subject, plus authored `state` elements and pseudo-states. A step that does not affect the subject is not one of its states: transitions through it are folded into the transition leaving the previous state, the way a decision is, so one model yields a different machine per subject. + +When no activity in the model declares `affects`, the model carries no such information and every activity is kept. Once any activity declares it, an activity without `affects` is not kept (an empty list is a declaration that it affects nothing). + +## Sequence fragments + +`toSequence` returns `messages`, the flat list in scenario order, and `items`, the same messages with the authored `fragments` wrapped around the ones that fall inside them. An item is a `message` or a `fragment` carrying its `operator` and the `operands` the scenario takes, each with its guard and its own items. Fragments are never invented: every one drawn is authored. + +A message belongs to an operand when it arrives at an element in that operand's body. A fragment is drawn when at least one operand has a body wholly on the scenario path and receives a message, and its messages are consecutive in the scenario. An operand the scenario does not take is not drawn (an `alt` shows the branch taken, and the other branch is among the `unshown-branches`). A fragment nests inside another when all its messages lie within one of that fragment's operands. One that cannot be drawn faithfully, being interleaved with messages outside it, off the path or overlapping another without nesting, is left out and reported as a `fragments` loss. + +## Loss reports + +Every projection result carries `loss`, what it did not carry over from the model. Each `Loss` is a discriminated union on `kind`, reported only when something was actually dropped, in this order: + +| `kind` | Dropped | Reported by | +| ------------------ | ------------------------------------------------------------------------------- | ---------------- | +| `layout` | Positions recorded in `views` for this projection's own notation. | every projection | +| `extensions` | Notation-only `extensions` on elements, which no projection shows. | every projection | +| `message-elements` | `message` elements, which no projection draws. | every projection | +| `fragments` | Authored fragments the projection does not draw. | every projection | +| `subject` | Activities left out because they do not affect the state machine's subject. | `toStateMachine` | +| `scenario` | Elements of the model that are not on the scenario. | `toSequence` | +| `unshown-branches` | Transitions leaving an element on the scenario that the scenario does not take. | `toSequence` | + +A flowchart drops ownership, a state machine needs its subject recorded, and a sequence diagram follows one path, so one model yields a different diagram per scenario. + +## Commands + +```sh +pnpm build # tsc -b -> dist/ (ESM + .d.ts) +pnpm test # unit tests +pnpm test:smoke # the built dist/ exercised as a real artefact +pnpm test:mutation # Stryker +``` diff --git a/packages/core/graph/projection/eslint.config.ts b/packages/core/graph/projection/eslint.config.ts new file mode 100644 index 0000000..4c43201 --- /dev/null +++ b/packages/core/graph/projection/eslint.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "eslint/config"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + }), +); diff --git a/packages/core/graph/projection/package.json b/packages/core/graph/projection/package.json new file mode 100644 index 0000000..c4e7f04 --- /dev/null +++ b/packages/core/graph/projection/package.json @@ -0,0 +1,74 @@ +{ + "name": "@exadev/process-graph-projection", + "version": "0.0.0", + "description": "Projections of the process model into a flowchart, swimlane, state machine and sequence diagram, plus the derived hand-offs.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/core/graph/projection" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/projection#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "sideEffects": false, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only" + }, + "keywords": [ + "process-model", + "flowchart", + "swimlane", + "state-machine", + "sequence-diagram", + "projection" + ], + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@types/node": "26.6.4", + "@vitest/coverage-v8": "4.1.11", + "eslint": "10.12.0", + "publint": "0.3.25", + "typescript": "6.0.3", + "vitest": "4.1.11" + }, + "dependencies": { + "@exadev/process-graph-schema": "0.0.0" + } +} diff --git a/packages/core/graph/projection/src/actors.ts b/packages/core/graph/projection/src/actors.ts new file mode 100644 index 0000000..4c06523 --- /dev/null +++ b/packages/core/graph/projection/src/actors.ts @@ -0,0 +1,19 @@ +import type { Element, IdSpace, Model } from "@exadev/process-graph-schema"; + +export type ElementOf = Element; + +export const actorMap = ( + model: Model, +): ReadonlyMap => { + const actors = new Map(); + for (const element of model.elements) { + if ( + (element.kind === "activity" || element.kind === "decision") && + element.actor !== undefined + ) { + actors.set(element.id, element.actor); + } + } + + return actors; +}; diff --git a/packages/core/graph/projection/src/compile-fail.unit.test.ts b/packages/core/graph/projection/src/compile-fail.unit.test.ts new file mode 100644 index 0000000..abb6963 --- /dev/null +++ b/packages/core/graph/projection/src/compile-fail.unit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { toSequence, toStateMachine } from "./index.ts"; +import { order } from "./test-support/order.ts"; + +describe("projection inputs are reference-checked", () => { + it("rejects a state machine subject that is not a declared participant", () => { + // @ts-expect-error "nowhere" is not a declared participant + expect(() => toStateMachine(order, "nowhere")).toThrow(); + }); + + it("rejects a state machine subject that is not an entity", () => { + // @ts-expect-error "shop" is a system, and a state machine subject must be an entity + expect(() => toStateMachine(order, "shop")).toThrow(); + }); + + it("rejects a scenario step that is not a declared transition", () => { + // @ts-expect-error "t99" is not a declared transition + expect(() => toSequence(order, ["t1", "t99"])).toThrow(); + }); + + it("rejects a scenario step that names an element", () => { + // @ts-expect-error a step is a transition id, and "a-pack" is an element id + expect(() => toSequence(order, ["a-pack"])).toThrow(); + }); +}); diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts new file mode 100644 index 0000000..ff60bca --- /dev/null +++ b/packages/core/graph/projection/src/flowchart.ts @@ -0,0 +1,83 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.ts"; +import { commonLoss, type Loss } from "./loss.ts"; + +export interface FlowNode { + readonly id: I["element"]; + readonly label: string; + readonly shape: "terminator" | "process" | "decision" | "bar"; +} + +export interface FlowEdge { + readonly from: I["element"]; + readonly to: I["element"]; + readonly label?: string; +} + +export interface Flowchart { + readonly nodes: readonly FlowNode[]; + readonly edges: readonly FlowEdge[]; + /** What the projection dropped from the model. */ + readonly loss: readonly Loss[]; +} + +const flowNode = ( + element: ElementOf, +): FlowNode | undefined => { + switch (element.kind) { + case "activity": + return { id: element.id, label: element.label, shape: "process" }; + case "decision": + return { id: element.id, label: element.label, shape: "decision" }; + case "pseudo": + switch (element.role) { + case "initial": + return { id: element.id, label: "Start", shape: "terminator" }; + case "final": + return { id: element.id, label: "End", shape: "terminator" }; + case "fork": + return { id: element.id, label: "Fork", shape: "bar" }; + case "join": + return { id: element.id, label: "Join", shape: "bar" }; + case "history": + break; + } + break; + case "state": + case "message": + break; + } + + return undefined; +}; + +/** Activities, decisions and terminators, joined by the transitions between them. */ +export const toFlowchart = ( + model: Model, +): Flowchart => { + const nodes: FlowNode[] = []; + for (const element of model.elements) { + const node = flowNode(element); + if (node !== undefined) { + nodes.push(node); + } + } + + const included = new Set(nodes.map((node) => node.id)); + const edges: FlowEdge[] = []; + for (const transition of model.transitions) { + if (included.has(transition.source) && included.has(transition.target)) { + edges.push( + transition.guard === undefined + ? { from: transition.source, to: transition.target } + : { + from: transition.source, + to: transition.target, + label: transition.guard, + }, + ); + } + } + + return { nodes, edges, loss: commonLoss(model, "flowchart") }; +}; diff --git a/packages/core/graph/projection/src/handoffs.ts b/packages/core/graph/projection/src/handoffs.ts new file mode 100644 index 0000000..5cb5536 --- /dev/null +++ b/packages/core/graph/projection/src/handoffs.ts @@ -0,0 +1,28 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.ts"; + +export interface Handoff { + readonly transition: I["transition"]; + readonly from: I["actor"]; + readonly to: I["actor"]; +} + +/** + * Swimlane view: a transition between two elements with different actors is a hand-off. Elements without an actor never produce one. + */ +export const handoffs = ( + model: Model, +): readonly Handoff[] => { + const actorOf = actorMap(model); + + const found: Handoff[] = []; + for (const transition of model.transitions) { + const from = actorOf.get(transition.source); + const to = actorOf.get(transition.target); + if (from !== undefined && to !== undefined && from !== to) { + found.push({ transition: transition.id, from, to }); + } + } + + return found; +}; diff --git a/packages/core/graph/projection/src/index.ts b/packages/core/graph/projection/src/index.ts new file mode 100644 index 0000000..1970790 --- /dev/null +++ b/packages/core/graph/projection/src/index.ts @@ -0,0 +1,25 @@ +/** + * Projections of the process model into each notation: hand-offs, flowchart, swimlane, state machine and sequence diagram. Each is a pure function of the model plus the extra input its notation needs (none, a subject entity, or a scenario of transition ids). + */ +export type { Loss } from "./loss.ts"; +export { handoffs, type Handoff } from "./handoffs.ts"; +export { + toFlowchart, + type Flowchart, + type FlowEdge, + type FlowNode, +} from "./flowchart.ts"; +export { toSwimlane, type Lane, type Swimlane } from "./swimlane.ts"; +export { + toStateMachine, + type StateMachine, + type StateMachineState, + type StateMachineTransition, +} from "./state-machine.ts"; +export { + toSequence, + type SequenceDiagram, + type SequenceItem, + type SequenceMessage, + type SequenceOperand, +} from "./sequence.ts"; diff --git a/packages/core/graph/projection/src/loss.ts b/packages/core/graph/projection/src/loss.ts new file mode 100644 index 0000000..0242e67 --- /dev/null +++ b/packages/core/graph/projection/src/loss.ts @@ -0,0 +1,84 @@ +import type { IdSpace, Model, Notation } from "@exadev/process-graph-schema"; + +/** + * Something a projection does not carry from the model into its result. A projection returns every loss that applies, in this order: layout, extensions, message elements, fragments, then the notation's own choice (subject, scenario, unshown branches). A kind is only reported when something was actually dropped. + */ +export type Loss = + /** Positions recorded for this projection's notation in `views`. No projection reads or keeps coordinates. */ + | { readonly kind: "layout"; readonly elements: readonly I["element"][] } + /** Elements carrying notation-only `extensions`, which no projection shows. */ + | { readonly kind: "extensions"; readonly elements: readonly I["element"][] } + /** `message` elements, which the projection does not draw. */ + | { + readonly kind: "message-elements"; + readonly elements: readonly I["element"][]; + } + /** Authored fragments the projection does not draw. */ + | { + readonly kind: "fragments"; + readonly fragments: readonly I["fragment"][]; + } + /** Activities left out of a state machine because they do not affect its subject. */ + | { + readonly kind: "subject"; + readonly subject: I["entity"]; + readonly elements: readonly I["element"][]; + } + /** Elements of the model that are not on the sequence diagram's scenario. */ + | { readonly kind: "scenario"; readonly elements: readonly I["element"][] } + /** Transitions leaving an element on the scenario that the scenario does not take. */ + | { + readonly kind: "unshown-branches"; + readonly transitions: readonly I["transition"][]; + }; + +/** The losses every projection can have, whatever its notation: dropped layout, notation-only extensions, message elements and authored fragments. */ +export const commonLoss = ( + model: Model, + notation: Notation, + shownFragments: ReadonlySet = new Set(), +): readonly Loss[] => { + const positions = model.views?.[notation]; + const positioned = model.elements.filter( + (element) => positions?.[element.id] !== undefined, + ); + const withExtensions = model.elements.filter( + (element) => + element.extensions !== undefined && + Object.keys(element.extensions).length > 0, + ); + const messages = model.elements.filter( + (element) => element.kind === "message", + ); + const hidden = model.fragments.filter( + (fragment) => !shownFragments.has(fragment.id), + ); + + const losses: Loss[] = []; + if (positioned.length > 0) { + losses.push({ + kind: "layout", + elements: positioned.map((element) => element.id), + }); + } + if (withExtensions.length > 0) { + losses.push({ + kind: "extensions", + elements: withExtensions.map((element) => element.id), + }); + } + if (messages.length > 0) { + losses.push({ + kind: "message-elements", + elements: messages.map((element) => element.id), + }); + } + if (hidden.length > 0) { + losses.push({ + kind: "fragments", + fragments: hidden.map((fragment) => fragment.id), + }); + } + + return losses; +}; diff --git a/packages/core/graph/projection/src/loss.unit.test.ts b/packages/core/graph/projection/src/loss.unit.test.ts new file mode 100644 index 0000000..1882702 --- /dev/null +++ b/packages/core/graph/projection/src/loss.unit.test.ts @@ -0,0 +1,122 @@ +import { defineModel } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { toFlowchart } from "./flowchart.ts"; +import { toSequence } from "./sequence.ts"; +import { toStateMachine } from "./state-machine.ts"; +import { toSwimlane } from "./swimlane.ts"; + +/** Carries every kind of information a projection can drop: positions, extensions, a message element, a fragment, and an activity that does not affect the subject. */ +const rich = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-place", + label: "Place order", + actor: "customer", + affects: ["order"], + extensions: { sequence: { activation: true } }, + }, + { + kind: "activity", + id: "a-notify", + label: "Notify", + actor: "shop", + affects: [], + }, + { + kind: "message", + id: "m-ack", + label: "Acknowledge", + from: "shop", + to: "customer", + mode: "reply", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-place" }, + { id: "t2", source: "a-place", target: "a-notify" }, + { id: "t3", source: "a-notify", target: "end" }, + ], + fragments: [ + { + id: "f1", + operator: "opt", + operands: [{ body: ["a-notify"] }], + }, + ], + projections: [], + views: { + flowchart: { "a-place": { x: 1, y: 2 } }, + swimlane: { "a-notify": { x: 3, y: 4 }, "a-place": { x: 5, y: 6 } }, + stateMachine: { start: { x: 0, y: 0 } }, + sequence: { end: { x: 9, y: 9 } }, + }, +}); + +describe("loss reports", () => { + it("reports the flowchart's own positions, extensions, messages and fragments", () => { + expect(toFlowchart(rich).loss).toEqual([ + { kind: "layout", elements: ["a-place"] }, + { kind: "extensions", elements: ["a-place"] }, + { kind: "message-elements", elements: ["m-ack"] }, + { kind: "fragments", fragments: ["f1"] }, + ]); + }); + + it("reports only the positions recorded for the swimlane notation", () => { + expect(toSwimlane(rich).loss).toEqual([ + { kind: "layout", elements: ["a-place", "a-notify"] }, + { kind: "extensions", elements: ["a-place"] }, + { kind: "message-elements", elements: ["m-ack"] }, + { kind: "fragments", fragments: ["f1"] }, + ]); + }); + + it("reports the activities left out of the subject's state machine", () => { + expect(toStateMachine(rich, "order").loss).toEqual([ + { kind: "layout", elements: ["start"] }, + { kind: "extensions", elements: ["a-place"] }, + { kind: "message-elements", elements: ["m-ack"] }, + { kind: "fragments", fragments: ["f1"] }, + { kind: "subject", subject: "order", elements: ["a-notify"] }, + ]); + }); + + it("reports what a sequence scenario leaves out, and not a fragment it draws", () => { + const diagram = toSequence(rich, ["t2"]); + + expect(diagram.items.map((item) => item.kind)).toEqual(["fragment"]); + expect(diagram.loss).toEqual([ + { kind: "layout", elements: ["end"] }, + { kind: "extensions", elements: ["a-place"] }, + { kind: "message-elements", elements: ["m-ack"] }, + { kind: "scenario", elements: ["start", "m-ack", "end"] }, + { kind: "unshown-branches", transitions: ["t3"] }, + ]); + }); + + it("does not report an empty extensions record", () => { + const bare = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [{ kind: "activity", id: "a", label: "A", extensions: {} }], + transitions: [], + fragments: [], + projections: [], + }); + + expect(toFlowchart(bare).loss).toEqual([]); + }); +}); diff --git a/packages/core/graph/projection/src/projections.unit.test.ts b/packages/core/graph/projection/src/projections.unit.test.ts new file mode 100644 index 0000000..2ce1ca0 --- /dev/null +++ b/packages/core/graph/projection/src/projections.unit.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + handoffs, + toFlowchart, + toSequence, + toStateMachine, + toSwimlane, +} from "./index.ts"; +import { order } from "./test-support/order.ts"; + +const shipped = ["t1", "t2", "t3", "t4", "t5", "t7"] as const; +const cancelled = ["t1", "t2", "t3", "t4", "t6", "t8"] as const; + +describe("the order process", () => { + it("has a hand-off wherever a transition changes actor", () => { + expect(handoffs(order)).toEqual([ + { transition: "t2", from: "customer", to: "shop" }, + { transition: "t3", from: "shop", to: "provider" }, + { transition: "t4", from: "provider", to: "shop" }, + ]); + }); + + it("projects to a flowchart of steps and decisions with guarded edges", () => { + const flow = toFlowchart(order); + + expect(flow.loss).toEqual([{ kind: "layout", elements: ["a-request"] }]); + expect(flow.nodes).toEqual([ + { id: "start", label: "Start", shape: "terminator" }, + { id: "a-submit", label: "Submit order", shape: "process" }, + { id: "a-request", label: "Request payment", shape: "process" }, + { id: "a-authorise", label: "Authorise or decline", shape: "process" }, + { id: "d-authorised", label: "Payment authorised?", shape: "decision" }, + { id: "a-pack", label: "Pack and ship", shape: "process" }, + { id: "a-cancel", label: "Cancel order and notify", shape: "process" }, + { id: "end", label: "End", shape: "terminator" }, + ]); + expect(flow.edges).toEqual([ + { from: "start", to: "a-submit" }, + { from: "a-submit", to: "a-request" }, + { from: "a-request", to: "a-authorise" }, + { from: "a-authorise", to: "d-authorised" }, + { from: "d-authorised", to: "a-pack", label: "authorised" }, + { from: "d-authorised", to: "a-cancel", label: "declined" }, + { from: "a-pack", to: "end" }, + { from: "a-cancel", to: "end" }, + ]); + }); + + it("projects to a swimlane with a lane per actor that has elements", () => { + const lanes = toSwimlane(order); + + expect(lanes.lanes).toEqual([ + { participant: "customer", label: "Customer", nodes: ["a-submit"] }, + { + participant: "shop", + label: "Shop", + nodes: ["a-request", "d-authorised", "a-pack", "a-cancel"], + }, + { + participant: "provider", + label: "Payment provider", + nodes: ["a-authorise"], + }, + ]); + expect(lanes.loss).toEqual([]); + expect(lanes.unassigned).toEqual(["start", "end"]); + expect(lanes.handoffs).toEqual(handoffs(order)); + }); + + it("projects to a state machine of the order with decisions folded into guards", () => { + const machine = toStateMachine(order, "order"); + + expect(machine.subject).toBe("order"); + expect(machine.loss).toEqual([]); + expect(machine.states).toEqual([ + { id: "start", label: "Start", role: "initial" }, + { id: "a-submit", label: "Submit order", role: "state" }, + { id: "a-request", label: "Request payment", role: "state" }, + { id: "a-authorise", label: "Authorise or decline", role: "state" }, + { id: "a-pack", label: "Pack and ship", role: "state" }, + { id: "a-cancel", label: "Cancel order and notify", role: "state" }, + { id: "end", label: "End", role: "final" }, + ]); + expect(machine.transitions).toEqual([ + { source: "start", target: "a-submit", event: "ev-submitted" }, + { source: "a-submit", target: "a-request", effect: "act-request" }, + { source: "a-request", target: "a-authorise" }, + { + source: "a-authorise", + target: "a-pack", + event: "ev-authorised", + guard: "authorised", + effect: "act-pack", + }, + { + source: "a-authorise", + target: "a-cancel", + event: "ev-declined", + guard: "declined", + effect: "act-cancel", + }, + { source: "a-pack", target: "end" }, + { source: "a-cancel", target: "end" }, + ]); + }); + + it("projects the shipped path to a sequence diagram", () => { + expect(toSequence(order, shipped)).toEqual({ + lifelines: ["customer", "shop", "provider"], + messages: [ + { + transition: "t2", + from: "customer", + to: "shop", + label: "Request payment", + }, + { + transition: "t3", + from: "shop", + to: "provider", + label: "Authorise or decline", + }, + { + transition: "t4", + from: "provider", + to: "shop", + label: "Payment authorised?", + }, + { + transition: "t5", + from: "shop", + to: "shop", + label: "payment authorised", + }, + ], + items: [ + { + kind: "message", + message: { + transition: "t2", + from: "customer", + to: "shop", + label: "Request payment", + }, + }, + { + kind: "message", + message: { + transition: "t3", + from: "shop", + to: "provider", + label: "Authorise or decline", + }, + }, + { + kind: "message", + message: { + transition: "t4", + from: "provider", + to: "shop", + label: "Payment authorised?", + }, + }, + { + kind: "message", + message: { + transition: "t5", + from: "shop", + to: "shop", + label: "payment authorised", + }, + }, + ], + loss: [ + { kind: "scenario", elements: ["a-cancel"] }, + { kind: "unshown-branches", transitions: ["t6"] }, + ], + }); + }); + + it("projects the cancelled path to a different sequence diagram", () => { + const diagram = toSequence(order, cancelled); + + expect(diagram.lifelines).toEqual(["customer", "shop", "provider"]); + expect(diagram.loss).toEqual([ + { kind: "scenario", elements: ["a-pack"] }, + { kind: "unshown-branches", transitions: ["t5"] }, + ]); + expect(diagram.messages.map((message) => message.label)).toEqual([ + "Request payment", + "Authorise or decline", + "Payment authorised?", + "payment declined", + ]); + }); +}); diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts new file mode 100644 index 0000000..a403f9a --- /dev/null +++ b/packages/core/graph/projection/src/sequence.ts @@ -0,0 +1,273 @@ +import type { + Fragment, + FragmentOperator, + IdSpace, + Model, + Transition, +} from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.ts"; +import { commonLoss, type Loss } from "./loss.ts"; + +export interface SequenceMessage { + readonly transition: I["transition"]; + readonly from: I["actor"]; + /** Equal to `from` for a self-message. */ + readonly to: I["actor"]; + readonly label: string; +} + +/** One operand of an emitted fragment: the operand's guard, when it has one, and what happens inside it. */ +export interface SequenceOperand { + readonly guard?: string; + readonly items: readonly SequenceItem[]; +} + +/** A message, or an authored fragment wrapping the messages of the scenario that fall inside it. */ +export type SequenceItem = + | { readonly kind: "message"; readonly message: SequenceMessage } + | { + readonly kind: "fragment"; + readonly fragment: I["fragment"]; + readonly operator: FragmentOperator; + /** The operands the scenario takes, in authored order. */ + readonly operands: readonly SequenceOperand[]; + }; + +export interface SequenceDiagram { + /** Actors in order of first appearance. */ + readonly lifelines: readonly I["actor"][]; + /** Every message in scenario order, including those inside fragments. */ + readonly messages: readonly SequenceMessage[]; + /** The same messages, with authored fragments wrapped around the ones that fall inside them. */ + readonly items: readonly SequenceItem[]; + /** What the projection dropped from the model. */ + readonly loss: readonly Loss[]; +} + +/** A scenario message with its position and the element it arrives at. */ +interface Entry { + readonly index: number; + readonly message: SequenceMessage; + readonly target: I["element"]; +} + +interface TakenOperand { + readonly guard: string | undefined; + readonly entries: readonly Entry[]; +} + +/** A place fragments are drawn into: the top level, or one operand of another fragment. */ +interface Container { + readonly children: Placed[]; +} + +/** An authored fragment the scenario passes through, with a container for the fragments nested inside each of its operands. */ +interface Placed { + readonly fragment: Fragment; + readonly operands: readonly TakenOperand[]; + readonly indices: ReadonlySet; + readonly nested: readonly Container[]; +} + +/** + * Wraps the scenario's messages in the authored fragments they fall inside. + * + * A fragment is drawn when at least one of its operands has a body that lies wholly on the scenario path and receives at least one message, and when the messages it holds are consecutive in the scenario. Only the operands the scenario takes are drawn, in authored order. A fragment whose messages overlap another's without nesting, or are interleaved with messages outside it, is not drawn, because drawing it would reorder or misrepresent the scenario. Fragments are never invented: every one drawn is authored. + * + * A message belongs to an operand when the element it arrives at is in that operand's body. + */ +const wrapFragments = ( + fragments: readonly Fragment[], + entries: readonly Entry[], + onPath: ReadonlySet, +): { + readonly items: SequenceItem[]; + readonly drawn: Set; +} => { + const candidates: Placed[] = []; + for (const fragment of fragments) { + const claimed = new Set(); + const operands: TakenOperand[] = []; + for (const operand of fragment.operands) { + if ( + operand.body.length === 0 || + !operand.body.every((id) => onPath.has(id)) + ) { + continue; + } + const inside = entries.filter( + (entry) => + operand.body.includes(entry.target) && !claimed.has(entry.index), + ); + if (inside.length === 0) { + continue; + } + for (const entry of inside) { + claimed.add(entry.index); + } + operands.push({ guard: operand.guard, entries: inside }); + } + const first = Math.min(...claimed); + const last = Math.max(...claimed); + if (operands.length > 0 && last - first + 1 === claimed.size) { + candidates.push({ + fragment, + operands, + indices: claimed, + nested: operands.map(() => ({ children: [] })), + }); + } + } + + // Largest first, so a fragment nests inside any larger one that holds all its messages. + const bySize = candidates.toSorted((a, b) => b.indices.size - a.indices.size); + const top: Container = { children: [] }; + const place = (node: Placed, container: Container): void => { + for (const sibling of container.children) { + const shared = [...node.indices].filter((index) => + sibling.indices.has(index), + ); + if (shared.length === 0) { + continue; + } + const slot = sibling.operands.findIndex((operand) => + [...node.indices].every((index) => + operand.entries.some((entry) => entry.index === index), + ), + ); + const inner = slot === -1 ? undefined : sibling.nested[slot]; + if (inner !== undefined) { + place(node, inner); + } + + return; + } + container.children.push(node); + }; + for (const node of bySize) { + place(node, top); + } + + const drawn = new Set(); + const itemsOf = ( + inside: readonly Entry[], + nodes: readonly Placed[], + ): SequenceItem[] => { + const items: SequenceItem[] = []; + const emitted = new Set>(); + for (const entry of inside) { + const owner = nodes.find((node) => node.indices.has(entry.index)); + if (owner === undefined) { + items.push({ kind: "message", message: entry.message }); + } else if (!emitted.has(owner)) { + emitted.add(owner); + drawn.add(owner.fragment.id); + items.push({ + kind: "fragment", + fragment: owner.fragment.id, + operator: owner.fragment.operator, + operands: owner.operands.map((operand, slot) => ({ + ...(operand.guard === undefined ? {} : { guard: operand.guard }), + items: itemsOf(operand.entries, owner.nested[slot]?.children ?? []), + })), + }); + } + } + + return items; + }; + + return { items: itemsOf(entries, top.children), drawn }; +}; + +/** + * One path through the process as messages. A step between two elements with actors becomes a message, labelled by the transition's event when it has one and by the target's label otherwise. Steps touching an element with no actor are skipped. Branches off the path are not drawn, which is why this direction is lossy and one-to-many. + +`messages` is the flat list. `items` is the same messages with the authored fragments the scenario passes through wrapped around the ones that fall inside them; a fragment that cannot be drawn faithfully is left out and reported in `loss`. + */ +export const toSequence = ( + model: Model, + scenario: readonly I["transition"][], +): SequenceDiagram => { + const actors = actorMap(model); + const labels = new Map(); + for (const element of model.elements) { + if (element.kind === "activity" || element.kind === "decision") { + labels.set(element.id, element.label); + } + } + const transitions = new Map>( + model.transitions.map((transition) => [transition.id, transition]), + ); + const eventNames = new Map( + model.events.map((event) => [event.id, event.name]), + ); + + const lifelines: I["actor"][] = []; + const messages: SequenceMessage[] = []; + const entries: Entry[] = []; + for (const id of scenario) { + const transition = transitions.get(id); + if (transition === undefined) { + throw new Error(`Scenario step "${id}" is not a declared transition`); + } + const from = actors.get(transition.source); + const to = actors.get(transition.target); + if (from === undefined || to === undefined) { + continue; + } + for (const participant of [from, to]) { + if (!lifelines.includes(participant)) { + lifelines.push(participant); + } + } + const eventName = + transition.trigger === undefined + ? undefined + : eventNames.get(transition.trigger); + const message: SequenceMessage = { + transition: id, + from, + to, + label: eventName ?? labels.get(transition.target) ?? id, + }; + entries.push({ + index: messages.length, + message, + target: transition.target, + }); + messages.push(message); + } + + const onPath = new Set(); + const taken = new Set(scenario); + for (const id of taken) { + const transition = transitions.get(id); + if (transition !== undefined) { + onPath.add(transition.source); + onPath.add(transition.target); + } + } + const offPath = model.elements.filter((element) => !onPath.has(element.id)); + const unshown = model.transitions.filter( + (transition) => !taken.has(transition.id) && onPath.has(transition.source), + ); + + const { items, drawn } = wrapFragments(model.fragments, entries, onPath); + + const loss: Loss[] = [...commonLoss(model, "sequence", drawn)]; + if (offPath.length > 0) { + loss.push({ + kind: "scenario", + elements: offPath.map((element) => element.id), + }); + } + if (unshown.length > 0) { + loss.push({ + kind: "unshown-branches", + transitions: unshown.map((transition) => transition.id), + }); + } + + return { lifelines, messages, items, loss }; +}; diff --git a/packages/core/graph/projection/src/sequence.unit.test.ts b/packages/core/graph/projection/src/sequence.unit.test.ts new file mode 100644 index 0000000..abfe8bc --- /dev/null +++ b/packages/core/graph/projection/src/sequence.unit.test.ts @@ -0,0 +1,278 @@ +import { + defineModel, + type Model, + type OpenIds, +} from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { toSequence, type SequenceItem } from "./sequence.ts"; + +/** + * A checkout with a payment retry loop and an authorised or declined branch. Messages arrive at the element named in their transition, so each fragment below is defined by the elements its messages arrive at. + */ +const checkout = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { kind: "activity", id: "a-cart", label: "Fill cart", actor: "customer" }, + { kind: "activity", id: "a-pay", label: "Take payment", actor: "shop" }, + { kind: "decision", id: "d-ok", label: "Paid?", actor: "shop" }, + { + kind: "activity", + id: "a-retry", + label: "Re-enter card", + actor: "customer", + }, + { kind: "activity", id: "a-ship", label: "Ship", actor: "shop" }, + { kind: "activity", id: "a-cancel", label: "Cancel", actor: "shop" }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-cart" }, + { id: "t2", source: "a-cart", target: "a-pay" }, + { id: "t3", source: "a-pay", target: "d-ok" }, + { id: "t4", source: "d-ok", target: "a-retry", guard: "declined" }, + { id: "t5", source: "a-retry", target: "a-pay" }, + { id: "t6", source: "d-ok", target: "a-ship", guard: "authorised" }, + { id: "t7", source: "a-ship", target: "end" }, + { id: "t8", source: "d-ok", target: "a-cancel", guard: "gave up" }, + ], + fragments: [], + projections: [], +}); + +/** The checkout with authored fragments, as a runtime model so the fragments can name any element. */ +const withFragments = ( + fragments: Model["fragments"], +): Model => ({ ...checkout, fragments }); + +const message = ( + transition: string, + from: string, + to: string, + label: string, +): SequenceItem => ({ + kind: "message", + message: { transition, from, to, label }, +}); + +const retried = ["t2", "t3", "t4", "t5", "t3", "t6", "t7"]; + +const paymentLoop = { + id: "f-loop", + operator: "loop", + operands: [{ guard: "until paid", body: ["a-pay", "d-ok", "a-retry"] }], +} as const; + +const outcome = { + id: "f-alt", + operator: "alt", + operands: [ + { guard: "authorised", body: ["a-ship"] }, + { guard: "declined", body: ["a-cancel"] }, + ], +} as const; + +describe("toSequence without fragments", () => { + it("lists every message as a message item in scenario order", () => { + const diagram = toSequence(withFragments([]), ["t2", "t3", "t6"]); + + expect(diagram.items).toEqual([ + message("t2", "customer", "shop", "Take payment"), + message("t3", "shop", "shop", "Paid?"), + message("t6", "shop", "shop", "Ship"), + ]); + expect(diagram.messages.map((entry) => entry.transition)).toEqual([ + "t2", + "t3", + "t6", + ]); + }); +}); + +describe("toSequence with authored fragments", () => { + it("wraps the messages a loop covers and leaves the rest outside", () => { + const diagram = toSequence(withFragments([paymentLoop]), retried); + + expect(diagram.items).toEqual([ + { + kind: "fragment", + fragment: "f-loop", + operator: "loop", + operands: [ + { + guard: "until paid", + items: [ + message("t2", "customer", "shop", "Take payment"), + message("t3", "shop", "shop", "Paid?"), + message("t4", "shop", "customer", "Re-enter card"), + message("t5", "customer", "shop", "Take payment"), + message("t3", "shop", "shop", "Paid?"), + ], + }, + ], + }, + message("t6", "shop", "shop", "Ship"), + ]); + expect(diagram.loss).not.toContainEqual( + expect.objectContaining({ kind: "fragments" }), + ); + }); + + it("draws only the alt operand the scenario takes", () => { + const shipped = toSequence(withFragments([outcome]), ["t2", "t3", "t6"]); + const cancelled = toSequence(withFragments([outcome]), ["t2", "t3", "t8"]); + + expect(shipped.items.at(-1)).toEqual({ + kind: "fragment", + fragment: "f-alt", + operator: "alt", + operands: [ + { + guard: "authorised", + items: [message("t6", "shop", "shop", "Ship")], + }, + ], + }); + expect(cancelled.items.at(-1)).toEqual({ + kind: "fragment", + fragment: "f-alt", + operator: "alt", + operands: [ + { + guard: "declined", + items: [message("t8", "shop", "shop", "Cancel")], + }, + ], + }); + }); + + it("emits a loop followed by an alt, keeping messages flat in `messages`", () => { + const diagram = toSequence(withFragments([paymentLoop, outcome]), retried); + + expect(diagram.items.map((item) => item.kind)).toEqual([ + "fragment", + "fragment", + ]); + expect(diagram.messages.map((entry) => entry.transition)).toEqual([ + "t2", + "t3", + "t4", + "t5", + "t3", + "t6", + ]); + }); + + it("nests a fragment inside the operand that holds all its messages", () => { + const optional = { + id: "f-opt", + operator: "opt", + operands: [{ body: ["a-pay", "d-ok", "a-retry", "a-ship"] }], + } as const; + const diagram = toSequence(withFragments([paymentLoop, optional]), retried); + + expect(diagram.items).toEqual([ + { + kind: "fragment", + fragment: "f-opt", + operator: "opt", + operands: [ + { + items: [ + { + kind: "fragment", + fragment: "f-loop", + operator: "loop", + operands: [ + { + guard: "until paid", + items: [ + message("t2", "customer", "shop", "Take payment"), + message("t3", "shop", "shop", "Paid?"), + message("t4", "shop", "customer", "Re-enter card"), + message("t5", "customer", "shop", "Take payment"), + message("t3", "shop", "shop", "Paid?"), + ], + }, + ], + }, + message("t6", "shop", "shop", "Ship"), + ], + }, + ], + }, + ]); + }); + + it("does not invent a fragment for one whose messages are interleaved with others", () => { + const split = { + id: "f-split", + operator: "opt", + operands: [{ body: ["a-pay", "a-ship"] }], + } as const; + const diagram = toSequence(withFragments([split]), ["t2", "t3", "t6"]); + + expect(diagram.items.map((item) => item.kind)).toEqual([ + "message", + "message", + "message", + ]); + expect(diagram.loss).toContainEqual({ + kind: "fragments", + fragments: ["f-split"], + }); + }); + + it("does not draw a fragment whose elements are off the scenario path", () => { + const offPath = { + id: "f-off", + operator: "opt", + operands: [{ body: ["a-cancel"] }], + } as const; + const diagram = toSequence(withFragments([offPath]), ["t2", "t3", "t6"]); + + expect(diagram.items.map((item) => item.kind)).toEqual([ + "message", + "message", + "message", + ]); + expect(diagram.loss).toContainEqual({ + kind: "fragments", + fragments: ["f-off"], + }); + }); + + it("does not draw a fragment that overlaps another without nesting", () => { + const left = { + id: "f-left", + operator: "opt", + operands: [{ body: ["a-pay", "d-ok"] }], + } as const; + const right = { + id: "f-right", + operator: "opt", + operands: [{ body: ["d-ok", "a-retry"] }], + } as const; + const diagram = toSequence(withFragments([left, right]), [ + "t2", + "t3", + "t4", + ]); + + const drawn = diagram.items.flatMap((item) => + item.kind === "fragment" ? [item.fragment] : [], + ); + + expect(drawn).toEqual(["f-left"]); + expect(diagram.loss).toContainEqual({ + kind: "fragments", + fragments: ["f-right"], + }); + }); +}); diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts new file mode 100644 index 0000000..c294d1c --- /dev/null +++ b/packages/core/graph/projection/src/state-machine.ts @@ -0,0 +1,183 @@ +import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.ts"; +import { commonLoss, type Loss } from "./loss.ts"; + +export interface StateMachineState { + readonly id: I["element"]; + readonly label: string; + readonly role: "initial" | "state" | "final"; +} + +export interface StateMachineTransition { + readonly source: I["element"]; + readonly target: I["element"]; + readonly event?: I["event"]; + readonly guard?: string; + readonly effect?: I["action"]; +} + +export interface StateMachine { + readonly subject: I["entity"]; + readonly states: readonly StateMachineState[]; + readonly transitions: readonly StateMachineTransition[]; + /** What the projection dropped from the model. */ + readonly loss: readonly Loss[]; +} + +const stateOf = ( + element: ElementOf, +): StateMachineState | undefined => { + switch (element.kind) { + case "activity": + case "state": + return { id: element.id, label: element.label, role: "state" }; + case "pseudo": + if (element.role === "initial") { + return { id: element.id, label: "Start", role: "initial" }; + } + if (element.role === "final") { + return { id: element.id, label: "End", role: "final" }; + } + + break; + case "decision": + case "message": + break; + } + + return undefined; +}; + +/** + * Activity-as-state projection. Each activity that affects the subject becomes a state the subject entity rests in, and a decision collapses into guarded transitions leaving the state before it. The target activity's action becomes the effect of the transition that enters it. + * + * Which activities affect the subject is declared by their `affects`. An activity that does not list the subject is not a state of it: transitions through it are folded into the transition that leaves the previous state, exactly as a decision is. Authored `state` elements and pseudo-states are always kept. When no activity in the model declares `affects`, the model carries no such information and every activity is kept, which is the unscoped behaviour. + * + * Other conventions exist, such as steps as transitions with states between them. Naming states such as "Paid" needs authored states, which the model can hold as `state` elements. + */ +export const toStateMachine = ( + model: Model, + subject: I["entity"], +): StateMachine => { + // The type already limits `subject` to declared entities. This guards models loaded from JSON. + const owner = model.participants.find( + (participant) => participant.id === subject, + ); + if (owner?.kind !== "entity") { + throw new Error( + `State machine subject "${subject}" must be a declared entity participant`, + ); + } + + interface Arrival { + readonly target: I["element"]; + readonly event: I["event"] | undefined; + readonly guard: string | undefined; + readonly effect: I["action"] | undefined; + } + + const elements = new Map>( + model.elements.map((element) => [element.id, element]), + ); + const outgoing = new Map[]>(); + for (const transition of model.transitions) { + const list = outgoing.get(transition.source); + if (list === undefined) { + outgoing.set(transition.source, [transition]); + } else { + list.push(transition); + } + } + + const scoped = model.elements.some( + (element) => element.kind === "activity" && element.affects !== undefined, + ); + /** Whether an element is folded through rather than shown as a state: a decision, or an activity that does not affect the subject in a model that declares what activities affect. */ + const isTransparent = (element: ElementOf): boolean => + element.kind === "decision" || + (element.kind === "activity" && + scoped && + element.affects?.includes(subject) !== true); + + const joinGuards = ( + first: string | undefined, + second: string | undefined, + ): string | undefined => { + if (first === undefined) { + return second; + } + + return second === undefined ? first : `${first} and ${second}`; + }; + + const resolve = ( + transition: Transition, + seen: ReadonlySet, + ): readonly Arrival[] => { + const target = elements.get(transition.target); + if (target !== undefined && isTransparent(target) && !seen.has(target.id)) { + const visited = new Set(seen).add(target.id); + const arrivals: Arrival[] = []; + for (const onward of outgoing.get(target.id) ?? []) { + for (const arrival of resolve(onward, visited)) { + arrivals.push({ + target: arrival.target, + event: arrival.event ?? transition.trigger, + guard: joinGuards(transition.guard, arrival.guard), + effect: arrival.effect ?? transition.effect, + }); + } + } + + return arrivals; + } + const entryAction = + target?.kind === "activity" && !isTransparent(target) + ? target.action + : undefined; + + return [ + { + target: transition.target, + event: transition.trigger, + guard: transition.guard, + effect: entryAction ?? transition.effect, + }, + ]; + }; + + const states: StateMachineState[] = []; + const transitions: StateMachineTransition[] = []; + for (const element of model.elements) { + const state = isTransparent(element) ? undefined : stateOf(element); + if (state === undefined) { + continue; + } + states.push(state); + for (const transition of outgoing.get(element.id) ?? []) { + for (const arrival of resolve(transition, new Set())) { + transitions.push({ + source: element.id, + target: arrival.target, + ...(arrival.event === undefined ? {} : { event: arrival.event }), + ...(arrival.guard === undefined ? {} : { guard: arrival.guard }), + ...(arrival.effect === undefined ? {} : { effect: arrival.effect }), + }); + } + } + } + + const folded = model.elements.filter( + (element) => element.kind === "activity" && isTransparent(element), + ); + const loss: Loss[] = [...commonLoss(model, "stateMachine")]; + if (folded.length > 0) { + loss.push({ + kind: "subject", + subject, + elements: folded.map((element) => element.id), + }); + } + + return { subject, states, transitions, loss }; +}; diff --git a/packages/core/graph/projection/src/state-machine.unit.test.ts b/packages/core/graph/projection/src/state-machine.unit.test.ts new file mode 100644 index 0000000..081a1c3 --- /dev/null +++ b/packages/core/graph/projection/src/state-machine.unit.test.ts @@ -0,0 +1,143 @@ +import { defineModel } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { toStateMachine } from "./state-machine.ts"; + +/** Order and invoice are both entities; each step affects one of them, or neither. */ +const fulfilment = defineModel({ + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + { id: "invoice", label: "Invoice", kind: "entity" }, + ], + events: [{ id: "ev-paid", name: "paid" }], + actions: [{ id: "act-issue", name: "issue invoice" }], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-accept", + label: "Accept order", + actor: "shop", + affects: ["order"], + }, + { + kind: "activity", + id: "a-invoice", + label: "Invoice customer", + actor: "shop", + action: "act-issue", + affects: ["invoice"], + }, + { + kind: "activity", + id: "a-ship", + label: "Ship order", + actor: "shop", + affects: ["order"], + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-accept" }, + { id: "t2", source: "a-accept", target: "a-invoice" }, + { id: "t3", source: "a-invoice", target: "a-ship", trigger: "ev-paid" }, + { id: "t4", source: "a-ship", target: "end" }, + ], + fragments: [], + projections: [], +}); + +describe("toStateMachine scoping by affects", () => { + it("keeps only the activities that affect the subject and folds through the rest", () => { + const machine = toStateMachine(fulfilment, "order"); + + expect(machine.states.map((state) => state.id)).toEqual([ + "start", + "a-accept", + "a-ship", + "end", + ]); + expect(machine.transitions).toEqual([ + { source: "start", target: "a-accept" }, + { source: "a-accept", target: "a-ship", event: "ev-paid" }, + { source: "a-ship", target: "end" }, + ]); + }); + + it("projects the same model for another subject to a different machine", () => { + const machine = toStateMachine(fulfilment, "invoice"); + + expect(machine.states.map((state) => state.id)).toEqual([ + "start", + "a-invoice", + "end", + ]); + expect(machine.transitions).toEqual([ + { source: "start", target: "a-invoice", effect: "act-issue" }, + { source: "a-invoice", target: "end", event: "ev-paid" }, + ]); + }); + + it("keeps every activity when no activity declares affects", () => { + const unscoped = defineModel({ + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "activity", id: "b", label: "B" }, + ], + transitions: [{ id: "t1", source: "a", target: "b" }], + fragments: [], + projections: [], + }); + + const machine = toStateMachine(unscoped, "order"); + + expect(machine.states.map((state) => state.id)).toEqual(["a", "b"]); + expect(machine.transitions).toEqual([{ source: "a", target: "b" }]); + }); + + it("drops an activity with no affects once any activity declares it", () => { + const mixed = defineModel({ + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A", affects: ["order"] }, + { kind: "activity", id: "undeclared", label: "Undeclared" }, + { kind: "activity", id: "none", label: "None", affects: [] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + + expect( + toStateMachine(mixed, "order").states.map((state) => state.id), + ).toEqual(["a"]); + }); + + it("always keeps authored states", () => { + const authored = defineModel({ + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "state", id: "paid", label: "Paid" }, + { kind: "activity", id: "a", label: "A", affects: [] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + + expect( + toStateMachine(authored, "order").states.map((state) => state.id), + ).toEqual(["paid"]); + }); +}); diff --git a/packages/core/graph/projection/src/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts new file mode 100644 index 0000000..b995c0b --- /dev/null +++ b/packages/core/graph/projection/src/swimlane.ts @@ -0,0 +1,70 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.ts"; +import { toFlowchart, type Flowchart } from "./flowchart.ts"; +import { handoffs, type Handoff } from "./handoffs.ts"; +import { commonLoss } from "./loss.ts"; + +/** One lane. `participant` is a declared role or system; in a runtime model it may instead be an actor that was named but never declared, which `validate` reports. */ +export interface Lane { + readonly participant: I["participant"] | I["actor"]; + readonly label: string; + readonly nodes: readonly I["element"][]; +} + +export interface Swimlane extends Flowchart { + readonly lanes: readonly Lane[]; + /** Nodes with no actor, such as start and end terminators. */ + readonly unassigned: readonly I["element"][]; + readonly handoffs: readonly Handoff[]; +} + +/** + * A flowchart plus partitions: a lane for every declared role or system, in declaration order and empty when nothing is assigned to it, then a lane for each actor that was named but never declared. Hand-offs are the edges that change lane. + */ +export const toSwimlane = (model: Model): Swimlane => { + const flow = toFlowchart(model); + const actors = actorMap(model); + + const members = new Map(); + const unassigned: I["element"][] = []; + for (const node of flow.nodes) { + const actor = actors.get(node.id); + if (actor === undefined) { + unassigned.push(node.id); + continue; + } + const lane = members.get(actor); + if (lane === undefined) { + members.set(actor, [node.id]); + } else { + lane.push(node.id); + } + } + + const lanes: Lane[] = []; + const declared = new Set(); + for (const participant of model.participants) { + if (participant.kind === "entity") { + continue; + } + declared.add(participant.id); + lanes.push({ + participant: participant.id, + label: participant.label, + nodes: members.get(participant.id) ?? [], + }); + } + for (const [actor, nodes] of members) { + if (!declared.has(actor)) { + lanes.push({ participant: actor, label: actor, nodes }); + } + } + + return { + ...flow, + lanes, + unassigned, + handoffs: handoffs(model), + loss: commonLoss(model, "swimlane"), + }; +}; diff --git a/packages/core/graph/projection/src/swimlane.unit.test.ts b/packages/core/graph/projection/src/swimlane.unit.test.ts new file mode 100644 index 0000000..43a75e0 --- /dev/null +++ b/packages/core/graph/projection/src/swimlane.unit.test.ts @@ -0,0 +1,72 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { toSwimlane } from "./swimlane.ts"; + +/** A runtime model: identifiers are plain strings, so an actor can be named without being declared. */ +const open = (overrides: Partial>): Model => ({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [], + transitions: [], + fragments: [], + projections: [], + ...overrides, +}); + +describe("toSwimlane lanes", () => { + it("has a lane for every declared actor, empty when nothing is assigned", () => { + const lanes = toSwimlane( + open({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "warehouse", label: "Warehouse", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "customer" }, + ], + }), + ); + + expect(lanes.lanes).toEqual([ + { participant: "customer", label: "Customer", nodes: ["a"] }, + { participant: "warehouse", label: "Warehouse", nodes: [] }, + ]); + }); + + it("orders lanes by declaration, not by first appearance", () => { + const lanes = toSwimlane( + open({ + participants: [ + { id: "first", label: "First", kind: "role" }, + { id: "second", label: "Second", kind: "role" }, + ], + elements: [ + { kind: "activity", id: "x", label: "X", actor: "second" }, + { kind: "activity", id: "y", label: "Y", actor: "first" }, + ], + }), + ); + + expect(lanes.lanes.map((lane) => lane.participant)).toEqual([ + "first", + "second", + ]); + }); + + it("keeps a lane for an actor that was named but never declared", () => { + const lanes = toSwimlane( + open({ + participants: [{ id: "shop", label: "Shop", kind: "system" }], + elements: [{ kind: "activity", id: "x", label: "X", actor: "ghost" }], + }), + ); + + expect(lanes.lanes).toEqual([ + { participant: "shop", label: "Shop", nodes: [] }, + { participant: "ghost", label: "ghost", nodes: ["x"] }, + ]); + }); +}); diff --git a/packages/core/graph/projection/src/test-support/order.ts b/packages/core/graph/projection/src/test-support/order.ts new file mode 100644 index 0000000..b5eb9d1 --- /dev/null +++ b/packages/core/graph/projection/src/test-support/order.ts @@ -0,0 +1,101 @@ +import { defineModel } from "@exadev/process-graph-schema"; + +/** The order process from the doc, with every reference checked at compile time. */ +export const order = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t7", source: "a-pack", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [ + { id: "p-flow", notation: "flowchart" }, + { id: "p-lanes", notation: "swimlane" }, + { id: "p-state", notation: "stateMachine", subject: "order" }, + { + id: "p-seq", + notation: "sequence", + scenario: ["t1", "t2", "t3", "t4", "t5", "t7"], + }, + ], + views: { + flowchart: { "a-request": { x: 306, y: 100 } }, + }, +}); diff --git a/packages/core/graph/projection/stryker.config.ts b/packages/core/graph/projection/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/core/graph/projection/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/core/graph/projection/test/package.smoke.test.ts b/packages/core/graph/projection/test/package.smoke.test.ts new file mode 100644 index 0000000..667bde7 --- /dev/null +++ b/packages/core/graph/projection/test/package.smoke.test.ts @@ -0,0 +1,28 @@ +import { defineModel } from "@exadev/process-graph-schema"; +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { handoffs, toFlowchart } from "../dist/index.js"; + +test("the built package projects a model", () => { + const model = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "submit", label: "Submit", actor: "customer" }, + { kind: "activity", id: "pack", label: "Pack", actor: "shop" }, + ], + transitions: [{ id: "t1", source: "submit", target: "pack" }], + fragments: [], + projections: [], + }); + + expect(handoffs(model)).toEqual([ + { transition: "t1", from: "customer", to: "shop" }, + ]); + expect(toFlowchart(model).edges).toEqual([{ from: "submit", to: "pack" }]); +}); diff --git a/packages/core/graph/projection/tsconfig.build.json b/packages/core/graph/projection/tsconfig.build.json new file mode 100644 index 0000000..79dba2e --- /dev/null +++ b/packages/core/graph/projection/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../schema/tsconfig.build.json" + } + ] +} diff --git a/packages/core/graph/projection/tsconfig.json b/packages/core/graph/projection/tsconfig.json new file mode 100644 index 0000000..5645111 --- /dev/null +++ b/packages/core/graph/projection/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/core/graph/projection/tsconfig.node.json b/packages/core/graph/projection/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/core/graph/projection/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/core/graph/projection/vitest.config.ts b/packages/core/graph/projection/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/core/graph/projection/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/packages/core/graph/schema/AGENTS.md b/packages/core/graph/schema/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/schema/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/schema/CLAUDE.md b/packages/core/graph/schema/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/schema/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md new file mode 100644 index 0000000..fa74968 --- /dev/null +++ b/packages/core/graph/schema/README.md @@ -0,0 +1,106 @@ +# @exadev/process-graph-schema + +The process model: one layout-free description of a process that a flowchart, a swimlane, a state machine and a sequence diagram are all views of. Participants, events, actions, elements, transitions and fragments are declared once and cross-referenced by identifier. `src/` imports nothing Node-only and runs unchanged in a browser or a Worker. + +```ts +import { defineModel, validate } from "@exadev/process-graph-schema"; + +const model = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + ], + events: [], + actions: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "submit", + label: "Submit order", + actor: "customer", + }, + { kind: "activity", id: "pack", label: "Pack", actor: "shop" }, + ], + transitions: [ + { id: "t1", source: "start", target: "submit" }, + { id: "t2", source: "submit", target: "pack" }, + ], + fragments: [], + projections: [], +}); + +validate(model); // [] +``` + +## Typed identifier spaces + +Each kind of thing that can be referenced has its own string-literal union, inferred by `defineModel` from the declaring arrays only. A reference to an identifier that was never declared, or an identifier of one kind used where another is expected, is a compile error. + +| Space | Holds | +| ------------------------------------------------------ | ------------------------------------------------------------------ | +| `participant` | Every declared participant. | +| `actor` | Participants of kind `role` or `system`: the ones that can act. | +| `entity` | Participants of kind `entity`: the ones a state machine can track. | +| `event`, `action`, `element`, `transition`, `fragment` | Their own declarations. | + +An activity's actor and a message's ends must be actors; a state machine projection's subject must be an entity. Naming a participant of the wrong kind is a compile error too. `OpenIds` is the space whose members are all plain `string`, for models whose identifiers are only known at runtime. + +## What the types cannot say + +`validate(model)` returns the issues a runtime model can still have: `duplicate-id`, `unknown-reference`, `multiple-initial`, `subject-not-entity`, `actor-is-entity` and `duplicate-source`, `multiple-producers` and `affects-not-entity`. An authored model is already reference-safe at compile time; `validate` is for models loaded from JSON and for the rules the type system cannot state. + +## Source binding + +An activity may carry `source`, a source-qualified identifier such as `github:org/repo#12`, tying it to the external record it stands for. The source is the activity's identity in its system, so two activities may not share one (`duplicate-source`). Activities without a source are unaffected. + +## Affected entities + +An activity may list the `entity` participants whose lifecycle it changes in `affects`. It is typed to the entity space only, so naming a role or a system is a compile error, and `validate` reports the same mistake in a loaded model (`affects-not-entity`). A state machine projection uses it to keep only the activities that change its subject. + +## Artefacts + +Control flow says what follows what; artefacts say what a step needs and makes. An `ArtefactDef` has an `id`, a `name` and an optional `deliverable` flag, and an activity lists the artefacts it `consumes` and `produces` by identifier. `artefact` is an identifier space like the others, so an undeclared artefact is a compile error. + +- An artefact no activity produces is an **input**: the process takes it as given. `inputsOf(model)` lists them. +- An artefact flagged `deliverable` is an **output**: what the process exists to deliver. `outputsOf(model)` lists them. +- An artefact has at most one producer (`multiple-producers`), so "what produces this" has one answer. + +## Conditions + +`guard` is the human label of a branch and is never evaluated. A transition's optional `condition` is the evaluable form: a three-valued predicate tree, the [`trilean`](https://www.npmjs.com/package/trilean) `PredicateNode`, so a decision can evaluate to true, false or unknown. The tree's references are opaque keys for the evaluator's resolvers, not identifiers of this model, so `validate` does not check them and `condition` is not parameterised by the identifier spaces. The open schema embeds trilean's own schema, so a loaded condition is validated structurally and a malformed one is located by JSON pointer. + +## Loading a model + +`parseModel(input)` loads a model from untrusted input such as parsed JSON and returns a `Model`. It checks the shape with `modelSchema` first, so every shape problem is reported with the JSON pointer of the offending value, then runs `validate`. A bad input throws `ModelParseError`, whose `issues` are either `{ source: "schema", pointer, message }` or `{ source: "validate", code, message }`. + +### Types and the open schema + +The two cover different things on purpose. + +- **Types** carry what only an author can get wrong before the model exists: a reference to an undeclared identifier, an identifier of the wrong space, an entity acting. They need the identifiers to be literals, so they only apply to a model written in source. +- **The open schema** carries what a loaded document can get wrong: shape, vocabulary, unknown keys (every object is strict, so a misspelt field is an error and is never dropped), then the rules in `validate`. + +`z.infer` is exactly `Model`, pinned by a type-level test, so a loaded model is usable anywhere an open-space model is. + +## Extensions + +Some concepts belong to one notation and have no meaning in the model: an activation bar, a history pseudo-state's depth, a pool. Every element may carry `extensions`, a record keyed by notation name whose values are JSON. The model gives them no meaning, so a projection ignores them (and reports that it did); they are kept so a round trip through a view restores them, and they load through the open schema unchanged. + +## Overlay + +Run state is never part of the model. An `Overlay` maps element identifiers to `"done"`, `"open"` or `"unknown"`, and is supplied by a source or by a person alongside the model, the same rule as layout: the model describes a process, an overlay describes one run of it, and many overlays can sit over one model. An element with no entry has no recorded state, which is distinct from the explicit `"unknown"`. `parseOverlay` loads one from untrusted input and reports problems by JSON pointer in an `OverlayParseError`. + +## Layout + +Layout is not part of the model's meaning. `views` holds optional positions keyed by element identifier and notation, so a projection never has to preserve or read coordinates. + +## Commands + +```sh +pnpm build # tsc -b -> dist/ (ESM + .d.ts) +pnpm test # unit tests, including the compile-fail checks +pnpm typecheck # also fails when an expected compile error stops failing +pnpm test:smoke # the built dist/ exercised as a real artefact +pnpm test:mutation # Stryker +``` diff --git a/packages/core/graph/schema/eslint.config.ts b/packages/core/graph/schema/eslint.config.ts new file mode 100644 index 0000000..4c43201 --- /dev/null +++ b/packages/core/graph/schema/eslint.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "eslint/config"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + }), +); diff --git a/packages/core/graph/schema/package.json b/packages/core/graph/schema/package.json new file mode 100644 index 0000000..9be5d39 --- /dev/null +++ b/packages/core/graph/schema/package.json @@ -0,0 +1,76 @@ +{ + "name": "@exadev/process-graph-schema", + "version": "0.0.0", + "description": "The process model: typed identifier spaces, defineModel with compile-time reference checking, structural validation, and a Zod schema for the open runtime shape.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/core/graph/schema" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/schema#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "sideEffects": false, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only" + }, + "keywords": [ + "process-model", + "flowchart", + "swimlane", + "state-machine", + "sequence-diagram", + "zod", + "typescript" + ], + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@types/node": "26.6.4", + "@vitest/coverage-v8": "4.1.11", + "eslint": "10.12.0", + "publint": "0.3.25", + "typescript": "6.0.3", + "vitest": "4.1.11" + }, + "dependencies": { + "trilean": "1.7.2", + "zod": "4.6.5" + } +} diff --git a/packages/core/graph/schema/src/artefacts.ts b/packages/core/graph/schema/src/artefacts.ts new file mode 100644 index 0000000..035161d --- /dev/null +++ b/packages/core/graph/schema/src/artefacts.ts @@ -0,0 +1,30 @@ +import type { ArtefactDef } from "./declarations.ts"; +import type { IdSpace } from "./ids.ts"; +import type { Model } from "./model.ts"; + +/** + * The artefacts the process takes as given: those no activity produces, in declaration order. + * @param model - The model to read. + * @returns The declared artefacts that appear in no activity's `produces`. + */ +export const inputsOf = ( + model: Model, +): readonly ArtefactDef[] => { + const produced = new Set( + model.elements.flatMap((element) => + element.kind === "activity" ? (element.produces ?? []) : [], + ), + ); + + return model.artefacts.filter((artefact) => !produced.has(artefact.id)); +}; + +/** + * The artefacts the process exists to deliver: those flagged `deliverable`, in declaration order. + * @param model - The model to read. + * @returns The declared artefacts flagged `deliverable`. + */ +export const outputsOf = ( + model: Model, +): readonly ArtefactDef[] => + model.artefacts.filter((artefact) => artefact.deliverable === true); diff --git a/packages/core/graph/schema/src/artefacts.unit.test.ts b/packages/core/graph/schema/src/artefacts.unit.test.ts new file mode 100644 index 0000000..99b2c21 --- /dev/null +++ b/packages/core/graph/schema/src/artefacts.unit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { inputsOf, outputsOf } from "./artefacts.ts"; +import { defineModel } from "./model.ts"; + +/** Brief, spec, code and release, each produced by a different step or by none. */ +const delivery = defineModel({ + participants: [{ id: "team", label: "Team", kind: "role" }], + events: [], + actions: [], + artefacts: [ + { id: "brief", name: "Brief" }, + { id: "spec", name: "Specification" }, + { id: "code", name: "Code" }, + { id: "release", name: "Release", deliverable: true }, + { id: "notes", name: "Notes", deliverable: false }, + ], + elements: [ + { + kind: "activity", + id: "write-spec", + label: "Write specification", + actor: "team", + consumes: ["brief"], + produces: ["spec"], + }, + { + kind: "activity", + id: "build", + label: "Build", + actor: "team", + consumes: ["spec"], + produces: ["code"], + }, + { + kind: "activity", + id: "ship", + label: "Ship", + actor: "team", + consumes: ["code"], + produces: ["release"], + }, + ], + transitions: [], + fragments: [], + projections: [], +}); + +describe("inputsOf", () => { + it("returns the artefacts no activity produces, in declaration order", () => { + expect(inputsOf(delivery).map((artefact) => artefact.id)).toEqual([ + "brief", + "notes", + ]); + }); + + it("returns every artefact when no activity produces any", () => { + const bare = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [{ id: "a", name: "A" }], + elements: [{ kind: "activity", id: "x", label: "X", consumes: ["a"] }], + transitions: [], + fragments: [], + projections: [], + }); + + expect(inputsOf(bare)).toEqual([{ id: "a", name: "A" }]); + }); +}); + +describe("outputsOf", () => { + it("returns only the artefacts flagged deliverable", () => { + expect(outputsOf(delivery)).toEqual([ + { id: "release", name: "Release", deliverable: true }, + ]); + }); + + it("returns nothing when no artefact is flagged", () => { + expect(outputsOf({ ...delivery, artefacts: [] })).toEqual([]); + }); +}); diff --git a/packages/core/graph/schema/src/compile-fail.unit.test.ts b/packages/core/graph/schema/src/compile-fail.unit.test.ts new file mode 100644 index 0000000..0765624 --- /dev/null +++ b/packages/core/graph/schema/src/compile-fail.unit.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { defineModel } from "./model.ts"; + +/** Every block below must fail to compile. `@ts-expect-error` turns a missing failure into an error, so `typecheck` fails when a regression stops one of them failing. */ + +describe("defineModel reference checking", () => { + it("rejects a transition target that was never declared as an element", () => { + defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [{ kind: "pseudo", id: "start", role: "initial" }], + transitions: [ + // @ts-expect-error "nowhere" is not a declared element + { id: "t1", source: "start", target: "nowhere" }, + ], + fragments: [], + projections: [], + }); + }); + + it("rejects an activity whose actor is not a declared participant", () => { + defineModel({ + participants: [{ id: "shop", label: "Shop", kind: "system" }], + events: [], + actions: [], + artefacts: [], + elements: [ + // @ts-expect-error "warehouse" is not a declared participant + { kind: "activity", id: "a1", label: "Pack", actor: "warehouse" }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects an identifier from the wrong space: an action id used where an event is expected", () => { + defineModel({ + participants: [], + events: [{ id: "ev-go", name: "go" }], + actions: [{ id: "act-go", name: "go" }], + elements: [ + { kind: "pseudo", id: "s", role: "initial" }, + { kind: "pseudo", id: "e", role: "final" }, + ], + transitions: [ + // @ts-expect-error "act-go" is an action id, not an event id + { id: "t1", source: "s", target: "e", trigger: "act-go" }, + ], + fragments: [], + projections: [], + }); + }); + + it("rejects a sequence scenario that names a transition that does not exist", () => { + defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "s", role: "initial" }, + { kind: "pseudo", id: "e", role: "final" }, + ], + transitions: [{ id: "t1", source: "s", target: "e" }], + fragments: [], + projections: [ + // @ts-expect-error "t9" is not a declared transition + { id: "p", notation: "sequence", scenario: ["t1", "t9"] }, + ], + }); + }); + + it("rejects a state machine projection without its subject entity", () => { + defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [], + transitions: [], + fragments: [], + projections: [ + // @ts-expect-error a stateMachine projection requires a subject + { id: "p", notation: "stateMachine" }, + ], + }); + }); + + it("rejects a fragment body that names an undeclared element", () => { + defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [{ kind: "pseudo", id: "s", role: "initial" }], + transitions: [], + fragments: [ + { + id: "f1", + operator: "alt", + // @ts-expect-error "ghost" is not a declared element + operands: [{ body: ["s", "ghost"] }], + }, + ], + projections: [], + }); + }); + + it("rejects an activity whose actor is an entity: only roles and systems can act", () => { + defineModel({ + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + // @ts-expect-error "order" is an entity, so it cannot be an actor + { kind: "activity", id: "a1", label: "Pack", actor: "order" }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects a message sent by an entity", () => { + defineModel({ + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { + kind: "message", + id: "m1", + label: "Pay", + // @ts-expect-error "order" is an entity, so it cannot send a message + from: "order", + to: "shop", + mode: "sync", + }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects a state machine subject that is a system, not an entity", () => { + defineModel({ + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [], + transitions: [], + fragments: [], + projections: [ + // @ts-expect-error "shop" is a system, so it cannot be a state machine subject + { id: "p", notation: "stateMachine", subject: "shop" }, + ], + }); + }); + + it("rejects a consumed artefact that was never declared", () => { + defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [{ id: "spec", name: "Specification" }], + elements: [ + // @ts-expect-error "design" is not a declared artefact + { kind: "activity", id: "a1", label: "Build", consumes: ["design"] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects a produced artefact that was never declared", () => { + defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [{ id: "spec", name: "Specification" }], + elements: [ + // @ts-expect-error "design" is not a declared artefact + { kind: "activity", id: "a1", label: "Draw", produces: ["design"] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects an identifier from another space where an artefact is expected", () => { + defineModel({ + participants: [], + events: [], + actions: [{ id: "act-go", name: "go" }], + artefacts: [{ id: "spec", name: "Specification" }], + elements: [ + // @ts-expect-error "act-go" is an action id, not an artefact id + { kind: "activity", id: "a1", label: "Build", consumes: ["act-go"] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects an activity that affects an actor rather than an entity", () => { + defineModel({ + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + // @ts-expect-error "shop" is a system, so it has no lifecycle to affect + { kind: "activity", id: "a1", label: "Pack", affects: ["shop"] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("rejects an activity that affects an undeclared participant", () => { + defineModel({ + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + // @ts-expect-error "invoice" is not a declared participant + { kind: "activity", id: "a1", label: "Pack", affects: ["invoice"] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + }); + + it("accepts an activity that affects a declared entity", () => { + const model = defineModel({ + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a1", label: "Pack", affects: ["order"] }, + ], + transitions: [], + fragments: [], + projections: [], + }); + + expect(model.elements).toHaveLength(1); + }); +}); diff --git a/packages/core/graph/schema/src/declarations.ts b/packages/core/graph/schema/src/declarations.ts new file mode 100644 index 0000000..fbdda3a --- /dev/null +++ b/packages/core/graph/schema/src/declarations.ts @@ -0,0 +1,163 @@ +import type { JsonValue, PredicateNode } from "trilean"; +import type { IdSpace } from "./ids.ts"; +import type { + FragmentOperator, + MessageMode, + Notation, + ParticipantKind, + PseudoRole, +} from "./vocabulary.ts"; + +/* --------------------------------------------------------------------------- + Declarations (D) and references (R) + + D supplies the identifier a thing declares. R supplies the identifiers it refers to. Keeping them apart lets defineModel infer identifiers from declarations only. + --------------------------------------------------------------------------- */ + +export interface Participant< + Id extends string, + Kind extends ParticipantKind = ParticipantKind, +> { + readonly id: Id; + readonly label: string; + readonly kind: Kind; +} + +export type AnyParticipants = readonly Participant[]; + +/** Identifiers of the declared participants whose kind is one of `Kind`. */ +export type IdsOfKind< + Ps extends AnyParticipants, + Kind extends ParticipantKind, +> = Extract["id"]; + +export interface EventDef { + readonly id: Id; + readonly name: string; + readonly payload?: Readonly>; +} + +export interface ActionDef { + readonly id: Id; + readonly name: string; +} + +/** + * Concepts that belong to one notation only, such as an activation bar, a history pseudo-state or a pool, keyed by notation name. The model gives them no meaning, so a projection ignores them and reports that it did; they exist so a round trip through a view restores them. + */ +export type Extensions = Readonly>; + +/** + * Something activities consume or produce: data, a document, a deliverable. An artefact nothing produces is an input to the process; one flagged `deliverable` is an output of it. + */ +export interface ArtefactDef { + readonly id: Id; + readonly name: string; + readonly deliverable?: boolean; +} + +export type Element = + | { + readonly kind: "activity"; + readonly id: Id; + readonly extensions?: Extensions; + readonly label: string; + readonly actor?: R["actor"]; + readonly action?: R["action"]; + /** + * The external record this activity stands for, as a source-qualified identifier such as `github:org/repo#12`. It is the activity's identity in its source and unique across the model's activities. + */ + readonly source?: string; + /** + * The entities whose lifecycle this activity changes. A state machine projection for a subject keeps the activities that affect it. Only entity participants can be named. + */ + readonly affects?: readonly R["entity"][]; + /** Artefacts this activity needs before it can run. */ + readonly consumes?: readonly R["artefact"][]; + /** Artefacts this activity makes. An artefact has at most one producing activity. */ + readonly produces?: readonly R["artefact"][]; + } + | { + readonly kind: "decision"; + readonly id: Id; + readonly extensions?: Extensions; + readonly label: string; + readonly actor?: R["actor"]; + } + | { + readonly kind: "state"; + readonly id: Id; + readonly extensions?: Extensions; + readonly label: string; + readonly parent?: R["element"]; + readonly entry?: R["action"]; + readonly exit?: R["action"]; + } + | { + readonly kind: "pseudo"; + readonly id: Id; + readonly extensions?: Extensions; + readonly role: PseudoRole; + readonly parent?: R["element"]; + } + | { + readonly kind: "message"; + readonly id: Id; + readonly extensions?: Extensions; + readonly label: string; + readonly from: R["actor"]; + readonly to: R["actor"]; + readonly mode: MessageMode; + readonly event?: R["event"]; + }; + +export interface Transition { + readonly id: Id; + readonly source: R["element"]; + readonly target: R["element"]; + readonly trigger?: R["event"]; + /** The human-readable label of the branch. It is never evaluated; `condition` is. */ + readonly guard?: string; + /** + * A three-valued predicate tree that decides whether this branch is taken, as a trilean `PredicateNode`. Its references are opaque keys for the evaluator's resolvers, not identifiers of this model, so it is not parameterised by the identifier spaces. + */ + readonly condition?: PredicateNode; + readonly effect?: R["action"]; +} + +export interface FragmentOperand { + readonly guard?: string; + readonly body: readonly R["element"][]; +} + +export interface Fragment { + readonly id: Id; + readonly operator: FragmentOperator; + readonly operands: readonly FragmentOperand[]; +} + +/** + * A projection records the extra input a lossy direction needs. Flowchart and swimlane need none. A state machine needs its subject entity. A sequence diagram needs the scenario it follows. + */ +export type Projection = + | { readonly id: string; readonly notation: "flowchart" | "swimlane" } + | { + readonly id: string; + readonly notation: "stateMachine"; + readonly subject: R["entity"]; + } + | { + readonly id: string; + readonly notation: "sequence"; + readonly scenario: readonly R["transition"][]; + }; + +export interface Point { + readonly x: number; + readonly y: number; +} + +/** Layout lives apart from the semantic model, keyed by element identifier. */ +export type Views = Readonly< + Partial>>>> +>; diff --git a/packages/core/graph/schema/src/ids.ts b/packages/core/graph/schema/src/ids.ts new file mode 100644 index 0000000..7f28c0d --- /dev/null +++ b/packages/core/graph/schema/src/ids.ts @@ -0,0 +1,18 @@ +export interface IdSpace { + /** Every declared participant. */ + readonly participant: string; + /** Participants of kind "role" or "system": the ones that can act. */ + readonly actor: string; + /** Participants of kind "entity": the ones whose lifecycle a state machine can track. */ + readonly entity: string; + readonly event: string; + readonly action: string; + readonly element: string; + readonly transition: string; + readonly fragment: string; + /** The data or deliverables activities consume and produce. */ + readonly artefact: string; +} + +/** The open identifier space, used for models whose identifiers are only known at runtime. */ +export type OpenIds = IdSpace; diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts new file mode 100644 index 0000000..31a186f --- /dev/null +++ b/packages/core/graph/schema/src/index.ts @@ -0,0 +1,42 @@ +/** + * Shared process model for flowcharts, swimlanes, state machines and sequence diagrams. + * + * References are typed by identifier space. Each kind of thing that can be referenced (participant, event, action, element, transition, fragment) has its own string-literal union, inferred from the declarations. A reference to an identifier that was never declared is a compile error, and an identifier of one kind cannot stand in for another. + * + * Participants are split further by their declared kind. An `actor` (who performs a step, or sends or receives a message) must be a role or a system. A state machine `subject` must be an entity. Naming a participant of the wrong kind is a compile error too. + */ +export type { IdSpace, OpenIds } from "./ids.ts"; +export type { + FragmentOperator, + MessageMode, + Notation, + ParticipantKind, + PseudoRole, +} from "./vocabulary.ts"; +export type { + ActionDef, + ArtefactDef, + Element, + EventDef, + Extensions, + Fragment, + FragmentOperand, + Participant, + Point, + Projection, + Transition, + Views, +} from "./declarations.ts"; +export { inputsOf, outputsOf } from "./artefacts.ts"; +export { defineModel, type Model, type ModelParts } from "./model.ts"; +export { validate, type Issue, type IssueCode } from "./validate.ts"; +export { + OverlayParseError, + overlaySchema, + parseOverlay, + type Overlay, + type OverlayIssue, +} from "./overlay.ts"; +export type { OverlayStatus } from "./vocabulary.ts"; +export { modelSchema } from "./schema.ts"; +export { ModelParseError, parseModel, type ModelIssue } from "./parse.ts"; diff --git a/packages/core/graph/schema/src/model.ts b/packages/core/graph/schema/src/model.ts new file mode 100644 index 0000000..737a6cc --- /dev/null +++ b/packages/core/graph/schema/src/model.ts @@ -0,0 +1,83 @@ +import type { + AnyParticipants, + ActionDef, + ArtefactDef, + Element, + EventDef, + Fragment, + IdsOfKind, + Participant, + Projection, + Transition, + Views, +} from "./declarations.ts"; +import type { IdSpace } from "./ids.ts"; + +export interface ModelParts< + D extends IdSpace, + R extends IdSpace, + Ps extends AnyParticipants = readonly Participant[], +> { + readonly participants: Ps; + readonly events: readonly EventDef[]; + readonly actions: readonly ActionDef[]; + readonly artefacts: readonly ArtefactDef[]; + readonly elements: readonly Element[]; + readonly transitions: readonly Transition[]; + readonly fragments: readonly Fragment[]; + readonly projections: readonly Projection[]; + readonly views?: Views; +} + +export type Model = ModelParts; + +/** + * Declare a model. Each identifier union is inferred from the declaring arrays only, so every reference elsewhere is checked against what was declared. + * + * The participants array is captured whole, with each kind kept as a literal. Actors and message ends are then checked against the role and system participants, and a state machine subject against the entity participants. + */ +export const defineModel = < + const Ps extends AnyParticipants, + const Ev extends string, + const Ac extends string, + const El extends string, + const Tr extends string, + const Fr extends string, + const Ar extends string, +>( + model: ModelParts< + { + readonly participant: Ps[number]["id"]; + readonly actor: IdsOfKind; + readonly entity: IdsOfKind; + readonly event: Ev; + readonly action: Ac; + readonly element: El; + readonly transition: Tr; + readonly fragment: Fr; + readonly artefact: Ar; + }, + { + readonly participant: NoInfer; + readonly actor: NoInfer>; + readonly entity: NoInfer>; + readonly event: NoInfer; + readonly action: NoInfer; + readonly element: NoInfer; + readonly transition: NoInfer; + readonly fragment: NoInfer; + readonly artefact: NoInfer; + }, + Ps + >, +): Model<{ + readonly participant: Ps[number]["id"]; + readonly actor: IdsOfKind; + readonly entity: IdsOfKind; + readonly event: Ev; + readonly action: Ac; + readonly element: El; + readonly transition: Tr; + readonly fragment: Fr; + readonly artefact: Ar; +}> => model; diff --git a/packages/core/graph/schema/src/overlay.ts b/packages/core/graph/schema/src/overlay.ts new file mode 100644 index 0000000..7bfe037 --- /dev/null +++ b/packages/core/graph/schema/src/overlay.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import type { IdSpace, OpenIds } from "./ids.ts"; +import { toPointer } from "./pointer.ts"; +import { overlayStatuses, type OverlayStatus } from "./vocabulary.ts"; + +/** + * Run state laid over a model, keyed by element identifier. It is separate from the model on purpose, the same rule as layout: a model describes a process, an overlay describes one run of it, and many overlays can sit over one model. + * + * An element with no entry has no recorded state, which is distinct from the explicit `"unknown"`, a source reporting that it cannot tell. + */ +export type Overlay = Readonly< + Partial> +>; + +/** The open overlay shape: element identifiers are plain strings. Its inferred type is exactly `Overlay`. */ +export const overlaySchema = z + .partialRecord(z.string(), z.enum(overlayStatuses)) + .readonly(); + +/** A shape problem found while loading an overlay. */ +export interface OverlayIssue { + /** RFC 6901 JSON pointer to the offending value in the input; the empty string is the document itself. */ + readonly pointer: string; + readonly message: string; +} + +/** Thrown by `parseOverlay` when the input is not a valid overlay. */ +export class OverlayParseError extends Error { + /** Every problem found. Never empty. */ + readonly issues: readonly OverlayIssue[]; + + /** + * @param issues - The problems found. Must not be empty. + */ + constructor(issues: readonly OverlayIssue[]) { + super( + `Invalid overlay: ${issues + .map( + (issue) => + `${issue.pointer === "" ? "/" : issue.pointer}: ${issue.message}`, + ) + .join("; ")}`, + ); + this.name = "OverlayParseError"; + this.issues = issues; + } +} + +/** + * Loads an overlay from untrusted input, such as parsed JSON. + * @param input - The value to load. + * @returns The overlay, keyed by plain-string element identifiers. + * @throws OverlayParseError when the input is not an object of element identifier to `"done"`, `"open"` or `"unknown"`. + */ +export const parseOverlay = (input: unknown): Overlay => { + const parsed = overlaySchema.safeParse(input); + if (!parsed.success) { + throw new OverlayParseError( + parsed.error.issues.map((issue) => ({ + pointer: toPointer(issue.path), + message: issue.message, + })), + ); + } + + return parsed.data; +}; diff --git a/packages/core/graph/schema/src/overlay.unit.test.ts b/packages/core/graph/schema/src/overlay.unit.test.ts new file mode 100644 index 0000000..4b32cc1 --- /dev/null +++ b/packages/core/graph/schema/src/overlay.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { z } from "zod"; +import type { OpenIds } from "./ids.ts"; +import { + OverlayParseError, + type Overlay, + type overlaySchema, + parseOverlay, +} from "./overlay.ts"; + +/** The issues `parseOverlay` reports for an input it must reject. */ +const issuesOf = (input: unknown): OverlayParseError["issues"] => { + try { + parseOverlay(input); + } catch (error) { + if (error instanceof OverlayParseError) { + return error.issues; + } + + throw error; + } + + throw new Error("expected parseOverlay to throw"); +}; + +describe("overlaySchema", () => { + it("infers exactly the open overlay type", () => { + expectTypeOf>().toEqualTypeOf< + Overlay + >(); + }); +}); + +describe("parseOverlay", () => { + it("loads each status by element identifier", () => { + const input = { + "a-submit": "done", + "a-request": "open", + "a-pack": "unknown", + }; + + expect(parseOverlay(input)).toEqual(input); + }); + + it("loads an empty overlay", () => { + expect(parseOverlay({})).toEqual({}); + }); + + it("locates a value outside the statuses by JSON pointer", () => { + const issues = issuesOf({ "a/b": "finished" }); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ pointer: "/a~1b" }); + }); + + it("rejects a document that is not an object", () => { + expect(issuesOf(["done"])).toMatchObject([{ pointer: "" }]); + }); + + it("names every problem in the error message", () => { + const error = new OverlayParseError([ + { pointer: "/a", message: "bad" }, + { pointer: "", message: "worse" }, + ]); + + expect(error.message).toBe("Invalid overlay: /a: bad; /: worse"); + expect(error.name).toBe("OverlayParseError"); + }); +}); diff --git a/packages/core/graph/schema/src/parse.ts b/packages/core/graph/schema/src/parse.ts new file mode 100644 index 0000000..43b891b --- /dev/null +++ b/packages/core/graph/schema/src/parse.ts @@ -0,0 +1,76 @@ +import type { Model } from "./model.ts"; +import type { OpenIds } from "./ids.ts"; +import { toPointer } from "./pointer.ts"; +import { modelSchema } from "./schema.ts"; +import { validate, type IssueCode } from "./validate.ts"; + +/** A problem found while loading a model: a shape violation located by JSON pointer, or a rule `validate` reports. */ +export type ModelIssue = + | { + readonly source: "schema"; + /** RFC 6901 JSON pointer to the offending value in the input; the empty string is the document itself. */ + readonly pointer: string; + readonly message: string; + } + | { + readonly source: "validate"; + readonly code: IssueCode; + readonly message: string; + }; + +/** Thrown by `parseModel` when the input is not a valid model. */ +export class ModelParseError extends Error { + /** Every problem found, in the order the schema and then `validate` reported them. Never empty. */ + readonly issues: readonly ModelIssue[]; + + /** + * @param issues - The problems found. Must not be empty. + */ + constructor(issues: readonly ModelIssue[]) { + super( + `Invalid model: ${issues + .map((issue) => + issue.source === "schema" + ? `${issue.pointer === "" ? "/" : issue.pointer}: ${issue.message}` + : issue.message, + ) + .join("; ")}`, + ); + this.name = "ModelParseError"; + this.issues = issues; + } +} + +/** + * Loads a model from untrusted input, such as parsed JSON. + * + * The input is checked against the open schema first, so every shape problem is reported with the JSON pointer of the offending value. Only a well-shaped model is then passed to `validate`, whose issues are surfaced the same way. + * @param input - The value to load. + * @returns The validated model, with identifiers as plain strings. + * @throws ModelParseError when the input is not a well-shaped model or fails `validate`. + */ +export const parseModel = (input: unknown): Model => { + const parsed = modelSchema.safeParse(input); + if (!parsed.success) { + throw new ModelParseError( + parsed.error.issues.map((issue) => ({ + source: "schema", + pointer: toPointer(issue.path), + message: issue.message, + })), + ); + } + + const issues = validate(parsed.data); + if (issues.length > 0) { + throw new ModelParseError( + issues.map((issue) => ({ + source: "validate", + code: issue.code, + message: issue.message, + })), + ); + } + + return parsed.data; +}; diff --git a/packages/core/graph/schema/src/parse.unit.test.ts b/packages/core/graph/schema/src/parse.unit.test.ts new file mode 100644 index 0000000..efd7a72 --- /dev/null +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "vitest"; +import { type ModelIssue, ModelParseError, parseModel } from "./parse.ts"; +import { order } from "./test-support/order.ts"; + +/** Round-trips through JSON text, as a loaded document would arrive. */ +const fromJson = (value: unknown): unknown => { + const parsed: unknown = JSON.parse(JSON.stringify(value)); + + return parsed; +}; + +/** The issues `parseModel` reports for an input it must reject. */ +const issuesOf = (input: unknown): readonly ModelIssue[] => { + try { + parseModel(input); + } catch (error) { + if (error instanceof ModelParseError) { + return error.issues; + } + + throw error; + } + + throw new Error("expected parseModel to throw"); +}; + +const withElements = (elements: unknown): unknown => ({ ...order, elements }); + +describe("parseModel", () => { + it("loads the order process from JSON text unchanged", () => { + expect(parseModel(fromJson(order))).toEqual(order); + }); + + it("locates a wrongly typed value by JSON pointer", () => { + const issues = issuesOf( + withElements([{ kind: "activity", id: "a", label: 7 }]), + ); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + source: "schema", + pointer: "/elements/0/label", + }); + }); + + it("rejects an unknown key rather than dropping it", () => { + const issues = issuesOf( + withElements([{ kind: "pseudo", id: "s", role: "initial", extra: 1 }]), + ); + + expect(issues).toEqual([ + { + source: "schema", + pointer: "/elements/0", + message: 'Unrecognized key: "extra"', + }, + ]); + }); + + it("locates a value outside a vocabulary", () => { + const issues = issuesOf({ + ...order, + participants: [{ id: "x", label: "X", kind: "robot" }], + }); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + source: "schema", + pointer: "/participants/0/kind", + }); + }); + + it("escapes a slash and a tilde inside a pointer segment", () => { + const issues = issuesOf({ + ...order, + events: [{ id: "e", name: "n", payload: { "a/b~c": 3 } }], + }); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + pointer: "/events/0/payload/a~1b~0c", + }); + }); + + it("reports a non-object document at the empty pointer", () => { + expect(issuesOf(null)).toMatchObject([{ source: "schema", pointer: "" }]); + }); + + it("surfaces what validate reports for a well-shaped model", () => { + const issues = issuesOf({ + ...order, + transitions: [{ id: "t1", source: "start", target: "nowhere" }], + projections: [], + }); + + expect(issues).toEqual([ + { + source: "validate", + code: "unknown-reference", + message: 'transition "t1" target refers to unknown id "nowhere"', + }, + ]); + }); + + it("keeps an activity's source through loading", () => { + const activity = { + kind: "activity", + id: "a", + label: "A", + source: "github:org/repo#1", + }; + const model = parseModel({ + ...order, + elements: [activity], + transitions: [], + projections: [], + }); + + expect(model.elements).toEqual([activity]); + }); + + it("round-trips artefacts and an activity's consumes and produces", () => { + const input = { + ...order, + artefacts: [ + { id: "spec", name: "Specification" }, + { id: "release", name: "Release", deliverable: true }, + ], + elements: [ + { + kind: "activity", + id: "a", + label: "A", + consumes: ["spec"], + produces: ["release"], + }, + ], + transitions: [], + projections: [], + }; + + expect(parseModel(fromJson(input))).toEqual(input); + }); + + it("round-trips a transition's trilean condition beside its guard", () => { + const input = { + ...order, + transitions: [ + { + id: "t1", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + condition: { + kind: "and", + left: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "amount" }, + right: { kind: "numberLiteral", value: 0 }, + }, + right: { + kind: "not", + operand: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "status" }, + right: { kind: "textLiteral", value: "declined" }, + }, + }, + }, + }, + ], + projections: [], + }; + + expect(parseModel(fromJson(input))).toEqual(input); + }); + + it("locates a malformed condition by JSON pointer", () => { + const issues = issuesOf({ + ...order, + transitions: [ + { + id: "t1", + source: "d-authorised", + target: "a-pack", + condition: { kind: "maybe" }, + }, + ], + projections: [], + }); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + source: "schema", + pointer: "/transitions/0/condition/kind", + }); + }); + + it("round-trips an activity's affected entities", () => { + const activity = { + kind: "activity", + id: "a", + label: "A", + actor: "shop", + affects: ["order"], + }; + + expect( + parseModel( + fromJson({ + ...order, + elements: [activity], + transitions: [], + projections: [], + }), + ).elements, + ).toEqual([activity]); + }); + + it("round-trips notation-only extensions on every kind of element unchanged", () => { + const extensions = { + sequence: { activation: { from: "t2", to: "t4" } }, + stateMachine: { history: "deep", tags: ["a", 1, null, true] }, + }; + const input = { + ...order, + elements: [ + { kind: "activity", id: "a", label: "A", extensions }, + { kind: "decision", id: "d", label: "D", extensions }, + { kind: "state", id: "s", label: "S", extensions }, + { kind: "pseudo", id: "p", role: "history", extensions }, + { + kind: "message", + id: "m", + label: "M", + from: "shop", + to: "provider", + mode: "sync", + extensions, + }, + ], + transitions: [], + projections: [], + }; + + expect(parseModel(fromJson(input))).toEqual(input); + }); + + it("rejects an extension value that is not JSON", () => { + const issues = issuesOf({ + ...order, + elements: [ + { + kind: "activity", + id: "a", + label: "A", + extensions: { sequence: { at: undefined, fn: () => 1 } }, + }, + ], + transitions: [], + projections: [], + }); + + expect(issues).toMatchObject([ + { source: "schema", pointer: "/elements/0/extensions/sequence" }, + ]); + }); + + it("names every problem in the error message", () => { + const error = new ModelParseError([ + { source: "schema", pointer: "/a", message: "bad" }, + { source: "validate", code: "duplicate-id", message: "dup" }, + ]); + + expect(error.message).toBe("Invalid model: /a: bad; dup"); + expect(error.name).toBe("ModelParseError"); + }); +}); diff --git a/packages/core/graph/schema/src/pointer.ts b/packages/core/graph/schema/src/pointer.ts new file mode 100644 index 0000000..969a024 --- /dev/null +++ b/packages/core/graph/schema/src/pointer.ts @@ -0,0 +1,8 @@ +/** Encodes a schema issue path as an RFC 6901 JSON pointer, escaping `~` and `/` inside a segment. The empty path is the document itself, `""`. */ +export const toPointer = (path: readonly PropertyKey[]): string => + path + .map( + (segment) => + `/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`, + ) + .join(""); diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts new file mode 100644 index 0000000..f5ef8b7 --- /dev/null +++ b/packages/core/graph/schema/src/schema.ts @@ -0,0 +1,156 @@ +import { JsonValueSchema, PredicateNodeSchema } from "trilean"; +import { z } from "zod"; +import { + fragmentOperators, + messageModes, + notations, + participantKinds, + pseudoRoles, +} from "./vocabulary.ts"; + +/** An object schema that rejects unknown keys, so a misspelt field in a loaded document is an error and not silently dropped. */ +const record = (shape: Shape) => + z.strictObject(shape).readonly(); + +const id = z.string(); +const elementRef = z.string(); +const actorRef = z.string(); +const eventRef = z.string(); +const actionRef = z.string(); +const transitionRef = z.string(); +const entityRef = z.string(); +const artefactRef = z.string(); + +const extensionsSchema = z.record(z.string(), JsonValueSchema).readonly(); + +const participantSchema = record({ + id, + label: z.string(), + kind: z.enum(participantKinds), +}); + +const eventSchema = record({ + id, + name: z.string(), + payload: z.record(z.string(), z.string()).readonly().exactOptional(), +}); + +const actionSchema = record({ id, name: z.string() }); + +const artefactSchema = record({ + id, + name: z.string(), + deliverable: z.boolean().exactOptional(), +}); + +const elementSchema = z.discriminatedUnion("kind", [ + record({ + kind: z.literal("activity"), + id, + extensions: extensionsSchema.exactOptional(), + label: z.string(), + actor: actorRef.exactOptional(), + action: actionRef.exactOptional(), + source: z.string().exactOptional(), + affects: z.array(entityRef).readonly().exactOptional(), + consumes: z.array(artefactRef).readonly().exactOptional(), + produces: z.array(artefactRef).readonly().exactOptional(), + }), + record({ + kind: z.literal("decision"), + id, + extensions: extensionsSchema.exactOptional(), + label: z.string(), + actor: actorRef.exactOptional(), + }), + record({ + kind: z.literal("state"), + id, + extensions: extensionsSchema.exactOptional(), + label: z.string(), + parent: elementRef.exactOptional(), + entry: actionRef.exactOptional(), + exit: actionRef.exactOptional(), + }), + record({ + kind: z.literal("pseudo"), + id, + extensions: extensionsSchema.exactOptional(), + role: z.enum(pseudoRoles), + parent: elementRef.exactOptional(), + }), + record({ + kind: z.literal("message"), + id, + extensions: extensionsSchema.exactOptional(), + label: z.string(), + from: actorRef, + to: actorRef, + mode: z.enum(messageModes), + event: eventRef.exactOptional(), + }), +]); + +const transitionSchema = record({ + id, + source: elementRef, + target: elementRef, + trigger: eventRef.exactOptional(), + guard: z.string().exactOptional(), + condition: PredicateNodeSchema.exactOptional(), + effect: actionRef.exactOptional(), +}); + +const fragmentOperandSchema = record({ + guard: z.string().exactOptional(), + body: z.array(elementRef).readonly(), +}); + +const fragmentSchema = record({ + id, + operator: z.enum(fragmentOperators), + operands: z.array(fragmentOperandSchema).readonly(), +}); + +const projectionSchema = z.union([ + record({ + id: z.string(), + notation: z.enum(["flowchart", "swimlane"]), + }), + record({ + id: z.string(), + notation: z.literal("stateMachine"), + subject: entityRef, + }), + record({ + id: z.string(), + notation: z.literal("sequence"), + scenario: z.array(transitionRef).readonly(), + }), +]); + +const pointSchema = record({ x: z.number(), y: z.number() }); + +const viewsSchema = z + .partialRecord( + z.enum(notations), + z.partialRecord(z.string(), pointSchema).readonly(), + ) + .readonly(); + +/** + * The open (runtime) shape of a model: identifiers are plain strings, so references are not checked here. `validate` checks what the shape cannot, and `parseModel` runs both. + * + * Every object is strict. The inferred type is exactly `Model`, which a type-level test pins in both directions. + */ +export const modelSchema = record({ + participants: z.array(participantSchema).readonly(), + events: z.array(eventSchema).readonly(), + actions: z.array(actionSchema).readonly(), + artefacts: z.array(artefactSchema).readonly(), + elements: z.array(elementSchema).readonly(), + transitions: z.array(transitionSchema).readonly(), + fragments: z.array(fragmentSchema).readonly(), + projections: z.array(projectionSchema).readonly(), + views: viewsSchema.exactOptional(), +}); diff --git a/packages/core/graph/schema/src/schema.unit.test.ts b/packages/core/graph/schema/src/schema.unit.test.ts new file mode 100644 index 0000000..6766cda --- /dev/null +++ b/packages/core/graph/schema/src/schema.unit.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { z } from "zod"; +import type { Model, OpenIds } from "./index.ts"; +import { modelSchema } from "./schema.ts"; +import { order } from "./test-support/order.ts"; + +describe("modelSchema", () => { + it("infers exactly the open model type", () => { + expectTypeOf>().toEqualTypeOf>(); + }); + + it("accepts the order process", () => { + expect(modelSchema.parse(order)).toEqual(order); + }); +}); diff --git a/packages/core/graph/schema/src/test-support/order.ts b/packages/core/graph/schema/src/test-support/order.ts new file mode 100644 index 0000000..428c586 --- /dev/null +++ b/packages/core/graph/schema/src/test-support/order.ts @@ -0,0 +1,101 @@ +import { defineModel } from "../model.ts"; + +/** The order process from the doc, with every reference checked at compile time. */ +export const order = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t7", source: "a-pack", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [ + { id: "p-flow", notation: "flowchart" }, + { id: "p-lanes", notation: "swimlane" }, + { id: "p-state", notation: "stateMachine", subject: "order" }, + { + id: "p-seq", + notation: "sequence", + scenario: ["t1", "t2", "t3", "t4", "t5", "t7"], + }, + ], + views: { + flowchart: { "a-request": { x: 306, y: 100 } }, + }, +}); diff --git a/packages/core/graph/schema/src/validate.ts b/packages/core/graph/schema/src/validate.ts new file mode 100644 index 0000000..6312996 --- /dev/null +++ b/packages/core/graph/schema/src/validate.ts @@ -0,0 +1,215 @@ +import type { IdSpace } from "./ids.ts"; +import type { Model } from "./model.ts"; +import type { ParticipantKind } from "./vocabulary.ts"; + +export type IssueCode = + | "duplicate-id" + | "unknown-reference" + | "multiple-initial" + | "subject-not-entity" + | "actor-is-entity" + | "duplicate-source" + | "multiple-producers" + | "affects-not-entity"; + +export interface Issue { + readonly code: IssueCode; + readonly message: string; +} + +const duplicates = (ids: readonly string[]): readonly string[] => { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } + seen.add(id); + } + + return [...repeated]; +}; + +/** + * Runtime validation. Authored models are already reference-safe at compile time. This is for models loaded from JSON, where identifiers are plain strings, and for the rules the type system cannot state: unique identifiers, one initial pseudo-state per scope, and entity subjects for state machine projections. + */ +export const validate = ( + model: Model, +): readonly Issue[] => { + const issues: Issue[] = []; + + const participantIds = new Set(model.participants.map((p) => p.id)); + const eventIds = new Set(model.events.map((e) => e.id)); + const actionIds = new Set(model.actions.map((a) => a.id)); + const elementIds = new Set(model.elements.map((e) => e.id)); + const artefactIds = new Set(model.artefacts.map((a) => a.id)); + const transitionIds = new Set(model.transitions.map((t) => t.id)); + + const spaces: readonly (readonly [string, readonly string[]])[] = [ + ["participant", model.participants.map((p) => p.id)], + ["event", model.events.map((e) => e.id)], + ["action", model.actions.map((a) => a.id)], + ["artefact", model.artefacts.map((a) => a.id)], + ["element", model.elements.map((e) => e.id)], + ["transition", model.transitions.map((t) => t.id)], + ["fragment", model.fragments.map((f) => f.id)], + ]; + for (const [space, ids] of spaces) { + for (const id of duplicates(ids)) { + issues.push({ + code: "duplicate-id", + message: `Duplicate ${space} id "${id}"`, + }); + } + } + + const require = ( + known: ReadonlySet, + id: string | undefined, + where: string, + ): void => { + if (id !== undefined && !known.has(id)) { + issues.push({ + code: "unknown-reference", + message: `${where} refers to unknown id "${id}"`, + }); + } + }; + + const kindOf = new Map( + model.participants.map((p) => [p.id, p.kind]), + ); + + /** An actor must be declared, and must not be an entity. */ + const requireActor = (id: string | undefined, where: string): void => { + require(participantIds, id, where); + if (id !== undefined && kindOf.get(id) === "entity") { + issues.push({ + code: "actor-is-entity", + message: `${where} refers to entity "${id}", which cannot act`, + }); + } + }; + + for (const element of model.elements) { + switch (element.kind) { + case "activity": + requireActor(element.actor, `activity "${element.id}" actor`); + require(actionIds, element.action, `activity "${element.id}" action`); + for (const id of element.affects ?? []) { + const where = `activity "${element.id}" affects`; + require(participantIds, id, where); + if (participantIds.has(id) && kindOf.get(id) !== "entity") { + issues.push({ + code: "affects-not-entity", + message: `${where} "${id}", which is not an entity`, + }); + } + } + for (const id of element.consumes ?? []) { + require(artefactIds, id, `activity "${element.id}" consumes`); + } + for (const id of element.produces ?? []) { + require(artefactIds, id, `activity "${element.id}" produces`); + } + break; + case "decision": + requireActor(element.actor, `decision "${element.id}" actor`); + break; + case "state": + require(elementIds, element.parent, `state "${element.id}" parent`); + require(actionIds, element.entry, `state "${element.id}" entry`); + require(actionIds, element.exit, `state "${element.id}" exit`); + break; + case "pseudo": + require(elementIds, element.parent, `pseudo "${element.id}" parent`); + break; + case "message": + requireActor(element.from, `message "${element.id}" from`); + requireActor(element.to, `message "${element.id}" to`); + require(eventIds, element.event, `message "${element.id}" event`); + break; + } + } + + for (const transition of model.transitions) { + const where = `transition "${transition.id}"`; + require(elementIds, transition.source, `${where} source`); + require(elementIds, transition.target, `${where} target`); + require(eventIds, transition.trigger, `${where} trigger`); + require(actionIds, transition.effect, `${where} effect`); + } + + for (const fragment of model.fragments) { + for (const operand of fragment.operands) { + for (const id of operand.body) { + require(elementIds, id, `fragment "${fragment.id}" body`); + } + } + } + + const producers = new Map(); + for (const element of model.elements) { + if (element.kind === "activity") { + for (const id of new Set(element.produces ?? [])) { + producers.set(id, [...(producers.get(id) ?? []), element.id]); + } + } + } + for (const [artefact, activities] of producers) { + if (activities.length > 1) { + issues.push({ + code: "multiple-producers", + message: `Artefact "${artefact}" is produced by more than one activity: ${activities.join(", ")}`, + }); + } + } + + const sources = model.elements.flatMap((element) => + element.kind === "activity" && element.source !== undefined + ? [element.source] + : [], + ); + for (const source of duplicates(sources)) { + issues.push({ + code: "duplicate-source", + message: `Duplicate activity source "${source}"`, + }); + } + + const initialsByScope = new Map(); + for (const element of model.elements) { + if (element.kind === "pseudo" && element.role === "initial") { + const scope = element.parent ?? ""; + initialsByScope.set(scope, (initialsByScope.get(scope) ?? 0) + 1); + } + } + for (const [scope, count] of initialsByScope) { + if (count > 1) { + const label = scope === "" ? "the top level" : `"${scope}"`; + issues.push({ + code: "multiple-initial", + message: `${label} has ${count} initial pseudo-states`, + }); + } + } + + for (const projection of model.projections) { + if (projection.notation === "stateMachine") { + require(participantIds, projection.subject, `projection "${projection.id}" subject`); + if (kindOf.get(projection.subject) !== "entity") { + issues.push({ + code: "subject-not-entity", + message: `projection "${projection.id}" subject "${projection.subject}" is not an entity`, + }); + } + } + if (projection.notation === "sequence") { + for (const id of projection.scenario) { + require(transitionIds, id, `projection "${projection.id}" scenario`); + } + } + } + + return issues; +}; diff --git a/packages/core/graph/schema/src/validate.unit.test.ts b/packages/core/graph/schema/src/validate.unit.test.ts new file mode 100644 index 0000000..59ab395 --- /dev/null +++ b/packages/core/graph/schema/src/validate.unit.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import type { Model, OpenIds } from "./index.ts"; +import { validate } from "./validate.ts"; +import { order } from "./test-support/order.ts"; + +/** A runtime model: identifiers are plain strings, so references are not checked at compile time. */ +const open = (overrides: Partial>): Model => ({ + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "s", role: "initial" }, + { kind: "pseudo", id: "e", role: "final" }, + ], + transitions: [{ id: "t1", source: "s", target: "e" }], + fragments: [], + projections: [], + ...overrides, +}); + +describe("validate", () => { + it("reports nothing for the order process", () => { + expect(validate(order)).toEqual([]); + }); + + it("reports a duplicate identifier within one space", () => { + const issues = validate( + open({ + elements: [ + { kind: "pseudo", id: "s", role: "initial" }, + { kind: "pseudo", id: "s", role: "final" }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { code: "duplicate-id", message: 'Duplicate element id "s"' }, + ]); + }); + + it("allows the same identifier in different spaces", () => { + expect( + validate( + open({ + events: [{ id: "s", name: "s" }], + }), + ), + ).toEqual([]); + }); + + it("reports an unknown reference with where it occurs", () => { + const issues = validate( + open({ transitions: [{ id: "t1", source: "s", target: "nowhere" }] }), + ); + + expect(issues).toEqual([ + { + code: "unknown-reference", + message: 'transition "t1" target refers to unknown id "nowhere"', + }, + ]); + }); + + it("reports an entity used as an actor", () => { + const issues = validate( + open({ + elements: [{ kind: "activity", id: "a", label: "A", actor: "order" }], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { + code: "actor-is-entity", + message: + 'activity "a" actor refers to entity "order", which cannot act', + }, + ]); + }); + + it("reports several initial pseudo-states in one scope", () => { + const issues = validate( + open({ + elements: [ + { kind: "pseudo", id: "s1", role: "initial" }, + { kind: "pseudo", id: "s2", role: "initial" }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { + code: "multiple-initial", + message: "the top level has 2 initial pseudo-states", + }, + ]); + }); + + it("reports a state machine subject that is not an entity", () => { + const issues = validate( + open({ + projections: [{ id: "p", notation: "stateMachine", subject: "shop" }], + }), + ); + + expect(issues).toEqual([ + { + code: "subject-not-entity", + message: 'projection "p" subject "shop" is not an entity', + }, + ]); + }); + + it("reports a scenario step that is not a declared transition", () => { + const issues = validate( + open({ + projections: [{ id: "p", notation: "sequence", scenario: ["t9"] }], + }), + ); + + expect(issues).toEqual([ + { + code: "unknown-reference", + message: 'projection "p" scenario refers to unknown id "t9"', + }, + ]); + }); + + it("reports a fragment body that names an unknown element", () => { + const issues = validate( + open({ + fragments: [ + { id: "f", operator: "alt", operands: [{ body: ["s", "ghost"] }] }, + ], + }), + ); + + expect(issues).toEqual([ + { + code: "unknown-reference", + message: 'fragment "f" body refers to unknown id "ghost"', + }, + ]); + }); + + it("reports an activity source used by two activities", () => { + const issues = validate( + open({ + elements: [ + { + kind: "activity", + id: "a", + label: "A", + source: "github:org/repo#1", + }, + { + kind: "activity", + id: "b", + label: "B", + source: "github:org/repo#1", + }, + { + kind: "activity", + id: "c", + label: "C", + source: "github:org/repo#2", + }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { + code: "duplicate-source", + message: 'Duplicate activity source "github:org/repo#1"', + }, + ]); + }); + + it("allows activities without a source to repeat the absence", () => { + const issues = validate( + open({ + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "activity", id: "b", label: "B" }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([]); + }); + + it("reports an unknown consumed or produced artefact", () => { + const issues = validate( + open({ + artefacts: [{ id: "spec", name: "Specification" }], + elements: [ + { + kind: "activity", + id: "a", + label: "A", + consumes: ["design"], + produces: ["spec", "code"], + }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { + code: "unknown-reference", + message: 'activity "a" consumes refers to unknown id "design"', + }, + { + code: "unknown-reference", + message: 'activity "a" produces refers to unknown id "code"', + }, + ]); + }); + + it("reports an artefact produced by more than one activity", () => { + const issues = validate( + open({ + artefacts: [{ id: "spec", name: "Specification" }], + elements: [ + { kind: "activity", id: "a", label: "A", produces: ["spec"] }, + { kind: "activity", id: "b", label: "B", produces: ["spec"] }, + { kind: "activity", id: "c", label: "C", consumes: ["spec"] }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { + code: "multiple-producers", + message: 'Artefact "spec" is produced by more than one activity: a, b', + }, + ]); + }); + + it("does not count one activity listing an artefact twice as two producers", () => { + const issues = validate( + open({ + artefacts: [{ id: "spec", name: "Specification" }], + elements: [ + { + kind: "activity", + id: "a", + label: "A", + produces: ["spec", "spec"], + }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([]); + }); + + it("reports a duplicate artefact identifier", () => { + const issues = validate( + open({ + artefacts: [ + { id: "spec", name: "One" }, + { id: "spec", name: "Two" }, + ], + }), + ); + + expect(issues).toEqual([ + { code: "duplicate-id", message: 'Duplicate artefact id "spec"' }, + ]); + }); + + it("reports an affected participant that is not an entity or not declared", () => { + const issues = validate( + open({ + elements: [ + { + kind: "activity", + id: "a", + label: "A", + affects: ["order", "shop", "ghost"], + }, + ], + transitions: [], + }), + ); + + expect(issues).toEqual([ + { + code: "affects-not-entity", + message: 'activity "a" affects "shop", which is not an entity', + }, + { + code: "unknown-reference", + message: 'activity "a" affects refers to unknown id "ghost"', + }, + ]); + }); +}); diff --git a/packages/core/graph/schema/src/vocabulary.ts b/packages/core/graph/schema/src/vocabulary.ts new file mode 100644 index 0000000..e739725 --- /dev/null +++ b/packages/core/graph/schema/src/vocabulary.ts @@ -0,0 +1,42 @@ +/** What a participant is: a `role` or `system` can act, an `entity` has a lifecycle a state machine can track. */ +export const participantKinds = ["role", "system", "entity"] as const; +export type ParticipantKind = (typeof participantKinds)[number]; + +export const pseudoRoles = [ + "initial", + "final", + "fork", + "join", + "history", +] as const; +export type PseudoRole = (typeof pseudoRoles)[number]; + +export const messageModes = [ + "sync", + "async", + "reply", + "create", + "destroy", +] as const; +export type MessageMode = (typeof messageModes)[number]; + +export const fragmentOperators = [ + "alt", + "opt", + "loop", + "par", + "break", +] as const; +export type FragmentOperator = (typeof fragmentOperators)[number]; + +/** Where an element stands in a run: finished, still to do, or not known. Run state is supplied by sources and never stored in the model. */ +export const overlayStatuses = ["done", "open", "unknown"] as const; +export type OverlayStatus = (typeof overlayStatuses)[number]; + +export const notations = [ + "flowchart", + "swimlane", + "stateMachine", + "sequence", +] as const; +export type Notation = (typeof notations)[number]; diff --git a/packages/core/graph/schema/stryker.config.ts b/packages/core/graph/schema/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/core/graph/schema/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/core/graph/schema/test/package.smoke.test.ts b/packages/core/graph/schema/test/package.smoke.test.ts new file mode 100644 index 0000000..f08853c --- /dev/null +++ b/packages/core/graph/schema/test/package.smoke.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { defineModel, validate } from "../dist/index.js"; + +test("the built package defines and validates a model", () => { + const model = defineModel({ + participants: [{ id: "shop", label: "Shop", kind: "system" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { kind: "activity", id: "pack", label: "Pack", actor: "shop" }, + ], + transitions: [{ id: "t1", source: "start", target: "pack" }], + fragments: [], + projections: [], + }); + + expect(validate(model)).toEqual([]); +}); diff --git a/packages/core/graph/schema/tsconfig.build.json b/packages/core/graph/schema/tsconfig.build.json new file mode 100644 index 0000000..facc9a8 --- /dev/null +++ b/packages/core/graph/schema/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [] +} diff --git a/packages/core/graph/schema/tsconfig.json b/packages/core/graph/schema/tsconfig.json new file mode 100644 index 0000000..5645111 --- /dev/null +++ b/packages/core/graph/schema/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/core/graph/schema/tsconfig.node.json b/packages/core/graph/schema/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/core/graph/schema/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/core/graph/schema/vitest.config.ts b/packages/core/graph/schema/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/core/graph/schema/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2d4374..9cf9bca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,83 @@ importers: specifier: 4.1.11 version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/projection: + dependencies: + '@exadev/process-graph-schema': + specifier: 0.0.0 + version: link:../schema + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@4.1.11) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + + packages/core/graph/schema: + dependencies: + trilean: + specifier: 1.7.2 + version: 1.7.2 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@4.1.11) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages: '@actions/core@3.0.1': @@ -3683,6 +3760,14 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trilean-regex@1.0.4: + resolution: {integrity: sha512-uUjHS0KKuqqcUwWZjhYS9WOlt0BAYwNztiJMfpXPkQfRCXAv3kIkvdY0NbXUGRuV/o/z+JpnRfbVA9hzBvZjMA==} + engines: {node: '>=20'} + + trilean@1.7.2: + resolution: {integrity: sha512-9LCIhYxQWeCaSHWH0ygv8bquw72co01Zs/0b1t1faKxdOEzQQCDt4FDoQdaS893218y+rURk6m04KgSxJdgmUA==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -7590,6 +7675,13 @@ snapshots: tree-kill@1.2.2: {} + trilean-regex@1.0.4: {} + + trilean@1.7.2: + dependencies: + trilean-regex: 1.0.4 + zod: 4.6.5 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40279d1..d555876 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,3 +31,4 @@ minimumReleaseAgeExclude: - "@exadev/process-graph-*" - cosmiconfig-extends - trilean + - trilean-regex diff --git a/tsconfig.build.json b/tsconfig.build.json index 40bedd5..ccc5306 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,12 @@ "references": [ { "path": "packages/core/graph/dag/tsconfig.build.json" + }, + { + "path": "packages/core/graph/schema/tsconfig.build.json" + }, + { + "path": "packages/core/graph/projection/tsconfig.build.json" } ] }