@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.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- 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
|
+
}
|