@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,968 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { pathsIdentifySameObject, pathsNameSameObject } from "../path-identity.js";
6
+ import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
7
+ import { comparePins, describePinDifference, takePin } from "./pin.js";
8
+ import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
9
+ /**
10
+ * THE SUITE GATE (kernel plan M2, M2-P3).
11
+ *
12
+ * Replaces "the suite is green" with a machine-countable claim: the suite
13
+ * ran as configured, every discovered test file was reported, every
14
+ * registered behavior resolves to a reported test, no behavior registered
15
+ * at the merge base has been deleted, every skip carries a reason, the
16
+ * tree did not change under the run (M2-C-5 pin), and the counts come from
17
+ * a pinned structured stream plus the child's exit code and from nowhere
18
+ * else (C-1).
19
+ *
20
+ * HOW THE SUITE IS RUN. The repository's own `package.json` scripts.test
21
+ * is executed VERBATIM through `/bin/sh -c`, exactly as npm runs it. The
22
+ * gate never parses or reconstructs the script: deciding what another
23
+ * program will do by pattern-matching the text of a file it consumes is
24
+ * the mechanism MECHANISMS.md records four fix rounds for. What npm adds
25
+ * beyond sh -c (node_modules/.bin on PATH, lifecycle hooks) is not
26
+ * reproduced; a test script that depends on either fails LOUDLY here
27
+ * (the reporter stream never appears, which is `error`), never silently.
28
+ *
29
+ * THE REPORTER IS A PINNED, REQUESTED INPUT (M2-P3 step 2, MECHANISMS.md
30
+ * "Parsing another program's reporter output"). Measured on both installed
31
+ * toolchains (v22.22.2 and v26.6.0, work history m2-p3 step 1):
32
+ *
33
+ * - tap carries NO file attribution for passing tests, so it cannot
34
+ * support discovery parity or name the missing file;
35
+ * - junit loses file attribution on nested tests and DROPS raw test
36
+ * output, so a counterfeit line cannot be captured verbatim from it;
37
+ * - the documented custom-reporter event stream carries file, name,
38
+ * nesting, skip (with reason), todo, entity type and failureType for
39
+ * every test on both toolchains, and delivers raw test output as
40
+ * test:stdout/test:stderr events whose payload is a JSON string.
41
+ *
42
+ * So the gate pins `tiphys-suite-events-v1`: an NDJSON stream emitted by
43
+ * the reporter module below, embedded as a data: URL so the reporter
44
+ * cannot drift apart from the parser that consumes it. It is REQUESTED
45
+ * explicitly for the child via child-scoped NODE_OPTIONS; the inherited
46
+ * NODE_OPTIONS is deliberately dropped, because an inherited reporter
47
+ * option is precisely the ambient default the pin exists to exclude. The
48
+ * received stream is validated to be this format BEFORE any count is
49
+ * parsed: header first, every line a known event, explicit stream-end
50
+ * trailer last. A stream in any other format, or truncated, is `error`
51
+ * naming the expected and observed formats. Widening the parse to accept
52
+ * whichever format arrived is forbidden.
53
+ *
54
+ * WHY A COUNTERFEIT LINE CANNOT COUNT (C-1, criterion 6). A test body
55
+ * printing "pass 999", or even a byte-exact tiphys event line, arrives as
56
+ * a test:stdout EVENT whose text sits INSIDE a JSON string field, escaped
57
+ * by the reporter's own JSON.stringify. It cannot form an event line, so
58
+ * it cannot reach the arithmetic; it is captured verbatim in the evidence
59
+ * instead. The measured tap contrast is in the work history: there the
60
+ * same bytes land as `# pass 999`, byte-identical in grammar to the real
61
+ * `# pass 3` summary line below it.
62
+ *
63
+ * DISCOVERY PARITY (step 3, PR-106): candidates are enumerated by WALKING
64
+ * the declared test roots for the declared suffix, never by expanding the
65
+ * runner's own selection pattern, because the pattern is the thing that
66
+ * can be wrong and an enumeration sharing it cannot see that. Both
67
+ * directions are enforced: a walked file absent from the reporter and a
68
+ * reported file outside the walk are each parity failures naming the file.
69
+ *
70
+ * M2-C-6: every path this gate reads that it did not create goes through
71
+ * the delivered classifyEntry / readRegularFileIfPresent /
72
+ * refuseOpenForWrite from src/task.ts. The walk probes types before
73
+ * reading names' targets and throws on anything irregular, so a named
74
+ * pipe inside a declared root is `error` naming the path and type, never
75
+ * a hang.
76
+ *
77
+ * CHILD ENVIRONMENT, deterministic by construction: NODE_OPTIONS is
78
+ * replaced (see above) and NODE_TEST_CONTEXT/NODE_TEST_* are removed,
79
+ * because a nested `node --test` that inherits them switches to its
80
+ * child-of-a-runner protocol and reports nothing usable (measured by
81
+ * M1-P6; see test/exit-test-local.test.ts identityLessEnv).
82
+ */
83
+ export const SUITE_GATE_ID = "suite";
84
+ export const SUITE_UNIT_LABEL = "tests reported";
85
+ export const REPORTER_NAME = "tiphys-suite-events-v1";
86
+ export const STREAM_FILE = "suite-events.ndjson";
87
+ export const STDOUT_FILE = "suite-stdout.txt";
88
+ export const STDERR_FILE = "suite-stderr.txt";
89
+ export const RAW_OUTPUT_FILE = "suite-raw-output.txt";
90
+ export const COUNTS_FILE = "counts.json";
91
+ /**
92
+ * The reporter module, verbatim. Pure ESM with no imports, so it can be
93
+ * carried as a data: URL. It emits one JSON object per line: a header
94
+ * naming the format version and the node that ran the suite, one event
95
+ * per test:pass / test:fail / test:stdout / test:stderr, and a stream-end
96
+ * trailer whose absence is how a truncated stream is recognized. Every
97
+ * other event type (test:diagnostic carries the human summary lines this
98
+ * gate is forbidden to read, C-1) is deliberately not emitted.
99
+ *
100
+ * No apostrophes, backticks or spaces-in-tokens beyond what
101
+ * encodeURIComponent escapes: the encoded URL must survive NODE_OPTIONS
102
+ * space-splitting.
103
+ */
104
+ const REPORTER_SOURCE = "export default async function* tiphysSuiteEventsV1(source) {\n" +
105
+ ' yield JSON.stringify({ tiphysSuiteEvents: 1, node: process.version }) + "\\n";\n' +
106
+ " for await (const event of source) {\n" +
107
+ " const type = event.type;\n" +
108
+ ' if (type === "test:pass" || type === "test:fail") {\n' +
109
+ " const data = event.data === undefined ? {} : event.data;\n" +
110
+ " const details = data.details === undefined ? {} : data.details;\n" +
111
+ " const error = details.error;\n" +
112
+ " yield JSON.stringify({\n" +
113
+ " event: type,\n" +
114
+ " name: data.name,\n" +
115
+ " file: data.file,\n" +
116
+ " nesting: data.nesting,\n" +
117
+ " skip: data.skip,\n" +
118
+ " todo: data.todo,\n" +
119
+ " entityType: details.type,\n" +
120
+ " failureType: error === undefined || error === null ? undefined : error.failureType,\n" +
121
+ ' }) + "\\n";\n' +
122
+ ' } else if (type === "test:stdout" || type === "test:stderr") {\n' +
123
+ " const data = event.data === undefined ? {} : event.data;\n" +
124
+ ' yield JSON.stringify({ event: type, file: data.file, message: data.message }) + "\\n";\n' +
125
+ " }\n" +
126
+ " }\n" +
127
+ ' yield JSON.stringify({ event: "stream-end" }) + "\\n";\n' +
128
+ "}\n";
129
+ export function reporterDataUrl() {
130
+ return `data:text/javascript,${encodeURIComponent(REPORTER_SOURCE)}`;
131
+ }
132
+ const KNOWN_EVENTS = new Set([
133
+ "test:pass",
134
+ "test:fail",
135
+ "test:stdout",
136
+ "test:stderr",
137
+ "stream-end",
138
+ ]);
139
+ function describeObserved(line) {
140
+ const shown = singleLine(line).slice(0, 120);
141
+ return shown === "" ? "an empty line" : `"${shown}"`;
142
+ }
143
+ /**
144
+ * Validate that `body` is a tiphys-suite-events-v1 stream and decode it.
145
+ * The format is checked BEFORE any count is derived; a stream in another
146
+ * format, however valid in its own grammar, is a refusal naming both
147
+ * formats, never a widened parse.
148
+ */
149
+ export function parseSuiteStream(body) {
150
+ const failure = (reason) => ({ ok: false, reason });
151
+ if (body === "") {
152
+ return failure(`expected the pinned reporter format ${REPORTER_NAME}; observed an empty stream`);
153
+ }
154
+ const lines = body.split("\n");
155
+ if (lines[lines.length - 1] === "") {
156
+ lines.pop();
157
+ }
158
+ const first = lines[0] ?? "";
159
+ let header;
160
+ try {
161
+ header = JSON.parse(first);
162
+ }
163
+ catch {
164
+ return failure(`expected the pinned reporter format ${REPORTER_NAME} (header line); observed ${describeObserved(first)}`);
165
+ }
166
+ const headerObject = header;
167
+ if (typeof header !== "object" ||
168
+ header === null ||
169
+ Array.isArray(header) ||
170
+ headerObject.tiphysSuiteEvents !== 1 ||
171
+ typeof headerObject.node !== "string") {
172
+ return failure(`expected the pinned reporter format ${REPORTER_NAME} (header line); observed ${describeObserved(first)}`);
173
+ }
174
+ const points = [];
175
+ let rawOutput = "";
176
+ let ended = false;
177
+ for (let index = 1; index < lines.length; index += 1) {
178
+ const line = lines[index];
179
+ if (ended) {
180
+ return failure(`stream line ${String(index + 1)} follows the stream-end trailer: ${describeObserved(line)}`);
181
+ }
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(line);
185
+ }
186
+ catch {
187
+ return failure(`stream line ${String(index + 1)} is not a ${REPORTER_NAME} event: ${describeObserved(line)}`);
188
+ }
189
+ const event = parsed;
190
+ if (typeof parsed !== "object" ||
191
+ parsed === null ||
192
+ Array.isArray(parsed) ||
193
+ typeof event.event !== "string" ||
194
+ !KNOWN_EVENTS.has(event.event)) {
195
+ return failure(`stream line ${String(index + 1)} is not a ${REPORTER_NAME} event: ${describeObserved(line)}`);
196
+ }
197
+ if (event.event === "stream-end") {
198
+ ended = true;
199
+ continue;
200
+ }
201
+ if (event.event === "test:stdout" || event.event === "test:stderr") {
202
+ if (typeof event.message !== "string") {
203
+ return failure(`stream line ${String(index + 1)} is a ${event.event} event without a string message`);
204
+ }
205
+ rawOutput += event.message;
206
+ continue;
207
+ }
208
+ const pointEvent = event.event === "test:pass" ? "test:pass" : "test:fail";
209
+ if (typeof event.name !== "string" ||
210
+ typeof event.file !== "string" ||
211
+ typeof event.nesting !== "number" ||
212
+ (event.entityType !== "test" && event.entityType !== "suite")) {
213
+ return failure(`stream line ${String(index + 1)} is a ${event.event} event missing name, file, nesting or entityType`);
214
+ }
215
+ const point = {
216
+ event: pointEvent,
217
+ name: event.name,
218
+ file: event.file,
219
+ nesting: event.nesting,
220
+ entityType: event.entityType,
221
+ };
222
+ if (typeof event.skip === "boolean" || typeof event.skip === "string") {
223
+ point.skip = event.skip;
224
+ }
225
+ if (typeof event.todo === "boolean" || typeof event.todo === "string") {
226
+ point.todo = event.todo;
227
+ }
228
+ if (typeof event.failureType === "string") {
229
+ point.failureType = event.failureType;
230
+ }
231
+ points.push(point);
232
+ }
233
+ if (!ended) {
234
+ return failure(`the ${REPORTER_NAME} stream is truncated: the stream-end trailer is absent, so the run cannot be` +
235
+ " known to have reported every test");
236
+ }
237
+ return {
238
+ ok: true,
239
+ stream: { childNode: headerObject.node, points, rawOutput },
240
+ };
241
+ }
242
+ /**
243
+ * THE MAPPING, recorded here once and restated in every counts.json
244
+ * (M2-P3 step 6, M2R-022). Buckets are mutually exclusive and each
245
+ * reported test lands in exactly one:
246
+ *
247
+ * skipped the point carries a skip marker (reason or bare)
248
+ * todo not skipped, carries a todo marker
249
+ * didNotRun test:fail with failureType cancelledByParent; this is the
250
+ * one shape node itself counted under "# cancelled" in the
251
+ * same measured run (work history step 1 finding 7). Every
252
+ * other failureType, including testTimeoutFailure, ran and
253
+ * failed, which is `fail`.
254
+ * fail any other test:fail
255
+ * pass any other test:pass
256
+ *
257
+ * `reported` counts points whose entityType is `test`, which reproduces
258
+ * node's own "# tests" semantics for every REAL test (measured: suites
259
+ * are not tests). This does NOT hold for a file that defines zero tests:
260
+ * node still emits one nesting-0 test:pass for it, entityType `test`,
261
+ * named after the file's own invocation path (CR-1306, fixed below by
262
+ * `isFileWrapperPhantom`; a prior version of this comment asserted the
263
+ * false generalization "file wrappers emit no pass/fail"). The identity
264
+ * pass + fail + skipped + todo + didNotRun == reported is asserted, and
265
+ * `discovered` is the independent walk's file set, tied to `reported` by
266
+ * the file-set equality check rather than by unit-mixing arithmetic.
267
+ */
268
+ export const MAPPING_STATEMENT = "pass, fail and skipped map directly; cancelledByParent carries did-not-run; " +
269
+ "todo is recorded and counted as its own bucket; discovered is the independent " +
270
+ "walk of the declared roots for the declared suffix, never the reporter's total; " +
271
+ "identity: pass + fail + skipped + todo + did-not-run == reported";
272
+ /**
273
+ * CR-1306: a `.test.ts` file that defines ZERO tests is not silent. Node
274
+ * itself (measured on v22.22.2 and v26.6.0, both directions of the glob
275
+ * the repo's own test script uses) still emits exactly one nesting-0
276
+ * test:pass point for such a file, entityType `test`, whose `name` is the
277
+ * file's own path exactly AS IT WAS INVOKED. Counting that point as
278
+ * `entityType === "test"` like every other reported point (the mechanism
279
+ * `bucketPoints` and the discovery/registry filters below all shared)
280
+ * inflates `units` by one per emptied file, and because `units > 0` the
281
+ * M2-C-2 "never green by omission" rewrite in result.ts never triggers:
282
+ * a suite that ran zero real tests would report green.
283
+ *
284
+ * A real top-level test is ALSO nesting 0 and entityType `test` (measured:
285
+ * `describe()` wrappers are entityType `suite` and never collide), so
286
+ * nesting and entityType alone cannot distinguish the phantom from a real
287
+ * test. The one further fact that does, and the only one node offers, is
288
+ * the point's own name coinciding exactly with its file's identity; a real
289
+ * test can only produce that collision by deliberately naming itself after
290
+ * its own file, which this function accepts as the residual, documented
291
+ * non-coverage (see delivery/work-history/m2-p3.md fix round one
292
+ * derivation).
293
+ *
294
+ * CR-1410-1 (fix round two): the string this coincidence is compared
295
+ * against MUST be spelling-invariant, not one particular spelling. Round
296
+ * one compared `point.name === relative(cwd, point.file)`, which is a
297
+ * comparison between two DIFFERENT strings whenever the invocation spells
298
+ * the path other than relative-to-cwd; node names the phantom by the
299
+ * file's path exactly as invoked (measured, both toolchains, fix round two
300
+ * derivation in delivery/work-history/m2-p3.md step "fix round two"):
301
+ * relative glob, bare auto-discovery and a `./`-prefixed path all name it
302
+ * relatively, but an ABSOLUTE-path invocation names it absolutely, and
303
+ * `relative(cwd, point.file)` is always relative, so the comparison misses
304
+ * that spelling and the phantom is counted as a real test again -- the
305
+ * exact CR-1306 defect through a different spelling, and the gate cannot
306
+ * control the spelling because it reads the target repo's `scripts.test`
307
+ * verbatim (see the file header). The fix compares two REPRESENTATIONS OF
308
+ * THE SAME FILE instead of two spellings of a path: `resolve(cwd,
309
+ * point.name)` normalizes whatever spelling `name` carries (relative,
310
+ * `./`-prefixed, or already absolute -- `resolve` returns an absolute
311
+ * argument unchanged, measured) into the same absolute string `point.file`
312
+ * already is, so the equality is invariant across every spelling node
313
+ * produces it in, by construction rather than by enumeration.
314
+ *
315
+ * The fix filters the phantom out before ANY of the three counting sites
316
+ * that read `entityType === "test"` (bucketPoints, the discovery-parity
317
+ * reportedFiles set, and the registry-resolution reportedTestNames set) so
318
+ * an emptied file is not silently miscounted as a passing test; it instead
319
+ * falls out of `reportedFiles`, which the existing discovery-parity check
320
+ * (step 3, unchanged) already reports as "test file discovered by the walk
321
+ * but absent from the reporter" -- a red finding, never a counted green.
322
+ */
323
+ export function isFileWrapperPhantom(point, cwd) {
324
+ return (point.entityType === "test" &&
325
+ point.nesting === 0 &&
326
+ // IDENTITY, NOT STRING EQUALITY, and the paragraph above claims more
327
+ // than `===` delivers. "Invariant across every spelling node produces
328
+ // it in, by construction" is false for one spelling: `resolve` does not
329
+ // resolve SYMLINKS, and node reports the CANONICAL path in its
330
+ // reporter's `file` field whatever spelling it was invoked with.
331
+ // Measured 2026-09-16 on node v26.6.0: invoked as
332
+ // <link>/test/a.test.js, `data.file` came back as <real>/test/a.test.js,
333
+ // so `resolve(cwd, point.name)` and `point.file` were two strings for
334
+ // one file and the phantom was counted as a real test again, which is
335
+ // the CR-1306 defect through the one spelling the enumeration missed.
336
+ // The string comparison is kept and tried first (it answers without
337
+ // touching the filesystem and is the common case); the identity check
338
+ // only ever turns a false "different" into a true "same".
339
+ pathsNameSameObject(resolve(cwd, point.name), point.file));
340
+ }
341
+ export function bucketPoints(points) {
342
+ const counts = {
343
+ reported: 0,
344
+ pass: 0,
345
+ fail: 0,
346
+ skipped: 0,
347
+ todo: 0,
348
+ didNotRun: 0,
349
+ };
350
+ const skipsWithoutReason = [];
351
+ const failures = [];
352
+ const cancelled = [];
353
+ for (const point of points) {
354
+ if (point.entityType !== "test") {
355
+ continue;
356
+ }
357
+ counts.reported += 1;
358
+ const skipped = point.skip !== undefined && point.skip !== false;
359
+ const todo = point.todo !== undefined && point.todo !== false;
360
+ if (skipped) {
361
+ counts.skipped += 1;
362
+ const reason = typeof point.skip === "string" ? point.skip.trim() : "";
363
+ if (reason === "") {
364
+ skipsWithoutReason.push(point);
365
+ }
366
+ continue;
367
+ }
368
+ if (todo) {
369
+ counts.todo += 1;
370
+ continue;
371
+ }
372
+ if (point.event === "test:fail") {
373
+ if (point.failureType === "cancelledByParent") {
374
+ counts.didNotRun += 1;
375
+ cancelled.push(point);
376
+ }
377
+ else {
378
+ counts.fail += 1;
379
+ failures.push(point);
380
+ }
381
+ continue;
382
+ }
383
+ counts.pass += 1;
384
+ }
385
+ return { counts, skipsWithoutReason, failures, cancelled };
386
+ }
387
+ /**
388
+ * Walk one declared root for files carrying the declared suffix. The walk
389
+ * never expands the runner's selection pattern; the roots and suffix are
390
+ * the declaration, and the walk is exhaustive under them. Fail closed on
391
+ * anything that is not a regular file or a real directory (M2-C-6): a
392
+ * named pipe inside a test root makes the gate `error`, never a hang.
393
+ */
394
+ export function walkTestFiles(root, suffix) {
395
+ const found = [];
396
+ const rootEntry = classifyEntry(root);
397
+ if (rootEntry.kind === "absent" || rootEntry.kind === "dangling") {
398
+ throw new Error(`declared test root ${root} does not exist`);
399
+ }
400
+ if (rootEntry.kind === "regular") {
401
+ throw new Error(`declared test root ${root} is a regular file, not a directory`);
402
+ }
403
+ if (rootEntry.kind === "unexaminable") {
404
+ throw new Error(rootEntry.reason);
405
+ }
406
+ // "irregular" covers real directories along with everything else that is
407
+ // not a regular file; only a REAL directory (never a symlink to one, the
408
+ // same rule pin.ts walks by) may be walked.
409
+ if (!lstatSync(root).isDirectory()) {
410
+ throw new Error(`${rootEntry.reason}; a declared test root must be a real directory`);
411
+ }
412
+ const walk = (dir) => {
413
+ const names = readdirSync(dir).sort();
414
+ for (const name of names) {
415
+ const path = join(dir, name);
416
+ let isDirectory = false;
417
+ try {
418
+ isDirectory = lstatSync(path).isDirectory();
419
+ }
420
+ catch {
421
+ isDirectory = false;
422
+ }
423
+ if (isDirectory) {
424
+ walk(path);
425
+ continue;
426
+ }
427
+ const entry = classifyEntry(path);
428
+ if (entry.kind === "absent" || entry.kind === "dangling") {
429
+ continue;
430
+ }
431
+ if (entry.kind !== "regular") {
432
+ throw new Error(`${entry.reason}; refusing to walk ${root}`);
433
+ }
434
+ if (name.endsWith(suffix)) {
435
+ found.push(path);
436
+ }
437
+ }
438
+ };
439
+ walk(root);
440
+ return found.sort();
441
+ }
442
+ const USAGE = "usage: node src/gates/suite.ts --result <file> --evidence <dir> --base <ref> " +
443
+ "[--head <ref>] [--test-root <dir>]... [--pin-root <dir>]... " +
444
+ "[--suffix <s>] [--registry <path>]";
445
+ function parseFlags(args) {
446
+ const flags = {
447
+ registry: "test/behaviors.json",
448
+ testRoots: [],
449
+ pinRoots: [],
450
+ suffix: ".test.ts",
451
+ };
452
+ for (let index = 0; index < args.length; index += 1) {
453
+ const flag = args[index];
454
+ const value = args[index + 1];
455
+ if (flag === undefined ||
456
+ ![
457
+ "--result",
458
+ "--evidence",
459
+ "--base",
460
+ "--head",
461
+ "--test-root",
462
+ "--pin-root",
463
+ "--suffix",
464
+ "--registry",
465
+ ].includes(flag)) {
466
+ return `unknown flag ${flag ?? ""}`;
467
+ }
468
+ if (value === undefined || value.startsWith("--")) {
469
+ return `${flag} requires a value`;
470
+ }
471
+ if (flag === "--result") {
472
+ flags.result = value;
473
+ }
474
+ else if (flag === "--evidence") {
475
+ flags.evidence = value;
476
+ }
477
+ else if (flag === "--base") {
478
+ flags.base = value;
479
+ }
480
+ else if (flag === "--head") {
481
+ flags.head = value;
482
+ }
483
+ else if (flag === "--test-root") {
484
+ flags.testRoots.push(value);
485
+ }
486
+ else if (flag === "--pin-root") {
487
+ flags.pinRoots.push(value);
488
+ }
489
+ else if (flag === "--suffix") {
490
+ flags.suffix = value;
491
+ }
492
+ else {
493
+ flags.registry = value;
494
+ }
495
+ index += 1;
496
+ }
497
+ if (flags.testRoots.length === 0) {
498
+ flags.testRoots.push("test");
499
+ }
500
+ if (flags.pinRoots.length === 0) {
501
+ flags.pinRoots.push("src", "test");
502
+ }
503
+ return flags;
504
+ }
505
+ function now() {
506
+ return new Date().toISOString();
507
+ }
508
+ function git(cwd, args) {
509
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
510
+ if (result.error !== undefined) {
511
+ return {
512
+ ok: false,
513
+ reason: `git ${args.join(" ")} could not be run: ${singleLine(String(result.error))}`,
514
+ };
515
+ }
516
+ if (result.status !== 0) {
517
+ return {
518
+ ok: false,
519
+ reason: `git ${args.join(" ")} exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
520
+ };
521
+ }
522
+ return { ok: true, stdout: result.stdout ?? "" };
523
+ }
524
+ function parseRegistry(body, what) {
525
+ let parsed;
526
+ try {
527
+ parsed = JSON.parse(body);
528
+ }
529
+ catch (error) {
530
+ return {
531
+ ok: false,
532
+ reason: `${what} does not parse as JSON: ${error.message}`,
533
+ };
534
+ }
535
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
536
+ return { ok: false, reason: `${what} is not a JSON object` };
537
+ }
538
+ const registry = {};
539
+ for (const [key, value] of Object.entries(parsed)) {
540
+ if (typeof value !== "string") {
541
+ return {
542
+ ok: false,
543
+ reason: `${what} entry ${key} is not a string description`,
544
+ };
545
+ }
546
+ registry[key] = value;
547
+ }
548
+ return { ok: true, registry };
549
+ }
550
+ /** NODE_OPTIONS values survive space-splitting only for these characters. */
551
+ const SAFE_PATH = /^[A-Za-z0-9._/-]+$/;
552
+ export function runSuiteGate(argv) {
553
+ const flags = parseFlags(argv);
554
+ if (typeof flags === "string") {
555
+ process.stderr.write(`tiphys suite gate: ${flags}\n${USAGE}\n`);
556
+ return 64;
557
+ }
558
+ if (flags.result === undefined || flags.evidence === undefined) {
559
+ process.stderr.write(`tiphys suite gate: --result and --evidence are required\n${USAGE}\n`);
560
+ return 64;
561
+ }
562
+ const resultPath = resolve(flags.result);
563
+ const evidenceDir = resolve(flags.evidence);
564
+ const startedAt = now();
565
+ const cwd = process.cwd();
566
+ const emit = (fields) => {
567
+ const result = makeGateResult({
568
+ gate: SUITE_GATE_ID,
569
+ status: fields.status,
570
+ units: fields.units,
571
+ unitLabel: SUITE_UNIT_LABEL,
572
+ startedAt,
573
+ endedAt: now(),
574
+ detail: singleLine(fields.detail),
575
+ evidence: fields.evidence,
576
+ ...(fields.pin === undefined ? {} : { pin: fields.pin }),
577
+ });
578
+ const refusal = refuseOpenForWrite(resultPath);
579
+ if (refusal !== undefined) {
580
+ process.stderr.write(`tiphys suite gate: ${refusal}\n`);
581
+ return EXIT_GATE_ERROR;
582
+ }
583
+ const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
584
+ if (!written.ok) {
585
+ process.stderr.write(`tiphys suite gate: ${written.reason}\n`);
586
+ return EXIT_GATE_ERROR;
587
+ }
588
+ process.stdout.write(`${SUITE_GATE_ID}: ${result.status} (${String(result.units)} ${SUITE_UNIT_LABEL})\n`);
589
+ if (result.detail !== "") {
590
+ process.stdout.write(`${result.detail}\n`);
591
+ }
592
+ return exitCodeForStatus(result.status);
593
+ };
594
+ const error = (detail, evidence = []) => emit({ status: "error", units: 0, detail, evidence });
595
+ // --base is a REQUIRED run parameter: without it the merge-base registry
596
+ // comparison cannot be performed, and a check that cannot reach a verdict
597
+ // is error, never not-applicable and never green (M2-C-3, criterion 9).
598
+ if (flags.base === undefined) {
599
+ return error("--base was not supplied; the merge-base registry comparison cannot be performed (M2-C-3)");
600
+ }
601
+ if (isAbsolute(flags.registry)) {
602
+ return error(`registry path ${flags.registry} must be repository-relative so the merge-base copy can be resolved`);
603
+ }
604
+ // Evidence directory: create it if needed, refusing any irregular entry.
605
+ const evidenceEntry = classifyEntry(evidenceDir);
606
+ if (evidenceEntry.kind === "regular") {
607
+ return error(`${evidenceDir} is a regular file, not a directory`);
608
+ }
609
+ if (evidenceEntry.kind === "unexaminable") {
610
+ return error(evidenceEntry.reason);
611
+ }
612
+ let evidenceIsDirectory = false;
613
+ try {
614
+ evidenceIsDirectory = lstatSync(evidenceDir).isDirectory();
615
+ }
616
+ catch {
617
+ evidenceIsDirectory = false;
618
+ }
619
+ if (evidenceEntry.kind === "irregular" && !evidenceIsDirectory) {
620
+ return error(evidenceEntry.reason);
621
+ }
622
+ const made = runStep(`creating ${evidenceDir}`, () => mkdirSync(evidenceDir, { recursive: true }));
623
+ if (!made.ok) {
624
+ return error(made.reason);
625
+ }
626
+ // The configured test command, executed verbatim and never interpreted.
627
+ const packageJsonPath = join(cwd, "package.json");
628
+ const packageRead = readRegularFileIfPresent(packageJsonPath);
629
+ if (packageRead.kind === "absent") {
630
+ return error(`${packageJsonPath} does not exist`);
631
+ }
632
+ if (packageRead.kind === "refused") {
633
+ return error(packageRead.reason);
634
+ }
635
+ let packageParsed;
636
+ try {
637
+ packageParsed = JSON.parse(packageRead.body);
638
+ }
639
+ catch (caught) {
640
+ return error(`${packageJsonPath} does not parse as JSON: ${caught.message}`);
641
+ }
642
+ const script = packageParsed.scripts?.test;
643
+ if (typeof script !== "string" || script.trim() === "") {
644
+ return error(`${packageJsonPath} declares no test script; the suite cannot be run as configured`);
645
+ }
646
+ // Merge base and the two registry copies.
647
+ const mergeBase = git(cwd, ["merge-base", flags.base, "HEAD"]);
648
+ if (!mergeBase.ok) {
649
+ return error(`the merge base of ${flags.base} and HEAD could not be resolved: ${mergeBase.reason}`);
650
+ }
651
+ const mergeBaseSha = mergeBase.stdout.trim();
652
+ const headRegistryRead = readRegularFileIfPresent(join(cwd, flags.registry));
653
+ if (headRegistryRead.kind === "absent") {
654
+ return error(`behavior registry ${flags.registry} does not exist at HEAD`);
655
+ }
656
+ if (headRegistryRead.kind === "refused") {
657
+ return error(headRegistryRead.reason);
658
+ }
659
+ const headRegistry = parseRegistry(headRegistryRead.body, `behavior registry ${flags.registry}`);
660
+ if (!headRegistry.ok) {
661
+ return error(headRegistry.reason);
662
+ }
663
+ const baseListing = git(cwd, ["ls-tree", mergeBaseSha, "--", flags.registry]);
664
+ if (!baseListing.ok) {
665
+ return error(baseListing.reason);
666
+ }
667
+ let baseRegistry = {};
668
+ let baseRegistryPresent = false;
669
+ if (baseListing.stdout.trim() !== "") {
670
+ const baseBody = git(cwd, ["show", `${mergeBaseSha}:${flags.registry}`]);
671
+ if (!baseBody.ok) {
672
+ return error(baseBody.reason);
673
+ }
674
+ const parsedBase = parseRegistry(baseBody.stdout, `merge-base behavior registry ${mergeBaseSha}:${flags.registry}`);
675
+ if (!parsedBase.ok) {
676
+ return error(parsedBase.reason);
677
+ }
678
+ baseRegistry = parsedBase.registry;
679
+ baseRegistryPresent = true;
680
+ }
681
+ // Independent discovery walk (step 3).
682
+ let discoveredFiles;
683
+ try {
684
+ discoveredFiles = flags.testRoots.flatMap((root) => walkTestFiles(resolve(cwd, root), flags.suffix));
685
+ discoveredFiles.sort();
686
+ }
687
+ catch (caught) {
688
+ return error(singleLine(caught.message));
689
+ }
690
+ // Reporter stream destination, probed and cleared BEFORE the child
691
+ // exists: a named pipe here would block the child harness in the kernel
692
+ // and this gate behind it (M2-C-6).
693
+ const streamPath = join(evidenceDir, STREAM_FILE);
694
+ if (!SAFE_PATH.test(streamPath)) {
695
+ return error(`evidence path ${streamPath} contains characters that cannot be carried through NODE_OPTIONS safely`);
696
+ }
697
+ const streamRefusal = refuseOpenForWrite(streamPath);
698
+ if (streamRefusal !== undefined) {
699
+ return error(streamRefusal);
700
+ }
701
+ const cleared = runStep(`clearing ${streamPath}`, () => rmSync(streamPath, { force: true }));
702
+ if (!cleared.ok) {
703
+ return error(cleared.reason);
704
+ }
705
+ // Start pin, child run, end pin (M2-C-5).
706
+ let startPin;
707
+ try {
708
+ startPin = takePin(flags.pinRoots.map((root) => resolve(cwd, root)));
709
+ }
710
+ catch (caught) {
711
+ return error(singleLine(caught.message));
712
+ }
713
+ const childEnv = {};
714
+ for (const [key, value] of Object.entries(process.env)) {
715
+ // NODE_OPTIONS: an inherited reporter option is the ambient default the
716
+ // pin exists to exclude. NODE_TEST_*: a nested `node --test` inheriting
717
+ // NODE_TEST_CONTEXT switches to its child-of-a-runner protocol.
718
+ if (value !== undefined && key !== "NODE_OPTIONS" && !key.startsWith("NODE_TEST")) {
719
+ childEnv[key] = value;
720
+ }
721
+ }
722
+ childEnv["NODE_OPTIONS"] =
723
+ `--test-reporter=${reporterDataUrl()} --test-reporter-destination=${streamPath}`;
724
+ const child = spawnSync("/bin/sh", ["-c", script], {
725
+ cwd,
726
+ encoding: "utf8",
727
+ env: childEnv,
728
+ maxBuffer: 64 * 1024 * 1024,
729
+ });
730
+ let endPin;
731
+ try {
732
+ endPin = takePin(flags.pinRoots.map((root) => resolve(cwd, root)));
733
+ }
734
+ catch (caught) {
735
+ return error(singleLine(caught.message));
736
+ }
737
+ const pin = { start: startPin, end: endPin };
738
+ const evidence = [];
739
+ const writeEvidence = (name, body) => {
740
+ const path = join(evidenceDir, name);
741
+ const refusal = refuseOpenForWrite(path);
742
+ if (refusal !== undefined) {
743
+ return refusal;
744
+ }
745
+ const written = runStep(`writing ${path}`, () => writeFileSync(path, body));
746
+ if (!written.ok) {
747
+ return written.reason;
748
+ }
749
+ evidence.push(name);
750
+ return undefined;
751
+ };
752
+ const stdoutRefusal = writeEvidence(STDOUT_FILE, child.stdout ?? "");
753
+ const stderrRefusal = writeEvidence(STDERR_FILE, child.stderr ?? "");
754
+ const captureRefusal = stdoutRefusal ?? stderrRefusal;
755
+ if (captureRefusal !== undefined) {
756
+ return error(captureRefusal, evidence);
757
+ }
758
+ if (child.error !== undefined) {
759
+ return error(`the configured test command could not be run: ${singleLine(String(child.error))}`, evidence);
760
+ }
761
+ if (child.signal !== null && child.signal !== undefined) {
762
+ return error(`the configured test command was terminated by ${child.signal}`, evidence);
763
+ }
764
+ const childExit = child.status ?? -1;
765
+ // The stream, read and validated as the pinned format before any count.
766
+ const streamRead = readRegularFileIfPresent(streamPath);
767
+ if (streamRead.kind === "absent") {
768
+ return error(`the configured test command (${singleLine(script)}) exited ${String(childExit)} without producing ` +
769
+ `the requested ${REPORTER_NAME} reporter stream at ${STREAM_FILE}`, evidence);
770
+ }
771
+ if (streamRead.kind === "refused") {
772
+ return error(streamRead.reason, evidence);
773
+ }
774
+ evidence.push(STREAM_FILE);
775
+ const parsed = parseSuiteStream(streamRead.body);
776
+ if (!parsed.ok) {
777
+ return error(parsed.reason, evidence);
778
+ }
779
+ const stream = parsed.stream;
780
+ const rawRefusal = writeEvidence(RAW_OUTPUT_FILE, stream.rawOutput);
781
+ if (rawRefusal !== undefined) {
782
+ return error(rawRefusal, evidence);
783
+ }
784
+ // CR-1306: strip a file-wrapper phantom (a file that defined zero tests)
785
+ // BEFORE any of the three sites below reads entityType === "test", so an
786
+ // emptied file cannot inflate `reported` and cannot slip past M2-C-2 as
787
+ // a counted green; see isFileWrapperPhantom's derivation above.
788
+ const points = stream.points.filter((point) => !isFileWrapperPhantom(point, cwd));
789
+ // M2-C-5: any pin difference makes the record error, whatever the counts
790
+ // said, because a run over a tree that changed names nothing.
791
+ const pinDifferences = comparePins(startPin, endPin);
792
+ if (pinDifferences.length > 0) {
793
+ return emit({
794
+ status: "error",
795
+ units: 0,
796
+ detail: "M2-C-5: the tree changed during the run: " +
797
+ pinDifferences.map(describePinDifference).join("; "),
798
+ evidence,
799
+ pin,
800
+ });
801
+ }
802
+ const { counts, skipsWithoutReason, failures, cancelled } = bucketPoints(points);
803
+ const identity = counts.pass + counts.fail + counts.skipped + counts.todo + counts.didNotRun;
804
+ if (identity !== counts.reported) {
805
+ return emit({
806
+ status: "error",
807
+ units: 0,
808
+ detail: `internal inconsistency: bucket sum ${String(identity)} does not equal reported ` +
809
+ `${String(counts.reported)} (${JSON.stringify(counts)})`,
810
+ evidence,
811
+ pin,
812
+ });
813
+ }
814
+ // Exit-code truth (step 8), cross-checked against the structured stream.
815
+ if (childExit === 0 && counts.fail + counts.didNotRun > 0) {
816
+ return emit({
817
+ status: "error",
818
+ units: 0,
819
+ detail: `the test command exited 0 while the stream reports ${String(counts.fail)} failing and ` +
820
+ `${String(counts.didNotRun)} did-not-run test(s); the two authorities disagree`,
821
+ evidence,
822
+ pin,
823
+ });
824
+ }
825
+ if (childExit !== 0 && counts.fail + counts.didNotRun === 0) {
826
+ return emit({
827
+ status: "error",
828
+ units: 0,
829
+ detail: `the test command exited ${String(childExit)} with no failing test in the stream; ` +
830
+ "the suite did not complete for a reason the stream does not carry",
831
+ evidence,
832
+ pin,
833
+ });
834
+ }
835
+ // Discovery parity, both directions (step 3).
836
+ //
837
+ // NOT Set membership on the raw strings (DV2-1): `discoveredFiles` is
838
+ // composed by this gate's own walk from `resolve(cwd, root)`, which does
839
+ // not resolve a symlinked ANCESTOR of the declared root, while
840
+ // `reportedFiles` comes from node's own test reporter, which is canonical
841
+ // whatever spelling node was invoked with (the same reason
842
+ // `isFileWrapperPhantom` above already compares by identity rather than by
843
+ // string). A `--test-root` that reaches its target through a symlinked
844
+ // ancestor then produces one file discovered once and reported once,
845
+ // reported as BOTH "discovered but absent from the reporter" and
846
+ // "reported but outside the declared roots", because the two strings
847
+ // differ even though they name the same object. `pathsNameSameObject`
848
+ // (src/path-identity.ts) is the round's own primitive for exactly this.
849
+ const reportedFiles = [
850
+ ...new Set(points
851
+ .filter((point) => point.entityType === "test")
852
+ .map((point) => point.file)),
853
+ ].sort();
854
+ const findings = [];
855
+ for (const file of discoveredFiles) {
856
+ if (!reportedFiles.some((other) => pathsNameSameObject(other, file))) {
857
+ findings.push(`test file discovered by the walk but absent from the reporter: ${relative(cwd, file)}`);
858
+ }
859
+ }
860
+ for (const file of reportedFiles) {
861
+ if (!discoveredFiles.some((other) => pathsNameSameObject(other, file))) {
862
+ findings.push(`test file reported but outside the declared roots and suffix: ${relative(cwd, file)}`);
863
+ }
864
+ }
865
+ // Registry resolution and merge-base preservation (step 4).
866
+ const reportedTestNames = new Set(points
867
+ .filter((point) => point.entityType === "test")
868
+ .map((point) => point.name));
869
+ for (const [behavior, description] of Object.entries(headRegistry.registry)) {
870
+ if (!reportedTestNames.has(description)) {
871
+ findings.push(`behavior ${behavior} does not resolve: no reported test is named "${description}"`);
872
+ }
873
+ }
874
+ for (const behavior of Object.keys(baseRegistry)) {
875
+ if (!(behavior in headRegistry.registry)) {
876
+ findings.push(`behavior ${behavior} is registered at the merge base (${mergeBaseSha.slice(0, 12)}) ` +
877
+ "and deleted from the head registry");
878
+ }
879
+ }
880
+ // Skip accounting (step 5, EXT-F-05 executable form).
881
+ for (const point of skipsWithoutReason) {
882
+ findings.push(`skipped without a reason: "${point.name}" (${relative(cwd, point.file)})`);
883
+ }
884
+ for (const point of failures) {
885
+ findings.push(`failing test: "${point.name}" (${relative(cwd, point.file)})`);
886
+ }
887
+ for (const point of cancelled) {
888
+ findings.push(`did-not-run test: "${point.name}" (${relative(cwd, point.file)})`);
889
+ }
890
+ const countsDocument = {
891
+ gate: SUITE_GATE_ID,
892
+ requestedReporter: REPORTER_NAME,
893
+ reporterRequestedVia: "child-scoped NODE_OPTIONS --test-reporter=data:... --test-reporter-destination",
894
+ childNode: stream.childNode,
895
+ gateNode: process.version,
896
+ testScript: script,
897
+ shell: "/bin/sh",
898
+ base: flags.base,
899
+ mergeBase: mergeBaseSha,
900
+ testRoots: flags.testRoots,
901
+ suffix: flags.suffix,
902
+ pinRoots: flags.pinRoots,
903
+ mapping: MAPPING_STATEMENT,
904
+ counts: {
905
+ ...counts,
906
+ discoveredFiles: discoveredFiles.length,
907
+ reportedFiles: reportedFiles.length,
908
+ behaviors: Object.keys(headRegistry.registry).length,
909
+ mergeBaseBehaviors: Object.keys(baseRegistry).length,
910
+ },
911
+ baseRegistryPresent,
912
+ childExit,
913
+ discovered: discoveredFiles.map((file) => relative(cwd, file)),
914
+ reported: reportedFiles.map((file) => relative(cwd, file)),
915
+ findings,
916
+ };
917
+ const countsRefusal = writeEvidence(COUNTS_FILE, `${JSON.stringify(countsDocument, null, 2)}\n`);
918
+ if (countsRefusal !== undefined) {
919
+ return error(countsRefusal, evidence);
920
+ }
921
+ if (findings.length > 0) {
922
+ const shown = findings.slice(0, 10);
923
+ const more = findings.length > shown.length
924
+ ? `; and ${String(findings.length - shown.length)} more (see ${COUNTS_FILE})`
925
+ : "";
926
+ return emit({
927
+ status: "red",
928
+ units: counts.reported,
929
+ detail: `${String(findings.length)} finding(s): ${shown.join("; ")}${more}`,
930
+ evidence,
931
+ pin,
932
+ });
933
+ }
934
+ return emit({
935
+ status: "green",
936
+ units: counts.reported,
937
+ detail: `suite green via ${REPORTER_NAME} (child node ${stream.childNode}): reported ${String(counts.reported)} ` +
938
+ `test(s) from ${String(reportedFiles.length)} file(s) (pass ${String(counts.pass)}, fail 0, ` +
939
+ `skipped ${String(counts.skipped)}, todo ${String(counts.todo)}, did-not-run 0); ` +
940
+ `discovered ${String(discoveredFiles.length)} file(s) walking ${flags.testRoots.join(", ")} ` +
941
+ `for ${flags.suffix}; ${String(Object.keys(headRegistry.registry).length)} behavior(s) resolve; ` +
942
+ `merge base ${mergeBaseSha.slice(0, 12)}`,
943
+ evidence,
944
+ pin,
945
+ });
946
+ }
947
+ const invokedDirectly = process.argv[1] !== undefined &&
948
+ pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1]);
949
+ if (invokedDirectly) {
950
+ // `process.exitCode`, NEVER `process.exit(runSuiteGate(...))` (M4-P29).
951
+ // This gate runs as a SUBPROCESS, so fd 1 is a buffered stream the parent
952
+ // owns and a write to one is QUEUED rather than completed. `process.exit`
953
+ // ends the process without draining that queue, so everything past the
954
+ // buffer is DISCARDED, while the exit code survives and the loss is
955
+ // silent. The plan names this gate as the plausible future trigger because
956
+ // its `detail` carries up to ten findings and every finding quotes text
957
+ // this gate read out of another program's report.
958
+ //
959
+ // Measured at the pre-fix parent commit on this container, through
960
+ // `spawnSync`, which is how the registry runner invokes gates
961
+ // (src/gates/run.ts:1528) and which hands a child SOCKETPAIRS rather than
962
+ // pipes: 451,014 bytes written, 182,750 delivered. A regular file never
963
+ // truncates, which is why this survives casual testing. Assigning
964
+ // `process.exitCode` lets the process end normally, which drains the queue
965
+ // first, and the full capture is
966
+ // witness/captures/m4-p29-gate-cli-stdio.txt.
967
+ process.exitCode = runSuiteGate(process.argv.slice(2));
968
+ }