@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,836 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { pathsIdentifySameObject } from "../path-identity.js";
5
+ import { Script, createContext } from "node:vm";
6
+ import { cpus, loadavg } from "node:os";
7
+ import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
8
+ import { loadSchema, validate } from "./validate.js";
9
+ import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
10
+ /**
11
+ * THE KERNEL CONFIG (kernel plan M2-P6 step 2): this repository's real
12
+ * pair and the four bucket kinds the plan states verbatim. It is a plain
13
+ * exported constant, not a checked-in data file, because M2-C-1's
14
+ * verification-first reading of the plan's files-to-touch list for this
15
+ * phase names the module, its schema and its test, and no committed
16
+ * configuration document alongside them; a `--config` flag (validated
17
+ * against `coverage-config.schema.json`) exists for a caller that wants a
18
+ * different pair, and every fixture test in `test/coverage-gate.test.ts`
19
+ * uses it rather than editing this constant.
20
+ */
21
+ export const KERNEL_COVERAGE_CONFIG = {
22
+ inventory: {
23
+ path: "delivery/requirements/migration-table.md",
24
+ idPattern: "R-[0-9]+[a-z]?",
25
+ },
26
+ coverageTable: {
27
+ path: "delivery/plan/kernel-plan-v1.md",
28
+ idPattern: "R-[0-9]+[a-z]?",
29
+ bucketColumn: 1,
30
+ noteColumn: 2,
31
+ },
32
+ bucketKinds: [
33
+ { kind: "phase", pattern: "M([0-9]+)-P[0-9]+", requiresNote: false },
34
+ { kind: "milestone", pattern: "M([0-9]+)", requiresNote: false },
35
+ {
36
+ kind: "decision",
37
+ pattern: "DR-[0-9]{4}|D-[0-9]+|M2-D-[0-9]+",
38
+ requiresNote: false,
39
+ },
40
+ { kind: "parked", pattern: "parked", requiresNote: true },
41
+ ],
42
+ expectedUnits: 115,
43
+ };
44
+ /**
45
+ * THE SHARED EMPTINESS PREDICATE (fix round 1, CR-987).
46
+ *
47
+ * Before this round, `checkCoverage`'s note check compared a value already
48
+ * passed through `String.prototype.trim` (applied once, at extraction) to
49
+ * `""`, and `checkFindingOutcomeParity`'s outcome check compared a value
50
+ * that was never trimmed at all to `""`. Two definitions, only one of
51
+ * which trimmed, and NEITHER strips a zero-width character: `trim()`
52
+ * removes Unicode whitespace (which includes U+00A0 and U+FEFF) but not
53
+ * U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER or U+200D ZERO
54
+ * WIDTH JOINER, so a cell or an outcome containing only one of those reads
55
+ * as non-empty to a bare `=== ""` comparison, trimmed or not. One
56
+ * predicate, called at every point this module asks "is this empty",
57
+ * closes both instances at once.
58
+ */
59
+ export function isEmptyCell(value) {
60
+ // U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER, U+200D ZERO
61
+ // WIDTH JOINER, U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM). Written as
62
+ // \u escapes rather than embedded literally: this repository's
63
+ // authored files are pure ASCII (CLAUDE.md convention 3), which a
64
+ // literal zero-width character in source would silently violate.
65
+ return value.replace(/[\u200B\u200C\u200D\uFEFF]/g, "").trim() === "";
66
+ }
67
+ /**
68
+ * A REUSED v8 CONTEXT for every bounded regex execution this module
69
+ * performs (fix round 1, the regex mechanism, CR-991). Created once at
70
+ * module load rather than per call: `vm.createContext` builds a real v8
71
+ * context and doing that once per row per bucket kind measured
72
+ * meaningfully slower in this round's own benchmark than reusing one.
73
+ */
74
+ const regexSandbox = createContext(Object.create(null));
75
+ /**
76
+ * THE VERDICT-PRODUCING BOUND IS CPU WORK, NOT ELAPSED TIME (M4-P28).
77
+ *
78
+ * What this constant replaces, and why the replacement is not a bigger
79
+ * number. Until this phase the bound was `REGEX_EXEC_TIMEOUT_MS = 250`,
80
+ * handed to `vm` as a WALL-CLOCK timeout, and exceeding it was reported as
81
+ * "did not complete within 250ms ... (possible catastrophic
82
+ * backtracking)". Elapsed wall time is complexity DIVIDED BY available
83
+ * CPU, so that condition did not test the property it named: it reddened
84
+ * whenever the machine was busy. Measured and recorded in
85
+ * delivery/verification/wall-clock-budgets-are-load-dependent.md:1, five
86
+ * independent witnesses hit it on six structurally different patterns,
87
+ * including `^(?:parked)$`, a doubly anchored literal with no quantifier,
88
+ * no character class and no alternation, which has nothing to backtrack
89
+ * over at any input length. One million executions of
90
+ * `^(?:R-[0-9]+[a-z]?)$` against `R-094a` take 154.2ms in total, so ONE is
91
+ * roughly 1.6 million times under the old budget; for one of them to
92
+ * exceed it the thread must be descheduled for a quarter of a second,
93
+ * which is a fact about the machine.
94
+ *
95
+ * Raising 250 to a larger number was considered and is REJECTED by the
96
+ * plan (delivery/plan/kernel-plan-m4.md:3784): it keeps the same
97
+ * instrument and only moves the load at which it lies.
98
+ *
99
+ * WHAT IS MEASURED NOW. `process.threadCpuUsage()` reports the CPU time
100
+ * this thread has consumed, in microseconds. Another process being busy
101
+ * does not add to it, because a descheduled thread consumes no CPU while
102
+ * it is not running. The regex runs on THIS thread inside
103
+ * `runInContext`, so the delta across that call is the work the regex
104
+ * did, and nothing else. On an interpreter without
105
+ * `process.threadCpuUsage` (added in Node 22.15; the package floor is 26)
106
+ * the fallback is `process.cpuUsage()`, which is process-wide and
107
+ * therefore an over-estimate, never an under-estimate, so the fallback
108
+ * cannot let a catastrophic pattern through.
109
+ */
110
+ export const REGEX_EXEC_CPU_BUDGET_MS = 250;
111
+ /**
112
+ * HOW LONG ONE ATTEMPT IS WILLING TO WAIT. This is PATIENCE, not a
113
+ * verdict: nothing is ever reported about a pattern because this elapsed.
114
+ * A wall-clock interrupt is still needed, because a catastrophic match
115
+ * never returns on its own and `vm`'s `timeout` is the only mechanism
116
+ * here that can stop one (v8 checks for the termination request during a
117
+ * regex match, not only between statements).
118
+ *
119
+ * It is set to TWICE the CPU budget so that a thread receiving at least
120
+ * half of one CPU reaches the budget inside a single attempt; measured on
121
+ * an unloaded box, `(a+)+b` against 30 a's consumes 230.6ms of CPU in a
122
+ * 252ms wall window, so a backstop equal to the budget would have needed
123
+ * a second attempt to reach a verdict it had nearly earned.
124
+ */
125
+ export const REGEX_EXEC_WALL_BACKSTOP_MS = 500;
126
+ /**
127
+ * How many interrupted attempts before this module admits it cannot
128
+ * reach a verdict. Each attempt doubles its own patience, and the CPU
129
+ * consumed ACCUMULATES across attempts, so a genuinely spinning pattern
130
+ * is still caught on a loaded machine (it burns CPU whenever it runs)
131
+ * while a benign one accumulates microseconds however often it is
132
+ * interrupted.
133
+ */
134
+ export const REGEX_EXEC_MAX_ATTEMPTS = 4;
135
+ /** The pattern did too much WORK. A verdict about the pattern. */
136
+ export class RegexBoundExceededError extends Error {
137
+ }
138
+ /**
139
+ * No verdict was reached: every attempt was interrupted before the
140
+ * pattern finished, and the CPU it consumed never came near the budget,
141
+ * which is the signature of a thread that is not being scheduled rather
142
+ * than one that is spinning. Under M2-C-3 a check that cannot reach a
143
+ * verdict reports ERROR, never a verdict it did not earn, so this is
144
+ * deliberately NOT a subclass of `RegexBoundExceededError`: the two must
145
+ * not be confusable by a `catch` or an `instanceof`.
146
+ */
147
+ export class RegexBudgetUndeterminedError extends Error {
148
+ }
149
+ /** Thread CPU time where the interpreter has it, process CPU otherwise. */
150
+ function cpuSample() {
151
+ const threadReader = process.threadCpuUsage;
152
+ if (typeof threadReader === "function") {
153
+ return threadReader.call(process);
154
+ }
155
+ return process.cpuUsage();
156
+ }
157
+ /** Milliseconds of CPU (user plus system) between two samples. */
158
+ function cpuMillisBetween(before, after) {
159
+ return (after.user - before.user + (after.system - before.system)) / 1000;
160
+ }
161
+ /**
162
+ * Execute `compiled.exec(value)` inside a v8 context, bounded by the CPU
163
+ * WORK it does rather than by the time it takes.
164
+ *
165
+ * One attempt runs the match with a wall-clock interrupt. Whatever
166
+ * happens, the CPU consumed by this thread during that call is added to a
167
+ * running total:
168
+ *
169
+ * - total CPU at or above the budget: the thread really did burn that
170
+ * much CPU on this one match, which no anchored non-backtracking
171
+ * pattern can do, so `RegexBoundExceededError` is thrown. A busy
172
+ * machine cannot cause this, because a descheduled thread accumulates
173
+ * no CPU.
174
+ * - the match finished: return its result, which is the only path that
175
+ * produces a match.
176
+ * - interrupted with the total still far below the budget: the machine
177
+ * was busy, not the pattern. Double the patience and try again.
178
+ *
179
+ * After `maxAttempts` interruptions with the CPU total still under
180
+ * budget, `RegexBudgetUndeterminedError` is thrown, naming the CPU
181
+ * consumed, the wall clock spent and the load average, so the record says
182
+ * what it observed instead of asserting something about the pattern.
183
+ *
184
+ * An error from `runInContext` that is NOT the timeout is also
185
+ * undetermined rather than a finding: the old code funnelled every throw
186
+ * into the catastrophic-backtracking message, which is the same
187
+ * substitution one cause over.
188
+ */
189
+ export function boundedExec(compiled, value, bounds = {}) {
190
+ const cpuBudgetMs = bounds.cpuBudgetMs ?? REGEX_EXEC_CPU_BUDGET_MS;
191
+ const maxAttempts = bounds.maxAttempts ?? REGEX_EXEC_MAX_ATTEMPTS;
192
+ let patienceMs = bounds.wallBackstopMs ?? REGEX_EXEC_WALL_BACKSTOP_MS;
193
+ let cpuSpentMs = 0;
194
+ let wallSpentMs = 0;
195
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
196
+ Object.assign(regexSandbox, { __pattern: compiled, __value: value, __out: undefined });
197
+ const cpuBefore = cpuSample();
198
+ const wallBefore = Date.now();
199
+ let failure;
200
+ let interrupted = false;
201
+ try {
202
+ new Script("__out = __pattern.exec(__value);").runInContext(regexSandbox, {
203
+ timeout: patienceMs,
204
+ });
205
+ }
206
+ catch (error) {
207
+ failure = error;
208
+ interrupted = error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT";
209
+ }
210
+ cpuSpentMs += cpuMillisBetween(cpuBefore, cpuSample());
211
+ wallSpentMs += Date.now() - wallBefore;
212
+ if (cpuSpentMs >= cpuBudgetMs) {
213
+ throw new RegexBoundExceededError(`pattern ${compiled.source} consumed ${cpuSpentMs.toFixed(1)}ms of CPU time ` +
214
+ `(budget ${String(cpuBudgetMs)}ms) against a value of length ${String(value.length)} ` +
215
+ `over ${String(attempt)} attempt(s) spanning ${String(wallSpentMs)}ms of wall clock ` +
216
+ "(catastrophic backtracking)");
217
+ }
218
+ if (failure !== undefined && !interrupted) {
219
+ throw new RegexBudgetUndeterminedError(`pattern ${compiled.source} against a value of length ${String(value.length)} ` +
220
+ `failed to execute: ${String(failure.message ?? failure)}; no verdict ` +
221
+ "about the pattern was reached");
222
+ }
223
+ if (!interrupted) {
224
+ const out = regexSandbox.__out;
225
+ return out ?? null;
226
+ }
227
+ patienceMs *= 2;
228
+ }
229
+ throw new RegexBudgetUndeterminedError(`pattern ${compiled.source} against a value of length ${String(value.length)} was ` +
230
+ `interrupted on all ${String(maxAttempts)} attempts after ${String(wallSpentMs)}ms of wall ` +
231
+ `clock, having consumed only ${cpuSpentMs.toFixed(1)}ms of CPU time against a budget of ` +
232
+ `${String(cpuBudgetMs)}ms; load average ${loadavg()[0]?.toFixed(2) ?? "unknown"} on ` +
233
+ `${String(cpus().length)} cpu(s). No verdict about the pattern was reached: this record ` +
234
+ "reports that the machine was too busy to establish one, not that the pattern is dangerous");
235
+ }
236
+ /**
237
+ * A STATIC heuristic for the single most common catastrophic-backtracking
238
+ * shape (fix round 1, CR-991): a parenthesised group containing an
239
+ * unbounded quantifier (`+` or `*`), itself immediately followed by
240
+ * another unbounded quantifier, e.g. `(a+)+`, `(a*)+`, `(a+)*`. This is
241
+ * exactly the shape the round's own red witness constructs
242
+ * (`(a+)+b`). It is a heuristic, not a proof: it does not see every
243
+ * catastrophic shape (a documented residue below), which is why every
244
+ * EXECUTION is also bounded by `boundedExec` regardless of whether a
245
+ * pattern passes this check.
246
+ */
247
+ export function catastrophicShapeReason(pattern) {
248
+ if (/\([^()]*[+*][^()]*\)[+*]/.test(pattern)) {
249
+ return (`contains a group ending in a repeated + or * immediately followed ` +
250
+ `by another + or * (a known catastrophic-backtracking shape); ` +
251
+ `rewrite it to avoid nested unbounded repetition`);
252
+ }
253
+ return undefined;
254
+ }
255
+ /**
256
+ * Validate one config-supplied pattern SOURCE before it is ever compiled
257
+ * into a live regex the gate will execute (fix round 1, CR-990/991):
258
+ * compiles cleanly, and does not match the static catastrophic-shape
259
+ * heuristic. `label` identifies which config field failed, so a config
260
+ * error names the field, not just "a pattern".
261
+ */
262
+ export function validatePatternSource(label, pattern) {
263
+ try {
264
+ // eslint-disable-next-line no-new
265
+ new RegExp(pattern);
266
+ }
267
+ catch (error) {
268
+ return `${label} ${JSON.stringify(pattern)} is not a valid regular expression: ${error.message}`;
269
+ }
270
+ const shape = catastrophicShapeReason(pattern);
271
+ if (shape !== undefined) {
272
+ return `${label} ${JSON.stringify(pattern)} ${shape}`;
273
+ }
274
+ return undefined;
275
+ }
276
+ /**
277
+ * Validate every config-supplied pattern this module will ever compile
278
+ * and execute: both `idPattern`s and every `bucketKinds[].pattern`. Run
279
+ * once per config, before the config is used, so a malformed or dangerous
280
+ * pattern is a named CONFIG error (a clean result record) rather than an
281
+ * uncaught exception with no record (CR-990) or an unbounded hang
282
+ * (CR-991).
283
+ */
284
+ export function validateConfigPatterns(config) {
285
+ const checks = [
286
+ ["inventory.idPattern", config.inventory.idPattern],
287
+ ["coverageTable.idPattern", config.coverageTable.idPattern],
288
+ ...config.bucketKinds.map((bucketKind, index) => [
289
+ `bucketKinds[${String(index)}] (kind ${bucketKind.kind}).pattern`,
290
+ bucketKind.pattern,
291
+ ]),
292
+ ];
293
+ for (const [label, pattern] of checks) {
294
+ const problem = validatePatternSource(label, pattern);
295
+ if (problem !== undefined) {
296
+ return problem;
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+ /**
302
+ * Walk a document's lines and return every markdown-table row whose first
303
+ * cell matches `idPattern` (compiled anchored). Separator rows
304
+ * (`|---|:--:|---|`) are skipped by construction: every cell in one matches
305
+ * `/^:?-+:?$/`, which cannot also match a realistic id pattern, but the
306
+ * separator test is applied explicitly rather than relied on implicitly, so
307
+ * a future id pattern that could coincide does not silently absorb one.
308
+ * Every match against a config-supplied pattern is BOUNDED (fix round 1,
309
+ * `boundedExec`), so a hostile `idPattern` cannot hang this walk.
310
+ */
311
+ export function extractIdRows(text, idPattern) {
312
+ const anchored = new RegExp(`^(?:${idPattern})$`);
313
+ const rows = [];
314
+ const lines = text.split(/\r?\n/);
315
+ for (let index = 0; index < lines.length; index += 1) {
316
+ const line = (lines[index] ?? "").trim();
317
+ if (!line.startsWith("|") || !line.endsWith("|") || line.length < 2) {
318
+ continue;
319
+ }
320
+ const cells = line
321
+ .slice(1, -1)
322
+ .split("|")
323
+ .map((cell) => cell.trim());
324
+ if (cells.length === 0) {
325
+ continue;
326
+ }
327
+ if (cells.every((cell) => /^:?-+:?$/.test(cell))) {
328
+ continue; // header separator row
329
+ }
330
+ const first = cells[0] ?? "";
331
+ if (boundedExec(anchored, first) !== null) {
332
+ rows.push({ id: first, cells, line: index + 1 });
333
+ }
334
+ }
335
+ return rows;
336
+ }
337
+ /**
338
+ * The core check. Pure: takes the two documents' TEXT (already read through
339
+ * M2-C-6's guarded read at the call site) and the config, and returns a
340
+ * report with nothing hidden inside a status string.
341
+ */
342
+ export function checkCoverage(config, inventoryText, coverageTableText) {
343
+ const inventoryRows = extractIdRows(inventoryText, config.inventory.idPattern);
344
+ const coverageRows = extractIdRows(coverageTableText, config.coverageTable.idPattern);
345
+ // CR-985: THE INVENTORY-SIDE CARDINALITY INVARIANT. `inventoryRows` can
346
+ // carry the same id more than once (the raw text says so); `units` is a
347
+ // count of DISTINCT ids, stated here rather than left implicit, and a
348
+ // repeated id is its own finding, never silently folded into "one more
349
+ // unit". `uniqueInventoryIds` preserves first-occurrence order so the
350
+ // classification loop below processes each distinct id exactly once,
351
+ // the same way the coverage side's `byId` grouping processes each
352
+ // distinct coverage-table id exactly once.
353
+ const inventoryIdOccurrences = new Map();
354
+ for (const row of inventoryRows) {
355
+ inventoryIdOccurrences.set(row.id, (inventoryIdOccurrences.get(row.id) ?? 0) + 1);
356
+ }
357
+ const uniqueInventoryIds = [...inventoryIdOccurrences.keys()];
358
+ const inventorySet = new Set(uniqueInventoryIds);
359
+ const byId = new Map();
360
+ for (const row of coverageRows) {
361
+ const existing = byId.get(row.id);
362
+ if (existing === undefined) {
363
+ byId.set(row.id, [row]);
364
+ }
365
+ else {
366
+ existing.push(row);
367
+ }
368
+ }
369
+ const findings = [];
370
+ const perKind = {};
371
+ const perMilestone = {};
372
+ for (const [id, occurrences] of inventoryIdOccurrences) {
373
+ if (occurrences > 1) {
374
+ findings.push({
375
+ kind: "duplicate-inventory-id",
376
+ id,
377
+ detail: `inventory id ${id} appears ${String(occurrences)} times in the inventory document`,
378
+ });
379
+ // The id is still checked against the coverage table below: a
380
+ // duplicated id can ALSO be an orphan, and both are worth naming.
381
+ }
382
+ const rows = byId.get(id) ?? [];
383
+ if (rows.length === 0) {
384
+ findings.push({
385
+ kind: "orphan",
386
+ id,
387
+ detail: `inventory id ${id} has no row in the coverage table`,
388
+ });
389
+ continue;
390
+ }
391
+ if (rows.length > 1) {
392
+ findings.push({
393
+ kind: "double-bucketed",
394
+ id,
395
+ detail: `inventory id ${id} appears in ${String(rows.length)} coverage-table rows (lines ${rows
396
+ .map((row) => String(row.line))
397
+ .join(", ")})`,
398
+ });
399
+ continue;
400
+ }
401
+ const row = rows[0];
402
+ const bucketValue = row.cells[config.coverageTable.bucketColumn] ?? "";
403
+ const note = row.cells[config.coverageTable.noteColumn] ?? "";
404
+ // CR-992: EVERY declared kind is tested, never only until the first
405
+ // match, so a value matching more than one kind is a named finding
406
+ // rather than a silent first-match-wins (which used to let a second
407
+ // kind's `requiresNote` go uninspected with nothing said).
408
+ const matches = [];
409
+ for (const bucketKind of config.bucketKinds) {
410
+ const compiled = new RegExp(`^(?:${bucketKind.pattern})$`);
411
+ const match = boundedExec(compiled, bucketValue);
412
+ if (match !== null) {
413
+ matches.push({ kind: bucketKind, milestone: match[1] });
414
+ }
415
+ }
416
+ if (matches.length === 0) {
417
+ findings.push({
418
+ kind: "unknown-kind",
419
+ id,
420
+ detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches no declared bucket kind`,
421
+ });
422
+ continue;
423
+ }
424
+ if (matches.length > 1) {
425
+ findings.push({
426
+ kind: "ambiguous-kind",
427
+ id,
428
+ detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches more than one declared bucket kind: ${matches
429
+ .map((entry) => entry.kind.kind)
430
+ .join(", ")}`,
431
+ });
432
+ continue;
433
+ }
434
+ const classified = matches[0];
435
+ // CR-987: ONE shared emptiness predicate, not a bare `=== ""`, so a
436
+ // whitespace-only or zero-width-only note is empty here the same way
437
+ // it is empty in `checkFindingOutcomeParity` below.
438
+ if (classified.kind.requiresNote && isEmptyCell(note)) {
439
+ findings.push({
440
+ kind: "missing-note",
441
+ id,
442
+ detail: `${classified.kind.kind} row for id ${id} (line ${String(row.line)}) has an empty note`,
443
+ });
444
+ continue;
445
+ }
446
+ const kindName = classified.kind.kind;
447
+ perKind[kindName] = (perKind[kindName] ?? 0) + 1;
448
+ const milestoneKey = classified.milestone === undefined ? kindName : `M${classified.milestone}`;
449
+ perMilestone[milestoneKey] = (perMilestone[milestoneKey] ?? 0) + 1;
450
+ }
451
+ for (const row of coverageRows) {
452
+ if (!inventorySet.has(row.id)) {
453
+ findings.push({
454
+ kind: "phantom",
455
+ id: row.id,
456
+ detail: `coverage-table row ${row.id} (line ${String(row.line)}) has no matching inventory id`,
457
+ });
458
+ }
459
+ }
460
+ const totalInventoryIds = uniqueInventoryIds.length;
461
+ // CR-986: THE EXPECTED-UNITS FLOOR. A row deleted from BOTH documents
462
+ // leaves them agreeing with each other, so orphan, phantom and
463
+ // duplicate-inventory-id are all silent about it; only a config-stated
464
+ // expectation can catch a computed total that is smaller (or larger)
465
+ // than the number of ids this pair is supposed to carry.
466
+ if (config.expectedUnits !== undefined && totalInventoryIds !== config.expectedUnits) {
467
+ findings.push({
468
+ kind: "expected-units-mismatch",
469
+ id: "(total)",
470
+ detail: `expected ${String(config.expectedUnits)} distinct inventory id(s) but found ${String(totalInventoryIds)}`,
471
+ });
472
+ }
473
+ return {
474
+ totalInventoryIds,
475
+ perKind,
476
+ perMilestone,
477
+ findings,
478
+ };
479
+ }
480
+ export function checkFindingOutcomeParity(inventoryIds, findings) {
481
+ const inventorySet = new Set(inventoryIds);
482
+ const byId = new Map();
483
+ for (const finding of findings) {
484
+ const existing = byId.get(finding.id);
485
+ if (existing === undefined) {
486
+ byId.set(finding.id, [finding]);
487
+ }
488
+ else {
489
+ existing.push(finding);
490
+ }
491
+ }
492
+ const missing = [];
493
+ const duplicated = [];
494
+ const empty = [];
495
+ const phantom = [];
496
+ for (const id of inventoryIds) {
497
+ const rows = byId.get(id) ?? [];
498
+ if (rows.length === 0) {
499
+ missing.push(id);
500
+ continue;
501
+ }
502
+ if (rows.length > 1) {
503
+ duplicated.push(id);
504
+ continue;
505
+ }
506
+ // CR-987: the shared predicate, not a bare `=== ""` (a whitespace-only
507
+ // or zero-width-only outcome used to read as a non-empty say).
508
+ if (isEmptyCell(rows[0].outcome)) {
509
+ empty.push(id);
510
+ }
511
+ }
512
+ // CR-988: THE OTHER DIRECTION. `inventoryIds` alone cannot see a report
513
+ // row for an id that no longer exists; that requires scanning the
514
+ // REPORT'S OWN ids against the inventory, the same shape as
515
+ // `checkCoverage`'s phantom scan over coverage-table rows.
516
+ for (const id of byId.keys()) {
517
+ if (!inventorySet.has(id)) {
518
+ phantom.push(id);
519
+ }
520
+ }
521
+ return {
522
+ ok: missing.length === 0 &&
523
+ duplicated.length === 0 &&
524
+ empty.length === 0 &&
525
+ phantom.length === 0,
526
+ checked: inventoryIds.length,
527
+ missing,
528
+ duplicated,
529
+ empty,
530
+ phantom,
531
+ };
532
+ }
533
+ /* -------------------------------------------------------------------- */
534
+ /* The CLI entry: `node src/gates/coverage.ts --result <path> --evidence */
535
+ /* <dir> [--config <path>]`, the gate subprocess contract src/gates/run.ts */
536
+ /* documents. Registered in gates.manifest.json with no declared */
537
+ /* parameters, so the runner's invocation is exactly these two flags. */
538
+ /* -------------------------------------------------------------------- */
539
+ const schemaUrl = new URL("./schemas/coverage-config.schema.json", import.meta.url);
540
+ let cachedConfigSchema;
541
+ function configSchema() {
542
+ if (cachedConfigSchema !== undefined) {
543
+ return { ok: true, schema: cachedConfigSchema };
544
+ }
545
+ const path = fileURLToPath(schemaUrl);
546
+ const read = readRegularFileIfPresent(path);
547
+ if (read.kind !== "read") {
548
+ return {
549
+ ok: false,
550
+ loadError: read.kind === "absent"
551
+ ? `schema document ${path} is missing from this installation`
552
+ : read.reason,
553
+ };
554
+ }
555
+ let parsed;
556
+ try {
557
+ parsed = JSON.parse(read.body);
558
+ }
559
+ catch (error) {
560
+ return { ok: false, loadError: `${path} does not parse as JSON: ${error.message}` };
561
+ }
562
+ const loaded = loadSchema(parsed, path);
563
+ if (!loaded.ok) {
564
+ return { ok: false, loadError: loaded.reason };
565
+ }
566
+ cachedConfigSchema = loaded.schema;
567
+ return { ok: true, schema: cachedConfigSchema };
568
+ }
569
+ const VALUE_FLAGS = ["--result", "--evidence", "--config"];
570
+ function parseFlags(args) {
571
+ const flags = {};
572
+ for (let index = 0; index < args.length; index += 1) {
573
+ const flag = args[index];
574
+ const value = args[index + 1];
575
+ if (flag === undefined || !VALUE_FLAGS.includes(flag)) {
576
+ return undefined;
577
+ }
578
+ if (value === undefined || value.startsWith("--")) {
579
+ return undefined;
580
+ }
581
+ if (flag === "--result") {
582
+ flags.result = value;
583
+ }
584
+ else if (flag === "--evidence") {
585
+ flags.evidence = value;
586
+ }
587
+ else {
588
+ flags.config = value;
589
+ }
590
+ index += 1;
591
+ }
592
+ return flags;
593
+ }
594
+ /** Load and validate a `--config` document, or fall back to the kernel config. */
595
+ function resolveConfig(configPath) {
596
+ if (configPath === undefined) {
597
+ return { ok: true, config: KERNEL_COVERAGE_CONFIG };
598
+ }
599
+ const read = readRegularFileIfPresent(configPath);
600
+ if (read.kind !== "read") {
601
+ return {
602
+ ok: false,
603
+ reason: read.kind === "absent"
604
+ ? `config ${configPath} does not exist`
605
+ : read.reason,
606
+ };
607
+ }
608
+ let parsed;
609
+ try {
610
+ parsed = JSON.parse(read.body);
611
+ }
612
+ catch (error) {
613
+ return {
614
+ ok: false,
615
+ reason: `config ${configPath} does not parse as JSON: ${error.message}`,
616
+ };
617
+ }
618
+ const schema = configSchema();
619
+ if (!schema.ok) {
620
+ return { ok: false, reason: schema.loadError };
621
+ }
622
+ const diagnostics = validate(schema.schema, parsed);
623
+ if (diagnostics.length > 0) {
624
+ return {
625
+ ok: false,
626
+ reason: `config ${configPath} is not a valid coverage config: ${diagnostics
627
+ .map((diagnostic) => `INVALID ${diagnostic.pointer} ${diagnostic.message}`)
628
+ .join("; ")}`,
629
+ };
630
+ }
631
+ return { ok: true, config: parsed };
632
+ }
633
+ function readConfiguredDocument(label, path) {
634
+ const read = readRegularFileIfPresent(path);
635
+ if (read.kind === "read") {
636
+ return { ok: true, text: read.body };
637
+ }
638
+ return {
639
+ ok: false,
640
+ reason: read.kind === "absent"
641
+ ? `${label} ${path} does not exist`
642
+ : read.reason,
643
+ };
644
+ }
645
+ function formatCounts(counts) {
646
+ const keys = Object.keys(counts).sort();
647
+ if (keys.length === 0) {
648
+ return "(none)";
649
+ }
650
+ return keys.map((key) => `${key} ${String(counts[key])}`).join(", ");
651
+ }
652
+ export function main(argv) {
653
+ const flags = parseFlags(argv);
654
+ const startedAt = new Date().toISOString();
655
+ if (flags === undefined || flags.result === undefined) {
656
+ process.stderr.write("usage: node src/gates/coverage.ts --result <file> --evidence <dir> [--config <file>]\n");
657
+ return 64;
658
+ }
659
+ const resolvedConfig = resolveConfig(flags.config);
660
+ if (!resolvedConfig.ok) {
661
+ return emit(flags.result, {
662
+ status: "error",
663
+ units: 0,
664
+ startedAt,
665
+ detail: resolvedConfig.reason,
666
+ evidence: [],
667
+ });
668
+ }
669
+ const config = resolvedConfig.config;
670
+ // CR-990/991, THE REGEX MECHANISM, VALIDATE HALF. Every config-supplied
671
+ // pattern is checked BEFORE it is compiled into a live regex anywhere
672
+ // else in this run: a malformed pattern used to throw out of
673
+ // `extractIdRows` and escape all the way to the top-level handler,
674
+ // which writes NO result record (CR-990); a syntactically valid but
675
+ // catastrophic pattern used to hang (CR-991). Both are now a named
676
+ // config error with a clean record, never an uncaught exception.
677
+ const patternProblem = validateConfigPatterns(config);
678
+ if (patternProblem !== undefined) {
679
+ return emit(flags.result, {
680
+ status: "error",
681
+ units: 0,
682
+ startedAt,
683
+ detail: `invalid config: ${patternProblem}`,
684
+ evidence: [],
685
+ });
686
+ }
687
+ const inventory = readConfiguredDocument("inventory", config.inventory.path);
688
+ if (!inventory.ok) {
689
+ return emit(flags.result, {
690
+ status: "error",
691
+ units: 0,
692
+ startedAt,
693
+ detail: inventory.reason,
694
+ evidence: [],
695
+ });
696
+ }
697
+ const coverageTable = readConfiguredDocument("coverage table", config.coverageTable.path);
698
+ if (!coverageTable.ok) {
699
+ return emit(flags.result, {
700
+ status: "error",
701
+ units: 0,
702
+ startedAt,
703
+ detail: coverageTable.reason,
704
+ evidence: [],
705
+ });
706
+ }
707
+ // CR-990/991, THE REGEX MECHANISM, BOUND HALF (defense in depth). The
708
+ // static validation above rejects the shapes `catastrophicShapeReason`
709
+ // recognises; this catches anything an exception escapes with anyway
710
+ // (a `RegexBoundExceededError` from `boundedExec`, or any other throw),
711
+ // converting it into a normal error record instead of letting it reach
712
+ // the top-level handler with no record written, which is the general
713
+ // form of CR-990's finding rather than only its regex instance.
714
+ let report;
715
+ try {
716
+ report = checkCoverage(config, inventory.text, coverageTable.text);
717
+ }
718
+ catch (error) {
719
+ return emit(flags.result, {
720
+ status: "error",
721
+ units: 0,
722
+ startedAt,
723
+ detail: `coverage check failed: ${singleLine(error.message ?? String(error))}`,
724
+ evidence: [],
725
+ });
726
+ }
727
+ // M2-C-2: an empty inventory is reported GREEN with zero units so that
728
+ // the shared constructor's rewrite (never duplicated here) turns it into
729
+ // `error` with `vacuous: true`, rather than reporting whatever the
730
+ // (vacuous) findings pass happened to compute.
731
+ const status = report.totalInventoryIds === 0
732
+ ? "green"
733
+ : report.findings.length > 0
734
+ ? "red"
735
+ : "green";
736
+ // CR-989: THE EVIDENCE-SIDE REFUSAL IS NOW HONOURED, not computed and
737
+ // discarded. A FIFO or an absent parent directory at the evidence path
738
+ // used to leave `refusal` and `written.reason` unread and report GREEN
739
+ // with empty evidence; this is now the same M2-C-6 discipline the
740
+ // result path already had (loud, and the gate reports `error`), applied
741
+ // to the write side rather than only the read side.
742
+ const evidenceFiles = [];
743
+ if (flags.evidence !== undefined) {
744
+ const countsPath = join(flags.evidence, "counts.json");
745
+ const refusal = refuseOpenForWrite(countsPath);
746
+ if (refusal !== undefined) {
747
+ return emit(flags.result, {
748
+ status: "error",
749
+ units: report.totalInventoryIds,
750
+ startedAt,
751
+ detail: `evidence write refused: ${refusal}`,
752
+ evidence: [],
753
+ });
754
+ }
755
+ const written = runStep(`writing ${countsPath}`, () => writeFileSync(countsPath, `${JSON.stringify({
756
+ totalInventoryIds: report.totalInventoryIds,
757
+ perKind: report.perKind,
758
+ perMilestone: report.perMilestone,
759
+ findings: report.findings,
760
+ }, null, 2)}\n`));
761
+ if (!written.ok) {
762
+ return emit(flags.result, {
763
+ status: "error",
764
+ units: report.totalInventoryIds,
765
+ startedAt,
766
+ detail: `evidence write failed: ${written.reason}`,
767
+ evidence: [],
768
+ });
769
+ }
770
+ evidenceFiles.push("counts.json");
771
+ }
772
+ const detail = report.findings.length > 0
773
+ ? `${String(report.findings.length)} finding(s): ${report.findings
774
+ .map((finding) => `${finding.kind} ${finding.id}`)
775
+ .join("; ")}`
776
+ : `${String(report.totalInventoryIds)} inventory id(s) checked; ` +
777
+ `per-kind: ${formatCounts(report.perKind)}; ` +
778
+ `per-milestone: ${formatCounts(report.perMilestone)}`;
779
+ return emit(flags.result, {
780
+ status,
781
+ units: report.totalInventoryIds,
782
+ startedAt,
783
+ detail,
784
+ evidence: evidenceFiles,
785
+ });
786
+ }
787
+ function emit(resultPath, fields) {
788
+ const result = makeGateResult({
789
+ gate: "coverage",
790
+ status: fields.status,
791
+ units: fields.units,
792
+ unitLabel: "finding ids checked",
793
+ startedAt: fields.startedAt,
794
+ endedAt: new Date().toISOString(),
795
+ detail: fields.detail,
796
+ evidence: fields.evidence,
797
+ });
798
+ const refusal = refuseOpenForWrite(resultPath);
799
+ if (refusal !== undefined) {
800
+ process.stderr.write(`tiphys coverage: ${refusal}\n`);
801
+ return EXIT_GATE_ERROR;
802
+ }
803
+ const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
804
+ if (!written.ok) {
805
+ process.stderr.write(`tiphys coverage: ${written.reason}\n`);
806
+ return EXIT_GATE_ERROR;
807
+ }
808
+ process.stdout.write(`coverage: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
809
+ if (result.detail !== "") {
810
+ process.stdout.write(`${result.detail}\n`);
811
+ }
812
+ return exitCodeForStatus(result.status);
813
+ }
814
+ /**
815
+ * Run only when invoked directly (`node src/gates/coverage.ts ...`), never
816
+ * on import: `test/coverage-gate.test.ts` imports this module's pure
817
+ * functions through the computed-URL pattern and must not trigger a CLI
818
+ * run as a side effect of that import.
819
+ */
820
+ // IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). Measured:
821
+ // through a symlinked directory in the invocation path, or through a
822
+ // symlink to this file, argv[1] carries the caller's spelling while
823
+ // import.meta.url carries the canonical one, so the bare comparison is
824
+ // false and this gate silently does nothing and exits 0. That is a guard
825
+ // that cannot go red (T-008), and six sibling gates already compare by
826
+ // identity.
827
+ if (process.argv[1] !== undefined &&
828
+ pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1])) {
829
+ try {
830
+ process.exitCode = main(process.argv.slice(2));
831
+ }
832
+ catch (error) {
833
+ process.stderr.write(`tiphys coverage: ${singleLine(error.message ?? String(error))}\n`);
834
+ process.exitCode = EXIT_GATE_ERROR;
835
+ }
836
+ }