@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.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/agent-skills.test.d.ts +1 -0
- package/dist/agent-skills.test.js +71 -0
- package/dist/capabilities.d.ts +40 -0
- package/dist/capabilities.js +211 -0
- package/dist/capabilities.test.d.ts +1 -0
- package/dist/capabilities.test.js +119 -0
- package/dist/content-classes.test.d.ts +1 -0
- package/dist/content-classes.test.js +77 -0
- package/dist/generate.cli.d.ts +2 -0
- package/dist/generate.cli.js +27 -0
- package/dist/generate.d.ts +54 -0
- package/dist/generate.js +133 -0
- package/dist/generate.test.d.ts +1 -0
- package/dist/generate.test.js +85 -0
- package/dist/generated.d.ts +101 -0
- package/dist/generated.js +9 -0
- package/dist/generated.ts +82 -0
- package/dist/hash.d.ts +36 -0
- package/dist/hash.js +102 -0
- package/dist/hash.test.d.ts +1 -0
- package/dist/hash.test.js +54 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +61 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +236 -0
- package/dist/interpolation.d.ts +11 -0
- package/dist/interpolation.js +34 -0
- package/dist/node.d.ts +41 -0
- package/dist/node.js +111 -0
- package/dist/p1.test.d.ts +1 -0
- package/dist/p1.test.js +87 -0
- package/dist/run-report.test.d.ts +1 -0
- package/dist/run-report.test.js +145 -0
- package/dist/served.d.ts +36 -0
- package/dist/served.js +128 -0
- package/dist/stats.d.ts +17 -0
- package/dist/stats.js +23 -0
- package/dist/stats.test.d.ts +1 -0
- package/dist/stats.test.js +32 -0
- package/dist/validate.d.ts +16 -0
- package/dist/validate.js +46 -0
- package/dist/validate.test.d.ts +1 -0
- package/dist/validate.test.js +61 -0
- package/dist/vocabularies.test.d.ts +1 -0
- package/dist/vocabularies.test.js +77 -0
- package/package.json +50 -0
- package/spec/v1/schema/attestation.json +62 -0
- package/spec/v1/schema/p0.json +383 -0
- package/spec/v1/schema/p1.json +408 -0
- package/spec/v1/schema/record.json +908 -0
- package/spec/v1/schema/run-report-envelope.json +25 -0
- package/spec/v1/schema/run-report.json +121 -0
package/dist/index.d.ts
ADDED
|
@@ -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 {};
|
package/dist/p1.test.js
ADDED
|
@@ -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 {};
|