From 8c622469417f14a908475225c64af2266be89b37 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:31:26 +0100 Subject: [PATCH 1/3] fix(projection): report elements and steps a projection drops toFlowchart and toSwimlane silently left out state elements and history pseudo-states, and toStateMachine left out fork, join and history pseudo-states while still emitting transitions that pointed at them. A sequence scenario step touching an element with no actor vanished without trace. Add the unrepresented-elements loss kind, carrying each element with what it was, the notation that cannot draw it and the transitions left out because of it, and the skipped-steps loss kind for scenario steps that draw no message. The state machine no longer emits a transition whose target is not one of its states. Decisions folded into guards remain by design and are not a loss. --- packages/core/graph/projection/README.md | 24 +-- .../core/graph/projection/src/flowchart.ts | 27 +++- packages/core/graph/projection/src/loss.ts | 67 ++++++++- .../graph/projection/src/loss.unit.test.ts | 141 ++++++++++++++++++ .../projection/src/projections.unit.test.ts | 2 + .../core/graph/projection/src/sequence.ts | 8 +- .../graph/projection/src/state-machine.ts | 31 +++- .../core/graph/projection/src/swimlane.ts | 5 +- 8 files changed, 280 insertions(+), 25 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index e979c29..f431d39 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -41,17 +41,19 @@ A message belongs to an operand when it arrives at an element in that operand's 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. +| `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 | +| `unrepresented-elements` | Elements the notation has no way to draw (`state`, `history`, and in a state machine `fork` and `join`), with the transitions touching them. | `toFlowchart`, `toSwimlane`, `toStateMachine` | +| `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` | +| `skipped-steps` | Scenario steps that touch an element with no actor and so draw no message. | `toSequence` | + +Decisions a state machine folds into guards are by design and not a loss. 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 ff60bca..83f5812 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -1,6 +1,6 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import type { ElementOf } from "./actors.ts"; -import { commonLoss, type Loss } from "./loss.ts"; +import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface FlowNode { readonly id: I["element"]; @@ -51,7 +51,28 @@ const flowNode = ( return undefined; }; -/** Activities, decisions and terminators, joined by the transitions between them. */ +/** + * What a flowchart-shaped projection drops: the common losses, then the elements the flowchart has no node for (`state` elements and `history` pseudo-states) and the transitions that touch them. + * @param model - The model projected. + * @param notation - The notation the result is for, `flowchart` or `swimlane`. + * @param nodes - The nodes the projection drew. + */ +export const flowchartLoss = ( + model: Model, + notation: "flowchart" | "swimlane", + nodes: readonly FlowNode[], +): readonly Loss[] => [ + ...commonLoss(model, notation), + ...unrepresentedLoss( + model, + notation, + new Set(nodes.map((node) => node.id)), + ), +]; + +/** + * Activities, decisions and terminators, joined by the transitions between them. `state` elements and `history` pseudo-states have no flowchart shape and are reported as unrepresented, with the transitions that touch them. + */ export const toFlowchart = ( model: Model, ): Flowchart => { @@ -79,5 +100,5 @@ export const toFlowchart = ( } } - return { nodes, edges, loss: commonLoss(model, "flowchart") }; + return { nodes, edges, loss: flowchartLoss(model, "flowchart", nodes) }; }; diff --git a/packages/core/graph/projection/src/loss.ts b/packages/core/graph/projection/src/loss.ts index 0242e67..d8fdbdc 100644 --- a/packages/core/graph/projection/src/loss.ts +++ b/packages/core/graph/projection/src/loss.ts @@ -1,7 +1,21 @@ -import type { IdSpace, Model, Notation } from "@exadev/process-graph-schema"; +import type { + IdSpace, + Model, + Notation, + PseudoRole, +} from "@exadev/process-graph-schema"; + +/** What an element that a notation cannot draw is: an element kind, or the role of a pseudo-state. */ +export type UnrepresentedKind = "activity" | "decision" | "state" | PseudoRole; + +/** An element a projection could not draw, and what it was. */ +export interface UnrepresentedElement { + readonly element: I["element"]; + readonly kind: UnrepresentedKind; +} /** - * 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. + * 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 (unrepresented elements, subject, scenario, unshown branches, skipped steps). 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. */ @@ -18,6 +32,13 @@ export type Loss = readonly kind: "fragments"; readonly fragments: readonly I["fragment"][]; } + /** Elements, other than messages, that the notation has no way to draw, and the transitions left out because one end of them is such an element. */ + | { + readonly kind: "unrepresented-elements"; + readonly notation: Notation; + readonly elements: readonly UnrepresentedElement[]; + readonly transitions: readonly I["transition"][]; + } /** Activities left out of a state machine because they do not affect its subject. */ | { readonly kind: "subject"; @@ -30,8 +51,50 @@ export type Loss = | { readonly kind: "unshown-branches"; readonly transitions: readonly I["transition"][]; + } + /** Scenario steps that produced no message because an end of the transition has no actor. */ + | { + readonly kind: "skipped-steps"; + readonly transitions: readonly I["transition"][]; }; +/** + * The elements the notation cannot draw: every element that is neither in `accounted` (drawn, or folded away by design, as a decision is in a state machine) nor a `message`, which is reported as `message-elements` by every projection. + * @param model - The model being projected. + * @param notation - The notation that cannot draw them. + * @param accounted - Elements the projection draws or folds away by design. + * @returns A single `unrepresented-elements` loss, or none when every element is accounted for. + */ +export const unrepresentedLoss = ( + model: Model, + notation: Notation, + accounted: ReadonlySet, +): readonly Loss[] => { + const elements: UnrepresentedElement[] = []; + for (const element of model.elements) { + if (element.kind === "message" || accounted.has(element.id)) { + continue; + } + elements.push({ + element: element.id, + kind: element.kind === "pseudo" ? element.role : element.kind, + }); + } + if (elements.length === 0) { + return []; + } + + const lost = new Set(elements.map((entry) => entry.element)); + const transitions = model.transitions + .filter( + (transition) => + lost.has(transition.source) || lost.has(transition.target), + ) + .map((transition) => transition.id); + + return [{ kind: "unrepresented-elements", notation, elements, transitions }]; +}; + /** The losses every projection can have, whatever its notation: dropped layout, notation-only extensions, message elements and authored fragments. */ export const commonLoss = ( model: Model, diff --git a/packages/core/graph/projection/src/loss.unit.test.ts b/packages/core/graph/projection/src/loss.unit.test.ts index 1882702..6442fb8 100644 --- a/packages/core/graph/projection/src/loss.unit.test.ts +++ b/packages/core/graph/projection/src/loss.unit.test.ts @@ -119,4 +119,145 @@ describe("loss reports", () => { expect(toFlowchart(bare).loss).toEqual([]); }); + + describe("elements a notation cannot draw", () => { + /** A `state` and a `history` pseudo-state, which no flowchart shape exists for, plus a fork that a flowchart draws and a state machine does not. */ + const stateful = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { kind: "activity", id: "a-pay", label: "Pay", actor: "customer" }, + { kind: "state", id: "s-paid", label: "Paid" }, + { kind: "pseudo", id: "h", role: "history" }, + { kind: "pseudo", id: "fork", role: "fork" }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-pay" }, + { id: "t2", source: "a-pay", target: "s-paid" }, + { id: "t3", source: "s-paid", target: "h" }, + { id: "t4", source: "a-pay", target: "fork" }, + { id: "t5", source: "fork", target: "end" }, + ], + fragments: [], + projections: [], + }); + + it("reports the state and the history pseudo-state a flowchart drops, with their transitions", () => { + const flow = toFlowchart(stateful); + + expect(flow.nodes.map((node) => node.id)).toEqual([ + "start", + "a-pay", + "fork", + "end", + ]); + expect(flow.loss).toEqual([ + { + kind: "unrepresented-elements", + notation: "flowchart", + elements: [ + { element: "s-paid", kind: "state" }, + { element: "h", kind: "history" }, + ], + transitions: ["t2", "t3"], + }, + ]); + }); + + it("reports the same drop for a swimlane under its own notation", () => { + expect(toSwimlane(stateful).loss).toEqual([ + { + kind: "unrepresented-elements", + notation: "swimlane", + elements: [ + { element: "s-paid", kind: "state" }, + { element: "h", kind: "history" }, + ], + transitions: ["t2", "t3"], + }, + ]); + }); + + it("reports the fork and history a state machine has no state for, and leaves no transition pointing at them", () => { + const machine = toStateMachine(stateful, "order"); + + expect(machine.states.map((state) => state.id)).toEqual([ + "start", + "a-pay", + "s-paid", + "end", + ]); + expect(machine.transitions.map((t) => [t.source, t.target])).toEqual([ + ["start", "a-pay"], + ["a-pay", "s-paid"], + ]); + expect(machine.loss).toEqual([ + { + kind: "unrepresented-elements", + notation: "stateMachine", + elements: [ + { element: "h", kind: "history" }, + { element: "fork", kind: "fork" }, + ], + transitions: ["t3", "t4", "t5"], + }, + ]); + }); + + it("does not report a decision a state machine folds into guards", () => { + const decided = defineModel({ + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "decision", id: "d", label: "D" }, + { kind: "activity", id: "b", label: "B" }, + ], + transitions: [ + { id: "t1", source: "a", target: "d" }, + { id: "t2", source: "d", target: "b", guard: "yes" }, + ], + fragments: [], + projections: [], + }); + + expect(toStateMachine(decided, "order").loss).toEqual([]); + }); + }); + + it("reports scenario steps that touch an element with no actor as skipped", () => { + const partial = 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", label: "A", actor: "customer" }, + { kind: "activity", id: "b", label: "B", actor: "shop" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a" }, + { id: "t2", source: "a", target: "b" }, + ], + fragments: [], + projections: [], + }); + + expect(toSequence(partial, ["t1", "t2"]).loss).toEqual([ + { kind: "skipped-steps", transitions: ["t1"] }, + ]); + }); }); diff --git a/packages/core/graph/projection/src/projections.unit.test.ts b/packages/core/graph/projection/src/projections.unit.test.ts index 2ce1ca0..d12d464 100644 --- a/packages/core/graph/projection/src/projections.unit.test.ts +++ b/packages/core/graph/projection/src/projections.unit.test.ts @@ -174,6 +174,7 @@ describe("the order process", () => { loss: [ { kind: "scenario", elements: ["a-cancel"] }, { kind: "unshown-branches", transitions: ["t6"] }, + { kind: "skipped-steps", transitions: ["t1", "t7"] }, ], }); }); @@ -185,6 +186,7 @@ describe("the order process", () => { expect(diagram.loss).toEqual([ { kind: "scenario", elements: ["a-pack"] }, { kind: "unshown-branches", transitions: ["t5"] }, + { kind: "skipped-steps", transitions: ["t1", "t8"] }, ]); expect(diagram.messages.map((message) => message.label)).toEqual([ "Request payment", diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts index a403f9a..d26b4a8 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -181,7 +181,7 @@ const wrapFragments = ( }; /** - * 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. + * 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 and reported as `skipped-steps`. 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`. */ @@ -203,6 +203,7 @@ export const toSequence = ( model.events.map((event) => [event.id, event.name]), ); + const skipped: I["transition"][] = []; const lifelines: I["actor"][] = []; const messages: SequenceMessage[] = []; const entries: Entry[] = []; @@ -214,6 +215,7 @@ export const toSequence = ( const from = actors.get(transition.source); const to = actors.get(transition.target); if (from === undefined || to === undefined) { + skipped.push(id); continue; } for (const participant of [from, to]) { @@ -269,5 +271,9 @@ export const toSequence = ( }); } + if (skipped.length > 0) { + loss.push({ kind: "skipped-steps", transitions: skipped }); + } + return { lifelines, messages, items, loss }; }; diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index c294d1c..b329458 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -1,6 +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"; +import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface StateMachineState { readonly id: I["element"]; @@ -53,6 +53,8 @@ const stateOf = ( * * 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. * + * Decisions are folded into guards by design and are not a loss. Fork, join and history pseudo-states have no state of their own here and are reported as unrepresented, together with the transitions that touch them, which are left out rather than pointing at a state that is not drawn. + * * 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 = ( @@ -147,6 +149,15 @@ export const toStateMachine = ( }; const states: StateMachineState[] = []; + const stateIds = new Set(); + const folded = new Set(); + for (const element of model.elements) { + if (isTransparent(element)) { + folded.add(element.id); + } else if (stateOf(element) !== undefined) { + stateIds.add(element.id); + } + } const transitions: StateMachineTransition[] = []; for (const element of model.elements) { const state = isTransparent(element) ? undefined : stateOf(element); @@ -156,6 +167,9 @@ export const toStateMachine = ( states.push(state); for (const transition of outgoing.get(element.id) ?? []) { for (const arrival of resolve(transition, new Set())) { + if (!stateIds.has(arrival.target)) { + continue; + } transitions.push({ source: element.id, target: arrival.target, @@ -167,15 +181,22 @@ export const toStateMachine = ( } } - const folded = model.elements.filter( + const loss: Loss[] = [ + ...commonLoss(model, "stateMachine"), + ...unrepresentedLoss( + model, + "stateMachine", + new Set([...stateIds, ...folded]), + ), + ]; + const skipped = model.elements.filter( (element) => element.kind === "activity" && isTransparent(element), ); - const loss: Loss[] = [...commonLoss(model, "stateMachine")]; - if (folded.length > 0) { + if (skipped.length > 0) { loss.push({ kind: "subject", subject, - elements: folded.map((element) => element.id), + elements: skipped.map((element) => element.id), }); } diff --git a/packages/core/graph/projection/src/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts index b995c0b..c570f19 100644 --- a/packages/core/graph/projection/src/swimlane.ts +++ b/packages/core/graph/projection/src/swimlane.ts @@ -1,8 +1,7 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; -import { toFlowchart, type Flowchart } from "./flowchart.ts"; +import { flowchartLoss, 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 { @@ -65,6 +64,6 @@ export const toSwimlane = (model: Model): Swimlane => { lanes, unassigned, handoffs: handoffs(model), - loss: commonLoss(model, "swimlane"), + loss: flowchartLoss(model, "swimlane", flow.nodes), }; }; From 7fca3c0fef97413170a20b9ca740648e8668ec8d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:32:14 +0100 Subject: [PATCH 2/3] feat(schema): validate an overlay against its model parseOverlay is model-independent, so an overlay naming an element the model never declared loaded without complaint and silently described nothing. Add validateOverlay(model, overlay), which returns an overlay-unknown-element issue naming each such identifier, in the package's existing Issue shape. parseOverlay is unchanged. --- packages/core/graph/schema/README.md | 4 +- packages/core/graph/schema/src/index.ts | 1 + packages/core/graph/schema/src/overlay.ts | 22 ++++++++ .../graph/schema/src/overlay.unit.test.ts | 55 +++++++++++++++++++ packages/core/graph/schema/src/validate.ts | 3 +- 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index fa74968..b6ac3a7 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -48,7 +48,7 @@ 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`, `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. +`validate(model)` returns the issues a runtime model can still have: `duplicate-id`, `unknown-reference`, `multiple-initial`, `subject-not-entity`, `actor-is-entity`, `duplicate-source`, `multiple-producers` and `affects-not-entity`. `validateOverlay` returns `Issue`s too, with the code `overlay-unknown-element`. 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 @@ -89,7 +89,7 @@ Some concepts belong to one notation and have no meaning in the model: an activa ## 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`. +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`. It does not know the model, so an entry naming an element the model never declared loads fine; `validateOverlay(model, overlay)` returns an `overlay-unknown-element` issue for each one. ## Layout diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index 31a186f..bffebbb 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -34,6 +34,7 @@ export { OverlayParseError, overlaySchema, parseOverlay, + validateOverlay, type Overlay, type OverlayIssue, } from "./overlay.ts"; diff --git a/packages/core/graph/schema/src/overlay.ts b/packages/core/graph/schema/src/overlay.ts index 7bfe037..d08577d 100644 --- a/packages/core/graph/schema/src/overlay.ts +++ b/packages/core/graph/schema/src/overlay.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import type { IdSpace, OpenIds } from "./ids.ts"; +import type { Model } from "./model.ts"; import { toPointer } from "./pointer.ts"; +import type { Issue } from "./validate.ts"; import { overlayStatuses, type OverlayStatus } from "./vocabulary.ts"; /** @@ -46,6 +48,26 @@ export class OverlayParseError extends Error { } } +/** + * Checks an overlay against the model it is laid over. `parseOverlay` is model-independent, so this is where an element identifier that the model does not declare is found. + * @param model - The model the overlay is for. + * @param overlay - The overlay to check. + * @returns One `overlay-unknown-element` issue per entry whose key is not an element of the model, in the overlay's key order. Empty when every entry names a declared element. + */ +export const validateOverlay = ( + model: Model, + overlay: Readonly>>, +): readonly Issue[] => { + const elementIds = new Set(model.elements.map((e) => e.id)); + + return Object.keys(overlay) + .filter((id) => !elementIds.has(id)) + .map((id) => ({ + code: "overlay-unknown-element", + message: `overlay refers to unknown element id "${id}"`, + })); +}; + /** * Loads an overlay from untrusted input, such as parsed JSON. * @param input - The value to load. diff --git a/packages/core/graph/schema/src/overlay.unit.test.ts b/packages/core/graph/schema/src/overlay.unit.test.ts index 4b32cc1..b01e9e8 100644 --- a/packages/core/graph/schema/src/overlay.unit.test.ts +++ b/packages/core/graph/schema/src/overlay.unit.test.ts @@ -1,11 +1,13 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { z } from "zod"; import type { OpenIds } from "./ids.ts"; +import { defineModel } from "./model.ts"; import { OverlayParseError, type Overlay, type overlaySchema, parseOverlay, + validateOverlay, } from "./overlay.ts"; /** The issues `parseOverlay` reports for an input it must reject. */ @@ -67,3 +69,56 @@ describe("parseOverlay", () => { expect(error.name).toBe("OverlayParseError"); }); }); + +describe("validateOverlay", () => { + const model = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a-submit", label: "Submit" }, + { kind: "activity", id: "a-pack", label: "Pack" }, + ], + transitions: [], + fragments: [], + projections: [], + }); + + it("accepts an overlay whose ids are all declared elements", () => { + expect( + validateOverlay(model, { "a-submit": "done", "a-pack": "open" }), + ).toEqual([]); + }); + + it("reports an id the model does not declare, naming it", () => { + expect( + validateOverlay(model, { "a-submit": "done", "a-ship": "open" }), + ).toEqual([ + { + code: "overlay-unknown-element", + message: 'overlay refers to unknown element id "a-ship"', + }, + ]); + }); + + it("reports an id that names another kind of identifier", () => { + const issues = validateOverlay( + defineModel({ + participants: [{ id: "shop", label: "Shop", kind: "system" }], + events: [], + actions: [], + artefacts: [], + elements: [], + transitions: [], + fragments: [], + projections: [], + }), + parseOverlay({ shop: "done" }), + ); + + expect(issues.map((issue) => issue.code)).toEqual([ + "overlay-unknown-element", + ]); + }); +}); diff --git a/packages/core/graph/schema/src/validate.ts b/packages/core/graph/schema/src/validate.ts index 6312996..f157d87 100644 --- a/packages/core/graph/schema/src/validate.ts +++ b/packages/core/graph/schema/src/validate.ts @@ -10,7 +10,8 @@ export type IssueCode = | "actor-is-entity" | "duplicate-source" | "multiple-producers" - | "affects-not-entity"; + | "affects-not-entity" + | "overlay-unknown-element"; export interface Issue { readonly code: IssueCode; From adf5478ad85b6023299844cf834df7cfc83c4ecb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:32:28 +0100 Subject: [PATCH 3/3] feat(projection): export the unrepresented element types --- packages/core/graph/projection/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/graph/projection/src/index.ts b/packages/core/graph/projection/src/index.ts index 1970790..70e6b89 100644 --- a/packages/core/graph/projection/src/index.ts +++ b/packages/core/graph/projection/src/index.ts @@ -1,7 +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 type { Loss, UnrepresentedElement, UnrepresentedKind } from "./loss.ts"; export { handoffs, type Handoff } from "./handoffs.ts"; export { toFlowchart,