@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,1536 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { accessSync, constants as fsConstants, lstatSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
6
+ /* M3-P2 step 4. The registry is validated by the M3-P1 Ajv engine, not by
7
+ M2's closed-keyword validator, because `schemas/gate-registry.schema.json`
8
+ uses `if`/`then`. `loadTypeSchema` is imported from the validate COMMAND
9
+ rather than re-deriving the shipped `schemas/` location here, because that
10
+ walk is already written, already tested, and already correct in both the
11
+ source and the dist layouts; a second copy would be a second thing to keep
12
+ right. */
13
+ import { loadTypeSchema } from "../commands/validate.js";
14
+ import { decodeDocument, formatDiagnostics, validateInstance } from "../validate.js";
15
+ import { loadManifest, validateManifestDocument, validateResultDocument } from "./manifest.js";
16
+ import { comparePins, describePinDifference } from "./pin.js";
17
+ import { EXIT_GATE_ERROR, EXIT_GREEN, EXIT_NOT_APPLICABLE, EXIT_RED, M2_C_2_DETAIL, exitCodeForStatus, makeGateResult, renderGateResult, statusForExitCode, } from "./result.js";
18
+ export const NO_APPLICABLE_GATE = "no applicable gate";
19
+ /** The default assurance mode when `--registry` is given without `--mode`. */
20
+ export const DEFAULT_MODE = "full";
21
+ /**
22
+ * Does this assurance mode select this entry?
23
+ *
24
+ * A named predicate rather than an inline callback, so the SELECTION RULE and
25
+ * the act of filtering are two separately breakable things. The clean-room
26
+ * review of this phase deleted the filter outright (`inMode = document.gates`)
27
+ * and the whole suite stayed green, because every mode fixture declared
28
+ * exactly the mode it was run under and no test had an entry that had to be
29
+ * EXCLUDED. `gate-registry-mode-excludes-other-modes` is the witness for the
30
+ * exclusion, and separating the predicate from the call site is what lets the
31
+ * two failure shapes, "no filter at all" and "a filter whose condition does
32
+ * not test membership", be reddened independently.
33
+ */
34
+ function selectsMode(entry, mode) {
35
+ return entry.modes.includes(mode);
36
+ }
37
+ /**
38
+ * LOAD A CANONICAL GATE REGISTRY AND PROJECT IT ONTO AN M2 GATE MANIFEST
39
+ * (kernel plan M3, M3-P2 steps 2 and 4; R-094).
40
+ *
41
+ * The registry is a SUPERSET of the M2-P1 manifest, so the promotion is a
42
+ * projection and not a rewrite, and this function is the whole of it. Four
43
+ * things happen here and each one is load-bearing:
44
+ *
45
+ * 1. The path is operator-supplied, so it is READ through the delivered
46
+ * `readRegularFileIfPresent` (M2-C-6). A named pipe reports the observed
47
+ * type and never blocks.
48
+ * 2. The document is DECODED (YAML or JSON, `decodeDocument`) and then
49
+ * VALIDATED against `schemas/gate-registry.schema.json` through the
50
+ * M3-P1 Ajv engine. Decoding and validation are separate stages and
51
+ * produce distinguishable diagnostics (DR-0013 YAML clause 3). The
52
+ * registry schema is NOT validated by `src/gates/validate.ts`: it uses
53
+ * `if`/`then`, which is outside M2-D-04's closed keyword set, which is
54
+ * exactly why it lives in the shipped `schemas/` directory.
55
+ * 3. Entries are selected by MODE and by `verified-by`. A
56
+ * `clean-room-checklist` entry has no process to run (D-11: R-043 and
57
+ * R-044 are not computable from a diff), so it is reported as declared
58
+ * rather than silently dropped.
59
+ * 4. The projected manifest is validated AGAIN, against the M2 manifest
60
+ * schema, by `validateManifestDocument`. That second validation is the
61
+ * superset claim turned into a check: if a projection is not a valid M2
62
+ * manifest then the registry is not a superset of the manifest, and the
63
+ * run refuses rather than proceeding on a document the M2 contract does
64
+ * not recognise.
65
+ *
66
+ * Everything after this function is the M2 runner untouched, so M2-C-2 (never
67
+ * green by omission) and M2-C-3 (fail closed) apply to registry runs by
68
+ * CONSTRUCTION rather than by being remembered. A `--registry` path that
69
+ * built `GateResult` literals of its own would be the realistic way those two
70
+ * constraints get dropped by a promotion, and there is no such path.
71
+ */
72
+ export function loadRegistry(path, mode) {
73
+ const read = readRegularFileIfPresent(path);
74
+ if (read.kind === "absent") {
75
+ return { ok: false, reason: `registry ${path} does not exist`, diagnostics: [] };
76
+ }
77
+ if (read.kind === "refused") {
78
+ return { ok: false, reason: read.reason, diagnostics: [] };
79
+ }
80
+ const decoded = decodeDocument(read.body, path);
81
+ if (!decoded.ok) {
82
+ return { ok: false, reason: decoded.reason, diagnostics: [] };
83
+ }
84
+ let schema;
85
+ try {
86
+ schema = loadTypeSchema("gate-registry");
87
+ }
88
+ catch (error) {
89
+ return {
90
+ ok: false,
91
+ reason: `the gate-registry schema could not be loaded: ${singleLine(error.message)}`,
92
+ diagnostics: [],
93
+ };
94
+ }
95
+ const diagnostics = formatDiagnostics(validateInstance(schema, decoded.value));
96
+ if (diagnostics.length > 0) {
97
+ return {
98
+ ok: false,
99
+ reason: `registry ${path} is not a valid gate registry`,
100
+ diagnostics,
101
+ };
102
+ }
103
+ const document = decoded.value;
104
+ const declaredModes = new Set();
105
+ for (const entry of document.gates) {
106
+ for (const name of entry.modes) {
107
+ declaredModes.add(name);
108
+ }
109
+ }
110
+ if (!declaredModes.has(mode)) {
111
+ // FAIL CLOSED (M2-C-3). A mode no entry declares selects nothing, and a
112
+ // run of nothing that exited 0 would be the vacuous pass this registry
113
+ // exists to prevent. The known modes are named so the operator can see
114
+ // the typo rather than guess at an empty bundle.
115
+ return {
116
+ ok: false,
117
+ reason: `registry ${path} declares no gate for mode ${mode}; declared mode(s): ` +
118
+ `${[...declaredModes].sort().join(", ")}`,
119
+ diagnostics: [],
120
+ };
121
+ }
122
+ const inMode = document.gates.filter((entry) => selectsMode(entry, mode));
123
+ const declaredByChecklist = inMode
124
+ .filter((entry) => entry["verified-by"] === "clean-room-checklist")
125
+ .map((entry) => ({
126
+ id: entry.id,
127
+ probe: entry.probe,
128
+ applicability: entry.applicability,
129
+ }));
130
+ const gates = inMode
131
+ .filter((entry) => entry["verified-by"] === "script")
132
+ .map((entry) => {
133
+ const projected = {
134
+ id: entry.id,
135
+ command: entry.command,
136
+ unitLabel: entry.unitLabel,
137
+ applicability: entry.applicability,
138
+ modes: entry.modes,
139
+ };
140
+ if (entry.parameters !== undefined) {
141
+ projected.parameters = entry.parameters;
142
+ }
143
+ if (entry.precondition !== undefined) {
144
+ projected.precondition = entry.precondition;
145
+ }
146
+ return projected;
147
+ });
148
+ const manifest = {
149
+ version: document.version,
150
+ gates,
151
+ destructiveCommands: document.destructiveCommands,
152
+ };
153
+ const projectionDiagnostics = validateManifestDocument(manifest);
154
+ if (projectionDiagnostics.length > 0) {
155
+ return {
156
+ ok: false,
157
+ reason: `registry ${path} projected onto an M2 gate manifest that the M2 schema refuses, ` +
158
+ "so this registry is not a superset of the manifest it promotes",
159
+ diagnostics: projectionDiagnostics,
160
+ };
161
+ }
162
+ return {
163
+ ok: true,
164
+ manifest,
165
+ sha256: createHash("sha256").update(read.body).digest("hex"),
166
+ body: read.body,
167
+ declaredByChecklist,
168
+ };
169
+ }
170
+ function now() {
171
+ return new Date().toISOString();
172
+ }
173
+ /**
174
+ * Escape a value being substituted into regex SOURCE. The set is the one
175
+ * MDN documents for this purpose; `-` is included because it is special
176
+ * inside a character class and a substituted value can land in one.
177
+ */
178
+ function escapeRegExp(value) {
179
+ return value.replace(/[.*+?^${}()|[\]\\\-]/g, "\\$&");
180
+ }
181
+ function isRealDirectory(path) {
182
+ try {
183
+ return lstatSync(path).isDirectory();
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ /**
190
+ * Create a directory, refusing a path that exists and is not a real
191
+ * directory. Never opens anything, so a named pipe here reports rather than
192
+ * blocking.
193
+ */
194
+ function ensureDirectory(path) {
195
+ const entry = classifyEntry(path);
196
+ if (entry.kind === "regular") {
197
+ return `${path} is a regular file, not a directory`;
198
+ }
199
+ if (entry.kind === "unexaminable") {
200
+ return entry.reason;
201
+ }
202
+ if (entry.kind === "irregular" && !isRealDirectory(path)) {
203
+ return entry.reason;
204
+ }
205
+ const made = runStep(`creating ${path}`, () => mkdirSync(path, { recursive: true }));
206
+ return made.ok ? undefined : made.reason;
207
+ }
208
+ /**
209
+ * MUTATE ONLY WHILE THIS RUN HOLDS THE CLAIM (CR-860, then CR-900).
210
+ *
211
+ * The finding's mechanism is *a mutation of evidence-directory state
212
+ * performed from a frame that does not know whether the claim is held*.
213
+ *
214
+ * CR-900 is the correction of ONE WORD in that sentence. Round two read the
215
+ * mechanism as being about WRITES, built `writeInsideClaim` around
216
+ * `writeFileSync`, derived its coverage by grepping for the name of its own
217
+ * new wrapper, and concluded that every write into the directory verified the
218
+ * claim. That sentence was true and it was not the property that mattered,
219
+ * because a directory's state is mutated by six operations and not by one:
220
+ *
221
+ * CREATE, CONTENT-WRITE, DELETE, RENAME, MKDIR, and SUBPROCESS DISPATCH
222
+ * that will write there.
223
+ *
224
+ * A two-gate construction defeated the total claim in one run: the runner
225
+ * `rmSync`ed a foreign holder's `result.json` and then spawned a gate into a
226
+ * directory whose claim it had lost, neither of which is a `writeFileSync`.
227
+ *
228
+ * So the predicate is separated from the operation. THIS function answers
229
+ * "may this run mutate that directory right now", and every one of the six
230
+ * operations asks it, rather than one of them owning the rule. The wrappers
231
+ * below (`writeInsideClaim`, `mkdirInsideClaim`) and the four inline checks in
232
+ * `runOneGate`, `runClaimedBundle` and `writeSummaryAtomically` are its
233
+ * callers; the work history's CR-900 section carries the full inventory that
234
+ * fixes what "every" ranges over, derived from the closure's `node:fs` and
235
+ * `node:child_process` imports rather than from the names of these wrappers.
236
+ *
237
+ * Guarding the mutation rather than the call site also makes the ordering
238
+ * OBSERVABLE, which it was not: a release moved back in front of a mutation is
239
+ * now a refusal with a reason, rather than an identical end state reachable
240
+ * two ways. That is what let the ordering be red-witnessed at all (G2b).
241
+ *
242
+ * NOT COVERED, and stated rather than implied: a gate subprocess's own write
243
+ * to the record path it was handed. This runner cannot guard another
244
+ * process's write. What it CAN do, and now does, is decline to dispatch that
245
+ * process at all when it does not hold the claim, and decline to certify a
246
+ * run in which that happened.
247
+ */
248
+ function refuseUnlessHolder(evidenceDir, runId, what) {
249
+ const holder = claimHolder(evidenceDir);
250
+ if (holder !== runId) {
251
+ return (`refusing to ${what}: this run (${runId}) does not hold the claim ` +
252
+ `on ${evidenceDir} (held by ${holder ?? "nobody"})`);
253
+ }
254
+ return undefined;
255
+ }
256
+ /** Content write, row 2 of the CR-900 inventory. */
257
+ function writeInsideClaim(evidenceDir, runId, path, body) {
258
+ return (refuseUnlessHolder(evidenceDir, runId, `write ${path}`) ??
259
+ guardedWrite(path, body));
260
+ }
261
+ /**
262
+ * Mkdir, rows 1a and 1b of the CR-900 inventory. Creating a directory inside
263
+ * an evidence tree this run no longer owns is a smaller harm than deleting a
264
+ * record, and it is the same mechanism: it leaves a directory the real holder
265
+ * never made, in a bundle attributed to the real holder.
266
+ *
267
+ * Row 1c, the creation of the evidence directory ITSELF at the top of
268
+ * `runGatesInner`, deliberately does NOT go through here and must not: the
269
+ * directory has to exist before a claim file can be created inside it, so a
270
+ * holdership test there would be unsatisfiable by construction. That single
271
+ * mkdir is the only mutation in this module that legitimately precedes the
272
+ * claim, and it creates a directory rather than touching any content.
273
+ */
274
+ function mkdirInsideClaim(evidenceDir, runId, path) {
275
+ return (refuseUnlessHolder(evidenceDir, runId, `create ${path}`) ??
276
+ ensureDirectory(path));
277
+ }
278
+ /** Write a file, refusing any path that is not safe to open for writing. */
279
+ function guardedWrite(path, body) {
280
+ const refusal = refuseOpenForWrite(path);
281
+ if (refusal !== undefined) {
282
+ return refusal;
283
+ }
284
+ const written = runStep(`writing ${path}`, () => writeFileSync(path, body));
285
+ return written.ok ? undefined : written.reason;
286
+ }
287
+ /**
288
+ * Which run parameters a gate cannot be evaluated without.
289
+ *
290
+ * Derived from two places and nowhere else: the gate's own `parameters`
291
+ * declaration, and the precondition kind, whose needs are a property of the
292
+ * kind rather than of the gate. `branch-matches` requires `--phase`
293
+ * unconditionally rather than only when the pattern happens to interpolate
294
+ * it, which is stricter than necessary and deliberately so: M2-C-3 says a
295
+ * check that cannot reach a verdict fails closed, and the cost of the strict
296
+ * reading is one flag on an invocation that already carries three.
297
+ */
298
+ export function requiredParameters(entry) {
299
+ const required = new Set(entry.parameters ?? []);
300
+ const kind = entry.precondition?.kind;
301
+ if (kind === "diff-touches") {
302
+ required.add("base");
303
+ }
304
+ if (kind === "branch-matches") {
305
+ required.add("phase");
306
+ }
307
+ return [...required].sort();
308
+ }
309
+ /**
310
+ * ELEMENTS THAT ARE NOT PATHS, WHATEVER SHAPE THEY HAVE. One function, so the
311
+ * two rules below cannot drift apart: fix round 2 found the URL case by
312
+ * fixing only the wider rule and watching the narrower one hard-refuse the
313
+ * same element BEFORE the spawn, which is a strictly worse failure because it
314
+ * refuses a command that would have exited 0. The three reasons are the three
315
+ * this file claims to know, and each is written down rather than inferred:
316
+ *
317
+ * an OPTION begins with `-`. `--out=/tmp/x` is an option carrying
318
+ * a value, not a path; probing the whole element could
319
+ * never succeed, since no file is named `--out=/tmp/x`.
320
+ * an OPTION'S CODE the element before it is in `CODE_VALUED_OPTIONS`.
321
+ * a URL `scheme://...` has slashes and no filesystem.
322
+ */
323
+ function namesNoPath(element, previous) {
324
+ return (element.startsWith("-") || CODE_VALUED_OPTIONS.has(previous) || URL_SHAPED.test(element));
325
+ }
326
+ /**
327
+ * The path operands of a command, by the four-part rule documented above,
328
+ * MINUS the elements `namesNoPath` rules out. This is the STRICT set: it is
329
+ * probed before the spawn and a failure here is a hard `error`, so it stays
330
+ * conservative and keeps the whitespace and after-an-option guards that the
331
+ * wider rule drops.
332
+ */
333
+ export function commandPathOperands(command) {
334
+ const operands = [];
335
+ for (let index = 1; index < command.length; index += 1) {
336
+ const element = command[index];
337
+ const previous = command[index - 1];
338
+ if (namesNoPath(element, previous)) {
339
+ continue;
340
+ }
341
+ if (!element.includes("/")) {
342
+ continue;
343
+ }
344
+ if (/\s/.test(element)) {
345
+ continue;
346
+ }
347
+ if (previous.startsWith("-")) {
348
+ continue;
349
+ }
350
+ operands.push(element);
351
+ }
352
+ return operands;
353
+ }
354
+ /**
355
+ * Options whose VALUE is CODE and never a path. A closed, explicit list, and
356
+ * that is the point: it is the one piece of launcher grammar this file claims
357
+ * to know, it is written down rather than inferred, and anything not in it is
358
+ * treated as possibly naming a path. `node -e` and `node --eval` are the pair
359
+ * that matters here, because `credential-token`'s real precondition in
360
+ * gates.manifest.json:57 is exactly that shape; the others are the same
361
+ * construct in the launchers a precondition is most likely to use (`sh -c`,
362
+ * `bash -c`, `python -c`, `perl -e`, `ruby -e`, `node -p`).
363
+ */
364
+ const CODE_VALUED_OPTIONS = new Set([
365
+ "-e",
366
+ "--eval",
367
+ "-p",
368
+ "--print",
369
+ "-c",
370
+ "--command",
371
+ ]);
372
+ /**
373
+ * Suffixes that make a DIRECTORY-LESS operand a script path. This is the only
374
+ * reason `node check.mjs` (fix round 1's declared residue, and the exact
375
+ * defect class this phase exists to close) is visible at all: it has no `/`,
376
+ * so the separator test cannot see it. A closed list, deliberately, because
377
+ * the alternative is dropping the shape test entirely and that breaks a real
378
+ * declaration: `check-dual-review`'s precondition ends `--precondition .`, and
379
+ * `.` resolves to a DIRECTORY, which `probeOpenable` calls irregular, which
380
+ * would make every run of that gate a false `error`. Measured, not assumed.
381
+ */
382
+ const SCRIPT_SUFFIXES = [
383
+ ".mjs",
384
+ ".cjs",
385
+ ".js",
386
+ ".ts",
387
+ ".mts",
388
+ ".cts",
389
+ ".sh",
390
+ ".bash",
391
+ ".py",
392
+ ".rb",
393
+ ".pl",
394
+ ];
395
+ /** `scheme://...`, which contains slashes and is never a filesystem path. */
396
+ const URL_SHAPED = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
397
+ /**
398
+ * Every argv element that names a PATH THE COMMAND NEEDED, used only to decide
399
+ * whether a NONZERO exit is attributable to the command's own logic.
400
+ *
401
+ * ------------------------------------------------------------------------
402
+ * FIX ROUND 2 (M3-P11) REWROTE THIS RULE, AND WHY IS THE WHOLE POINT.
403
+ *
404
+ * Fix round 1 defined it as "contains `/`", at any position, launcher
405
+ * included. A delta verifier measured what that costs
406
+ * (delivery/verification/m3-p11-fix-round-1.md, findings 2a and 2b) and the
407
+ * cost is larger than round 1's own note conveyed: `decideAggregate` checks
408
+ * `counts.error > 0` FIRST, so ONE false error on ONE conditional gate fails
409
+ * the ENTIRE bundle. An honest, correctly written precondition could take a
410
+ * consumer's whole delivery down. That is a worse failure than the silent
411
+ * skip this phase set out to abolish, because a silent skip is wrong and
412
+ * quiet while this is wrong and total.
413
+ *
414
+ * THE MECHANISM: "contains a slash" was being used as a proxy for "is a path
415
+ * operand", and it is neither necessary nor sufficient.
416
+ *
417
+ * NOT SUFFICIENT: inline code (`process.exit(existsSync("/marker")?0:1)`),
418
+ * a URL, an `--opt=/value` pair, a date (`2026/08/14`), a regex and plain
419
+ * division all contain `/` and none of them is a path.
420
+ *
421
+ * NOT NECESSARY: `node check.mjs` names a real script with no `/` in it.
422
+ *
423
+ * So the rule now tests four things instead of one. An element at index >= 1
424
+ * is a path this command needed when ALL FOUR hold:
425
+ *
426
+ * 1. it does not itself begin with `-`. An option is not an operand, and
427
+ * `--out=/tmp/x` is an option carrying a value, not a path: probing the
428
+ * whole element was a GUARANTEED false error for every `--opt=/path`
429
+ * form, since no file is ever named `--out=/tmp/x`.
430
+ * 2. the element before it is not in `CODE_VALUED_OPTIONS`. This is the
431
+ * `node -e` case, and it is the one measured in finding 2a.
432
+ * 3. it is not URL-shaped.
433
+ * 4. it either contains `/`, or it carries a `SCRIPT_SUFFIXES` suffix and
434
+ * no whitespace. The second disjunct is new in round 2 and is what
435
+ * closes the bare-operand residue.
436
+ *
437
+ * The launcher (index 0) is deliberately NOT in this set. It is already
438
+ * probed, with the executable conditions on top, by `probeCommandRunnable`
439
+ * before the spawn, so including it here only duplicated that work.
440
+ *
441
+ * WHAT THIS RULE STILL GETS WRONG, stated rather than left to be discovered.
442
+ * A FALSE ERROR remains reachable for an element that is not a path, is not
443
+ * an option's value, and either contains `/` or ends in a script suffix: a
444
+ * bare date operand (`mytool 2026/08/14`), an operand-position regex, and a
445
+ * value passed to an option that takes a non-path value NOT in
446
+ * `CODE_VALUED_OPTIONS` (`awk -v expr=a/b`). A SILENT SKIP remains reachable
447
+ * for an operand with no `/` and no known suffix (`node check`, an
448
+ * extensionless script), for a path named through an environment variable or
449
+ * produced by a shell, and for an `--opt=/path` pair, which rule 1 now
450
+ * declines to probe. Both lists are shorter than round 1's; neither is empty.
451
+ * Full accounting, with the enumeration that produced it, in
452
+ * delivery/work-history/m3-p11.md.
453
+ */
454
+ export function commandPathCandidates(command) {
455
+ const candidates = [];
456
+ for (let index = 1; index < command.length; index += 1) {
457
+ const element = command[index];
458
+ const previous = command[index - 1];
459
+ if (namesNoPath(element, previous)) {
460
+ continue;
461
+ }
462
+ const named = element.includes("/") ||
463
+ (!/\s/.test(element) && SCRIPT_SUFFIXES.some((suffix) => element.endsWith(suffix)));
464
+ if (!named) {
465
+ continue;
466
+ }
467
+ candidates.push(element);
468
+ }
469
+ return [...new Set(candidates)];
470
+ }
471
+ /**
472
+ * MAY THIS PROCESS OPEN THIS PATH? The complete question, asked once, so
473
+ * there is one enumeration of "what opening a file requires" and not one per
474
+ * call site: it must exist, it must be a regular file, and this process must
475
+ * be permitted to read it. Fix round 1, half A (finding H-1).
476
+ *
477
+ * `requireExecutable` adds the launcher's two extra conditions: the
478
+ * UID-independent mode-bit test that was already here, and `access(X_OK)`,
479
+ * which is the UID-dependent one it could not answer.
480
+ */
481
+ function probeOpenable(display, absolute, requireExecutable) {
482
+ const entry = classifyEntry(absolute);
483
+ if (entry.kind === "absent") {
484
+ return { ok: false, reason: `${display} does not exist (resolved to ${absolute})` };
485
+ }
486
+ if (entry.kind === "dangling") {
487
+ return {
488
+ ok: false,
489
+ reason: `${display} is a symbolic link whose target does not exist (resolved to ${absolute})`,
490
+ };
491
+ }
492
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
493
+ return { ok: false, reason: entry.reason };
494
+ }
495
+ const stat = runStep(`examining ${absolute}`, () => statSync(absolute));
496
+ if (!stat.ok) {
497
+ return { ok: false, reason: stat.reason };
498
+ }
499
+ const mode = (stat.value.mode & 0o777).toString(8);
500
+ const readable = runStep(`checking read access to ${absolute}`, () => accessSync(absolute, fsConstants.R_OK));
501
+ if (!readable.ok) {
502
+ return {
503
+ ok: false,
504
+ reason: `${display} exists and is a regular file but is NOT READABLE by this process ` +
505
+ `(mode ${mode}, resolved to ${absolute}); the command would launch and then fail to ` +
506
+ "open it, which an exit code cannot be distinguished from a deliberate refusal",
507
+ };
508
+ }
509
+ if (!requireExecutable) {
510
+ return { ok: true };
511
+ }
512
+ if ((stat.value.mode & 0o111) === 0) {
513
+ return {
514
+ ok: false,
515
+ reason: `${display} is not executable (mode ${mode}, resolved to ${absolute})`,
516
+ };
517
+ }
518
+ const executable = runStep(`checking execute access to ${absolute}`, () => accessSync(absolute, fsConstants.X_OK));
519
+ if (!executable.ok) {
520
+ return {
521
+ ok: false,
522
+ reason: `${display} carries an execute bit but is NOT EXECUTABLE by this process ` +
523
+ `(mode ${mode}, resolved to ${absolute})`,
524
+ };
525
+ }
526
+ return { ok: true };
527
+ }
528
+ /**
529
+ * Can this command run at all? See the block comment above for the rule and
530
+ * for what it deliberately does not cover.
531
+ *
532
+ * The launcher is probed only when it names a path (contains `/`); a bare
533
+ * name is a PATH lookup and is left to `spawnSync`'s own `error`, which
534
+ * already reports it, rather than reimplementing PATH resolution here.
535
+ */
536
+ export function probeCommandRunnable(command, cwd) {
537
+ const probed = [];
538
+ const launcher = command[0];
539
+ if (launcher === undefined || launcher === "") {
540
+ return { runnable: false, reason: "the command is empty", probed };
541
+ }
542
+ const targets = [];
543
+ if (launcher.includes("/")) {
544
+ targets.push({ path: launcher, isLauncher: true });
545
+ }
546
+ for (const operand of commandPathOperands(command)) {
547
+ targets.push({ path: operand, isLauncher: false });
548
+ }
549
+ for (const target of targets) {
550
+ const absolute = isAbsolute(target.path) ? target.path : resolve(cwd, target.path);
551
+ probed.push(target.path);
552
+ const openable = probeOpenable(target.path, absolute, target.isLauncher);
553
+ if (!openable.ok) {
554
+ return { runnable: false, reason: openable.reason, probed };
555
+ }
556
+ }
557
+ return { runnable: true, reason: "", probed };
558
+ }
559
+ /**
560
+ * Every path-shaped argv element this process cannot open, with the reason.
561
+ * Empty means a nonzero exit is ATTRIBUTABLE to the command's own logic;
562
+ * non-empty means it is not, and M2-C-3 says a check that cannot reach a
563
+ * verdict fails closed rather than guessing one.
564
+ *
565
+ * FIX ROUND 2 MOVED THE CALL SITE, and the move is the fix for a second
566
+ * mechanism, independent of which elements are scanned. Round 1 ran this
567
+ * AFTER the spawn, on the nonzero arm only. The question it answers is "did
568
+ * the command have what it needed IN ORDER TO RUN", which is a question about
569
+ * the moment BEFORE the spawn, and answering it from the filesystem AFTER the
570
+ * spawn reads the command's own effects back as evidence about its inputs. A
571
+ * precondition script that legitimately decides "unmet" and deletes itself as
572
+ * its last act (a one-shot or bootstrap script) was therefore reported
573
+ * `error`, deterministically, no timing window needed: measured in
574
+ * delivery/verification/m3-p11-fix-round-1.md as finding 2b.
575
+ *
576
+ * So the scan now runs BEFORE the spawn and its result is CARRIED. The exit
577
+ * code still decides whether the result is consulted: an exit of 0 is its own
578
+ * proof that the command ran, so no declaration that succeeds can be affected
579
+ * by this at all, which is the property round 1 established and round 2 keeps.
580
+ */
581
+ export function attributionGaps(command, cwd) {
582
+ const gaps = [];
583
+ for (const element of commandPathCandidates(command)) {
584
+ const absolute = isAbsolute(element) ? element : resolve(cwd, element);
585
+ // A DIRECTORY IS A PATH, and this scan must not say otherwise. Round 2
586
+ // found this by enumeration rather than by argument: this repository's OWN
587
+ // `scope` gate is declared as
588
+ // `node src/gates/scope.ts --declarations delivery/plan/phase-declarations`
589
+ // (gate-registry.yaml:126), whose last element is a directory that exists,
590
+ // is exactly what the command wants, and which `probeOpenable` refuses as
591
+ // "not a regular file". That gate has no `command-exit-zero` precondition,
592
+ // so nothing was breaking today, but it is a real declared counter-example
593
+ // to the regular-file question being the right one HERE. The pre-spawn
594
+ // runnability probe keeps asking the stricter question, because there the
595
+ // element is a script the LAUNCHER is about to open.
596
+ const kind = runStep(`examining ${absolute}`, () => statSync(absolute));
597
+ if (kind.ok && kind.value.isDirectory()) {
598
+ const enterable = runStep(`entering ${absolute}`, () => accessSync(absolute, fsConstants.R_OK | fsConstants.X_OK));
599
+ if (!enterable.ok) {
600
+ gaps.push(`${element} is a directory this process cannot read or enter (resolved to ${absolute})`);
601
+ }
602
+ continue;
603
+ }
604
+ const openable = probeOpenable(element, absolute, false);
605
+ if (!openable.ok) {
606
+ gaps.push(openable.reason);
607
+ }
608
+ }
609
+ return gaps;
610
+ }
611
+ function gitLines(cwd, args) {
612
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
613
+ if (result.error !== undefined) {
614
+ return {
615
+ ok: false,
616
+ reason: `git ${args.join(" ")} could not be run: ${singleLine(String(result.error))}`,
617
+ };
618
+ }
619
+ if (result.status !== 0) {
620
+ return {
621
+ ok: false,
622
+ reason: `git ${args.join(" ")} exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
623
+ };
624
+ }
625
+ return {
626
+ ok: true,
627
+ lines: (result.stdout ?? "")
628
+ .split("\n")
629
+ .map((line) => line.trim())
630
+ .filter((line) => line !== ""),
631
+ };
632
+ }
633
+ function evaluatePrecondition(precondition, options, cwd) {
634
+ const id = precondition.id;
635
+ if (precondition.kind === "file-exists" || precondition.kind === "file-absent") {
636
+ const path = precondition.path;
637
+ if (path === undefined) {
638
+ return { kind: "error", reason: `precondition ${id} declares no path` };
639
+ }
640
+ const entry = classifyEntry(path);
641
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
642
+ // M2-C-6: present, and not a thing this gate may open. Reporting the
643
+ // observed type is the whole point; guessing "unmet" would be a
644
+ // verdict about a path nobody examined.
645
+ return { kind: "error", reason: entry.reason };
646
+ }
647
+ const present = entry.kind === "regular";
648
+ const wanted = precondition.kind === "file-exists";
649
+ const met = present === wanted;
650
+ const record = {
651
+ id,
652
+ met,
653
+ reason: present
654
+ ? `${path} is a regular file`
655
+ : `${path} does not exist`,
656
+ evidence: [path],
657
+ };
658
+ return met ? { kind: "met", record } : { kind: "unmet", record };
659
+ }
660
+ if (precondition.kind === "branch-matches") {
661
+ const pattern = precondition.pattern;
662
+ if (pattern === undefined) {
663
+ return { kind: "error", reason: `precondition ${id} declares no pattern` };
664
+ }
665
+ if (options.phase === undefined) {
666
+ return {
667
+ kind: "error",
668
+ reason: `precondition ${id} is kind branch-matches and --phase was not supplied`,
669
+ };
670
+ }
671
+ const branch = gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
672
+ if (!branch.ok) {
673
+ return { kind: "error", reason: branch.reason };
674
+ }
675
+ const name = branch.lines[0] ?? "";
676
+ // CR-805, two faults in one line. `{phase}` is documented as a TOKEN
677
+ // substitution, and substituting a value into regex SOURCE without
678
+ // escaping silently changes the pattern's meaning: a phase id containing
679
+ // `.` matched any character. And the compiled expression was unanchored,
680
+ // so `claude/m2-p4-` matched the decoy branch
681
+ // `evil/claude/m2-p4-scope-auditor-DECOY`. A precondition whose job is to
682
+ // decide "am I on the branch this phase governs" was deciding it on a
683
+ // strictly weaker predicate than it appeared to.
684
+ const source = pattern.split("{phase}").join(escapeRegExp(options.phase));
685
+ let expression;
686
+ try {
687
+ expression = new RegExp(`^(?:${source})$`);
688
+ }
689
+ catch (error) {
690
+ return {
691
+ kind: "error",
692
+ reason: `precondition ${id} pattern ${source} is not a valid expression: ${error.message}`,
693
+ };
694
+ }
695
+ const met = expression.test(name);
696
+ const record = {
697
+ id,
698
+ met,
699
+ reason: `branch ${name} ${met ? "matches" : "does not match"} ^(?:${source})$`,
700
+ };
701
+ return met ? { kind: "met", record } : { kind: "unmet", record };
702
+ }
703
+ if (precondition.kind === "diff-touches") {
704
+ const paths = precondition.paths;
705
+ if (paths === undefined || paths.length === 0) {
706
+ return { kind: "error", reason: `precondition ${id} declares no paths` };
707
+ }
708
+ if (options.base === undefined) {
709
+ return {
710
+ kind: "error",
711
+ reason: `precondition ${id} is kind diff-touches and --base was not supplied`,
712
+ };
713
+ }
714
+ const head = options.head ?? "HEAD";
715
+ const changed = gitLines(cwd, [
716
+ "diff",
717
+ "--name-only",
718
+ `${options.base}...${head}`,
719
+ ]);
720
+ if (!changed.ok) {
721
+ return { kind: "error", reason: changed.reason };
722
+ }
723
+ const touched = changed.lines.filter((line) => paths.some((prefix) => line === prefix || line.startsWith(prefix)));
724
+ const met = touched.length > 0;
725
+ const record = {
726
+ id,
727
+ met,
728
+ reason: met
729
+ ? `${String(touched.length)} changed path(s) under ${paths.join(", ")}`
730
+ : `no changed path under ${paths.join(", ")}`,
731
+ evidence: touched,
732
+ };
733
+ return met ? { kind: "met", record } : { kind: "unmet", record };
734
+ }
735
+ const command = precondition.command;
736
+ if (command === undefined || command.length === 0) {
737
+ return { kind: "error", reason: `precondition ${id} declares no command` };
738
+ }
739
+ // M3-P11. THE PROBE COMES BEFORE THE SPAWN, and it is the whole of the
740
+ // difference between the three outcomes below and the two there used to be.
741
+ // After the spawn there is only an exit code, and an exit code cannot
742
+ // separate "this script does not exist" from "this script says no".
743
+ const runnable = probeCommandRunnable(command, cwd);
744
+ if (!runnable.runnable) {
745
+ return {
746
+ kind: "error",
747
+ reason: `precondition ${id} command ${command.join(" ")} could not be run: ${runnable.reason}` +
748
+ ` (this is NOT not-applicable: nothing was evaluated, M2-C-3)`,
749
+ };
750
+ }
751
+ // FIX ROUND 2, MECHANISM B. Taken HERE, before the spawn, and carried.
752
+ // "Did this command have what it needed" is a question about the state the
753
+ // command was launched into, and the filesystem after it has run is a
754
+ // different subject. Consulted only on the nonzero arm, below.
755
+ const gapsAtSpawnTime = attributionGaps(command, cwd);
756
+ const result = spawnSync(command[0], command.slice(1), {
757
+ cwd,
758
+ encoding: "utf8",
759
+ });
760
+ if (result.error !== undefined) {
761
+ // The command does not exist, or could not be executed. That is not
762
+ // "the precondition is unmet": nothing was evaluated (M2-C-3).
763
+ return {
764
+ kind: "error",
765
+ reason: `precondition ${id} command ${command.join(" ")} could not be run: ${singleLine(String(result.error))}`,
766
+ };
767
+ }
768
+ if (result.signal !== null && result.signal !== undefined) {
769
+ return {
770
+ kind: "error",
771
+ reason: `precondition ${id} command ${command.join(" ")} was terminated by ${result.signal}`,
772
+ };
773
+ }
774
+ const met = result.status === 0;
775
+ if (!met) {
776
+ // FIX ROUND 1, HALF B, AS CORRECTED BY ROUND 2. Reading this nonzero exit
777
+ // as "evaluated and unmet" asserts that the command reached its own logic.
778
+ // That assertion is only sound if everything the command needed in order
779
+ // to get there was available AT THE MOMENT IT WAS LAUNCHED, which is what
780
+ // `gapsAtSpawnTime` records. The pre-spawn runnability probe establishes
781
+ // the same thing for a PROPER SUBSET of the elements (it declines to look
782
+ // at an option's value at all, and it hard-refuses rather than carrying a
783
+ // result), so this wider, carried scan is what covers the rest.
784
+ if (gapsAtSpawnTime.length > 0) {
785
+ return {
786
+ kind: "error",
787
+ reason: `precondition ${id} command ${command.join(" ")} exited ${String(result.status)}, ` +
788
+ `and that exit CANNOT BE ATTRIBUTED to an evaluated precondition: ` +
789
+ `${String(gapsAtSpawnTime.length)} path-shaped argv element(s) could not be opened by ` +
790
+ `this process when the command was launched: ` +
791
+ `${gapsAtSpawnTime.join("; ")}` +
792
+ ` (this is NOT not-applicable: nothing was established, M2-C-3. If such an element is ` +
793
+ `not a path, give the command a form in which it is not path-shaped)`,
794
+ };
795
+ }
796
+ }
797
+ const record = {
798
+ id,
799
+ met,
800
+ reason: `${command.join(" ")} exited ${String(result.status)}`,
801
+ };
802
+ return met ? { kind: "met", record } : { kind: "unmet", record };
803
+ }
804
+ function errorResult(entry, startedAt, detail, precondition) {
805
+ return makeGateResult({
806
+ gate: entry.id,
807
+ status: "error",
808
+ units: 0,
809
+ unitLabel: entry.unitLabel,
810
+ startedAt,
811
+ endedAt: now(),
812
+ detail,
813
+ precondition,
814
+ });
815
+ }
816
+ function runOneGate(entry, options, cwd, evidenceDir, runId) {
817
+ const startedAt = now();
818
+ const gateDir = join(evidenceDir, entry.id);
819
+ // CR-900 row 1a. Every mutation below is inside `evidenceDir`, and the claim
820
+ // can be lost between gates (a previous gate steals it) or DURING one (this
821
+ // gate's own precondition command steals it), so the question is asked at
822
+ // each operation and not once at the top of this function.
823
+ const dirRefusal = mkdirInsideClaim(evidenceDir, runId, gateDir);
824
+ if (dirRefusal !== undefined) {
825
+ return {
826
+ result: errorResult(entry, startedAt, dirRefusal),
827
+ applicable: false,
828
+ };
829
+ }
830
+ const recordPath = join(gateDir, "result.json");
831
+ const stdoutPath = join(gateDir, "stdout.txt");
832
+ const stderrPath = join(gateDir, "stderr.txt");
833
+ // Parameters first: a gate invoked without something it needs measured
834
+ // nothing, and that is `error`, never `not-applicable` (M2-C-3, M2R-003).
835
+ const missing = requiredParameters(entry).filter((name) => options[name] === undefined);
836
+ if (missing.length > 0) {
837
+ return {
838
+ result: errorResult(entry, startedAt, `gate ${entry.id} requires ${missing.map((name) => `--${name}`).join(" ")}, which was not supplied`),
839
+ applicable: false,
840
+ };
841
+ }
842
+ if (entry.precondition !== undefined) {
843
+ // CR-900 row 4, which the review's table did not list. Evaluating a
844
+ // precondition DISPATCHES a subprocess: `git` for `branch-matches` and
845
+ // `diff-touches`, and for kind `command` an ARBITRARY program named by the
846
+ // manifest. Dispatching a program is a mutation of this directory by
847
+ // proxy, so it asks the same question the delete and the gate spawn ask.
848
+ const preconditionRefusal = refuseUnlessHolder(evidenceDir, runId, `evaluate precondition ${entry.precondition.id} for gate ${entry.id}`);
849
+ if (preconditionRefusal !== undefined) {
850
+ return {
851
+ result: errorResult(entry, startedAt, preconditionRefusal),
852
+ applicable: false,
853
+ };
854
+ }
855
+ const outcome = evaluatePrecondition(entry.precondition, options, cwd);
856
+ if (outcome.kind === "error") {
857
+ return {
858
+ result: errorResult(entry, startedAt, outcome.reason),
859
+ applicable: false,
860
+ };
861
+ }
862
+ if (outcome.kind === "unmet") {
863
+ return {
864
+ result: makeGateResult({
865
+ gate: entry.id,
866
+ status: "not-applicable",
867
+ units: 0,
868
+ unitLabel: entry.unitLabel,
869
+ startedAt,
870
+ endedAt: now(),
871
+ detail: `precondition ${outcome.record.id} evaluated and unmet: ${outcome.record.reason}`,
872
+ precondition: outcome.record,
873
+ }),
874
+ applicable: false,
875
+ };
876
+ }
877
+ }
878
+ // M3-P11, the same rule one level out. A GATE whose command names a path
879
+ // that does not exist reached `error` before this phase too, but by a
880
+ // route that named the wrong path: the child exited 1 without writing a
881
+ // record, so the detail read "gate X exited 1 without writing a result
882
+ // record at <the RECORD path>", which is the one path in the sentence that
883
+ // is not the problem. Probing first makes the missing path the thing the
884
+ // operator is told about (criterion 1). This sits AFTER the precondition
885
+ // block deliberately: a precondition the runner evaluated and found unmet
886
+ // is a real skip, and a gate that was never going to run is honestly
887
+ // reported as not-applicable rather than as a crash.
888
+ const commandRunnable = probeCommandRunnable(entry.command, cwd);
889
+ if (!commandRunnable.runnable) {
890
+ return {
891
+ result: errorResult(entry, startedAt, `gate ${entry.id} could not be run: ${commandRunnable.reason}`),
892
+ applicable: false,
893
+ };
894
+ }
895
+ // The record path is probed BEFORE the child exists. A named pipe here
896
+ // would block the gate in the kernel forever and the runner behind it.
897
+ const recordRefusal = refuseOpenForWrite(recordPath);
898
+ if (recordRefusal !== undefined) {
899
+ return {
900
+ result: errorResult(entry, startedAt, `${recordRefusal}; refusing to run gate ${entry.id}`),
901
+ applicable: false,
902
+ };
903
+ }
904
+ // CR-900 rows 5 and 6, the two the finding named. The DELETE below removes a
905
+ // record that, under a stolen claim, belongs to another run; the DISPATCH
906
+ // after it hands that other run's directory to a program of this manifest's
907
+ // choosing. One question covers both because nothing between them can change
908
+ // the claim: the argv construction is pure string work. Splitting it into two
909
+ // identical checks would add a line no test could redden independently.
910
+ //
911
+ // This check is what makes the fix COMPLETE rather than the review's sketch
912
+ // of one check at the top of `runOneGate`. By here the precondition command
913
+ // has already run, and it can have stolen the claim after that top check
914
+ // passed (witness W3).
915
+ const mutateRefusal = refuseUnlessHolder(evidenceDir, runId, `clear ${recordPath} and run gate ${entry.id}`);
916
+ if (mutateRefusal !== undefined) {
917
+ return {
918
+ result: errorResult(entry, startedAt, mutateRefusal),
919
+ applicable: false,
920
+ };
921
+ }
922
+ const cleared = runStep(`clearing ${recordPath}`, () => rmSync(recordPath, { force: true }));
923
+ if (!cleared.ok) {
924
+ return {
925
+ result: errorResult(entry, startedAt, cleared.reason),
926
+ applicable: false,
927
+ };
928
+ }
929
+ const argv = [...entry.command.slice(1), "--result", recordPath, "--evidence", gateDir];
930
+ for (const name of requiredParameters(entry)) {
931
+ argv.push(`--${name}`, options[name]);
932
+ }
933
+ const child = spawnSync(entry.command[0], argv, {
934
+ cwd,
935
+ encoding: "utf8",
936
+ });
937
+ // Through writeInsideClaim like every other write into this directory: the
938
+ // rule is a property of writing here, not of one call site (CR-860).
939
+ const stdoutRefusal = writeInsideClaim(evidenceDir, runId, stdoutPath, child.stdout ?? "");
940
+ const stderrRefusal = writeInsideClaim(evidenceDir, runId, stderrPath, child.stderr ?? "");
941
+ const captureRefusal = stdoutRefusal ?? stderrRefusal;
942
+ const ingested = ingestGateRun(entry, startedAt, recordPath, child, captureRefusal);
943
+ return {
944
+ result: ingested,
945
+ // CR-800: "applicable" is read off the RESULT, never off the fact that a
946
+ // child was started. A gate that decides its own applicability and says
947
+ // `not-applicable` is not applicable, and it used to be counted as one.
948
+ applicable: ingested.status !== "not-applicable",
949
+ recordPath,
950
+ stdoutPath,
951
+ stderrPath,
952
+ };
953
+ }
954
+ /**
955
+ * Turn one gate subprocess's exit code plus whatever it left at the record
956
+ * path into a GateResult. Every path that is not "the gate said what it
957
+ * meant" ends at `error`.
958
+ */
959
+ function ingestGateRun(entry, startedAt, recordPath, child, captureRefusal) {
960
+ if (captureRefusal !== undefined) {
961
+ return errorResult(entry, startedAt, captureRefusal);
962
+ }
963
+ if (child.error !== undefined) {
964
+ return errorResult(entry, startedAt, `gate ${entry.id} could not be run: ${singleLine(String(child.error))}`);
965
+ }
966
+ if (child.signal !== null && child.signal !== undefined) {
967
+ return errorResult(entry, startedAt, `gate ${entry.id} was terminated by ${child.signal}`);
968
+ }
969
+ const exitCode = child.status ?? -1;
970
+ const entryClass = classifyEntry(recordPath);
971
+ if (entryClass.kind === "irregular" || entryClass.kind === "unexaminable") {
972
+ return errorResult(entry, startedAt, entryClass.reason);
973
+ }
974
+ if (entryClass.kind !== "regular") {
975
+ // No record. A nonzero exit here is NOT red: Node exits 1 on an uncaught
976
+ // exception, which collides exactly with the red code, so a crash and a
977
+ // refutation would be indistinguishable (step 7).
978
+ return errorResult(entry, startedAt, `gate ${entry.id} exited ${String(exitCode)} without writing a result record at ${recordPath}`);
979
+ }
980
+ const read = readRegularFileIfPresent(recordPath);
981
+ if (read.kind !== "read") {
982
+ return errorResult(entry, startedAt, read.kind === "refused"
983
+ ? read.reason
984
+ : `gate ${entry.id} result record vanished at ${recordPath}`);
985
+ }
986
+ let parsed;
987
+ try {
988
+ parsed = JSON.parse(read.body);
989
+ }
990
+ catch (error) {
991
+ return errorResult(entry, startedAt, `gate ${entry.id} result record does not parse: ${error.message}`);
992
+ }
993
+ const diagnostics = validateResultDocument(parsed);
994
+ if (diagnostics.length > 0) {
995
+ return errorResult(entry, startedAt, `gate ${entry.id} result record is invalid: ${diagnostics.join("; ")}`);
996
+ }
997
+ const record = parsed;
998
+ if (record.gate !== entry.id) {
999
+ return errorResult(entry, startedAt, `gate ${entry.id} wrote a record for ${record.gate}`);
1000
+ }
1001
+ const expected = exitCodeForStatus(record.status);
1002
+ if (exitCode !== expected) {
1003
+ const named = statusForExitCode(exitCode);
1004
+ return errorResult(entry, startedAt, `gate ${entry.id} recorded status ${record.status} (exit ${String(expected)}) but exited ${String(exitCode)}` +
1005
+ (named === undefined ? "" : ` (${named})`));
1006
+ }
1007
+ // CR-806. `vacuous` exists to be set by the two rewrite points and by
1008
+ // nothing else (deviation D2). A gate that writes it itself was being
1009
+ // believed, so a green record could be counted in the `vacuous` bucket and
1010
+ // break step 8's "vacuous is a strict subset of error". The runner is
1011
+ // documented as adversarial towards its own gates; here it was trusting one.
1012
+ const claimed = { ...record };
1013
+ delete claimed.vacuous;
1014
+ if (claimed.status === "green" && claimed.units === 0) {
1015
+ // The constructor cannot produce this, but a gate that does not use the
1016
+ // constructor can, and that is exactly the party this rule is aimed at.
1017
+ return {
1018
+ ...claimed,
1019
+ status: "error",
1020
+ vacuous: true,
1021
+ detail: claimed.detail === ""
1022
+ ? M2_C_2_DETAIL
1023
+ : `${M2_C_2_DETAIL}; the gate reported: ${claimed.detail}`,
1024
+ };
1025
+ }
1026
+ // CR-804 part two: M2-C-5, enforced by the runner rather than left to each
1027
+ // gate's own honesty. The runner holds both the record's pins and the
1028
+ // module that compares them, two lines from where it applies the
1029
+ // structurally identical M2-C-2 rewrite, and was doing nothing with either.
1030
+ const pinFailure = pinRefusal(claimed);
1031
+ if (pinFailure !== undefined && claimed.status === "green") {
1032
+ return {
1033
+ ...claimed,
1034
+ status: "error",
1035
+ detail: claimed.detail === ""
1036
+ ? pinFailure
1037
+ : `${pinFailure}; the gate reported: ${claimed.detail}`,
1038
+ };
1039
+ }
1040
+ return claimed;
1041
+ }
1042
+ export const M2_C_5_DETAIL = "M2-C-5 (a run that cannot name what it executed is not evidence)";
1043
+ /**
1044
+ * Why a record's pins refuse it, or undefined when they do not. A gate that
1045
+ * declares no pin is not bound by M2-C-5 and is not touched here; the
1046
+ * constraint binds the gates that execute a test suite, and each of those
1047
+ * declares its pins.
1048
+ */
1049
+ function pinRefusal(record) {
1050
+ if (record.pin === undefined) {
1051
+ return undefined;
1052
+ }
1053
+ if (record.pin.start.fileCount === 0 || record.pin.end.fileCount === 0) {
1054
+ return `${M2_C_5_DETAIL}: a pin over ${record.pin.start.roots.join(", ")} measured no files`;
1055
+ }
1056
+ const differences = comparePins(record.pin.start, record.pin.end);
1057
+ if (differences.length === 0) {
1058
+ return undefined;
1059
+ }
1060
+ return (`${M2_C_5_DETAIL}: the tree changed during the run: ` +
1061
+ differences.map(describePinDifference).join("; "));
1062
+ }
1063
+ /**
1064
+ * THE ONE AGGREGATE DECISION, extracted so it can be EXERCISED rather than
1065
+ * read (CR-800's fix round). CR-800 was a reading of these branch conditions
1066
+ * that turned out not to hold, and the first test written for the fix
1067
+ * asserted on the TEXT of this function, which is the guard-that-asserts-text
1068
+ * class MECHANISMS.md records six instances of. A pure function over a counts
1069
+ * object can be handed states the runner cannot currently produce, including
1070
+ * the internally inconsistent ones the invariants below exist for, so the
1071
+ * invariants are witnessed instead of quoted.
1072
+ *
1073
+ * AGGREGATE PRECEDENCE, fixed here so it is one rule and not a reading. A
1074
+ * concrete failure outranks the vacuity check, because "3 gates reported
1075
+ * error" tells the operator more than "no applicable gate" and both exit 21
1076
+ * anyway. The vacuity check outranks a required not-applicable gate, because
1077
+ * a bundle that examined nothing is not a report about any one gate (M2-C-2
1078
+ * at the aggregate level, M2R-012).
1079
+ */
1080
+ export function decideAggregate(counts, requiredNotApplicable, rows) {
1081
+ let exitCode = EXIT_GREEN;
1082
+ let reason = "every applicable gate is green";
1083
+ if (counts.error > 0) {
1084
+ exitCode = EXIT_GATE_ERROR;
1085
+ reason = `${String(counts.error)} gate(s) reported error: ${rows
1086
+ .filter((row) => row.status === "error")
1087
+ .map((row) => row.id)
1088
+ .join(", ")}`;
1089
+ }
1090
+ else if (counts.red > 0) {
1091
+ exitCode = EXIT_RED;
1092
+ reason = `${String(counts.red)} gate(s) reported red: ${rows
1093
+ .filter((row) => row.status === "red")
1094
+ .map((row) => row.id)
1095
+ .join(", ")}`;
1096
+ }
1097
+ else if (counts.verdict === 0) {
1098
+ // CR-800. This used to read `counts.applicable === 0`, and `applicable`
1099
+ // used to mean "was spawned", so a bundle of gates that each declared
1100
+ // their own not-applicable slipped past it and exited 0. The count
1101
+ // consulted here is now the only one that means work was done.
1102
+ exitCode = EXIT_GATE_ERROR;
1103
+ reason = NO_APPLICABLE_GATE;
1104
+ }
1105
+ else if (requiredNotApplicable.length > 0) {
1106
+ exitCode = EXIT_NOT_APPLICABLE;
1107
+ reason = `required gate(s) not applicable: ${requiredNotApplicable.join(", ")}`;
1108
+ }
1109
+ // TOTAL OVER GARBAGE, not just over zero (CR-862). The first version of
1110
+ // this check was `counts.green === 0`, which is FALSE for NaN, for
1111
+ // undefined, for a missing key and for a negative number, so all four
1112
+ // reached exit 0 with "every applicable gate is green". None is reachable
1113
+ // through the runner today, where `counts` is built from integer literals
1114
+ // and `+= 1`. That is exactly the argument this function exists to refuse
1115
+ // to rely on: its stated contract is to be handed states the runner cannot
1116
+ // currently produce, and a guard that only rejects the value it was written
1117
+ // against is the same shape as the defect it was written for.
1118
+ //
1119
+ // So every count is checked for being a non-negative safe integer FIRST,
1120
+ // and the success-path assertion is written as `!(green > 0)`, which is
1121
+ // true for every non-number as well as for zero.
1122
+ const badCounts = Object.entries(counts)
1123
+ .filter(([, value]) => !Number.isSafeInteger(value) || value < 0)
1124
+ .map(([name]) => name)
1125
+ .sort();
1126
+ if (badCounts.length > 0) {
1127
+ return {
1128
+ exitCode: EXIT_GATE_ERROR,
1129
+ reason: "internal inconsistency: count(s) that are not non-negative integers: " +
1130
+ `${badCounts.join(", ")} (${JSON.stringify(counts)})`,
1131
+ };
1132
+ }
1133
+ for (const name of [
1134
+ "declared",
1135
+ "applicable",
1136
+ "verdict",
1137
+ "green",
1138
+ "red",
1139
+ "not-applicable",
1140
+ "error",
1141
+ "vacuous",
1142
+ ]) {
1143
+ if (!Object.prototype.hasOwnProperty.call(counts, name)) {
1144
+ return {
1145
+ exitCode: EXIT_GATE_ERROR,
1146
+ reason: `internal inconsistency: the count ${name} is missing (${JSON.stringify(counts)})`,
1147
+ };
1148
+ }
1149
+ }
1150
+ // THE SUCCESS PATH CANNOT DESCRIBE AN EMPTY GREEN BUCKET. The branches
1151
+ // above already make exit 0 unreachable with a zero green bucket, and this
1152
+ // asserts it rather than trusting the reading, because CR-800 was exactly a
1153
+ // reading of the branch conditions that turned out not to hold.
1154
+ //
1155
+ // `!(green > 0)` rather than `green === 0` is WITNESSED, not belt and braces
1156
+ // (CR-901). Round two recorded it as "unwitnessable: no input distinguishes
1157
+ // the two forms", which was an impossibility claim of exactly the shape
1158
+ // tuition T-006 is about, and it is false. The two screens above do not
1159
+ // screen the same set of properties: the bad-count screen enumerates with
1160
+ // `Object.entries`, which sees own ENUMERABLE properties, while the presence
1161
+ // screen uses `hasOwnProperty`, which sees own properties enumerable or not.
1162
+ // A count defined as
1163
+ //
1164
+ // Object.defineProperty(counts, "green", { value: NaN, enumerable: false })
1165
+ //
1166
+ // is invisible to the first screen and present to the second, so it arrives
1167
+ // here unexamined. `!(NaN > 0)` is true and reports the inconsistency;
1168
+ // `NaN === 0` is false and would certify "every applicable gate is green"
1169
+ // with green equal to NaN. Registered as
1170
+ // `gate-aggregate-nonenumerable-nan-green`.
1171
+ if (exitCode === EXIT_GREEN && !(counts.green > 0)) {
1172
+ exitCode = EXIT_GATE_ERROR;
1173
+ reason =
1174
+ "internal inconsistency: the run reached the success path with zero " +
1175
+ `green gates (${JSON.stringify(counts)})`;
1176
+ }
1177
+ // Step 8's stated relation, asserted rather than assumed (CR-806).
1178
+ if (counts.vacuous > counts.error) {
1179
+ exitCode = EXIT_GATE_ERROR;
1180
+ reason =
1181
+ `internal inconsistency: vacuous ${String(counts.vacuous)} exceeds ` +
1182
+ `error ${String(counts.error)}, and vacuous is a strict subset of error`;
1183
+ }
1184
+ return { exitCode, reason };
1185
+ }
1186
+ export const RUN_CLAIM_FILE = ".tiphys-gate-run.json";
1187
+ /**
1188
+ * Claim the evidence directory with an O_EXCL create, the pattern
1189
+ * `src/lock.ts` already carries (MECHANISMS.md, "Claim file (mutual exclusion
1190
+ * by O_EXCL)", which requires a third user to read that module first: done,
1191
+ * and this follows its rule rather than inventing a second one).
1192
+ *
1193
+ * No steal, no age heuristic, no bounded wait. A claim that cannot be taken
1194
+ * fails LOUDLY and NAMES THE STUCK FILE, because a silent wait is
1195
+ * indistinguishable from an absence of contention, and because an evidence
1196
+ * directory is not a contended resource by design: two runs sharing one is
1197
+ * an operator error, not a queue.
1198
+ */
1199
+ function claimEvidenceDirectory(evidenceDir, runId, manifestPath) {
1200
+ const claimPath = join(evidenceDir, RUN_CLAIM_FILE);
1201
+ const refusal = refuseOpenForWrite(claimPath);
1202
+ if (refusal !== undefined) {
1203
+ return refusal;
1204
+ }
1205
+ const body = `${JSON.stringify({ runId, manifest: manifestPath, startedAt: now() }, null, 2)}\n`;
1206
+ try {
1207
+ writeFileSync(claimPath, body, { flag: "wx" });
1208
+ return undefined;
1209
+ }
1210
+ catch (error) {
1211
+ if (error.code !== "EEXIST") {
1212
+ return `evidence directory ${evidenceDir} could not be claimed: ${singleLine(String(error))}`;
1213
+ }
1214
+ const holder = claimHolder(evidenceDir);
1215
+ const held = readRegularFileIfPresent(claimPath);
1216
+ const who = held.kind === "read" ? singleLine(held.body) : "unreadable";
1217
+ // CR-861. The refused run writes NOTHING here, which is correct, and that
1218
+ // used to mean the only statement in the directory about what happened
1219
+ // was a DIFFERENT run's: the previous summary sat there saying exit 0,
1220
+ // aborted false, "every applicable gate is green". So the refusal itself
1221
+ // carries both ids and says, in words, that anything in the directory
1222
+ // belongs to the other run.
1223
+ return (`evidence directory ${evidenceDir} is already claimed by another run; ` +
1224
+ `claim file ${claimPath} holds ${who}. ` +
1225
+ `This run is ${runId} and it wrote NOTHING: any summary.json in ` +
1226
+ `${evidenceDir} belongs to run ${holder ?? "unknown"}, not to this run, ` +
1227
+ "and is not a report about this invocation. " +
1228
+ "Two runs sharing one evidence directory produce a bundle attributable " +
1229
+ "to neither. Use a different --evidence directory, or delete that file " +
1230
+ "if no run holds it.");
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Replace `summary.json` atomically, staging under a name that carries this
1235
+ * run's id. MECHANISMS.md's "Atomic file replacement" row is explicit that a
1236
+ * FIXED stage name lets two concurrent passes share one temporary and the
1237
+ * loser dies on ENOENT, so the runId is in the stage name and no other run
1238
+ * can collide with it. The claim above already excludes a second runner from
1239
+ * this directory; this is the second lock on the same door, and it costs one
1240
+ * rename.
1241
+ */
1242
+ function writeSummaryAtomically(evidenceDir, summaryPath, runId, summary) {
1243
+ const stagePath = `${summaryPath}.${runId}.stage`;
1244
+ const staged = writeInsideClaim(evidenceDir, runId, stagePath, `${JSON.stringify(summary, null, 2)}\n`);
1245
+ if (staged !== undefined) {
1246
+ return staged;
1247
+ }
1248
+ const refusal = refuseOpenForWrite(summaryPath);
1249
+ if (refusal !== undefined) {
1250
+ return refusal;
1251
+ }
1252
+ // CR-900 row 8. A rename is a mutation of two entries in this directory, and
1253
+ // it was guarded only by the READING that the staged write above must have
1254
+ // succeeded first. That reading happens to be sound today; this phase is on
1255
+ // its third round because a reading of control flow was wrong twice, and the
1256
+ // rule this module now states is that the mutation asks, not its caller.
1257
+ const renameRefusal = refuseUnlessHolder(evidenceDir, runId, `replace ${summaryPath}`);
1258
+ if (renameRefusal !== undefined) {
1259
+ return renameRefusal;
1260
+ }
1261
+ const renamed = runStep(`replacing ${summaryPath}`, () => renameSync(stagePath, summaryPath));
1262
+ return renamed.ok ? undefined : renamed.reason;
1263
+ }
1264
+ /** The runId recorded in a claim file, or undefined if it cannot be read. */
1265
+ function claimHolder(evidenceDir) {
1266
+ const read = readRegularFileIfPresent(join(evidenceDir, RUN_CLAIM_FILE));
1267
+ if (read.kind !== "read") {
1268
+ return undefined;
1269
+ }
1270
+ try {
1271
+ const parsed = JSON.parse(read.body);
1272
+ return typeof parsed.runId === "string" ? parsed.runId : undefined;
1273
+ }
1274
+ catch {
1275
+ return undefined;
1276
+ }
1277
+ }
1278
+ /**
1279
+ * RELEASE ONLY WHAT THIS RUN HOLDS (CR-860).
1280
+ *
1281
+ * The mechanism the finding names is: *cleanup that is valid only while the
1282
+ * claim is held, performed from a frame that does not know whether the claim
1283
+ * is held*. The instance was a release in an inner `finally` followed by a
1284
+ * second, unconditional release in an outer `catch`, so a crashed run could
1285
+ * unlink a claim that by then belonged to a DIFFERENT run, revoking a live
1286
+ * run's exclusion. That is "release a lock you no longer hold", the classic
1287
+ * claim-file defect.
1288
+ *
1289
+ * Two things close it, and both are here because the line deletion alone is
1290
+ * the instance fix and this project fixes the mechanism:
1291
+ *
1292
+ * 1. THIS function reads the claim and unlinks ONLY when the runId is its
1293
+ * own, which is what `src/lock.ts` does when it verifies holdership
1294
+ * before mutating. A release from a frame that no longer holds the claim
1295
+ * is then a no-op rather than a revocation, whatever the call graph does.
1296
+ * 2. The call graph is also fixed, in `runGates`: exactly one release, in
1297
+ * one `finally`, after every write into the directory. Depending on the
1298
+ * guard alone would leave the writes happening outside the claimed
1299
+ * region, which is the other half of the same finding.
1300
+ *
1301
+ * A claim this run does not hold is deliberately LEFT IN PLACE. Deleting
1302
+ * another run's claim is the harm; leaving a stranded one costs a human one
1303
+ * `rm`, and the refusal text says which file and why.
1304
+ */
1305
+ export function releaseEvidenceDirectory(evidenceDir, runId) {
1306
+ const holder = claimHolder(evidenceDir);
1307
+ if (holder !== runId) {
1308
+ return false;
1309
+ }
1310
+ try {
1311
+ unlinkSync(join(evidenceDir, RUN_CLAIM_FILE));
1312
+ return true;
1313
+ }
1314
+ catch {
1315
+ // Releasing a claim that is already gone is not a failure.
1316
+ return false;
1317
+ }
1318
+ }
1319
+ /**
1320
+ * The public entry. It exists so that NO throw can escape the runner and be
1321
+ * read as this phase's RED exit code by whatever consumes it (CR-801). The
1322
+ * runner enforces exactly this rule on its gates; it now obeys it itself.
1323
+ */
1324
+ export function runGates(options) {
1325
+ const runId = randomBytes(12).toString("hex");
1326
+ try {
1327
+ return runGatesInner(options, runId);
1328
+ }
1329
+ catch (error) {
1330
+ // Reached only for a throw BEFORE the claim was taken, because every
1331
+ // path after it is wrapped inside the claimed region below. Nothing is
1332
+ // written here: without the claim this run does not own the directory.
1333
+ return {
1334
+ runId,
1335
+ exitCode: EXIT_GATE_ERROR,
1336
+ reason: `the gate runner failed: ${singleLine(error.message ?? String(error))}`,
1337
+ };
1338
+ }
1339
+ }
1340
+ function writeAbortedSummary(options, runId, reason) {
1341
+ if (!isRealDirectory(options.evidenceDir)) {
1342
+ return;
1343
+ }
1344
+ const summary = {
1345
+ runId,
1346
+ manifest: options.manifestPath,
1347
+ manifestSha256: "",
1348
+ startedAt: now(),
1349
+ endedAt: now(),
1350
+ parameters: {},
1351
+ only: options.only ?? [],
1352
+ manifestGates: 0,
1353
+ gates: [],
1354
+ counts: {
1355
+ declared: 0,
1356
+ applicable: 0,
1357
+ verdict: 0,
1358
+ green: 0,
1359
+ red: 0,
1360
+ "not-applicable": 0,
1361
+ error: 0,
1362
+ vacuous: 0,
1363
+ },
1364
+ requiredNotApplicable: [],
1365
+ aborted: true,
1366
+ exitCode: EXIT_GATE_ERROR,
1367
+ reason,
1368
+ };
1369
+ writeInsideClaim(options.evidenceDir, runId, join(options.evidenceDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
1370
+ }
1371
+ function runGatesInner(options, runId) {
1372
+ const cwd = options.cwd ?? process.cwd();
1373
+ const startedAt = now();
1374
+ // The evidence directory is created and CLAIMED before the manifest is
1375
+ // loaded, so that every failure from here on leaves a summary a consumer
1376
+ // can read. CR-801 member 1 (a throw during the manifest load) otherwise
1377
+ // left no directory, no summary and only an exit code, and M2-P9's harness
1378
+ // is a programmatic consumer of the bundle.
1379
+ const dirRefusal = ensureDirectory(options.evidenceDir);
1380
+ if (dirRefusal !== undefined) {
1381
+ return { runId, exitCode: EXIT_GATE_ERROR, reason: dirRefusal };
1382
+ }
1383
+ const claimRefusal = claimEvidenceDirectory(options.evidenceDir, runId, options.manifestPath);
1384
+ if (claimRefusal !== undefined) {
1385
+ // Deliberately no write and no release: the directory belongs to the run
1386
+ // that holds the claim, and writing into it is the very thing this
1387
+ // refusal exists to prevent. What the refusal DOES carry is both run
1388
+ // ids, so a caller can tell that any summary.json there is not its own
1389
+ // (CR-861).
1390
+ return { runId, exitCode: EXIT_GATE_ERROR, reason: claimRefusal };
1391
+ }
1392
+ // EVERY WRITE INTO THE DIRECTORY HAPPENS INSIDE THIS BLOCK, and the single
1393
+ // release is its `finally` (CR-860). The aborted summary used to be written
1394
+ // by an OUTER catch, after an inner `finally` had already released, so for
1395
+ // a measured 2.13ms the run wrote into a directory it no longer owned.
1396
+ try {
1397
+ try {
1398
+ /* One load, two document shapes. A registry is projected onto a
1399
+ manifest by `loadRegistry` and everything downstream is identical,
1400
+ which is the property the promotion depends on. */
1401
+ const loaded = options.registry === true
1402
+ ? loadRegistry(options.manifestPath, options.mode ?? DEFAULT_MODE)
1403
+ : loadManifest(options.manifestPath);
1404
+ if (!loaded.ok) {
1405
+ const reason = [loaded.reason, ...loaded.diagnostics].join("\n");
1406
+ writeAbortedSummary(options, runId, reason);
1407
+ return { runId, exitCode: EXIT_GATE_ERROR, reason };
1408
+ }
1409
+ return runClaimedBundle(options, cwd, startedAt, runId, loaded);
1410
+ }
1411
+ catch (error) {
1412
+ const reason = `the gate runner failed: ${singleLine(error.message ?? String(error))}`;
1413
+ // A failure to record must not itself throw out of the catch.
1414
+ try {
1415
+ writeAbortedSummary(options, runId, reason);
1416
+ }
1417
+ catch {
1418
+ // Nothing further can be recorded; the exit code and stderr remain.
1419
+ }
1420
+ return { runId, exitCode: EXIT_GATE_ERROR, reason };
1421
+ }
1422
+ }
1423
+ finally {
1424
+ releaseEvidenceDirectory(options.evidenceDir, runId);
1425
+ }
1426
+ }
1427
+ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
1428
+ const manifest = loaded.manifest;
1429
+ const only = options.only ?? [];
1430
+ if (only.length > 0) {
1431
+ const known = new Set(manifest.gates.map((gate) => gate.id));
1432
+ const unknown = only.filter((id) => !known.has(id));
1433
+ if (unknown.length > 0) {
1434
+ return {
1435
+ runId,
1436
+ exitCode: EXIT_GATE_ERROR,
1437
+ reason: `--only names no such gate: ${unknown.join(", ")}`,
1438
+ };
1439
+ }
1440
+ }
1441
+ const selected = only.length > 0
1442
+ ? manifest.gates.filter((gate) => only.includes(gate.id))
1443
+ : manifest.gates;
1444
+ const rows = [];
1445
+ const counts = {
1446
+ declared: selected.length,
1447
+ applicable: 0,
1448
+ verdict: 0,
1449
+ green: 0,
1450
+ red: 0,
1451
+ "not-applicable": 0,
1452
+ error: 0,
1453
+ vacuous: 0,
1454
+ };
1455
+ const requiredNotApplicable = [];
1456
+ for (const entry of selected) {
1457
+ const outcome = runOneGate(entry, options, cwd, options.evidenceDir, runId);
1458
+ const result = outcome.result;
1459
+ if (outcome.applicable) {
1460
+ counts.applicable += 1;
1461
+ }
1462
+ counts[result.status] += 1;
1463
+ if (result.status === "green" || result.status === "red") {
1464
+ counts.verdict += 1;
1465
+ }
1466
+ if (result.vacuous === true) {
1467
+ counts.vacuous += 1;
1468
+ }
1469
+ if (result.status === "not-applicable" && entry.applicability === "required") {
1470
+ requiredNotApplicable.push(entry.id);
1471
+ }
1472
+ // The runner owns the record on disk whenever it produced or changed
1473
+ // one: a not-applicable gate never ran and wrote nothing, and a rewritten
1474
+ // vacuous green must not stay green in the evidence (criterion 4).
1475
+ const recordPath = outcome.recordPath ?? join(options.evidenceDir, entry.id, "result.json");
1476
+ // CR-900 row 1b. Same mutation, second call site.
1477
+ const dirRefusalForGate = mkdirInsideClaim(options.evidenceDir, runId, join(options.evidenceDir, entry.id));
1478
+ if (dirRefusalForGate === undefined) {
1479
+ writeInsideClaim(options.evidenceDir, runId, recordPath, renderGateResult(result));
1480
+ }
1481
+ rows.push({
1482
+ id: entry.id,
1483
+ status: result.status,
1484
+ units: result.units,
1485
+ unitLabel: result.unitLabel,
1486
+ vacuous: result.vacuous === true,
1487
+ applicable: outcome.applicable,
1488
+ detail: result.detail,
1489
+ record: dirRefusalForGate === undefined ? recordPath : undefined,
1490
+ stdout: outcome.stdoutPath,
1491
+ stderr: outcome.stderrPath,
1492
+ });
1493
+ }
1494
+ const decided = decideAggregate(counts, requiredNotApplicable, rows);
1495
+ const exitCode = decided.exitCode;
1496
+ const reason = decided.reason;
1497
+ const parameters = {};
1498
+ if (options.base !== undefined) {
1499
+ parameters.base = options.base;
1500
+ }
1501
+ if (options.head !== undefined) {
1502
+ parameters.head = options.head;
1503
+ }
1504
+ if (options.phase !== undefined) {
1505
+ parameters.phase = options.phase;
1506
+ }
1507
+ const summary = {
1508
+ runId,
1509
+ manifest: options.manifestPath,
1510
+ ...(options.registry === true
1511
+ ? {
1512
+ registry: true,
1513
+ mode: options.mode ?? DEFAULT_MODE,
1514
+ declaredByChecklist: loaded.declaredByChecklist ?? [],
1515
+ }
1516
+ : {}),
1517
+ manifestSha256: loaded.sha256,
1518
+ startedAt,
1519
+ endedAt: now(),
1520
+ parameters,
1521
+ only,
1522
+ manifestGates: manifest.gates.length,
1523
+ gates: rows,
1524
+ counts,
1525
+ requiredNotApplicable,
1526
+ aborted: false,
1527
+ exitCode,
1528
+ reason,
1529
+ };
1530
+ const summaryPath = join(options.evidenceDir, "summary.json");
1531
+ const summaryRefusal = writeSummaryAtomically(options.evidenceDir, summaryPath, runId, summary);
1532
+ if (summaryRefusal !== undefined) {
1533
+ return { runId, exitCode: EXIT_GATE_ERROR, summary, reason: summaryRefusal };
1534
+ }
1535
+ return { runId, exitCode, summary, reason };
1536
+ }