From 8328962392434ea08e90b56d7a624e95dc7c305a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 09:57:40 +0100 Subject: [PATCH 01/14] feat: add schema package The process model as typed identifier spaces (participant, actor, entity, event, action, element, transition, fragment), declarations and references kept apart, defineModel with compile-time reference checking, and a runtime validate for what the types cannot state. The compile-fail checks run as unit tests, so the package typecheck fails when an expected error stops being one. --- README.md | 7 +- packages/core/graph/schema/AGENTS.md | 1 + packages/core/graph/schema/CLAUDE.md | 1 + packages/core/graph/schema/README.md | 65 +++++++ packages/core/graph/schema/eslint.config.ts | 9 + packages/core/graph/schema/package.json | 72 ++++++++ .../schema/src/compile-fail.unit.test.ts | 167 ++++++++++++++++++ .../core/graph/schema/src/declarations.ts | 126 +++++++++++++ packages/core/graph/schema/src/ids.ts | 16 ++ packages/core/graph/schema/src/index.ts | 29 +++ packages/core/graph/schema/src/model.ts | 77 ++++++++ .../graph/schema/src/test-support/order.ts | 100 +++++++++++ packages/core/graph/schema/src/validate.ts | 165 +++++++++++++++++ .../graph/schema/src/validate.unit.test.ts | 150 ++++++++++++++++ packages/core/graph/schema/src/vocabulary.ts | 5 + packages/core/graph/schema/stryker.config.ts | 3 + .../graph/schema/test/package.smoke.test.ts | 20 +++ .../core/graph/schema/tsconfig.build.json | 13 ++ packages/core/graph/schema/tsconfig.json | 9 + packages/core/graph/schema/tsconfig.node.json | 8 + packages/core/graph/schema/vitest.config.ts | 16 ++ pnpm-lock.yaml | 33 ++++ tsconfig.build.json | 3 + 23 files changed, 1092 insertions(+), 3 deletions(-) create mode 120000 packages/core/graph/schema/AGENTS.md create mode 120000 packages/core/graph/schema/CLAUDE.md create mode 100644 packages/core/graph/schema/README.md create mode 100644 packages/core/graph/schema/eslint.config.ts create mode 100644 packages/core/graph/schema/package.json create mode 100644 packages/core/graph/schema/src/compile-fail.unit.test.ts create mode 100644 packages/core/graph/schema/src/declarations.ts create mode 100644 packages/core/graph/schema/src/ids.ts create mode 100644 packages/core/graph/schema/src/index.ts create mode 100644 packages/core/graph/schema/src/model.ts create mode 100644 packages/core/graph/schema/src/test-support/order.ts create mode 100644 packages/core/graph/schema/src/validate.ts create mode 100644 packages/core/graph/schema/src/validate.unit.test.ts create mode 100644 packages/core/graph/schema/src/vocabulary.ts create mode 100644 packages/core/graph/schema/stryker.config.ts create mode 100644 packages/core/graph/schema/test/package.smoke.test.ts create mode 100644 packages/core/graph/schema/tsconfig.build.json create mode 100644 packages/core/graph/schema/tsconfig.json create mode 100644 packages/core/graph/schema/tsconfig.node.json create mode 100644 packages/core/graph/schema/vitest.config.ts diff --git a/README.md b/README.md index db3a115..20f9c06 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,10 @@ 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. | ## Conventions 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..120dee0 --- /dev/null +++ b/packages/core/graph/schema/README.md @@ -0,0 +1,65 @@ +# @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` and `actor-is-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. + +## 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..97b127c --- /dev/null +++ b/packages/core/graph/schema/package.json @@ -0,0 +1,72 @@ +{ + "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" + } +} 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..8e928cd --- /dev/null +++ b/packages/core/graph/schema/src/compile-fail.unit.test.ts @@ -0,0 +1,167 @@ +import { describe, 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: [], + 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: [], + 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: [], + 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: [], + 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: [], + 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: [], + 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: [], + 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: [], + 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" }, + ], + }); + }); +}); diff --git a/packages/core/graph/schema/src/declarations.ts b/packages/core/graph/schema/src/declarations.ts new file mode 100644 index 0000000..e1094ac --- /dev/null +++ b/packages/core/graph/schema/src/declarations.ts @@ -0,0 +1,126 @@ +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; +} + +export type Element = + | { + readonly kind: "activity"; + readonly id: Id; + readonly label: string; + readonly actor?: R["actor"]; + readonly action?: R["action"]; + } + | { + readonly kind: "decision"; + readonly id: Id; + readonly label: string; + readonly actor?: R["actor"]; + } + | { + readonly kind: "state"; + readonly id: Id; + readonly label: string; + readonly parent?: R["element"]; + readonly entry?: R["action"]; + readonly exit?: R["action"]; + } + | { + readonly kind: "pseudo"; + readonly id: Id; + readonly role: PseudoRole; + readonly parent?: R["element"]; + } + | { + readonly kind: "message"; + readonly id: Id; + 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"]; + readonly guard?: string; + 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..12279dc --- /dev/null +++ b/packages/core/graph/schema/src/ids.ts @@ -0,0 +1,16 @@ +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 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..cb3639f --- /dev/null +++ b/packages/core/graph/schema/src/index.ts @@ -0,0 +1,29 @@ +/** + * 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, + Element, + EventDef, + Fragment, + FragmentOperand, + Participant, + Point, + Projection, + Transition, + Views, +} from "./declarations.ts"; +export { defineModel, type Model, type ModelParts } from "./model.ts"; +export { validate, type Issue, type IssueCode } from "./validate.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..515b4d1 --- /dev/null +++ b/packages/core/graph/schema/src/model.ts @@ -0,0 +1,77 @@ +import type { + AnyParticipants, + ActionDef, + 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 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, +>( + 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 participant: NoInfer; + readonly actor: NoInfer>; + readonly entity: NoInfer>; + readonly event: NoInfer; + readonly action: NoInfer; + readonly element: NoInfer; + readonly transition: NoInfer; + readonly fragment: 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; +}> => model; 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..0f1be8f --- /dev/null +++ b/packages/core/graph/schema/src/test-support/order.ts @@ -0,0 +1,100 @@ +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" }, + ], + 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..dec042a --- /dev/null +++ b/packages/core/graph/schema/src/validate.ts @@ -0,0 +1,165 @@ +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"; + +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 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)], + ["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`); + 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 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..85599c5 --- /dev/null +++ b/packages/core/graph/schema/src/validate.unit.test.ts @@ -0,0 +1,150 @@ +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: [], + 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"', + }, + ]); + }); +}); diff --git a/packages/core/graph/schema/src/vocabulary.ts b/packages/core/graph/schema/src/vocabulary.ts new file mode 100644 index 0000000..57bc70a --- /dev/null +++ b/packages/core/graph/schema/src/vocabulary.ts @@ -0,0 +1,5 @@ +export type ParticipantKind = "role" | "system" | "entity"; +export type PseudoRole = "initial" | "final" | "fork" | "join" | "history"; +export type MessageMode = "sync" | "async" | "reply" | "create" | "destroy"; +export type FragmentOperator = "alt" | "opt" | "loop" | "par" | "break"; +export type Notation = "flowchart" | "swimlane" | "stateMachine" | "sequence"; 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..75fad8c --- /dev/null +++ b/packages/core/graph/schema/test/package.smoke.test.ts @@ -0,0 +1,20 @@ +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: [], + 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..f84f119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,39 @@ 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/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: '@actions/core@3.0.1': diff --git a/tsconfig.build.json b/tsconfig.build.json index 40bedd5..a4012a3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,9 @@ "references": [ { "path": "packages/core/graph/dag/tsconfig.build.json" + }, + { + "path": "packages/core/graph/schema/tsconfig.build.json" } ] } From c19c1cf32a26684946897af48d6f92ab561ab81c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 09:57:57 +0100 Subject: [PATCH 02/14] feat: add projection package The reference projections of the process model: hand-offs, flowchart, swimlane, state machine and sequence diagram, each a pure function of the model plus the extra input its notation needs. The order process lives in test support and is asserted in every notation, so an example is never exported from the library entry point. --- README.md | 9 +- packages/core/graph/projection/AGENTS.md | 1 + packages/core/graph/projection/CLAUDE.md | 1 + packages/core/graph/projection/README.md | 35 ++++ .../core/graph/projection/eslint.config.ts | 9 ++ packages/core/graph/projection/package.json | 74 +++++++++ packages/core/graph/projection/src/actors.ts | 19 +++ .../projection/src/compile-fail.unit.test.ts | 25 +++ .../core/graph/projection/src/flowchart.ts | 80 +++++++++ .../core/graph/projection/src/handoffs.ts | 35 ++++ packages/core/graph/projection/src/index.ts | 22 +++ .../projection/src/projections.unit.test.ts | 147 +++++++++++++++++ .../core/graph/projection/src/sequence.ts | 69 ++++++++ .../graph/projection/src/state-machine.ts | 153 ++++++++++++++++++ .../core/graph/projection/src/swimlane.ts | 53 ++++++ .../projection/src/test-support/order.ts | 100 ++++++++++++ .../core/graph/projection/stryker.config.ts | 3 + .../projection/test/package.smoke.test.ts | 27 ++++ .../core/graph/projection/tsconfig.build.json | 17 ++ packages/core/graph/projection/tsconfig.json | 9 ++ .../core/graph/projection/tsconfig.node.json | 8 + .../core/graph/projection/vitest.config.ts | 16 ++ pnpm-lock.yaml | 37 +++++ tsconfig.build.json | 3 + 24 files changed, 948 insertions(+), 4 deletions(-) create mode 120000 packages/core/graph/projection/AGENTS.md create mode 120000 packages/core/graph/projection/CLAUDE.md create mode 100644 packages/core/graph/projection/README.md create mode 100644 packages/core/graph/projection/eslint.config.ts create mode 100644 packages/core/graph/projection/package.json create mode 100644 packages/core/graph/projection/src/actors.ts create mode 100644 packages/core/graph/projection/src/compile-fail.unit.test.ts create mode 100644 packages/core/graph/projection/src/flowchart.ts create mode 100644 packages/core/graph/projection/src/handoffs.ts create mode 100644 packages/core/graph/projection/src/index.ts create mode 100644 packages/core/graph/projection/src/projections.unit.test.ts create mode 100644 packages/core/graph/projection/src/sequence.ts create mode 100644 packages/core/graph/projection/src/state-machine.ts create mode 100644 packages/core/graph/projection/src/swimlane.ts create mode 100644 packages/core/graph/projection/src/test-support/order.ts create mode 100644 packages/core/graph/projection/stryker.config.ts create mode 100644 packages/core/graph/projection/test/package.smoke.test.ts create mode 100644 packages/core/graph/projection/tsconfig.build.json create mode 100644 packages/core/graph/projection/tsconfig.json create mode 100644 packages/core/graph/projection/tsconfig.node.json create mode 100644 packages/core/graph/projection/vitest.config.ts diff --git a/README.md b/README.md index 20f9c06..bb823d9 100644 --- a/README.md +++ b/README.md @@ -43,10 +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. | -| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | +| 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..9ce6d69 --- /dev/null +++ b/packages/core/graph/projection/README.md @@ -0,0 +1,35 @@ +# @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. + +## Lossy directions + +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. Branches off the scenario are not drawn. + +## 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..88761b9 --- /dev/null +++ b/packages/core/graph/projection/src/flowchart.ts @@ -0,0 +1,80 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.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[]; +} + +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 }; +}; diff --git a/packages/core/graph/projection/src/handoffs.ts b/packages/core/graph/projection/src/handoffs.ts new file mode 100644 index 0000000..6e048e3 --- /dev/null +++ b/packages/core/graph/projection/src/handoffs.ts @@ -0,0 +1,35 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; + +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 = new Map(); + for (const element of model.elements) { + if ( + (element.kind === "activity" || element.kind === "decision") && + element.actor !== undefined + ) { + actorOf.set(element.id, element.actor); + } + } + + 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..a1005c0 --- /dev/null +++ b/packages/core/graph/projection/src/index.ts @@ -0,0 +1,22 @@ +/** + * 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 { 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 SequenceMessage, +} from "./sequence.ts"; 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..e5b7b7e --- /dev/null +++ b/packages/core/graph/projection/src/projections.unit.test.ts @@ -0,0 +1,147 @@ +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.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.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.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", + }, + ], + }); + }); + + it("projects the cancelled path to a different sequence diagram", () => { + const diagram = toSequence(order, cancelled); + + expect(diagram.lifelines).toEqual(["customer", "shop", "provider"]); + 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..05f349f --- /dev/null +++ b/packages/core/graph/projection/src/sequence.ts @@ -0,0 +1,69 @@ +import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.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; +} + +export interface SequenceDiagram { + /** Actors in order of first appearance. */ + readonly lifelines: readonly I["actor"][]; + readonly messages: readonly SequenceMessage[]; +} + +/** + * 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. + */ +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[] = []; + 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); + messages.push({ + transition: id, + from, + to, + label: eventName ?? labels.get(transition.target) ?? id, + }); + } + + return { lifelines, messages }; +}; 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..83c4879 --- /dev/null +++ b/packages/core/graph/projection/src/state-machine.ts @@ -0,0 +1,153 @@ +import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.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[]; +} + +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 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. + * + * 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 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?.kind === "decision" && !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" ? 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 = 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 }), + }); + } + } + } + + return { subject, states, transitions }; +}; diff --git a/packages/core/graph/projection/src/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts new file mode 100644 index 0000000..b835dfb --- /dev/null +++ b/packages/core/graph/projection/src/swimlane.ts @@ -0,0 +1,53 @@ +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"; + +export interface Lane { + readonly 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: lanes from actors, hand-offs where an edge changes 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[] = []; + for (const [actor, nodes] of members) { + const declared = model.participants.find( + (participant) => participant.id === actor, + ); + lanes.push({ + participant: actor, + label: declared === undefined ? actor : declared.label, + nodes, + }); + } + + return { ...flow, lanes, unassigned, handoffs: handoffs(model) }; +}; 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..dfd69f7 --- /dev/null +++ b/packages/core/graph/projection/src/test-support/order.ts @@ -0,0 +1,100 @@ +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" }, + ], + 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..e70cbb1 --- /dev/null +++ b/packages/core/graph/projection/test/package.smoke.test.ts @@ -0,0 +1,27 @@ +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: [], + 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/pnpm-lock.yaml b/pnpm-lock.yaml index f84f119..81a0b2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,43 @@ 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: devDependencies: '@arethetypeswrong/cli': diff --git a/tsconfig.build.json b/tsconfig.build.json index a4012a3..ccc5306 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,9 @@ }, { "path": "packages/core/graph/schema/tsconfig.build.json" + }, + { + "path": "packages/core/graph/projection/tsconfig.build.json" } ] } From 5ecb950817f2e86d2eadddffbc2c613f9b0cb38a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:00:19 +0100 Subject: [PATCH 03/14] feat(schema): add a Zod schema and loader for the open model shape The open shape of a model, with plain-string identifiers and every object strict, so a loaded document with a misspelt field is rejected rather than silently stripped. A type-level test pins the inferred type to exactly Model. parseModel checks the shape first, reporting each problem by JSON pointer, then runs validate and surfaces its issues in the same error. --- packages/core/graph/schema/README.md | 13 ++ packages/core/graph/schema/package.json | 3 + packages/core/graph/schema/src/index.ts | 2 + packages/core/graph/schema/src/parse.ts | 84 +++++++++++ .../core/graph/schema/src/parse.unit.test.ts | 111 ++++++++++++++ packages/core/graph/schema/src/schema.ts | 135 ++++++++++++++++++ .../core/graph/schema/src/schema.unit.test.ts | 15 ++ packages/core/graph/schema/src/vocabulary.ts | 43 +++++- pnpm-lock.yaml | 4 + 9 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 packages/core/graph/schema/src/parse.ts create mode 100644 packages/core/graph/schema/src/parse.unit.test.ts create mode 100644 packages/core/graph/schema/src/schema.ts create mode 100644 packages/core/graph/schema/src/schema.unit.test.ts diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 120dee0..6bd65e1 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -50,6 +50,19 @@ An activity's actor and a message's ends must be actors; a state machine project `validate(model)` returns the issues a runtime model can still have: `duplicate-id`, `unknown-reference`, `multiple-initial`, `subject-not-entity` and `actor-is-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. +## 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. + ## 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. diff --git a/packages/core/graph/schema/package.json b/packages/core/graph/schema/package.json index 97b127c..3b9e7a3 100644 --- a/packages/core/graph/schema/package.json +++ b/packages/core/graph/schema/package.json @@ -68,5 +68,8 @@ "publint": "0.3.25", "typescript": "6.0.3", "vitest": "4.1.11" + }, + "dependencies": { + "zod": "4.6.5" } } diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index cb3639f..63b17e3 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -27,3 +27,5 @@ export type { } from "./declarations.ts"; export { defineModel, type Model, type ModelParts } from "./model.ts"; export { validate, type Issue, type IssueCode } from "./validate.ts"; +export { modelSchema } from "./schema.ts"; +export { ModelParseError, parseModel, type ModelIssue } from "./parse.ts"; diff --git a/packages/core/graph/schema/src/parse.ts b/packages/core/graph/schema/src/parse.ts new file mode 100644 index 0000000..03740f2 --- /dev/null +++ b/packages/core/graph/schema/src/parse.ts @@ -0,0 +1,84 @@ +import type { Model } from "./model.ts"; +import type { OpenIds } from "./ids.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; + } +} + +/** Encodes one path as an RFC 6901 JSON pointer, escaping `~` and `/` inside a segment. */ +const toPointer = (path: readonly PropertyKey[]): string => + path + .map( + (segment) => + `/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`, + ) + .join(""); + +/** + * 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..57b9be6 --- /dev/null +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -0,0 +1,111 @@ +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 => + JSON.parse(JSON.stringify(value)) as unknown; + +/** 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("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/schema.ts b/packages/core/graph/schema/src/schema.ts new file mode 100644 index 0000000..f1213e4 --- /dev/null +++ b/packages/core/graph/schema/src/schema.ts @@ -0,0 +1,135 @@ +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 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 elementSchema = z.discriminatedUnion("kind", [ + record({ + kind: z.literal("activity"), + id, + label: z.string(), + actor: actorRef.exactOptional(), + action: actionRef.exactOptional(), + }), + record({ + kind: z.literal("decision"), + id, + label: z.string(), + actor: actorRef.exactOptional(), + }), + record({ + kind: z.literal("state"), + id, + label: z.string(), + parent: elementRef.exactOptional(), + entry: actionRef.exactOptional(), + exit: actionRef.exactOptional(), + }), + record({ + kind: z.literal("pseudo"), + id, + role: z.enum(pseudoRoles), + parent: elementRef.exactOptional(), + }), + record({ + kind: z.literal("message"), + id, + 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(), + 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(), + 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/vocabulary.ts b/packages/core/graph/schema/src/vocabulary.ts index 57bc70a..38ee827 100644 --- a/packages/core/graph/schema/src/vocabulary.ts +++ b/packages/core/graph/schema/src/vocabulary.ts @@ -1,5 +1,38 @@ -export type ParticipantKind = "role" | "system" | "entity"; -export type PseudoRole = "initial" | "final" | "fork" | "join" | "history"; -export type MessageMode = "sync" | "async" | "reply" | "create" | "destroy"; -export type FragmentOperator = "alt" | "opt" | "loop" | "par" | "break"; -export type Notation = "flowchart" | "swimlane" | "stateMachine" | "sequence"; +/** 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]; + +export const notations = [ + "flowchart", + "swimlane", + "stateMachine", + "sequence", +] as const; +export type Notation = (typeof notations)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a0b2a..5960a2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,10 @@ importers: 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: + zod: + specifier: 4.6.5 + version: 4.6.5 devDependencies: '@arethetypeswrong/cli': specifier: 0.18.5 From 2aaf4b3737ef2f45a7e4011e8a213df9cf202e67 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:00:59 +0100 Subject: [PATCH 04/14] feat(schema): bind an activity to its source record An activity may carry a source-qualified identifier such as github:org/repo#12, so a model built from an external tracker keeps each element's identity in that tracker. validate rejects two activities sharing one source with the duplicate-source issue. --- packages/core/graph/schema/README.md | 6 ++- .../core/graph/schema/src/declarations.ts | 4 ++ .../core/graph/schema/src/parse.unit.test.ts | 24 ++++++++- packages/core/graph/schema/src/schema.ts | 1 + packages/core/graph/schema/src/validate.ts | 15 +++++- .../graph/schema/src/validate.unit.test.ts | 49 +++++++++++++++++++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 6bd65e1..7e2c740 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -48,7 +48,11 @@ An activity's actor and a message's ends must be actors; a state machine project ## 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` and `actor-is-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. +`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`. 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. ## Loading a model diff --git a/packages/core/graph/schema/src/declarations.ts b/packages/core/graph/schema/src/declarations.ts index e1094ac..d12eaef 100644 --- a/packages/core/graph/schema/src/declarations.ts +++ b/packages/core/graph/schema/src/declarations.ts @@ -48,6 +48,10 @@ export type Element = 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; } | { readonly kind: "decision"; diff --git a/packages/core/graph/schema/src/parse.unit.test.ts b/packages/core/graph/schema/src/parse.unit.test.ts index 57b9be6..2877e1c 100644 --- a/packages/core/graph/schema/src/parse.unit.test.ts +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -3,8 +3,11 @@ 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 => - JSON.parse(JSON.stringify(value)) as unknown; +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[] => { @@ -99,6 +102,23 @@ describe("parseModel", () => { ]); }); + 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("names every problem in the error message", () => { const error = new ModelParseError([ { source: "schema", pointer: "/a", message: "bad" }, diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts index f1213e4..3aef67a 100644 --- a/packages/core/graph/schema/src/schema.ts +++ b/packages/core/graph/schema/src/schema.ts @@ -40,6 +40,7 @@ const elementSchema = z.discriminatedUnion("kind", [ label: z.string(), actor: actorRef.exactOptional(), action: actionRef.exactOptional(), + source: z.string().exactOptional(), }), record({ kind: z.literal("decision"), diff --git a/packages/core/graph/schema/src/validate.ts b/packages/core/graph/schema/src/validate.ts index dec042a..6d632d3 100644 --- a/packages/core/graph/schema/src/validate.ts +++ b/packages/core/graph/schema/src/validate.ts @@ -7,7 +7,8 @@ export type IssueCode = | "unknown-reference" | "multiple-initial" | "subject-not-entity" - | "actor-is-entity"; + | "actor-is-entity" + | "duplicate-source"; export interface Issue { readonly code: IssueCode; @@ -127,6 +128,18 @@ export const validate = ( } } + 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") { diff --git a/packages/core/graph/schema/src/validate.unit.test.ts b/packages/core/graph/schema/src/validate.unit.test.ts index 85599c5..c357c41 100644 --- a/packages/core/graph/schema/src/validate.unit.test.ts +++ b/packages/core/graph/schema/src/validate.unit.test.ts @@ -147,4 +147,53 @@ describe("validate", () => { }, ]); }); + + 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([]); + }); }); From 92813080d8f786d7127288895194177c757fb1bb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:02:12 +0100 Subject: [PATCH 05/14] feat(schema): add artefacts as data and deliverable dependencies A new artefact identifier space, ArtefactDef with an optional deliverable flag, and per-activity consumes and produces lists typed against it, so an undeclared artefact reference is a compile error like every other space. validate reports unknown artefact references and an artefact produced by more than one activity. inputsOf lists the artefacts nothing produces and outputsOf those flagged deliverable. --- .../projection/src/test-support/order.ts | 1 + .../projection/test/package.smoke.test.ts | 1 + packages/core/graph/schema/README.md | 10 ++- packages/core/graph/schema/src/artefacts.ts | 30 +++++++ .../graph/schema/src/artefacts.unit.test.ts | 82 ++++++++++++++++++ .../schema/src/compile-fail.unit.test.ts | 56 ++++++++++++ .../core/graph/schema/src/declarations.ts | 13 +++ packages/core/graph/schema/src/ids.ts | 2 + packages/core/graph/schema/src/index.ts | 2 + packages/core/graph/schema/src/model.ts | 6 ++ .../core/graph/schema/src/parse.unit.test.ts | 23 +++++ packages/core/graph/schema/src/schema.ts | 10 +++ .../graph/schema/src/test-support/order.ts | 1 + packages/core/graph/schema/src/validate.ts | 28 +++++- .../graph/schema/src/validate.unit.test.ts | 85 +++++++++++++++++++ .../graph/schema/test/package.smoke.test.ts | 1 + 16 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 packages/core/graph/schema/src/artefacts.ts create mode 100644 packages/core/graph/schema/src/artefacts.unit.test.ts diff --git a/packages/core/graph/projection/src/test-support/order.ts b/packages/core/graph/projection/src/test-support/order.ts index dfd69f7..b5eb9d1 100644 --- a/packages/core/graph/projection/src/test-support/order.ts +++ b/packages/core/graph/projection/src/test-support/order.ts @@ -18,6 +18,7 @@ export const order = defineModel({ { id: "act-pack", name: "pack and ship" }, { id: "act-cancel", name: "cancel order and notify" }, ], + artefacts: [], elements: [ { kind: "pseudo", id: "start", role: "initial" }, { diff --git a/packages/core/graph/projection/test/package.smoke.test.ts b/packages/core/graph/projection/test/package.smoke.test.ts index e70cbb1..667bde7 100644 --- a/packages/core/graph/projection/test/package.smoke.test.ts +++ b/packages/core/graph/projection/test/package.smoke.test.ts @@ -11,6 +11,7 @@ test("the built package projects a model", () => { ], events: [], actions: [], + artefacts: [], elements: [ { kind: "activity", id: "submit", label: "Submit", actor: "customer" }, { kind: "activity", id: "pack", label: "Pack", actor: "shop" }, diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 7e2c740..23f54e9 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -48,12 +48,20 @@ An activity's actor and a message's ends must be actors; a state machine project ## 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`. 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. +`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` and `multiple-producers`. 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. +## 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. + ## 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 }`. 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 index 8e928cd..f66c98d 100644 --- a/packages/core/graph/schema/src/compile-fail.unit.test.ts +++ b/packages/core/graph/schema/src/compile-fail.unit.test.ts @@ -9,6 +9,7 @@ describe("defineModel reference checking", () => { participants: [], events: [], actions: [], + artefacts: [], elements: [{ kind: "pseudo", id: "start", role: "initial" }], transitions: [ // @ts-expect-error "nowhere" is not a declared element @@ -24,6 +25,7 @@ describe("defineModel reference checking", () => { 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" }, @@ -57,6 +59,7 @@ describe("defineModel reference checking", () => { participants: [], events: [], actions: [], + artefacts: [], elements: [ { kind: "pseudo", id: "s", role: "initial" }, { kind: "pseudo", id: "e", role: "final" }, @@ -75,6 +78,7 @@ describe("defineModel reference checking", () => { participants: [], events: [], actions: [], + artefacts: [], elements: [], transitions: [], fragments: [], @@ -90,6 +94,7 @@ describe("defineModel reference checking", () => { participants: [], events: [], actions: [], + artefacts: [], elements: [{ kind: "pseudo", id: "s", role: "initial" }], transitions: [], fragments: [ @@ -112,6 +117,7 @@ describe("defineModel reference checking", () => { ], 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" }, @@ -130,6 +136,7 @@ describe("defineModel reference checking", () => { ], events: [], actions: [], + artefacts: [], elements: [ { kind: "message", @@ -155,6 +162,7 @@ describe("defineModel reference checking", () => { ], events: [], actions: [], + artefacts: [], elements: [], transitions: [], fragments: [], @@ -164,4 +172,52 @@ describe("defineModel reference checking", () => { ], }); }); + + 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: [], + }); + }); }); diff --git a/packages/core/graph/schema/src/declarations.ts b/packages/core/graph/schema/src/declarations.ts index d12eaef..3b61d86 100644 --- a/packages/core/graph/schema/src/declarations.ts +++ b/packages/core/graph/schema/src/declarations.ts @@ -41,6 +41,15 @@ export interface ActionDef { readonly name: string; } +/** + * 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"; @@ -52,6 +61,10 @@ export type Element = * 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; + /** 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"; diff --git a/packages/core/graph/schema/src/ids.ts b/packages/core/graph/schema/src/ids.ts index 12279dc..7f28c0d 100644 --- a/packages/core/graph/schema/src/ids.ts +++ b/packages/core/graph/schema/src/ids.ts @@ -10,6 +10,8 @@ export interface IdSpace { 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. */ diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index 63b17e3..f615131 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -15,6 +15,7 @@ export type { } from "./vocabulary.ts"; export type { ActionDef, + ArtefactDef, Element, EventDef, Fragment, @@ -25,6 +26,7 @@ export type { 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 { modelSchema } from "./schema.ts"; diff --git a/packages/core/graph/schema/src/model.ts b/packages/core/graph/schema/src/model.ts index 515b4d1..737a6cc 100644 --- a/packages/core/graph/schema/src/model.ts +++ b/packages/core/graph/schema/src/model.ts @@ -1,6 +1,7 @@ import type { AnyParticipants, ActionDef, + ArtefactDef, Element, EventDef, Fragment, @@ -20,6 +21,7 @@ export interface ModelParts< 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[]; @@ -41,6 +43,7 @@ export const defineModel = < const El extends string, const Tr extends string, const Fr extends string, + const Ar extends string, >( model: ModelParts< { @@ -52,6 +55,7 @@ export const defineModel = < readonly element: El; readonly transition: Tr; readonly fragment: Fr; + readonly artefact: Ar; }, { readonly participant: NoInfer; @@ -62,6 +66,7 @@ export const defineModel = < readonly element: NoInfer; readonly transition: NoInfer; readonly fragment: NoInfer; + readonly artefact: NoInfer; }, Ps >, @@ -74,4 +79,5 @@ export const defineModel = < readonly element: El; readonly transition: Tr; readonly fragment: Fr; + readonly artefact: Ar; }> => model; diff --git a/packages/core/graph/schema/src/parse.unit.test.ts b/packages/core/graph/schema/src/parse.unit.test.ts index 2877e1c..e612433 100644 --- a/packages/core/graph/schema/src/parse.unit.test.ts +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -119,6 +119,29 @@ describe("parseModel", () => { 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("names every problem in the error message", () => { const error = new ModelParseError([ { source: "schema", pointer: "/a", message: "bad" }, diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts index 3aef67a..2b9cdf9 100644 --- a/packages/core/graph/schema/src/schema.ts +++ b/packages/core/graph/schema/src/schema.ts @@ -18,6 +18,7 @@ const eventRef = z.string(); const actionRef = z.string(); const transitionRef = z.string(); const entityRef = z.string(); +const artefactRef = z.string(); const participantSchema = record({ id, @@ -33,6 +34,12 @@ const eventSchema = record({ 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"), @@ -41,6 +48,8 @@ const elementSchema = z.discriminatedUnion("kind", [ actor: actorRef.exactOptional(), action: actionRef.exactOptional(), source: z.string().exactOptional(), + consumes: z.array(artefactRef).readonly().exactOptional(), + produces: z.array(artefactRef).readonly().exactOptional(), }), record({ kind: z.literal("decision"), @@ -128,6 +137,7 @@ 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(), diff --git a/packages/core/graph/schema/src/test-support/order.ts b/packages/core/graph/schema/src/test-support/order.ts index 0f1be8f..428c586 100644 --- a/packages/core/graph/schema/src/test-support/order.ts +++ b/packages/core/graph/schema/src/test-support/order.ts @@ -18,6 +18,7 @@ export const order = defineModel({ { id: "act-pack", name: "pack and ship" }, { id: "act-cancel", name: "cancel order and notify" }, ], + artefacts: [], elements: [ { kind: "pseudo", id: "start", role: "initial" }, { diff --git a/packages/core/graph/schema/src/validate.ts b/packages/core/graph/schema/src/validate.ts index 6d632d3..deac7bb 100644 --- a/packages/core/graph/schema/src/validate.ts +++ b/packages/core/graph/schema/src/validate.ts @@ -8,7 +8,8 @@ export type IssueCode = | "multiple-initial" | "subject-not-entity" | "actor-is-entity" - | "duplicate-source"; + | "duplicate-source" + | "multiple-producers"; export interface Issue { readonly code: IssueCode; @@ -40,12 +41,14 @@ export const validate = ( 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)], @@ -92,6 +95,12 @@ export const validate = ( case "activity": requireActor(element.actor, `activity "${element.id}" actor`); require(actionIds, element.action, `activity "${element.id}" action`); + 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`); @@ -128,6 +137,23 @@ export const validate = ( } } + 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] diff --git a/packages/core/graph/schema/src/validate.unit.test.ts b/packages/core/graph/schema/src/validate.unit.test.ts index c357c41..345f379 100644 --- a/packages/core/graph/schema/src/validate.unit.test.ts +++ b/packages/core/graph/schema/src/validate.unit.test.ts @@ -11,6 +11,7 @@ const open = (overrides: Partial>): Model => ({ ], events: [], actions: [], + artefacts: [], elements: [ { kind: "pseudo", id: "s", role: "initial" }, { kind: "pseudo", id: "e", role: "final" }, @@ -196,4 +197,88 @@ describe("validate", () => { 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"' }, + ]); + }); }); diff --git a/packages/core/graph/schema/test/package.smoke.test.ts b/packages/core/graph/schema/test/package.smoke.test.ts index 75fad8c..f08853c 100644 --- a/packages/core/graph/schema/test/package.smoke.test.ts +++ b/packages/core/graph/schema/test/package.smoke.test.ts @@ -7,6 +7,7 @@ test("the built package defines and validates a model", () => { 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" }, From 03b172e44d6de1cccbad0d443ce1f33fe8cfaac0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:03:56 +0100 Subject: [PATCH 06/14] feat(schema): type a transition's condition as a trilean predicate tree A transition may carry an evaluable condition beside its human-readable guard, typed as trilean's PredicateNode so a decision can resolve to true, false or unknown. The open schema embeds trilean's own schema, so a malformed condition is located by JSON pointer when a model is loaded. trilean-regex, a dependency of trilean published by the same organisation, joins the install-age exclusions with the other own packages. --- packages/core/graph/schema/README.md | 4 ++ packages/core/graph/schema/package.json | 1 + .../core/graph/schema/src/declarations.ts | 6 ++ .../core/graph/schema/src/parse.unit.test.ts | 56 +++++++++++++++++++ packages/core/graph/schema/src/schema.ts | 2 + pnpm-lock.yaml | 18 ++++++ pnpm-workspace.yaml | 1 + 7 files changed, 88 insertions(+) diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 23f54e9..8cd5b18 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -62,6 +62,10 @@ Control flow says what follows what; artefacts say what a step needs and makes. - 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 }`. diff --git a/packages/core/graph/schema/package.json b/packages/core/graph/schema/package.json index 3b9e7a3..9be5d39 100644 --- a/packages/core/graph/schema/package.json +++ b/packages/core/graph/schema/package.json @@ -70,6 +70,7 @@ "vitest": "4.1.11" }, "dependencies": { + "trilean": "1.7.2", "zod": "4.6.5" } } diff --git a/packages/core/graph/schema/src/declarations.ts b/packages/core/graph/schema/src/declarations.ts index 3b61d86..39c0212 100644 --- a/packages/core/graph/schema/src/declarations.ts +++ b/packages/core/graph/schema/src/declarations.ts @@ -1,3 +1,4 @@ +import type { PredicateNode } from "trilean"; import type { IdSpace } from "./ids.ts"; import type { FragmentOperator, @@ -101,7 +102,12 @@ export interface Transition { 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"]; } diff --git a/packages/core/graph/schema/src/parse.unit.test.ts b/packages/core/graph/schema/src/parse.unit.test.ts index e612433..38c39d9 100644 --- a/packages/core/graph/schema/src/parse.unit.test.ts +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -142,6 +142,62 @@ describe("parseModel", () => { 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("names every problem in the error message", () => { const error = new ModelParseError([ { source: "schema", pointer: "/a", message: "bad" }, diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts index 2b9cdf9..32cc7ff 100644 --- a/packages/core/graph/schema/src/schema.ts +++ b/packages/core/graph/schema/src/schema.ts @@ -1,3 +1,4 @@ +import { PredicateNodeSchema } from "trilean"; import { z } from "zod"; import { fragmentOperators, @@ -88,6 +89,7 @@ const transitionSchema = record({ target: elementRef, trigger: eventRef.exactOptional(), guard: z.string().exactOptional(), + condition: PredicateNodeSchema.exactOptional(), effect: actionRef.exactOptional(), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5960a2a..9cf9bca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: packages/core/graph/schema: dependencies: + trilean: + specifier: 1.7.2 + version: 1.7.2 zod: specifier: 4.6.5 version: 4.6.5 @@ -3757,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'} @@ -7664,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 From e160aee12f0a6594a107f2a9b5b0406b8969efeb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:04:44 +0100 Subject: [PATCH 07/14] feat(schema): let an activity declare the entities it affects An activity may list the entities whose lifecycle it changes. The list 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 as affects-not-entity. --- packages/core/graph/schema/README.md | 6 ++- .../schema/src/compile-fail.unit.test.ts | 54 ++++++++++++++++++- .../core/graph/schema/src/declarations.ts | 4 ++ .../core/graph/schema/src/parse.unit.test.ts | 21 ++++++++ packages/core/graph/schema/src/schema.ts | 1 + packages/core/graph/schema/src/validate.ts | 13 ++++- .../graph/schema/src/validate.unit.test.ts | 27 ++++++++++ 7 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 8cd5b18..6f354cb 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -48,12 +48,16 @@ An activity's actor and a message's ends must be actors; a state machine project ## 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` and `multiple-producers`. 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. +`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. diff --git a/packages/core/graph/schema/src/compile-fail.unit.test.ts b/packages/core/graph/schema/src/compile-fail.unit.test.ts index f66c98d..0765624 100644 --- a/packages/core/graph/schema/src/compile-fail.unit.test.ts +++ b/packages/core/graph/schema/src/compile-fail.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +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. */ @@ -220,4 +220,56 @@ describe("defineModel reference checking", () => { 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 index 39c0212..266ecf0 100644 --- a/packages/core/graph/schema/src/declarations.ts +++ b/packages/core/graph/schema/src/declarations.ts @@ -62,6 +62,10 @@ export type Element = * 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. */ diff --git a/packages/core/graph/schema/src/parse.unit.test.ts b/packages/core/graph/schema/src/parse.unit.test.ts index 38c39d9..8aa0b97 100644 --- a/packages/core/graph/schema/src/parse.unit.test.ts +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -198,6 +198,27 @@ describe("parseModel", () => { }); }); + 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("names every problem in the error message", () => { const error = new ModelParseError([ { source: "schema", pointer: "/a", message: "bad" }, diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts index 32cc7ff..bfed02b 100644 --- a/packages/core/graph/schema/src/schema.ts +++ b/packages/core/graph/schema/src/schema.ts @@ -49,6 +49,7 @@ const elementSchema = z.discriminatedUnion("kind", [ 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(), }), diff --git a/packages/core/graph/schema/src/validate.ts b/packages/core/graph/schema/src/validate.ts index deac7bb..6312996 100644 --- a/packages/core/graph/schema/src/validate.ts +++ b/packages/core/graph/schema/src/validate.ts @@ -9,7 +9,8 @@ export type IssueCode = | "subject-not-entity" | "actor-is-entity" | "duplicate-source" - | "multiple-producers"; + | "multiple-producers" + | "affects-not-entity"; export interface Issue { readonly code: IssueCode; @@ -95,6 +96,16 @@ export const validate = ( 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`); } diff --git a/packages/core/graph/schema/src/validate.unit.test.ts b/packages/core/graph/schema/src/validate.unit.test.ts index 345f379..59ab395 100644 --- a/packages/core/graph/schema/src/validate.unit.test.ts +++ b/packages/core/graph/schema/src/validate.unit.test.ts @@ -281,4 +281,31 @@ describe("validate", () => { { 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"', + }, + ]); + }); }); From dde03c0c709026cfe4445080163f96e94542966b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:05:28 +0100 Subject: [PATCH 08/14] feat(schema): add an overlay for run state, apart from the model An Overlay maps element identifiers to done, open or unknown, with a Zod schema whose inferred type is pinned to Overlay and a parseOverlay loader that reports problems by JSON pointer. Run state stays out of the model, the same rule as layout. The JSON pointer encoding is shared with the model loader. --- packages/core/graph/schema/README.md | 4 ++ packages/core/graph/schema/src/index.ts | 8 +++ packages/core/graph/schema/src/overlay.ts | 67 ++++++++++++++++++ .../graph/schema/src/overlay.unit.test.ts | 69 +++++++++++++++++++ packages/core/graph/schema/src/parse.ts | 10 +-- packages/core/graph/schema/src/pointer.ts | 8 +++ packages/core/graph/schema/src/vocabulary.ts | 4 ++ 7 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 packages/core/graph/schema/src/overlay.ts create mode 100644 packages/core/graph/schema/src/overlay.unit.test.ts create mode 100644 packages/core/graph/schema/src/pointer.ts diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 6f354cb..8542a1c 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -83,6 +83,10 @@ The two cover different things on purpose. `z.infer` is exactly `Model`, pinned by a type-level test, so a loaded model is usable anywhere an open-space model is. +## 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. diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index f615131..3c5982f 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -29,5 +29,13 @@ export type { 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/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 index 03740f2..43b891b 100644 --- a/packages/core/graph/schema/src/parse.ts +++ b/packages/core/graph/schema/src/parse.ts @@ -1,5 +1,6 @@ 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"; @@ -40,15 +41,6 @@ export class ModelParseError extends Error { } } -/** Encodes one path as an RFC 6901 JSON pointer, escaping `~` and `/` inside a segment. */ -const toPointer = (path: readonly PropertyKey[]): string => - path - .map( - (segment) => - `/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`, - ) - .join(""); - /** * Loads a model from untrusted input, such as parsed JSON. * 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/vocabulary.ts b/packages/core/graph/schema/src/vocabulary.ts index 38ee827..e739725 100644 --- a/packages/core/graph/schema/src/vocabulary.ts +++ b/packages/core/graph/schema/src/vocabulary.ts @@ -29,6 +29,10 @@ export const fragmentOperators = [ ] 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", From 235043fbb9cb8aa7fd36be8f982f0c26c268b294 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:06:04 +0100 Subject: [PATCH 09/14] feat(schema): carry notation-only extensions on elements Every element may hold a record of JSON values keyed by notation name, for concepts that have no meaning in the model such as an activation bar or a history depth. They load through the open schema unchanged, so a round trip through a view can restore them. --- packages/core/graph/schema/README.md | 4 ++ .../core/graph/schema/src/declarations.ts | 12 ++++- packages/core/graph/schema/src/index.ts | 1 + .../core/graph/schema/src/parse.unit.test.ts | 49 +++++++++++++++++++ packages/core/graph/schema/src/schema.ts | 9 +++- 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index 8542a1c..fa74968 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -83,6 +83,10 @@ The two cover different things on purpose. `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`. diff --git a/packages/core/graph/schema/src/declarations.ts b/packages/core/graph/schema/src/declarations.ts index 266ecf0..fbdda3a 100644 --- a/packages/core/graph/schema/src/declarations.ts +++ b/packages/core/graph/schema/src/declarations.ts @@ -1,4 +1,4 @@ -import type { PredicateNode } from "trilean"; +import type { JsonValue, PredicateNode } from "trilean"; import type { IdSpace } from "./ids.ts"; import type { FragmentOperator, @@ -42,6 +42,11 @@ export interface ActionDef { 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. */ @@ -55,6 +60,7 @@ export type Element = | { readonly kind: "activity"; readonly id: Id; + readonly extensions?: Extensions; readonly label: string; readonly actor?: R["actor"]; readonly action?: R["action"]; @@ -74,12 +80,14 @@ export type Element = | { 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"]; @@ -88,12 +96,14 @@ export type Element = | { 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"]; diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index 3c5982f..31a186f 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -18,6 +18,7 @@ export type { ArtefactDef, Element, EventDef, + Extensions, Fragment, FragmentOperand, Participant, diff --git a/packages/core/graph/schema/src/parse.unit.test.ts b/packages/core/graph/schema/src/parse.unit.test.ts index 8aa0b97..efd7a72 100644 --- a/packages/core/graph/schema/src/parse.unit.test.ts +++ b/packages/core/graph/schema/src/parse.unit.test.ts @@ -219,6 +219,55 @@ describe("parseModel", () => { ).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" }, diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts index bfed02b..f5ef8b7 100644 --- a/packages/core/graph/schema/src/schema.ts +++ b/packages/core/graph/schema/src/schema.ts @@ -1,4 +1,4 @@ -import { PredicateNodeSchema } from "trilean"; +import { JsonValueSchema, PredicateNodeSchema } from "trilean"; import { z } from "zod"; import { fragmentOperators, @@ -21,6 +21,8 @@ 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(), @@ -45,6 +47,7 @@ const elementSchema = z.discriminatedUnion("kind", [ record({ kind: z.literal("activity"), id, + extensions: extensionsSchema.exactOptional(), label: z.string(), actor: actorRef.exactOptional(), action: actionRef.exactOptional(), @@ -56,12 +59,14 @@ const elementSchema = z.discriminatedUnion("kind", [ 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(), @@ -70,12 +75,14 @@ const elementSchema = z.discriminatedUnion("kind", [ 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, From b50a20137e324b815629edfbca0dedf870cccbb8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:07:00 +0100 Subject: [PATCH 10/14] feat(projection): scope a state machine to the activities that affect its subject toStateMachine now keeps the activities whose affects lists the subject, plus authored states and pseudo-states, and folds transitions through the rest the way it folds a decision. A model in which no activity declares affects keeps every activity, so models without that information project as before. --- packages/core/graph/projection/README.md | 6 + .../graph/projection/src/state-machine.ts | 23 ++- .../projection/src/state-machine.unit.test.ts | 143 ++++++++++++++++++ 3 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 packages/core/graph/projection/src/state-machine.unit.test.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 9ce6d69..500b791 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -21,6 +21,12 @@ toSequence(model, ["t1", "t2"]); A transition between two elements with different actors is a hand-off, never stored. Elements without an actor never produce one. +## 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). + ## Lossy directions 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. Branches off the scenario are not drawn. diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index 83c4879..f55faff 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -46,7 +46,9 @@ const stateOf = ( }; /** - * Activity-as-state projection. Each activity 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. + * 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. */ @@ -84,6 +86,16 @@ export const toStateMachine = ( } } + 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, @@ -100,7 +112,7 @@ export const toStateMachine = ( seen: ReadonlySet, ): readonly Arrival[] => { const target = elements.get(transition.target); - if (target?.kind === "decision" && !seen.has(target.id)) { + 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) ?? []) { @@ -116,7 +128,10 @@ export const toStateMachine = ( return arrivals; } - const entryAction = target?.kind === "activity" ? target.action : undefined; + const entryAction = + target?.kind === "activity" && !isTransparent(target) + ? target.action + : undefined; return [ { @@ -131,7 +146,7 @@ export const toStateMachine = ( const states: StateMachineState[] = []; const transitions: StateMachineTransition[] = []; for (const element of model.elements) { - const state = stateOf(element); + const state = isTransparent(element) ? undefined : stateOf(element); if (state === undefined) { continue; } 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"]); + }); +}); From 81a2cbdaf42e028434fdb21cd6310b8f910f6a19 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:07:59 +0100 Subject: [PATCH 11/14] feat(projection): give every declared actor a swimlane lane toSwimlane now emits a lane for each declared role and system in declaration order, empty when nothing is assigned to it, so a participant that has not acted yet is still visible. An actor a runtime model names without declaring keeps its lane after the declared ones. --- packages/core/graph/projection/README.md | 4 ++ .../core/graph/projection/src/swimlane.ts | 30 +++++--- .../projection/src/swimlane.unit.test.ts | 72 +++++++++++++++++++ 3 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 packages/core/graph/projection/src/swimlane.unit.test.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 500b791..6b6c078 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -21,6 +21,10 @@ toSequence(model, ["t1", "t2"]); 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. diff --git a/packages/core/graph/projection/src/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts index b835dfb..f6b57d2 100644 --- a/packages/core/graph/projection/src/swimlane.ts +++ b/packages/core/graph/projection/src/swimlane.ts @@ -3,8 +3,9 @@ import { actorMap } from "./actors.ts"; import { toFlowchart, type Flowchart } from "./flowchart.ts"; import { handoffs, type Handoff } from "./handoffs.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["actor"]; + readonly participant: I["participant"] | I["actor"]; readonly label: string; readonly nodes: readonly I["element"][]; } @@ -16,12 +17,14 @@ export interface Swimlane extends Flowchart { readonly handoffs: readonly Handoff[]; } -/** A flowchart plus partitions: lanes from actors, hand-offs where an edge changes lane. */ +/** + * 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 members = new Map(); const unassigned: I["element"][] = []; for (const node of flow.nodes) { const actor = actors.get(node.id); @@ -38,16 +41,23 @@ export const toSwimlane = (model: Model): Swimlane => { } const lanes: Lane[] = []; - for (const [actor, nodes] of members) { - const declared = model.participants.find( - (participant) => participant.id === actor, - ); + const declared = new Set(); + for (const participant of model.participants) { + if (participant.kind === "entity") { + continue; + } + declared.add(participant.id); lanes.push({ - participant: actor, - label: declared === undefined ? actor : declared.label, - nodes, + 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) }; }; 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"] }, + ]); + }); +}); From 246e41b6ddcce966330750ba7411b38ef84be517 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:09:29 +0100 Subject: [PATCH 12/14] feat(projection): report what each projection dropped as a loss list Every projection result now carries a loss list, a discriminated union on kind: positions for the projection's own notation, notation-only extensions, message elements, authored fragments, the activities left out of a state machine's subject, and the elements and untaken branches off a sequence scenario. A kind is reported only when something was dropped. --- packages/core/graph/projection/README.md | 16 ++- .../core/graph/projection/src/flowchart.ts | 5 +- packages/core/graph/projection/src/index.ts | 1 + packages/core/graph/projection/src/loss.ts | 84 ++++++++++++ .../graph/projection/src/loss.unit.test.ts | 120 ++++++++++++++++++ .../projection/src/projections.unit.test.ts | 11 ++ .../core/graph/projection/src/sequence.ts | 33 ++++- .../graph/projection/src/state-machine.ts | 17 ++- .../core/graph/projection/src/swimlane.ts | 9 +- 9 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 packages/core/graph/projection/src/loss.ts create mode 100644 packages/core/graph/projection/src/loss.unit.test.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 6b6c078..7205dcc 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -31,9 +31,21 @@ A transition between two elements with different actors is a hand-off, never sto 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). -## Lossy directions +## Loss reports -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. Branches off the scenario are not drawn. +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 diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts index 88761b9..ff60bca 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -1,5 +1,6 @@ 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"]; @@ -16,6 +17,8 @@ export interface FlowEdge { export interface Flowchart { readonly nodes: readonly FlowNode[]; readonly edges: readonly FlowEdge[]; + /** What the projection dropped from the model. */ + readonly loss: readonly Loss[]; } const flowNode = ( @@ -76,5 +79,5 @@ export const toFlowchart = ( } } - return { nodes, edges }; + return { nodes, edges, loss: commonLoss(model, "flowchart") }; }; diff --git a/packages/core/graph/projection/src/index.ts b/packages/core/graph/projection/src/index.ts index a1005c0..9adb669 100644 --- a/packages/core/graph/projection/src/index.ts +++ b/packages/core/graph/projection/src/index.ts @@ -1,6 +1,7 @@ /** * 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, 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..3d63929 --- /dev/null +++ b/packages/core/graph/projection/src/loss.unit.test.ts @@ -0,0 +1,120 @@ +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", () => { + expect(toSequence(rich, ["t2"]).loss).toEqual([ + { kind: "layout", elements: ["end"] }, + { kind: "extensions", elements: ["a-place"] }, + { kind: "message-elements", elements: ["m-ack"] }, + { kind: "fragments", fragments: ["f1"] }, + { 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 index e5b7b7e..bac4dc1 100644 --- a/packages/core/graph/projection/src/projections.unit.test.ts +++ b/packages/core/graph/projection/src/projections.unit.test.ts @@ -23,6 +23,7 @@ describe("the order process", () => { 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" }, @@ -61,6 +62,7 @@ describe("the order process", () => { nodes: ["a-authorise"], }, ]); + expect(lanes.loss).toEqual([]); expect(lanes.unassigned).toEqual(["start", "end"]); expect(lanes.handoffs).toEqual(handoffs(order)); }); @@ -69,6 +71,7 @@ describe("the order process", () => { 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" }, @@ -130,6 +133,10 @@ describe("the order process", () => { label: "payment authorised", }, ], + loss: [ + { kind: "scenario", elements: ["a-cancel"] }, + { kind: "unshown-branches", transitions: ["t6"] }, + ], }); }); @@ -137,6 +144,10 @@ describe("the order process", () => { 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", diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts index 05f349f..8303fce 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -1,5 +1,6 @@ import type { 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"]; @@ -13,6 +14,8 @@ export interface SequenceDiagram { /** Actors in order of first appearance. */ readonly lifelines: readonly I["actor"][]; readonly messages: readonly SequenceMessage[]; + /** What the projection dropped from the model. */ + readonly loss: readonly Loss[]; } /** @@ -65,5 +68,33 @@ export const toSequence = ( }); } - return { lifelines, messages }; + 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 loss: Loss[] = [...commonLoss(model, "sequence")]; + 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, loss }; }; diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index f55faff..c294d1c 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -1,5 +1,6 @@ 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"]; @@ -19,6 +20,8 @@ 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 = ( @@ -164,5 +167,17 @@ export const toStateMachine = ( } } - return { subject, states, transitions }; + 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/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts index f6b57d2..b995c0b 100644 --- a/packages/core/graph/projection/src/swimlane.ts +++ b/packages/core/graph/projection/src/swimlane.ts @@ -2,6 +2,7 @@ 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 { @@ -59,5 +60,11 @@ export const toSwimlane = (model: Model): Swimlane => { } } - return { ...flow, lanes, unassigned, handoffs: handoffs(model) }; + return { + ...flow, + lanes, + unassigned, + handoffs: handoffs(model), + loss: commonLoss(model, "swimlane"), + }; }; From f0cbbf9fd100016ae794eed5ad7d1a6d33ff2b00 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:12:29 +0100 Subject: [PATCH 13/14] feat(projection): draw authored fragments in sequence diagrams toSequence now returns items beside the flat messages: each is a message or an authored fragment wrapping the scenario's messages that arrive at its body, with the operands the scenario takes, nested where one fragment's messages lie inside another's operand. A fragment that cannot be drawn without reordering the scenario is left out and reported as lost, never approximated. --- packages/core/graph/projection/README.md | 6 + packages/core/graph/projection/src/index.ts | 2 + .../graph/projection/src/loss.unit.test.ts | 8 +- .../projection/src/projections.unit.test.ts | 38 +++ .../core/graph/projection/src/sequence.ts | 181 +++++++++++- .../projection/src/sequence.unit.test.ts | 278 ++++++++++++++++++ 6 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 packages/core/graph/projection/src/sequence.unit.test.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 7205dcc..e979c29 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -31,6 +31,12 @@ A transition between two elements with different actors is a hand-off, never sto 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: diff --git a/packages/core/graph/projection/src/index.ts b/packages/core/graph/projection/src/index.ts index 9adb669..1970790 100644 --- a/packages/core/graph/projection/src/index.ts +++ b/packages/core/graph/projection/src/index.ts @@ -19,5 +19,7 @@ export { export { toSequence, type SequenceDiagram, + type SequenceItem, type SequenceMessage, + type SequenceOperand, } from "./sequence.ts"; diff --git a/packages/core/graph/projection/src/loss.unit.test.ts b/packages/core/graph/projection/src/loss.unit.test.ts index 3d63929..1882702 100644 --- a/packages/core/graph/projection/src/loss.unit.test.ts +++ b/packages/core/graph/projection/src/loss.unit.test.ts @@ -92,12 +92,14 @@ describe("loss reports", () => { ]); }); - it("reports what a sequence scenario leaves out", () => { - expect(toSequence(rich, ["t2"]).loss).toEqual([ + 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: "fragments", fragments: ["f1"] }, { kind: "scenario", elements: ["start", "m-ack", "end"] }, { kind: "unshown-branches", transitions: ["t3"] }, ]); diff --git a/packages/core/graph/projection/src/projections.unit.test.ts b/packages/core/graph/projection/src/projections.unit.test.ts index bac4dc1..2ce1ca0 100644 --- a/packages/core/graph/projection/src/projections.unit.test.ts +++ b/packages/core/graph/projection/src/projections.unit.test.ts @@ -133,6 +133,44 @@ describe("the order process", () => { 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"] }, diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts index 8303fce..a403f9a 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -1,4 +1,10 @@ -import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; +import type { + Fragment, + FragmentOperator, + IdSpace, + Model, + Transition, +} from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; import { commonLoss, type Loss } from "./loss.ts"; @@ -10,16 +16,174 @@ export interface SequenceMessage { 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, @@ -41,6 +205,7 @@ export const toSequence = ( const lifelines: I["actor"][] = []; const messages: SequenceMessage[] = []; + const entries: Entry[] = []; for (const id of scenario) { const transition = transitions.get(id); if (transition === undefined) { @@ -60,12 +225,18 @@ export const toSequence = ( transition.trigger === undefined ? undefined : eventNames.get(transition.trigger); - messages.push({ + 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(); @@ -82,7 +253,9 @@ export const toSequence = ( (transition) => !taken.has(transition.id) && onPath.has(transition.source), ); - const loss: Loss[] = [...commonLoss(model, "sequence")]; + const { items, drawn } = wrapFragments(model.fragments, entries, onPath); + + const loss: Loss[] = [...commonLoss(model, "sequence", drawn)]; if (offPath.length > 0) { loss.push({ kind: "scenario", @@ -96,5 +269,5 @@ export const toSequence = ( }); } - return { lifelines, messages, loss }; + 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"], + }); + }); +}); From 35f6f238ff47e3517ca8eac13931edb33a6b664e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:12:48 +0100 Subject: [PATCH 14/14] refactor(projection): reuse the shared actor map for hand-offs handoffs built the same element-to-actor map the other projections take from actorMap. --- packages/core/graph/projection/src/handoffs.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/core/graph/projection/src/handoffs.ts b/packages/core/graph/projection/src/handoffs.ts index 6e048e3..5cb5536 100644 --- a/packages/core/graph/projection/src/handoffs.ts +++ b/packages/core/graph/projection/src/handoffs.ts @@ -1,4 +1,5 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.ts"; export interface Handoff { readonly transition: I["transition"]; @@ -12,15 +13,7 @@ export interface Handoff { export const handoffs = ( model: Model, ): readonly Handoff[] => { - const actorOf = new Map(); - for (const element of model.elements) { - if ( - (element.kind === "activity" || element.kind === "decision") && - element.actor !== undefined - ) { - actorOf.set(element.id, element.actor); - } - } + const actorOf = actorMap(model); const found: Handoff[] = []; for (const transition of model.transitions) {