@tiphys/kernel 0.0.0 → 0.2.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 (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -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 +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,932 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { EX_USAGE } from "../cli.js";
6
+ import { pathsIdentifySameObject } from "../path-identity.js";
7
+ import { describeAdmittedVerdicts, describeOffHeadVerdicts, loadCommittedVerdicts, missingRegimeDocument, readReviewFamilies, registeredChecks, relateDeclaredHead, resolveCorpusSource, } from "../checks.js";
8
+ import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine } from "../task.js";
9
+ import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
10
+ /**
11
+ * THE MERGE PRECONDITION READER (kernel plan M4, M4-P12; DR-0012, DR-0036,
12
+ * T-009, R-064, R-065a).
13
+ *
14
+ * WHAT THIS IS NOT. It is not a merge command. M4-D-09 puts the merge
15
+ * capability in the plugin at cutover and DR-0036 keeps merge authority with
16
+ * the current process for the whole of M4. What is missing today is not the
17
+ * ability to merge; it is any ARTIFACT saying the six conditions of
18
+ * delivery/decisions/DR-0012-delegated-merge-authority.md:22 to :27 held at the
19
+ * head that was merged. So this gate produces one ROW PER CONDITION, each
20
+ * carrying the head sha it was evaluated against, and the orchestrator reads it
21
+ * before merging, by hand.
22
+ *
23
+ * THE HAZARD CLASS, in the plan's words: A MERGE PRECONDITION CHECK THAT IS
24
+ * GREEN BECAUSE IT COULD NOT LOOK. Every arm below is written against one of
25
+ * its members, and each member is named where it is refused rather than in a
26
+ * list nobody rereads:
27
+ *
28
+ * a 401 piped into a `|| true` -> `probeApi` runs FIRST and an
29
+ * unreachable or refusing API is
30
+ * `error` with units 0. CLAUDE.md
31
+ * standing warning 6 records this shape
32
+ * costing a whole watcher.
33
+ * an API failure called N/A -> `not-applicable` is reachable from
34
+ * exactly one place here, the
35
+ * NO-VERDICT-AT-THIS-HEAD arm, and it
36
+ * carries an evaluated precondition
37
+ * record (SC-011).
38
+ * CI-green read off the BRANCH -> condition 4 compares
39
+ * `check_run.head_sha` against the head
40
+ * under evaluation and reddens when they
41
+ * differ, which is T-009 one scope down.
42
+ * scope satisfied by a record EXISTING -> condition 5 reads the record's
43
+ * STATUS word.
44
+ * arbitration satisfied by a file EXISTING -> condition 6 requires the
45
+ * document to name BOTH verdicts and the
46
+ * SAME head.
47
+ * a ruleset read as a default -> an empty body from the ruleset API is
48
+ * `error`, never an assumed shape.
49
+ *
50
+ * WHY CONDITIONS 1 AND 2 ARE COMPOSED AND NOT REIMPLEMENTED (plan step 6).
51
+ * M4-P10 shipped `dual-review-decorrelation` (DR-0012 condition 1) and
52
+ * `verdict-pair-approves` (condition 2) as derived checks in `src/checks.ts`,
53
+ * and `scripts/check-dual-review.mjs` is the runner around them. This gate is a
54
+ * SECOND CALLER of the same exported primitives rather than a second copy of
55
+ * the rules: it resolves the corpus with `resolveCorpusSource`, refuses a
56
+ * context whose delivery regime is undeterminable with `missingRegimeDocument`,
57
+ * reads the DR-0038 declaration with `readReviewFamilies`, and then runs the
58
+ * two checks BY ID out of `registeredChecks()`.
59
+ *
60
+ * BY ID, AND A MISSING REGISTRATION IS `error`. `scripts/check-dual-review.mjs`
61
+ * prints `0 registered check(s) named X` beside its own verdict, which is the
62
+ * Kind B witness that deregistering the check is visible. On the MERGE path a
63
+ * condition with no check behind it is a condition nobody evaluated, so here it
64
+ * is `error` and never a green with the comparison quietly skipped.
65
+ *
66
+ * WHY THE ROW STATUS AND THE GATE STATUS ARE NOT THE SAME WORD, and this is the
67
+ * one place the plan and the criteria say different things, so the reconciliation
68
+ * is written down rather than left to a reader. Plan step 4
69
+ * (delivery/plan/kernel-plan-m4.md:1911) says an ABSENT scope record is `error`;
70
+ * criterion 4 (delivery/plan/kernel-plan-m4.md:1938) says condition 5 is RED when
71
+ * no record exists. Both hold at once because they speak about different objects:
72
+ * the CONDITION is unsatisfied either way and its row reads `red` with a reason
73
+ * naming which arm it was, while the GATE could not reach a verdict about an
74
+ * instrument that is not there, so the gate's own status is `error`. Everything a
75
+ * reader needs is printed: the row, its reason, and the gate word.
76
+ */
77
+ /* -------------------------------------------------------------------- */
78
+ /* Plumbing */
79
+ /* -------------------------------------------------------------------- */
80
+ const GATE_ID = "merge-preconditions";
81
+ const UNIT_LABEL = "merge preconditions evaluated";
82
+ const DEFAULT_API_BASE = "https://api.github.com";
83
+ const DECORRELATION_CHECK_ID = "dual-review-decorrelation";
84
+ const PAIR_CHECK_ID = "verdict-pair-approves";
85
+ const REQUIRED_CHECK_CONTEXT = "gates";
86
+ /**
87
+ * The id of the one precondition this gate can report unmet.
88
+ *
89
+ * SC-011: `not-applicable` ASSERTS that a precondition was evaluated. The
90
+ * precondition here is "a merge is being proposed at this head", evidenced by
91
+ * at least one committed verdict document naming it. A head with no such
92
+ * document is not a merge waiting on six conditions; it is a branch nobody has
93
+ * reviewed yet, and reporting red for that would make the gate unusable on
94
+ * every push while making it say something false.
95
+ */
96
+ const PRECONDITION_ID = "merge-preconditions-verdict-names-this-head";
97
+ const USAGE = "usage: node src/gates/merge-preconditions.ts --result <file> --head <sha> --phase <id> " +
98
+ "[--evidence <dir>] [--context <dir>] [--repo <owner/name>] [--api-base <url>] " +
99
+ "[--scope-record <file>] [--arbitrations <dir>]";
100
+ const SINGLE_VALUE_FLAGS = [
101
+ "--result",
102
+ "--evidence",
103
+ "--head",
104
+ "--phase",
105
+ "--context",
106
+ "--repo",
107
+ "--api-base",
108
+ "--scope-record",
109
+ "--arbitrations",
110
+ ];
111
+ function parseFlags(args) {
112
+ const flags = {};
113
+ for (let index = 0; index < args.length; index += 1) {
114
+ const flag = args[index];
115
+ const value = args[index + 1];
116
+ if (flag === undefined) {
117
+ return undefined;
118
+ }
119
+ if (!SINGLE_VALUE_FLAGS.includes(flag)) {
120
+ return undefined;
121
+ }
122
+ if (value === undefined || value.startsWith("--")) {
123
+ return undefined;
124
+ }
125
+ flags[flag.slice(2)] = value;
126
+ index += 1;
127
+ }
128
+ return flags;
129
+ }
130
+ function usageError(message) {
131
+ if (message !== undefined) {
132
+ process.stderr.write(`tiphys gates ${GATE_ID}: ${message}\n`);
133
+ }
134
+ process.stderr.write(`${USAGE}\n`);
135
+ return EX_USAGE;
136
+ }
137
+ function now() {
138
+ return new Date().toISOString();
139
+ }
140
+ function absolute(path) {
141
+ return isAbsolute(path) ? path : resolve(process.cwd(), path);
142
+ }
143
+ export function renderRow(row) {
144
+ return `${row.id} (${row.clause}) at ${row.head}: ${row.status} -- ${row.sentence}`;
145
+ }
146
+ /**
147
+ * The gate word for a set of rows.
148
+ *
149
+ * `error` DOMINATES `red`, which is M2-C-3's direction: a run that could not
150
+ * look at one condition has not reached a verdict about the merge, and a red
151
+ * would be a verdict. A red that is also accompanied by an error still reports
152
+ * error, and both rows print either way, so nothing is hidden by the ordering.
153
+ */
154
+ export function gateStatusForRows(rows) {
155
+ if (rows.some((row) => row.status === "error")) {
156
+ return "error";
157
+ }
158
+ if (rows.some((row) => row.status === "red")) {
159
+ return "red";
160
+ }
161
+ return "green";
162
+ }
163
+ /**
164
+ * ONE REQUEST, AND THE FAILURE ARM IS WRITTEN FIRST.
165
+ *
166
+ * CLAUDE.md standing warning 6 records the exact defect this shape exists
167
+ * against: a watcher whose failure arm was a `.catch(() => {})` emitted nothing
168
+ * and was indistinguishable from a run still in progress. There is no catch
169
+ * here that returns a value the caller can mistake for data: a transport
170
+ * failure becomes `{ok: false, reason}` and every caller turns that into
171
+ * `error`.
172
+ *
173
+ * NO CREDENTIAL IS READ, AND THAT IS A RULE RATHER THAN AN OVERSIGHT.
174
+ * test/m2-exit-test.test.ts:425 asserts, by grepping every file under
175
+ * `src/gates/`, that no production gate reads a LITERAL-NAMED environment
176
+ * variable, because such a read is an ambient switch that changes a gate's
177
+ * reported status with nothing in the record to say so. An earlier draft of
178
+ * this module read `GH_TOKEN` and that test caught it, which is the guard
179
+ * working. Measured 2026-09-17 from this container, with NO Authorization
180
+ * header at all: `GET /repos/{slug}`, `GET /repos/{slug}/rulesets` and
181
+ * `GET /repos/{slug}/commits/{sha}/check-runs` each answered HTTP 200, because
182
+ * the agent proxy substitutes credentials on the way out and the value in
183
+ * `GH_TOKEN` is irrelevant (CLAUDE.md standing warning 6's invalid-token
184
+ * control measures the same thing). WHAT THIS COSTS, recorded rather than left
185
+ * to be found: in a deployment where the API genuinely requires a credential,
186
+ * every request here answers 401 or 404 and the gate reports `error`. That is
187
+ * the fail-closed direction and never a silent pass, and supplying a token
188
+ * would have to be a DECLARED FLAG in the registry command rather than an
189
+ * ambient environment read.
190
+ */
191
+ export async function requestJson(url) {
192
+ const headers = {
193
+ accept: "application/vnd.github+json",
194
+ "user-agent": "tiphys-merge-preconditions",
195
+ };
196
+ try {
197
+ const response = await fetch(url, { headers });
198
+ const body = await response.text();
199
+ return { ok: true, status: response.status, body };
200
+ }
201
+ catch (error) {
202
+ const message = error.message ?? String(error);
203
+ const cause = error.cause;
204
+ const detail = cause === undefined
205
+ ? message
206
+ : `${message} (${String(cause.code ?? "")}${cause.message === undefined ? "" : ` ${cause.message}`})`;
207
+ return { ok: false, reason: `GET ${url} could not be performed: ${singleLine(detail)}` };
208
+ }
209
+ }
210
+ /**
211
+ * PARSE A RESPONSE BODY, AND AN EMPTY ONE IS A FAILURE RATHER THAN A DEFAULT.
212
+ *
213
+ * Criterion 6 is the whole reason this is a named function: the ruleset API
214
+ * answering with a zero-length body must produce `error`, because a default
215
+ * here is the silent pass this phase exists against. `JSON.parse("")` throws,
216
+ * so the empty case would reach the same place anyway; it is separated out so
217
+ * the REASON a reader is given names the emptiness rather than a parser
218
+ * message that says nothing about what happened.
219
+ */
220
+ export function readJsonBody(url, response) {
221
+ if (!response.ok) {
222
+ return { ok: false, reason: response.reason };
223
+ }
224
+ if (response.status < 200 || response.status > 299) {
225
+ return {
226
+ ok: false,
227
+ reason: `GET ${url} answered HTTP ${String(response.status)}, so nothing was read from it`,
228
+ };
229
+ }
230
+ if (response.body.trim() === "") {
231
+ return {
232
+ ok: false,
233
+ reason: `GET ${url} answered HTTP ${String(response.status)} with an EMPTY BODY; a merge ` +
234
+ "precondition assumed from an empty answer is the silent pass this gate exists against",
235
+ };
236
+ }
237
+ try {
238
+ return { ok: true, value: JSON.parse(response.body) };
239
+ }
240
+ catch (error) {
241
+ return {
242
+ ok: false,
243
+ reason: `GET ${url} answered a body that is not JSON: ${singleLine(error.message)}`,
244
+ };
245
+ }
246
+ }
247
+ /**
248
+ * `owner/name` out of a git remote URL, or undefined when the URL is not one.
249
+ *
250
+ * DERIVED RATHER THAN WRITTEN INTO THE REGISTRY, and the reason is DR-0029: the
251
+ * kernel is just another project under the scheme, so a registry entry naming
252
+ * ONE repository would be the kernel's registry claiming to be everybody's. The
253
+ * remote is the fact this gate is actually about.
254
+ */
255
+ export function slugFromRemote(url) {
256
+ const trimmed = url.trim().replace(/\.git$/, "");
257
+ const match = /(?:[:/])([^/:]+)\/([^/]+)$/.exec(trimmed);
258
+ if (match === null) {
259
+ return undefined;
260
+ }
261
+ return `${match[1]}/${match[2]}`;
262
+ }
263
+ function slugFromGit(contextDirectory) {
264
+ const run = spawnSync("git", ["-C", contextDirectory, "remote", "get-url", "origin"], {
265
+ encoding: "utf8",
266
+ });
267
+ if (run.status !== 0) {
268
+ return undefined;
269
+ }
270
+ return slugFromRemote(run.stdout ?? "");
271
+ }
272
+ /**
273
+ * Condition 4, and the comparison that makes it worth having.
274
+ *
275
+ * T-009 ONE SCOPE DOWN. "CI is green" is never a complete sentence: the
276
+ * complete one names the event and the HEAD SHA. A check that asked the API for
277
+ * the newest run on the BRANCH would report green off a run for an earlier
278
+ * head, which is exactly the state this repository spent four hours and
279
+ * twenty-one minutes in. So every check run the API returns is compared on
280
+ * `head_sha` and one that names a different commit is reported as the different
281
+ * commit it is, never counted.
282
+ */
283
+ export function judgeCheckRuns(head, runs, context) {
284
+ const named = runs.filter((run) => String(run.name ?? "") === context);
285
+ if (named.length === 0) {
286
+ return {
287
+ ok: false,
288
+ sentence: `the API returned ${String(runs.length)} check run(s) and none is named ${context}, ` +
289
+ "so nothing asserts this head was built",
290
+ };
291
+ }
292
+ const forThisHead = named.filter((run) => String(run.head_sha ?? "").toLowerCase() === head.toLowerCase());
293
+ if (forThisHead.length === 0) {
294
+ const others = [...new Set(named.map((run) => String(run.head_sha ?? "(no head_sha)")))];
295
+ return {
296
+ ok: false,
297
+ sentence: `every ${context} check run the API returned names a DIFFERENT head (${others.join(", ")}); ` +
298
+ `a green run for an earlier head is not evidence about ${head}`,
299
+ };
300
+ }
301
+ const succeeded = forThisHead.filter((run) => String(run.status ?? "") === "completed" && String(run.conclusion ?? "") === "success");
302
+ if (succeeded.length === 0) {
303
+ const seen = forThisHead.map((run) => `${String(run.status ?? "(no status)")}/${String(run.conclusion ?? "(no conclusion)")}`);
304
+ return {
305
+ ok: false,
306
+ sentence: `${String(forThisHead.length)} ${context} check run(s) name this head and none concluded ` +
307
+ `success (${seen.join(", ")})`,
308
+ };
309
+ }
310
+ return {
311
+ ok: true,
312
+ sentence: `${String(succeeded.length)} ${context} check run(s) concluded success with head_sha equal ` +
313
+ "to the head under evaluation",
314
+ };
315
+ }
316
+ /* -------------------------------------------------------------------- */
317
+ /* Condition 5: the scope gate's own record */
318
+ /* -------------------------------------------------------------------- */
319
+ /**
320
+ * Condition 5, and the distinction the plan's hazard list names: the condition
321
+ * is satisfied by the record's STATUS, never by the record's EXISTENCE.
322
+ *
323
+ * The absent arm and the red arm are DIFFERENT REASONS on purpose (criterion
324
+ * 4). `instrument` is what the caller turns into the gate's own word: an absent
325
+ * record means the gate could not look, which is `error` at gate level while
326
+ * the condition itself is unsatisfied.
327
+ */
328
+ export function judgeScopeRecord(path, read) {
329
+ if (read.kind === "absent") {
330
+ return {
331
+ ok: false,
332
+ instrument: "missing",
333
+ sentence: `no scope gate record exists at ${path}, so whether the change is the change that was ` +
334
+ "promised is UNKNOWN; an absent record is not a passing one",
335
+ };
336
+ }
337
+ if (read.kind === "refused") {
338
+ return { ok: false, instrument: "missing", sentence: read.reason };
339
+ }
340
+ let parsed;
341
+ try {
342
+ parsed = JSON.parse(read.body);
343
+ }
344
+ catch (error) {
345
+ return {
346
+ ok: false,
347
+ instrument: "missing",
348
+ sentence: `the scope gate record at ${path} does not parse as JSON: ${singleLine(error.message)}`,
349
+ };
350
+ }
351
+ const record = parsed;
352
+ const status = String(record?.status ?? "");
353
+ if (status === "") {
354
+ return {
355
+ ok: false,
356
+ instrument: "missing",
357
+ sentence: `the scope gate record at ${path} carries no status, so it asserts nothing`,
358
+ };
359
+ }
360
+ if (status !== "green") {
361
+ return {
362
+ ok: false,
363
+ instrument: "present",
364
+ sentence: `the scope gate record at ${path} reads ${status}, not green`,
365
+ };
366
+ }
367
+ return {
368
+ ok: true,
369
+ instrument: "present",
370
+ sentence: `the scope gate record at ${path} reads green over ${String(record?.units ?? 0)} unit(s)`,
371
+ };
372
+ }
373
+ /* -------------------------------------------------------------------- */
374
+ /* Condition 6: the arbitration document */
375
+ /* -------------------------------------------------------------------- */
376
+ const HEX_TOKEN = /\b[0-9a-f]{7,40}\b/gi;
377
+ /**
378
+ * Condition 6, and EXISTENCE IS NOT THE TEST.
379
+ *
380
+ * Criterion 5 asks for two structurally different members of that class, and
381
+ * they are structurally different because they fail on different halves of the
382
+ * same document: one that names only ONE of the two verdicts has read half the
383
+ * evidence, and one that names a DIFFERENT HEAD has read the right number of
384
+ * documents about the wrong commit. A check that tested for the file's presence
385
+ * passes both.
386
+ *
387
+ * VERDICTS ARE IDENTIFIED BY FILE NAME because `schemas/verdict.schema.json`
388
+ * gives a verdict no id of its own; its required keys are kind, phase, head,
389
+ * verdict, produced-by, framing, review-contract, findings, criteria and
390
+ * deviations-judged. The existing arbitration documents in `delivery/review/`
391
+ * already cite their reviews by path, so the convention that exists is read
392
+ * rather than a field invented.
393
+ *
394
+ * THE FILE NAME AND NOT THE FULL PATH, and that is a measured correction rather
395
+ * than a preference. `loadCommittedVerdicts` returns ABSOLUTE paths when it
396
+ * falls back to the worktree and repository-relative ones when it reads a
397
+ * commit, so comparing whole paths made the same document resolve or not
398
+ * resolve depending on which corpus source happened to be chosen. The file name
399
+ * is the part that is stable across both, and it is the part an arbitration
400
+ * document's `- reviews:` line contains either way.
401
+ */
402
+ export function judgeArbitration(path, head, verdictPaths, read) {
403
+ if (read.kind === "absent") {
404
+ return {
405
+ ok: false,
406
+ sentence: `no arbitration document exists at ${path}, so no recorded ruling covers this head`,
407
+ };
408
+ }
409
+ if (read.kind === "refused") {
410
+ return { ok: false, sentence: read.reason };
411
+ }
412
+ const body = read.body;
413
+ const missing = verdictPaths.filter((verdictPath) => !body.includes(basename(verdictPath)));
414
+ if (missing.length > 0) {
415
+ return {
416
+ ok: false,
417
+ sentence: `the arbitration document ${path} EXISTS and names ${String(verdictPaths.length - missing.length)} ` +
418
+ `of the ${String(verdictPaths.length)} verdict(s) for this head; it does not name ` +
419
+ `${missing.map((verdictPath) => basename(verdictPath)).join(", ")}, so it did not arbitrate between them`,
420
+ };
421
+ }
422
+ const tokens = [...new Set((body.match(HEX_TOKEN) ?? []).map((token) => token.toLowerCase()))];
423
+ const matching = tokens.filter((token) => head.toLowerCase().startsWith(token));
424
+ if (matching.length === 0) {
425
+ return {
426
+ ok: false,
427
+ sentence: `the arbitration document ${path} EXISTS and names ${String(verdictPaths.length)} verdict(s) ` +
428
+ `but no commit-shaped token in it is a prefix of ${head}` +
429
+ (tokens.length === 0
430
+ ? "; it names no head at all"
431
+ : `; it names ${tokens.join(", ")}, which is a ruling about a different head`),
432
+ };
433
+ }
434
+ return {
435
+ ok: true,
436
+ sentence: `the arbitration document ${path} names all ${String(verdictPaths.length)} verdict(s) for ` +
437
+ `this head and carries ${matching.join(", ")}, a prefix of the head under evaluation`,
438
+ };
439
+ }
440
+ /**
441
+ * The ruleset encoding, and the TWO members of criterion 7.
442
+ *
443
+ * `enforcement: disabled` is PRESENT-BUT-TOOTHLESS and a
444
+ * `required_status_checks` rule that does not name `gates` is
445
+ * PRESENT-BUT-WRONG. They are different failures and they are reported with
446
+ * different sentences, because a reader told only "the ruleset is wrong" has to
447
+ * go and find out which.
448
+ *
449
+ * R-065a IS DATA, NOT A VERDICT. Squash-only is an OWNER action the owner has
450
+ * deferred; the plan (step 7) says report its state and do not judge it, so
451
+ * `allowed_merge_methods` is printed and never turns this row red.
452
+ */
453
+ export function judgeRulesets(rulesets) {
454
+ if (rulesets.length === 0) {
455
+ return {
456
+ ok: false,
457
+ sentence: "the API returned no branch ruleset at all, so nothing protects the default branch",
458
+ };
459
+ }
460
+ const active = rulesets.filter((ruleset) => ruleset.enforcement === "active");
461
+ if (active.length === 0) {
462
+ const seen = rulesets.map((ruleset) => `${ruleset.name}: ${ruleset.enforcement}`);
463
+ return {
464
+ ok: false,
465
+ sentence: `${String(rulesets.length)} ruleset(s) exist and NONE is enforcement active (${seen.join(", ")}); ` +
466
+ "a ruleset that is present and disabled protects nothing",
467
+ };
468
+ }
469
+ const withGates = active.filter((ruleset) => ruleset.rules.some((rule) => String(rule.type ?? "") === "required_status_checks" &&
470
+ (rule.parameters?.["required_status_checks"] ?? []).some((check) => String(check?.context ?? "") === REQUIRED_CHECK_CONTEXT)));
471
+ if (withGates.length === 0) {
472
+ const named = active.flatMap((ruleset) => ruleset.rules
473
+ .filter((rule) => String(rule.type ?? "") === "required_status_checks")
474
+ .flatMap((rule) => (rule.parameters?.["required_status_checks"] ??
475
+ []).map((check) => String(check?.context ?? ""))));
476
+ return {
477
+ ok: false,
478
+ sentence: `${String(active.length)} active ruleset(s) exist and none requires the status check ` +
479
+ `${REQUIRED_CHECK_CONTEXT}` +
480
+ (named.length === 0
481
+ ? "; none carries a required_status_checks rule at all"
482
+ : `; the contexts they require are ${named.join(", ")}`),
483
+ };
484
+ }
485
+ const merge = active.flatMap((ruleset) => ruleset.rules
486
+ .filter((rule) => String(rule.type ?? "") === "pull_request")
487
+ .flatMap((rule) => rule.parameters?.["allowed_merge_methods"] ?? []));
488
+ const methods = [...new Set(merge)];
489
+ return {
490
+ ok: true,
491
+ sentence: `${withGates.map((ruleset) => ruleset.name).join(", ")} is enforcement active and requires ` +
492
+ `the status check ${REQUIRED_CHECK_CONTEXT} (R-064). R-065a DATA, not a verdict: ` +
493
+ `allowed_merge_methods = ${methods.length === 0 ? "(none reported)" : methods.join(", ")}`,
494
+ };
495
+ }
496
+ /**
497
+ * Condition 3, and what it DOES and DOES NOT establish.
498
+ *
499
+ * DR-0012 condition 3 (delivery/decisions/DR-0012-delegated-merge-authority.md:24)
500
+ * says both reviewers were given the phase's acceptance criteria as their
501
+ * contract and both WALKED OR EXECUTED them. What is reachable from the verdict
502
+ * documents alone is that each one declares a `review-contract` and carries a
503
+ * non-empty `criteria[]` whose entries each record `met`. Whether that walk
504
+ * COVERS every acceptance criterion the plan declares is a comparison against a
505
+ * different document and it is the shipped Kind B check
506
+ * `verdict-criteria-complete`, which resolves a `plan.yaml` out of its context.
507
+ * This repository has no such document, so running it here would make this row
508
+ * permanently error about the instrument rather than about the merge. The row's
509
+ * sentence therefore says which half it established, and the other half is
510
+ * recorded as residue in delivery/work-history/m4-p12.md rather than implied.
511
+ */
512
+ export function judgeCriteriaWalked(verdicts) {
513
+ const faults = [];
514
+ for (const verdict of verdicts) {
515
+ const contract = String(verdict.record["review-contract"] ?? "");
516
+ if (contract === "") {
517
+ faults.push(`${verdict.path} declares no review-contract`);
518
+ }
519
+ const criteria = verdict.record["criteria"];
520
+ if (!Array.isArray(criteria) || criteria.length === 0) {
521
+ faults.push(`${verdict.path} walks no acceptance criterion`);
522
+ continue;
523
+ }
524
+ const unwalked = criteria.filter((entry) => entry?.met === undefined);
525
+ if (unwalked.length > 0) {
526
+ faults.push(`${verdict.path} carries ${String(unwalked.length)} criterion entr(ies) with no met field`);
527
+ }
528
+ }
529
+ if (faults.length > 0) {
530
+ return { ok: false, sentence: faults.join("; ") };
531
+ }
532
+ const contracts = verdicts.map((verdict) => String(verdict.record["review-contract"] ?? ""));
533
+ return {
534
+ ok: true,
535
+ sentence: `all ${String(verdicts.length)} verdict(s) declare a review-contract (${contracts.join(", ")}) ` +
536
+ "and walk at least one acceptance criterion with a recorded met; COMPLETENESS against the " +
537
+ "plan's acceptance list is the separate check verdict-criteria-complete and is NOT asserted here",
538
+ };
539
+ }
540
+ /**
541
+ * Run ONE registered check over the verdicts for this head.
542
+ *
543
+ * A check that is NOT REGISTERED is `error`, never a green with the comparison
544
+ * skipped, which is the whole of the "green because it could not look" class
545
+ * applied to this gate's own instrument.
546
+ */
547
+ function runRegisteredCheck(id, verdicts, contextDirectory) {
548
+ const selected = registeredChecks().filter((check) => check.id === id);
549
+ if (selected.length === 0) {
550
+ return {
551
+ status: "error",
552
+ sentence: `0 registered check(s) named ${id}, so nothing evaluated this condition; a condition with ` +
553
+ "no check behind it is a condition nobody looked at",
554
+ };
555
+ }
556
+ const messages = new Set();
557
+ for (const verdict of verdicts) {
558
+ for (const check of selected) {
559
+ const outcome = check.run(verdict.record, contextDirectory);
560
+ for (const violation of outcome.violations) {
561
+ messages.add(`${violation.pointer} ${violation.message}`);
562
+ }
563
+ }
564
+ }
565
+ if (messages.size > 0) {
566
+ return { status: "red", sentence: [...messages].sort().join(" | ") };
567
+ }
568
+ return {
569
+ status: "green",
570
+ sentence: `${id} reported no violation over the ${String(verdicts.length)} verdict(s) committed for ` +
571
+ "this head",
572
+ };
573
+ }
574
+ /* -------------------------------------------------------------------- */
575
+ /* The gate */
576
+ /* -------------------------------------------------------------------- */
577
+ function emit(resultPath, fields, rows) {
578
+ const result = makeGateResult(fields);
579
+ const refusal = refuseOpenForWrite(resultPath);
580
+ if (refusal !== undefined) {
581
+ process.stderr.write(`tiphys gates ${GATE_ID}: ${refusal}\n`);
582
+ return EXIT_GATE_ERROR;
583
+ }
584
+ const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
585
+ if (!written.ok) {
586
+ process.stderr.write(`tiphys gates ${GATE_ID}: ${written.reason}\n`);
587
+ return EXIT_GATE_ERROR;
588
+ }
589
+ process.stdout.write(`${result.gate}: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
590
+ for (const row of rows) {
591
+ process.stdout.write(`${renderRow(row)}\n`);
592
+ }
593
+ if (result.detail !== "") {
594
+ process.stdout.write(`${result.detail}\n`);
595
+ }
596
+ return exitCodeForStatus(result.status);
597
+ }
598
+ function read(path) {
599
+ const result = readRegularFileIfPresent(path);
600
+ if (result.kind === "read") {
601
+ return { kind: "read", body: result.body };
602
+ }
603
+ if (result.kind === "absent") {
604
+ return { kind: "absent" };
605
+ }
606
+ return { kind: "refused", reason: result.reason };
607
+ }
608
+ /** Every branch ruleset, with its rules, or the reason none could be read. */
609
+ async function readRulesets(apiBase, slug) {
610
+ const listUrl = `${apiBase}/repos/${slug}/rulesets?includes_parents=true`;
611
+ const listed = readJsonBody(listUrl, await requestJson(listUrl));
612
+ if (!listed.ok) {
613
+ return { ok: false, reason: listed.reason };
614
+ }
615
+ if (!Array.isArray(listed.value)) {
616
+ return { ok: false, reason: `GET ${listUrl} answered a body that is not an array of rulesets` };
617
+ }
618
+ const rulesets = [];
619
+ for (const entry of listed.value) {
620
+ if (String(entry["target"] ?? "branch") !== "branch") {
621
+ continue;
622
+ }
623
+ /* THE DETAIL DOCUMENT IS AUTHORITATIVE WHEN IT IS FETCHED, AND THAT IS NOT
624
+ TIDINESS. The real list endpoint answers with SUMMARIES carrying no
625
+ `rules`, so the rules always come from the detail; reading `enforcement`
626
+ off the summary while reading the rules off the detail would be one
627
+ verdict assembled from two documents that can disagree. Whichever
628
+ document supplied the rules supplies the enforcement word too. */
629
+ let source = entry;
630
+ if (!Array.isArray(entry["rules"])) {
631
+ const detailUrl = `${apiBase}/repos/${slug}/rulesets/${String(entry["id"] ?? "")}`;
632
+ const detail = readJsonBody(detailUrl, await requestJson(detailUrl));
633
+ if (!detail.ok) {
634
+ return { ok: false, reason: detail.reason };
635
+ }
636
+ source = detail.value;
637
+ if (!Array.isArray(source["rules"])) {
638
+ return {
639
+ ok: false,
640
+ reason: `GET ${detailUrl} answered a ruleset carrying no rules array, so what it enforces is unknown`,
641
+ };
642
+ }
643
+ }
644
+ rulesets.push({
645
+ id: String(source["id"] ?? entry["id"] ?? ""),
646
+ name: String(source["name"] ?? entry["name"] ?? "(unnamed)"),
647
+ enforcement: String(source["enforcement"] ?? "(none reported)"),
648
+ rules: source["rules"],
649
+ });
650
+ }
651
+ return { ok: true, rulesets };
652
+ }
653
+ export async function runGate(flags) {
654
+ const startedAt = now();
655
+ const resultPath = flags.result;
656
+ const head = flags.head.toLowerCase();
657
+ const phase = flags.phase.toLowerCase();
658
+ const contextDirectory = absolute(flags.context ?? process.cwd());
659
+ const apiBase = (flags["api-base"] ?? DEFAULT_API_BASE).replace(/\/+$/, "");
660
+ const slug = flags.repo ?? slugFromGit(contextDirectory) ?? "";
661
+ const shared = { gate: GATE_ID, unitLabel: UNIT_LABEL, startedAt };
662
+ if (slug === "") {
663
+ return emit(resultPath, {
664
+ ...shared,
665
+ status: "error",
666
+ units: 0,
667
+ endedAt: now(),
668
+ detail: "no repository could be established: `git -C <context> remote get-url origin` named none " +
669
+ "and --repo <owner/name> was not supplied. Without one, condition 4 and the " +
670
+ "branch-protection encoding have nothing to ask about",
671
+ }, []);
672
+ }
673
+ /* THE PROBE COMES FIRST (plan step 1 and criterion 2). An unreachable API is
674
+ `error` with units 0 and a reason naming the failure, and the test asserts
675
+ the STATUS WORD rather than the detail string, because the mutant this arm
676
+ exists against is a request wrapped in a catch that returns "unknown" and
677
+ reports green. CLAUDE.md standing warning 6 says REST reachability here is
678
+ a thing to PROBE at the start of a run that depends on it, in either
679
+ direction, so this is the probe and not an assumption. */
680
+ const probeUrl = `${apiBase}/repos/${slug}`;
681
+ const probe = readJsonBody(probeUrl, await requestJson(probeUrl));
682
+ if (!probe.ok) {
683
+ return emit(resultPath, {
684
+ ...shared,
685
+ status: "error",
686
+ units: 0,
687
+ endedAt: now(),
688
+ detail: `the GitHub REST API could not be reached, so DR-0012 condition 4 and the ` +
689
+ `branch-protection encoding were not evaluated and no merge verdict was reached: ${probe.reason}`,
690
+ }, []);
691
+ }
692
+ /* THE CORPUS, READ THROUGH THE SHIPPED PRIMITIVES (plan step 6). Every
693
+ refusal below is `error` rather than red, and each is the one
694
+ `scripts/check-dual-review.mjs` already makes at the same layer: a merge
695
+ gate that cannot establish the regime, cannot read the DR-0038 declaration,
696
+ or cannot examine a document that looks like a verdict has NOT reached a
697
+ verdict (M2-C-3). */
698
+ const source = resolveCorpusSource(contextDirectory);
699
+ const regime = missingRegimeDocument(contextDirectory, source);
700
+ if (regime !== undefined) {
701
+ return emit(resultPath, { ...shared, status: "error", units: 0, endedAt: now(), detail: regime.reason }, []);
702
+ }
703
+ const families = readReviewFamilies(contextDirectory);
704
+ if (families.kind === "error") {
705
+ return emit(resultPath, { ...shared, status: "error", units: 0, endedAt: now(), detail: families.reason }, []);
706
+ }
707
+ const corpus = loadCommittedVerdicts(contextDirectory, source);
708
+ if (!corpus.ok) {
709
+ return emit(resultPath, { ...shared, status: "error", units: 0, endedAt: now(), detail: corpus.reason }, []);
710
+ }
711
+ if (corpus.unexaminable.length > 0) {
712
+ return emit(resultPath, {
713
+ ...shared,
714
+ status: "error",
715
+ units: 0,
716
+ endedAt: now(),
717
+ detail: `${String(corpus.unexaminable.length)} document(s) could not be examined, so whether a ` +
718
+ `review refusing this head is among them is unknown: ` +
719
+ corpus.unexaminable.map((diagnostic) => diagnostic.message).join("; "),
720
+ }, []);
721
+ }
722
+ /* THE SECOND CALL SITE OF THE EQUALITY MECHANISM, FOUND BY DERIVATION AND
723
+ NOT BY A REVIEW (DR-0047 sweep round 2).
724
+ `scripts/check-dual-review.mjs` selected its corpus by comparing the
725
+ DECLARED head to the RUN head with `===`, and no real flow satisfies that:
726
+ reviewers read commit X, committing their verdicts produces X+1, and CI
727
+ audits X+1. This line was the same comparison, spelled once more, so this
728
+ gate's precondition ("a merge is being proposed at this head, evidenced by
729
+ a committed verdict naming it") could never be met either and every run
730
+ reported not-applicable.
731
+
732
+ The relation is now ancestry constrained to a paperwork-only gap, which is
733
+ the same rule and the same function both gates read it from, so the two
734
+ cannot drift into two answers about one question. An EQUAL head still
735
+ passes and touches git not at all (`relateDeclaredHead` answers that case
736
+ before any spawn), which matters here because `--head` comes from the CI
737
+ event and need not be an object in this checkout. */
738
+ const admitted = [];
739
+ const excluded = [];
740
+ const forHead = [];
741
+ for (const entry of corpus.verdicts) {
742
+ const declared = String(entry.record["head"] ?? "").toLowerCase();
743
+ const relation = relateDeclaredHead(contextDirectory, declared, head);
744
+ if (relation.kind === "same" || relation.kind === "evidence-only-ancestor") {
745
+ admitted.push({ path: entry.path, declared, relation });
746
+ forHead.push({ path: entry.path, record: entry.record });
747
+ continue;
748
+ }
749
+ excluded.push({ path: entry.path, declared, relation });
750
+ }
751
+ if (forHead.length === 0) {
752
+ /* SC-011: the ONE not-applicable arm, and it carries an EVALUATED
753
+ precondition rather than a silence. A head with no verdict naming it is
754
+ not a merge waiting on six conditions. */
755
+ const precondition = {
756
+ id: PRECONDITION_ID,
757
+ met: false,
758
+ reason: `no committed verdict document names head ${head}, so no merge is being proposed at this ` +
759
+ "head and DR-0012's conditions have no subject",
760
+ evidence: [
761
+ `${String(corpus.verdicts.length)} committed verdict document(s) were read and examined`,
762
+ `head under evaluation: ${head}`,
763
+ /* EVERY EXCLUDED DOCUMENT IS NAMED WITH THE ROUTE THAT EXCLUDED IT.
764
+ "There is no verdict here" and "there are two approving verdicts and
765
+ each reviewed something else" are different facts, and printing the
766
+ first for both is the fail-open direction `describeOffHeadVerdicts`
767
+ exists to close one gate along. */
768
+ ...describeOffHeadVerdicts(excluded, head).map((line) => `EXCLUDED ${line}`),
769
+ ],
770
+ };
771
+ return emit(resultPath, {
772
+ ...shared,
773
+ status: "not-applicable",
774
+ units: 0,
775
+ endedAt: now(),
776
+ precondition,
777
+ detail: precondition.reason,
778
+ }, []);
779
+ }
780
+ const rows = [];
781
+ /* THE ADMISSION ROUTE IS A ROW, NOT A FOOTNOTE. Every other condition here
782
+ gets a row because a reader has to be able to see what was asserted; the
783
+ corpus SELECTION decides what all six conditions are about, so a run whose
784
+ verdicts were admitted by ANCESTRY rather than by naming this commit must
785
+ say so in the same place. Its status is green because selection succeeded:
786
+ a selection that found nothing does not reach this line at all, it reaches
787
+ the not-applicable arm above with every excluded document named. */
788
+ rows.push({
789
+ id: "verdict-selection",
790
+ clause: "DR-0047 the verdicts selected are evidence about THIS head",
791
+ status: "green",
792
+ head,
793
+ sentence: `${String(admitted.length)} verdict(s) admitted and ${String(excluded.length)} excluded; ` +
794
+ describeAdmittedVerdicts(admitted, head).join(" | ") +
795
+ (excluded.length === 0
796
+ ? ""
797
+ : ` | EXCLUDED: ${describeOffHeadVerdicts(excluded, head).join(" | ")}`),
798
+ });
799
+ const condition1 = runRegisteredCheck(DECORRELATION_CHECK_ID, forHead, contextDirectory);
800
+ rows.push({
801
+ id: "condition-1",
802
+ clause: "DR-0012:22 two decorrelated clean-room reviews of this head",
803
+ status: condition1.status,
804
+ head,
805
+ sentence: condition1.sentence,
806
+ });
807
+ const condition2 = runRegisteredCheck(PAIR_CHECK_ID, forHead, contextDirectory);
808
+ rows.push({
809
+ id: "condition-2",
810
+ clause: "DR-0012:23 no unresolved finding at medium or above",
811
+ status: condition2.status,
812
+ head,
813
+ sentence: condition2.sentence,
814
+ });
815
+ const condition3 = judgeCriteriaWalked(forHead);
816
+ rows.push({
817
+ id: "condition-3",
818
+ clause: "DR-0012:24 the acceptance criteria were the reviewers' contract",
819
+ status: condition3.ok ? "green" : "red",
820
+ head,
821
+ sentence: condition3.sentence,
822
+ });
823
+ const checkRunsUrl = `${apiBase}/repos/${slug}/commits/${head}/check-runs`;
824
+ const checkRuns = readJsonBody(checkRunsUrl, await requestJson(checkRunsUrl));
825
+ if (!checkRuns.ok) {
826
+ rows.push({
827
+ id: "condition-4",
828
+ clause: "DR-0012:25 CI green on the EXACT head",
829
+ status: "error",
830
+ head,
831
+ sentence: checkRuns.reason,
832
+ });
833
+ }
834
+ else {
835
+ const listed = checkRuns.value.check_runs;
836
+ const judged = judgeCheckRuns(head, Array.isArray(listed) ? listed : [], REQUIRED_CHECK_CONTEXT);
837
+ rows.push({
838
+ id: "condition-4",
839
+ clause: "DR-0012:25 CI green on the EXACT head",
840
+ status: judged.ok ? "green" : "red",
841
+ head,
842
+ sentence: judged.sentence,
843
+ });
844
+ }
845
+ const scopeRecordPath = absolute(flags["scope-record"] ??
846
+ (flags.evidence === undefined
847
+ ? join(contextDirectory, "scope", "result.json")
848
+ : join(dirname(absolute(flags.evidence)), "scope", "result.json")));
849
+ const scope = judgeScopeRecord(scopeRecordPath, read(scopeRecordPath));
850
+ rows.push({
851
+ id: "condition-5",
852
+ clause: "DR-0012:26 the scope audit passes",
853
+ status: scope.ok ? "green" : "red",
854
+ head,
855
+ sentence: scope.sentence,
856
+ });
857
+ const arbitrationDirectory = absolute(flags.arbitrations ?? join(contextDirectory, "delivery", "review"));
858
+ const arbitrationPath = join(arbitrationDirectory, `arbitration-${phase}.md`);
859
+ const arbitration = judgeArbitration(arbitrationPath, head, forHead.map((verdict) => verdict.path), read(arbitrationPath));
860
+ rows.push({
861
+ id: "condition-6",
862
+ clause: "DR-0012:27 a recorded arbitration over BOTH verdicts at this head",
863
+ status: arbitration.ok ? "green" : "red",
864
+ head,
865
+ sentence: arbitration.sentence,
866
+ });
867
+ const rulesets = await readRulesets(apiBase, slug);
868
+ if (!rulesets.ok) {
869
+ rows.push({
870
+ id: "branch-protection",
871
+ clause: "R-064 the ruleset encoding, R-065a reported as DATA",
872
+ status: "error",
873
+ head,
874
+ sentence: rulesets.reason,
875
+ });
876
+ }
877
+ else {
878
+ const judged = judgeRulesets(rulesets.rulesets);
879
+ rows.push({
880
+ id: "branch-protection",
881
+ clause: "R-064 the ruleset encoding, R-065a reported as DATA",
882
+ status: judged.ok ? "green" : "red",
883
+ head,
884
+ sentence: judged.sentence,
885
+ });
886
+ }
887
+ /* THE GATE WORD, AND THE ONE PLACE A ROW AND THE GATE DELIBERATELY DIFFER.
888
+ An absent scope record leaves condition 5's row RED (criterion 4) while the
889
+ gate itself is ERROR (plan step 4), because the instrument was not there to
890
+ read. Both facts are printed. */
891
+ const scopeInstrumentMissing = scope.instrument === "missing";
892
+ const status = scopeInstrumentMissing ? "error" : gateStatusForRows(rows);
893
+ return emit(resultPath, {
894
+ ...shared,
895
+ status,
896
+ units: rows.length,
897
+ endedAt: now(),
898
+ detail: `DR-0012 at head ${head}, phase ${phase}: ` +
899
+ rows.map((row) => `${row.id}=${row.status}`).join(" ") +
900
+ (scopeInstrumentMissing
901
+ ? "; the gate word is error rather than red because the scope gate record was ABSENT, " +
902
+ "so condition 5 could not be looked at (plan step 4); the row stays red because the " +
903
+ "condition is not satisfied either way (criterion 4)"
904
+ : ""),
905
+ }, rows);
906
+ }
907
+ export async function main(argv) {
908
+ const flags = parseFlags(argv);
909
+ if (flags === undefined) {
910
+ return usageError();
911
+ }
912
+ const missing = ["result", "head", "phase"].filter((name) => flags[name] === undefined);
913
+ if (missing.length > 0) {
914
+ return usageError(`${GATE_ID} requires ${missing.map((name) => `--${name}`).join(" ")}`);
915
+ }
916
+ return runGate(flags);
917
+ }
918
+ const entry = process.argv[1];
919
+ if (entry !== undefined && pathsIdentifySameObject(fileURLToPath(import.meta.url), entry)) {
920
+ /* The same second layer src/gates/scope.ts and src/gates/gate-classes.ts
921
+ carry: an uncaught throw would exit 1, which is EXIT_RED, and a crash
922
+ reported as a red verdict is indistinguishable from a real one to a
923
+ consumer reading the exit code. */
924
+ try {
925
+ process.exitCode = await main(process.argv.slice(2));
926
+ }
927
+ catch (error) {
928
+ process.stderr.write(`tiphys gates ${GATE_ID}: ${singleLine(error.message ?? String(error))}\n`);
929
+ process.exitCode = EXIT_GATE_ERROR;
930
+ }
931
+ }
932
+ export { GATE_ID, PRECONDITION_ID, UNIT_LABEL };