@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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/diff.d.ts +74 -0
- package/dist/diff.js +148 -0
- package/dist/diff.test.d.ts +1 -0
- package/dist/diff.test.js +140 -0
- package/dist/edit.d.ts +160 -0
- package/dist/edit.js +299 -0
- package/dist/edit.test.d.ts +1 -0
- package/dist/edit.test.js +231 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/invariants.d.ts +33 -0
- package/dist/invariants.js +253 -0
- package/dist/invariants.test.d.ts +1 -0
- package/dist/invariants.test.js +149 -0
- package/dist/layout.d.ts +51 -0
- package/dist/layout.js +185 -0
- package/dist/layout.test.d.ts +1 -0
- package/dist/layout.test.js +181 -0
- package/dist/model.d.ts +106 -0
- package/dist/model.js +118 -0
- package/dist/paths.d.ts +47 -0
- package/dist/paths.js +84 -0
- package/dist/paths.test.d.ts +1 -0
- package/dist/paths.test.js +255 -0
- package/dist/relations.d.ts +118 -0
- package/dist/relations.js +213 -0
- package/dist/relations.test.d.ts +1 -0
- package/dist/relations.test.js +140 -0
- package/dist/simulate.d.ts +64 -0
- package/dist/simulate.js +115 -0
- package/dist/simulate.test.d.ts +1 -0
- package/dist/simulate.test.js +109 -0
- package/dist/subgraph.d.ts +69 -0
- package/dist/subgraph.js +119 -0
- package/dist/subgraph.test.d.ts +1 -0
- package/dist/subgraph.test.js +127 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhail Dorokhovich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# `@runbooks/graph`
|
|
2
|
+
|
|
3
|
+
The graph model, the seven blocking invariants, and layered layout.
|
|
4
|
+
|
|
5
|
+
**Tasks:** [P-07](../../tasks/v0/P-07-graph-model-package.md),
|
|
6
|
+
[P-08](../../tasks/v0/P-08-graph-layout.md) · **Normative:** RUNBOOK.md §6
|
|
7
|
+
|
|
8
|
+
One model, three consumers: the linter, the renderer, the editor. If the editor grows
|
|
9
|
+
its own graph representation, the two drift and the constructor starts producing
|
|
10
|
+
documents the linter rejects for reasons the editor cannot explain.
|
|
11
|
+
|
|
12
|
+
No dependency on React, the DOM, or Node — it runs in the browser for live editing and
|
|
13
|
+
in CI for linting.
|
|
14
|
+
|
|
15
|
+
Layout is not generic Sugiyama: the happy path is a strictly vertical centre axis,
|
|
16
|
+
`on_fail`/`escalate` go right, `rollback` goes left. Consistency of direction beats
|
|
17
|
+
compactness, because a reader must locate the bad path in two seconds.
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph diff (W-17, §18.4).
|
|
3
|
+
*
|
|
4
|
+
* `/r/{…}/diff/{a}..{b}` answers one question a textual diff buries: **did anything get
|
|
5
|
+
* more dangerous?** A version bump that turns a `reversible-write` into a `destructive`
|
|
6
|
+
* and one that renames a step are not comparable events, and a unified diff shows them as
|
|
7
|
+
* the same number of changed lines.
|
|
8
|
+
*
|
|
9
|
+
* So risk changes are their own class here, and so are capability changes — a new
|
|
10
|
+
* capability in a new version is a new privilege request, and it deserves the prominence
|
|
11
|
+
* that word implies rather than a green plus sign.
|
|
12
|
+
*
|
|
13
|
+
* Alignment is by id first. Ids are how a document refers to its own steps, and two
|
|
14
|
+
* versions that kept them are telling us what corresponds to what. Where an id vanished
|
|
15
|
+
* and another appeared, the structural match is offered as a *candidate* and marked as
|
|
16
|
+
* one: an alignment that guessed silently would draw a correspondence nobody wrote.
|
|
17
|
+
*/
|
|
18
|
+
import type { Graph, GraphEdge, GraphNode } from "./model.js";
|
|
19
|
+
import type { Risk } from "@runbooks/schema";
|
|
20
|
+
export type Change = "added" | "removed" | "unchanged" | "modified";
|
|
21
|
+
export interface NodeDiff {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly change: Change;
|
|
24
|
+
readonly before?: GraphNode;
|
|
25
|
+
readonly after?: GraphNode;
|
|
26
|
+
/** Set when the risk moved. `escalated` is the one this whole view exists for. */
|
|
27
|
+
readonly risk?: {
|
|
28
|
+
readonly from?: Risk;
|
|
29
|
+
readonly to?: Risk;
|
|
30
|
+
readonly direction: "escalated" | "reduced";
|
|
31
|
+
};
|
|
32
|
+
/** Set when a matched pair was matched structurally rather than by id. */
|
|
33
|
+
readonly matchedBy?: "position";
|
|
34
|
+
/** What else moved: title, command, tool, approval. Named, never counted. */
|
|
35
|
+
readonly fields?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
export interface EdgeDiff {
|
|
38
|
+
readonly from: string;
|
|
39
|
+
readonly to: string;
|
|
40
|
+
readonly kind: GraphEdge["kind"];
|
|
41
|
+
readonly change: Exclude<Change, "modified">;
|
|
42
|
+
}
|
|
43
|
+
export interface GraphDiff {
|
|
44
|
+
readonly nodes: readonly NodeDiff[];
|
|
45
|
+
readonly edges: readonly EdgeDiff[];
|
|
46
|
+
/** Risk escalations, first, because they are the reason this page exists. */
|
|
47
|
+
readonly escalations: readonly NodeDiff[];
|
|
48
|
+
/** A new capability is a new privilege request (§13.2). */
|
|
49
|
+
readonly capabilities: {
|
|
50
|
+
readonly added: readonly string[];
|
|
51
|
+
readonly removed: readonly string[];
|
|
52
|
+
};
|
|
53
|
+
/** True when nothing about what this procedure does or may invoke changed. */
|
|
54
|
+
readonly safe: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The union of both graphs, for a layout both renderings share.
|
|
58
|
+
*
|
|
59
|
+
* This is what makes "unchanged regions do not shift" true rather than approximately
|
|
60
|
+
* true: the coordinates are computed once, over every node either version has, and each
|
|
61
|
+
* picture draws its own subset at those coordinates. Laying out each version separately
|
|
62
|
+
* would move everything below an inserted step, and a reader would compare two pictures
|
|
63
|
+
* that disagree about where the procedure is.
|
|
64
|
+
*/
|
|
65
|
+
export declare function unionGraph(before: Graph, after: Graph): Graph;
|
|
66
|
+
export declare function diffGraphs(before: Graph, after: Graph): GraphDiff;
|
|
67
|
+
/**
|
|
68
|
+
* What a reader is told first.
|
|
69
|
+
*
|
|
70
|
+
* Ordered by what can hurt them: a risk escalation, then a new privilege, then structure.
|
|
71
|
+
* A diff that led with "three steps renamed" would bury the one line that matters in the
|
|
72
|
+
* only case where the page is being read urgently.
|
|
73
|
+
*/
|
|
74
|
+
export declare function summarise(diff: GraphDiff): string[];
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { plural } from "@runbooks/schema";
|
|
2
|
+
const RISK_ORDER = ["read-only", "reversible-write", "destructive", "irreversible"];
|
|
3
|
+
const edgeKey = (edge) => `${edge.from}->${edge.to}:${edge.kind}`;
|
|
4
|
+
/**
|
|
5
|
+
* The union of both graphs, for a layout both renderings share.
|
|
6
|
+
*
|
|
7
|
+
* This is what makes "unchanged regions do not shift" true rather than approximately
|
|
8
|
+
* true: the coordinates are computed once, over every node either version has, and each
|
|
9
|
+
* picture draws its own subset at those coordinates. Laying out each version separately
|
|
10
|
+
* would move everything below an inserted step, and a reader would compare two pictures
|
|
11
|
+
* that disagree about where the procedure is.
|
|
12
|
+
*/
|
|
13
|
+
export function unionGraph(before, after) {
|
|
14
|
+
const nodes = new Map();
|
|
15
|
+
for (const node of before.nodes)
|
|
16
|
+
nodes.set(node.id, node);
|
|
17
|
+
// The later version wins on shape and risk: the union exists to place things, and where
|
|
18
|
+
// a node changed, its current form is the one a reader is being asked about.
|
|
19
|
+
for (const node of after.nodes)
|
|
20
|
+
nodes.set(node.id, node);
|
|
21
|
+
const edges = new Map();
|
|
22
|
+
for (const edge of [...before.edges, ...after.edges])
|
|
23
|
+
edges.set(edgeKey(edge), edge);
|
|
24
|
+
return { nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
25
|
+
}
|
|
26
|
+
function fieldsThatMoved(before, after) {
|
|
27
|
+
const moved = [];
|
|
28
|
+
if (before.title !== after.title)
|
|
29
|
+
moved.push("title");
|
|
30
|
+
if (before.step?.command !== after.step?.command)
|
|
31
|
+
moved.push("command");
|
|
32
|
+
if (before.step?.tool !== after.step?.tool)
|
|
33
|
+
moved.push("tool");
|
|
34
|
+
if (before.requiresApproval !== after.requiresApproval)
|
|
35
|
+
moved.push("requires_approval");
|
|
36
|
+
if (before.kind !== after.kind)
|
|
37
|
+
moved.push("kind");
|
|
38
|
+
return moved;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A structural candidate for a node that lost its id.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately narrow: same kind, same command, and no other unmatched node shares them.
|
|
44
|
+
* Anything looser would pair two steps because they are both actions, which is not
|
|
45
|
+
* evidence of anything.
|
|
46
|
+
*/
|
|
47
|
+
function structuralMatch(node, candidates) {
|
|
48
|
+
const same = candidates.filter((candidate) => candidate.kind === node.kind && candidate.step?.command === node.step?.command);
|
|
49
|
+
return same.length === 1 ? same[0] : undefined;
|
|
50
|
+
}
|
|
51
|
+
export function diffGraphs(before, after) {
|
|
52
|
+
const beforeById = new Map(before.nodes.map((node) => [node.id, node]));
|
|
53
|
+
const afterById = new Map(after.nodes.map((node) => [node.id, node]));
|
|
54
|
+
const nodes = [];
|
|
55
|
+
const matchedAfter = new Set();
|
|
56
|
+
for (const node of before.nodes) {
|
|
57
|
+
const counterpart = afterById.get(node.id);
|
|
58
|
+
if (counterpart) {
|
|
59
|
+
matchedAfter.add(counterpart.id);
|
|
60
|
+
nodes.push(compare(node, counterpart));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const candidate = structuralMatch(node, after.nodes.filter((other) => !beforeById.has(other.id) && !matchedAfter.has(other.id)));
|
|
64
|
+
if (candidate) {
|
|
65
|
+
matchedAfter.add(candidate.id);
|
|
66
|
+
nodes.push({ ...compare(node, candidate), matchedBy: "position" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
nodes.push({ id: node.id, change: "removed", before: node });
|
|
70
|
+
}
|
|
71
|
+
for (const node of after.nodes) {
|
|
72
|
+
if (matchedAfter.has(node.id) || beforeById.has(node.id))
|
|
73
|
+
continue;
|
|
74
|
+
nodes.push({ id: node.id, change: "added", after: node });
|
|
75
|
+
}
|
|
76
|
+
const beforeEdges = new Map(before.edges.map((edge) => [edgeKey(edge), edge]));
|
|
77
|
+
const afterEdges = new Map(after.edges.map((edge) => [edgeKey(edge), edge]));
|
|
78
|
+
const edges = [];
|
|
79
|
+
for (const [key, edge] of beforeEdges) {
|
|
80
|
+
edges.push({ from: edge.from, to: edge.to, kind: edge.kind, change: afterEdges.has(key) ? "unchanged" : "removed" });
|
|
81
|
+
}
|
|
82
|
+
for (const [key, edge] of afterEdges) {
|
|
83
|
+
if (!beforeEdges.has(key))
|
|
84
|
+
edges.push({ from: edge.from, to: edge.to, kind: edge.kind, change: "added" });
|
|
85
|
+
}
|
|
86
|
+
const capabilitiesBefore = capabilitiesOf(before);
|
|
87
|
+
const capabilitiesAfter = capabilitiesOf(after);
|
|
88
|
+
const escalations = nodes.filter((node) => node.risk?.direction === "escalated");
|
|
89
|
+
return {
|
|
90
|
+
nodes: nodes.sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
91
|
+
edges: edges.sort((a, b) => (`${a.from}${a.to}${a.kind}` < `${b.from}${b.to}${b.kind}` ? -1 : 1)),
|
|
92
|
+
escalations,
|
|
93
|
+
capabilities: {
|
|
94
|
+
added: [...capabilitiesAfter].filter((capability) => !capabilitiesBefore.has(capability)).sort(),
|
|
95
|
+
removed: [...capabilitiesBefore].filter((capability) => !capabilitiesAfter.has(capability)).sort(),
|
|
96
|
+
},
|
|
97
|
+
safe: escalations.length === 0 &&
|
|
98
|
+
[...capabilitiesAfter].every((capability) => capabilitiesBefore.has(capability)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function compare(before, after) {
|
|
102
|
+
const fields = fieldsThatMoved(before, after);
|
|
103
|
+
const from = before.risk;
|
|
104
|
+
const to = after.risk;
|
|
105
|
+
const moved = from !== to;
|
|
106
|
+
const direction = RISK_ORDER.indexOf(to ?? "read-only") > RISK_ORDER.indexOf(from ?? "read-only") ? "escalated" : "reduced";
|
|
107
|
+
return {
|
|
108
|
+
id: after.id,
|
|
109
|
+
change: moved || fields.length > 0 ? "modified" : "unchanged",
|
|
110
|
+
before,
|
|
111
|
+
after,
|
|
112
|
+
...(moved ? { risk: { ...(from ? { from } : {}), ...(to ? { to } : {}), direction } } : {}),
|
|
113
|
+
...(fields.length > 0 ? { fields } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function capabilitiesOf(graph) {
|
|
117
|
+
const out = new Set();
|
|
118
|
+
for (const node of graph.nodes) {
|
|
119
|
+
if (node.step?.tool)
|
|
120
|
+
out.add(node.step.tool);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* What a reader is told first.
|
|
126
|
+
*
|
|
127
|
+
* Ordered by what can hurt them: a risk escalation, then a new privilege, then structure.
|
|
128
|
+
* A diff that led with "three steps renamed" would bury the one line that matters in the
|
|
129
|
+
* only case where the page is being read urgently.
|
|
130
|
+
*/
|
|
131
|
+
export function summarise(diff) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
for (const node of diff.escalations) {
|
|
134
|
+
lines.push(`${node.id} became ${node.risk?.to ?? "more dangerous"}: it was ${node.risk?.from ?? "unclassified"}. A step that changed class is not a step that was renamed.`);
|
|
135
|
+
}
|
|
136
|
+
for (const capability of diff.capabilities.added) {
|
|
137
|
+
lines.push(`${capability} is new in this version. A new capability is a new privilege request: check it against your allowlist before running.`);
|
|
138
|
+
}
|
|
139
|
+
const added = diff.nodes.filter((node) => node.change === "added").length;
|
|
140
|
+
const removed = diff.nodes.filter((node) => node.change === "removed").length;
|
|
141
|
+
const modified = diff.nodes.filter((node) => node.change === "modified").length;
|
|
142
|
+
if (added || removed || modified) {
|
|
143
|
+
lines.push(`${plural(added, "step")} added, ${removed} removed, ${modified} changed.`);
|
|
144
|
+
}
|
|
145
|
+
if (lines.length === 0)
|
|
146
|
+
lines.push("Nothing changed in the graph.");
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toGraph } from "./model.js";
|
|
3
|
+
import { layout } from "./layout.js";
|
|
4
|
+
import { diffGraphs, unionGraph, summarise } from "./diff.js";
|
|
5
|
+
const doc = (steps) => ({ runbook: { steps } });
|
|
6
|
+
const v1 = toGraph(doc([
|
|
7
|
+
{ id: "s1", kind: "check", title: "Look", tool: "cli:df", command: "df -h", risk: "read-only", next: "s2" },
|
|
8
|
+
{ id: "s2", kind: "action", title: "Clean up", tool: "cli:rm", command: "rm /tmp/x", risk: "reversible-write", next: "end:success" },
|
|
9
|
+
]));
|
|
10
|
+
/** Same procedure, one step now destructive and a new tool. */
|
|
11
|
+
const v2 = toGraph(doc([
|
|
12
|
+
{ id: "s1", kind: "check", title: "Look", tool: "cli:df", command: "df -h", risk: "read-only", next: "s2" },
|
|
13
|
+
{ id: "s2", kind: "action", title: "Clean up", tool: "cli:rm", command: "rm -rf /var/lib/x", risk: "destructive", requires_approval: true, next: "s3" },
|
|
14
|
+
{ id: "s3", kind: "check", title: "Confirm", tool: "cli:kubectl", command: "kubectl get pods", risk: "read-only", next: "end:success" },
|
|
15
|
+
]));
|
|
16
|
+
/** "A risk escalation is visible without reading labels" — and named, first, in words. */
|
|
17
|
+
describe("what got more dangerous comes first", () => {
|
|
18
|
+
it("reports the escalation as its own class", () => {
|
|
19
|
+
const diff = diffGraphs(v1, v2);
|
|
20
|
+
expect(diff.escalations.map((node) => node.id)).toEqual(["s2"]);
|
|
21
|
+
expect(diff.escalations[0].risk).toEqual({ from: "reversible-write", to: "destructive", direction: "escalated" });
|
|
22
|
+
});
|
|
23
|
+
it("leads with it, before anything structural", () => {
|
|
24
|
+
const lines = summarise(diffGraphs(v1, v2));
|
|
25
|
+
expect(lines[0]).toMatch(/s2 became destructive/);
|
|
26
|
+
expect(lines[0]).toMatch(/not a step that was renamed/);
|
|
27
|
+
});
|
|
28
|
+
it("distinguishes a reduction from an escalation", () => {
|
|
29
|
+
const diff = diffGraphs(v2, v1);
|
|
30
|
+
expect(diff.escalations).toEqual([]);
|
|
31
|
+
expect(diff.nodes.find((node) => node.id === "s2").risk.direction).toBe("reduced");
|
|
32
|
+
});
|
|
33
|
+
it("carries the risk into both pictures, where hue already says it", () => {
|
|
34
|
+
// §18.2: risk owns the colour channel, so a class change repaints the node. The diff
|
|
35
|
+
// does not invent a second encoding for it; it names the change in words as well.
|
|
36
|
+
const diff = diffGraphs(v1, v2);
|
|
37
|
+
const s2 = diff.nodes.find((node) => node.id === "s2");
|
|
38
|
+
expect(s2.before?.risk).toBe("reversible-write");
|
|
39
|
+
expect(s2.after?.risk).toBe("destructive");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
/** "A capability added between versions is called out, not left to the reader to notice." */
|
|
43
|
+
describe("a new capability is a new privilege request", () => {
|
|
44
|
+
it("names it", () => {
|
|
45
|
+
expect(diffGraphs(v1, v2).capabilities.added).toEqual(["cli:kubectl"]);
|
|
46
|
+
});
|
|
47
|
+
it("says what it means, rather than listing a delta", () => {
|
|
48
|
+
expect(summarise(diffGraphs(v1, v2)).join(" ")).toMatch(/check it against your allowlist/);
|
|
49
|
+
});
|
|
50
|
+
it("reports a removed capability without calling it a risk", () => {
|
|
51
|
+
const diff = diffGraphs(v2, v1);
|
|
52
|
+
expect(diff.capabilities.removed).toEqual(["cli:kubectl"]);
|
|
53
|
+
expect(diff.capabilities.added).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
it("calls a version safe only when nothing escalated and nothing was granted", () => {
|
|
56
|
+
expect(diffGraphs(v1, v2).safe).toBe(false);
|
|
57
|
+
expect(diffGraphs(v1, v1).safe).toBe(true);
|
|
58
|
+
expect(diffGraphs(v2, v1).safe).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
/** "Unchanged regions of the graph do not shift position between the two renderings." */
|
|
62
|
+
describe("one layout, two pictures", () => {
|
|
63
|
+
it("places every shared node identically in both versions", () => {
|
|
64
|
+
const shared = layout(unionGraph(v1, v2)).positions;
|
|
65
|
+
const before = layout(unionGraph(v1, v2)).positions;
|
|
66
|
+
for (const id of ["s1", "s2"]) {
|
|
67
|
+
expect(shared[id], id).toEqual(before[id]);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it("keeps a node's position when a step is inserted after it", () => {
|
|
71
|
+
const union = layout(unionGraph(v1, v2)).positions;
|
|
72
|
+
const alone = layout(v1).positions;
|
|
73
|
+
// s1 is the first step in both, and an insertion later in the procedure must not
|
|
74
|
+
// move it: a reader comparing two pictures should not have to re-find the top.
|
|
75
|
+
expect(union["s1"]).toEqual(alone["s1"]);
|
|
76
|
+
});
|
|
77
|
+
it("is deterministic, which is what makes the shared layout possible", () => {
|
|
78
|
+
expect(layout(unionGraph(v1, v2)).positions).toEqual(layout(unionGraph(v1, v2)).positions);
|
|
79
|
+
});
|
|
80
|
+
it("contains every node either version has", () => {
|
|
81
|
+
const union = unionGraph(v1, v2);
|
|
82
|
+
for (const id of ["s1", "s2", "s3"]) {
|
|
83
|
+
expect(union.nodes.some((node) => node.id === id), id).toBe(true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
it("takes the later form of a node that changed, since that is what is being asked about", () => {
|
|
87
|
+
expect(unionGraph(v1, v2).nodes.find((node) => node.id === "s2").risk).toBe("destructive");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("what changed structurally", () => {
|
|
91
|
+
it("marks an added step as added and a removed one as removed", () => {
|
|
92
|
+
expect(diffGraphs(v1, v2).nodes.find((node) => node.id === "s3").change).toBe("added");
|
|
93
|
+
expect(diffGraphs(v2, v1).nodes.find((node) => node.id === "s3").change).toBe("removed");
|
|
94
|
+
});
|
|
95
|
+
it("names the fields that moved rather than counting them", () => {
|
|
96
|
+
const s2 = diffGraphs(v1, v2).nodes.find((node) => node.id === "s2");
|
|
97
|
+
expect(s2.fields).toContain("command");
|
|
98
|
+
expect(s2.fields).toContain("requires_approval");
|
|
99
|
+
});
|
|
100
|
+
it("reports an unchanged step as unchanged", () => {
|
|
101
|
+
expect(diffGraphs(v1, v2).nodes.find((node) => node.id === "s1").change).toBe("unchanged");
|
|
102
|
+
});
|
|
103
|
+
it("diffs the edges an insertion changed", () => {
|
|
104
|
+
const diff = diffGraphs(v1, v2);
|
|
105
|
+
expect(diff.edges.some((edge) => edge.from === "s2" && edge.to === "s3" && edge.change === "added")).toBe(true);
|
|
106
|
+
expect(diff.edges.some((edge) => edge.from === "s2" && edge.to === "__end_success" && edge.change === "removed")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it("says nothing changed when nothing did", () => {
|
|
109
|
+
expect(summarise(diffGraphs(v1, v1))).toEqual(["Nothing changed in the graph."]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
/** An alignment that guessed silently would draw a correspondence nobody wrote. */
|
|
113
|
+
describe("a step that lost its id", () => {
|
|
114
|
+
const renamed = toGraph(doc([
|
|
115
|
+
{ id: "s1", kind: "check", title: "Look", tool: "cli:df", command: "df -h", risk: "read-only", next: "cleanup" },
|
|
116
|
+
{ id: "cleanup", kind: "action", title: "Clean up", tool: "cli:rm", command: "rm /tmp/x", risk: "reversible-write", next: "end:success" },
|
|
117
|
+
]));
|
|
118
|
+
it("is matched structurally when the match is unambiguous", () => {
|
|
119
|
+
const diff = diffGraphs(v1, renamed);
|
|
120
|
+
const matched = diff.nodes.find((node) => node.id === "cleanup");
|
|
121
|
+
expect(matched.change).not.toBe("added");
|
|
122
|
+
expect(matched.matchedBy).toBe("position");
|
|
123
|
+
});
|
|
124
|
+
it("says the match was structural, so a reader can disbelieve it", () => {
|
|
125
|
+
expect(diffGraphs(v1, renamed).nodes.some((node) => node.matchedBy === "position")).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
it("refuses to match two candidates that are merely the same kind", () => {
|
|
128
|
+
const two = toGraph(doc([
|
|
129
|
+
{ id: "a", kind: "action", title: "One", tool: "cli:x", command: "x", risk: "reversible-write", next: "b" },
|
|
130
|
+
{ id: "b", kind: "action", title: "Two", tool: "cli:y", command: "y", risk: "reversible-write", next: "end:success" },
|
|
131
|
+
]));
|
|
132
|
+
const other = toGraph(doc([
|
|
133
|
+
{ id: "c", kind: "action", title: "One", tool: "cli:x", command: "z", risk: "reversible-write", next: "d" },
|
|
134
|
+
{ id: "d", kind: "action", title: "Two", tool: "cli:y", command: "w", risk: "reversible-write", next: "end:success" },
|
|
135
|
+
]));
|
|
136
|
+
const diff = diffGraphs(two, other);
|
|
137
|
+
expect(diff.nodes.filter((node) => node.matchedBy === "position")).toEqual([]);
|
|
138
|
+
expect(diff.nodes.filter((node) => node.change === "added").length).toBe(2);
|
|
139
|
+
});
|
|
140
|
+
});
|
package/dist/edit.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor operations (W-10, §18.3).
|
|
3
|
+
*
|
|
4
|
+
* The constructor is a **view over this model**, not a second representation of it. If it
|
|
5
|
+
* grew its own mutable graph, the editor and the linter would drift, and the editor would
|
|
6
|
+
* start producing documents the linter rejects for reasons the editor cannot explain.
|
|
7
|
+
*
|
|
8
|
+
* So every operation here takes `steps[]` and returns `steps[]`. There is no editor
|
|
9
|
+
* state, no node objects with identity, nothing to keep in sync — the document is the
|
|
10
|
+
* state, and the graph is derived from it whenever anything needs to look at shape.
|
|
11
|
+
*
|
|
12
|
+
* Two §18.3 rules shape all of this, and both are about refusing to accumulate garbage:
|
|
13
|
+
*
|
|
14
|
+
* - **A node cannot exist without a `kind`** (rule 2). There is no placeholder node, so
|
|
15
|
+
* there is no moment where a draft contains something nobody has decided about.
|
|
16
|
+
* - **Deleting a node requires reconnection** (rule 4). A dangling edge does not exist,
|
|
17
|
+
* not even in a draft, because a draft that cannot be reasoned about is a draft the
|
|
18
|
+
* validation cannot help with.
|
|
19
|
+
*/
|
|
20
|
+
import type { NodeKind, RawStep } from "./model.js";
|
|
21
|
+
export type StepKind = Exclude<NodeKind, "start" | "end">;
|
|
22
|
+
export declare const STEP_KINDS: readonly StepKind[];
|
|
23
|
+
export type EditFailure = {
|
|
24
|
+
readonly code: "unknown-step";
|
|
25
|
+
readonly why: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly code: "kind-required";
|
|
28
|
+
readonly why: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly code: "reconnect-required";
|
|
31
|
+
readonly why: string;
|
|
32
|
+
} | {
|
|
33
|
+
readonly code: "illegal-edge";
|
|
34
|
+
readonly why: string;
|
|
35
|
+
} | {
|
|
36
|
+
readonly code: "duplicate-id";
|
|
37
|
+
readonly why: string;
|
|
38
|
+
};
|
|
39
|
+
export type EditResult = {
|
|
40
|
+
readonly ok: true;
|
|
41
|
+
readonly steps: readonly RawStep[];
|
|
42
|
+
readonly note?: string;
|
|
43
|
+
} | {
|
|
44
|
+
readonly ok: false;
|
|
45
|
+
readonly failure: EditFailure;
|
|
46
|
+
};
|
|
47
|
+
/** The next free `sN`, so ids stay short and predictable rather than opaque. */
|
|
48
|
+
export declare function nextId(steps: readonly RawStep[]): string;
|
|
49
|
+
export interface AddStep {
|
|
50
|
+
/** Required. A node without one cannot exist (§18.3 rule 2). */
|
|
51
|
+
readonly kind: StepKind;
|
|
52
|
+
readonly title: string;
|
|
53
|
+
/** Where it goes. Appended at the end when absent. */
|
|
54
|
+
readonly after?: string;
|
|
55
|
+
readonly id?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Add a step.
|
|
59
|
+
*
|
|
60
|
+
* The kind is a parameter rather than something set later, which is the whole of rule 2:
|
|
61
|
+
* an editor that creates a node and asks afterwards has a state where the node exists and
|
|
62
|
+
* nobody has decided what it is — and that state ends up in a draft, in an undo stack,
|
|
63
|
+
* and eventually in a file.
|
|
64
|
+
*
|
|
65
|
+
* The new step inherits the `next` of whatever it follows and takes its place, so
|
|
66
|
+
* inserting into a chain does not leave the chain pointing past it.
|
|
67
|
+
*/
|
|
68
|
+
export declare function addStep(steps: readonly RawStep[], input: AddStep): EditResult;
|
|
69
|
+
export interface DeleteStep {
|
|
70
|
+
/**
|
|
71
|
+
* Where the incoming edges go instead. Required whenever anything points at the step:
|
|
72
|
+
* §18.3 rule 4. `"drop"` removes those references explicitly rather than silently.
|
|
73
|
+
*/
|
|
74
|
+
readonly reconnectTo?: string | "drop";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Delete a step, reconnecting what pointed at it.
|
|
78
|
+
*
|
|
79
|
+
* Refusing without an instruction is the point. An editor that deletes and leaves the
|
|
80
|
+
* edges is an editor that produces a document the linter rejects for a reason the person
|
|
81
|
+
* who deleted the node will not connect to what they did.
|
|
82
|
+
*/
|
|
83
|
+
export declare function deleteStep(steps: readonly RawStep[], id: string, options?: DeleteStep): EditResult;
|
|
84
|
+
/**
|
|
85
|
+
* The edges an author draws.
|
|
86
|
+
*
|
|
87
|
+
* A subset of the model's `EdgeKind`, and deliberately not the same type: the model also
|
|
88
|
+
* has `approval`, which the derivation adds at every gated arrival and no author ever
|
|
89
|
+
* draws. Letting an editor offer it would let somebody draw a gate that the derivation
|
|
90
|
+
* then adds again.
|
|
91
|
+
*/
|
|
92
|
+
export type AuthoredEdge = "next" | "on_fail" | "branch" | "rollback" | "retry";
|
|
93
|
+
/**
|
|
94
|
+
* Which edges a kind may have.
|
|
95
|
+
*
|
|
96
|
+
* Derived from what the kinds mean rather than from what the schema tolerates: a `wait`
|
|
97
|
+
* has no failure route because waiting does not fail, it times out; an `escalate` has no
|
|
98
|
+
* outgoing edge at all because it is where a procedure hands off to a person.
|
|
99
|
+
*/
|
|
100
|
+
export declare const LEGAL_EDGES: Readonly<Record<StepKind, readonly AuthoredEdge[]>>;
|
|
101
|
+
export interface Connect {
|
|
102
|
+
readonly from: string;
|
|
103
|
+
readonly to: string;
|
|
104
|
+
readonly kind: AuthoredEdge;
|
|
105
|
+
/** Required for a branch: an unlabelled branch is a choice nobody can read. */
|
|
106
|
+
readonly label?: string;
|
|
107
|
+
}
|
|
108
|
+
export declare function connect(steps: readonly RawStep[], edge: Connect): EditResult;
|
|
109
|
+
/**
|
|
110
|
+
* Undo and redo over whole documents.
|
|
111
|
+
*
|
|
112
|
+
* A history of documents rather than of operations, so "undo restores state exactly"
|
|
113
|
+
* (W-10) is true by construction — including after an automatic insertion, which is the
|
|
114
|
+
* case an operation-based history gets wrong: the inverse of "add a step" is not
|
|
115
|
+
* "delete a step" when adding one also inserted an approval gate.
|
|
116
|
+
*/
|
|
117
|
+
export interface History {
|
|
118
|
+
readonly past: readonly (readonly RawStep[])[];
|
|
119
|
+
readonly present: readonly RawStep[];
|
|
120
|
+
readonly future: readonly (readonly RawStep[])[];
|
|
121
|
+
}
|
|
122
|
+
export declare function begin(steps: readonly RawStep[]): History;
|
|
123
|
+
export declare function commit(history: History, steps: readonly RawStep[]): History;
|
|
124
|
+
export declare function undo(history: History): History;
|
|
125
|
+
export declare function redo(history: History): History;
|
|
126
|
+
/**
|
|
127
|
+
* Changing risk changes the graph (§18.3 rule 3).
|
|
128
|
+
*
|
|
129
|
+
* Setting `destructive` or `irreversible` on a step makes invariant 5 apply to it: the
|
|
130
|
+
* version does not validate until that step is paired with an approval gate or a
|
|
131
|
+
* rollback. An editor that only reported this would be technically correct and useless —
|
|
132
|
+
* the author has just told it what they are doing, and the fix is mechanical.
|
|
133
|
+
*
|
|
134
|
+
* So the change comes with an offer, and the offer is one action. That is what makes the
|
|
135
|
+
* safety model feel like help rather than obstruction, which is the difference between a
|
|
136
|
+
* rule people follow and a rule people work around.
|
|
137
|
+
*/
|
|
138
|
+
export type RiskSuggestion = {
|
|
139
|
+
readonly kind: "none";
|
|
140
|
+
readonly why: string;
|
|
141
|
+
} | {
|
|
142
|
+
readonly kind: "gate" | "rollback";
|
|
143
|
+
readonly why: string;
|
|
144
|
+
/** What accepting it does, in the author's words rather than the schema's. */
|
|
145
|
+
readonly action: string;
|
|
146
|
+
};
|
|
147
|
+
export declare function suggestForRisk(steps: readonly RawStep[], id: string, risk: NonNullable<RawStep["risk"]>): RiskSuggestion;
|
|
148
|
+
/**
|
|
149
|
+
* Accept the offer, in one action.
|
|
150
|
+
*
|
|
151
|
+
* The rollback case inserts the undoing step as well as pointing at it, because "specify
|
|
152
|
+
* a rollback" with nothing to specify is not one action — it is an offer that hands back
|
|
153
|
+
* a second task.
|
|
154
|
+
*/
|
|
155
|
+
export declare function acceptRiskSuggestion(steps: readonly RawStep[], id: string, suggestion: RiskSuggestion): EditResult;
|
|
156
|
+
/** Set a risk and, where it changes what the graph needs, say what would fix it. */
|
|
157
|
+
export declare function setRisk(steps: readonly RawStep[], id: string, risk: NonNullable<RawStep["risk"]>): {
|
|
158
|
+
readonly result: EditResult;
|
|
159
|
+
readonly suggestion: RiskSuggestion;
|
|
160
|
+
};
|