@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
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,22 @@
1
+ # `@runbooks/schema`
2
+
3
+ The published runbook schemas, the content hash, capability parsing, and a validator that
4
+ uses stock ajv with no custom keywords.
5
+
6
+ **Tasks:** [S-01](../../tasks/v0/S-01-frontmatter-and-p0-schema.md),
7
+ [S-02](../../tasks/v0/S-02-p1-step-schema.md),
8
+ [P-14](../../tasks/v2/P-14-converter-package.md) (published) ·
9
+ **Normative:** RUNBOOK.md §5, §9
10
+
11
+ Stock ajv is the point: a published JSON Schema that only validates with our extensions is
12
+ not a published schema. A third party checks conformance with any draft-2020-12 validator,
13
+ and this package is a convenience rather than a requirement.
14
+
15
+ The schemas travel with the package — `spec/v1/schema/*.json`, bundled at pack time from
16
+ the one place they are authored. A validator installed without the documents it validates
17
+ against is a function that throws on first use.
18
+
19
+ `schema/record.json` is the shape of a record **as the catalog serves it**: the authored
20
+ document plus what the build concluded. Validate what you fetch against that one; validate
21
+ what you are about to submit against `p0.json` or `p1.json`, which forbid the fields the
22
+ catalog assigns.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { validateProfile } from "./node.js";
3
+ /**
4
+ * S-01, acceptance criterion 1: a SKILL.md from a public Agent Skills catalog validates
5
+ * as P0 after adding only provenance fields, with no restructuring of its existing keys.
6
+ *
7
+ * This is the criterion the Q2 decision could most easily have broken. Namespacing our
8
+ * fields under `runbook` is only safe if the top level stays genuinely theirs — every
9
+ * key an Agent Skills document already carries, including ones neither specification
10
+ * has defined yet, must survive untouched.
11
+ */
12
+ // Shaped like a real published skill: required keys plus extras we do not control.
13
+ const publishedSkill = {
14
+ name: "kafka-consumer-group-stuck-restart",
15
+ description: "Restart a consumer group that has stopped advancing its offsets.",
16
+ version: "2.1.0",
17
+ license: "Apache-2.0",
18
+ author: "acme platform team",
19
+ metadata: { category: "ops" },
20
+ "allowed-tools": ["Bash"],
21
+ };
22
+ const provenance = {
23
+ runbook: {
24
+ schema_version: "v1",
25
+ profile: "P0",
26
+ domain: "data-pipelines",
27
+ targets: ["kafka"],
28
+ trigger: "alert",
29
+ execution: "human-with-agent",
30
+ risk: "reversible-write",
31
+ capabilities: ["cli:kafka-consumer-groups"],
32
+ publisher: "github:acme",
33
+ lang: "en",
34
+ license: "Apache-2.0",
35
+ source: {
36
+ url: "https://github.com/acme/skills/blob/HEAD/kafka.md",
37
+ fetched_at: "2026-09-02",
38
+ upstream_state: "current",
39
+ },
40
+ },
41
+ };
42
+ describe("Agent Skills superset (S-01 acceptance 1)", () => {
43
+ it("accepts a published skill once provenance is added", () => {
44
+ const result = validateProfile({ ...publishedSkill, ...provenance }, "P0");
45
+ expect(result.errors).toEqual([]);
46
+ });
47
+ it("leaves every original key untouched", () => {
48
+ const doc = { ...publishedSkill, ...provenance };
49
+ for (const [key, value] of Object.entries(publishedSkill)) {
50
+ expect(doc[key]).toEqual(value);
51
+ }
52
+ });
53
+ // The collision Q2 was decided to avoid. `license` at the top level is theirs and
54
+ // means whatever they say; `runbook.license` is ours and is the SPDX identifier the
55
+ // catalog filters on. Both coexist, which is the entire argument for one namespace
56
+ // key over flat fields.
57
+ it("lets an upstream key coexist with our field of the same name", () => {
58
+ const doc = {
59
+ ...publishedSkill,
60
+ license: "see LICENSE.txt",
61
+ ...provenance,
62
+ };
63
+ expect(validateProfile(doc, "P0").valid).toBe(true);
64
+ expect(doc.license).toBe("see LICENSE.txt");
65
+ expect(doc.runbook.license).toBe("Apache-2.0");
66
+ });
67
+ // Without provenance it is a skill, not a catalog record (RUNBOOK.md 10).
68
+ it("rejects the same skill with no provenance", () => {
69
+ expect(validateProfile(publishedSkill, "P0").valid).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Capability grammar, and extraction of the capabilities a shell command actually needs.
3
+ *
4
+ * This lives here, beside the grammar, because two consumers must agree on it exactly:
5
+ * the linter's capability/command consistency gate (P-06) and the supervisor's
6
+ * enforcement (R-02). A second parser would mean a document that passes T2 fails at run
7
+ * time — the catalog would be certifying something the runtime then refuses, which is
8
+ * worse than certifying nothing.
9
+ *
10
+ * Pure: no I/O, so the supervisor's tree stays auditable.
11
+ */
12
+ export type CapabilityKind = "mcp" | "cli" | "iam";
13
+ export interface Capability {
14
+ readonly kind: CapabilityKind;
15
+ readonly urn: string;
16
+ /** The server, binary or service the capability names. */
17
+ readonly name: string;
18
+ }
19
+ export declare function parseCapability(urn: string): Capability | undefined;
20
+ export interface Extraction {
21
+ /** Capabilities the command demonstrably needs. */
22
+ readonly capabilities: readonly string[];
23
+ /**
24
+ * True when the command could not be parsed with confidence.
25
+ *
26
+ * A distinct outcome from "needs nothing", and deliberately so: a gate that treats
27
+ * "I could not tell" as "consistent" is decoration. R-02 blocks and reports; P-06
28
+ * warns that the step is unanalyzable.
29
+ */
30
+ readonly unanalyzable: boolean;
31
+ readonly reason?: string;
32
+ }
33
+ /**
34
+ * The capabilities a command needs, seen through the common wrappers.
35
+ *
36
+ * Conservative by design: an unrecognized shape is reported, never silently passed. A
37
+ * step whose command cannot be analysed is a step nobody has checked, and saying so is
38
+ * more useful than a clean result that means nothing.
39
+ */
40
+ export declare function capabilitiesInCommand(command: string): Extraction;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Capability grammar, and extraction of the capabilities a shell command actually needs.
3
+ *
4
+ * This lives here, beside the grammar, because two consumers must agree on it exactly:
5
+ * the linter's capability/command consistency gate (P-06) and the supervisor's
6
+ * enforcement (R-02). A second parser would mean a document that passes T2 fails at run
7
+ * time — the catalog would be certifying something the runtime then refuses, which is
8
+ * worse than certifying nothing.
9
+ *
10
+ * Pure: no I/O, so the supervisor's tree stays auditable.
11
+ */
12
+ const CAPABILITY = /^(?:(mcp):([a-z0-9][a-z0-9-]*)|(cli):([a-z0-9][a-z0-9._-]*)|(iam):([a-z0-9-]+):([A-Za-z0-9*]+))$/;
13
+ export function parseCapability(urn) {
14
+ const m = CAPABILITY.exec(urn);
15
+ if (!m)
16
+ return undefined;
17
+ if (m[1])
18
+ return { kind: "mcp", urn, name: m[2] };
19
+ if (m[3])
20
+ return { kind: "cli", urn, name: m[4] };
21
+ return { kind: "iam", urn, name: m[6] };
22
+ }
23
+ /** Wrappers that run another command rather than being the command. */
24
+ const PASSTHROUGH = new Set(["sudo", "doas", "command", "nohup", "time", "nice", "ionice"]);
25
+ /**
26
+ * Flags that consume the next token. Without this, `sudo -u postgres psql` attributes
27
+ * the command to `postgres` — the flag's argument — rather than to psql. Getting this
28
+ * wrong is not a cosmetic miss: it silently authorizes the wrong capability.
29
+ */
30
+ const FLAGS_WITH_VALUE = {
31
+ sudo: new Set(["-u", "--user", "-g", "--group", "-p", "--prompt", "-h", "--host", "-C", "-r", "-t"]),
32
+ doas: new Set(["-u", "-C"]),
33
+ nice: new Set(["-n", "--adjustment"]),
34
+ ionice: new Set(["-c", "-n", "-p"]),
35
+ };
36
+ function skipFlags(tokens, from, wrapper) {
37
+ const takesValue = FLAGS_WITH_VALUE[wrapper] ?? new Set();
38
+ let i = from;
39
+ while (i < tokens.length && tokens[i].startsWith("-")) {
40
+ const flag = tokens[i];
41
+ i++;
42
+ // `--user=postgres` carries its value; `--user postgres` does not.
43
+ if (takesValue.has(flag) && !flag.includes("=") && i < tokens.length)
44
+ i++;
45
+ }
46
+ return i;
47
+ }
48
+ const DASH_C = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
49
+ const TAKES_COMMAND_ARG = new Set(["xargs", "watch", "timeout"]);
50
+ /** Shell metacharacters we cannot reason about without a real parser. */
51
+ const OPAQUE = /[`$(){}<>]/;
52
+ /**
53
+ * The command with quoted content removed, so opacity is judged on shell syntax rather
54
+ * than on data.
55
+ *
56
+ * `psql -c "select f('x')"` is perfectly analysable — the parentheses are SQL, not a
57
+ * subshell — and flagging it would make the gate noise that gets ignored. Single quotes
58
+ * are literal, so their content goes entirely. Inside double quotes a shell still
59
+ * expands `$` and backticks, so those survive and everything else does not.
60
+ */
61
+ function shellView(command) {
62
+ // `{{inputs.x}}` is our syntax, not the shell's, and by the time a command runs the
63
+ // supervisor has bound it to a literal (§13.2). Reading our own placeholders as shell
64
+ // grouping would make every parameterised command unanalysable.
65
+ command = command.replace(/\{\{[^}]*\}\}/g, "VALUE");
66
+ let out = "";
67
+ let quote;
68
+ for (const ch of command) {
69
+ if (!quote) {
70
+ if (ch === '"' || ch === "'") {
71
+ quote = ch;
72
+ continue;
73
+ }
74
+ out += ch;
75
+ continue;
76
+ }
77
+ if (ch === quote) {
78
+ quote = undefined;
79
+ continue;
80
+ }
81
+ // Inside double quotes only expansion is still shell; inside single quotes nothing is.
82
+ if (quote === '"' && (ch === "$" || ch === "`"))
83
+ out += ch;
84
+ }
85
+ return out;
86
+ }
87
+ function stripQuotes(token) {
88
+ const first = token[0];
89
+ if ((first === '"' || first === "'") && token.endsWith(first) && token.length > 1) {
90
+ return token.slice(1, -1);
91
+ }
92
+ return token;
93
+ }
94
+ function tokenize(command) {
95
+ // Deliberately simple: quotes and whitespace only. Anything richer is reported as
96
+ // unanalyzable rather than guessed at.
97
+ const out = [];
98
+ let current = "";
99
+ let quote;
100
+ for (const ch of command) {
101
+ if (quote) {
102
+ if (ch === quote)
103
+ quote = undefined;
104
+ else
105
+ current += ch;
106
+ continue;
107
+ }
108
+ if (ch === '"' || ch === "'") {
109
+ quote = ch;
110
+ continue;
111
+ }
112
+ if (/\s/.test(ch)) {
113
+ if (current)
114
+ out.push(current);
115
+ current = "";
116
+ continue;
117
+ }
118
+ current += ch;
119
+ }
120
+ if (current)
121
+ out.push(current);
122
+ return out;
123
+ }
124
+ /**
125
+ * The capabilities a command needs, seen through the common wrappers.
126
+ *
127
+ * Conservative by design: an unrecognized shape is reported, never silently passed. A
128
+ * step whose command cannot be analysed is a step nobody has checked, and saying so is
129
+ * more useful than a clean result that means nothing.
130
+ */
131
+ export function capabilitiesInCommand(command) {
132
+ const trimmed = command.trim();
133
+ if (!trimmed)
134
+ return { capabilities: [], unanalyzable: false };
135
+ if (OPAQUE.test(shellView(trimmed))) {
136
+ return {
137
+ capabilities: [],
138
+ unanalyzable: true,
139
+ reason: "The command uses substitution or grouping, so what it invokes cannot be determined statically. Split it into steps, or name the tool explicitly.",
140
+ };
141
+ }
142
+ const segments = trimmed.split(/\s*(?:\|\||&&|[|;])\s*/).filter(Boolean);
143
+ const found = new Set();
144
+ let unanalyzable = false;
145
+ let reason;
146
+ for (const segment of segments) {
147
+ const tokens = tokenize(segment);
148
+ let i = 0;
149
+ // env-style leading assignments belong to the command, not to a binary.
150
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
151
+ i++;
152
+ if (tokens[i] === "env") {
153
+ i++;
154
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
155
+ i++;
156
+ }
157
+ while (i < tokens.length && PASSTHROUGH.has(tokens[i])) {
158
+ const wrapper = tokens[i];
159
+ i = skipFlags(tokens, i + 1, wrapper);
160
+ }
161
+ const head = tokens[i];
162
+ if (!head)
163
+ continue;
164
+ if (DASH_C.has(head)) {
165
+ const flag = tokens.indexOf("-c", i);
166
+ const inner = flag >= 0 ? tokens.slice(flag + 1).join(" ") : undefined;
167
+ if (!inner) {
168
+ unanalyzable = true;
169
+ reason = `A ${head} invocation with no -c argument reads a script this cannot see.`;
170
+ continue;
171
+ }
172
+ const nested = capabilitiesInCommand(stripQuotes(inner));
173
+ for (const c of nested.capabilities)
174
+ found.add(c);
175
+ if (nested.unanalyzable) {
176
+ unanalyzable = true;
177
+ reason = nested.reason;
178
+ }
179
+ continue;
180
+ }
181
+ if (TAKES_COMMAND_ARG.has(head)) {
182
+ let j = skipFlags(tokens, i + 1, head);
183
+ // `timeout 30s cmd` — skip a bare duration argument too.
184
+ if (head === "timeout" && j < tokens.length && /^\d+[smhd]?$/.test(tokens[j]))
185
+ j++;
186
+ const inner = tokens.slice(j).join(" ");
187
+ if (!inner)
188
+ continue;
189
+ const nested = capabilitiesInCommand(inner);
190
+ for (const c of nested.capabilities)
191
+ found.add(c);
192
+ if (nested.unanalyzable) {
193
+ unanalyzable = true;
194
+ reason = nested.reason;
195
+ }
196
+ continue;
197
+ }
198
+ const binary = head.split("/").pop();
199
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(binary)) {
200
+ unanalyzable = true;
201
+ reason = `"${head}" does not look like a command this can attribute to a capability.`;
202
+ continue;
203
+ }
204
+ found.add(`cli:${binary.toLowerCase()}`);
205
+ }
206
+ return {
207
+ capabilities: [...found],
208
+ unanalyzable,
209
+ ...(reason ? { reason } : {}),
210
+ };
211
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseCapability, capabilitiesInCommand } from "./capabilities.js";
3
+ describe("capability grammar", () => {
4
+ it.each([
5
+ ["mcp:postgres", "mcp", "postgres"],
6
+ ["cli:kubectl", "cli", "kubectl"],
7
+ ["cli:kafka-consumer-groups", "cli", "kafka-consumer-groups"],
8
+ ["iam:s3:PutObject", "iam", "s3"],
9
+ ])("parses %s", (urn, kind, name) => {
10
+ expect(parseCapability(urn)).toMatchObject({ kind, name });
11
+ });
12
+ it.each(["postgres", "mcp:", "http:example.com", "MCP:Postgres"])("rejects %s", (urn) => expect(parseCapability(urn)).toBeUndefined());
13
+ });
14
+ /**
15
+ * R-02 requires this extraction to be the same code the linter uses. These cases are
16
+ * therefore the shared contract: if the two ever disagree about what a command needs, a
17
+ * document can pass T2 and be refused at run time, which is worse than certifying
18
+ * nothing.
19
+ */
20
+ describe("what a command actually invokes", () => {
21
+ const needs = (cmd) => [...capabilitiesInCommand(cmd).capabilities].sort();
22
+ it("finds the plain binary", () => {
23
+ expect(needs("kubectl get pods")).toEqual(["cli:kubectl"]);
24
+ });
25
+ it("looks through sudo and env assignments", () => {
26
+ expect(needs("sudo -u postgres psql -c 'select 1'")).toEqual(["cli:psql"]);
27
+ expect(needs("PGPASSWORD=x psql -l")).toEqual(["cli:psql"]);
28
+ expect(needs("env FOO=1 kubectl get pods")).toEqual(["cli:kubectl"]);
29
+ });
30
+ it("looks inside sh -c", () => {
31
+ expect(needs("sh -c 'kubectl delete pod x'")).toEqual(["cli:kubectl"]);
32
+ });
33
+ it("finds every stage of a pipeline", () => {
34
+ expect(needs("kubectl get pods | grep Running")).toEqual(["cli:grep", "cli:kubectl"]);
35
+ });
36
+ it("follows && and ; as separate commands", () => {
37
+ expect(needs("kubectl drain node && psql -c 'x'")).toEqual(["cli:kubectl", "cli:psql"]);
38
+ });
39
+ it("looks past xargs and timeout", () => {
40
+ expect(needs("xargs -n1 kubectl delete pod")).toEqual(["cli:kubectl"]);
41
+ expect(needs("timeout 30s kubectl get pods")).toEqual(["cli:kubectl"]);
42
+ });
43
+ it("takes the binary out of a path", () => {
44
+ expect(needs("/usr/local/bin/kubectl get pods")).toEqual(["cli:kubectl"]);
45
+ });
46
+ // The conservative fallback. "I could not tell" must never look like "needs nothing":
47
+ // a step nobody could analyse is a step nobody has checked.
48
+ describe("unanalyzable rather than silently clean", () => {
49
+ it.each([
50
+ "kubectl delete pod $(kubectl get pods -o name)",
51
+ "eval `cat script.sh`",
52
+ "for p in a b; do kubectl delete pod $p; done",
53
+ ])("reports %s", (cmd) => {
54
+ const result = capabilitiesInCommand(cmd);
55
+ expect(result.unanalyzable).toBe(true);
56
+ expect(result.reason).toBeTypeOf("string");
57
+ });
58
+ it("is a different outcome from needing nothing", () => {
59
+ const empty = capabilitiesInCommand("");
60
+ expect(empty.unanalyzable).toBe(false);
61
+ expect(empty.capabilities).toEqual([]);
62
+ });
63
+ });
64
+ });
65
+ describe("flags that consume their next token", () => {
66
+ const needs = (cmd) => [...capabilitiesInCommand(cmd).capabilities].sort();
67
+ // Attributing a command to a flag's argument does not merely miss the real binary,
68
+ // it authorizes the wrong capability.
69
+ it.each([
70
+ ["sudo -u postgres psql -c 'select 1'", ["cli:psql"]],
71
+ ["sudo --user=postgres psql -l", ["cli:psql"]],
72
+ ["sudo -g ops -u deploy kubectl get pods", ["cli:kubectl"]],
73
+ ["nice -n 10 kubectl get pods", ["cli:kubectl"]],
74
+ ])("%s needs %j", (cmd, expected) => {
75
+ expect(needs(cmd)).toEqual(expected);
76
+ });
77
+ });
78
+ describe("quoted content is data, not shell syntax", () => {
79
+ const needs = (cmd) => [...capabilitiesInCommand(cmd).capabilities].sort();
80
+ // Flagging every SQL call as unanalysable would make the gate noise, and a noisy gate
81
+ // is one people learn to ignore.
82
+ it("reads a command whose argument contains parentheses", () => {
83
+ const command = `psql -tAc "select pg_drop_replication_slot('wal_keeper')"`;
84
+ expect(capabilitiesInCommand(command).unanalyzable).toBe(false);
85
+ expect(needs(command)).toEqual(["cli:psql"]);
86
+ });
87
+ it("reads a single-quoted argument containing anything at all", () => {
88
+ expect(needs(`psql -c 'select $(1); {x}'`)).toEqual(["cli:psql"]);
89
+ });
90
+ // A shell still expands these inside double quotes, so they remain opaque.
91
+ it("still refuses a substitution inside double quotes", () => {
92
+ expect(capabilitiesInCommand(`sh -c "kubectl delete pod $(id -u)"`).unanalyzable).toBe(true);
93
+ expect(capabilitiesInCommand('echo "`whoami`"').unanalyzable).toBe(true);
94
+ });
95
+ it("still refuses a substitution outside quotes", () => {
96
+ expect(capabilitiesInCommand("kubectl delete pod $(kubectl get pods -o name)").unanalyzable)
97
+ .toBe(true);
98
+ });
99
+ });
100
+ describe("an interpolation is a value, not shell syntax", () => {
101
+ const needs = (cmd) => [...capabilitiesInCommand(cmd).capabilities].sort();
102
+ // Reading our own placeholders as shell grouping would make every parameterised
103
+ // command unanalysable, which is most of the useful ones.
104
+ it("reads a command with a placeholder argument", () => {
105
+ expect(capabilitiesInCommand("df -h {{inputs.mount}}").unanalyzable).toBe(false);
106
+ expect(needs("df -h {{inputs.mount}}")).toEqual(["cli:df"]);
107
+ });
108
+ it("reads a placeholder inside a quoted argument", () => {
109
+ expect(needs(`psql -tAc "select active where name='{{inputs.slot}}'"`)).toEqual(["cli:psql"]);
110
+ });
111
+ // A placeholder may be an argument but not the command itself: if the binary is
112
+ // supplied at run time there is nothing to check against the manifest, and saying so
113
+ // is better than attributing the step to whatever the value happened to be.
114
+ it("refuses a command whose head is a placeholder", () => {
115
+ const result = capabilitiesInCommand("{{inputs.binary}} --flag");
116
+ expect(result.unanalyzable).toBe(true);
117
+ expect(result.capabilities).toEqual([]);
118
+ });
119
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The four content classes are one list (§15).
3
+ *
4
+ * They were written out three times: here as prose in the schema, in `@runbooks/lint` as a
5
+ * type beside a table of what each class means, and in `@runbooks/moderation` as a type
6
+ * beside a table of what the queue does about it. Three copies is two chances to disagree,
7
+ * and they had already taken one — the linter said a `curated` record does not auto-publish
8
+ * while the policy said silence may publish it, and nothing read the linter's copy, so the
9
+ * wrong one was also the invisible one.
10
+ *
11
+ * The names now live in this package with `Risk` and `StepKind`. What holds them to the
12
+ * schema itself is this file: an enum in `record.json` that gains a fifth value, or loses
13
+ * one, fails here rather than in whichever consumer meets the value first.
14
+ */
15
+ import { describe, it, expect } from "vitest";
16
+ import { readFileSync } from "node:fs";
17
+ import { join, dirname, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { CONTENT_CLASSES, UPSTREAM_STATES } from "./index.js";
20
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
21
+ function enumIn(file, field = "content_class") {
22
+ const schema = JSON.parse(readFileSync(join(ROOT, "spec", "v1", "schema", file), "utf8"));
23
+ const found = [];
24
+ const walk = (node) => {
25
+ if (Array.isArray(node))
26
+ return node.forEach(walk);
27
+ if (typeof node !== "object" || node === null)
28
+ return;
29
+ for (const [key, value] of Object.entries(node)) {
30
+ if (key === field && typeof value === "object" && value !== null) {
31
+ const candidate = value.enum;
32
+ if (Array.isArray(candidate))
33
+ found.push(...candidate);
34
+ }
35
+ walk(value);
36
+ }
37
+ };
38
+ walk(schema);
39
+ return found;
40
+ }
41
+ describe("the content class vocabulary", () => {
42
+ it.each(["record.json", "p0.json"])("matches the enum in %s", (file) => {
43
+ const declared = enumIn(file);
44
+ expect(declared.length, `${file} declares no content_class enum`).toBeGreaterThan(0);
45
+ expect([...declared].sort()).toEqual([...CONTENT_CLASSES].sort());
46
+ });
47
+ it("is in the order §15 lists them, least claimed first", () => {
48
+ expect(CONTENT_CLASSES).toEqual(["ingested", "adapted", "submitted", "curated"]);
49
+ });
50
+ /** The type is the list, so a class cannot be added to one and not the other. */
51
+ it("has a type that admits exactly these", () => {
52
+ const every = {
53
+ ingested: true,
54
+ adapted: true,
55
+ submitted: true,
56
+ curated: true,
57
+ };
58
+ expect(Object.keys(every).sort()).toEqual([...CONTENT_CLASSES].sort());
59
+ });
60
+ });
61
+ /**
62
+ * The same, for the state of the source a record came from.
63
+ *
64
+ * Written out three times — the reindexer that sets it, the resolver that warns on it, the
65
+ * page that shows it — and shared by none of them.
66
+ */
67
+ describe("the upstream state vocabulary", () => {
68
+ it.each(["record.json", "p0.json"])("matches the enum in %s", (file) => {
69
+ const declared = enumIn(file, "upstream_state");
70
+ expect(declared.length, `${file} declares no upstream_state enum`).toBeGreaterThan(0);
71
+ expect([...declared].sort()).toEqual([...UPSTREAM_STATES].sort());
72
+ });
73
+ it("has a type that admits exactly these", () => {
74
+ const every = { current: true, changed: true, gone: true };
75
+ expect(Object.keys(every).sort()).toEqual([...UPSTREAM_STATES].sort());
76
+ });
77
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { generateTypes } from "./generate.js";
6
+ import { loadSchema, specSchemaDir } from "./node.js";
7
+ import { servedSchema } from "./served.js";
8
+ const source = generateTypes([
9
+ { name: "RunbookP0", schema: loadSchema("P0") },
10
+ ]);
11
+ // Written to src/, not beside the compiled file: the generated types are source, and
12
+ // checking them in is what lets CI notice when the schema moved and they did not.
13
+ const out = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "generated.ts");
14
+ await writeFile(out, source, "utf8");
15
+ process.stdout.write(`wrote ${out}\n`);
16
+ /**
17
+ * The served-record schema, derived rather than written.
18
+ *
19
+ * The catalog publishes what a record must look like on the way in and serves something
20
+ * with the build's conclusions added. Both are legitimate; publishing only the first,
21
+ * while `/v1/runbooks/*.json` is the second, made "check conformance without our code"
22
+ * fail for anyone who tried it.
23
+ */
24
+ const served = servedSchema(loadSchema("P0"), loadSchema("P1"));
25
+ const servedOut = join(specSchemaDir(), "record.json");
26
+ await writeFile(servedOut, `${JSON.stringify(served, null, 2)}\n`, "utf8");
27
+ process.stdout.write(`wrote ${servedOut}\n`);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Type generation from the published schemas.
3
+ *
4
+ * The failure this prevents: a hand-written interface drifting from the schema we
5
+ * publish, so the site renders a field the specification does not define — or stops
6
+ * rendering one it does. Types are generated, and a schema change without regeneration
7
+ * fails CI.
8
+ *
9
+ * Deliberately narrow rather than a general JSON Schema compiler. It understands exactly
10
+ * the constructs our schemas use and **throws on anything else**, so adding an
11
+ * unsupported construct breaks generation loudly instead of quietly producing a type
12
+ * that is wrong in a way nobody notices. A general library gets this backwards: it
13
+ * guesses, and a guess about a contract is worse than a failure to compile one.
14
+ */
15
+ export interface JsonSchema {
16
+ $schema?: string;
17
+ $id?: string;
18
+ $defs?: Record<string, JsonSchema>;
19
+ $ref?: string;
20
+ title?: string;
21
+ description?: string;
22
+ type?: string;
23
+ enum?: unknown[];
24
+ const?: unknown;
25
+ properties?: Record<string, JsonSchema>;
26
+ required?: string[];
27
+ additionalProperties?: boolean | JsonSchema;
28
+ items?: JsonSchema;
29
+ allOf?: JsonSchema[];
30
+ anyOf?: JsonSchema[];
31
+ oneOf?: JsonSchema[];
32
+ not?: JsonSchema;
33
+ if?: JsonSchema;
34
+ then?: JsonSchema;
35
+ pattern?: string;
36
+ format?: string;
37
+ minLength?: number;
38
+ maxLength?: number;
39
+ minimum?: number;
40
+ maximum?: number;
41
+ minItems?: number;
42
+ maxItems?: number;
43
+ minProperties?: number;
44
+ uniqueItems?: boolean;
45
+ default?: unknown;
46
+ }
47
+ export declare class UnsupportedConstruct extends Error {
48
+ constructor(construct: string, path: string);
49
+ }
50
+ export declare const GENERATED_BANNER = "/* eslint-disable */\n/**\n * GENERATED \u2014 do not edit.\n *\n * Run `pnpm --filter @runbooks/schema generate` after changing spec/v1/schema/*.json.\n * A schema change without regeneration fails CI: a hand-written type that has drifted\n * from the published schema is worse than no type, because it makes the drift invisible.\n */\n";
51
+ export declare function generateTypes(schemas: {
52
+ name: string;
53
+ schema: JsonSchema;
54
+ }[]): string;