@tiphys/kernel 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,701 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Script, createContext } from "node:vm";
5
+ import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
6
+ import { loadSchema, validate } from "./validate.js";
7
+ import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
8
+ /**
9
+ * THE KERNEL CONFIG (kernel plan M2-P6 step 2): this repository's real
10
+ * pair and the four bucket kinds the plan states verbatim. It is a plain
11
+ * exported constant, not a checked-in data file, because M2-C-1's
12
+ * verification-first reading of the plan's files-to-touch list for this
13
+ * phase names the module, its schema and its test, and no committed
14
+ * configuration document alongside them; a `--config` flag (validated
15
+ * against `coverage-config.schema.json`) exists for a caller that wants a
16
+ * different pair, and every fixture test in `test/coverage-gate.test.ts`
17
+ * uses it rather than editing this constant.
18
+ */
19
+ export const KERNEL_COVERAGE_CONFIG = {
20
+ inventory: {
21
+ path: "delivery/requirements/migration-table.md",
22
+ idPattern: "R-[0-9]+[a-z]?",
23
+ },
24
+ coverageTable: {
25
+ path: "delivery/plan/kernel-plan-v1.md",
26
+ idPattern: "R-[0-9]+[a-z]?",
27
+ bucketColumn: 1,
28
+ noteColumn: 2,
29
+ },
30
+ bucketKinds: [
31
+ { kind: "phase", pattern: "M([0-9]+)-P[0-9]+", requiresNote: false },
32
+ { kind: "milestone", pattern: "M([0-9]+)", requiresNote: false },
33
+ {
34
+ kind: "decision",
35
+ pattern: "DR-[0-9]{4}|D-[0-9]+|M2-D-[0-9]+",
36
+ requiresNote: false,
37
+ },
38
+ { kind: "parked", pattern: "parked", requiresNote: true },
39
+ ],
40
+ expectedUnits: 115,
41
+ };
42
+ /**
43
+ * THE SHARED EMPTINESS PREDICATE (fix round 1, CR-987).
44
+ *
45
+ * Before this round, `checkCoverage`'s note check compared a value already
46
+ * passed through `String.prototype.trim` (applied once, at extraction) to
47
+ * `""`, and `checkFindingOutcomeParity`'s outcome check compared a value
48
+ * that was never trimmed at all to `""`. Two definitions, only one of
49
+ * which trimmed, and NEITHER strips a zero-width character: `trim()`
50
+ * removes Unicode whitespace (which includes U+00A0 and U+FEFF) but not
51
+ * U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER or U+200D ZERO
52
+ * WIDTH JOINER, so a cell or an outcome containing only one of those reads
53
+ * as non-empty to a bare `=== ""` comparison, trimmed or not. One
54
+ * predicate, called at every point this module asks "is this empty",
55
+ * closes both instances at once.
56
+ */
57
+ export function isEmptyCell(value) {
58
+ // U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER, U+200D ZERO
59
+ // WIDTH JOINER, U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM). Written as
60
+ // \u escapes rather than embedded literally: this repository's
61
+ // authored files are pure ASCII (CLAUDE.md convention 3), which a
62
+ // literal zero-width character in source would silently violate.
63
+ return value.replace(/[\u200B\u200C\u200D\uFEFF]/g, "").trim() === "";
64
+ }
65
+ /**
66
+ * A REUSED v8 CONTEXT for every bounded regex execution this module
67
+ * performs (fix round 1, the regex mechanism, CR-991). Created once at
68
+ * module load rather than per call: `vm.createContext` builds a real v8
69
+ * context and doing that once per row per bucket kind measured
70
+ * meaningfully slower in this round's own benchmark than reusing one.
71
+ */
72
+ const regexSandbox = createContext(Object.create(null));
73
+ /** Wall-clock bound on one regex execution. Measured (this round): a real
74
+ * catastrophic pattern, `(a+)+b` against 30 a's, which never returns on
75
+ * its own, is interrupted within 251-267ms under this mechanism; a safe
76
+ * pattern executes in under a millisecond, so the bound is not on the
77
+ * critical path for any pattern this repository's own config uses. */
78
+ export const REGEX_EXEC_TIMEOUT_MS = 250;
79
+ export class RegexBoundExceededError extends Error {
80
+ }
81
+ /**
82
+ * Execute `compiled.exec(value)` inside a v8 context with a wall-clock
83
+ * timeout. `node:vm`'s `timeout` option interrupts synchronous JavaScript
84
+ * execution, INCLUDING regex backtracking (v8 checks for the termination
85
+ * request during a regex match, not only between statements), which is
86
+ * why this bound can stop a hung `.exec()` where a plain try/catch around
87
+ * a synchronous call cannot: a catastrophic match never throws on its
88
+ * own, it simply never returns. Measured directly (this round): the same
89
+ * `(a+)+b` pattern against inputs of length 18 through 40 completes in
90
+ * under 40ms up to length 22, then 63ms, 302ms, and is interrupted at the
91
+ * 250ms bound from length 26 onward, rather than running to the multi-
92
+ * second and then multi-minute times the unbounded engine produces at
93
+ * length 26 and 40.
94
+ */
95
+ export function boundedExec(compiled, value) {
96
+ Object.assign(regexSandbox, { __pattern: compiled, __value: value, __out: undefined });
97
+ try {
98
+ new Script("__out = __pattern.exec(__value);").runInContext(regexSandbox, {
99
+ timeout: REGEX_EXEC_TIMEOUT_MS,
100
+ });
101
+ }
102
+ catch {
103
+ throw new RegexBoundExceededError(`pattern ${compiled.source} did not complete within ${String(REGEX_EXEC_TIMEOUT_MS)}ms ` +
104
+ `against a value of length ${String(value.length)} (possible catastrophic backtracking)`);
105
+ }
106
+ const out = regexSandbox.__out;
107
+ return out ?? null;
108
+ }
109
+ /**
110
+ * A STATIC heuristic for the single most common catastrophic-backtracking
111
+ * shape (fix round 1, CR-991): a parenthesised group containing an
112
+ * unbounded quantifier (`+` or `*`), itself immediately followed by
113
+ * another unbounded quantifier, e.g. `(a+)+`, `(a*)+`, `(a+)*`. This is
114
+ * exactly the shape the round's own red witness constructs
115
+ * (`(a+)+b`). It is a heuristic, not a proof: it does not see every
116
+ * catastrophic shape (a documented residue below), which is why every
117
+ * EXECUTION is also bounded by `boundedExec` regardless of whether a
118
+ * pattern passes this check.
119
+ */
120
+ export function catastrophicShapeReason(pattern) {
121
+ if (/\([^()]*[+*][^()]*\)[+*]/.test(pattern)) {
122
+ return (`contains a group ending in a repeated + or * immediately followed ` +
123
+ `by another + or * (a known catastrophic-backtracking shape); ` +
124
+ `rewrite it to avoid nested unbounded repetition`);
125
+ }
126
+ return undefined;
127
+ }
128
+ /**
129
+ * Validate one config-supplied pattern SOURCE before it is ever compiled
130
+ * into a live regex the gate will execute (fix round 1, CR-990/991):
131
+ * compiles cleanly, and does not match the static catastrophic-shape
132
+ * heuristic. `label` identifies which config field failed, so a config
133
+ * error names the field, not just "a pattern".
134
+ */
135
+ export function validatePatternSource(label, pattern) {
136
+ try {
137
+ // eslint-disable-next-line no-new
138
+ new RegExp(pattern);
139
+ }
140
+ catch (error) {
141
+ return `${label} ${JSON.stringify(pattern)} is not a valid regular expression: ${error.message}`;
142
+ }
143
+ const shape = catastrophicShapeReason(pattern);
144
+ if (shape !== undefined) {
145
+ return `${label} ${JSON.stringify(pattern)} ${shape}`;
146
+ }
147
+ return undefined;
148
+ }
149
+ /**
150
+ * Validate every config-supplied pattern this module will ever compile
151
+ * and execute: both `idPattern`s and every `bucketKinds[].pattern`. Run
152
+ * once per config, before the config is used, so a malformed or dangerous
153
+ * pattern is a named CONFIG error (a clean result record) rather than an
154
+ * uncaught exception with no record (CR-990) or an unbounded hang
155
+ * (CR-991).
156
+ */
157
+ export function validateConfigPatterns(config) {
158
+ const checks = [
159
+ ["inventory.idPattern", config.inventory.idPattern],
160
+ ["coverageTable.idPattern", config.coverageTable.idPattern],
161
+ ...config.bucketKinds.map((bucketKind, index) => [
162
+ `bucketKinds[${String(index)}] (kind ${bucketKind.kind}).pattern`,
163
+ bucketKind.pattern,
164
+ ]),
165
+ ];
166
+ for (const [label, pattern] of checks) {
167
+ const problem = validatePatternSource(label, pattern);
168
+ if (problem !== undefined) {
169
+ return problem;
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+ /**
175
+ * Walk a document's lines and return every markdown-table row whose first
176
+ * cell matches `idPattern` (compiled anchored). Separator rows
177
+ * (`|---|:--:|---|`) are skipped by construction: every cell in one matches
178
+ * `/^:?-+:?$/`, which cannot also match a realistic id pattern, but the
179
+ * separator test is applied explicitly rather than relied on implicitly, so
180
+ * a future id pattern that could coincide does not silently absorb one.
181
+ * Every match against a config-supplied pattern is BOUNDED (fix round 1,
182
+ * `boundedExec`), so a hostile `idPattern` cannot hang this walk.
183
+ */
184
+ export function extractIdRows(text, idPattern) {
185
+ const anchored = new RegExp(`^(?:${idPattern})$`);
186
+ const rows = [];
187
+ const lines = text.split(/\r?\n/);
188
+ for (let index = 0; index < lines.length; index += 1) {
189
+ const line = (lines[index] ?? "").trim();
190
+ if (!line.startsWith("|") || !line.endsWith("|") || line.length < 2) {
191
+ continue;
192
+ }
193
+ const cells = line
194
+ .slice(1, -1)
195
+ .split("|")
196
+ .map((cell) => cell.trim());
197
+ if (cells.length === 0) {
198
+ continue;
199
+ }
200
+ if (cells.every((cell) => /^:?-+:?$/.test(cell))) {
201
+ continue; // header separator row
202
+ }
203
+ const first = cells[0] ?? "";
204
+ if (boundedExec(anchored, first) !== null) {
205
+ rows.push({ id: first, cells, line: index + 1 });
206
+ }
207
+ }
208
+ return rows;
209
+ }
210
+ /**
211
+ * The core check. Pure: takes the two documents' TEXT (already read through
212
+ * M2-C-6's guarded read at the call site) and the config, and returns a
213
+ * report with nothing hidden inside a status string.
214
+ */
215
+ export function checkCoverage(config, inventoryText, coverageTableText) {
216
+ const inventoryRows = extractIdRows(inventoryText, config.inventory.idPattern);
217
+ const coverageRows = extractIdRows(coverageTableText, config.coverageTable.idPattern);
218
+ // CR-985: THE INVENTORY-SIDE CARDINALITY INVARIANT. `inventoryRows` can
219
+ // carry the same id more than once (the raw text says so); `units` is a
220
+ // count of DISTINCT ids, stated here rather than left implicit, and a
221
+ // repeated id is its own finding, never silently folded into "one more
222
+ // unit". `uniqueInventoryIds` preserves first-occurrence order so the
223
+ // classification loop below processes each distinct id exactly once,
224
+ // the same way the coverage side's `byId` grouping processes each
225
+ // distinct coverage-table id exactly once.
226
+ const inventoryIdOccurrences = new Map();
227
+ for (const row of inventoryRows) {
228
+ inventoryIdOccurrences.set(row.id, (inventoryIdOccurrences.get(row.id) ?? 0) + 1);
229
+ }
230
+ const uniqueInventoryIds = [...inventoryIdOccurrences.keys()];
231
+ const inventorySet = new Set(uniqueInventoryIds);
232
+ const byId = new Map();
233
+ for (const row of coverageRows) {
234
+ const existing = byId.get(row.id);
235
+ if (existing === undefined) {
236
+ byId.set(row.id, [row]);
237
+ }
238
+ else {
239
+ existing.push(row);
240
+ }
241
+ }
242
+ const findings = [];
243
+ const perKind = {};
244
+ const perMilestone = {};
245
+ for (const [id, occurrences] of inventoryIdOccurrences) {
246
+ if (occurrences > 1) {
247
+ findings.push({
248
+ kind: "duplicate-inventory-id",
249
+ id,
250
+ detail: `inventory id ${id} appears ${String(occurrences)} times in the inventory document`,
251
+ });
252
+ // The id is still checked against the coverage table below: a
253
+ // duplicated id can ALSO be an orphan, and both are worth naming.
254
+ }
255
+ const rows = byId.get(id) ?? [];
256
+ if (rows.length === 0) {
257
+ findings.push({
258
+ kind: "orphan",
259
+ id,
260
+ detail: `inventory id ${id} has no row in the coverage table`,
261
+ });
262
+ continue;
263
+ }
264
+ if (rows.length > 1) {
265
+ findings.push({
266
+ kind: "double-bucketed",
267
+ id,
268
+ detail: `inventory id ${id} appears in ${String(rows.length)} coverage-table rows (lines ${rows
269
+ .map((row) => String(row.line))
270
+ .join(", ")})`,
271
+ });
272
+ continue;
273
+ }
274
+ const row = rows[0];
275
+ const bucketValue = row.cells[config.coverageTable.bucketColumn] ?? "";
276
+ const note = row.cells[config.coverageTable.noteColumn] ?? "";
277
+ // CR-992: EVERY declared kind is tested, never only until the first
278
+ // match, so a value matching more than one kind is a named finding
279
+ // rather than a silent first-match-wins (which used to let a second
280
+ // kind's `requiresNote` go uninspected with nothing said).
281
+ const matches = [];
282
+ for (const bucketKind of config.bucketKinds) {
283
+ const compiled = new RegExp(`^(?:${bucketKind.pattern})$`);
284
+ const match = boundedExec(compiled, bucketValue);
285
+ if (match !== null) {
286
+ matches.push({ kind: bucketKind, milestone: match[1] });
287
+ }
288
+ }
289
+ if (matches.length === 0) {
290
+ findings.push({
291
+ kind: "unknown-kind",
292
+ id,
293
+ detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches no declared bucket kind`,
294
+ });
295
+ continue;
296
+ }
297
+ if (matches.length > 1) {
298
+ findings.push({
299
+ kind: "ambiguous-kind",
300
+ id,
301
+ detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches more than one declared bucket kind: ${matches
302
+ .map((entry) => entry.kind.kind)
303
+ .join(", ")}`,
304
+ });
305
+ continue;
306
+ }
307
+ const classified = matches[0];
308
+ // CR-987: ONE shared emptiness predicate, not a bare `=== ""`, so a
309
+ // whitespace-only or zero-width-only note is empty here the same way
310
+ // it is empty in `checkFindingOutcomeParity` below.
311
+ if (classified.kind.requiresNote && isEmptyCell(note)) {
312
+ findings.push({
313
+ kind: "missing-note",
314
+ id,
315
+ detail: `${classified.kind.kind} row for id ${id} (line ${String(row.line)}) has an empty note`,
316
+ });
317
+ continue;
318
+ }
319
+ const kindName = classified.kind.kind;
320
+ perKind[kindName] = (perKind[kindName] ?? 0) + 1;
321
+ const milestoneKey = classified.milestone === undefined ? kindName : `M${classified.milestone}`;
322
+ perMilestone[milestoneKey] = (perMilestone[milestoneKey] ?? 0) + 1;
323
+ }
324
+ for (const row of coverageRows) {
325
+ if (!inventorySet.has(row.id)) {
326
+ findings.push({
327
+ kind: "phantom",
328
+ id: row.id,
329
+ detail: `coverage-table row ${row.id} (line ${String(row.line)}) has no matching inventory id`,
330
+ });
331
+ }
332
+ }
333
+ const totalInventoryIds = uniqueInventoryIds.length;
334
+ // CR-986: THE EXPECTED-UNITS FLOOR. A row deleted from BOTH documents
335
+ // leaves them agreeing with each other, so orphan, phantom and
336
+ // duplicate-inventory-id are all silent about it; only a config-stated
337
+ // expectation can catch a computed total that is smaller (or larger)
338
+ // than the number of ids this pair is supposed to carry.
339
+ if (config.expectedUnits !== undefined && totalInventoryIds !== config.expectedUnits) {
340
+ findings.push({
341
+ kind: "expected-units-mismatch",
342
+ id: "(total)",
343
+ detail: `expected ${String(config.expectedUnits)} distinct inventory id(s) but found ${String(totalInventoryIds)}`,
344
+ });
345
+ }
346
+ return {
347
+ totalInventoryIds,
348
+ perKind,
349
+ perMilestone,
350
+ findings,
351
+ };
352
+ }
353
+ export function checkFindingOutcomeParity(inventoryIds, findings) {
354
+ const inventorySet = new Set(inventoryIds);
355
+ const byId = new Map();
356
+ for (const finding of findings) {
357
+ const existing = byId.get(finding.id);
358
+ if (existing === undefined) {
359
+ byId.set(finding.id, [finding]);
360
+ }
361
+ else {
362
+ existing.push(finding);
363
+ }
364
+ }
365
+ const missing = [];
366
+ const duplicated = [];
367
+ const empty = [];
368
+ const phantom = [];
369
+ for (const id of inventoryIds) {
370
+ const rows = byId.get(id) ?? [];
371
+ if (rows.length === 0) {
372
+ missing.push(id);
373
+ continue;
374
+ }
375
+ if (rows.length > 1) {
376
+ duplicated.push(id);
377
+ continue;
378
+ }
379
+ // CR-987: the shared predicate, not a bare `=== ""` (a whitespace-only
380
+ // or zero-width-only outcome used to read as a non-empty say).
381
+ if (isEmptyCell(rows[0].outcome)) {
382
+ empty.push(id);
383
+ }
384
+ }
385
+ // CR-988: THE OTHER DIRECTION. `inventoryIds` alone cannot see a report
386
+ // row for an id that no longer exists; that requires scanning the
387
+ // REPORT'S OWN ids against the inventory, the same shape as
388
+ // `checkCoverage`'s phantom scan over coverage-table rows.
389
+ for (const id of byId.keys()) {
390
+ if (!inventorySet.has(id)) {
391
+ phantom.push(id);
392
+ }
393
+ }
394
+ return {
395
+ ok: missing.length === 0 &&
396
+ duplicated.length === 0 &&
397
+ empty.length === 0 &&
398
+ phantom.length === 0,
399
+ checked: inventoryIds.length,
400
+ missing,
401
+ duplicated,
402
+ empty,
403
+ phantom,
404
+ };
405
+ }
406
+ /* -------------------------------------------------------------------- */
407
+ /* The CLI entry: `node src/gates/coverage.ts --result <path> --evidence */
408
+ /* <dir> [--config <path>]`, the gate subprocess contract src/gates/run.ts */
409
+ /* documents. Registered in gates.manifest.json with no declared */
410
+ /* parameters, so the runner's invocation is exactly these two flags. */
411
+ /* -------------------------------------------------------------------- */
412
+ const schemaUrl = new URL("./schemas/coverage-config.schema.json", import.meta.url);
413
+ let cachedConfigSchema;
414
+ function configSchema() {
415
+ if (cachedConfigSchema !== undefined) {
416
+ return { ok: true, schema: cachedConfigSchema };
417
+ }
418
+ const path = fileURLToPath(schemaUrl);
419
+ const read = readRegularFileIfPresent(path);
420
+ if (read.kind !== "read") {
421
+ return {
422
+ ok: false,
423
+ loadError: read.kind === "absent"
424
+ ? `schema document ${path} is missing from this installation`
425
+ : read.reason,
426
+ };
427
+ }
428
+ let parsed;
429
+ try {
430
+ parsed = JSON.parse(read.body);
431
+ }
432
+ catch (error) {
433
+ return { ok: false, loadError: `${path} does not parse as JSON: ${error.message}` };
434
+ }
435
+ const loaded = loadSchema(parsed, path);
436
+ if (!loaded.ok) {
437
+ return { ok: false, loadError: loaded.reason };
438
+ }
439
+ cachedConfigSchema = loaded.schema;
440
+ return { ok: true, schema: cachedConfigSchema };
441
+ }
442
+ const VALUE_FLAGS = ["--result", "--evidence", "--config"];
443
+ function parseFlags(args) {
444
+ const flags = {};
445
+ for (let index = 0; index < args.length; index += 1) {
446
+ const flag = args[index];
447
+ const value = args[index + 1];
448
+ if (flag === undefined || !VALUE_FLAGS.includes(flag)) {
449
+ return undefined;
450
+ }
451
+ if (value === undefined || value.startsWith("--")) {
452
+ return undefined;
453
+ }
454
+ if (flag === "--result") {
455
+ flags.result = value;
456
+ }
457
+ else if (flag === "--evidence") {
458
+ flags.evidence = value;
459
+ }
460
+ else {
461
+ flags.config = value;
462
+ }
463
+ index += 1;
464
+ }
465
+ return flags;
466
+ }
467
+ /** Load and validate a `--config` document, or fall back to the kernel config. */
468
+ function resolveConfig(configPath) {
469
+ if (configPath === undefined) {
470
+ return { ok: true, config: KERNEL_COVERAGE_CONFIG };
471
+ }
472
+ const read = readRegularFileIfPresent(configPath);
473
+ if (read.kind !== "read") {
474
+ return {
475
+ ok: false,
476
+ reason: read.kind === "absent"
477
+ ? `config ${configPath} does not exist`
478
+ : read.reason,
479
+ };
480
+ }
481
+ let parsed;
482
+ try {
483
+ parsed = JSON.parse(read.body);
484
+ }
485
+ catch (error) {
486
+ return {
487
+ ok: false,
488
+ reason: `config ${configPath} does not parse as JSON: ${error.message}`,
489
+ };
490
+ }
491
+ const schema = configSchema();
492
+ if (!schema.ok) {
493
+ return { ok: false, reason: schema.loadError };
494
+ }
495
+ const diagnostics = validate(schema.schema, parsed);
496
+ if (diagnostics.length > 0) {
497
+ return {
498
+ ok: false,
499
+ reason: `config ${configPath} is not a valid coverage config: ${diagnostics
500
+ .map((diagnostic) => `INVALID ${diagnostic.pointer} ${diagnostic.message}`)
501
+ .join("; ")}`,
502
+ };
503
+ }
504
+ return { ok: true, config: parsed };
505
+ }
506
+ function readConfiguredDocument(label, path) {
507
+ const read = readRegularFileIfPresent(path);
508
+ if (read.kind === "read") {
509
+ return { ok: true, text: read.body };
510
+ }
511
+ return {
512
+ ok: false,
513
+ reason: read.kind === "absent"
514
+ ? `${label} ${path} does not exist`
515
+ : read.reason,
516
+ };
517
+ }
518
+ function formatCounts(counts) {
519
+ const keys = Object.keys(counts).sort();
520
+ if (keys.length === 0) {
521
+ return "(none)";
522
+ }
523
+ return keys.map((key) => `${key} ${String(counts[key])}`).join(", ");
524
+ }
525
+ export function main(argv) {
526
+ const flags = parseFlags(argv);
527
+ const startedAt = new Date().toISOString();
528
+ if (flags === undefined || flags.result === undefined) {
529
+ process.stderr.write("usage: node src/gates/coverage.ts --result <file> --evidence <dir> [--config <file>]\n");
530
+ return 64;
531
+ }
532
+ const resolvedConfig = resolveConfig(flags.config);
533
+ if (!resolvedConfig.ok) {
534
+ return emit(flags.result, {
535
+ status: "error",
536
+ units: 0,
537
+ startedAt,
538
+ detail: resolvedConfig.reason,
539
+ evidence: [],
540
+ });
541
+ }
542
+ const config = resolvedConfig.config;
543
+ // CR-990/991, THE REGEX MECHANISM, VALIDATE HALF. Every config-supplied
544
+ // pattern is checked BEFORE it is compiled into a live regex anywhere
545
+ // else in this run: a malformed pattern used to throw out of
546
+ // `extractIdRows` and escape all the way to the top-level handler,
547
+ // which writes NO result record (CR-990); a syntactically valid but
548
+ // catastrophic pattern used to hang (CR-991). Both are now a named
549
+ // config error with a clean record, never an uncaught exception.
550
+ const patternProblem = validateConfigPatterns(config);
551
+ if (patternProblem !== undefined) {
552
+ return emit(flags.result, {
553
+ status: "error",
554
+ units: 0,
555
+ startedAt,
556
+ detail: `invalid config: ${patternProblem}`,
557
+ evidence: [],
558
+ });
559
+ }
560
+ const inventory = readConfiguredDocument("inventory", config.inventory.path);
561
+ if (!inventory.ok) {
562
+ return emit(flags.result, {
563
+ status: "error",
564
+ units: 0,
565
+ startedAt,
566
+ detail: inventory.reason,
567
+ evidence: [],
568
+ });
569
+ }
570
+ const coverageTable = readConfiguredDocument("coverage table", config.coverageTable.path);
571
+ if (!coverageTable.ok) {
572
+ return emit(flags.result, {
573
+ status: "error",
574
+ units: 0,
575
+ startedAt,
576
+ detail: coverageTable.reason,
577
+ evidence: [],
578
+ });
579
+ }
580
+ // CR-990/991, THE REGEX MECHANISM, BOUND HALF (defense in depth). The
581
+ // static validation above rejects the shapes `catastrophicShapeReason`
582
+ // recognises; this catches anything an exception escapes with anyway
583
+ // (a `RegexBoundExceededError` from `boundedExec`, or any other throw),
584
+ // converting it into a normal error record instead of letting it reach
585
+ // the top-level handler with no record written, which is the general
586
+ // form of CR-990's finding rather than only its regex instance.
587
+ let report;
588
+ try {
589
+ report = checkCoverage(config, inventory.text, coverageTable.text);
590
+ }
591
+ catch (error) {
592
+ return emit(flags.result, {
593
+ status: "error",
594
+ units: 0,
595
+ startedAt,
596
+ detail: `coverage check failed: ${singleLine(error.message ?? String(error))}`,
597
+ evidence: [],
598
+ });
599
+ }
600
+ // M2-C-2: an empty inventory is reported GREEN with zero units so that
601
+ // the shared constructor's rewrite (never duplicated here) turns it into
602
+ // `error` with `vacuous: true`, rather than reporting whatever the
603
+ // (vacuous) findings pass happened to compute.
604
+ const status = report.totalInventoryIds === 0
605
+ ? "green"
606
+ : report.findings.length > 0
607
+ ? "red"
608
+ : "green";
609
+ // CR-989: THE EVIDENCE-SIDE REFUSAL IS NOW HONOURED, not computed and
610
+ // discarded. A FIFO or an absent parent directory at the evidence path
611
+ // used to leave `refusal` and `written.reason` unread and report GREEN
612
+ // with empty evidence; this is now the same M2-C-6 discipline the
613
+ // result path already had (loud, and the gate reports `error`), applied
614
+ // to the write side rather than only the read side.
615
+ const evidenceFiles = [];
616
+ if (flags.evidence !== undefined) {
617
+ const countsPath = join(flags.evidence, "counts.json");
618
+ const refusal = refuseOpenForWrite(countsPath);
619
+ if (refusal !== undefined) {
620
+ return emit(flags.result, {
621
+ status: "error",
622
+ units: report.totalInventoryIds,
623
+ startedAt,
624
+ detail: `evidence write refused: ${refusal}`,
625
+ evidence: [],
626
+ });
627
+ }
628
+ const written = runStep(`writing ${countsPath}`, () => writeFileSync(countsPath, `${JSON.stringify({
629
+ totalInventoryIds: report.totalInventoryIds,
630
+ perKind: report.perKind,
631
+ perMilestone: report.perMilestone,
632
+ findings: report.findings,
633
+ }, null, 2)}\n`));
634
+ if (!written.ok) {
635
+ return emit(flags.result, {
636
+ status: "error",
637
+ units: report.totalInventoryIds,
638
+ startedAt,
639
+ detail: `evidence write failed: ${written.reason}`,
640
+ evidence: [],
641
+ });
642
+ }
643
+ evidenceFiles.push("counts.json");
644
+ }
645
+ const detail = report.findings.length > 0
646
+ ? `${String(report.findings.length)} finding(s): ${report.findings
647
+ .map((finding) => `${finding.kind} ${finding.id}`)
648
+ .join("; ")}`
649
+ : `${String(report.totalInventoryIds)} inventory id(s) checked; ` +
650
+ `per-kind: ${formatCounts(report.perKind)}; ` +
651
+ `per-milestone: ${formatCounts(report.perMilestone)}`;
652
+ return emit(flags.result, {
653
+ status,
654
+ units: report.totalInventoryIds,
655
+ startedAt,
656
+ detail,
657
+ evidence: evidenceFiles,
658
+ });
659
+ }
660
+ function emit(resultPath, fields) {
661
+ const result = makeGateResult({
662
+ gate: "coverage",
663
+ status: fields.status,
664
+ units: fields.units,
665
+ unitLabel: "finding ids checked",
666
+ startedAt: fields.startedAt,
667
+ endedAt: new Date().toISOString(),
668
+ detail: fields.detail,
669
+ evidence: fields.evidence,
670
+ });
671
+ const refusal = refuseOpenForWrite(resultPath);
672
+ if (refusal !== undefined) {
673
+ process.stderr.write(`tiphys coverage: ${refusal}\n`);
674
+ return EXIT_GATE_ERROR;
675
+ }
676
+ const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
677
+ if (!written.ok) {
678
+ process.stderr.write(`tiphys coverage: ${written.reason}\n`);
679
+ return EXIT_GATE_ERROR;
680
+ }
681
+ process.stdout.write(`coverage: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
682
+ if (result.detail !== "") {
683
+ process.stdout.write(`${result.detail}\n`);
684
+ }
685
+ return exitCodeForStatus(result.status);
686
+ }
687
+ /**
688
+ * Run only when invoked directly (`node src/gates/coverage.ts ...`), never
689
+ * on import: `test/coverage-gate.test.ts` imports this module's pure
690
+ * functions through the computed-URL pattern and must not trigger a CLI
691
+ * run as a side effect of that import.
692
+ */
693
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
694
+ try {
695
+ process.exitCode = main(process.argv.slice(2));
696
+ }
697
+ catch (error) {
698
+ process.stderr.write(`tiphys coverage: ${singleLine(error.message ?? String(error))}\n`);
699
+ process.exitCode = EXIT_GATE_ERROR;
700
+ }
701
+ }