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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config

## Packages

| Package | What it is |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. |
| Package | What it is |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. |
| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. |
| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. |

## Conventions

Expand Down
1 change: 1 addition & 0 deletions packages/core/graph/projection/AGENTS.md
1 change: 1 addition & 0 deletions packages/core/graph/projection/CLAUDE.md
63 changes: 63 additions & 0 deletions packages/core/graph/projection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# @exadev/process-graph-projection

Projections of the process model from `@exadev/process-graph-schema` into each notation. Every projection is a pure function of the model plus the extra input its notation needs, and none of them reads or preserves layout. `src/` imports nothing Node-only.

| Function | Extra input | Result |
| -------------------------------- | ------------------------ | --------------------------------------------------------------- |
| `handoffs(model)` | none | Transitions between elements whose actors differ. |
| `toFlowchart(model)` | none | Nodes (process, decision, terminator, bar) and guarded edges. |
| `toSwimlane(model)` | none | The flowchart plus lanes by actor, unassigned nodes, hand-offs. |
| `toStateMachine(model, subject)` | a subject entity | States and transitions with decisions folded into guards. |
| `toSequence(model, scenario)` | a path of transition ids | Lifelines in order of first appearance and messages. |

```ts
import { handoffs, toSequence } from "@exadev/process-graph-projection";

handoffs(model); // [{ transition: "t2", from: "customer", to: "shop" }]
toSequence(model, ["t1", "t2"]);
```

## Hand-offs are derived

A transition between two elements with different actors is a hand-off, never stored. Elements without an actor never produce one.

## Swimlane lanes

`toSwimlane` has a lane for every declared role or system, in declaration order, with empty `nodes` when nothing is assigned to it, so a participant that has not started yet is still visible. Entities are not actors and get no lane. An actor that a runtime model names without declaring still gets a lane, after the declared ones, and `validate` reports it.

## State machine scope

`toStateMachine(model, subject)` keeps the activities whose `affects` lists the subject, plus authored `state` elements and pseudo-states. A step that does not affect the subject is not one of its states: transitions through it are folded into the transition leaving the previous state, the way a decision is, so one model yields a different machine per subject.

When no activity in the model declares `affects`, the model carries no such information and every activity is kept. Once any activity declares it, an activity without `affects` is not kept (an empty list is a declaration that it affects nothing).

## Sequence fragments

`toSequence` returns `messages`, the flat list in scenario order, and `items`, the same messages with the authored `fragments` wrapped around the ones that fall inside them. An item is a `message` or a `fragment` carrying its `operator` and the `operands` the scenario takes, each with its guard and its own items. Fragments are never invented: every one drawn is authored.

A message belongs to an operand when it arrives at an element in that operand's body. A fragment is drawn when at least one operand has a body wholly on the scenario path and receives a message, and its messages are consecutive in the scenario. An operand the scenario does not take is not drawn (an `alt` shows the branch taken, and the other branch is among the `unshown-branches`). A fragment nests inside another when all its messages lie within one of that fragment's operands. One that cannot be drawn faithfully, being interleaved with messages outside it, off the path or overlapping another without nesting, is left out and reported as a `fragments` loss.

## Loss reports

Every projection result carries `loss`, what it did not carry over from the model. Each `Loss` is a discriminated union on `kind`, reported only when something was actually dropped, in this order:

| `kind` | Dropped | Reported by |
| ------------------ | ------------------------------------------------------------------------------- | ---------------- |
| `layout` | Positions recorded in `views` for this projection's own notation. | every projection |
| `extensions` | Notation-only `extensions` on elements, which no projection shows. | every projection |
| `message-elements` | `message` elements, which no projection draws. | every projection |
| `fragments` | Authored fragments the projection does not draw. | every projection |
| `subject` | Activities left out because they do not affect the state machine's subject. | `toStateMachine` |
| `scenario` | Elements of the model that are not on the scenario. | `toSequence` |
| `unshown-branches` | Transitions leaving an element on the scenario that the scenario does not take. | `toSequence` |

A flowchart drops ownership, a state machine needs its subject recorded, and a sequence diagram follows one path, so one model yields a different diagram per scenario.

## Commands

```sh
pnpm build # tsc -b -> dist/ (ESM + .d.ts)
pnpm test # unit tests
pnpm test:smoke # the built dist/ exercised as a real artefact
pnpm test:mutation # Stryker
```
9 changes: 9 additions & 0 deletions packages/core/graph/projection/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
74 changes: 74 additions & 0 deletions packages/core/graph/projection/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
19 changes: 19 additions & 0 deletions packages/core/graph/projection/src/actors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Element, IdSpace, Model } from "@exadev/process-graph-schema";

export type ElementOf<I extends IdSpace> = Element<I["element"], I>;

export const actorMap = <I extends IdSpace>(
model: Model<I>,
): ReadonlyMap<I["element"], I["actor"]> => {
const actors = new Map<I["element"], I["actor"]>();
for (const element of model.elements) {
if (
(element.kind === "activity" || element.kind === "decision") &&
element.actor !== undefined
) {
actors.set(element.id, element.actor);
}
}

return actors;
};
25 changes: 25 additions & 0 deletions packages/core/graph/projection/src/compile-fail.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
83 changes: 83 additions & 0 deletions packages/core/graph/projection/src/flowchart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { IdSpace, Model } from "@exadev/process-graph-schema";
import type { ElementOf } from "./actors.ts";
import { commonLoss, type Loss } from "./loss.ts";

export interface FlowNode<I extends IdSpace> {
readonly id: I["element"];
readonly label: string;
readonly shape: "terminator" | "process" | "decision" | "bar";
}

export interface FlowEdge<I extends IdSpace> {
readonly from: I["element"];
readonly to: I["element"];
readonly label?: string;
}

export interface Flowchart<I extends IdSpace> {
readonly nodes: readonly FlowNode<I>[];
readonly edges: readonly FlowEdge<I>[];
/** What the projection dropped from the model. */
readonly loss: readonly Loss<I>[];
}

const flowNode = <I extends IdSpace>(
element: ElementOf<I>,
): FlowNode<I> | 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 = <I extends IdSpace>(
model: Model<I>,
): Flowchart<I> => {
const nodes: FlowNode<I>[] = [];
for (const element of model.elements) {
const node = flowNode<I>(element);
if (node !== undefined) {
nodes.push(node);
}
}

const included = new Set<I["element"]>(nodes.map((node) => node.id));
const edges: FlowEdge<I>[] = [];
for (const transition of model.transitions) {
if (included.has(transition.source) && included.has(transition.target)) {
edges.push(
transition.guard === undefined
? { from: transition.source, to: transition.target }
: {
from: transition.source,
to: transition.target,
label: transition.guard,
},
);
}
}

return { nodes, edges, loss: commonLoss(model, "flowchart") };
};
28 changes: 28 additions & 0 deletions packages/core/graph/projection/src/handoffs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { IdSpace, Model } from "@exadev/process-graph-schema";
import { actorMap } from "./actors.ts";

export interface Handoff<I extends IdSpace> {
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 = <I extends IdSpace>(
model: Model<I>,
): readonly Handoff<I>[] => {
const actorOf = actorMap(model);

const found: Handoff<I>[] = [];
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;
};
25 changes: 25 additions & 0 deletions packages/core/graph/projection/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Projections of the process model into each notation: hand-offs, flowchart, swimlane, state machine and sequence diagram. Each is a pure function of the model plus the extra input its notation needs (none, a subject entity, or a scenario of transition ids).
*/
export type { Loss } from "./loss.ts";
export { handoffs, type Handoff } from "./handoffs.ts";
export {
toFlowchart,
type Flowchart,
type FlowEdge,
type FlowNode,
} from "./flowchart.ts";
export { toSwimlane, type Lane, type Swimlane } from "./swimlane.ts";
export {
toStateMachine,
type StateMachine,
type StateMachineState,
type StateMachineTransition,
} from "./state-machine.ts";
export {
toSequence,
type SequenceDiagram,
type SequenceItem,
type SequenceMessage,
type SequenceOperand,
} from "./sequence.ts";
Loading
Loading