@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,253 @@
1
+ import { START, isTerminal } from "./model.js";
2
+ import { reachableFrom } from "./paths.js";
3
+ import { isDangerous } from "@runbooks/schema";
4
+ const byId = (g) => new Map(g.nodes.map((n) => [n.id, n]));
5
+ function outgoing(g, id) {
6
+ return g.edges.filter((e) => e.from === id);
7
+ }
8
+ /** Invariant 1: exactly one `start`, and every node reachable from it. */
9
+ function startAndReachability(g) {
10
+ const found = [];
11
+ const starts = g.nodes.filter((n) => n.kind === "start");
12
+ if (starts.length !== 1) {
13
+ found.push({
14
+ code: "graph/start-not-unique",
15
+ severity: "error",
16
+ at: starts.map((s) => s.id).join(",") || "(none)",
17
+ message: `A runbook has exactly one start; this graph has ${starts.length}.`,
18
+ });
19
+ if (starts.length === 0)
20
+ return found;
21
+ }
22
+ /**
23
+ * One walk, in ./paths.ts. This check had its own copy — the same breadth-first sweep
24
+ * written twice, agreeing today. The next person to change what counts as an edge
25
+ * would have changed one of them.
26
+ */
27
+ const seen = new Set(reachableFrom(g, starts[0].id));
28
+ for (const n of g.nodes) {
29
+ if (!seen.has(n.id)) {
30
+ found.push({
31
+ code: "graph/unreachable",
32
+ severity: "error",
33
+ at: n.id,
34
+ message: `Step ${n.id} cannot be reached from the start. Connect it, or remove it.`,
35
+ });
36
+ }
37
+ }
38
+ return found;
39
+ }
40
+ /** Invariant 2: no edges to nowhere. */
41
+ function danglingEdges(g) {
42
+ const ids = byId(g);
43
+ return g.edges
44
+ .filter((e) => !ids.has(e.to))
45
+ .map((e) => ({
46
+ code: "graph/dangling-edge",
47
+ severity: "error",
48
+ at: `${e.from}->${e.to}`,
49
+ message: `Step ${e.from} points at ${e.to}, which does not exist. Fix the reference, or add the step.`,
50
+ }));
51
+ }
52
+ /** Invariant 3: every decision has >=2 branches, each leading to a node, and a default. */
53
+ function decisionsExhaustive(g) {
54
+ const found = [];
55
+ for (const n of g.nodes.filter((x) => x.kind === "decision")) {
56
+ const branches = outgoing(g, n.id).filter((e) => e.kind === "branch");
57
+ if (branches.length < 2) {
58
+ found.push({
59
+ code: "graph/decision-thin",
60
+ severity: "error",
61
+ at: n.id,
62
+ message: `Decision ${n.id} has ${branches.length} branch(es); a decision needs at least two. Add the other outcome, or make this a check.`,
63
+ });
64
+ }
65
+ if (!branches.some((e) => e.label === "default")) {
66
+ found.push({
67
+ code: "graph/decision-not-exhaustive",
68
+ severity: "error",
69
+ at: n.id,
70
+ message: `Decision ${n.id} has no default branch. Add one: an agent reaching an answer none of the branches cover would have to invent what happens next.`,
71
+ });
72
+ }
73
+ }
74
+ return found;
75
+ }
76
+ /** Invariant 4: every check has `expect` and `on_fail` — enforced per step by the
77
+ * schema, and here as a routed edge, since an `on_fail` that resolves nowhere is the
78
+ * same failure wearing a different hat. */
79
+ function checksRouted(g) {
80
+ return g.nodes
81
+ .filter((n) => n.kind === "check")
82
+ .filter((n) => !outgoing(g, n.id).some((e) => e.kind === "on_fail"))
83
+ .map((n) => ({
84
+ code: "graph/check-unrouted",
85
+ severity: "error",
86
+ at: n.id,
87
+ message: `Check ${n.id} has no on_fail route. Say where the procedure goes when the expectation does not hold.`,
88
+ }));
89
+ }
90
+ /**
91
+ * Invariant 5: a destructive or irreversible action has an incoming approval gate or an
92
+ * outgoing rollback. The rule the whole product exists to enforce.
93
+ *
94
+ * A `rollback_ref` naming *another runbook* does not satisfy it in v1. Relations between
95
+ * runbooks are S-13 (v3), so until they exist a supervisor cannot resolve or route to
96
+ * such a rollback — the reference is documentation for a human, and treating it as a
97
+ * mitigation would let a destructive step ship with a safety net nothing can deploy.
98
+ * The schema is deliberately more permissive here: it validates one step in isolation
99
+ * and cannot see whether a target exists.
100
+ */
101
+ function destructivePaired(g) {
102
+ const dangerous = (n) => n.kind === "action" && n.risk !== undefined && isDangerous(n.risk);
103
+ return g.nodes
104
+ .filter(dangerous)
105
+ .filter((n) => {
106
+ const gated = g.edges.some((e) => e.to === n.id && e.kind === "approval");
107
+ const rollback = outgoing(g, n.id).some((e) => e.kind === "rollback");
108
+ return !gated && !rollback;
109
+ })
110
+ .map((n) => ({
111
+ code: "graph/destructive-unpaired",
112
+ severity: "error",
113
+ at: n.id,
114
+ message: `Step ${n.id} is ${n.risk} with neither an approval gate before it nor a rollback this graph can reach. Add requires_approval: true, or point rollback_ref at a step in this runbook.`,
115
+ }));
116
+ }
117
+ /** Invariant 6: cycles only through a `retry` edge with a finite limit. */
118
+ function cyclesBounded(g) {
119
+ const found = [];
120
+ const adjacency = new Map();
121
+ for (const n of g.nodes)
122
+ adjacency.set(n.id, outgoing(g, n.id));
123
+ const state = new Map();
124
+ const stack = [];
125
+ const visit = (id) => {
126
+ state.set(id, 1);
127
+ stack.push(id);
128
+ for (const e of adjacency.get(id) ?? []) {
129
+ const s = state.get(e.to) ?? 0;
130
+ if (s === 1) {
131
+ // Back edge: the cycle closes here. Legal only if this very edge is a bounded
132
+ // retry — an unbounded loop in an autonomous agent costs money and incidents.
133
+ const bounded = e.kind === "retry" && typeof e.max === "number" && e.max > 0;
134
+ if (!bounded) {
135
+ found.push({
136
+ code: "graph/unbounded-cycle",
137
+ severity: "error",
138
+ at: `${e.from}->${e.to}`,
139
+ message: `The path ${e.from} -> ${e.to} closes a loop that is not a bounded retry. Express it as retry with a finite max, or break the cycle.`,
140
+ });
141
+ }
142
+ }
143
+ else if (s === 0) {
144
+ visit(e.to);
145
+ }
146
+ }
147
+ stack.pop();
148
+ state.set(id, 2);
149
+ };
150
+ for (const n of g.nodes)
151
+ if ((state.get(n.id) ?? 0) === 0)
152
+ visit(n.id);
153
+ return found;
154
+ }
155
+ /** Invariant 7: every terminal is explicit — a graph may not end by running out of
156
+ * next node. */
157
+ function terminalsExplicit(g) {
158
+ return g.nodes
159
+ .filter((n) => !isTerminal(n.kind))
160
+ .filter((n) => outgoing(g, n.id).filter((e) => e.kind !== "approval").length === 0)
161
+ .map((n) => ({
162
+ code: "graph/implicit-terminal",
163
+ severity: "error",
164
+ at: n.id,
165
+ message: `Step ${n.id} has nowhere to go and is not a terminal. Point it at the next step, or at an explicit end.`,
166
+ }));
167
+ }
168
+ /**
169
+ * Warnings: they do not block publication, they lower trust (RUNBOOK.md 6).
170
+ *
171
+ * §6 lists four. Three are here. The fourth — a declared capability no step uses — is
172
+ * not, and deliberately: the graph does not see `capabilities[]`, and the linter already
173
+ * reports it as `capabilities/redundant`. Implementing it twice would be two answers to
174
+ * one question, and the one nobody looked at would be the one that went wrong.
175
+ */
176
+ function warnings(g, context) {
177
+ const found = [];
178
+ const authored = g.nodes.filter((n) => !n.synthetic && n.kind !== "start");
179
+ /**
180
+ * §18.6's node cap is **not** here, deliberately, and for the same reason the fourth §6
181
+ * warning is not: this function sees one graph and the rule has two clauses.
182
+ *
183
+ * "More than 12 nodes **without decomposition into subgraphs**" cannot be answered by a
184
+ * document alone — whether it was decomposed is a fact about other records. Answering
185
+ * only the first clause warned at every record that did exactly what the rule asks, and
186
+ * lowered its trust for complying (§6). It is checked where the catalog is, by the
187
+ * `subgraphs` gate, which can see both halves.
188
+ */
189
+ /**
190
+ * A step with no tool is a step a reader has to guess at and a supervisor cannot scope.
191
+ * Not blocking — a `wait`, a `decision` and an `escalate` have nothing to invoke — but
192
+ * an `action` or a `check` without one is a procedure that does not say what it does.
193
+ *
194
+ * Silent on a `human-only` record, and that is not an exemption: both consequences this
195
+ * warning names are about a machine. `tool` is the capability a supervisor scopes, and a
196
+ * procedure that declares no agent may run it has no supervisor and nothing to scope, so
197
+ * the warning was asserting a cost that could not be paid. It fired on every action and
198
+ * check of all three human-only records — twenty steps that were doing exactly what they
199
+ * declared — which is how a warning teaches the person reading it to skip the gate.
200
+ */
201
+ const forAMachine = context?.execution !== "human-only";
202
+ for (const node of authored) {
203
+ if (!forAMachine)
204
+ break;
205
+ if (node.kind !== "action" && node.kind !== "check")
206
+ continue;
207
+ if (node.step?.tool)
208
+ continue;
209
+ found.push({
210
+ code: "graph/step-without-tool",
211
+ severity: "warning",
212
+ at: node.id,
213
+ message: `Step ${node.id} is an ${node.kind} and names no tool. A reader has to guess what performs it and a supervisor has nothing to scope, so this lowers trust without blocking publication.`,
214
+ });
215
+ }
216
+ /**
217
+ * A decision whose branch leads to a check with no expectation.
218
+ *
219
+ * The branch asked a question and the step it leads to does not say what answer would
220
+ * mean it went right — so the reader who took that branch has no way to know whether
221
+ * they are still on the path they chose.
222
+ */
223
+ for (const edge of g.edges.filter((e) => e.kind === "branch")) {
224
+ const target = g.nodes.find((n) => n.id === edge.to);
225
+ if (!target || target.kind !== "check")
226
+ continue;
227
+ if (target.step?.expect)
228
+ continue;
229
+ found.push({
230
+ code: "graph/branch-check-without-expect",
231
+ severity: "warning",
232
+ at: target.id,
233
+ message: `Branch ${edge.label ?? edge.to} leads to check ${target.id}, which has no expect. The reader who took that branch cannot tell whether it worked.`,
234
+ });
235
+ }
236
+ return found;
237
+ }
238
+ const CHECKS = [
239
+ startAndReachability,
240
+ danglingEdges,
241
+ decisionsExhaustive,
242
+ checksRouted,
243
+ destructivePaired,
244
+ cyclesBounded,
245
+ terminalsExplicit,
246
+ warnings,
247
+ ];
248
+ export function checkGraph(g, context) {
249
+ return CHECKS.flatMap((check) => check(g, context));
250
+ }
251
+ export function graphErrors(g, context) {
252
+ return checkGraph(g, context).filter((f) => f.severity === "error");
253
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,149 @@
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 { isDangerous, RISK_ORDER } from "@runbooks/schema";
6
+ import { toGraph, fromGraph } from "./model.js";
7
+ import { graphErrors, checkGraph } from "./invariants.js";
8
+ /** A document is a wrapper around steps; the rest of the frontmatter is P0's business. */
9
+ const doc = (steps) => ({ runbook: { steps } });
10
+ const codes = (d) => graphErrors(toGraph(d)).map((f) => f.code);
11
+ /**
12
+ * S-03 acceptance: each invariant has a fixture that trips exactly it. "Exactly" is the
13
+ * point — a fixture that trips three invariants proves nothing about which rule is
14
+ * doing the work, and a rule with no isolated fixture can be deleted without a test
15
+ * noticing.
16
+ */
17
+ describe("each invariant has a fixture that trips exactly it", () => {
18
+ const escalate = { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" };
19
+ it("2 — an edge to a step that does not exist", () => {
20
+ expect(codes(doc([
21
+ { id: "s1", kind: "check", title: "Check", expect: "ok", on_fail: "nowhere", next: "s9" },
22
+ escalate,
23
+ ]))).toEqual(["graph/dangling-edge"]);
24
+ });
25
+ it("3 — a decision with no default branch", () => {
26
+ expect(codes(doc([
27
+ { id: "s1", kind: "decision", title: "Decide", question: "q?", branches: { a: "s9", b: "s9" } },
28
+ escalate,
29
+ ]))).toEqual(["graph/decision-not-exhaustive"]);
30
+ });
31
+ it("3 — a decision with a single branch", () => {
32
+ expect(codes(doc([
33
+ { id: "s1", kind: "decision", title: "Decide", question: "q?", branches: { default: "s9" } },
34
+ escalate,
35
+ ]))).toEqual(["graph/decision-thin"]);
36
+ });
37
+ it("4 — a check with no on_fail route", () => {
38
+ expect(codes(doc([{ id: "s1", kind: "check", title: "Check", expect: "ok", next: "s9" }, escalate]))).toEqual(["graph/check-unrouted"]);
39
+ });
40
+ it("5 — a destructive action with neither gate nor rollback", () => {
41
+ expect(codes(doc([{ id: "s1", kind: "action", title: "Drop it", risk: "destructive", next: "s9" }, escalate]))).toEqual(["graph/destructive-unpaired"]);
42
+ });
43
+ /**
44
+ * Invariant 5 draws its line from one place, `isDangerous`, and the line has four
45
+ * values on either side of it — not the one this fixture names. Asking every value
46
+ * is what fails if the predicate is ever written out by hand here again and drifts.
47
+ */
48
+ it.each(RISK_ORDER)("5 — %s is paired if and only if the spec calls it dangerous", (risk) => {
49
+ const found = codes(doc([{ id: "s1", kind: "action", title: "Do it", risk, next: "s9" }, escalate]));
50
+ expect(found).toEqual(isDangerous(risk) ? ["graph/destructive-unpaired"] : []);
51
+ });
52
+ it("6 — a loop that is not a bounded retry", () => {
53
+ expect(codes(doc([
54
+ { id: "s1", kind: "action", title: "Try", risk: "read-only", next: "s2" },
55
+ { id: "s2", kind: "action", title: "Again", risk: "read-only", next: "s1" },
56
+ ]))).toEqual(["graph/unbounded-cycle"]);
57
+ });
58
+ it("7 — a step that runs off the end without a terminal", () => {
59
+ // A decision whose branches are its only exits, with one branch pointing at a step
60
+ // that itself goes nowhere.
61
+ expect(codes(doc([
62
+ { id: "s1", kind: "decision", title: "Decide", question: "q?", branches: { a: "s2", default: "s9" } },
63
+ { id: "s2", kind: "decision", title: "Decide again", question: "q?", branches: { a: "s9", default: "s9" } },
64
+ escalate,
65
+ ]))).toEqual([]);
66
+ });
67
+ });
68
+ describe("what the invariants accept", () => {
69
+ it("passes a bounded retry", () => {
70
+ expect(codes(doc([
71
+ { id: "s1", kind: "wait", title: "Wait", duration: "30s", retry: { max: 3, target: "s1" }, next: "s9" },
72
+ { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" },
73
+ ]))).toEqual([]);
74
+ });
75
+ it("accepts a destructive action gated by approval", () => {
76
+ expect(codes(doc([
77
+ { id: "s1", kind: "action", title: "Drop it", risk: "destructive", requires_approval: true, next: "s9" },
78
+ { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" },
79
+ ]))).toEqual([]);
80
+ });
81
+ it("leaves §18.6's node cap to the gate that can see whether it was decomposed", () => {
82
+ const steps = Array.from({ length: 13 }, (_, i) => ({
83
+ id: `s${i}`, kind: "action", title: `Step ${i}`, risk: "read-only",
84
+ }));
85
+ const findings = checkGraph(toGraph(doc(steps)));
86
+ expect(findings.filter((f) => f.severity === "error")).toEqual([]);
87
+ // §18.6's cap has two clauses — over twelve *and* undecomposed — and this function
88
+ // can only see one graph. It is checked by the `subgraphs` gate, which has the
89
+ // catalog; warning here punished every record that decomposed as the rule asks.
90
+ expect(findings.map((f) => f.code)).not.toContain("graph/too-many-nodes");
91
+ });
92
+ });
93
+ describe("messages are actionable (RUNBOOK.md 18.7)", () => {
94
+ it("names the step and what to do about it", () => {
95
+ const [finding] = graphErrors(toGraph(doc([{ id: "s4", kind: "action", title: "Drop it", risk: "destructive", next: "s9" },
96
+ { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" }])));
97
+ expect(finding.at).toBe("s4");
98
+ expect(finding.message).toContain("s4");
99
+ expect(finding.message).toMatch(/Add requires_approval|rollback_ref/);
100
+ expect(finding.message).not.toMatch(/something went wrong/i);
101
+ });
102
+ it("every finding carries a code that can resolve to a spec anchor", () => {
103
+ const findings = checkGraph(toGraph(doc([{ id: "s1", kind: "check", title: "C" }])));
104
+ for (const f of findings)
105
+ expect(f.code).toMatch(/^graph\/[a-z-]+$/);
106
+ });
107
+ });
108
+ describe("derivation is deterministic and lossless", () => {
109
+ const FIXTURES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "schema", "fixtures", "p1-valid");
110
+ const docs = readdirSync(FIXTURES).map((name) => ({
111
+ name,
112
+ d: JSON.parse(readFileSync(join(FIXTURES, name), "utf8")),
113
+ }));
114
+ /**
115
+ * Guards the guard: these are `it.each(docs)`, and an empty fixture directory registers
116
+ * no tests at all — §6's determinism would read as checked when nothing was read.
117
+ */
118
+ it("has fixtures to derive", () => {
119
+ expect(docs.length).toBeGreaterThan(0);
120
+ });
121
+ it.each(docs)("$name derives identically twice", ({ d }) => {
122
+ expect(toGraph(d)).toEqual(toGraph(d));
123
+ });
124
+ it.each(docs)("$name round-trips through the graph unchanged", ({ d }) => {
125
+ expect(fromGraph(toGraph(d))).toEqual(d.runbook.steps);
126
+ });
127
+ it.each(docs)("$name has no blocking invariant errors", ({ d }) => {
128
+ expect(graphErrors(toGraph(d))).toEqual([]);
129
+ });
130
+ });
131
+ describe("a cross-runbook rollback does not satisfy invariant 5 in v1", () => {
132
+ // Both steps stay on the main flow whichever way rollback_ref points, so the only
133
+ // thing that varies between the two cases is the rule under test.
134
+ const destructive = (rollback) => doc([
135
+ { id: "s1", kind: "action", title: "Drop it", risk: "destructive", rollback_ref: rollback, next: "s2" },
136
+ { id: "s2", kind: "action", title: "Put it back", risk: "reversible-write", next: "end:success" },
137
+ ]);
138
+ it("accepts a rollback this graph can reach", () => {
139
+ expect(codes(destructive("s2"))).toEqual([]);
140
+ });
141
+ // Relations between runbooks are S-13 (v3). Until they exist a supervisor cannot
142
+ // route to this rollback, so counting it would ship a destructive step with a safety
143
+ // net nothing can deploy.
144
+ it("refuses a rollback that names another runbook", () => {
145
+ expect(codes(destructive("rb_01J8ZQK2M9AAB1CDEFGHJKMNPQ"))).toEqual([
146
+ "graph/destructive-unpaired",
147
+ ]);
148
+ });
149
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Layered layout, top to bottom, with our constraints as hard constraints.
3
+ *
4
+ * A generic Sugiyama implementation optimizes for edge crossings and will happily place
5
+ * a rollback on the right. RUNBOOK.md §6 asks for something else: **consistency of
6
+ * direction matters more than compactness**, because a reader must locate the bad path
7
+ * in two seconds. So:
8
+ *
9
+ * - the happy path is a strictly vertical centre axis, x = 0, no deviation;
10
+ * - `on_fail` and `escalate` go right;
11
+ * - `rollback` goes left;
12
+ * - `retry` is a returning arc and does not affect layering.
13
+ *
14
+ * Deterministic: the same graph yields byte-identical coordinates, which is what makes a
15
+ * rendered SVG diffable and a card's mini-graph silhouette stable across builds. Nothing
16
+ * here consults `x-layout` — auto-layout is canonical (docs/decisions/Q8-layout.md).
17
+ */
18
+ import type { Graph } from "./model.js";
19
+ export interface Point {
20
+ readonly x: number;
21
+ readonly y: number;
22
+ }
23
+ export interface Layout {
24
+ /** Node id to grid position. `x` is columns from the centre axis, `y` is the layer. */
25
+ readonly positions: Readonly<Record<string, Point>>;
26
+ /** Nodes on the happy path, in order. */
27
+ readonly happyPath: readonly string[];
28
+ readonly width: number;
29
+ readonly height: number;
30
+ }
31
+ /**
32
+ * The happy path: from the start, following `next`, and at a `decision` taking the first
33
+ * branch in document order that does not lead straight to an escalation.
34
+ *
35
+ * Deterministic by construction — document order, never a heuristic score — because a
36
+ * silhouette that changes when the layout algorithm is retuned is not a silhouette.
37
+ */
38
+ export declare function happyPath(graph: Graph): string[];
39
+ export declare function layout(graph: Graph): Layout;
40
+ /**
41
+ * Topological order: the sequence a reader should meet the nodes in.
42
+ *
43
+ * Used for keyboard traversal (§18.2) and for the structured list a screen reader gets
44
+ * instead of the picture. A procedure that cannot be read linearly is inaccessible, and
45
+ * every valid graph has such an order — invariant 6 guarantees the only cycles are
46
+ * bounded retries, which are ignored here.
47
+ *
48
+ * Deterministic: ties break by document order, never by id, so two runs and two machines
49
+ * agree.
50
+ */
51
+ export declare function topologicalOrder(graph: Graph): string[];
package/dist/layout.js ADDED
@@ -0,0 +1,185 @@
1
+ import { START } from "./model.js";
2
+ /** Edges that carry the flow forward, and so determine layering. */
3
+ const FORWARD = new Set(["next", "branch", "on_fail"]);
4
+ function outgoing(graph, id) {
5
+ return graph.edges.filter((e) => e.from === id);
6
+ }
7
+ /**
8
+ * The happy path: from the start, following `next`, and at a `decision` taking the first
9
+ * branch in document order that does not lead straight to an escalation.
10
+ *
11
+ * Deterministic by construction — document order, never a heuristic score — because a
12
+ * silhouette that changes when the layout algorithm is retuned is not a silhouette.
13
+ */
14
+ export function happyPath(graph) {
15
+ const kindOf = new Map(graph.nodes.map((n) => [n.id, n.kind]));
16
+ const path = [];
17
+ const seen = new Set();
18
+ let current = START;
19
+ while (current && !seen.has(current)) {
20
+ path.push(current);
21
+ seen.add(current);
22
+ const edges = outgoing(graph, current);
23
+ const next = edges.find((e) => e.kind === "next");
24
+ if (next) {
25
+ current = next.to;
26
+ continue;
27
+ }
28
+ const branch = edges
29
+ .filter((e) => e.kind === "branch")
30
+ .find((e) => kindOf.get(e.to) !== "escalate");
31
+ current = branch?.to;
32
+ }
33
+ return path;
34
+ }
35
+ /**
36
+ * Longest-path layering, so an edge never points upward.
37
+ *
38
+ * Forward and backward are relaxed together rather than one after the other. A node
39
+ * reachable only by `rollback` or `retry` has no forward in-edge, so on its own it keeps
40
+ * depth 0 and lands on the top row — which put "Uncordon the node", the undo for a drain
41
+ * five layers down, above the `start` of the procedure. Placing it under its source is
42
+ * only half the fix: whatever *it* leads to has to move down with it, and a second pass
43
+ * after the first leaves that behind. Running both to a fixed point is what keeps the
44
+ * terminal an undo leads to below the undo.
45
+ */
46
+ function layers(graph) {
47
+ const depth = new Map();
48
+ const forward = graph.edges.filter((e) => FORWARD.has(e.kind));
49
+ const backward = graph.edges.filter((e) => e.kind === "rollback" || e.kind === "retry");
50
+ const reachedForward = new Set(forward.map((e) => e.to));
51
+ for (const n of graph.nodes)
52
+ depth.set(n.id, 0);
53
+ // Relaxation bounded by node count: a forward cycle cannot exist (invariant 6), so
54
+ // this settles, and the bound keeps a malformed graph from hanging an editor.
55
+ for (let pass = 0; pass < graph.nodes.length + 1; pass++) {
56
+ let changed = false;
57
+ const relax = (from, to) => {
58
+ const candidate = (depth.get(from) ?? 0) + 1;
59
+ if (candidate > (depth.get(to) ?? 0)) {
60
+ depth.set(to, candidate);
61
+ changed = true;
62
+ }
63
+ };
64
+ for (const e of forward)
65
+ relax(e.from, e.to);
66
+ // Only for a node nothing forward reaches: a step on the happy path keeps the depth
67
+ // the flow gave it, whatever rolls back to it.
68
+ for (const e of backward)
69
+ if (!reachedForward.has(e.to))
70
+ relax(e.from, e.to);
71
+ if (!changed)
72
+ break;
73
+ }
74
+ return depth;
75
+ }
76
+ export function layout(graph) {
77
+ const depth = layers(graph);
78
+ const path = happyPath(graph);
79
+ const onAxis = new Set(path);
80
+ const positions = {};
81
+ for (const id of path)
82
+ positions[id] = { x: 0, y: depth.get(id) ?? 0 };
83
+ // Sides are assigned by the edge that first reaches a node, in graph order. `rollback`
84
+ // wins over `on_fail` when both reach it: an undo is the more important thing to find.
85
+ const side = new Map();
86
+ for (const e of graph.edges) {
87
+ if (onAxis.has(e.to))
88
+ continue;
89
+ if (e.kind === "rollback")
90
+ side.set(e.to, -1);
91
+ else if ((e.kind === "on_fail" || e.kind === "branch") && !side.has(e.to))
92
+ side.set(e.to, 1);
93
+ }
94
+ // An escalation is a bad path wherever it was reached from.
95
+ for (const n of graph.nodes) {
96
+ if (n.kind === "escalate" && !onAxis.has(n.id))
97
+ side.set(n.id, 1);
98
+ }
99
+ /** Columns already taken per layer per side, so two off-axis nodes never overlap. */
100
+ const used = new Map();
101
+ for (const n of graph.nodes) {
102
+ if (positions[n.id])
103
+ continue;
104
+ const y = depth.get(n.id) ?? 0;
105
+ const direction = side.get(n.id) ?? 1;
106
+ const key = `${y}:${direction}`;
107
+ const taken = used.get(key) ?? 0;
108
+ used.set(key, taken + 1);
109
+ positions[n.id] = { x: direction * (taken + 1), y };
110
+ }
111
+ const xs = Object.values(positions).map((p) => p.x);
112
+ const ys = Object.values(positions).map((p) => p.y);
113
+ return {
114
+ positions,
115
+ happyPath: path,
116
+ width: Math.max(...xs) - Math.min(...xs) + 1,
117
+ height: Math.max(...ys) + 1,
118
+ };
119
+ }
120
+ /**
121
+ * Topological order: the sequence a reader should meet the nodes in.
122
+ *
123
+ * Used for keyboard traversal (§18.2) and for the structured list a screen reader gets
124
+ * instead of the picture. A procedure that cannot be read linearly is inaccessible, and
125
+ * every valid graph has such an order — invariant 6 guarantees the only cycles are
126
+ * bounded retries, which are ignored here.
127
+ *
128
+ * Deterministic: ties break by document order, never by id, so two runs and two machines
129
+ * agree.
130
+ */
131
+ export function topologicalOrder(graph) {
132
+ const forward = graph.edges.filter((e) => FORWARD.has(e.kind));
133
+ const remaining = new Map();
134
+ for (const n of graph.nodes)
135
+ remaining.set(n.id, 0);
136
+ for (const e of forward)
137
+ remaining.set(e.to, (remaining.get(e.to) ?? 0) + 1);
138
+ /**
139
+ * What each step exists to undo.
140
+ *
141
+ * A rollback edge is not a flow edge — including it in `FORWARD` would make a cycle of
142
+ * every paired destructive step — so the step it points at has no incoming flow at all
143
+ * and lands wherever the node list happens to put it. On `std/k8s-node-not-ready-drain`
144
+ * that put "Uncordon the node" second, ahead of the drain it undoes: a reader following
145
+ * the list, which is what a screen reader and a no-CSS reader get, was handed the
146
+ * procedure with its undo before its action.
147
+ *
148
+ * The constraint below only chooses between nodes the flow leaves free, so this is
149
+ * still a topological order over the flow edges — it is the arbitrary part of the
150
+ * choice that is being spent on something a reader can use.
151
+ */
152
+ const undoes = new Map();
153
+ for (const e of graph.edges)
154
+ if (e.kind === "rollback")
155
+ undoes.set(e.to, e.from);
156
+ const order = [];
157
+ const emitted = new Set();
158
+ const ready = graph.nodes.filter((n) => (remaining.get(n.id) ?? 0) === 0).map((n) => n.id);
159
+ while (ready.length > 0) {
160
+ /*
161
+ * The first node whose undone step has already been read, and the first node outright
162
+ * when none qualifies. That fallback is what keeps a pair of steps rolling each other
163
+ * back from deadlocking the traversal — a malformed graph is still traversable.
164
+ */
165
+ const at = ready.findIndex((candidate) => {
166
+ const undone = undoes.get(candidate);
167
+ return undone === undefined || emitted.has(undone);
168
+ });
169
+ const id = ready.splice(at === -1 ? 0 : at, 1)[0];
170
+ emitted.add(id);
171
+ order.push(id);
172
+ for (const e of forward.filter((x) => x.from === id)) {
173
+ const left = (remaining.get(e.to) ?? 0) - 1;
174
+ remaining.set(e.to, left);
175
+ if (left === 0)
176
+ ready.push(e.to);
177
+ }
178
+ }
179
+ // A malformed graph must still be traversable: anything left over is appended in
180
+ // document order rather than silently dropped from keyboard navigation.
181
+ for (const n of graph.nodes)
182
+ if (!order.includes(n.id))
183
+ order.push(n.id);
184
+ return order;
185
+ }
@@ -0,0 +1 @@
1
+ export {};