@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,255 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { toGraph, START } from "./model.js";
6
+ import { reachableFrom, unreachable, allPathsTo, successPaths } from "./paths.js";
7
+ import { checkGraph } from "./invariants.js";
8
+ const doc = (steps) => ({ runbook: { steps } });
9
+ const linear = toGraph(doc([
10
+ { id: "s1", kind: "check", title: "Look", tool: "cli:df", expect: "it is there", on_fail: "escalate:s9", next: "s2" },
11
+ { id: "s2", kind: "action", title: "Fix it", tool: "cli:rm", next: "end:success" },
12
+ { id: "s9", kind: "escalate", title: "Escalate", to: "team:x" },
13
+ ]));
14
+ const branching = toGraph(doc([
15
+ { id: "s1", kind: "decision", title: "Which way", question: "Which?", branches: { a: "s2", b: "s3", default: "s3" } },
16
+ { id: "s2", kind: "action", title: "Left", tool: "cli:a", next: "end:success" },
17
+ { id: "s3", kind: "action", title: "Right", tool: "cli:b", next: "end:failed" },
18
+ ]));
19
+ const retrying = toGraph(doc([
20
+ { id: "s1", kind: "check", title: "Settled", tool: "cli:test", expect: "settled", assert: { exit_code: 0 }, on_fail: "s2", next: "end:success" },
21
+ { id: "s2", kind: "wait", title: "Wait", duration: "5s", retry: { max: 2, target: "s1" }, next: "end:failed" },
22
+ ]));
23
+ describe("what a graph can reach", () => {
24
+ it("reaches every node of a linear procedure from the start", () => {
25
+ expect(reachableFrom(linear)).toContain("s1");
26
+ expect(reachableFrom(linear)).toContain("s2");
27
+ expect(reachableFrom(linear)).toContain("s9");
28
+ });
29
+ it("reports nothing unreachable in a valid graph", () => {
30
+ expect(unreachable(linear)).toEqual([]);
31
+ expect(unreachable(branching)).toEqual([]);
32
+ });
33
+ it("names an orphan, which is what invariant 1 blocks on", () => {
34
+ const orphaned = toGraph(doc([
35
+ { id: "s1", kind: "action", title: "Only", tool: "cli:a", next: "end:success" },
36
+ { id: "s7", kind: "action", title: "Nobody points here", tool: "cli:b", next: "end:success" },
37
+ ]));
38
+ // s7 is reached by the fall-through the derivation adds, so orphan it explicitly.
39
+ const graph = { ...orphaned, edges: orphaned.edges.filter((e) => e.to !== "s7") };
40
+ expect(unreachable(graph)).toContain("s7");
41
+ expect(checkGraph(graph).some((f) => f.code === "graph/unreachable")).toBe(true);
42
+ });
43
+ it("starts from the start node by default", () => {
44
+ expect(reachableFrom(linear)[0]).toBe(START);
45
+ });
46
+ });
47
+ describe("every path to an ending", () => {
48
+ it("enumerates both branches of a decision", () => {
49
+ const paths = allPathsTo(branching);
50
+ expect(paths.length).toBeGreaterThanOrEqual(2);
51
+ expect(paths.some((path) => path.includes("s2"))).toBe(true);
52
+ expect(paths.some((path) => path.includes("s3"))).toBe(true);
53
+ });
54
+ it("ends every path at a terminal", () => {
55
+ for (const path of allPathsTo(branching)) {
56
+ expect(path.at(-1)).toMatch(/^__end_/);
57
+ }
58
+ });
59
+ /**
60
+ * A bounded retry means a node genuinely appears twice on a real run. A walk that
61
+ * refused to revisit anything would be simulating a different procedure.
62
+ */
63
+ it("lets a retried step appear twice", () => {
64
+ const paths = allPathsTo(retrying);
65
+ expect(paths.some((path) => path.filter((id) => id === "s1").length > 1)).toBe(true);
66
+ });
67
+ it("bounds the revisiting, so a cycle cannot run forever", () => {
68
+ for (const path of allPathsTo(retrying)) {
69
+ for (const id of new Set(path)) {
70
+ expect(path.filter((step) => step === id).length).toBeLessThanOrEqual(2);
71
+ }
72
+ }
73
+ });
74
+ it("takes a visit budget from the caller", () => {
75
+ const once = allPathsTo(retrying, START, { maxVisits: 1 });
76
+ for (const path of once) {
77
+ expect(path.filter((id) => id === "s1").length).toBe(1);
78
+ }
79
+ });
80
+ it("stops at a ceiling rather than hanging a caller on a wide graph", () => {
81
+ expect(allPathsTo(branching, START, { limit: 1 })).toHaveLength(1);
82
+ });
83
+ it("returns a path that ran out of edges, rather than nothing", () => {
84
+ const dangling = toGraph(doc([{ id: "s1", kind: "action", title: "Ends nowhere", tool: "cli:a" }]));
85
+ const stripped = { ...dangling, edges: dangling.edges.filter((e) => e.from !== "s1") };
86
+ expect(allPathsTo(stripped).some((path) => path.at(-1) === "s1")).toBe(true);
87
+ });
88
+ });
89
+ describe("the paths where nothing goes wrong", () => {
90
+ it("follows forward edges only", () => {
91
+ for (const path of successPaths(linear)) {
92
+ expect(path).not.toContain("s9");
93
+ }
94
+ });
95
+ it("keeps both sides of a decision, since choosing is not failing", () => {
96
+ const paths = successPaths(branching);
97
+ expect(paths.some((p) => p.includes("s2"))).toBe(true);
98
+ expect(paths.some((p) => p.includes("s3"))).toBe(true);
99
+ });
100
+ it("is deterministic, so a silhouette is stable across builds", () => {
101
+ expect(successPaths(branching)).toEqual(successPaths(branching));
102
+ });
103
+ });
104
+ /** §6 lists four warnings. Three are here; the fourth is the linter's. */
105
+ describe("the warnings that lower trust without blocking", () => {
106
+ it("names an action with no tool", () => {
107
+ const graph = toGraph(doc([{ id: "s1", kind: "action", title: "Do the thing", next: "end:success" }]));
108
+ const found = checkGraph(graph).find((f) => f.code === "graph/step-without-tool");
109
+ expect(found?.at).toBe("s1");
110
+ expect(found?.severity).toBe("warning");
111
+ });
112
+ /**
113
+ * The consequences it names are a machine's, so it stops where the machine does.
114
+ *
115
+ * It fired on every action and check of all three `human-only` records — twenty steps
116
+ * doing exactly what they declared — and told each one that a supervisor had nothing to
117
+ * scope, on a procedure that declares no agent may run it. A warning that cannot be acted
118
+ * on is how a reader learns to skip the gate.
119
+ */
120
+ it("says nothing on a record no agent may run", () => {
121
+ const graph = toGraph(doc([{ id: "s1", kind: "action", title: "Do the thing", next: "end:success" }]));
122
+ expect(checkGraph(graph, { execution: "human-only" }).some((f) => f.code === "graph/step-without-tool")).toBe(false);
123
+ });
124
+ it.each(["human-with-agent", "agent-autonomous"])("still names it under %s", (execution) => {
125
+ const graph = toGraph(doc([{ id: "s1", kind: "action", title: "Do the thing", next: "end:success" }]));
126
+ expect(checkGraph(graph, { execution }).find((f) => f.code === "graph/step-without-tool")?.at).toBe("s1");
127
+ });
128
+ /** And the other §6 warnings are not suppressed with it. */
129
+ it("keeps the warnings that are not about a tool on a human-only record", () => {
130
+ const graph = toGraph(doc([
131
+ { id: "s1", kind: "decision", title: "Which way?", question: "Which?", branches: { a: "s2", default: "s2" } },
132
+ { id: "s2", kind: "check", title: "Look", next: "end:success" },
133
+ ]));
134
+ const codes = checkGraph(graph, { execution: "human-only" }).map((f) => f.code);
135
+ expect(codes.some((code) => code !== "graph/step-without-tool")).toBe(true);
136
+ });
137
+ it("says nothing about a wait or a decision, which have nothing to invoke", () => {
138
+ const graph = toGraph(doc([
139
+ { id: "s1", kind: "wait", title: "Wait", duration: "5s", next: "end:success" },
140
+ ]));
141
+ expect(checkGraph(graph).some((f) => f.code === "graph/step-without-tool")).toBe(false);
142
+ });
143
+ it("names a branch leading to a check with no expectation", () => {
144
+ const graph = toGraph(doc([
145
+ { id: "s1", kind: "decision", title: "Which", question: "Which?", branches: { a: "s2", default: "s2" } },
146
+ { id: "s2", kind: "check", title: "Did it work", tool: "cli:test", on_fail: "end:failed", next: "end:success" },
147
+ ]));
148
+ const found = checkGraph(graph).find((f) => f.code === "graph/branch-check-without-expect");
149
+ expect(found?.at).toBe("s2");
150
+ expect(found?.message).toMatch(/cannot tell whether it worked/);
151
+ });
152
+ it("says nothing when the check does state an expectation", () => {
153
+ const graph = toGraph(doc([
154
+ { id: "s1", kind: "decision", title: "Which", question: "Which?", branches: { a: "s2", default: "s2" } },
155
+ { id: "s2", kind: "check", title: "Did it work", tool: "cli:test", expect: "the marker is gone", on_fail: "end:failed", next: "end:success" },
156
+ ]));
157
+ expect(checkGraph(graph).some((f) => f.code === "graph/branch-check-without-expect")).toBe(false);
158
+ });
159
+ it("leaves the unused-capability warning to the linter, which can see capabilities", () => {
160
+ const source = new URL("./invariants.ts", import.meta.url);
161
+ expect(source.pathname.endsWith("invariants.ts")).toBe(true);
162
+ // The reason is written where somebody would look for the missing check.
163
+ expect(checkGraph(linear).some((f) => f.code === "capabilities/redundant")).toBe(false);
164
+ });
165
+ });
166
+ /**
167
+ * P-07's third criterion: the package runs in the browser for the live editor and in CI
168
+ * for the linter, so it may depend on neither React, the DOM, nor Node. Checked through
169
+ * the import graph rather than by reading, because the rule erodes through a transitive
170
+ * import nobody looked at — which is how `packages/supervise` is checked for the same
171
+ * kind of promise.
172
+ */
173
+ describe("the model runs anywhere", () => {
174
+ const FORBIDDEN = /from\s+"(react|react-dom|node:[a-z/]+|fs|path|os|crypto)"/g;
175
+ /**
176
+ * Follows relative imports and steps into a workspace package's entry point, because
177
+ * the rule is about what ends up in the bundle rather than about this directory.
178
+ */
179
+ function reachable(entry) {
180
+ const seen = new Map();
181
+ const queue = [entry];
182
+ while (queue.length > 0) {
183
+ const file = queue.pop();
184
+ if (seen.has(file) || !existsSync(file))
185
+ continue;
186
+ const source = readFileSync(file, "utf8");
187
+ seen.set(file, [...source.matchAll(FORBIDDEN)].map((m) => m[1]));
188
+ for (const [, spec] of source.matchAll(/from\s+"(\.[^"]+)"/g)) {
189
+ queue.push(join(dirname(file), spec.replace(/\.js$/, ".ts")));
190
+ }
191
+ for (const [, pkg] of source.matchAll(/from\s+"@runbooks\/([a-z-]+)"/g)) {
192
+ queue.push(join(here, "..", "..", pkg, "src", "index.ts"));
193
+ }
194
+ }
195
+ return seen;
196
+ }
197
+ const here = dirname(fileURLToPath(import.meta.url));
198
+ const modules = reachable(join(here, "index.ts"));
199
+ it("reaches more than its own entry point", () => {
200
+ expect(modules.size).toBeGreaterThan(3);
201
+ });
202
+ it("imports no React, no DOM and nothing Node-specific", () => {
203
+ const offenders = [...modules]
204
+ .filter(([, forbidden]) => forbidden.length > 0)
205
+ .map(([file, forbidden]) => `${file.slice(here.length + 1)} imports ${forbidden.join(", ")}`);
206
+ expect(offenders, "the editor runs this in a browser and the linter runs it in CI: one import breaks one of them").toEqual([]);
207
+ });
208
+ /**
209
+ * Only workspace packages, and each of those is checked for the same property by its
210
+ * own tests. A third-party dependency is where React or a Node built-in arrives
211
+ * without anybody choosing it.
212
+ */
213
+ it("depends on nothing outside the workspace", () => {
214
+ const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
215
+ for (const [name, range] of Object.entries(manifest.dependencies ?? {})) {
216
+ expect(name, `${name} is not a workspace package`).toMatch(/^@runbooks\//);
217
+ // `workspace:^0.1.0` on a package that is published (P-14): the protocol is still
218
+ // the workspace's, and pack resolves it to the version a consumer installs. What
219
+ // this test is about is that nothing here comes from outside the workspace.
220
+ expect(range, `${name} is not resolved from the workspace`).toMatch(/^workspace:/);
221
+ }
222
+ });
223
+ });
224
+ /**
225
+ * Found in audit: reachability was implemented twice — once here and once inside
226
+ * invariant 1 — and the file claimed consumers were built on it while having none. Two
227
+ * sweeps that agree today are one sweep and one bug waiting for somebody to change what
228
+ * counts as an edge.
229
+ */
230
+ describe("reachability has one implementation", () => {
231
+ const src = dirname(fileURLToPath(import.meta.url));
232
+ it("is what invariant 1 uses", () => {
233
+ const source = readFileSync(join(src, "invariants.ts"), "utf8");
234
+ expect(source).toContain("reachableFrom(g, starts[0]!.id)");
235
+ // And no second sweep beside it.
236
+ expect(source).not.toMatch(/const queue = \[starts/);
237
+ });
238
+ it("agrees with what the invariant reports, by construction rather than by luck", () => {
239
+ const orphaned = toGraph(doc([
240
+ { id: "s1", kind: "action", title: "Only", tool: "cli:a", next: "end:success" },
241
+ { id: "s7", kind: "action", title: "Orphan", tool: "cli:b", next: "end:success" },
242
+ ]));
243
+ const graph = { ...orphaned, edges: orphaned.edges.filter((e) => e.to !== "s7") };
244
+ const fromPaths = unreachable(graph);
245
+ const fromInvariant = checkGraph(graph)
246
+ .filter((f) => f.code === "graph/unreachable")
247
+ .map((f) => f.at);
248
+ expect(fromPaths.sort()).toEqual(fromInvariant.sort());
249
+ });
250
+ it("says which consumers do not exist yet, rather than implying they do", () => {
251
+ const source = readFileSync(join(src, "paths.ts"), "utf8");
252
+ expect(source).toMatch(/The other consumers do not exist yet/);
253
+ expect(source.replace(/\s*\*\s*/g, " ")).toMatch(/exactly one caller and says so/);
254
+ });
255
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Relations between runbooks (S-13, §22).
3
+ *
4
+ * v3's premise is that procedures compose: the graph stops being intra-runbook and becomes
5
+ * a graph of runbooks. Four kinds, and each asserts something different enough that the
6
+ * validation rules differ too.
7
+ *
8
+ * A relation is **authored**, unlike an attestation: only the author can say how their
9
+ * procedure stands to another. What they cannot do is make the claim resolve — a reference
10
+ * to a record this catalog does not hold is a broken record, not a pending one.
11
+ */
12
+ import { type UpstreamState } from "@runbooks/schema";
13
+ export declare const RELATION_KINDS: readonly ["requires", "rollback_of", "escalates_to", "part_of"];
14
+ export type RelationKind = (typeof RELATION_KINDS)[number];
15
+ export interface Relation {
16
+ readonly kind: RelationKind;
17
+ /** `publisher/slug`, optionally `@semver`. */
18
+ readonly ref: string;
19
+ readonly why?: string;
20
+ /**
21
+ * `part_of` only: the step of the parent this record expands (§18.6, W-21).
22
+ *
23
+ * Without it a decomposition is a claim of membership with no place in the parent's
24
+ * procedure, and a reader cannot tell which of twelve steps this is the detail of.
25
+ */
26
+ readonly step?: string;
27
+ }
28
+ export interface Reference {
29
+ readonly publisher: string;
30
+ readonly slug: string;
31
+ readonly semver?: string;
32
+ }
33
+ export declare function parseRef(ref: string): Reference | undefined;
34
+ /**
35
+ * Which kinds must pin a version, and which must not.
36
+ *
37
+ * `requires` and `rollback_of` assert what *another record does* — it prepares this, it is
38
+ * undone by this — and that is only true of a version. An unpinned claim about behaviour
39
+ * follows the other author's next edit, which is exactly the update drift this catalog
40
+ * exists to expose rather than to inherit.
41
+ *
42
+ * `part_of` and `escalates_to` are structural: this belongs to that, failures go there.
43
+ * Pinning those freezes an arrangement in place, so that a playbook keeps pointing at
44
+ * last year's version of its own step, and the composition rots while every reference
45
+ * still resolves.
46
+ */
47
+ export declare const MUST_PIN: Readonly<Record<RelationKind, boolean>>;
48
+ /**
49
+ * Which kinds may cross a publisher boundary.
50
+ *
51
+ * All but `part_of`. Declaring yourself part of somebody else's procedure is a claim about
52
+ * *their* composition, made without them: the containing record decides what it contains.
53
+ * The rest are claims about your own record's relationship to theirs, which is yours to
54
+ * make — and to be wrong about in public.
55
+ */
56
+ export declare const MAY_CROSS_PUBLISHER: Readonly<Record<RelationKind, boolean>>;
57
+ export interface RelationProblem {
58
+ /** Which relation, so a finding points at the edge rather than at the document. */
59
+ readonly index: number;
60
+ readonly code: "relations/unparseable" | "relations/dangling" | "relations/must-pin" | "relations/must-not-pin" | "relations/cross-publisher" | "relations/self" | "relations/cycle";
61
+ readonly relation: Relation;
62
+ readonly message: string;
63
+ }
64
+ export interface Known {
65
+ /** `publisher/slug` of every record the catalog holds. */
66
+ readonly refs: ReadonlySet<string>;
67
+ /** Versions per `publisher/slug`, for checking a pin resolves. */
68
+ readonly versions: Readonly<Record<string, readonly string[]>>;
69
+ /** Step ids per record, so a `part_of` naming a step can be checked against it (W-21). */
70
+ readonly steps?: Readonly<Record<string, readonly string[]>>;
71
+ }
72
+ /**
73
+ * Check one record's relations.
74
+ *
75
+ * Every problem here is a blocking one. A relation that does not resolve is not a
76
+ * best-effort link: it is the record asserting a composition that cannot be assembled, and
77
+ * a reader following it arrives nowhere.
78
+ */
79
+ export declare function checkRelations(self: {
80
+ readonly publisher: string;
81
+ readonly slug: string;
82
+ readonly relations?: readonly Relation[];
83
+ }, known: Known): RelationProblem[];
84
+ /**
85
+ * Cycles, across records.
86
+ *
87
+ * All four kinds must be acyclic, for reasons that differ by kind and agree in outcome. A
88
+ * `requires` cycle is a dependency nobody can satisfy first. A `part_of` cycle is a
89
+ * containment that contains itself. An `escalates_to` cycle is the failure mode it exists
90
+ * to prevent: a handoff loop where an incident circulates and nobody owns it. A
91
+ * `rollback_of` cycle says each of two procedures undoes the other, which is not a thing.
92
+ */
93
+ export declare function relationCycles(records: readonly {
94
+ readonly ref: string;
95
+ readonly relations?: readonly Relation[];
96
+ }[], kind: RelationKind): string[][];
97
+ /**
98
+ * The trust of a composition: the weakest member's, and never better.
99
+ *
100
+ * Stated rather than left to intuition, because intuition here is optimistic. A playbook
101
+ * of a T4 and a T1 is a procedure that will run the T1 — the level of the thing you
102
+ * assembled cannot exceed the level of the part you did not check, and averaging would let
103
+ * one good member launder four bad ones.
104
+ */
105
+ declare const TRUST_ORDER: readonly ["T0", "T1", "T2", "T3", "T4"];
106
+ export type CompositionTrust = (typeof TRUST_ORDER)[number];
107
+ export declare function compositionTrust(levels: readonly CompositionTrust[]): CompositionTrust;
108
+ /**
109
+ * What a relation to a record whose upstream is gone means.
110
+ *
111
+ * Not a broken reference: the record is still here, still published, still what it was.
112
+ * What has gone is the source it was indexed from, and that is a fact about *its*
113
+ * provenance which the composition inherits rather than a fault in the relation. It is
114
+ * surfaced, and the composition's trust already reflects it — that member's own level fell
115
+ * when its upstream did.
116
+ */
117
+ export declare function inheritsUpstream(state: string | undefined): UpstreamState | "unknown";
118
+ export {};
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Relations between runbooks (S-13, §22).
3
+ *
4
+ * v3's premise is that procedures compose: the graph stops being intra-runbook and becomes
5
+ * a graph of runbooks. Four kinds, and each asserts something different enough that the
6
+ * validation rules differ too.
7
+ *
8
+ * A relation is **authored**, unlike an attestation: only the author can say how their
9
+ * procedure stands to another. What they cannot do is make the claim resolve — a reference
10
+ * to a record this catalog does not hold is a broken record, not a pending one.
11
+ */
12
+ import { UPSTREAM_STATES } from "@runbooks/schema";
13
+ export const RELATION_KINDS = ["requires", "rollback_of", "escalates_to", "part_of"];
14
+ export function parseRef(ref) {
15
+ const match = /^([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)(?:@(\d+\.\d+\.\d+))?$/.exec(ref);
16
+ if (!match)
17
+ return undefined;
18
+ return { publisher: match[1], slug: match[2], ...(match[3] ? { semver: match[3] } : {}) };
19
+ }
20
+ /**
21
+ * Which kinds must pin a version, and which must not.
22
+ *
23
+ * `requires` and `rollback_of` assert what *another record does* — it prepares this, it is
24
+ * undone by this — and that is only true of a version. An unpinned claim about behaviour
25
+ * follows the other author's next edit, which is exactly the update drift this catalog
26
+ * exists to expose rather than to inherit.
27
+ *
28
+ * `part_of` and `escalates_to` are structural: this belongs to that, failures go there.
29
+ * Pinning those freezes an arrangement in place, so that a playbook keeps pointing at
30
+ * last year's version of its own step, and the composition rots while every reference
31
+ * still resolves.
32
+ */
33
+ export const MUST_PIN = {
34
+ requires: true,
35
+ rollback_of: true,
36
+ escalates_to: false,
37
+ part_of: false,
38
+ };
39
+ /**
40
+ * Which kinds may cross a publisher boundary.
41
+ *
42
+ * All but `part_of`. Declaring yourself part of somebody else's procedure is a claim about
43
+ * *their* composition, made without them: the containing record decides what it contains.
44
+ * The rest are claims about your own record's relationship to theirs, which is yours to
45
+ * make — and to be wrong about in public.
46
+ */
47
+ export const MAY_CROSS_PUBLISHER = {
48
+ requires: true,
49
+ rollback_of: true,
50
+ escalates_to: true,
51
+ part_of: false,
52
+ };
53
+ /**
54
+ * Check one record's relations.
55
+ *
56
+ * Every problem here is a blocking one. A relation that does not resolve is not a
57
+ * best-effort link: it is the record asserting a composition that cannot be assembled, and
58
+ * a reader following it arrives nowhere.
59
+ */
60
+ export function checkRelations(self, known) {
61
+ const problems = [];
62
+ (self.relations ?? []).forEach((relation, index) => {
63
+ const parsed = parseRef(relation.ref);
64
+ if (!parsed) {
65
+ problems.push({
66
+ index,
67
+ code: "relations/unparseable",
68
+ relation,
69
+ message: `${relation.ref} is not publisher/slug or publisher/slug@semver.`,
70
+ });
71
+ return;
72
+ }
73
+ const target = `${parsed.publisher}/${parsed.slug}`;
74
+ if (target === `${self.publisher}/${self.slug}`) {
75
+ problems.push({
76
+ index,
77
+ code: "relations/self",
78
+ relation,
79
+ message: `A record cannot ${relation.kind} itself. Whatever this was meant to say, it says nothing.`,
80
+ });
81
+ return;
82
+ }
83
+ if (MUST_PIN[relation.kind] && !parsed.semver) {
84
+ problems.push({
85
+ index,
86
+ code: "relations/must-pin",
87
+ relation,
88
+ message: `\`${relation.kind}\` asserts what ${target} does, which is only true of a version. Pin it: ${target}@<semver>.`,
89
+ });
90
+ }
91
+ if (!MUST_PIN[relation.kind] && parsed.semver) {
92
+ problems.push({
93
+ index,
94
+ code: "relations/must-not-pin",
95
+ relation,
96
+ message: `\`${relation.kind}\` is structural, and pinning it freezes the arrangement: the composition would keep pointing at ${parsed.semver} while ${target} moves on. Drop the version.`,
97
+ });
98
+ }
99
+ if (!MAY_CROSS_PUBLISHER[relation.kind] && parsed.publisher !== self.publisher) {
100
+ problems.push({
101
+ index,
102
+ code: "relations/cross-publisher",
103
+ relation,
104
+ message: `\`part_of\` is a claim about ${parsed.publisher}'s composition, and only they can make it. A record does not join somebody else's playbook by declaring that it has.`,
105
+ });
106
+ }
107
+ if (!known.refs.has(target)) {
108
+ problems.push({
109
+ index,
110
+ code: "relations/dangling",
111
+ relation,
112
+ message: `${target} is not a record this catalog holds. A relation to nothing is a composition that cannot be assembled, and a reader following it arrives nowhere.`,
113
+ });
114
+ return;
115
+ }
116
+ /**
117
+ * A `part_of` naming a step the parent does not have (W-21).
118
+ *
119
+ * Checked here because only the catalog can: the child cannot see the parent's steps,
120
+ * and an unchecked `step` would send a reader looking for the detail of a step that
121
+ * is not in the procedure.
122
+ */
123
+ if (relation.kind === "part_of" && relation.step && known.steps) {
124
+ const steps = known.steps[target];
125
+ if (steps && !steps.includes(relation.step)) {
126
+ problems.push({
127
+ index,
128
+ code: "relations/dangling",
129
+ relation,
130
+ message: `${target} has no step \`${relation.step}\`. A decomposition names the step it expands, and this one names something that is not in the parent: ${steps.join(", ") || "it has no steps"}.`,
131
+ });
132
+ }
133
+ }
134
+ if (parsed.semver && !(known.versions[target] ?? []).includes(parsed.semver)) {
135
+ problems.push({
136
+ index,
137
+ code: "relations/dangling",
138
+ relation,
139
+ message: `${target} exists and has no version ${parsed.semver}. Pin one it has: ${(known.versions[target] ?? []).join(", ") || "none published"}.`,
140
+ });
141
+ }
142
+ });
143
+ return problems;
144
+ }
145
+ /**
146
+ * Cycles, across records.
147
+ *
148
+ * All four kinds must be acyclic, for reasons that differ by kind and agree in outcome. A
149
+ * `requires` cycle is a dependency nobody can satisfy first. A `part_of` cycle is a
150
+ * containment that contains itself. An `escalates_to` cycle is the failure mode it exists
151
+ * to prevent: a handoff loop where an incident circulates and nobody owns it. A
152
+ * `rollback_of` cycle says each of two procedures undoes the other, which is not a thing.
153
+ */
154
+ export function relationCycles(records, kind) {
155
+ const edges = new Map();
156
+ for (const record of records) {
157
+ const targets = (record.relations ?? [])
158
+ .filter((relation) => relation.kind === kind)
159
+ .map((relation) => parseRef(relation.ref))
160
+ .filter((parsed) => parsed !== undefined)
161
+ .map((parsed) => `${parsed.publisher}/${parsed.slug}`);
162
+ if (targets.length > 0)
163
+ edges.set(record.ref, targets);
164
+ }
165
+ const cycles = [];
166
+ const seen = new Set();
167
+ const walk = (node, path) => {
168
+ const at = path.indexOf(node);
169
+ if (at !== -1) {
170
+ cycles.push([...path.slice(at), node]);
171
+ return;
172
+ }
173
+ if (seen.has(node))
174
+ return;
175
+ seen.add(node);
176
+ for (const next of edges.get(node) ?? [])
177
+ walk(next, [...path, node]);
178
+ };
179
+ for (const record of records)
180
+ walk(record.ref, []);
181
+ return cycles;
182
+ }
183
+ /**
184
+ * The trust of a composition: the weakest member's, and never better.
185
+ *
186
+ * Stated rather than left to intuition, because intuition here is optimistic. A playbook
187
+ * of a T4 and a T1 is a procedure that will run the T1 — the level of the thing you
188
+ * assembled cannot exceed the level of the part you did not check, and averaging would let
189
+ * one good member launder four bad ones.
190
+ */
191
+ const TRUST_ORDER = ["T0", "T1", "T2", "T3", "T4"];
192
+ export function compositionTrust(levels) {
193
+ if (levels.length === 0)
194
+ return "T0";
195
+ return levels.reduce((weakest, level) => TRUST_ORDER.indexOf(level) < TRUST_ORDER.indexOf(weakest) ? level : weakest);
196
+ }
197
+ /**
198
+ * What a relation to a record whose upstream is gone means.
199
+ *
200
+ * Not a broken reference: the record is still here, still published, still what it was.
201
+ * What has gone is the source it was indexed from, and that is a fact about *its*
202
+ * provenance which the composition inherits rather than a fault in the relation. It is
203
+ * surfaced, and the composition's trust already reflects it — that member's own level fell
204
+ * when its upstream did.
205
+ */
206
+ export function inheritsUpstream(state) {
207
+ // Membership, from the list that defines it. Spelled out, this says "these three are the
208
+ // states" a second time, and a fourth state would be read here as `unknown` — which is a
209
+ // composition quietly told the source is unknowable rather than that it moved.
210
+ return UPSTREAM_STATES.includes(state ?? "")
211
+ ? state
212
+ : "unknown";
213
+ }
@@ -0,0 +1 @@
1
+ export {};