@runbooks/search 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/dist/stack.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Stack matching (W-20, §17, §13.3).
3
+ *
4
+ * §17's third entry point taken to its conclusion: a reader says what they have — targets
5
+ * and versions, the capabilities their client offers, how much risk they will accept, what
6
+ * supervision they enforce — and the catalog answers what applies to *them*.
7
+ *
8
+ * The privacy shape is the constraint that decides the design. **A declared stack is an
9
+ * infrastructure fingerprint**: postgres 14.3 and kubernetes 1.29 and a private MCP server
10
+ * names an organization more precisely than most of what §14 refuses to collect in a run
11
+ * report. So the declaration never travels. This module is a pure function over the
12
+ * published index, run in the reader's browser, and there is nothing here that could send
13
+ * one anywhere — no client, no logger, no callback.
14
+ *
15
+ * The matching itself is deliberately unclever. A range that cannot be parsed does not
16
+ * match rather than matching hopefully: telling somebody a procedure applies to their
17
+ * cluster when nobody checked is worse than telling them nothing.
18
+ */
19
+ import { DEFAULT_TRUST_MIN } from "./search.js";
20
+ const RISK_ORDER = ["read-only", "reversible-write", "destructive", "irreversible"];
21
+ const PROFILE_ORDER = ["R0", "R1", "R2"];
22
+ const TRUST_ORDER = ["T0", "T1", "T2", "T3", "T4"];
23
+ /**
24
+ * Does this version satisfy this range?
25
+ *
26
+ * A deliberately small subset of what a version range can be: comparators over dotted
27
+ * numbers, joined by spaces meaning "and". Anything else is **unparseable, and unparseable
28
+ * does not match** — a range this cannot read is a question nobody answered, and answering
29
+ * it optimistically puts a procedure in front of somebody whose cluster it was never
30
+ * checked against.
31
+ */
32
+ export function satisfies(version, range) {
33
+ const parts = range.trim().split(/\s+/).filter(Boolean);
34
+ if (parts.length === 0)
35
+ return undefined;
36
+ let verdict = true;
37
+ for (const part of parts) {
38
+ const match = /^(>=|<=|>|<|=)?(\d+(?:\.\d+){0,2})$/.exec(part);
39
+ if (!match)
40
+ return undefined;
41
+ const operator = match[1] ?? "=";
42
+ const compared = compare(version, match[2]);
43
+ const ok = operator === ">=" ? compared >= 0 :
44
+ operator === "<=" ? compared <= 0 :
45
+ operator === ">" ? compared > 0 :
46
+ operator === "<" ? compared < 0 :
47
+ compared === 0;
48
+ verdict = verdict && ok;
49
+ }
50
+ return verdict;
51
+ }
52
+ function compare(a, b) {
53
+ const left = a.split(".").map(Number);
54
+ const right = b.split(".").map(Number);
55
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
56
+ const difference = (left[i] ?? 0) - (right[i] ?? 0);
57
+ if (difference !== 0)
58
+ return difference;
59
+ }
60
+ return 0;
61
+ }
62
+ /**
63
+ * Whether a record applies to a stack, and when it does not, why.
64
+ *
65
+ * The refusals are named because "no results" is the least useful thing a catalog can say
66
+ * to somebody who has just described their environment: a reader who is told *their client
67
+ * only enforces R0* knows what to change, and a reader shown an empty list concludes the
68
+ * catalog is empty.
69
+ */
70
+ export function applicability(record, stack) {
71
+ const declared = Object.keys(stack.targets);
72
+ const matchedTargets = record.targets.filter((target) => {
73
+ if (!declared.includes(target))
74
+ return false;
75
+ const range = record.applies_to?.[target];
76
+ const version = stack.targets[target];
77
+ if (!range || !version)
78
+ return true;
79
+ // Unparseable or unsatisfied both fail: see `satisfies`.
80
+ return satisfies(version, range) === true;
81
+ });
82
+ if (declared.length > 0 && matchedTargets.length === 0) {
83
+ const versioned = record.targets.filter((target) => declared.includes(target));
84
+ if (versioned.length > 0) {
85
+ const ranges = versioned
86
+ .map((target) => `${target} ${record.applies_to?.[target] ?? "any"}`)
87
+ .join(", ");
88
+ return {
89
+ applies: false,
90
+ why: `Your versions are outside what this declares: it applies to ${ranges}.`,
91
+ };
92
+ }
93
+ return {
94
+ applies: false,
95
+ why: `It is for ${record.targets.join(", ") || "no declared target"}, which is not in your stack.`,
96
+ };
97
+ }
98
+ /**
99
+ * §13.3, and the one refusal that is about safety rather than relevance: a client that
100
+ * enforces R0 is not shown a procedure demanding R2. Ranked low, it would still be
101
+ * clicked; refused, it says what the reader would have to change.
102
+ */
103
+ const needs = record.min_runtime_profile ?? "R0";
104
+ const enforces = stack.runtimeProfile ?? "R0";
105
+ if (PROFILE_ORDER.indexOf(needs) > PROFILE_ORDER.indexOf(enforces)) {
106
+ return {
107
+ applies: false,
108
+ why: `It requires ${needs} supervision and your client enforces ${enforces}. This is a refusal rather than a low rank: a procedure that outruns its supervisor is the failure the execution contract exists to prevent.`,
109
+ };
110
+ }
111
+ const missing = record.capabilities.filter((capability) => !stack.capabilities.includes(capability));
112
+ if (missing.length > 0) {
113
+ return {
114
+ applies: false,
115
+ why: `You have not offered ${missing.join(", ")}. Every capability a procedure declares has to be available, or it stops halfway — and part of a destructive procedure is a state nobody designed.`,
116
+ };
117
+ }
118
+ if (stack.riskCeiling && RISK_ORDER.indexOf(record.risk) > RISK_ORDER.indexOf(stack.riskCeiling)) {
119
+ return {
120
+ applies: false,
121
+ why: `Its highest risk is ${record.risk} and you set a ceiling of ${stack.riskCeiling}.`,
122
+ };
123
+ }
124
+ if (stack.execution && record.execution && stack.execution !== record.execution) {
125
+ return { applies: false, why: `It is ${record.execution} and you asked for ${stack.execution}.` };
126
+ }
127
+ const floor = stack.trustMin ?? DEFAULT_TRUST_MIN;
128
+ if (TRUST_ORDER.indexOf(record.trust) < TRUST_ORDER.indexOf(floor)) {
129
+ return {
130
+ applies: false,
131
+ why: stack.trustMin
132
+ ? `It is ${record.trust} and you set a floor of ${stack.trustMin}.`
133
+ : `It is ${record.trust}, below the ${DEFAULT_TRUST_MIN} this shows by default. Lower the floor to see it.`,
134
+ };
135
+ }
136
+ return { applies: true, matchedTargets };
137
+ }
138
+ /**
139
+ * What applies, ranked by applicability and then by trust.
140
+ *
141
+ * Applicability first because relevance is what was asked for; trust second because
142
+ * between two procedures that both fit, the one somebody has verified is the better
143
+ * answer. Ties break on the ref, so two builds of the same catalog agree.
144
+ */
145
+ export function matchStack(records, stack) {
146
+ return records
147
+ .map((record) => ({ record, verdict: applicability(record, stack) }))
148
+ .filter((entry) => entry.verdict.applies)
149
+ .map((entry) => ({ record: entry.record, matchedTargets: entry.verdict.matchedTargets }))
150
+ .sort((a, b) => {
151
+ if (a.matchedTargets.length !== b.matchedTargets.length) {
152
+ return b.matchedTargets.length - a.matchedTargets.length;
153
+ }
154
+ const trust = TRUST_ORDER.indexOf(b.record.trust) - TRUST_ORDER.indexOf(a.record.trust);
155
+ return trust !== 0 ? trust : a.record.ref < b.record.ref ? -1 : 1;
156
+ });
157
+ }
158
+ /** Everything that did not apply, with the reason: an empty list explains nothing. */
159
+ export function excluded(records, stack) {
160
+ return records
161
+ .map((record) => ({ record, verdict: applicability(record, stack) }))
162
+ .filter((entry) => !entry.verdict.applies)
163
+ .map((entry) => ({ ref: entry.record.ref, why: entry.verdict.why }));
164
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@runbooks/search",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Search over the prebuilt index: symptom, facet and capability.",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {},
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/runbooks-directory/runbooks.directory"
20
+ },
21
+ "engines": {
22
+ "node": ">=20.11"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc -b",
33
+ "test": "vitest run --passWithNoTests",
34
+ "lint": "eslint src"
35
+ }
36
+ }