@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.
Files changed (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. 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
+ }