@runbooks/schema 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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -0
  3. package/dist/agent-skills.test.d.ts +1 -0
  4. package/dist/agent-skills.test.js +71 -0
  5. package/dist/capabilities.d.ts +40 -0
  6. package/dist/capabilities.js +211 -0
  7. package/dist/capabilities.test.d.ts +1 -0
  8. package/dist/capabilities.test.js +119 -0
  9. package/dist/content-classes.test.d.ts +1 -0
  10. package/dist/content-classes.test.js +77 -0
  11. package/dist/generate.cli.d.ts +2 -0
  12. package/dist/generate.cli.js +27 -0
  13. package/dist/generate.d.ts +54 -0
  14. package/dist/generate.js +133 -0
  15. package/dist/generate.test.d.ts +1 -0
  16. package/dist/generate.test.js +85 -0
  17. package/dist/generated.d.ts +101 -0
  18. package/dist/generated.js +9 -0
  19. package/dist/generated.ts +82 -0
  20. package/dist/hash.d.ts +36 -0
  21. package/dist/hash.js +102 -0
  22. package/dist/hash.test.d.ts +1 -0
  23. package/dist/hash.test.js +54 -0
  24. package/dist/index.d.ts +58 -0
  25. package/dist/index.js +61 -0
  26. package/dist/index.test.d.ts +1 -0
  27. package/dist/index.test.js +236 -0
  28. package/dist/interpolation.d.ts +11 -0
  29. package/dist/interpolation.js +34 -0
  30. package/dist/node.d.ts +41 -0
  31. package/dist/node.js +111 -0
  32. package/dist/p1.test.d.ts +1 -0
  33. package/dist/p1.test.js +87 -0
  34. package/dist/run-report.test.d.ts +1 -0
  35. package/dist/run-report.test.js +145 -0
  36. package/dist/served.d.ts +36 -0
  37. package/dist/served.js +128 -0
  38. package/dist/stats.d.ts +17 -0
  39. package/dist/stats.js +23 -0
  40. package/dist/stats.test.d.ts +1 -0
  41. package/dist/stats.test.js +32 -0
  42. package/dist/validate.d.ts +16 -0
  43. package/dist/validate.js +46 -0
  44. package/dist/validate.test.d.ts +1 -0
  45. package/dist/validate.test.js +61 -0
  46. package/dist/vocabularies.test.d.ts +1 -0
  47. package/dist/vocabularies.test.js +77 -0
  48. package/package.json +50 -0
  49. package/spec/v1/schema/attestation.json +62 -0
  50. package/spec/v1/schema/p0.json +383 -0
  51. package/spec/v1/schema/p1.json +408 -0
  52. package/spec/v1/schema/record.json +908 -0
  53. package/spec/v1/schema/run-report-envelope.json +25 -0
  54. package/spec/v1/schema/run-report.json +121 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Conformance profiles, generated types, and content_hash.
3
+ * Implementation: tasks/v0/P-02-schema-package.md
4
+ */
5
+ export declare const SCHEMA_VERSION: "v1";
6
+ export { validate, createValidator } from "./validate.js";
7
+ export * from "./served.js";
8
+ export { checkInterpolations } from "./interpolation.js";
9
+ export type { ValidationResult, ValidationError } from "./validate.js";
10
+ export type Profile = "P0" | "P1" | "P2";
11
+ export type RuntimeProfile = "R0" | "R1" | "R2";
12
+ export type Risk = "read-only" | "reversible-write" | "destructive" | "irreversible";
13
+ export type StepKind = "check" | "action" | "decision" | "wait" | "escalate";
14
+ /**
15
+ * How a record got here (§15) — the four classes, in the order §15 lists them.
16
+ *
17
+ * Here rather than in a package that acts on them, for the reason `Risk` and `StepKind`
18
+ * are here: it is a vocabulary the schema defines and several packages read. It had been
19
+ * written out twice, in `@runbooks/lint` and `@runbooks/moderation`, and each grew its own
20
+ * table of what the classes mean. `vocabularies.test.ts` holds this list to the
21
+ * schema's own enum, so the two cannot drift apart silently either.
22
+ */
23
+ export declare const CONTENT_CLASSES: readonly ["ingested", "adapted", "submitted", "curated"];
24
+ export type ContentClass = (typeof CONTENT_CLASSES)[number];
25
+ /**
26
+ * What the source a record came from says today (§3, §10).
27
+ *
28
+ * Another vocabulary the schema declares and several packages read, and another one that
29
+ * had been written out separately in each of them — once in the reindexer, once in the
30
+ * CLI's resolver, once in the site's record page. Three copies of a three-value union is
31
+ * how a fourth state ends up meaning one thing to the program that writes it and nothing
32
+ * at all to the two that read it.
33
+ */
34
+ export declare const UPSTREAM_STATES: readonly ["current", "changed", "gone"];
35
+ export type UpstreamState = (typeof UPSTREAM_STATES)[number];
36
+ /** Risk levels ordered least to most dangerous. Order is load-bearing: a risk ceiling
37
+ * is a comparison, and getting the order wrong permits what it means to forbid. */
38
+ export declare const RISK_ORDER: readonly Risk[];
39
+ /**
40
+ * A count and the noun that goes with it, chosen in code.
41
+ *
42
+ * "3 step(s)" is a programmer counting rather than a sentence. It was fixed on the catalog
43
+ * pages, then in the client components, and stayed on the third surface: the terminal, and
44
+ * the review sheet an operator opens in a browser. One home, so the fourth surface does not
45
+ * get its own version. An irregular noun gives its plural as the third argument.
46
+ */
47
+ export declare function plural(count: number, singular: string, many?: string): string;
48
+ export declare function riskAtMost(actual: Risk, ceiling: Risk): boolean;
49
+ /** The two risk levels the spec treats as dangerous work. This is the line every
50
+ * consequence is drawn from, so it is drawn once: RUNBOOK.md 12 forces
51
+ * `requires_approval: true` on them regardless of what the source document says,
52
+ * invariant 5 requires each one to be paired with a gate or a rollback, and the
53
+ * catalog marks them wherever a reader can see a step without reading it. */
54
+ export declare function isDangerous(risk: Risk): boolean;
55
+ export * from "./capabilities.js";
56
+ export * from "./stats.js";
57
+ export * from "./hash.js";
58
+ export type { RunbookP0 } from "./generated.js";
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Conformance profiles, generated types, and content_hash.
3
+ * Implementation: tasks/v0/P-02-schema-package.md
4
+ */
5
+ export const SCHEMA_VERSION = "v1";
6
+ export { validate, createValidator } from "./validate.js";
7
+ export * from "./served.js";
8
+ export { checkInterpolations } from "./interpolation.js";
9
+ /**
10
+ * How a record got here (§15) — the four classes, in the order §15 lists them.
11
+ *
12
+ * Here rather than in a package that acts on them, for the reason `Risk` and `StepKind`
13
+ * are here: it is a vocabulary the schema defines and several packages read. It had been
14
+ * written out twice, in `@runbooks/lint` and `@runbooks/moderation`, and each grew its own
15
+ * table of what the classes mean. `vocabularies.test.ts` holds this list to the
16
+ * schema's own enum, so the two cannot drift apart silently either.
17
+ */
18
+ export const CONTENT_CLASSES = ["ingested", "adapted", "submitted", "curated"];
19
+ /**
20
+ * What the source a record came from says today (§3, §10).
21
+ *
22
+ * Another vocabulary the schema declares and several packages read, and another one that
23
+ * had been written out separately in each of them — once in the reindexer, once in the
24
+ * CLI's resolver, once in the site's record page. Three copies of a three-value union is
25
+ * how a fourth state ends up meaning one thing to the program that writes it and nothing
26
+ * at all to the two that read it.
27
+ */
28
+ export const UPSTREAM_STATES = ["current", "changed", "gone"];
29
+ /** Risk levels ordered least to most dangerous. Order is load-bearing: a risk ceiling
30
+ * is a comparison, and getting the order wrong permits what it means to forbid. */
31
+ export const RISK_ORDER = [
32
+ "read-only",
33
+ "reversible-write",
34
+ "destructive",
35
+ "irreversible",
36
+ ];
37
+ /**
38
+ * A count and the noun that goes with it, chosen in code.
39
+ *
40
+ * "3 step(s)" is a programmer counting rather than a sentence. It was fixed on the catalog
41
+ * pages, then in the client components, and stayed on the third surface: the terminal, and
42
+ * the review sheet an operator opens in a browser. One home, so the fourth surface does not
43
+ * get its own version. An irregular noun gives its plural as the third argument.
44
+ */
45
+ export function plural(count, singular, many = `${singular}s`) {
46
+ return `${count} ${count === 1 ? singular : many}`;
47
+ }
48
+ export function riskAtMost(actual, ceiling) {
49
+ return RISK_ORDER.indexOf(actual) <= RISK_ORDER.indexOf(ceiling);
50
+ }
51
+ /** The two risk levels the spec treats as dangerous work. This is the line every
52
+ * consequence is drawn from, so it is drawn once: RUNBOOK.md 12 forces
53
+ * `requires_approval: true` on them regardless of what the source document says,
54
+ * invariant 5 requires each one to be paired with a gate or a rollback, and the
55
+ * catalog marks them wherever a reader can see a step without reading it. */
56
+ export function isDangerous(risk) {
57
+ return risk === "destructive" || risk === "irreversible";
58
+ }
59
+ export * from "./capabilities.js";
60
+ export * from "./stats.js";
61
+ export * from "./hash.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { RISK_ORDER, riskAtMost, isDangerous, plural } from "./index.js";
6
+ function repoRoot() {
7
+ let dir = dirname(fileURLToPath(import.meta.url));
8
+ for (let i = 0; i < 8; i += 1) {
9
+ if (existsSync(join(dir, "pnpm-workspace.yaml")))
10
+ return dir;
11
+ dir = resolve(dir, "..");
12
+ }
13
+ throw new Error("repo root not found");
14
+ }
15
+ /**
16
+ * Every source in the workspace, walked once.
17
+ *
18
+ * Two sweeps below read the same tree for two different one-home rules, and a third will
19
+ * come. Reading it twice is how the second one quietly stops covering what the first does.
20
+ */
21
+ const roots = ["packages", "apps"].map((dir) => join(repoRoot(), dir));
22
+ const sources = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
23
+ const full = join(dir, entry.name);
24
+ if (entry.isDirectory()) {
25
+ return ["node_modules", "dist", ".next", ".next-serve"].includes(entry.name)
26
+ ? []
27
+ : sources(full);
28
+ }
29
+ /*
30
+ * `.tsx` too, which for a long time it was not.
31
+ *
32
+ * Every sweep below walked `.ts` and stopped, so the forty-one files that are the
33
+ * pages and the client components — the surface a reader actually meets — were outside
34
+ * all of them. The plural rule was being broken in six of them while the sweep that
35
+ * guards it reported the tree clean.
36
+ */
37
+ const source = entry.name.endsWith(".ts") || entry.name.endsWith(".tsx");
38
+ return source && !entry.name.includes(".test.") ? [full] : [];
39
+ });
40
+ const allSources = roots.flatMap(sources);
41
+ /** Every source but the one that defines the plural rule, which may quote what it forbids. */
42
+ const written = allSources.filter((file) => !file.endsWith(join("schema", "src", "index.ts")));
43
+ describe("risk ordering", () => {
44
+ it("is ordered least to most dangerous", () => {
45
+ expect(RISK_ORDER).toEqual([
46
+ "read-only",
47
+ "reversible-write",
48
+ "destructive",
49
+ "irreversible",
50
+ ]);
51
+ });
52
+ // The order is load-bearing: a risk ceiling is a comparison, and an inverted order
53
+ // permits exactly what it is meant to forbid.
54
+ it("lets a ceiling forbid everything above it", () => {
55
+ expect(riskAtMost("read-only", "reversible-write")).toBe(true);
56
+ expect(riskAtMost("destructive", "reversible-write")).toBe(false);
57
+ expect(riskAtMost("irreversible", "destructive")).toBe(false);
58
+ });
59
+ // RUNBOOK.md 12: forced on output regardless of what the source document says.
60
+ it("forces approval for destructive and irreversible only", () => {
61
+ expect(isDangerous("destructive")).toBe(true);
62
+ expect(isDangerous("irreversible")).toBe(true);
63
+ expect(isDangerous("reversible-write")).toBe(false);
64
+ });
65
+ });
66
+ /**
67
+ * The parenthetical plural, at its last surface (audit).
68
+ *
69
+ * "2 procedure(s)" was fixed on the catalog pages, then "step(s)" three times in the client
70
+ * components, and both sweeps read what those surfaces emit — the built HTML and the
71
+ * component sources. Twenty-four more were in the terminal, in the review sheet an operator
72
+ * opens in a browser, and in supervisor and linter messages, because no sweep read the
73
+ * sources that write them.
74
+ *
75
+ * `plural` is the one home. This is the sweep that keeps a fourth surface from inventing a
76
+ * fourth version of it: no source writes a count in parentheses, and the file that defines
77
+ * the rule may quote the shape it forbids.
78
+ */
79
+ describe("nothing counts in parentheses", () => {
80
+ const files = written;
81
+ it("finds the sources by reading the tree, not a list kept here", () => {
82
+ expect(files.length).toBeGreaterThan(50);
83
+ });
84
+ it.each(files.map((file) => [file.slice(repoRoot().length + 1), file]))("%s writes its plurals out", (name, file) => {
85
+ const at = readFileSync(file, "utf8")
86
+ .split("\n")
87
+ .findIndex((line) => /[A-Za-z]\(s\)/.test(line));
88
+ expect(at, `${name}:${at + 1} counts in parentheses; use plural()`).toBe(-1);
89
+ });
90
+ /**
91
+ * And does not build one out of a ternary, which is the other way to write it by hand.
92
+ *
93
+ * This sweep forbade the string `(s)` — the spelling that was in front of whoever wrote
94
+ * it. Eight plurals were being assembled a different way in the meantime, six of them on
95
+ * pages: `${n} step${n === 1 ? "" : "s"}`, and once `capabilit${n === 1 ? "y" : "ies"}`.
96
+ * Every one of them is `plural()` with more characters and one more place to get an
97
+ * irregular noun wrong.
98
+ */
99
+ /**
100
+ * A ternary is a hand-made plural when its two branches are one noun and its plural —
101
+ * and only then.
102
+ *
103
+ * The first attempt matched any two quoted branches where one ended in `s`, and reported
104
+ * `? "was" : "were"` and `? "pass" : "allow"`: a verb agreeing with its subject, and an
105
+ * enum. Neither is `plural()`'s to fix, and a sweep that cries about them is one somebody
106
+ * turns off. So the two branches are compared as words: singular and singular + s, or
107
+ * -y and -ies, or the bare suffix.
108
+ */
109
+ const isPlural = (one, other) => other === `${one}s` ||
110
+ other === `${one}es` ||
111
+ (one.endsWith("y") && other === `${one.slice(0, -1)}ies`) ||
112
+ (one === "" && (other === "s" || other === "es"));
113
+ it.each(files.map((file) => [file.slice(repoRoot().length + 1), file]))("%s does not assemble a plural out of a ternary", (name, file) => {
114
+ const at = readFileSync(file, "utf8")
115
+ .split("\n")
116
+ .findIndex((line) => [...line.matchAll(/\?\s*"([a-z]*)"\s*:\s*"([a-z]*)"/g)].some((match) => isPlural(match[1], match[2]) || isPlural(match[2], match[1])));
117
+ expect(at, `${name}:${at + 1} builds a plural by hand; use plural()`).toBe(-1);
118
+ });
119
+ });
120
+ /**
121
+ * `median` is the one home too, and this is the sweep that keeps a seventh site from
122
+ * inventing a seventh version of it.
123
+ *
124
+ * There were six: two in the status page's metrics, two in the adaptation numbers, one in
125
+ * the takedown log and one inside the moderation queue's own `percentile`. Five picked the
126
+ * upper of the two middles and the sixth the lower, so `p50` and "median age" were one
127
+ * word for two different numbers — over an even count, which is why every test of each one
128
+ * passed.
129
+ *
130
+ * It reuses the walker above rather than carrying its own: a sweep against duplication
131
+ * that duplicates the sweep is an argument against itself.
132
+ */
133
+ describe("nothing computes a middle by hand", () => {
134
+ const middles = allSources.filter((file) => !file.endsWith(join("schema", "src", "stats.ts")));
135
+ it.each(middles.map((file) => [file.slice(repoRoot().length + 1), file]))("%s asks for the middle rather than working one out", (name, file) => {
136
+ /*
137
+ * Halving a length, in any spelling.
138
+ *
139
+ * This matched `[Math.floor(x.length / 2)]` — square brackets and all — which is one
140
+ * of at least four ways to take a middle. `xs.at(Math.floor(xs.length / 2))`,
141
+ * `xs[(xs.length / 2) | 0]` and `xs.slice(Math.floor(xs.length / 2))[0]` all went
142
+ * straight past it; measured, by writing each into a source and watching the sweep
143
+ * stay green. What they have in common is not the brackets, it is dividing a length
144
+ * by two, so that is what is forbidden — along with `(n - 1) * p`, which is the
145
+ * percentile spelling the moderation queue used before there was one home for it.
146
+ *
147
+ * `stats.ts` is excluded because it is the home. Nothing else in the tree divides a
148
+ * length by two for any reason, so this costs no false positives; a geometric
149
+ * midpoint divides a width or a count, not the length of a list.
150
+ */
151
+ const at = readFileSync(file, "utf8")
152
+ .split("\n")
153
+ .map((line) => line.replace(/\/\/.*$/, ""))
154
+ .findIndex((line) => /\.length\s*(?:\/\s*2\b|\*\s*0\.5)|\(\s*\w+\.length\s*-\s*1\s*\)\s*\*/.test(line));
155
+ expect(at, `${name}:${at + 1} works out a middle by hand; use median() or percentile()`).toBe(-1);
156
+ });
157
+ });
158
+ /**
159
+ * A third rule with one home, guarded from the same walk.
160
+ *
161
+ * `isUntrusted` decides whether a step body is data or instructions (§12), and it lived
162
+ * twice: `packages/lint` derives it from the ladder, `apps/web` enumerated it as T0 or T1.
163
+ * They agreed over the five levels that exist and would have parted over a sixth — with
164
+ * the enumerated one calling an unknown low level trusted, which is the wrong direction
165
+ * for this particular question. The web copy was called by nothing but its own test.
166
+ *
167
+ * It lives beside the other two sweeps because they share the walk, not because the rule
168
+ * belongs to this package: the file that owns it is `packages/lint/src/trust.ts`.
169
+ */
170
+ /**
171
+ * The schema version is written once, and `SCHEMA_VERSION` is where.
172
+ *
173
+ * The constant is the first line of this package and nothing used it. Six places in
174
+ * shipped code wrote `"v1"` into a document by hand — the build twice, the ingest
175
+ * pipeline, the supervisor's report, and both of the web app's exporters — so the version
176
+ * this catalog serves was a string repeated in six files and a constant nobody read.
177
+ *
178
+ * Only value positions. A type annotation has to be the literal (`readonly
179
+ * schema_version: "v1";`), and a semicolon is what tells them apart — checked against the
180
+ * tree before this was written, where it separated the two type positions from the
181
+ * sixteen value ones without a single wrong call.
182
+ *
183
+ * Fixtures are not swept. A test that builds a document with the version spelled out is
184
+ * saying what that document is, and pinning it to the constant would make the fixture
185
+ * follow the code it is meant to hold still.
186
+ */
187
+ describe("the schema version has one home", () => {
188
+ const shipped = written.filter((file) => !file.includes(".test."));
189
+ it.each(shipped.map((file) => [file.slice(repoRoot().length + 1), file]))("%s asks for the version rather than spelling it", (name, file) => {
190
+ const at = readFileSync(file, "utf8")
191
+ .split("\n")
192
+ .map((line) => line.replace(/\/\/.*$/, ""))
193
+ .findIndex((line) => /schema_version:\s*"v\d+"\s*[,}]/.test(line));
194
+ expect(at, `${name}:${at + 1} writes the schema version out; use SCHEMA_VERSION`).toBe(-1);
195
+ });
196
+ });
197
+ describe("nothing decides trust for itself", () => {
198
+ const owner = join("lint", "src", "trust.ts");
199
+ const others = allSources.filter((file) => !file.endsWith(owner));
200
+ /** Comments say what the rule is; only code decides it. */
201
+ const code = (file) => readFileSync(file, "utf8")
202
+ .replace(/\/\*[\s\S]*?\*\//g, "")
203
+ .split("\n")
204
+ .map((line) => line.replace(/\/\/.*$/, ""));
205
+ it.each(others.map((file) => [file.slice(repoRoot().length + 1), file]))("%s does not define its own isUntrusted", (name, file) => {
206
+ const at = code(file).findIndex((line) => /(function|const)\s+isUntrusted\b/.test(line));
207
+ expect(at, `${name}:${at + 1} defines isUntrusted; import it from @runbooks/lint`).toBe(-1);
208
+ });
209
+ /**
210
+ * And does not spell the same rule out inline, which is where the first version of this
211
+ * sweep could not see it.
212
+ *
213
+ * That one forbade a function called `isUntrusted` and found one copy. Two more were
214
+ * sitting in plain sight as `!== "T0" && !== "T1"` — the listing filter that decides
215
+ * which records a reader sees, and the "At T2 or above" number on `/status` — because a
216
+ * check written against a name cannot see the rule written without one. A guard narrower
217
+ * than the rule it guards is the shape this repository keeps finding, and it found it
218
+ * here in its own guard.
219
+ */
220
+ it.each(others.map((file) => [file.slice(repoRoot().length + 1), file]))("%s asks the ladder rather than naming a level", (name, file) => {
221
+ const at = code(file).findIndex((line) => /[=!]==\s*"T[0-4]"/.test(line));
222
+ expect(at, `${name}:${at + 1} compares a trust level by name; ask isUntrusted or atLeast`).toBe(-1);
223
+ });
224
+ });
225
+ describe("a count and its noun", () => {
226
+ it("says one thing in the singular", () => {
227
+ expect(plural(1, "step")).toBe("1 step");
228
+ });
229
+ it("says none and many in the plural", () => {
230
+ expect(plural(0, "step")).toBe("0 steps");
231
+ expect(plural(3, "step")).toBe("3 steps");
232
+ });
233
+ it("takes an irregular plural when the noun has one", () => {
234
+ expect(plural(2, "entry", "entries")).toBe("2 entries");
235
+ });
236
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Interpolation checking — semantic, not structural, so JSON Schema cannot express it:
3
+ * it compares strings inside `command` against the keys of `runbook.inputs`.
4
+ *
5
+ * Why it blocks rather than warns: a command interpolating an undeclared input produces
6
+ * an empty string at run time, and an empty string is how a scoped operation silently
7
+ * becomes an unscoped one. `--group ""` is not a narrower command than `--group prod`;
8
+ * it is a different one (RUNBOOK.md 5, 13.2).
9
+ */
10
+ import type { ValidationError } from "./validate.js";
11
+ export declare function checkInterpolations(doc: unknown): ValidationError[];
@@ -0,0 +1,34 @@
1
+ const PLACEHOLDER = /\{\{\s*inputs\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
2
+ const ANY_PLACEHOLDER = /\{\{\s*([^}]+?)\s*\}\}/g;
3
+ export function checkInterpolations(doc) {
4
+ const d = doc;
5
+ const declared = new Set(Object.keys(d.runbook?.inputs ?? {}));
6
+ const steps = d.runbook?.steps ?? [];
7
+ const errors = [];
8
+ steps.forEach((step, i) => {
9
+ if (typeof step?.command !== "string")
10
+ return;
11
+ const at = `/runbook/steps/${i}/command`;
12
+ for (const [, name] of step.command.matchAll(PLACEHOLDER)) {
13
+ if (name && !declared.has(name)) {
14
+ errors.push({
15
+ path: at,
16
+ message: `step ${step.id ?? i} interpolates {{inputs.${name}}}, which is not declared ` +
17
+ `in runbook.inputs. Declare it, or remove the reference.`,
18
+ });
19
+ }
20
+ }
21
+ // Anything else in braces is not an input reference, and a supervisor has nothing
22
+ // to bind it to. Naming the expression is more useful than "invalid template".
23
+ for (const [, expr] of step.command.matchAll(ANY_PLACEHOLDER)) {
24
+ if (expr && !/^inputs\.[a-zA-Z_][a-zA-Z0-9_]*$/.test(expr.trim())) {
25
+ errors.push({
26
+ path: at,
27
+ message: `step ${step.id ?? i} interpolates {{${expr}}}; only {{inputs.<name>}} is ` +
28
+ `defined. Nothing else has a value at run time.`,
29
+ });
30
+ }
31
+ }
32
+ });
33
+ return errors;
34
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ValidationResult } from "./validate.js";
2
+ import type { Profile } from "./index.js";
3
+ export type PublishedProfile = Extract<Profile, "P0" | "P1">;
4
+ export declare function specSchemaDir(): string;
5
+ export declare function loadSchema(profile: PublishedProfile): object;
6
+ /**
7
+ * The attestation schema (S-05).
8
+ *
9
+ * Published and, until R-09, validated by nothing: the format existed and no code checked
10
+ * a statement against it before signing one. A signature over an unchecked document is a
11
+ * key behind a field nobody reviewed.
12
+ */
13
+ export declare function loadAttestationSchema(): object;
14
+ export declare function validateAttestation(statement: unknown): ValidationResult;
15
+ /** The run report schema, and the CloudEvents envelope that carries one (S-06, §14). */
16
+ export declare function loadRunReportSchema(): object;
17
+ export declare function loadRunReportEnvelopeSchema(): object;
18
+ /**
19
+ * Validate a report, or the envelope carrying one.
20
+ *
21
+ * Nothing in a report is checked semantically the way a runbook is: the whole design is
22
+ * that the schema is the check. A report that validates carries no free text, because
23
+ * there is nowhere in the schema for free text to go.
24
+ */
25
+ export declare function validateRunReport(report: unknown): ValidationResult;
26
+ export declare function validateRunReportEnvelope(event: unknown): ValidationResult;
27
+ /**
28
+ * Schema validation plus the semantic checks that JSON Schema cannot express. Both are
29
+ * reported together because a caller does not care which layer rejected a document -
30
+ * it cares what to fix.
31
+ */
32
+ export declare function validateProfile(doc: unknown, profile: PublishedProfile): ValidationResult;
33
+ /**
34
+ * Which profile a document satisfies, highest first.
35
+ *
36
+ * A document is P1 when it validates as P1, P0 when it only validates as P0, and
37
+ * undefined when it is neither. Reported rather than assumed: a record claiming
38
+ * `profile: P1` with no steps is a claim, and the profile facet has to be the checked
39
+ * fact rather than the claim (§5).
40
+ */
41
+ export declare function detectProfile(doc: unknown): PublishedProfile | undefined;
package/dist/node.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Node-only entry point: loading schemas from disk.
3
+ *
4
+ * Deliberately separate from `./validate.js`. Everything that touches the filesystem
5
+ * lives here so that packages depending on the pure half — supervise, graph — stay free
6
+ * of I/O and can be checked for it mechanically.
7
+ */
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { join, dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { validate } from "./validate.js";
12
+ import { checkInterpolations } from "./interpolation.js";
13
+ export function specSchemaDir() {
14
+ return specDir();
15
+ }
16
+ /**
17
+ * Where the schemas are, in both worlds this package lives in.
18
+ *
19
+ * Installed from a tarball there is one copy: the one `prepack` bundled, beside `dist`.
20
+ * In this repository there are two — that copy and the authored originals in `spec/v1` —
21
+ * and reading the bundled one here would be reading a snapshot of the schemas instead of
22
+ * the schemas. It happened the first time the copy existed: the generator regenerated
23
+ * `record.json` from a stale `p0.json` and wrote it into the package rather than the spec.
24
+ *
25
+ * So the repository wins where there is a repository, identified the way every other
26
+ * loader here identifies it, and the bundled copy is the fallback that makes the published
27
+ * package work at all.
28
+ */
29
+ function specDir() {
30
+ let dir = dirname(fileURLToPath(import.meta.url));
31
+ for (let i = 0; i < 8; i++) {
32
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) {
33
+ const authored = join(dir, "spec", "v1", "schema");
34
+ if (existsSync(authored))
35
+ return authored;
36
+ }
37
+ dir = resolve(dir, "..");
38
+ }
39
+ dir = dirname(fileURLToPath(import.meta.url));
40
+ for (let i = 0; i < 8; i++) {
41
+ const candidate = join(dir, "spec", "v1", "schema");
42
+ if (existsSync(candidate))
43
+ return candidate;
44
+ dir = resolve(dir, "..");
45
+ }
46
+ throw new Error("spec/v1/schema not found above " + import.meta.url);
47
+ }
48
+ export function loadSchema(profile) {
49
+ return JSON.parse(readFileSync(join(specDir(), `${profile.toLowerCase()}.json`), "utf8"));
50
+ }
51
+ /**
52
+ * The attestation schema (S-05).
53
+ *
54
+ * Published and, until R-09, validated by nothing: the format existed and no code checked
55
+ * a statement against it before signing one. A signature over an unchecked document is a
56
+ * key behind a field nobody reviewed.
57
+ */
58
+ export function loadAttestationSchema() {
59
+ return JSON.parse(readFileSync(join(specDir(), "attestation.json"), "utf8"));
60
+ }
61
+ export function validateAttestation(statement) {
62
+ return validate(statement, loadAttestationSchema());
63
+ }
64
+ /** The run report schema, and the CloudEvents envelope that carries one (S-06, §14). */
65
+ export function loadRunReportSchema() {
66
+ return JSON.parse(readFileSync(join(specDir(), "run-report.json"), "utf8"));
67
+ }
68
+ export function loadRunReportEnvelopeSchema() {
69
+ return JSON.parse(readFileSync(join(specDir(), "run-report-envelope.json"), "utf8"));
70
+ }
71
+ /**
72
+ * Validate a report, or the envelope carrying one.
73
+ *
74
+ * Nothing in a report is checked semantically the way a runbook is: the whole design is
75
+ * that the schema is the check. A report that validates carries no free text, because
76
+ * there is nowhere in the schema for free text to go.
77
+ */
78
+ export function validateRunReport(report) {
79
+ return validate(report, loadRunReportSchema());
80
+ }
81
+ export function validateRunReportEnvelope(event) {
82
+ return validate(event, loadRunReportEnvelopeSchema(), [loadRunReportSchema()]);
83
+ }
84
+ /**
85
+ * Schema validation plus the semantic checks that JSON Schema cannot express. Both are
86
+ * reported together because a caller does not care which layer rejected a document -
87
+ * it cares what to fix.
88
+ */
89
+ export function validateProfile(doc, profile) {
90
+ // P1 refers to P0 by absolute $id, so P0 must be registered before P1 compiles.
91
+ const refs = profile === "P1" ? [loadSchema("P0")] : [];
92
+ const structural = validate(doc, loadSchema(profile), refs);
93
+ const semantic = profile === "P1" ? checkInterpolations(doc) : [];
94
+ const errors = [...structural.errors, ...semantic];
95
+ return { valid: errors.length === 0, errors };
96
+ }
97
+ /**
98
+ * Which profile a document satisfies, highest first.
99
+ *
100
+ * A document is P1 when it validates as P1, P0 when it only validates as P0, and
101
+ * undefined when it is neither. Reported rather than assumed: a record claiming
102
+ * `profile: P1` with no steps is a claim, and the profile facet has to be the checked
103
+ * fact rather than the claim (§5).
104
+ */
105
+ export function detectProfile(doc) {
106
+ if (validateProfile(doc, "P1").valid)
107
+ return "P1";
108
+ if (validateProfile(doc, "P0").valid)
109
+ return "P0";
110
+ return undefined;
111
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { validateProfile } from "./node.js";
6
+ import { checkInterpolations } from "./interpolation.js";
7
+ const FIXTURES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
8
+ function load(kind) {
9
+ const dir = join(FIXTURES, kind);
10
+ return readdirSync(dir).map((name) => ({
11
+ name,
12
+ doc: JSON.parse(readFileSync(join(dir, name), "utf8")),
13
+ }));
14
+ }
15
+ describe("P1 schema", () => {
16
+ it.each(load("p1-valid"))("accepts $name", ({ doc }) => {
17
+ const result = validateProfile(doc, "P1");
18
+ expect(result.errors).toEqual([]);
19
+ expect(result.valid).toBe(true);
20
+ });
21
+ it.each(load("p1-invalid"))("rejects $name", ({ doc }) => {
22
+ expect(doc.__expect, "fixture must state which rule it trips").toBeTypeOf("string");
23
+ expect(validateProfile(doc, "P1").valid).toBe(false);
24
+ });
25
+ // A P1 document is a P0 document with more in it. If that stopped being true, every
26
+ // facet, hub and search filter would need a second code path.
27
+ it.each(load("p1-valid"))("$name is also valid P0", ({ doc }) => {
28
+ expect(validateProfile(doc, "P0").valid).toBe(true);
29
+ });
30
+ });
31
+ describe("invariant 5 is structural, not advisory", () => {
32
+ const base = JSON.parse(readFileSync(join(FIXTURES, "p1-valid", "destructive-with-rollback.json"), "utf8"));
33
+ function withStep(step) {
34
+ return { ...base, runbook: { ...base.runbook, steps: [base.runbook.steps[0], step] } };
35
+ }
36
+ it("accepts a destructive action gated by approval", () => {
37
+ const doc = withStep({
38
+ id: "s2", kind: "action", title: "Drop the replication slot",
39
+ risk: "destructive", requires_approval: true,
40
+ });
41
+ expect(validateProfile(doc, "P1").valid).toBe(true);
42
+ });
43
+ it("accepts a destructive action paired with a rollback", () => {
44
+ const doc = withStep({
45
+ id: "s2", kind: "action", title: "Drop the replication slot",
46
+ risk: "destructive", rollback_ref: "s1",
47
+ });
48
+ expect(validateProfile(doc, "P1").valid).toBe(true);
49
+ });
50
+ // requires_approval: false on a destructive step is the case someone will eventually
51
+ // try. It must not satisfy the pairing rule.
52
+ it("refuses a destructive action that opts out of approval", () => {
53
+ const doc = withStep({
54
+ id: "s2", kind: "action", title: "Drop the replication slot",
55
+ risk: "destructive", requires_approval: false,
56
+ });
57
+ expect(validateProfile(doc, "P1").valid).toBe(false);
58
+ });
59
+ it("leaves reversible actions unpaired", () => {
60
+ const doc = withStep({
61
+ id: "s2", kind: "action", title: "Restart the consumer group",
62
+ risk: "reversible-write",
63
+ });
64
+ expect(validateProfile(doc, "P1").valid).toBe(true);
65
+ });
66
+ });
67
+ describe("interpolation", () => {
68
+ const doc = (inputs, command) => ({
69
+ runbook: { inputs, steps: [{ id: "s1", command }] },
70
+ });
71
+ it("accepts a declared input", () => {
72
+ expect(checkInterpolations(doc({ group: { type: "string" } }, "x --g {{inputs.group}}"))).toEqual([]);
73
+ });
74
+ it("names the undeclared input and what to do", () => {
75
+ const errors = checkInterpolations(doc({}, "x --g {{inputs.group}}"));
76
+ expect(errors).toHaveLength(1);
77
+ expect(errors[0].message).toContain("inputs.group");
78
+ expect(errors[0].message).toContain("Declare it");
79
+ });
80
+ it("rejects anything that is not an input reference", () => {
81
+ const errors = checkInterpolations(doc({}, "x --g {{env.GROUP}}"));
82
+ expect(errors.some((e) => e.message.includes("env.GROUP"))).toBe(true);
83
+ });
84
+ it("tolerates a step with no command", () => {
85
+ expect(checkInterpolations({ runbook: { steps: [{ id: "s1" }] } })).toEqual([]);
86
+ });
87
+ });
@@ -0,0 +1 @@
1
+ export {};