@tiphys/kernel 0.1.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 (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
@@ -0,0 +1,633 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { EX_USAGE } from "../cli.js";
6
+ import { pathsIdentifySameObject } from "../path-identity.js";
7
+ import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
8
+ import { decodeDocument } from "../validate.js";
9
+ import { formatDiagnostics, loadSchema, validate } from "./validate.js";
10
+ import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
11
+ /**
12
+ * THE GATE CLASS VOCABULARY (kernel plan M4, M4-P14; DR-0029 part 2a; R-041).
13
+ *
14
+ * TWO gate ids out of one module, the shape src/gates/credentials.ts already
15
+ * ships for `credential-scrub` and `credential-token`. The first argument is
16
+ * the gate id.
17
+ *
18
+ * `gate-classes` the check DR-0029 promised: a PHASE declares at least one
19
+ * gate in each required class, or declares an escape that is
20
+ * DATA rather than silence.
21
+ * `typecheck` R-041, retargeted by M4-P13 under DR-0028: the kernel is
22
+ * just another project under the scheme, so the kernel's OWN
23
+ * registry declares the command that satisfies `correctness`
24
+ * alongside `suite`. The kernel ships the class; the project
25
+ * ships the command.
26
+ *
27
+ * WHY ONE MODULE AND NOT TWO. Plan section 3.3.2 authorises exactly one new
28
+ * source file for this phase (`src/gates/gate-classes.ts`), and the scope
29
+ * auditor reads that list literally. `credentials.ts` is the delivered
30
+ * precedent for two gate ids behind one entry point, so this follows it rather
31
+ * than widening the declaration. Recorded in delivery/work-history/m4-p14.md
32
+ * as a content-level deviation from the READING that one gate means one file,
33
+ * which the plan never actually says.
34
+ *
35
+ * ================= THE CLASS CHECK =================
36
+ *
37
+ * DR-0029 states the required classes and the two escapes:
38
+ *
39
+ * correctness something executable that fails when the code is wrong
40
+ * scope the change is the change that was promised
41
+ * review an independent read happened, with a recorded verdict
42
+ *
43
+ * A class is satisfied by naming one or more gate ids that this repository's
44
+ * own registry declares, OR by `not-applicable` WITH a recorded reason, OR by
45
+ * `not-yet-establishable` NAMING the phase that will establish it. The second
46
+ * is an IOU with a due date, not a waiver.
47
+ *
48
+ * THE PROPERTY BEING PROTECTED, in DR-0029's words: you can start from
49
+ * nothing; you can never SILENTLY have nothing. So an escape is not refused,
50
+ * it is PRINTED BY NAME on the green arm, which is the same trade
51
+ * src/gates/scope.ts's M3-P11 change B makes for a declaration addition. A
52
+ * reviewer who does not read the printed line gets no protection from it.
53
+ *
54
+ * THIS CHECK JUDGES NAMING, NEVER ASSERTING, AND SAYING SO IS PART OF ITS JOB
55
+ * (CR-FS-GATES-01, the DR-0047 final sweep). It establishes that a phase NAMES
56
+ * a registered gate for each required class. It does not, and from here cannot,
57
+ * establish that the named gate was APPLICABLE on the head under audit, because
58
+ * that is a fact about a run and this reads a declaration. Measured at the swept
59
+ * head, one phase, two commands:
60
+ *
61
+ * gate-classes --phase m4-p28 -> green, "review: asserted by check-dual-review", exit 0
62
+ * check-dual-review --precondition . -> exit 1, "0 verdict document(s)"
63
+ *
64
+ * Two carve-outs, each correct on its own, composing into the state DR-0029
65
+ * forbids: a declaration check SHOULD check declarations, and a gate with no
66
+ * subject SHOULD report not-applicable, and `check-dual-review` is
67
+ * `applicability: conditional` so its vacuity never reddens the bundle either.
68
+ * All 17 declarations carrying `gateClasses` satisfy `review` with exactly
69
+ * `{"gates":["check-dual-review"]}`.
70
+ *
71
+ * THE ANSWER IS DISCLOSURE, NOT REFUSAL, and the choice is the module's own
72
+ * existing trade rather than a new one. Refusing a class satisfied only by a
73
+ * conditional gate would redden 17 landed declarations over a property this
74
+ * command cannot measure. So a class satisfied by a gate that is CONDITIONAL in
75
+ * the registry is named on the green arm, exactly as a declared escape is, and
76
+ * the sentence says that the named gate's applicability was not checked here. A
77
+ * reviewer who reads the line can then go and look; a reader of the old line
78
+ * could not know there was anything to look at.
79
+ *
80
+ * WHY A BAD DECLARATION IS `red` AND A BAD REGISTRY IS `error`, because
81
+ * M2-C-3 makes that distinction load-bearing. A declaration that does not
82
+ * parse, does not validate, or names a class with no disposition is a DEFECT
83
+ * IN THE SUBJECT: the check reached a verdict about the thing it audits, and
84
+ * that verdict is red. A registry that cannot be read is an INSTRUMENT
85
+ * failure: the set of legal gate ids is unknown, so no verdict about the
86
+ * declaration's gate names can be reached at all, and that is `error`, never
87
+ * a green with the id check quietly skipped.
88
+ *
89
+ * WHERE THE DECLARATION IS READ FROM, and it is deliberately NOT the merge
90
+ * base. src/gates/scope.ts reads the merge base because its declaration
91
+ * GRANTS scope and a branch must not widen its own grant. This check grants
92
+ * nothing: it only requires a phase to say, out loud and in a committed file,
93
+ * what asserts each class. Reading the working tree is what lets a phase fix
94
+ * its own declaration and see the result before pushing, and the disclosure
95
+ * on the green arm is what a reviewer reads either way.
96
+ *
97
+ * ================= THE TYPECHECK GATE =================
98
+ *
99
+ * `tsc -b <projects> --force --listFiles`, and both halves of that command
100
+ * are there for a reason.
101
+ *
102
+ * `--force` without it the build is incremental and the file list depends
103
+ * on what happened to be up to date, so the unit count would
104
+ * vary with the state of `dist/` rather than with the code.
105
+ * Measured on this repository at one head: 308 lines when warm,
106
+ * 657 lines with --force, three --force runs all 657.
107
+ * `--listFiles` the unit count is DERIVED FROM A NUMBER THE COMPILER
108
+ * PRINTS (plan criterion 3), never a constant: it is the count
109
+ * of distinct paths tsc reported it read. A constant, or a
110
+ * count of the gate's own argv, would be green on a compiler
111
+ * that checked nothing.
112
+ *
113
+ * A compiler that prints NO file list is `error`, not green with zero units:
114
+ * M2-C-2 would rewrite the green anyway, and saying so here makes the reason
115
+ * legible instead of arriving as a vacuity rewrite.
116
+ */
117
+ /* -------------------------------------------------------------------- */
118
+ /* Shared plumbing */
119
+ /* -------------------------------------------------------------------- */
120
+ const GATE_IDS = ["gate-classes", "typecheck"];
121
+ const USAGE = "usage: node src/gates/gate-classes.ts gate-classes --declarations <dir> " +
122
+ "--registry <file> --result <file> [--evidence <dir>] --phase <id>\n" +
123
+ " node src/gates/gate-classes.ts typecheck --result <file> " +
124
+ "[--evidence <dir>] --project <tsconfig> [--project <tsconfig> ...]";
125
+ const SINGLE_VALUE_FLAGS = [
126
+ "--declarations",
127
+ "--registry",
128
+ "--result",
129
+ "--evidence",
130
+ "--phase",
131
+ ];
132
+ function parseFlags(args) {
133
+ const flags = { project: [] };
134
+ for (let index = 0; index < args.length; index += 1) {
135
+ const flag = args[index];
136
+ const value = args[index + 1];
137
+ if (flag === undefined) {
138
+ return undefined;
139
+ }
140
+ if (value === undefined || value.startsWith("--")) {
141
+ return undefined;
142
+ }
143
+ if (flag === "--project") {
144
+ flags.project.push(value);
145
+ index += 1;
146
+ continue;
147
+ }
148
+ if (!SINGLE_VALUE_FLAGS.includes(flag)) {
149
+ return undefined;
150
+ }
151
+ const key = flag.slice(2);
152
+ flags[key] = value;
153
+ index += 1;
154
+ }
155
+ return flags;
156
+ }
157
+ function usageError(message) {
158
+ if (message !== undefined) {
159
+ process.stderr.write(`tiphys gates gate-classes: ${message}\n`);
160
+ }
161
+ process.stderr.write(`${USAGE}\n`);
162
+ return EX_USAGE;
163
+ }
164
+ function now() {
165
+ return new Date().toISOString();
166
+ }
167
+ /** Write the record, print the one-line summary, and return the exit code. */
168
+ function emit(gateId, resultPath, fields) {
169
+ const result = makeGateResult(fields);
170
+ const refusal = refuseOpenForWrite(resultPath);
171
+ if (refusal !== undefined) {
172
+ process.stderr.write(`tiphys gates ${gateId}: ${refusal}\n`);
173
+ return EXIT_GATE_ERROR;
174
+ }
175
+ const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
176
+ if (!written.ok) {
177
+ process.stderr.write(`tiphys gates ${gateId}: ${written.reason}\n`);
178
+ return EXIT_GATE_ERROR;
179
+ }
180
+ const status = result.status;
181
+ process.stdout.write(`${result.gate}: ${status} (${String(result.units)} ${result.unitLabel})\n`);
182
+ if (result.detail !== "") {
183
+ process.stdout.write(`${result.detail}\n`);
184
+ }
185
+ return exitCodeForStatus(status);
186
+ }
187
+ /* -------------------------------------------------------------------- */
188
+ /* The class check */
189
+ /* -------------------------------------------------------------------- */
190
+ /** DR-0029's three required classes, in the order the record reports them. */
191
+ const REQUIRED_CLASSES = ["correctness", "scope", "review"];
192
+ const CLASS_UNIT_LABEL = "declared gate classes checked";
193
+ const PHASE_ID_PATTERN = /^M[0-9]+-P[0-9]+$/;
194
+ const schemaDirectory = new URL("./schemas/", import.meta.url);
195
+ let cachedSchema;
196
+ /** M2-C-6: the schema document is opened through the delivered primitive. */
197
+ function declarationSchema() {
198
+ if (cachedSchema !== undefined) {
199
+ return cachedSchema;
200
+ }
201
+ const path = fileURLToPath(new URL("phase-declaration.schema.json", schemaDirectory));
202
+ const read = readRegularFileIfPresent(path);
203
+ if (read.kind !== "read") {
204
+ throw new Error(read.kind === "absent"
205
+ ? `schema document ${path} is missing from this installation`
206
+ : read.reason);
207
+ }
208
+ const loaded = loadSchema(JSON.parse(read.body), "phase-declaration.schema.json");
209
+ if (!loaded.ok) {
210
+ throw new Error(loaded.reason);
211
+ }
212
+ cachedSchema = loaded.schema;
213
+ return cachedSchema;
214
+ }
215
+ /**
216
+ * The legal gate ids: every entry of this repository's own registry.
217
+ *
218
+ * A failure here is `error` at the call site and never a skipped check. The
219
+ * registry is DECODED rather than JSON-parsed because it ships as YAML and
220
+ * `decodeDocument` is the delivered decoder for both (DR-0013 YAML clause 3).
221
+ */
222
+ function registryGateIds(path) {
223
+ const read = readRegularFileIfPresent(path);
224
+ if (read.kind === "absent") {
225
+ return { ok: false, reason: `gate registry ${path} does not exist` };
226
+ }
227
+ if (read.kind === "refused") {
228
+ return { ok: false, reason: read.reason };
229
+ }
230
+ const decoded = decodeDocument(read.body, path);
231
+ if (!decoded.ok) {
232
+ return { ok: false, reason: decoded.reason };
233
+ }
234
+ const gates = decoded.value.gates;
235
+ if (!Array.isArray(gates)) {
236
+ return { ok: false, reason: `gate registry ${path} declares no gates array` };
237
+ }
238
+ const ids = new Set();
239
+ /* CR-FS-GATES-01. The APPLICABILITY is read here because it is the one fact
240
+ about a named gate this command can establish from the registry it is
241
+ already parsing. It is never used to REFUSE a class: it is carried out so
242
+ the green sentence can name which of the phase's chosen gates can report
243
+ not-applicable without reddening anything. */
244
+ const conditional = new Set();
245
+ for (const entry of gates) {
246
+ const id = entry?.id;
247
+ if (typeof id === "string" && id !== "") {
248
+ ids.add(id);
249
+ if (entry?.applicability === "conditional") {
250
+ conditional.add(id);
251
+ }
252
+ }
253
+ }
254
+ if (ids.size === 0) {
255
+ return { ok: false, reason: `gate registry ${path} declares no usable gate id` };
256
+ }
257
+ return { ok: true, ids, conditional };
258
+ }
259
+ /**
260
+ * Judge ONE class. Every red arm names the class, so the detail a reader sees
261
+ * says which of the three is wrong rather than only that something is.
262
+ */
263
+ function judgeClass(name, entry, knownGateIds, conditionalGateIds = new Set()) {
264
+ if (entry === undefined) {
265
+ return {
266
+ name,
267
+ ok: false,
268
+ sentence: `${name}: MISSING, the declaration names no disposition for this required class`,
269
+ escape: false,
270
+ conditionalGates: [],
271
+ };
272
+ }
273
+ const hasGates = Array.isArray(entry.gates) && entry.gates.length > 0;
274
+ const hasStatus = typeof entry.status === "string" && entry.status !== "";
275
+ if (hasGates && hasStatus) {
276
+ return {
277
+ name,
278
+ ok: false,
279
+ sentence: `${name}: AMBIGUOUS, it names gate(s) ${entry.gates.join(", ")} and also ` +
280
+ `status ${String(entry.status)}; a class is asserted by a gate or excused by a status, never both`,
281
+ escape: false,
282
+ conditionalGates: [],
283
+ };
284
+ }
285
+ if (hasGates) {
286
+ const named = entry.gates;
287
+ const unknown = named.filter((id) => !knownGateIds.has(id));
288
+ if (unknown.length > 0) {
289
+ return {
290
+ name,
291
+ ok: false,
292
+ sentence: `${name}: names gate id(s) ${unknown.join(", ")} that this repository's gate registry ` +
293
+ "does not declare, so nothing runs for this class",
294
+ escape: false,
295
+ conditionalGates: [],
296
+ };
297
+ }
298
+ /* CR-FS-GATES-01. The per-class SENTENCE is unchanged, deliberately: it
299
+ says what the declaration says, and this check judges the declaration.
300
+ The disclosure is a TRAILING note built by `runClassGate`, beside the
301
+ declared-escape note it is a sibling of, for two reasons. It keeps one
302
+ disclosure idiom rather than two, and it keeps every committed capture of
303
+ this gate's detail a PREFIX of the new one, so the existing witnesses
304
+ still assert what they were taken to assert instead of being re-recorded
305
+ to match a change they were supposed to be independent of. */
306
+ const conditional = named.filter((id) => conditionalGateIds.has(id));
307
+ return {
308
+ name,
309
+ ok: true,
310
+ sentence: `${name}: asserted by ${named.join(", ")}`,
311
+ escape: false,
312
+ conditionalGates: conditional,
313
+ };
314
+ }
315
+ if (!hasStatus) {
316
+ return {
317
+ name,
318
+ ok: false,
319
+ sentence: `${name}: declares neither a gate nor a status, which is the SILENT nothing DR-0029 ` +
320
+ "exists to refuse",
321
+ escape: false,
322
+ conditionalGates: [],
323
+ };
324
+ }
325
+ if (entry.status === "not-applicable") {
326
+ const reason = typeof entry.reason === "string" ? entry.reason.trim() : "";
327
+ if (reason === "") {
328
+ return {
329
+ name,
330
+ ok: false,
331
+ sentence: `${name}: not-applicable with no recorded reason; the reason is what makes it data rather than silence`,
332
+ escape: false,
333
+ conditionalGates: [],
334
+ };
335
+ }
336
+ return {
337
+ name,
338
+ ok: true,
339
+ sentence: `${name}: DECLARED not-applicable, reason: ${reason}`,
340
+ escape: true,
341
+ conditionalGates: [],
342
+ };
343
+ }
344
+ if (entry.status === "not-yet-establishable") {
345
+ const by = typeof entry.establishedBy === "string" ? entry.establishedBy.trim() : "";
346
+ if (by === "") {
347
+ return {
348
+ name,
349
+ ok: false,
350
+ sentence: `${name}: not-yet-establishable naming no establishing phase; an IOU with no due date ` +
351
+ "is a waiver, which DR-0029 does not grant",
352
+ escape: false,
353
+ conditionalGates: [],
354
+ };
355
+ }
356
+ if (!PHASE_ID_PATTERN.test(by)) {
357
+ return {
358
+ name,
359
+ ok: false,
360
+ sentence: `${name}: not-yet-establishable names establishedBy ${by}, which is not a phase id of ` +
361
+ "the form M<n>-P<n>",
362
+ escape: false,
363
+ conditionalGates: [],
364
+ };
365
+ }
366
+ return {
367
+ name,
368
+ ok: true,
369
+ sentence: `${name}: DECLARED not-yet-establishable, to be established by ${by}`,
370
+ escape: true,
371
+ conditionalGates: [],
372
+ };
373
+ }
374
+ return {
375
+ name,
376
+ ok: false,
377
+ sentence: `${name}: declares unknown status ${String(entry.status)}`,
378
+ escape: false,
379
+ conditionalGates: [],
380
+ };
381
+ }
382
+ function runClassGate(flags) {
383
+ const startedAt = now();
384
+ const resultPath = flags.result;
385
+ const phase = flags.phase;
386
+ const declarationsDir = flags.declarations;
387
+ const registryPath = flags.registry;
388
+ const shared = { gate: "gate-classes", unitLabel: CLASS_UNIT_LABEL, startedAt };
389
+ const relPath = `${declarationsDir.replace(/\/+$/, "")}/${phase}.json`;
390
+ const absolute = isAbsolute(relPath) ? relPath : resolve(process.cwd(), relPath);
391
+ const registryIds = registryGateIds(isAbsolute(registryPath) ? registryPath : resolve(process.cwd(), registryPath));
392
+ if (!registryIds.ok) {
393
+ // INSTRUMENT failure, not a verdict about the subject (M2-C-3).
394
+ return emit("gate-classes", resultPath, {
395
+ ...shared,
396
+ status: "error",
397
+ units: 0,
398
+ endedAt: now(),
399
+ detail: `the set of legal gate ids could not be established: ${registryIds.reason}; refusing to ` +
400
+ "report on a declaration's gate names without knowing which names exist",
401
+ });
402
+ }
403
+ const read = readRegularFileIfPresent(absolute);
404
+ if (read.kind === "absent") {
405
+ return emit("gate-classes", resultPath, {
406
+ ...shared,
407
+ status: "red",
408
+ units: 0,
409
+ endedAt: now(),
410
+ detail: `phase ${phase} has no declaration at ${relPath}, so it declares no gate class at all; ` +
411
+ `DR-0029 requires a disposition for ${REQUIRED_CLASSES.join(", ")}`,
412
+ });
413
+ }
414
+ if (read.kind === "refused") {
415
+ return emit("gate-classes", resultPath, {
416
+ ...shared,
417
+ status: "error",
418
+ units: 0,
419
+ endedAt: now(),
420
+ detail: read.reason,
421
+ });
422
+ }
423
+ let parsed;
424
+ try {
425
+ parsed = JSON.parse(read.body);
426
+ }
427
+ catch (error) {
428
+ return emit("gate-classes", resultPath, {
429
+ ...shared,
430
+ status: "red",
431
+ units: 0,
432
+ endedAt: now(),
433
+ detail: `declaration ${relPath} does not parse as JSON: ${singleLine(error.message)}`,
434
+ });
435
+ }
436
+ const diagnostics = formatDiagnostics(validate(declarationSchema(), parsed));
437
+ if (diagnostics.length > 0) {
438
+ return emit("gate-classes", resultPath, {
439
+ ...shared,
440
+ status: "red",
441
+ units: 0,
442
+ endedAt: now(),
443
+ detail: `declaration ${relPath} is not a valid phase declaration: ${diagnostics.join("; ")}`,
444
+ });
445
+ }
446
+ const declaration = parsed;
447
+ if (phase.toUpperCase() !== declaration.id) {
448
+ return emit("gate-classes", resultPath, {
449
+ ...shared,
450
+ status: "red",
451
+ units: 0,
452
+ endedAt: now(),
453
+ detail: `--phase ${phase} does not match declaration ${relPath}'s own id ${declaration.id}; ` +
454
+ "refusing to report class coverage for a phase against another phase's declaration",
455
+ });
456
+ }
457
+ const classes = declaration.gateClasses;
458
+ const verdicts = REQUIRED_CLASSES.map((name) => judgeClass(name, classes === undefined ? undefined : classes[name], registryIds.ids, registryIds.conditional));
459
+ const failed = verdicts.filter((verdict) => !verdict.ok);
460
+ const escapes = verdicts.filter((verdict) => verdict.ok && verdict.escape);
461
+ const conditionallySatisfied = verdicts.filter((verdict) => verdict.ok && verdict.conditionalGates.length > 0);
462
+ const units = verdicts.length;
463
+ if (failed.length > 0) {
464
+ return emit("gate-classes", resultPath, {
465
+ ...shared,
466
+ status: "red",
467
+ units,
468
+ endedAt: now(),
469
+ detail: `phase ${declaration.id} fails ${String(failed.length)} of ${String(units)} required gate ` +
470
+ `class(es) in ${relPath}: ${failed.map((verdict) => verdict.sentence).join("; ")}`,
471
+ });
472
+ }
473
+ // THE DISCLOSURE, and it is on the GREEN arm on purpose. An escape is
474
+ // allowed by DR-0029 and is the thing a reviewer must actually read, so it
475
+ // is named here rather than being visible only as an absence.
476
+ const escapeNote = escapes.length === 0
477
+ ? ""
478
+ : `; ${String(escapes.length)} class(es) satisfied by a DECLARED ESCAPE rather than a gate, ` +
479
+ "which a reviewer signs off rather than the gate refusing: " +
480
+ escapes.map((verdict) => verdict.name).join(", ");
481
+ /* THE SECOND DISCLOSURE, AND IT IS ON THE GREEN ARM FOR THE SAME REASON AS
482
+ THE FIRST (CR-FS-GATES-01). A class whose only satisfier is a conditional
483
+ gate is DECLARED correctly and may still have had nothing run for it at
484
+ this head, and the composition of those two correct behaviours is the
485
+ silent nothing DR-0029 forbids. Naming it here does not make the gate
486
+ assert anything; it makes the reviewer's signature informed. */
487
+ const conditionalNote = conditionallySatisfied.length === 0
488
+ ? ""
489
+ : `; ${String(conditionallySatisfied.length)} class(es) are satisfied ONLY BY NAMING a gate, and ` +
490
+ "this check never establishes that the named gate asserted anything on this head: " +
491
+ conditionallySatisfied
492
+ .map((verdict) => `${verdict.name} -> ${verdict.conditionalGates.join(", ")}`)
493
+ .join("; ");
494
+ return emit("gate-classes", resultPath, {
495
+ ...shared,
496
+ status: "green",
497
+ units,
498
+ endedAt: now(),
499
+ detail: `phase ${declaration.id} declares all ${String(units)} required gate class(es) in ${relPath}: ` +
500
+ `${verdicts.map((verdict) => verdict.sentence).join("; ")}${escapeNote}${conditionalNote}`,
501
+ });
502
+ }
503
+ /* -------------------------------------------------------------------- */
504
+ /* The typecheck gate */
505
+ /* -------------------------------------------------------------------- */
506
+ const TYPECHECK_UNIT_LABEL = "source files type-checked";
507
+ /** `<path>(<line>,<col>): error TS<n>:` is the compiler's own diagnostic shape. */
508
+ const TSC_ERROR_LINE = /error TS[0-9]+:/;
509
+ /** Split a real `tsc --listFiles` capture into its file list and its errors. */
510
+ function readCompilerOutput(text) {
511
+ const files = [];
512
+ const errors = [];
513
+ const seen = new Set();
514
+ for (const raw of text.split("\n")) {
515
+ const line = raw.trim();
516
+ if (line === "") {
517
+ continue;
518
+ }
519
+ if (TSC_ERROR_LINE.test(line)) {
520
+ errors.push(line);
521
+ continue;
522
+ }
523
+ if (!seen.has(line)) {
524
+ seen.add(line);
525
+ files.push(line);
526
+ }
527
+ }
528
+ return { files, errors };
529
+ }
530
+ function runTypecheckGate(flags) {
531
+ const startedAt = now();
532
+ const resultPath = flags.result;
533
+ const shared = { gate: "typecheck", unitLabel: TYPECHECK_UNIT_LABEL, startedAt };
534
+ const projects = flags.project;
535
+ const compiler = join(process.cwd(), "node_modules", "typescript", "bin", "tsc");
536
+ if (!existsSync(compiler)) {
537
+ return emit("typecheck", resultPath, {
538
+ ...shared,
539
+ status: "error",
540
+ units: 0,
541
+ endedAt: now(),
542
+ detail: `the TypeScript compiler is not installed at ${compiler}; this gate asserts nothing ` +
543
+ "without it and reports error rather than a green that measured no project",
544
+ });
545
+ }
546
+ const run = spawnSync(process.execPath, [compiler, "-b", ...projects, "--force", "--listFiles"], { encoding: "utf8", cwd: process.cwd() });
547
+ if (run.error !== undefined) {
548
+ return emit("typecheck", resultPath, {
549
+ ...shared,
550
+ status: "error",
551
+ units: 0,
552
+ endedAt: now(),
553
+ detail: `the compiler could not be run: ${singleLine(String(run.error))}`,
554
+ });
555
+ }
556
+ const reading = readCompilerOutput(`${run.stdout ?? ""}\n${run.stderr ?? ""}`);
557
+ const units = reading.files.length;
558
+ if (reading.errors.length > 0 || run.status !== 0) {
559
+ const named = reading.errors.slice(0, 5);
560
+ return emit("typecheck", resultPath, {
561
+ ...shared,
562
+ status: "red",
563
+ units,
564
+ endedAt: now(),
565
+ detail: `tsc -b ${projects.join(" ")} exited ${String(run.status)} with ` +
566
+ `${String(reading.errors.length)} diagnostic(s): ` +
567
+ (named.length === 0
568
+ ? "the compiler printed no diagnostic line, so the nonzero exit is the whole evidence"
569
+ : named.map((line) => singleLine(line)).join(" | ")),
570
+ });
571
+ }
572
+ if (units === 0) {
573
+ // A green with zero units is rewritten by makeGateResult anyway; saying it
574
+ // here makes the CAUSE legible rather than arriving as a vacuity rewrite.
575
+ return emit("typecheck", resultPath, {
576
+ ...shared,
577
+ status: "error",
578
+ units: 0,
579
+ endedAt: now(),
580
+ detail: `tsc -b ${projects.join(" ")} --listFiles printed no file, so nothing was counted and ` +
581
+ "this gate asserts nothing about the code",
582
+ });
583
+ }
584
+ return emit("typecheck", resultPath, {
585
+ ...shared,
586
+ status: "green",
587
+ units,
588
+ endedAt: now(),
589
+ detail: `tsc -b ${projects.join(" ")} --force --listFiles exited 0 and reported ${String(units)} ` +
590
+ "distinct file(s); the unit count is those printed paths, not a constant",
591
+ });
592
+ }
593
+ /* -------------------------------------------------------------------- */
594
+ /* Entry point */
595
+ /* -------------------------------------------------------------------- */
596
+ export function main(argv) {
597
+ const gateId = argv[0];
598
+ if (gateId === undefined || !GATE_IDS.includes(gateId)) {
599
+ return usageError(`expected a gate id: ${GATE_IDS.join(" or ")}${gateId === undefined ? "" : `, saw ${gateId}`}`);
600
+ }
601
+ const flags = parseFlags(argv.slice(1));
602
+ if (flags === undefined) {
603
+ return usageError();
604
+ }
605
+ if (flags.result === undefined) {
606
+ return usageError(`${gateId} requires --result`);
607
+ }
608
+ if (gateId === "gate-classes") {
609
+ const missing = ["declarations", "registry", "phase"].filter((name) => flags[name] === undefined);
610
+ if (missing.length > 0) {
611
+ return usageError(`gate-classes requires ${missing.map((name) => `--${name}`).join(" ")}`);
612
+ }
613
+ return runClassGate(flags);
614
+ }
615
+ if (flags.project.length === 0) {
616
+ return usageError("typecheck requires at least one --project");
617
+ }
618
+ return runTypecheckGate(flags);
619
+ }
620
+ const entry = process.argv[1];
621
+ if (entry !== undefined && pathsIdentifySameObject(fileURLToPath(import.meta.url), entry)) {
622
+ // The same second layer src/gates/scope.ts carries: an uncaught throw would
623
+ // exit 1, which is EXIT_RED, and a crash reported as a red verdict is
624
+ // indistinguishable from a real one to a consumer reading the exit code.
625
+ try {
626
+ process.exitCode = main(process.argv.slice(2));
627
+ }
628
+ catch (error) {
629
+ process.stderr.write(`tiphys gates gate-classes: ${singleLine(error.message ?? String(error))}\n`);
630
+ process.exitCode = EXIT_GATE_ERROR;
631
+ }
632
+ }
633
+ export { judgeClass, readCompilerOutput, registryGateIds, REQUIRED_CLASSES };