@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isDangerous, RISK_ORDER } from "@runbooks/schema";
|
|
3
|
+
import { aggregate, decomposition, childrenOf, overCap, NODE_CAP, MAX_ROLLUP_DEPTH, } from "./subgraph.js";
|
|
4
|
+
const record = (ref, steps, relations = [], capabilities = []) => ({
|
|
5
|
+
ref,
|
|
6
|
+
document: { runbook: { steps, relations, capabilities } },
|
|
7
|
+
});
|
|
8
|
+
const parent = record("std/drain", [
|
|
9
|
+
{ id: "s1", kind: "check", title: "Look", risk: "read-only", next: "s2" },
|
|
10
|
+
{ id: "s2", kind: "action", title: "Prepare", risk: "reversible-write", next: "s3" },
|
|
11
|
+
{ id: "s3", kind: "action", title: "Finish", risk: "read-only", next: "end:success" },
|
|
12
|
+
], [], ["cli:kubectl"]);
|
|
13
|
+
const child = record("std/prepare", [
|
|
14
|
+
{ id: "c1", kind: "check", title: "Check", risk: "read-only", next: "c2" },
|
|
15
|
+
{ id: "c2", kind: "action", title: "Cordon", risk: "reversible-write", next: "end:success" },
|
|
16
|
+
], [{ kind: "part_of", ref: "std/drain", step: "s2" }], ["cli:kubectl"]);
|
|
17
|
+
const dangerousGrandchild = record("std/wipe", [{ id: "g1", kind: "action", title: "Wipe", risk: "irreversible", next: "end:success" }], [{ kind: "part_of", ref: "std/prepare", step: "c2" }], ["cli:rm"]);
|
|
18
|
+
const world = [parent, child, dangerousGrandchild];
|
|
19
|
+
/** "A collapsed subgraph containing a destructive step is visibly marked as containing one." */
|
|
20
|
+
describe("nothing dangerous is hidden by collapsing", () => {
|
|
21
|
+
it("rolls the worst risk up from a grandchild", () => {
|
|
22
|
+
const summary = aggregate(child, "std/drain", world);
|
|
23
|
+
expect(summary.highestRisk).toBe("irreversible");
|
|
24
|
+
expect(summary.containsDangerous).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it("says so on the collapsed node, not on the page you would have to open", () => {
|
|
27
|
+
const [line] = decomposition(parent, world);
|
|
28
|
+
expect(line.containsDangerous).toBe(true);
|
|
29
|
+
expect(line.highestRisk).toBe("irreversible");
|
|
30
|
+
});
|
|
31
|
+
it("is not dangerous when nothing inside is", () => {
|
|
32
|
+
const summary = aggregate(child, "std/drain", [parent, child]);
|
|
33
|
+
expect(summary.highestRisk).toBe("reversible-write");
|
|
34
|
+
expect(summary.containsDangerous).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Two of the four risks are exercised above. The mark comes from `isDangerous`, and a
|
|
38
|
+
* collapsed subgraph is the one place a reader cannot look inside — so ask it about
|
|
39
|
+
* each risk there is, not about the two this world happens to contain.
|
|
40
|
+
*/
|
|
41
|
+
it.each(RISK_ORDER)("marks a hidden %s step if and only if the spec calls it dangerous", (risk) => {
|
|
42
|
+
const hidden = record("std/hidden", [{ id: "h1", kind: "action", title: "Do it", risk, next: "end:success" }], [{ kind: "part_of", ref: "std/prepare", step: "c2" }]);
|
|
43
|
+
const summary = aggregate(child, "std/drain", [parent, child, hidden]);
|
|
44
|
+
expect(summary.containsDangerous).toBe(isDangerous(risk));
|
|
45
|
+
});
|
|
46
|
+
it("counts every node inside, including the ones two levels down", () => {
|
|
47
|
+
expect(aggregate(child, "std/drain", world).nodes).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
it("unions the capabilities, because granting the parent grants all of them", () => {
|
|
50
|
+
expect(aggregate(child, "std/drain", world).capabilities).toEqual(["cli:kubectl", "cli:rm"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
/** Displayed depth is one; aggregation depth is not. */
|
|
54
|
+
describe("what is shown stays small and what is summarised stays complete", () => {
|
|
55
|
+
it("lists direct children only", () => {
|
|
56
|
+
expect(decomposition(parent, world).map((line) => line.ref)).toEqual(["std/prepare"]);
|
|
57
|
+
});
|
|
58
|
+
it("reports how deep the summary actually went", () => {
|
|
59
|
+
expect(decomposition(parent, world)[0].depth).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
it("orders children by the parent's own steps, so a keyboard walks the procedure", () => {
|
|
62
|
+
const second = record("std/finish-detail", [{ id: "f1", kind: "check", title: "Check", risk: "read-only", next: "end:success" }], [{ kind: "part_of", ref: "std/drain", step: "s3" }]);
|
|
63
|
+
const first = record("std/look-detail", [{ id: "l1", kind: "check", title: "Check", risk: "read-only", next: "end:success" }], [{ kind: "part_of", ref: "std/drain", step: "s1" }]);
|
|
64
|
+
const order = decomposition(parent, [parent, second, first, child]).map((line) => line.step);
|
|
65
|
+
expect(order).toEqual(["s1", "s2", "s3"]);
|
|
66
|
+
});
|
|
67
|
+
it("names which step each child expands", () => {
|
|
68
|
+
expect(decomposition(parent, world)[0].step).toBe("s2");
|
|
69
|
+
});
|
|
70
|
+
it("finds the children that declare themselves part of a record", () => {
|
|
71
|
+
expect(childrenOf("std/drain", world).map((r) => r.ref)).toEqual(["std/prepare"]);
|
|
72
|
+
expect(childrenOf("std/prepare", world).map((r) => r.ref)).toEqual(["std/wipe"]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
/** A cycle here means the catalog moved underneath us; hanging would be the worse answer. */
|
|
76
|
+
describe("a malformed decomposition terminates and says so", () => {
|
|
77
|
+
it("stops on a part_of cycle rather than recursing", () => {
|
|
78
|
+
const a = record("std/a", [{ id: "a1", kind: "check", title: "A", next: "end:success" }], [
|
|
79
|
+
{ kind: "part_of", ref: "std/b", step: "b1" },
|
|
80
|
+
]);
|
|
81
|
+
const b = record("std/b", [{ id: "b1", kind: "check", title: "B", next: "end:success" }], [
|
|
82
|
+
{ kind: "part_of", ref: "std/a", step: "a1" },
|
|
83
|
+
]);
|
|
84
|
+
const summary = aggregate(a, "std/b", [a, b]);
|
|
85
|
+
expect(summary.truncated).toMatch(/already on this path/);
|
|
86
|
+
});
|
|
87
|
+
it("has a stated depth limit rather than an implicit one", () => {
|
|
88
|
+
expect(MAX_ROLLUP_DEPTH).toBeGreaterThan(4);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
/** §18.6 rule 2, as a question about one record. */
|
|
92
|
+
describe("the twelve-node cap", () => {
|
|
93
|
+
const big = record("std/long", Array.from({ length: NODE_CAP + 2 }, (_, i) => ({
|
|
94
|
+
id: `s${i}`,
|
|
95
|
+
kind: "check",
|
|
96
|
+
title: `Step ${i}`,
|
|
97
|
+
risk: "read-only",
|
|
98
|
+
next: i === NODE_CAP + 1 ? "end:success" : `s${i + 1}`,
|
|
99
|
+
})));
|
|
100
|
+
it("notices a graph over the cap with nothing decomposed out of it", () => {
|
|
101
|
+
expect(overCap(big, [big])).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
it("is satisfied by a decomposition rather than by shortening the procedure", () => {
|
|
104
|
+
const part = record("std/long-part", [{ id: "p1", kind: "check", title: "Part", next: "end:success" }], [{ kind: "part_of", ref: "std/long", step: "s0" }]);
|
|
105
|
+
expect(overCap(big, [big, part])).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
it("leaves a small graph alone", () => {
|
|
108
|
+
expect(overCap(parent, world)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Q7's answer, as a property of the code: there is no expansion. What the parent has is a
|
|
113
|
+
* summary and a link, and both are computed rather than typed by an author.
|
|
114
|
+
*/
|
|
115
|
+
describe("nothing expands in place", () => {
|
|
116
|
+
it("summarises rather than returning the child's graph", () => {
|
|
117
|
+
const summary = aggregate(child, "std/drain", world);
|
|
118
|
+
expect(Object.keys(summary).sort()).toEqual([
|
|
119
|
+
"capabilities", "containsDangerous", "depth", "highestRisk", "nodes", "ref", "step",
|
|
120
|
+
]);
|
|
121
|
+
expect(summary).not.toHaveProperty("graph");
|
|
122
|
+
expect(summary).not.toHaveProperty("nodesList");
|
|
123
|
+
});
|
|
124
|
+
it("gives a reader the child's own address, since it is a record", () => {
|
|
125
|
+
expect(decomposition(parent, world)[0].ref).toBe("std/prepare");
|
|
126
|
+
});
|
|
127
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runbooks/graph",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Graph model, the seven blocking invariants, and layered layout.",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@runbooks/schema": "^0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/runbooks-directory/runbooks.directory"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.11"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -b",
|
|
35
|
+
"test": "vitest run --passWithNoTests",
|
|
36
|
+
"lint": "eslint src"
|
|
37
|
+
}
|
|
38
|
+
}
|