@runbooks/graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { toGraph } from "./model.js";
6
+ import { layout, happyPath, topologicalOrder } from "./layout.js";
7
+ const CORPUS = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "schema", "fixtures", "p1-valid");
8
+ const corpus = readdirSync(CORPUS).map((name) => ({
9
+ name,
10
+ graph: toGraph(JSON.parse(readFileSync(join(CORPUS, name), "utf8"))),
11
+ }));
12
+ /**
13
+ * P-08's constraints are hard, not preferences. A generic layered algorithm optimizes
14
+ * edge crossings and would happily place a rollback on the right; the point of the rule
15
+ * is that a reader locates the bad path in two seconds, and that only works if direction
16
+ * means the same thing on every graph in the catalog.
17
+ */
18
+ describe("the happy path is a strictly vertical centre axis", () => {
19
+ it.each(corpus)("$name keeps every happy-path node at x = 0", ({ graph }) => {
20
+ const { positions, happyPath: path } = layout(graph);
21
+ expect(path.length).toBeGreaterThan(1);
22
+ for (const id of path)
23
+ expect(positions[id].x).toBe(0);
24
+ });
25
+ it.each(corpus)("$name descends without revisiting a layer", ({ graph }) => {
26
+ const { positions, happyPath: path } = layout(graph);
27
+ const ys = path.map((id) => positions[id].y);
28
+ for (let i = 1; i < ys.length; i++)
29
+ expect(ys[i]).toBeGreaterThan(ys[i - 1]);
30
+ });
31
+ });
32
+ describe("direction is consistent across every graph", () => {
33
+ it.each(corpus)("$name puts no rollback target right of the axis", ({ graph }) => {
34
+ const { positions } = layout(graph);
35
+ for (const e of graph.edges.filter((x) => x.kind === "rollback")) {
36
+ expect(positions[e.to].x, `${e.to} is a rollback target`).toBeLessThanOrEqual(0);
37
+ }
38
+ });
39
+ // A node can be both a rollback and a failure route — a check that fails routes to the
40
+ // step that undoes the previous action. Rollback wins, because that is what the node
41
+ // *is*; the on_fail edge merely reaches it.
42
+ it.each(corpus)("$name puts a purely on_fail target right of the axis", ({ graph }) => {
43
+ const { positions, happyPath: path } = layout(graph);
44
+ const onAxis = new Set(path);
45
+ const rollbackTargets = new Set(graph.edges.filter((x) => x.kind === "rollback").map((x) => x.to));
46
+ for (const e of graph.edges.filter((x) => x.kind === "on_fail")) {
47
+ if (onAxis.has(e.to) || rollbackTargets.has(e.to))
48
+ continue;
49
+ expect(positions[e.to].x, `${e.to} is an on_fail target`).toBeGreaterThan(0);
50
+ }
51
+ });
52
+ it("keeps a node that is both a rollback and a failure route on the rollback side", () => {
53
+ const graph = toGraph({
54
+ runbook: {
55
+ steps: [
56
+ { id: "s1", kind: "action", title: "Drop it", risk: "destructive", rollback_ref: "s3", next: "s2" },
57
+ { id: "s2", kind: "check", title: "Confirm", expect: "gone", on_fail: "s3", next: "end:success" },
58
+ { id: "s3", kind: "action", title: "Put it back", risk: "reversible-write", next: "end:aborted" },
59
+ ],
60
+ },
61
+ });
62
+ expect(layout(graph).positions.s3.x).toBeLessThan(0);
63
+ });
64
+ it.each(corpus)("$name puts every escalation right of the axis", ({ graph }) => {
65
+ const { positions, happyPath: path } = layout(graph);
66
+ for (const n of graph.nodes.filter((x) => x.kind === "escalate")) {
67
+ if (path.includes(n.id))
68
+ continue;
69
+ expect(positions[n.id].x).toBeGreaterThan(0);
70
+ }
71
+ });
72
+ });
73
+ describe("layout is pure and deterministic", () => {
74
+ // What makes a rendered SVG diffable and a card's mini-graph silhouette stable across
75
+ // builds. Without it, a silhouette is not a silhouette.
76
+ it.each(corpus)("$name produces byte-identical coordinates twice", ({ graph }) => {
77
+ expect(JSON.stringify(layout(graph))).toBe(JSON.stringify(layout(graph)));
78
+ });
79
+ it.each(corpus)("$name places every node exactly once", ({ graph }) => {
80
+ const { positions } = layout(graph);
81
+ expect(Object.keys(positions).sort()).toEqual(graph.nodes.map((n) => n.id).sort());
82
+ });
83
+ it.each(corpus)("$name never stacks two nodes on one point", ({ graph }) => {
84
+ const seen = new Set(Object.values(layout(graph).positions).map((p) => `${p.x},${p.y}`));
85
+ expect(seen.size).toBe(graph.nodes.length);
86
+ });
87
+ // A malformed graph must not hang an editor that lays out on every keystroke.
88
+ it("settles on a 12-node graph well inside an interactive budget", () => {
89
+ const steps = Array.from({ length: 12 }, (_, i) => ({
90
+ id: `s${i}`, kind: "action", title: `Step ${i}`, risk: "read-only",
91
+ }));
92
+ const graph = toGraph({ runbook: { steps } });
93
+ const started = performance.now();
94
+ for (let i = 0; i < 100; i++)
95
+ layout(graph);
96
+ expect((performance.now() - started) / 100).toBeLessThan(5);
97
+ });
98
+ });
99
+ describe("happy path selection", () => {
100
+ it("does not follow a branch straight into an escalation", () => {
101
+ const graph = toGraph({
102
+ runbook: {
103
+ steps: [
104
+ { id: "s1", kind: "decision", title: "Decide", question: "q?", branches: { bad: "s9", good: "s2", default: "s9" } },
105
+ { id: "s2", kind: "action", title: "Act", risk: "read-only", next: "end:success" },
106
+ { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" },
107
+ ],
108
+ },
109
+ });
110
+ expect(happyPath(graph)).toContain("s2");
111
+ expect(happyPath(graph)).not.toContain("s9");
112
+ });
113
+ });
114
+ /**
115
+ * The order a person is handed, not just an order (§18.2, W-02).
116
+ *
117
+ * The graph is also a structured list, and the list is what a screen reader and a no-CSS
118
+ * reader get. A rollback edge is deliberately not a flow edge — including it would make a
119
+ * cycle of every paired destructive step — which left the step it points at with no
120
+ * incoming flow and put it wherever the node list happened to fall. On
121
+ * `k8s-node-not-ready-drain` that read "start, Uncordon the node, Confirm the node is
122
+ * NotReady": the undo before the action, which is the procedure told backwards.
123
+ */
124
+ describe("the linear order reads as the procedure", () => {
125
+ const undoPairs = (graph) => graph.edges.filter((e) => e.kind === "rollback");
126
+ it("has fixtures with a rollback in them", () => {
127
+ // Otherwise the sweep below would pass over a corpus that never exercises the rule.
128
+ expect(corpus.some(({ graph }) => undoPairs(graph).length > 0)).toBe(true);
129
+ });
130
+ it.each(corpus)("$name reads an undo after the step it undoes", ({ graph }) => {
131
+ const order = topologicalOrder(graph);
132
+ for (const edge of undoPairs(graph)) {
133
+ expect(order.indexOf(edge.to), `${edge.to} undoes ${edge.from} and is read before it`).toBeGreaterThan(order.indexOf(edge.from));
134
+ }
135
+ });
136
+ /** The guarantee that must survive: it is still a topological order over the flow. */
137
+ it.each(corpus)("$name still orders every flow edge from before to", ({ graph }) => {
138
+ const order = topologicalOrder(graph);
139
+ for (const edge of graph.edges) {
140
+ if (!["next", "branch", "on_fail"].includes(edge.kind))
141
+ continue;
142
+ expect(order.indexOf(edge.from), `${edge.from} -> ${edge.to}`).toBeLessThan(order.indexOf(edge.to));
143
+ }
144
+ });
145
+ /*
146
+ * The shape the corpus does not have.
147
+ *
148
+ * Every fixture happens to write its undo after the step it undoes, so document order
149
+ * hides the defect and a sweep over them cannot fail. `std/k8s-node-not-ready-drain`
150
+ * writes it second, which is how the list came to open with "Uncordon the node". This
151
+ * is that record's shape, small enough to read.
152
+ */
153
+ it("reads an undo written before its action after it anyway", () => {
154
+ const graph = toGraph({
155
+ runbook: {
156
+ steps: [
157
+ { id: "s1", kind: "check", title: "Confirm", expect: "ok", on_fail: "s9", next: "s2" },
158
+ { id: "s5", kind: "action", title: "Undo it", risk: "reversible-write", next: "end:aborted" },
159
+ { id: "s2", kind: "action", title: "Do it", risk: "destructive", rollback_ref: "s5", next: "end:success" },
160
+ { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" },
161
+ ],
162
+ },
163
+ });
164
+ const order = topologicalOrder(graph);
165
+ expect(order.indexOf("s5"), "the undo is read before the step it undoes").toBeGreaterThan(order.indexOf("s2"));
166
+ });
167
+ it("traverses every node of a graph whose steps roll each other back", () => {
168
+ // A malformed pair must not deadlock the traversal: keyboard navigation that skips a
169
+ // node is worse than an order that has to make an arbitrary choice.
170
+ const mutual = toGraph({
171
+ runbook: {
172
+ steps: [
173
+ { id: "s1", kind: "action", title: "One", risk: "destructive", rollback_ref: "s2", next: "end:success" },
174
+ { id: "s2", kind: "action", title: "Two", risk: "destructive", rollback_ref: "s1", next: "end:success" },
175
+ ],
176
+ },
177
+ });
178
+ const order = topologicalOrder(mutual);
179
+ expect(new Set(order).size).toBe(mutual.nodes.length);
180
+ });
181
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The graph model and its deterministic derivation from `steps[]`.
3
+ *
4
+ * One model, three consumers: the linter, the renderer, the editor (RUNBOOK.md 6). If
5
+ * the editor grows its own representation the two drift, and the constructor starts
6
+ * producing documents the linter rejects for reasons the editor cannot explain.
7
+ *
8
+ * No dependency on React, the DOM, or Node — this runs in a browser for the live editor
9
+ * and in the gate runner for linting.
10
+ */
11
+ import type { Risk } from "@runbooks/schema";
12
+ export type NodeKind = "start" | "check" | "action" | "decision" | "wait" | "escalate" | "end";
13
+ export type EdgeKind = "next" | "branch" | "on_fail" | "rollback" | "approval" | "retry";
14
+ export type EndOutcome = "success" | "failed" | "aborted";
15
+ export interface GraphNode {
16
+ readonly id: string;
17
+ readonly kind: NodeKind;
18
+ readonly title: string;
19
+ readonly risk?: Risk;
20
+ readonly requiresApproval?: boolean;
21
+ /** `end` only. */
22
+ readonly outcome?: EndOutcome;
23
+ /** Nodes the derivation created rather than the author writing them. */
24
+ readonly synthetic?: boolean;
25
+ /**
26
+ * The step this node was derived from, carried verbatim.
27
+ *
28
+ * The graph is a view over the document, not a replacement for it: the source of
29
+ * truth is the parsed tree, not the picture (RUNBOOK.md 18.3). Carrying the step
30
+ * makes `graph -> steps[]` lossless by construction rather than by a field list
31
+ * somebody has to keep in sync — which is what the constructor's YAML/Graph toggle
32
+ * needs to be trustworthy, and an engineer is right not to trust an editor that
33
+ * loses what it did not think to model.
34
+ */
35
+ readonly step?: RawStep;
36
+ }
37
+ export interface GraphEdge {
38
+ readonly from: string;
39
+ readonly to: string;
40
+ readonly kind: EdgeKind;
41
+ /** Required on `branch` (the value from `branches`) and on `retry` (the limit). */
42
+ readonly label?: string;
43
+ /** `retry` only: the finite limit that makes the cycle legal (invariant 6). */
44
+ readonly max?: number;
45
+ }
46
+ export interface Graph {
47
+ readonly nodes: readonly GraphNode[];
48
+ readonly edges: readonly GraphEdge[];
49
+ }
50
+ export declare const START = "__start";
51
+ export declare const end: (outcome: EndOutcome) => string;
52
+ /** Nodes a graph cannot continue past (RUNBOOK.md 6). */
53
+ export declare function isTerminal(kind: NodeKind): boolean;
54
+ export interface RawStep {
55
+ id: string;
56
+ kind: Exclude<NodeKind, "start" | "end">;
57
+ title: string;
58
+ risk?: Risk;
59
+ /** The capability this step alone invokes. Scope is per step, never the union. */
60
+ tool?: string;
61
+ command?: string;
62
+ /** Machine-checkable postcondition. `expect` is prose for the reader; this is what an
63
+ * R2 supervisor evaluates (docs/decisions/Q12-expect-evaluability.md). */
64
+ assert?: Record<string, unknown>;
65
+ requires_approval?: boolean;
66
+ rollback_ref?: string;
67
+ expect?: string;
68
+ on_fail?: string;
69
+ question?: string;
70
+ branches?: Record<string, string>;
71
+ /** `wait` only, and one of these forms is required: a bound is not optional, because
72
+ * an unbounded wait is a hang with better manners. */
73
+ duration?: string;
74
+ until?: string;
75
+ timeout?: string;
76
+ retry?: {
77
+ max?: number;
78
+ target?: string;
79
+ };
80
+ next?: string;
81
+ to?: string;
82
+ }
83
+ export interface RunbookDocument {
84
+ runbook?: {
85
+ steps?: RawStep[];
86
+ };
87
+ }
88
+ /**
89
+ * `steps[] → graph`, deterministic: two implementations must produce the same graph
90
+ * from the same document, or the linter and the renderer disagree about what they are
91
+ * looking at.
92
+ *
93
+ * Ordering rule: nodes follow document order with the synthetic `start` first and
94
+ * synthetic `end` nodes last; edges follow the order they are derived per step. Nothing
95
+ * here sorts by id or by anything else — determinism comes from the document, so a
96
+ * reordering of `steps[]` is a real change and shows up as one.
97
+ */
98
+ export declare function toGraph(doc: RunbookDocument): Graph;
99
+ /**
100
+ * `graph -> steps[]`. Lossless because each node carries its source step; the graph
101
+ * adds a reading of the document, it does not replace it.
102
+ *
103
+ * Synthetic nodes (`start`, `end`) are dropped: they exist so that invariant 7 can be
104
+ * checked without a hole in the graph, and they have no representation in `steps[]`.
105
+ */
106
+ export declare function fromGraph(g: Graph): RawStep[];
package/dist/model.js ADDED
@@ -0,0 +1,118 @@
1
+ export const START = "__start";
2
+ export const end = (outcome) => `__end_${outcome}`;
3
+ /** Nodes a graph cannot continue past (RUNBOOK.md 6). */
4
+ export function isTerminal(kind) {
5
+ return kind === "escalate" || kind === "end";
6
+ }
7
+ /**
8
+ * `steps[] → graph`, deterministic: two implementations must produce the same graph
9
+ * from the same document, or the linter and the renderer disagree about what they are
10
+ * looking at.
11
+ *
12
+ * Ordering rule: nodes follow document order with the synthetic `start` first and
13
+ * synthetic `end` nodes last; edges follow the order they are derived per step. Nothing
14
+ * here sorts by id or by anything else — determinism comes from the document, so a
15
+ * reordering of `steps[]` is a real change and shows up as one.
16
+ */
17
+ export function toGraph(doc) {
18
+ const steps = doc.runbook?.steps ?? [];
19
+ const nodes = [
20
+ { id: START, kind: "start", title: "start", synthetic: true },
21
+ ];
22
+ const edges = [];
23
+ const known = new Set(steps.map((s) => s.id));
24
+ const endsUsed = new Set();
25
+ /** Steps whose every incoming transition passes an approval gate. Modelled on the
26
+ * edges rather than as a self-loop on the node: a gate is a property of arriving at
27
+ * a step, and a self-loop would be a cycle, which invariant 6 would then reject. */
28
+ const gated = new Set(steps.filter((s) => s.requires_approval === true).map((s) => s.id));
29
+ const target = (ref) => {
30
+ // `end:<outcome>` names an explicit terminal, `escalate:sN` and a bare id name a
31
+ // step. Anything else is dangling, and invariant 2 reports it against the id as
32
+ // written rather than silently dropping it.
33
+ if (ref.startsWith("end:")) {
34
+ const outcome = ref.slice("end:".length);
35
+ endsUsed.add(outcome);
36
+ return end(outcome);
37
+ }
38
+ return ref.startsWith("escalate:") ? ref.slice("escalate:".length) : ref;
39
+ };
40
+ /** Every arrival at a gated step carries the gate. */
41
+ const link = (from, to, kind, extra = {}) => {
42
+ edges.push({ from, to, kind, ...extra });
43
+ if (gated.has(to) && kind !== "approval" && kind !== "rollback") {
44
+ edges.push({ from, to, kind: "approval", label: "approval" });
45
+ }
46
+ };
47
+ if (steps.length > 0)
48
+ link(START, steps[0].id, "next");
49
+ for (const [i, step] of steps.entries()) {
50
+ nodes.push({
51
+ id: step.id,
52
+ kind: step.kind,
53
+ title: step.title,
54
+ ...(step.risk ? { risk: step.risk } : {}),
55
+ ...(step.requires_approval !== undefined
56
+ ? { requiresApproval: step.requires_approval }
57
+ : {}),
58
+ step,
59
+ });
60
+ if (step.rollback_ref && known.has(step.rollback_ref)) {
61
+ edges.push({ from: step.id, to: step.rollback_ref, kind: "rollback" });
62
+ }
63
+ if (step.on_fail) {
64
+ link(step.id, target(step.on_fail), "on_fail");
65
+ }
66
+ if (step.branches) {
67
+ for (const [label, to] of Object.entries(step.branches)) {
68
+ link(step.id, target(to), "branch", { label });
69
+ }
70
+ }
71
+ if (step.retry) {
72
+ edges.push({
73
+ from: step.id,
74
+ to: step.retry.target ?? step.id,
75
+ kind: "retry",
76
+ label: String(step.retry.max ?? ""),
77
+ ...(step.retry.max !== undefined ? { max: step.retry.max } : {}),
78
+ });
79
+ }
80
+ if (step.kind === "escalate")
81
+ continue;
82
+ // A step whose branches are its routes gets no fall-through. Reading the next step
83
+ // in file order as an edge would put a route in the graph that the author never
84
+ // wrote, and a supervisor may only take routes the document declares.
85
+ const next = step.next ? target(step.next) : step.branches ? undefined : steps[i + 1]?.id;
86
+ if (next) {
87
+ link(step.id, next, "next");
88
+ }
89
+ else if (!step.branches) {
90
+ // A step that runs off the end of the list terminates. Invariant 7 wants that
91
+ // explicit, so the derivation makes it explicit rather than leaving a hole.
92
+ endsUsed.add("success");
93
+ link(step.id, end("success"), "next");
94
+ }
95
+ }
96
+ for (const outcome of ["success", "failed", "aborted"]) {
97
+ if (endsUsed.has(outcome)) {
98
+ nodes.push({
99
+ id: end(outcome),
100
+ kind: "end",
101
+ title: outcome,
102
+ outcome,
103
+ synthetic: true,
104
+ });
105
+ }
106
+ }
107
+ return { nodes, edges };
108
+ }
109
+ /**
110
+ * `graph -> steps[]`. Lossless because each node carries its source step; the graph
111
+ * adds a reading of the document, it does not replace it.
112
+ *
113
+ * Synthetic nodes (`start`, `end`) are dropped: they exist so that invariant 7 can be
114
+ * checked without a hole in the graph, and they have no representation in `steps[]`.
115
+ */
116
+ export function fromGraph(g) {
117
+ return g.nodes.filter((n) => !n.synthetic && n.step).map((n) => n.step);
118
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Path enumeration (P-07, §6).
3
+ *
4
+ * What simulation (W-15), the mini-graph silhouette (§18.5) and keyboard navigation
5
+ * (§18.2) will be built from, and what invariant 1 is built on today: reachability had
6
+ * two implementations — the same sweep written twice, agreeing until the next person
7
+ * changed what counts as an edge in one of them.
8
+ *
9
+ * The other consumers do not exist yet. `layout.ts` keeps its own `happyPath`, which is a
10
+ * different question — the single vertical axis a layout is arranged around, not the set
11
+ * of routes a run can take — and `topologicalOrder` stays there for the same reason. When
12
+ * W-15 arrives it uses these rather than walking again; until then this file has exactly
13
+ * one caller and says so.
14
+ */
15
+ import { type Graph } from "./model.js";
16
+ /** Every node reachable from a starting point, in the order first reached. */
17
+ export declare function reachableFrom(graph: Graph, from?: string): string[];
18
+ /** Nodes no path from the start can arrive at. Invariant 1 blocks on these; §18 highlights them. */
19
+ export declare function unreachable(graph: Graph): string[];
20
+ export interface PathOptions {
21
+ /** Edge kinds to follow. Omitted means all of them. */
22
+ readonly kinds?: readonly string[];
23
+ /**
24
+ * How many times one node may appear on a path. Two, by default: a bounded retry is a
25
+ * legal cycle (invariant 6), and a path that may not revisit anything cannot express
26
+ * one — while a path that may revisit freely never terminates.
27
+ */
28
+ readonly maxVisits?: number;
29
+ /** A ceiling on how many paths are returned, so a wide graph cannot hang a caller. */
30
+ readonly limit?: number;
31
+ }
32
+ /**
33
+ * Every path from a node to a terminal.
34
+ *
35
+ * Depth-first with a visit budget rather than a visited set, because a legal retry means
36
+ * a node genuinely appears twice on a real run and a simulation that could not express
37
+ * that would be simulating a different procedure.
38
+ */
39
+ export declare function allPathsTo(graph: Graph, from?: string, options?: PathOptions): string[][];
40
+ /**
41
+ * The paths a run can take without anything going wrong: forward edges only.
42
+ *
43
+ * `happyPath` gives the single vertical axis a layout is built around; this gives every
44
+ * branch of it, which is what a reader comparing variants and a simulation stepping
45
+ * through choices both need.
46
+ */
47
+ export declare function successPaths(graph: Graph): string[][];
package/dist/paths.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Path enumeration (P-07, §6).
3
+ *
4
+ * What simulation (W-15), the mini-graph silhouette (§18.5) and keyboard navigation
5
+ * (§18.2) will be built from, and what invariant 1 is built on today: reachability had
6
+ * two implementations — the same sweep written twice, agreeing until the next person
7
+ * changed what counts as an edge in one of them.
8
+ *
9
+ * The other consumers do not exist yet. `layout.ts` keeps its own `happyPath`, which is a
10
+ * different question — the single vertical axis a layout is arranged around, not the set
11
+ * of routes a run can take — and `topologicalOrder` stays there for the same reason. When
12
+ * W-15 arrives it uses these rather than walking again; until then this file has exactly
13
+ * one caller and says so.
14
+ */
15
+ import { isTerminal, START } from "./model.js";
16
+ /** Every node reachable from a starting point, in the order first reached. */
17
+ export function reachableFrom(graph, from = START) {
18
+ const seen = new Set();
19
+ const order = [];
20
+ const queue = [from];
21
+ while (queue.length > 0) {
22
+ const id = queue.shift();
23
+ if (seen.has(id))
24
+ continue;
25
+ seen.add(id);
26
+ order.push(id);
27
+ for (const edge of graph.edges.filter((e) => e.from === id))
28
+ queue.push(edge.to);
29
+ }
30
+ return order;
31
+ }
32
+ /** Nodes no path from the start can arrive at. Invariant 1 blocks on these; §18 highlights them. */
33
+ export function unreachable(graph) {
34
+ const reached = new Set(reachableFrom(graph));
35
+ return graph.nodes.filter((node) => !reached.has(node.id)).map((node) => node.id);
36
+ }
37
+ /**
38
+ * Every path from a node to a terminal.
39
+ *
40
+ * Depth-first with a visit budget rather than a visited set, because a legal retry means
41
+ * a node genuinely appears twice on a real run and a simulation that could not express
42
+ * that would be simulating a different procedure.
43
+ */
44
+ export function allPathsTo(graph, from = START, options = {}) {
45
+ const maxVisits = options.maxVisits ?? 2;
46
+ const limit = options.limit ?? 256;
47
+ const kinds = options.kinds;
48
+ const paths = [];
49
+ const walk = (id, path, visits) => {
50
+ if (paths.length >= limit)
51
+ return;
52
+ const next = [...path, id];
53
+ const node = graph.nodes.find((n) => n.id === id);
54
+ if (node && isTerminal(node.kind)) {
55
+ paths.push(next);
56
+ return;
57
+ }
58
+ const outgoing = graph.edges.filter((edge) => edge.from === id && (!kinds || kinds.includes(edge.kind)));
59
+ if (outgoing.length === 0) {
60
+ // Running out of edges is not reaching a terminal. Invariant 7 blocks on it; a
61
+ // caller enumerating paths should see where it happened rather than nothing.
62
+ paths.push(next);
63
+ return;
64
+ }
65
+ for (const edge of outgoing) {
66
+ const used = visits.get(edge.to) ?? 0;
67
+ if (used >= maxVisits)
68
+ continue;
69
+ walk(edge.to, next, new Map(visits).set(edge.to, used + 1));
70
+ }
71
+ };
72
+ walk(from, [], new Map([[from, 1]]));
73
+ return paths;
74
+ }
75
+ /**
76
+ * The paths a run can take without anything going wrong: forward edges only.
77
+ *
78
+ * `happyPath` gives the single vertical axis a layout is built around; this gives every
79
+ * branch of it, which is what a reader comparing variants and a simulation stepping
80
+ * through choices both need.
81
+ */
82
+ export function successPaths(graph) {
83
+ return allPathsTo(graph, START, { kinds: ["next", "branch", "approval"] });
84
+ }
@@ -0,0 +1 @@
1
+ export {};