@tiphys/kernel 0.0.0 → 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/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readRegularFileIfPresent, refuseOpenForWrite } from "../../task.js";
|
|
4
|
+
const ADAPTER_NAME = "http-json";
|
|
5
|
+
/** RFC 6901 JSON pointer resolution. Absent is distinguished from null. */
|
|
6
|
+
export function resolvePointer(document, pointer) {
|
|
7
|
+
if (pointer === "") {
|
|
8
|
+
return { found: true, value: document };
|
|
9
|
+
}
|
|
10
|
+
if (!pointer.startsWith("/")) {
|
|
11
|
+
return { found: false };
|
|
12
|
+
}
|
|
13
|
+
let current = document;
|
|
14
|
+
for (const rawToken of pointer.slice(1).split("/")) {
|
|
15
|
+
const token = rawToken.split("~1").join("/").split("~0").join("~");
|
|
16
|
+
if (Array.isArray(current)) {
|
|
17
|
+
const index = Number(token);
|
|
18
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
19
|
+
return { found: false };
|
|
20
|
+
}
|
|
21
|
+
current = current[index];
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (typeof current === "object" && current !== null) {
|
|
25
|
+
if (!Object.hasOwn(current, token)) {
|
|
26
|
+
return { found: false };
|
|
27
|
+
}
|
|
28
|
+
current = current[token];
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
return { found: false };
|
|
32
|
+
}
|
|
33
|
+
return { found: true, value: current };
|
|
34
|
+
}
|
|
35
|
+
/** Strict equality for JSON primitives; deep for anything else via JSON. */
|
|
36
|
+
function jsonEqual(a, b) {
|
|
37
|
+
if (a === b) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
44
|
+
}
|
|
45
|
+
function display(value) {
|
|
46
|
+
return value === undefined ? "(no value)" : JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
function writeResponse(request, fields) {
|
|
49
|
+
const refusal = refuseOpenForWrite(request.recordPath);
|
|
50
|
+
if (refusal !== undefined) {
|
|
51
|
+
process.stderr.write(`${ADAPTER_NAME}: ${refusal}\n`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const response = {
|
|
56
|
+
contractVersion: "1",
|
|
57
|
+
adapter: ADAPTER_NAME,
|
|
58
|
+
subject: request.subject,
|
|
59
|
+
outcome: fields.outcome,
|
|
60
|
+
...(fields.resolved === undefined ? {} : { resolved: fields.resolved }),
|
|
61
|
+
observedAt: new Date().toISOString(),
|
|
62
|
+
...(fields.observation === undefined ? {} : { observation: fields.observation }),
|
|
63
|
+
...(fields.reason === undefined ? {} : { reason: fields.reason }),
|
|
64
|
+
...(fields.units === undefined ? {} : { units: fields.units }),
|
|
65
|
+
...(fields.transport === undefined ? {} : { transport: fields.transport }),
|
|
66
|
+
};
|
|
67
|
+
writeFileSync(request.recordPath, `${JSON.stringify(response, null, 2)}\n`);
|
|
68
|
+
}
|
|
69
|
+
function configError(request, field, message) {
|
|
70
|
+
writeResponse(request, {
|
|
71
|
+
outcome: "error",
|
|
72
|
+
reason: `misconfiguration at config.${field}: ${message}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function observeOnce(request) {
|
|
76
|
+
const config = request.config;
|
|
77
|
+
if (typeof config?.url !== "string" || config.url === "") {
|
|
78
|
+
configError(request, "url", "a request URL is required");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (typeof config.observe?.statusPointer !== "string") {
|
|
82
|
+
configError(request, "observe.statusPointer", "a status pointer is required");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!("satisfiedValue" in config.observe)) {
|
|
86
|
+
configError(request, "observe.satisfiedValue", "one satisfying value is required");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const hasTerminalPointer = typeof config.observe.terminalPointer === "string";
|
|
90
|
+
const hasTerminalValue = "terminalValue" in config.observe;
|
|
91
|
+
if (hasTerminalPointer !== hasTerminalValue) {
|
|
92
|
+
configError(request, "observe.terminalPointer", "terminalPointer and terminalValue are declared together or not at all");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let body;
|
|
96
|
+
let httpStatus;
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(config.url, {
|
|
99
|
+
method: config.method ?? "GET",
|
|
100
|
+
});
|
|
101
|
+
httpStatus = response.status;
|
|
102
|
+
body = await response.text();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
writeResponse(request, {
|
|
106
|
+
outcome: "error",
|
|
107
|
+
reason: `request to ${config.url} could not be completed: ${String(error.message)}`,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (httpStatus < 200 || httpStatus > 299) {
|
|
112
|
+
// Unreachable-in-substance: unauthenticated (401), forbidden, missing,
|
|
113
|
+
// server error. No verdict was reached; never pending (M2-C-3).
|
|
114
|
+
writeResponse(request, {
|
|
115
|
+
outcome: "error",
|
|
116
|
+
reason: `HTTP ${String(httpStatus)} from ${config.url}; no verdict can be read from a non-success transport status`,
|
|
117
|
+
transport: { httpStatus },
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(body);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
writeResponse(request, {
|
|
127
|
+
outcome: "error",
|
|
128
|
+
reason: `response body from ${config.url} does not parse as JSON: ${error.message}`,
|
|
129
|
+
transport: { httpStatus },
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Locate, when configured: find the subject's own release object.
|
|
134
|
+
let target = parsed;
|
|
135
|
+
let resolved = { kind: "endpoint", id: config.url };
|
|
136
|
+
if (config.locate !== undefined) {
|
|
137
|
+
const locate = config.locate;
|
|
138
|
+
if (typeof locate.listPointer !== "string" || typeof locate.idPointer !== "string" || !Array.isArray(locate.match) || locate.match.length === 0) {
|
|
139
|
+
configError(request, "locate", "listPointer, idPointer and a non-empty match list are required");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const list = resolvePointer(parsed, locate.listPointer);
|
|
143
|
+
if (!list.found || !Array.isArray(list.value)) {
|
|
144
|
+
writeResponse(request, {
|
|
145
|
+
outcome: "error",
|
|
146
|
+
reason: `no array at locate.listPointer ${JSON.stringify(locate.listPointer)} in the response from ${config.url}`,
|
|
147
|
+
transport: { httpStatus },
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const matches = list.value.filter((item) => locate.match.every((rule) => {
|
|
152
|
+
const value = resolvePointer(item, rule.pointer);
|
|
153
|
+
if (!value.found) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (rule.subjectField !== undefined) {
|
|
157
|
+
return jsonEqual(value.value, request.subject[rule.subjectField]);
|
|
158
|
+
}
|
|
159
|
+
return jsonEqual(value.value, rule.equals);
|
|
160
|
+
}));
|
|
161
|
+
if (matches.length === 0) {
|
|
162
|
+
writeResponse(request, {
|
|
163
|
+
outcome: "absent",
|
|
164
|
+
reason: `no release object matches the subject in the listing from ${config.url} ` +
|
|
165
|
+
`(${String(list.value.length)} object(s) examined against ${String(locate.match.length)} match rule(s))`,
|
|
166
|
+
observation: {
|
|
167
|
+
raw: { objectsExamined: list.value.length },
|
|
168
|
+
detail: "no release object for subject",
|
|
169
|
+
},
|
|
170
|
+
transport: { httpStatus },
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
target = matches[0];
|
|
175
|
+
const id = resolvePointer(target, locate.idPointer);
|
|
176
|
+
if (!id.found || typeof id.value !== "string") {
|
|
177
|
+
writeResponse(request, {
|
|
178
|
+
outcome: "error",
|
|
179
|
+
reason: `located object has no string value at locate.idPointer ${JSON.stringify(locate.idPointer)}`,
|
|
180
|
+
transport: { httpStatus },
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
resolved = { kind: locate.kind ?? "release-object", id: id.value };
|
|
185
|
+
if (locate.createdAtPointer !== undefined) {
|
|
186
|
+
const created = resolvePointer(target, locate.createdAtPointer);
|
|
187
|
+
if (created.found) {
|
|
188
|
+
resolved["createdAt"] = created.value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (matches.length > 1) {
|
|
192
|
+
resolved["matchCount"] = matches.length;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Observe. One satisfying value; verbatim recording of everything else.
|
|
196
|
+
const status = resolvePointer(target, config.observe.statusPointer);
|
|
197
|
+
const observedRaw = {};
|
|
198
|
+
observedRaw[config.observe.statusPointer] = status.found
|
|
199
|
+
? status.value
|
|
200
|
+
: "(no value)";
|
|
201
|
+
let terminal = false;
|
|
202
|
+
if (hasTerminalPointer) {
|
|
203
|
+
const terminalObserved = resolvePointer(target, config.observe.terminalPointer);
|
|
204
|
+
observedRaw[config.observe.terminalPointer] = terminalObserved.found
|
|
205
|
+
? terminalObserved.value
|
|
206
|
+
: "(no value)";
|
|
207
|
+
terminal =
|
|
208
|
+
terminalObserved.found &&
|
|
209
|
+
jsonEqual(terminalObserved.value, config.observe.terminalValue);
|
|
210
|
+
}
|
|
211
|
+
const satisfied = status.found && jsonEqual(status.value, config.observe.satisfiedValue);
|
|
212
|
+
const detail = `${config.observe.statusPointer} = ${display(status.found ? status.value : undefined)}` +
|
|
213
|
+
(hasTerminalPointer
|
|
214
|
+
? `, ${config.observe.terminalPointer} = ${display(observedRaw[config.observe.terminalPointer])}`
|
|
215
|
+
: "");
|
|
216
|
+
if (satisfied) {
|
|
217
|
+
writeResponse(request, {
|
|
218
|
+
outcome: "satisfied",
|
|
219
|
+
resolved,
|
|
220
|
+
observation: { raw: observedRaw, detail },
|
|
221
|
+
transport: { httpStatus },
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (hasTerminalPointer && terminal) {
|
|
226
|
+
// The platform's state machine is finished and the satisfying value is
|
|
227
|
+
// not there: a terminal non-success, named verbatim, never enumerated.
|
|
228
|
+
writeResponse(request, {
|
|
229
|
+
outcome: "failed",
|
|
230
|
+
resolved,
|
|
231
|
+
reason: `terminal state reached with a non-satisfying value: ${detail} ` +
|
|
232
|
+
`(satisfying value is ${display(config.observe.satisfiedValue)})`,
|
|
233
|
+
observation: { raw: observedRaw, detail },
|
|
234
|
+
transport: { httpStatus },
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
writeResponse(request, {
|
|
239
|
+
outcome: "pending",
|
|
240
|
+
resolved,
|
|
241
|
+
observation: { raw: observedRaw, detail },
|
|
242
|
+
transport: { httpStatus },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
async function main() {
|
|
246
|
+
const requestPath = process.argv[2];
|
|
247
|
+
if (requestPath === undefined) {
|
|
248
|
+
process.stderr.write(`usage: node src/gates/adapters/http-json.ts <request-file>\n`);
|
|
249
|
+
process.exitCode = 64;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const read = readRegularFileIfPresent(requestPath);
|
|
253
|
+
if (read.kind !== "read") {
|
|
254
|
+
process.stderr.write(`${ADAPTER_NAME}: request file ${requestPath} ${read.kind === "absent" ? "does not exist" : `refused: ${read.reason}`}\n`);
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let request;
|
|
259
|
+
try {
|
|
260
|
+
request = JSON.parse(read.body);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
process.stderr.write(`${ADAPTER_NAME}: request file does not parse as JSON: ${error.message}\n`);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
await observeOnce(request);
|
|
268
|
+
}
|
|
269
|
+
function isMain() {
|
|
270
|
+
const argv1 = process.argv[1];
|
|
271
|
+
if (argv1 === undefined) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (isMain()) {
|
|
282
|
+
await main();
|
|
283
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, singleLine, } from "../../task.js";
|
|
7
|
+
const ADAPTER_NAME = "migrations-command";
|
|
8
|
+
/** RFC 6901 JSON pointer resolution, local to this adapter's configuration. */
|
|
9
|
+
function resolvePointer(document, pointer) {
|
|
10
|
+
if (pointer === "") {
|
|
11
|
+
return { found: true, value: document };
|
|
12
|
+
}
|
|
13
|
+
if (!pointer.startsWith("/")) {
|
|
14
|
+
return { found: false };
|
|
15
|
+
}
|
|
16
|
+
let current = document;
|
|
17
|
+
for (const rawToken of pointer.slice(1).split("/")) {
|
|
18
|
+
const token = rawToken.split("~1").join("/").split("~0").join("~");
|
|
19
|
+
if (Array.isArray(current)) {
|
|
20
|
+
const index = Number(token);
|
|
21
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
22
|
+
return { found: false };
|
|
23
|
+
}
|
|
24
|
+
current = current[index];
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (typeof current === "object" && current !== null) {
|
|
28
|
+
if (!Object.hasOwn(current, token)) {
|
|
29
|
+
return { found: false };
|
|
30
|
+
}
|
|
31
|
+
current = current[token];
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
return { found: false };
|
|
35
|
+
}
|
|
36
|
+
return { found: true, value: current };
|
|
37
|
+
}
|
|
38
|
+
function writeResponse(request, fields) {
|
|
39
|
+
const refusal = refuseOpenForWrite(request.recordPath);
|
|
40
|
+
if (refusal !== undefined) {
|
|
41
|
+
process.stderr.write(`${ADAPTER_NAME}: ${refusal}\n`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const response = {
|
|
46
|
+
contractVersion: "1",
|
|
47
|
+
adapter: ADAPTER_NAME,
|
|
48
|
+
subject: request.subject,
|
|
49
|
+
outcome: fields.outcome,
|
|
50
|
+
...(fields.resolved === undefined ? {} : { resolved: fields.resolved }),
|
|
51
|
+
observedAt: new Date().toISOString(),
|
|
52
|
+
...(fields.observation === undefined ? {} : { observation: fields.observation }),
|
|
53
|
+
...(fields.reason === undefined ? {} : { reason: fields.reason }),
|
|
54
|
+
...(fields.units === undefined ? {} : { units: fields.units }),
|
|
55
|
+
...(fields.precondition === undefined ? {} : { precondition: fields.precondition }),
|
|
56
|
+
};
|
|
57
|
+
writeFileSync(request.recordPath, `${JSON.stringify(response, null, 2)}\n`);
|
|
58
|
+
}
|
|
59
|
+
function configError(request, field, message) {
|
|
60
|
+
writeResponse(request, {
|
|
61
|
+
outcome: "error",
|
|
62
|
+
reason: `misconfiguration at config.${field}: ${message}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function observeOnce(request) {
|
|
66
|
+
const config = request.config;
|
|
67
|
+
if (typeof config?.migrationsDir !== "string" || config.migrationsDir === "") {
|
|
68
|
+
configError(request, "migrationsDir", "a repository migrations directory is required");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (typeof config.pattern !== "string" || config.pattern === "") {
|
|
72
|
+
configError(request, "pattern", "a filename pattern is required");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(config.appliedCommand) || config.appliedCommand.length === 0) {
|
|
76
|
+
configError(request, "appliedCommand", "an applied-inventory command argv is required");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (typeof config.appliedPointer !== "string") {
|
|
80
|
+
configError(request, "appliedPointer", "a JSON pointer to the applied list is required");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (typeof config.idPointer !== "string") {
|
|
84
|
+
configError(request, "idPointer", "a JSON pointer to each entry's id is required");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let pattern;
|
|
88
|
+
try {
|
|
89
|
+
pattern = new RegExp(config.pattern);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
configError(request, "pattern", `not a valid expression: ${error.message}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// REPOSITORY inventory. The directory and its files are paths this
|
|
96
|
+
// adapter did not create: M2-C-6, type established before any open, a
|
|
97
|
+
// non-regular entry is error naming the path and the type, never a block.
|
|
98
|
+
const dirEntry = classifyEntry(config.migrationsDir);
|
|
99
|
+
if (dirEntry.kind === "absent" || dirEntry.kind === "dangling") {
|
|
100
|
+
writeResponse(request, {
|
|
101
|
+
outcome: "not-applicable",
|
|
102
|
+
reason: `repository migrations directory ${config.migrationsDir} does not exist`,
|
|
103
|
+
precondition: {
|
|
104
|
+
id: "repository-migrations-present",
|
|
105
|
+
evidence: [`${config.migrationsDir}: absent`],
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (dirEntry.kind === "unexaminable") {
|
|
111
|
+
writeResponse(request, { outcome: "error", reason: dirEntry.reason });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (dirEntry.kind === "regular") {
|
|
115
|
+
writeResponse(request, {
|
|
116
|
+
outcome: "error",
|
|
117
|
+
reason: `${config.migrationsDir} is a regular file, not a directory`,
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let names;
|
|
122
|
+
try {
|
|
123
|
+
names = readdirSync(config.migrationsDir);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
writeResponse(request, {
|
|
127
|
+
outcome: "error",
|
|
128
|
+
reason: `${config.migrationsDir} could not be listed: ${singleLine(String(error))}`,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const matching = names.filter((name) => pattern.test(name)).sort();
|
|
133
|
+
if (matching.length === 0) {
|
|
134
|
+
writeResponse(request, {
|
|
135
|
+
outcome: "not-applicable",
|
|
136
|
+
reason: `repository migrations directory ${config.migrationsDir} contains zero ` +
|
|
137
|
+
`migrations matching ${config.pattern}`,
|
|
138
|
+
precondition: {
|
|
139
|
+
id: "repository-migrations-present",
|
|
140
|
+
evidence: [
|
|
141
|
+
`${config.migrationsDir}: ${String(names.length)} entr(ies), 0 matching ${config.pattern}`,
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const repository = new Map();
|
|
148
|
+
for (const name of matching) {
|
|
149
|
+
const path = join(config.migrationsDir, name);
|
|
150
|
+
const read = readRegularFileIfPresent(path);
|
|
151
|
+
if (read.kind === "refused") {
|
|
152
|
+
// M2-C-6: a FIFO or other non-regular entry in the migrations
|
|
153
|
+
// directory is error naming the path and the observed type.
|
|
154
|
+
writeResponse(request, { outcome: "error", reason: read.reason });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (read.kind === "absent") {
|
|
158
|
+
writeResponse(request, {
|
|
159
|
+
outcome: "error",
|
|
160
|
+
reason: `${path} vanished between listing and reading`,
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const match = pattern.exec(name);
|
|
165
|
+
const id = match !== null && match[1] !== undefined ? match[1] : name;
|
|
166
|
+
repository.set(id, {
|
|
167
|
+
file: name,
|
|
168
|
+
sha256: createHash("sha256").update(read.body).digest("hex"),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// APPLIED inventory: another program's output, parsed only at the
|
|
172
|
+
// project-declared pointers, never pattern-matched as text.
|
|
173
|
+
const child = spawnSync(config.appliedCommand[0], config.appliedCommand.slice(1), { encoding: "utf8" });
|
|
174
|
+
if (child.error !== undefined) {
|
|
175
|
+
writeResponse(request, {
|
|
176
|
+
outcome: "error",
|
|
177
|
+
reason: `applied-inventory command could not be run: ${singleLine(String(child.error))}`,
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (child.status !== 0) {
|
|
182
|
+
writeResponse(request, {
|
|
183
|
+
outcome: "error",
|
|
184
|
+
reason: `applied-inventory command exited ${String(child.status)}: ` +
|
|
185
|
+
`${singleLine(child.stderr ?? "")}`,
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(child.stdout ?? "");
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
writeResponse(request, {
|
|
195
|
+
outcome: "error",
|
|
196
|
+
reason: `applied-inventory output does not parse as JSON: ${error.message}`,
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const list = resolvePointer(parsed, config.appliedPointer);
|
|
201
|
+
if (!list.found || !Array.isArray(list.value)) {
|
|
202
|
+
writeResponse(request, {
|
|
203
|
+
outcome: "error",
|
|
204
|
+
reason: `no array at appliedPointer ${JSON.stringify(config.appliedPointer)} in the applied-inventory output`,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const applied = new Map();
|
|
209
|
+
for (let index = 0; index < list.value.length; index += 1) {
|
|
210
|
+
const entry = list.value[index];
|
|
211
|
+
const id = resolvePointer(entry, config.idPointer);
|
|
212
|
+
if (!id.found || (typeof id.value !== "string" && typeof id.value !== "number")) {
|
|
213
|
+
writeResponse(request, {
|
|
214
|
+
outcome: "error",
|
|
215
|
+
reason: `applied entry ${String(index)} has no string or number value at ` +
|
|
216
|
+
`idPointer ${JSON.stringify(config.idPointer)}`,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const record = {};
|
|
221
|
+
if (config.checksumPointer !== undefined) {
|
|
222
|
+
// Content verification was requested. A usable checksum is a non-empty
|
|
223
|
+
// string; null, absent, empty or non-string is NOT a usable checksum,
|
|
224
|
+
// and (CR-P7H-2) must not silently reduce this row to an id-only pass.
|
|
225
|
+
const checksum = resolvePointer(entry, config.checksumPointer);
|
|
226
|
+
if (checksum.found && typeof checksum.value === "string" && checksum.value !== "") {
|
|
227
|
+
record.checksum = checksum.value;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
record.checksumAbsent = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
applied.set(String(id.value), record);
|
|
234
|
+
}
|
|
235
|
+
const repositoryIds = [...repository.keys()];
|
|
236
|
+
const appliedIds = [...applied.keys()];
|
|
237
|
+
const raw = { repository: repositoryIds, applied: appliedIds };
|
|
238
|
+
// Drift: applied migrations the repository does not have. Terminal.
|
|
239
|
+
const drift = appliedIds.filter((id) => !repository.has(id)).sort();
|
|
240
|
+
if (drift.length > 0) {
|
|
241
|
+
writeResponse(request, {
|
|
242
|
+
outcome: "failed",
|
|
243
|
+
resolved: { kind: "applied-migrations", id: appliedIds.join(",") },
|
|
244
|
+
reason: `applied inventory contains ${String(drift.length)} migration(s) the ` +
|
|
245
|
+
`repository does not: ${drift.join(", ")} (drift)`,
|
|
246
|
+
observation: { raw, detail: `drift: ${drift.join(", ")}` },
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Content drift where both sides expose a checksum. Terminal.
|
|
251
|
+
for (const [id, entry] of repository) {
|
|
252
|
+
const appliedEntry = applied.get(id);
|
|
253
|
+
if (appliedEntry?.checksum !== undefined && appliedEntry.checksum !== entry.sha256) {
|
|
254
|
+
writeResponse(request, {
|
|
255
|
+
outcome: "failed",
|
|
256
|
+
resolved: { kind: "applied-migrations", id: appliedIds.join(",") },
|
|
257
|
+
reason: `migration ${id} (${entry.file}) is applied with checksum ` +
|
|
258
|
+
`${appliedEntry.checksum}, repository content sha256 is ${entry.sha256} ` +
|
|
259
|
+
`(content drift)`,
|
|
260
|
+
observation: { raw, detail: `content drift: ${id}` },
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// CR-P7H-2: content verification was requested (checksumPointer configured)
|
|
266
|
+
// but a MATCHED row (present in both inventories) exposes no usable applied
|
|
267
|
+
// checksum, so the comparison the config asked for could not be made. This
|
|
268
|
+
// is NOT a silent pass on id-match: an unchecked assumption never becomes a
|
|
269
|
+
// green (M2-C-3). It is surfaced as error naming the ids, and observation.raw
|
|
270
|
+
// discloses which rows were checksum-compared versus which could not be.
|
|
271
|
+
// Terminal, and ahead of the missing/pending check, because waiting cannot
|
|
272
|
+
// add a checksum the applied inventory does not expose. Two structurally
|
|
273
|
+
// different members reach this: an applied checksum of null, and an absent
|
|
274
|
+
// checksum key (both set checksumAbsent above).
|
|
275
|
+
if (config.checksumPointer !== undefined) {
|
|
276
|
+
const checksumCompared = repositoryIds
|
|
277
|
+
.filter((id) => applied.get(id)?.checksum !== undefined)
|
|
278
|
+
.sort();
|
|
279
|
+
const checksumAbsent = repositoryIds
|
|
280
|
+
.filter((id) => applied.get(id)?.checksumAbsent === true)
|
|
281
|
+
.sort();
|
|
282
|
+
if (checksumAbsent.length > 0) {
|
|
283
|
+
writeResponse(request, {
|
|
284
|
+
outcome: "error",
|
|
285
|
+
resolved: { kind: "applied-migrations", id: appliedIds.join(",") },
|
|
286
|
+
reason: `checksumPointer ${JSON.stringify(config.checksumPointer)} is configured, but ` +
|
|
287
|
+
`${String(checksumAbsent.length)} matched migration(s) expose no applied checksum, ` +
|
|
288
|
+
`so the requested content comparison could not be made: ${checksumAbsent.join(", ")}; ` +
|
|
289
|
+
`an unverifiable row is not a silent pass on id-match (M2-C-3)`,
|
|
290
|
+
observation: {
|
|
291
|
+
raw: { ...raw, checksumCompared, checksumAbsent },
|
|
292
|
+
detail: `checksum requested but absent for: ${checksumAbsent.join(", ")}`,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Missing: repository migrations the applied inventory does not show.
|
|
299
|
+
// Pending, never a terminal red here: the apply job may be queued, and
|
|
300
|
+
// the kernel's deadline is what turns a wait into a red.
|
|
301
|
+
const missing = repositoryIds.filter((id) => !applied.has(id)).sort();
|
|
302
|
+
if (missing.length > 0) {
|
|
303
|
+
writeResponse(request, {
|
|
304
|
+
outcome: "pending",
|
|
305
|
+
resolved: {
|
|
306
|
+
kind: "applied-migrations",
|
|
307
|
+
id: appliedIds.length === 0 ? "(empty applied inventory)" : appliedIds.join(","),
|
|
308
|
+
},
|
|
309
|
+
observation: {
|
|
310
|
+
raw,
|
|
311
|
+
detail: `migrations not applied: ${missing.join(", ")}`,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Every matched row was id-matched, and (when checksumPointer is configured)
|
|
317
|
+
// every matched row also exposed a usable checksum that agreed: an absent
|
|
318
|
+
// one would have been surfaced as error above, so a green here is auditable.
|
|
319
|
+
const satisfiedRaw = config.checksumPointer === undefined
|
|
320
|
+
? raw
|
|
321
|
+
: { ...raw, checksumCompared: repositoryIds };
|
|
322
|
+
writeResponse(request, {
|
|
323
|
+
outcome: "satisfied",
|
|
324
|
+
resolved: { kind: "applied-migrations", id: repositoryIds.join(",") },
|
|
325
|
+
observation: {
|
|
326
|
+
raw: satisfiedRaw,
|
|
327
|
+
detail: `${String(repositoryIds.length)} migration(s) applied and matching` +
|
|
328
|
+
(config.checksumPointer === undefined
|
|
329
|
+
? ""
|
|
330
|
+
: ` (all ${String(repositoryIds.length)} checksum(s) compared and matched)`),
|
|
331
|
+
},
|
|
332
|
+
units: repositoryIds.length,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
function main() {
|
|
336
|
+
const requestPath = process.argv[2];
|
|
337
|
+
if (requestPath === undefined) {
|
|
338
|
+
process.stderr.write(`usage: node src/gates/adapters/migrations-command.ts <request-file>\n`);
|
|
339
|
+
process.exitCode = 64;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const read = readRegularFileIfPresent(requestPath);
|
|
343
|
+
if (read.kind !== "read") {
|
|
344
|
+
process.stderr.write(`${ADAPTER_NAME}: request file ${requestPath} ${read.kind === "absent" ? "does not exist" : `refused: ${read.reason}`}\n`);
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let request;
|
|
349
|
+
try {
|
|
350
|
+
request = JSON.parse(read.body);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
process.stderr.write(`${ADAPTER_NAME}: request file does not parse as JSON: ${error.message}\n`);
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
observeOnce(request);
|
|
358
|
+
}
|
|
359
|
+
function isMain() {
|
|
360
|
+
const argv1 = process.argv[1];
|
|
361
|
+
if (argv1 === undefined) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (isMain()) {
|
|
372
|
+
main();
|
|
373
|
+
}
|