@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.
package/dist/edit.js ADDED
@@ -0,0 +1,299 @@
1
+ import { isDangerous, plural } from "@runbooks/schema";
2
+ export const STEP_KINDS = ["check", "action", "decision", "wait", "escalate"];
3
+ /** The next free `sN`, so ids stay short and predictable rather than opaque. */
4
+ export function nextId(steps) {
5
+ let n = steps.length + 1;
6
+ const taken = new Set(steps.map((step) => step.id));
7
+ while (taken.has(`s${n}`))
8
+ n += 1;
9
+ return `s${n}`;
10
+ }
11
+ /**
12
+ * Add a step.
13
+ *
14
+ * The kind is a parameter rather than something set later, which is the whole of rule 2:
15
+ * an editor that creates a node and asks afterwards has a state where the node exists and
16
+ * nobody has decided what it is — and that state ends up in a draft, in an undo stack,
17
+ * and eventually in a file.
18
+ *
19
+ * The new step inherits the `next` of whatever it follows and takes its place, so
20
+ * inserting into a chain does not leave the chain pointing past it.
21
+ */
22
+ export function addStep(steps, input) {
23
+ if (!STEP_KINDS.includes(input.kind)) {
24
+ return {
25
+ ok: false,
26
+ failure: {
27
+ code: "kind-required",
28
+ why: `A step needs one of ${STEP_KINDS.join(", ")}. There is no placeholder node: a node nobody has decided about ends up in a draft and then in a file.`,
29
+ },
30
+ };
31
+ }
32
+ const id = input.id ?? nextId(steps);
33
+ if (steps.some((step) => step.id === id)) {
34
+ return { ok: false, failure: { code: "duplicate-id", why: `${id} is already a step.` } };
35
+ }
36
+ const created = { id, kind: input.kind, title: input.title };
37
+ if (!input.after)
38
+ return { ok: true, steps: [...steps, created] };
39
+ const index = steps.findIndex((step) => step.id === input.after);
40
+ if (index < 0) {
41
+ return { ok: false, failure: { code: "unknown-step", why: `${input.after} is not a step.` } };
42
+ }
43
+ const previous = steps[index];
44
+ const inherited = previous.next;
45
+ return {
46
+ ok: true,
47
+ steps: [
48
+ ...steps.slice(0, index),
49
+ { ...previous, next: id },
50
+ ...(inherited ? [{ ...created, next: inherited }] : [created]),
51
+ ...steps.slice(index + 1),
52
+ ],
53
+ };
54
+ }
55
+ function referencesTo(steps, id) {
56
+ return steps
57
+ .filter((step) => step.next === id ||
58
+ step.on_fail === id ||
59
+ step.on_fail === `escalate:${id}` ||
60
+ step.rollback_ref === id ||
61
+ step.retry?.target === id ||
62
+ Object.values(step.branches ?? {}).includes(id))
63
+ .map((step) => step.id);
64
+ }
65
+ /** Re-point every reference to `from` at `to`, or remove it when `to` is undefined. */
66
+ function repoint(step, from, to) {
67
+ const swap = (value) => {
68
+ if (value === undefined)
69
+ return undefined;
70
+ if (value === from)
71
+ return to;
72
+ if (value === `escalate:${from}`)
73
+ return to ? `escalate:${to}` : undefined;
74
+ return value;
75
+ };
76
+ const branches = step.branches
77
+ ? Object.fromEntries(Object.entries(step.branches)
78
+ .map(([label, target]) => [label, target === from ? to : target])
79
+ .filter((entry) => entry[1] !== undefined))
80
+ : undefined;
81
+ const next = { ...step };
82
+ const setOrDelete = (key, value) => {
83
+ if (value === undefined)
84
+ delete next[key];
85
+ else
86
+ next[key] = value;
87
+ };
88
+ setOrDelete("next", swap(step.next));
89
+ setOrDelete("on_fail", swap(step.on_fail));
90
+ setOrDelete("rollback_ref", swap(step.rollback_ref));
91
+ if (step.retry?.target === from) {
92
+ if (to)
93
+ next.retry = { ...step.retry, target: to };
94
+ else
95
+ delete next.retry;
96
+ }
97
+ if (branches)
98
+ next.branches = branches;
99
+ return next;
100
+ }
101
+ /**
102
+ * Delete a step, reconnecting what pointed at it.
103
+ *
104
+ * Refusing without an instruction is the point. An editor that deletes and leaves the
105
+ * edges is an editor that produces a document the linter rejects for a reason the person
106
+ * who deleted the node will not connect to what they did.
107
+ */
108
+ export function deleteStep(steps, id, options = {}) {
109
+ if (!steps.some((step) => step.id === id)) {
110
+ return { ok: false, failure: { code: "unknown-step", why: `${id} is not a step.` } };
111
+ }
112
+ const pointing = referencesTo(steps, id);
113
+ if (pointing.length > 0 && options.reconnectTo === undefined) {
114
+ return {
115
+ ok: false,
116
+ failure: {
117
+ code: "reconnect-required",
118
+ why: `${pointing.join(", ")} point at ${id}. Say where those edges go — another step, or "drop" — because a dangling edge does not exist here even in a draft.`,
119
+ },
120
+ };
121
+ }
122
+ if (options.reconnectTo &&
123
+ options.reconnectTo !== "drop" &&
124
+ !steps.some((step) => step.id === options.reconnectTo)) {
125
+ return {
126
+ ok: false,
127
+ failure: { code: "unknown-step", why: `${options.reconnectTo} is not a step to reconnect to.` },
128
+ };
129
+ }
130
+ const to = options.reconnectTo === "drop" || options.reconnectTo === undefined ? undefined : options.reconnectTo;
131
+ return {
132
+ ok: true,
133
+ steps: steps.filter((step) => step.id !== id).map((step) => repoint(step, id, to)),
134
+ ...(pointing.length > 0
135
+ ? {
136
+ note: to
137
+ ? `${plural(pointing.length, "edge")} now point at ${to}.`
138
+ : `${plural(pointing.length, "edge")} were removed with it.`,
139
+ }
140
+ : {}),
141
+ };
142
+ }
143
+ /**
144
+ * Which edges a kind may have.
145
+ *
146
+ * Derived from what the kinds mean rather than from what the schema tolerates: a `wait`
147
+ * has no failure route because waiting does not fail, it times out; an `escalate` has no
148
+ * outgoing edge at all because it is where a procedure hands off to a person.
149
+ */
150
+ export const LEGAL_EDGES = {
151
+ check: ["next", "on_fail"],
152
+ action: ["next", "on_fail", "rollback"],
153
+ decision: ["branch"],
154
+ wait: ["next", "retry"],
155
+ escalate: [],
156
+ };
157
+ export function connect(steps, edge) {
158
+ const from = steps.find((step) => step.id === edge.from);
159
+ const to = steps.find((step) => step.id === edge.to);
160
+ if (!from)
161
+ return { ok: false, failure: { code: "unknown-step", why: `${edge.from} is not a step.` } };
162
+ if (!to && !edge.to.startsWith("end:")) {
163
+ return { ok: false, failure: { code: "unknown-step", why: `${edge.to} is not a step.` } };
164
+ }
165
+ const allowed = LEGAL_EDGES[from.kind];
166
+ if (!allowed.includes(edge.kind)) {
167
+ return {
168
+ ok: false,
169
+ failure: {
170
+ code: "illegal-edge",
171
+ why: `A ${from.kind} has no ${edge.kind} edge. It may have: ${allowed.join(", ") || "none — this is where a procedure hands off to a person"}.`,
172
+ },
173
+ };
174
+ }
175
+ if (edge.kind === "branch" && !edge.label) {
176
+ return {
177
+ ok: false,
178
+ failure: {
179
+ code: "illegal-edge",
180
+ why: "A branch needs a label. An unlabelled branch is a choice nobody reading the graph can make.",
181
+ },
182
+ };
183
+ }
184
+ const updated = { ...from };
185
+ if (edge.kind === "next")
186
+ updated.next = edge.to;
187
+ if (edge.kind === "on_fail")
188
+ updated.on_fail = edge.to;
189
+ if (edge.kind === "rollback")
190
+ updated.rollback_ref = edge.to;
191
+ if (edge.kind === "retry")
192
+ updated.retry = { ...(from.retry ?? {}), max: from.retry?.max ?? 3, target: edge.to };
193
+ if (edge.kind === "branch")
194
+ updated.branches = { ...(from.branches ?? {}), [edge.label]: edge.to };
195
+ return { ok: true, steps: steps.map((step) => (step.id === from.id ? updated : step)) };
196
+ }
197
+ export function begin(steps) {
198
+ return { past: [], present: steps, future: [] };
199
+ }
200
+ export function commit(history, steps) {
201
+ return { past: [...history.past, history.present], present: steps, future: [] };
202
+ }
203
+ export function undo(history) {
204
+ const previous = history.past.at(-1);
205
+ if (!previous)
206
+ return history;
207
+ return {
208
+ past: history.past.slice(0, -1),
209
+ present: previous,
210
+ future: [history.present, ...history.future],
211
+ };
212
+ }
213
+ export function redo(history) {
214
+ const [next, ...rest] = history.future;
215
+ if (!next)
216
+ return history;
217
+ return { past: [...history.past, history.present], present: next, future: rest };
218
+ }
219
+ export function suggestForRisk(steps, id, risk) {
220
+ const step = steps.find((candidate) => candidate.id === id);
221
+ if (!step)
222
+ return { kind: "none", why: `${id} is not a step.` };
223
+ if (!isDangerous(risk)) {
224
+ return { kind: "none", why: `${risk} needs no pairing: invariant 5 applies to destructive and irreversible work.` };
225
+ }
226
+ if (step.requires_approval || step.rollback_ref) {
227
+ return { kind: "none", why: `${id} is already paired with ${step.requires_approval ? "an approval gate" : "a rollback"}.` };
228
+ }
229
+ /**
230
+ * A gate for irreversible work, a rollback offered first for destructive.
231
+ *
232
+ * Undoing is better than asking where undoing is possible: a rollback restores the
233
+ * system, an approval only moves the decision to a person who may also be wrong at
234
+ * 3am. Where nothing can restore it — which is what irreversible means — a person is
235
+ * the only thing left.
236
+ */
237
+ if (risk === "irreversible") {
238
+ return {
239
+ kind: "gate",
240
+ why: `${id} is irreversible, so invariant 5 requires a pairing and a rollback cannot be one: nothing restores what cannot be undone.`,
241
+ action: "Add an approval gate before this step",
242
+ };
243
+ }
244
+ return {
245
+ kind: "rollback",
246
+ why: `${id} is destructive, so invariant 5 requires either a step that undoes it or a human decision before it.`,
247
+ action: "Add a step that undoes this one, and point at it",
248
+ };
249
+ }
250
+ /**
251
+ * Accept the offer, in one action.
252
+ *
253
+ * The rollback case inserts the undoing step as well as pointing at it, because "specify
254
+ * a rollback" with nothing to specify is not one action — it is an offer that hands back
255
+ * a second task.
256
+ */
257
+ export function acceptRiskSuggestion(steps, id, suggestion) {
258
+ const step = steps.find((candidate) => candidate.id === id);
259
+ if (!step)
260
+ return { ok: false, failure: { code: "unknown-step", why: `${id} is not a step.` } };
261
+ if (suggestion.kind === "none") {
262
+ return { ok: false, failure: { code: "illegal-edge", why: suggestion.why } };
263
+ }
264
+ if (suggestion.kind === "gate") {
265
+ return {
266
+ ok: true,
267
+ steps: steps.map((candidate) => candidate.id === id ? { ...candidate, requires_approval: true } : candidate),
268
+ note: `${id} now halts for a human decision before it runs.`,
269
+ };
270
+ }
271
+ const undoId = nextId(steps);
272
+ const undo = {
273
+ id: undoId,
274
+ kind: "action",
275
+ title: `Undo ${step.title.toLowerCase()}`,
276
+ risk: "reversible-write",
277
+ next: "end:aborted",
278
+ };
279
+ return {
280
+ ok: true,
281
+ steps: [
282
+ ...steps.map((candidate) => candidate.id === id ? { ...candidate, rollback_ref: undoId } : candidate),
283
+ undo,
284
+ ],
285
+ note: `${undoId} was added as the step that undoes ${id}. It needs a command before it is worth anything — a rollback nobody wrote is a rollback nobody can run.`,
286
+ };
287
+ }
288
+ /** Set a risk and, where it changes what the graph needs, say what would fix it. */
289
+ export function setRisk(steps, id, risk) {
290
+ const step = steps.find((candidate) => candidate.id === id);
291
+ if (!step) {
292
+ return {
293
+ result: { ok: false, failure: { code: "unknown-step", why: `${id} is not a step.` } },
294
+ suggestion: { kind: "none", why: `${id} is not a step.` },
295
+ };
296
+ }
297
+ const updated = steps.map((candidate) => (candidate.id === id ? { ...candidate, risk } : candidate));
298
+ return { result: { ok: true, steps: updated }, suggestion: suggestForRisk(updated, id, risk) };
299
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { toGraph } from "./model.js";
3
+ import { addStep, deleteStep, connect, nextId, begin, commit, undo, redo, STEP_KINDS, LEGAL_EDGES, } from "./edit.js";
4
+ const ok = (result) => {
5
+ if (!result.ok)
6
+ throw new Error(`expected success: ${result.failure.why}`);
7
+ return result.steps;
8
+ };
9
+ const base = [
10
+ { id: "s1", kind: "check", title: "Look", tool: "cli:df", expect: "there", on_fail: "s3", next: "s2" },
11
+ { id: "s2", kind: "action", title: "Fix", tool: "cli:rm", next: "end:success" },
12
+ { id: "s3", kind: "escalate", title: "Escalate", to: "team:x" },
13
+ ];
14
+ /** §18.3 rule 2: a node cannot exist without a kind. There is no placeholder. */
15
+ describe("a step is created with its kind or not at all", () => {
16
+ it("takes the kind as a parameter rather than setting it later", () => {
17
+ const steps = ok(addStep(base, { kind: "wait", title: "Wait for it" }));
18
+ expect(steps.at(-1).kind).toBe("wait");
19
+ });
20
+ it("refuses a kind that is not one of the five", () => {
21
+ const result = addStep(base, { kind: "placeholder", title: "Later" });
22
+ expect(result.ok).toBe(false);
23
+ if (!result.ok)
24
+ expect(result.failure.why).toMatch(/There is no placeholder node/);
25
+ });
26
+ it("offers exactly the five kinds §5 defines", () => {
27
+ expect([...STEP_KINDS]).toEqual(["check", "action", "decision", "wait", "escalate"]);
28
+ });
29
+ it("gives a new step an unused id", () => {
30
+ expect(nextId(base)).toBe("s4");
31
+ expect(nextId([...base, { id: "s4", kind: "wait", title: "W" }])).toBe("s5");
32
+ });
33
+ it("inserts into a chain without leaving it pointing past the new step", () => {
34
+ const steps = ok(addStep(base, { kind: "check", title: "Confirm", after: "s1" }));
35
+ const first = steps.find((s) => s.id === "s1");
36
+ const inserted = steps.find((s) => s.id === first.next);
37
+ expect(inserted.title).toBe("Confirm");
38
+ expect(inserted.next).toBe("s2");
39
+ });
40
+ });
41
+ /** §18.3 rule 4: deletion requires reconnection. A dangling edge does not exist. */
42
+ describe("deleting a step says where its edges go", () => {
43
+ it("refuses while something points at it", () => {
44
+ const result = deleteStep(base, "s2");
45
+ expect(result.ok).toBe(false);
46
+ if (!result.ok) {
47
+ expect(result.failure.code).toBe("reconnect-required");
48
+ expect(result.failure.why).toMatch(/does not exist here even in a draft/);
49
+ }
50
+ });
51
+ it("re-points the incoming edges when told where", () => {
52
+ const steps = ok(deleteStep(base, "s2", { reconnectTo: "s3" }));
53
+ expect(steps.find((s) => s.id === "s1").next).toBe("s3");
54
+ expect(steps.some((s) => s.id === "s2")).toBe(false);
55
+ });
56
+ it("removes them when told to drop them, rather than silently", () => {
57
+ const steps = ok(deleteStep(base, "s2", { reconnectTo: "drop" }));
58
+ expect(steps.find((s) => s.id === "s1").next).toBeUndefined();
59
+ });
60
+ it("re-points an escalate: reference, keeping the prefix the author wrote", () => {
61
+ const prefixed = base.map((step) => step.id === "s1" ? { ...step, on_fail: "escalate:s3" } : step);
62
+ const steps = ok(deleteStep(prefixed, "s3", { reconnectTo: "s2" }));
63
+ expect(steps.find((s) => s.id === "s1").on_fail).toBe("escalate:s2");
64
+ });
65
+ it("leaves a plain reference plain", () => {
66
+ const steps = ok(deleteStep(base, "s3", { reconnectTo: "s2" }));
67
+ expect(steps.find((s) => s.id === "s1").on_fail).toBe("s2");
68
+ });
69
+ it("deletes a step nothing points at without asking", () => {
70
+ const orphan = [...base, { id: "s9", kind: "wait", title: "Alone" }];
71
+ expect(ok(deleteStep(orphan, "s9")).some((s) => s.id === "s9")).toBe(false);
72
+ });
73
+ it("refuses to reconnect to a step that does not exist", () => {
74
+ expect(deleteStep(base, "s2", { reconnectTo: "s99" }).ok).toBe(false);
75
+ });
76
+ });
77
+ /** Only legal edge types between legal node types (§6, §18.3). */
78
+ describe("edges are constrained by what the kinds mean", () => {
79
+ it.each([
80
+ ["check", "next", true],
81
+ ["check", "on_fail", true],
82
+ ["check", "rollback", false],
83
+ ["action", "rollback", true],
84
+ ["decision", "branch", true],
85
+ ["decision", "next", false],
86
+ ["wait", "retry", true],
87
+ ["escalate", "next", false],
88
+ ])("a %s may have a %s edge: %s", (kind, edge, allowed) => {
89
+ expect(LEGAL_EDGES[kind].includes(edge)).toBe(allowed);
90
+ });
91
+ it("says what an escalate is instead of listing nothing", () => {
92
+ const escalate = base.find((s) => s.id === "s3");
93
+ const result = connect(base, { from: escalate.id, to: "s1", kind: "next" });
94
+ expect(result.ok).toBe(false);
95
+ if (!result.ok)
96
+ expect(result.failure.why).toMatch(/hands off to a person/);
97
+ });
98
+ it("refuses an unlabelled branch", () => {
99
+ const withDecision = ok(addStep(base, { kind: "decision", title: "Which way", id: "s4" }));
100
+ const result = connect(withDecision, { from: "s4", to: "s2", kind: "branch" });
101
+ expect(result.ok).toBe(false);
102
+ if (!result.ok)
103
+ expect(result.failure.why).toMatch(/choice nobody reading the graph can make/);
104
+ });
105
+ it("accepts a labelled one", () => {
106
+ const withDecision = ok(addStep(base, { kind: "decision", title: "Which way", id: "s4" }));
107
+ const steps = ok(connect(withDecision, { from: "s4", to: "s2", kind: "branch", label: "yes" }));
108
+ expect(steps.find((s) => s.id === "s4").branches).toEqual({ yes: "s2" });
109
+ });
110
+ it("connects to a terminal without requiring it to be a step", () => {
111
+ expect(connect(base, { from: "s2", to: "end:failed", kind: "next" }).ok).toBe(true);
112
+ });
113
+ /** The editor may not draw an approval edge: the derivation adds those at every gate. */
114
+ it("offers no approval edge, since the derivation adds them", () => {
115
+ for (const kind of STEP_KINDS) {
116
+ expect(LEGAL_EDGES[kind]).not.toContain("approval");
117
+ }
118
+ });
119
+ });
120
+ /**
121
+ * W-10's first criterion, property-tested rather than clicked through: no sequence of
122
+ * operations reaches a dangling edge or a kindless node.
123
+ */
124
+ describe("no sequence of operations reaches a broken draft", () => {
125
+ function randomSequence(seed) {
126
+ // A small deterministic PRNG, so a failure is reproducible from its seed.
127
+ let state = seed;
128
+ const random = () => ((state = (state * 1103515245 + 12345) % 2147483648) / 2147483648);
129
+ const pick = (items) => items[Math.floor(random() * items.length)];
130
+ let steps = [...base];
131
+ for (let i = 0; i < 40; i++) {
132
+ const roll = random();
133
+ if (roll < 0.4) {
134
+ const result = addStep(steps, {
135
+ kind: pick(STEP_KINDS),
136
+ title: `Step ${i}`,
137
+ ...(steps.length > 0 && random() < 0.5 ? { after: pick(steps).id } : {}),
138
+ });
139
+ if (result.ok)
140
+ steps = result.steps;
141
+ }
142
+ else if (roll < 0.7 && steps.length > 1) {
143
+ const victim = pick(steps);
144
+ const survivor = steps.find((s) => s.id !== victim.id);
145
+ const result = deleteStep(steps, victim.id, {
146
+ reconnectTo: random() < 0.5 ? survivor.id : "drop",
147
+ });
148
+ if (result.ok)
149
+ steps = result.steps;
150
+ }
151
+ else if (steps.length > 1) {
152
+ const from = pick(steps);
153
+ const to = pick(steps);
154
+ const result = connect(steps, {
155
+ from: from.id,
156
+ to: to.id,
157
+ kind: pick(LEGAL_EDGES[from.kind].length > 0 ? LEGAL_EDGES[from.kind] : ["next"]),
158
+ label: "branch-label",
159
+ });
160
+ if (result.ok)
161
+ steps = result.steps;
162
+ }
163
+ }
164
+ return steps;
165
+ }
166
+ it.each([1, 7, 42, 1337, 90210, 5150, 271828, 31415])("seed %i leaves no broken step", (seed) => {
167
+ const steps = randomSequence(seed);
168
+ const ids = new Set(steps.map((step) => step.id));
169
+ for (const step of steps) {
170
+ expect(step.kind, `${step.id} has no kind`).toBeDefined();
171
+ expect(STEP_KINDS).toContain(step.kind);
172
+ expect(step.title.length).toBeGreaterThan(0);
173
+ const targets = [
174
+ step.next,
175
+ step.on_fail?.replace(/^escalate:/, ""),
176
+ step.rollback_ref,
177
+ step.retry?.target,
178
+ ...Object.values(step.branches ?? {}),
179
+ ].filter((target) => typeof target === "string");
180
+ for (const target of targets) {
181
+ if (target.startsWith("end:"))
182
+ continue;
183
+ expect(ids.has(target), `${step.id} points at ${target}, which is not a step`).toBe(true);
184
+ }
185
+ }
186
+ });
187
+ it.each([3, 99, 2024])("seed %i still derives a graph", (seed) => {
188
+ const steps = randomSequence(seed);
189
+ expect(() => toGraph({ runbook: { steps } })).not.toThrow();
190
+ });
191
+ });
192
+ /**
193
+ * "Undo restores state exactly, including after an automatic insertion." A history of
194
+ * documents rather than of operations, because the inverse of "add a step" is not
195
+ * "delete a step" when adding one also inserted a gate.
196
+ */
197
+ describe("undo and redo", () => {
198
+ it("restores the previous document exactly", () => {
199
+ const history = commit(begin(base), ok(addStep(base, { kind: "wait", title: "Wait" })));
200
+ expect(undo(history).present).toEqual(base);
201
+ });
202
+ it("restores it exactly after an insertion that changed two steps", () => {
203
+ const inserted = ok(addStep(base, { kind: "check", title: "Confirm", after: "s1" }));
204
+ expect(inserted.find((s) => s.id === "s1").next).not.toBe("s2");
205
+ expect(undo(commit(begin(base), inserted)).present).toEqual(base);
206
+ });
207
+ it("redoes what it undid", () => {
208
+ const changed = ok(addStep(base, { kind: "wait", title: "Wait" }));
209
+ const history = commit(begin(base), changed);
210
+ expect(redo(undo(history)).present).toEqual(changed);
211
+ });
212
+ it("does nothing at the ends rather than throwing", () => {
213
+ expect(undo(begin(base)).present).toEqual(base);
214
+ expect(redo(begin(base)).present).toEqual(base);
215
+ });
216
+ it("drops the redo stack once something new is done, as every editor does", () => {
217
+ const first = commit(begin(base), ok(addStep(base, { kind: "wait", title: "One" })));
218
+ const undone = undo(first);
219
+ const other = commit(undone, ok(addStep(base, { kind: "wait", title: "Two" })));
220
+ expect(other.future).toEqual([]);
221
+ });
222
+ it("survives a long session, holding each state rather than replaying operations", () => {
223
+ let history = begin(base);
224
+ for (let i = 0; i < 20; i++) {
225
+ history = commit(history, ok(addStep(history.present, { kind: "wait", title: `W${i}` })));
226
+ }
227
+ for (let i = 0; i < 20; i++)
228
+ history = undo(history);
229
+ expect(history.present).toEqual(base);
230
+ });
231
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Graph model, invariants, layout.
3
+ * Implementation: tasks/v0/P-07-graph-model-package.md, P-08-graph-layout.md
4
+ *
5
+ * No dependency on React, the DOM, or Node: this runs in the browser for the live
6
+ * editor and in the gate runner for linting.
7
+ */
8
+ export * from "./model.js";
9
+ export * from "./invariants.js";
10
+ export * from "./layout.js";
11
+ export * from "./paths.js";
12
+ export * from "./edit.js";
13
+ export * from "./simulate.js";
14
+ export * from "./diff.js";
15
+ export * from "./relations.js";
16
+ export * from "./subgraph.js";
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Graph model, invariants, layout.
3
+ * Implementation: tasks/v0/P-07-graph-model-package.md, P-08-graph-layout.md
4
+ *
5
+ * No dependency on React, the DOM, or Node: this runs in the browser for the live
6
+ * editor and in the gate runner for linting.
7
+ */
8
+ export * from "./model.js";
9
+ export * from "./invariants.js";
10
+ export * from "./layout.js";
11
+ export * from "./paths.js";
12
+ export * from "./edit.js";
13
+ export * from "./simulate.js";
14
+ export * from "./diff.js";
15
+ export * from "./relations.js";
16
+ export * from "./subgraph.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The seven blocking invariants of RUNBOOK.md 6, plus the four warnings.
3
+ *
4
+ * These are the structural half of T1/T2 (section 8): the same rules the linter runs in
5
+ * CI, the editor runs on every idle keystroke, and the renderer assumes hold. Each
6
+ * returns the node or edge it failed on, so an error can highlight what is wrong rather
7
+ * than describing it.
8
+ *
9
+ * Messages follow section 18.7: name the location, name the fix, never "something went
10
+ * wrong". Every code resolves to a spec anchor (`/spec/v1/graph-model`), which is what
11
+ * lets an error message be short without being unhelpful.
12
+ */
13
+ import type { Graph } from "./model.js";
14
+ export type Severity = "error" | "warning";
15
+ export interface GraphFinding {
16
+ readonly code: string;
17
+ readonly severity: Severity;
18
+ readonly message: string;
19
+ /** Node id, or `from->to` for an edge. */
20
+ readonly at: string;
21
+ }
22
+ /**
23
+ * What the document says about itself that a graph alone cannot: how it is meant to be run.
24
+ *
25
+ * The graph is the same shape whoever executes it. Some warnings are not — see the tool
26
+ * warning in `warnings` — so the caller passes what it knows rather than the check
27
+ * guessing, and a caller with only a graph gets the graph-only answers.
28
+ */
29
+ export interface GraphContext {
30
+ readonly execution?: string;
31
+ }
32
+ export declare function checkGraph(g: Graph, context?: GraphContext): GraphFinding[];
33
+ export declare function graphErrors(g: Graph, context?: GraphContext): GraphFinding[];