@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,932 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, 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 { describeAdmittedVerdicts, describeOffHeadVerdicts, loadCommittedVerdicts, missingRegimeDocument, readReviewFamilies, registeredChecks, relateDeclaredHead, resolveCorpusSource, } from "../checks.js";
|
|
8
|
+
import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine } from "../task.js";
|
|
9
|
+
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
10
|
+
/**
|
|
11
|
+
* THE MERGE PRECONDITION READER (kernel plan M4, M4-P12; DR-0012, DR-0036,
|
|
12
|
+
* T-009, R-064, R-065a).
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS IS NOT. It is not a merge command. M4-D-09 puts the merge
|
|
15
|
+
* capability in the plugin at cutover and DR-0036 keeps merge authority with
|
|
16
|
+
* the current process for the whole of M4. What is missing today is not the
|
|
17
|
+
* ability to merge; it is any ARTIFACT saying the six conditions of
|
|
18
|
+
* delivery/decisions/DR-0012-delegated-merge-authority.md:22 to :27 held at the
|
|
19
|
+
* head that was merged. So this gate produces one ROW PER CONDITION, each
|
|
20
|
+
* carrying the head sha it was evaluated against, and the orchestrator reads it
|
|
21
|
+
* before merging, by hand.
|
|
22
|
+
*
|
|
23
|
+
* THE HAZARD CLASS, in the plan's words: A MERGE PRECONDITION CHECK THAT IS
|
|
24
|
+
* GREEN BECAUSE IT COULD NOT LOOK. Every arm below is written against one of
|
|
25
|
+
* its members, and each member is named where it is refused rather than in a
|
|
26
|
+
* list nobody rereads:
|
|
27
|
+
*
|
|
28
|
+
* a 401 piped into a `|| true` -> `probeApi` runs FIRST and an
|
|
29
|
+
* unreachable or refusing API is
|
|
30
|
+
* `error` with units 0. CLAUDE.md
|
|
31
|
+
* standing warning 6 records this shape
|
|
32
|
+
* costing a whole watcher.
|
|
33
|
+
* an API failure called N/A -> `not-applicable` is reachable from
|
|
34
|
+
* exactly one place here, the
|
|
35
|
+
* NO-VERDICT-AT-THIS-HEAD arm, and it
|
|
36
|
+
* carries an evaluated precondition
|
|
37
|
+
* record (SC-011).
|
|
38
|
+
* CI-green read off the BRANCH -> condition 4 compares
|
|
39
|
+
* `check_run.head_sha` against the head
|
|
40
|
+
* under evaluation and reddens when they
|
|
41
|
+
* differ, which is T-009 one scope down.
|
|
42
|
+
* scope satisfied by a record EXISTING -> condition 5 reads the record's
|
|
43
|
+
* STATUS word.
|
|
44
|
+
* arbitration satisfied by a file EXISTING -> condition 6 requires the
|
|
45
|
+
* document to name BOTH verdicts and the
|
|
46
|
+
* SAME head.
|
|
47
|
+
* a ruleset read as a default -> an empty body from the ruleset API is
|
|
48
|
+
* `error`, never an assumed shape.
|
|
49
|
+
*
|
|
50
|
+
* WHY CONDITIONS 1 AND 2 ARE COMPOSED AND NOT REIMPLEMENTED (plan step 6).
|
|
51
|
+
* M4-P10 shipped `dual-review-decorrelation` (DR-0012 condition 1) and
|
|
52
|
+
* `verdict-pair-approves` (condition 2) as derived checks in `src/checks.ts`,
|
|
53
|
+
* and `scripts/check-dual-review.mjs` is the runner around them. This gate is a
|
|
54
|
+
* SECOND CALLER of the same exported primitives rather than a second copy of
|
|
55
|
+
* the rules: it resolves the corpus with `resolveCorpusSource`, refuses a
|
|
56
|
+
* context whose delivery regime is undeterminable with `missingRegimeDocument`,
|
|
57
|
+
* reads the DR-0038 declaration with `readReviewFamilies`, and then runs the
|
|
58
|
+
* two checks BY ID out of `registeredChecks()`.
|
|
59
|
+
*
|
|
60
|
+
* BY ID, AND A MISSING REGISTRATION IS `error`. `scripts/check-dual-review.mjs`
|
|
61
|
+
* prints `0 registered check(s) named X` beside its own verdict, which is the
|
|
62
|
+
* Kind B witness that deregistering the check is visible. On the MERGE path a
|
|
63
|
+
* condition with no check behind it is a condition nobody evaluated, so here it
|
|
64
|
+
* is `error` and never a green with the comparison quietly skipped.
|
|
65
|
+
*
|
|
66
|
+
* WHY THE ROW STATUS AND THE GATE STATUS ARE NOT THE SAME WORD, and this is the
|
|
67
|
+
* one place the plan and the criteria say different things, so the reconciliation
|
|
68
|
+
* is written down rather than left to a reader. Plan step 4
|
|
69
|
+
* (delivery/plan/kernel-plan-m4.md:1911) says an ABSENT scope record is `error`;
|
|
70
|
+
* criterion 4 (delivery/plan/kernel-plan-m4.md:1938) says condition 5 is RED when
|
|
71
|
+
* no record exists. Both hold at once because they speak about different objects:
|
|
72
|
+
* the CONDITION is unsatisfied either way and its row reads `red` with a reason
|
|
73
|
+
* naming which arm it was, while the GATE could not reach a verdict about an
|
|
74
|
+
* instrument that is not there, so the gate's own status is `error`. Everything a
|
|
75
|
+
* reader needs is printed: the row, its reason, and the gate word.
|
|
76
|
+
*/
|
|
77
|
+
/* -------------------------------------------------------------------- */
|
|
78
|
+
/* Plumbing */
|
|
79
|
+
/* -------------------------------------------------------------------- */
|
|
80
|
+
const GATE_ID = "merge-preconditions";
|
|
81
|
+
const UNIT_LABEL = "merge preconditions evaluated";
|
|
82
|
+
const DEFAULT_API_BASE = "https://api.github.com";
|
|
83
|
+
const DECORRELATION_CHECK_ID = "dual-review-decorrelation";
|
|
84
|
+
const PAIR_CHECK_ID = "verdict-pair-approves";
|
|
85
|
+
const REQUIRED_CHECK_CONTEXT = "gates";
|
|
86
|
+
/**
|
|
87
|
+
* The id of the one precondition this gate can report unmet.
|
|
88
|
+
*
|
|
89
|
+
* SC-011: `not-applicable` ASSERTS that a precondition was evaluated. The
|
|
90
|
+
* precondition here is "a merge is being proposed at this head", evidenced by
|
|
91
|
+
* at least one committed verdict document naming it. A head with no such
|
|
92
|
+
* document is not a merge waiting on six conditions; it is a branch nobody has
|
|
93
|
+
* reviewed yet, and reporting red for that would make the gate unusable on
|
|
94
|
+
* every push while making it say something false.
|
|
95
|
+
*/
|
|
96
|
+
const PRECONDITION_ID = "merge-preconditions-verdict-names-this-head";
|
|
97
|
+
const USAGE = "usage: node src/gates/merge-preconditions.ts --result <file> --head <sha> --phase <id> " +
|
|
98
|
+
"[--evidence <dir>] [--context <dir>] [--repo <owner/name>] [--api-base <url>] " +
|
|
99
|
+
"[--scope-record <file>] [--arbitrations <dir>]";
|
|
100
|
+
const SINGLE_VALUE_FLAGS = [
|
|
101
|
+
"--result",
|
|
102
|
+
"--evidence",
|
|
103
|
+
"--head",
|
|
104
|
+
"--phase",
|
|
105
|
+
"--context",
|
|
106
|
+
"--repo",
|
|
107
|
+
"--api-base",
|
|
108
|
+
"--scope-record",
|
|
109
|
+
"--arbitrations",
|
|
110
|
+
];
|
|
111
|
+
function parseFlags(args) {
|
|
112
|
+
const flags = {};
|
|
113
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
114
|
+
const flag = args[index];
|
|
115
|
+
const value = args[index + 1];
|
|
116
|
+
if (flag === undefined) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (!SINGLE_VALUE_FLAGS.includes(flag)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (value === undefined || value.startsWith("--")) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
flags[flag.slice(2)] = value;
|
|
126
|
+
index += 1;
|
|
127
|
+
}
|
|
128
|
+
return flags;
|
|
129
|
+
}
|
|
130
|
+
function usageError(message) {
|
|
131
|
+
if (message !== undefined) {
|
|
132
|
+
process.stderr.write(`tiphys gates ${GATE_ID}: ${message}\n`);
|
|
133
|
+
}
|
|
134
|
+
process.stderr.write(`${USAGE}\n`);
|
|
135
|
+
return EX_USAGE;
|
|
136
|
+
}
|
|
137
|
+
function now() {
|
|
138
|
+
return new Date().toISOString();
|
|
139
|
+
}
|
|
140
|
+
function absolute(path) {
|
|
141
|
+
return isAbsolute(path) ? path : resolve(process.cwd(), path);
|
|
142
|
+
}
|
|
143
|
+
export function renderRow(row) {
|
|
144
|
+
return `${row.id} (${row.clause}) at ${row.head}: ${row.status} -- ${row.sentence}`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The gate word for a set of rows.
|
|
148
|
+
*
|
|
149
|
+
* `error` DOMINATES `red`, which is M2-C-3's direction: a run that could not
|
|
150
|
+
* look at one condition has not reached a verdict about the merge, and a red
|
|
151
|
+
* would be a verdict. A red that is also accompanied by an error still reports
|
|
152
|
+
* error, and both rows print either way, so nothing is hidden by the ordering.
|
|
153
|
+
*/
|
|
154
|
+
export function gateStatusForRows(rows) {
|
|
155
|
+
if (rows.some((row) => row.status === "error")) {
|
|
156
|
+
return "error";
|
|
157
|
+
}
|
|
158
|
+
if (rows.some((row) => row.status === "red")) {
|
|
159
|
+
return "red";
|
|
160
|
+
}
|
|
161
|
+
return "green";
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* ONE REQUEST, AND THE FAILURE ARM IS WRITTEN FIRST.
|
|
165
|
+
*
|
|
166
|
+
* CLAUDE.md standing warning 6 records the exact defect this shape exists
|
|
167
|
+
* against: a watcher whose failure arm was a `.catch(() => {})` emitted nothing
|
|
168
|
+
* and was indistinguishable from a run still in progress. There is no catch
|
|
169
|
+
* here that returns a value the caller can mistake for data: a transport
|
|
170
|
+
* failure becomes `{ok: false, reason}` and every caller turns that into
|
|
171
|
+
* `error`.
|
|
172
|
+
*
|
|
173
|
+
* NO CREDENTIAL IS READ, AND THAT IS A RULE RATHER THAN AN OVERSIGHT.
|
|
174
|
+
* test/m2-exit-test.test.ts:425 asserts, by grepping every file under
|
|
175
|
+
* `src/gates/`, that no production gate reads a LITERAL-NAMED environment
|
|
176
|
+
* variable, because such a read is an ambient switch that changes a gate's
|
|
177
|
+
* reported status with nothing in the record to say so. An earlier draft of
|
|
178
|
+
* this module read `GH_TOKEN` and that test caught it, which is the guard
|
|
179
|
+
* working. Measured 2026-09-17 from this container, with NO Authorization
|
|
180
|
+
* header at all: `GET /repos/{slug}`, `GET /repos/{slug}/rulesets` and
|
|
181
|
+
* `GET /repos/{slug}/commits/{sha}/check-runs` each answered HTTP 200, because
|
|
182
|
+
* the agent proxy substitutes credentials on the way out and the value in
|
|
183
|
+
* `GH_TOKEN` is irrelevant (CLAUDE.md standing warning 6's invalid-token
|
|
184
|
+
* control measures the same thing). WHAT THIS COSTS, recorded rather than left
|
|
185
|
+
* to be found: in a deployment where the API genuinely requires a credential,
|
|
186
|
+
* every request here answers 401 or 404 and the gate reports `error`. That is
|
|
187
|
+
* the fail-closed direction and never a silent pass, and supplying a token
|
|
188
|
+
* would have to be a DECLARED FLAG in the registry command rather than an
|
|
189
|
+
* ambient environment read.
|
|
190
|
+
*/
|
|
191
|
+
export async function requestJson(url) {
|
|
192
|
+
const headers = {
|
|
193
|
+
accept: "application/vnd.github+json",
|
|
194
|
+
"user-agent": "tiphys-merge-preconditions",
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
const response = await fetch(url, { headers });
|
|
198
|
+
const body = await response.text();
|
|
199
|
+
return { ok: true, status: response.status, body };
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const message = error.message ?? String(error);
|
|
203
|
+
const cause = error.cause;
|
|
204
|
+
const detail = cause === undefined
|
|
205
|
+
? message
|
|
206
|
+
: `${message} (${String(cause.code ?? "")}${cause.message === undefined ? "" : ` ${cause.message}`})`;
|
|
207
|
+
return { ok: false, reason: `GET ${url} could not be performed: ${singleLine(detail)}` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* PARSE A RESPONSE BODY, AND AN EMPTY ONE IS A FAILURE RATHER THAN A DEFAULT.
|
|
212
|
+
*
|
|
213
|
+
* Criterion 6 is the whole reason this is a named function: the ruleset API
|
|
214
|
+
* answering with a zero-length body must produce `error`, because a default
|
|
215
|
+
* here is the silent pass this phase exists against. `JSON.parse("")` throws,
|
|
216
|
+
* so the empty case would reach the same place anyway; it is separated out so
|
|
217
|
+
* the REASON a reader is given names the emptiness rather than a parser
|
|
218
|
+
* message that says nothing about what happened.
|
|
219
|
+
*/
|
|
220
|
+
export function readJsonBody(url, response) {
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
return { ok: false, reason: response.reason };
|
|
223
|
+
}
|
|
224
|
+
if (response.status < 200 || response.status > 299) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
reason: `GET ${url} answered HTTP ${String(response.status)}, so nothing was read from it`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (response.body.trim() === "") {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
reason: `GET ${url} answered HTTP ${String(response.status)} with an EMPTY BODY; a merge ` +
|
|
234
|
+
"precondition assumed from an empty answer is the silent pass this gate exists against",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
return { ok: true, value: JSON.parse(response.body) };
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
reason: `GET ${url} answered a body that is not JSON: ${singleLine(error.message)}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* `owner/name` out of a git remote URL, or undefined when the URL is not one.
|
|
249
|
+
*
|
|
250
|
+
* DERIVED RATHER THAN WRITTEN INTO THE REGISTRY, and the reason is DR-0029: the
|
|
251
|
+
* kernel is just another project under the scheme, so a registry entry naming
|
|
252
|
+
* ONE repository would be the kernel's registry claiming to be everybody's. The
|
|
253
|
+
* remote is the fact this gate is actually about.
|
|
254
|
+
*/
|
|
255
|
+
export function slugFromRemote(url) {
|
|
256
|
+
const trimmed = url.trim().replace(/\.git$/, "");
|
|
257
|
+
const match = /(?:[:/])([^/:]+)\/([^/]+)$/.exec(trimmed);
|
|
258
|
+
if (match === null) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
return `${match[1]}/${match[2]}`;
|
|
262
|
+
}
|
|
263
|
+
function slugFromGit(contextDirectory) {
|
|
264
|
+
const run = spawnSync("git", ["-C", contextDirectory, "remote", "get-url", "origin"], {
|
|
265
|
+
encoding: "utf8",
|
|
266
|
+
});
|
|
267
|
+
if (run.status !== 0) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
return slugFromRemote(run.stdout ?? "");
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Condition 4, and the comparison that makes it worth having.
|
|
274
|
+
*
|
|
275
|
+
* T-009 ONE SCOPE DOWN. "CI is green" is never a complete sentence: the
|
|
276
|
+
* complete one names the event and the HEAD SHA. A check that asked the API for
|
|
277
|
+
* the newest run on the BRANCH would report green off a run for an earlier
|
|
278
|
+
* head, which is exactly the state this repository spent four hours and
|
|
279
|
+
* twenty-one minutes in. So every check run the API returns is compared on
|
|
280
|
+
* `head_sha` and one that names a different commit is reported as the different
|
|
281
|
+
* commit it is, never counted.
|
|
282
|
+
*/
|
|
283
|
+
export function judgeCheckRuns(head, runs, context) {
|
|
284
|
+
const named = runs.filter((run) => String(run.name ?? "") === context);
|
|
285
|
+
if (named.length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
sentence: `the API returned ${String(runs.length)} check run(s) and none is named ${context}, ` +
|
|
289
|
+
"so nothing asserts this head was built",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const forThisHead = named.filter((run) => String(run.head_sha ?? "").toLowerCase() === head.toLowerCase());
|
|
293
|
+
if (forThisHead.length === 0) {
|
|
294
|
+
const others = [...new Set(named.map((run) => String(run.head_sha ?? "(no head_sha)")))];
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
sentence: `every ${context} check run the API returned names a DIFFERENT head (${others.join(", ")}); ` +
|
|
298
|
+
`a green run for an earlier head is not evidence about ${head}`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const succeeded = forThisHead.filter((run) => String(run.status ?? "") === "completed" && String(run.conclusion ?? "") === "success");
|
|
302
|
+
if (succeeded.length === 0) {
|
|
303
|
+
const seen = forThisHead.map((run) => `${String(run.status ?? "(no status)")}/${String(run.conclusion ?? "(no conclusion)")}`);
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
sentence: `${String(forThisHead.length)} ${context} check run(s) name this head and none concluded ` +
|
|
307
|
+
`success (${seen.join(", ")})`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
sentence: `${String(succeeded.length)} ${context} check run(s) concluded success with head_sha equal ` +
|
|
313
|
+
"to the head under evaluation",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/* -------------------------------------------------------------------- */
|
|
317
|
+
/* Condition 5: the scope gate's own record */
|
|
318
|
+
/* -------------------------------------------------------------------- */
|
|
319
|
+
/**
|
|
320
|
+
* Condition 5, and the distinction the plan's hazard list names: the condition
|
|
321
|
+
* is satisfied by the record's STATUS, never by the record's EXISTENCE.
|
|
322
|
+
*
|
|
323
|
+
* The absent arm and the red arm are DIFFERENT REASONS on purpose (criterion
|
|
324
|
+
* 4). `instrument` is what the caller turns into the gate's own word: an absent
|
|
325
|
+
* record means the gate could not look, which is `error` at gate level while
|
|
326
|
+
* the condition itself is unsatisfied.
|
|
327
|
+
*/
|
|
328
|
+
export function judgeScopeRecord(path, read) {
|
|
329
|
+
if (read.kind === "absent") {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
instrument: "missing",
|
|
333
|
+
sentence: `no scope gate record exists at ${path}, so whether the change is the change that was ` +
|
|
334
|
+
"promised is UNKNOWN; an absent record is not a passing one",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (read.kind === "refused") {
|
|
338
|
+
return { ok: false, instrument: "missing", sentence: read.reason };
|
|
339
|
+
}
|
|
340
|
+
let parsed;
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(read.body);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
instrument: "missing",
|
|
348
|
+
sentence: `the scope gate record at ${path} does not parse as JSON: ${singleLine(error.message)}`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const record = parsed;
|
|
352
|
+
const status = String(record?.status ?? "");
|
|
353
|
+
if (status === "") {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
instrument: "missing",
|
|
357
|
+
sentence: `the scope gate record at ${path} carries no status, so it asserts nothing`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (status !== "green") {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
instrument: "present",
|
|
364
|
+
sentence: `the scope gate record at ${path} reads ${status}, not green`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
instrument: "present",
|
|
370
|
+
sentence: `the scope gate record at ${path} reads green over ${String(record?.units ?? 0)} unit(s)`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/* -------------------------------------------------------------------- */
|
|
374
|
+
/* Condition 6: the arbitration document */
|
|
375
|
+
/* -------------------------------------------------------------------- */
|
|
376
|
+
const HEX_TOKEN = /\b[0-9a-f]{7,40}\b/gi;
|
|
377
|
+
/**
|
|
378
|
+
* Condition 6, and EXISTENCE IS NOT THE TEST.
|
|
379
|
+
*
|
|
380
|
+
* Criterion 5 asks for two structurally different members of that class, and
|
|
381
|
+
* they are structurally different because they fail on different halves of the
|
|
382
|
+
* same document: one that names only ONE of the two verdicts has read half the
|
|
383
|
+
* evidence, and one that names a DIFFERENT HEAD has read the right number of
|
|
384
|
+
* documents about the wrong commit. A check that tested for the file's presence
|
|
385
|
+
* passes both.
|
|
386
|
+
*
|
|
387
|
+
* VERDICTS ARE IDENTIFIED BY FILE NAME because `schemas/verdict.schema.json`
|
|
388
|
+
* gives a verdict no id of its own; its required keys are kind, phase, head,
|
|
389
|
+
* verdict, produced-by, framing, review-contract, findings, criteria and
|
|
390
|
+
* deviations-judged. The existing arbitration documents in `delivery/review/`
|
|
391
|
+
* already cite their reviews by path, so the convention that exists is read
|
|
392
|
+
* rather than a field invented.
|
|
393
|
+
*
|
|
394
|
+
* THE FILE NAME AND NOT THE FULL PATH, and that is a measured correction rather
|
|
395
|
+
* than a preference. `loadCommittedVerdicts` returns ABSOLUTE paths when it
|
|
396
|
+
* falls back to the worktree and repository-relative ones when it reads a
|
|
397
|
+
* commit, so comparing whole paths made the same document resolve or not
|
|
398
|
+
* resolve depending on which corpus source happened to be chosen. The file name
|
|
399
|
+
* is the part that is stable across both, and it is the part an arbitration
|
|
400
|
+
* document's `- reviews:` line contains either way.
|
|
401
|
+
*/
|
|
402
|
+
export function judgeArbitration(path, head, verdictPaths, read) {
|
|
403
|
+
if (read.kind === "absent") {
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
sentence: `no arbitration document exists at ${path}, so no recorded ruling covers this head`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (read.kind === "refused") {
|
|
410
|
+
return { ok: false, sentence: read.reason };
|
|
411
|
+
}
|
|
412
|
+
const body = read.body;
|
|
413
|
+
const missing = verdictPaths.filter((verdictPath) => !body.includes(basename(verdictPath)));
|
|
414
|
+
if (missing.length > 0) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
sentence: `the arbitration document ${path} EXISTS and names ${String(verdictPaths.length - missing.length)} ` +
|
|
418
|
+
`of the ${String(verdictPaths.length)} verdict(s) for this head; it does not name ` +
|
|
419
|
+
`${missing.map((verdictPath) => basename(verdictPath)).join(", ")}, so it did not arbitrate between them`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const tokens = [...new Set((body.match(HEX_TOKEN) ?? []).map((token) => token.toLowerCase()))];
|
|
423
|
+
const matching = tokens.filter((token) => head.toLowerCase().startsWith(token));
|
|
424
|
+
if (matching.length === 0) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
sentence: `the arbitration document ${path} EXISTS and names ${String(verdictPaths.length)} verdict(s) ` +
|
|
428
|
+
`but no commit-shaped token in it is a prefix of ${head}` +
|
|
429
|
+
(tokens.length === 0
|
|
430
|
+
? "; it names no head at all"
|
|
431
|
+
: `; it names ${tokens.join(", ")}, which is a ruling about a different head`),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
sentence: `the arbitration document ${path} names all ${String(verdictPaths.length)} verdict(s) for ` +
|
|
437
|
+
`this head and carries ${matching.join(", ")}, a prefix of the head under evaluation`,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The ruleset encoding, and the TWO members of criterion 7.
|
|
442
|
+
*
|
|
443
|
+
* `enforcement: disabled` is PRESENT-BUT-TOOTHLESS and a
|
|
444
|
+
* `required_status_checks` rule that does not name `gates` is
|
|
445
|
+
* PRESENT-BUT-WRONG. They are different failures and they are reported with
|
|
446
|
+
* different sentences, because a reader told only "the ruleset is wrong" has to
|
|
447
|
+
* go and find out which.
|
|
448
|
+
*
|
|
449
|
+
* R-065a IS DATA, NOT A VERDICT. Squash-only is an OWNER action the owner has
|
|
450
|
+
* deferred; the plan (step 7) says report its state and do not judge it, so
|
|
451
|
+
* `allowed_merge_methods` is printed and never turns this row red.
|
|
452
|
+
*/
|
|
453
|
+
export function judgeRulesets(rulesets) {
|
|
454
|
+
if (rulesets.length === 0) {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
sentence: "the API returned no branch ruleset at all, so nothing protects the default branch",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const active = rulesets.filter((ruleset) => ruleset.enforcement === "active");
|
|
461
|
+
if (active.length === 0) {
|
|
462
|
+
const seen = rulesets.map((ruleset) => `${ruleset.name}: ${ruleset.enforcement}`);
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
sentence: `${String(rulesets.length)} ruleset(s) exist and NONE is enforcement active (${seen.join(", ")}); ` +
|
|
466
|
+
"a ruleset that is present and disabled protects nothing",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const withGates = active.filter((ruleset) => ruleset.rules.some((rule) => String(rule.type ?? "") === "required_status_checks" &&
|
|
470
|
+
(rule.parameters?.["required_status_checks"] ?? []).some((check) => String(check?.context ?? "") === REQUIRED_CHECK_CONTEXT)));
|
|
471
|
+
if (withGates.length === 0) {
|
|
472
|
+
const named = active.flatMap((ruleset) => ruleset.rules
|
|
473
|
+
.filter((rule) => String(rule.type ?? "") === "required_status_checks")
|
|
474
|
+
.flatMap((rule) => (rule.parameters?.["required_status_checks"] ??
|
|
475
|
+
[]).map((check) => String(check?.context ?? ""))));
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
sentence: `${String(active.length)} active ruleset(s) exist and none requires the status check ` +
|
|
479
|
+
`${REQUIRED_CHECK_CONTEXT}` +
|
|
480
|
+
(named.length === 0
|
|
481
|
+
? "; none carries a required_status_checks rule at all"
|
|
482
|
+
: `; the contexts they require are ${named.join(", ")}`),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const merge = active.flatMap((ruleset) => ruleset.rules
|
|
486
|
+
.filter((rule) => String(rule.type ?? "") === "pull_request")
|
|
487
|
+
.flatMap((rule) => rule.parameters?.["allowed_merge_methods"] ?? []));
|
|
488
|
+
const methods = [...new Set(merge)];
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
sentence: `${withGates.map((ruleset) => ruleset.name).join(", ")} is enforcement active and requires ` +
|
|
492
|
+
`the status check ${REQUIRED_CHECK_CONTEXT} (R-064). R-065a DATA, not a verdict: ` +
|
|
493
|
+
`allowed_merge_methods = ${methods.length === 0 ? "(none reported)" : methods.join(", ")}`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Condition 3, and what it DOES and DOES NOT establish.
|
|
498
|
+
*
|
|
499
|
+
* DR-0012 condition 3 (delivery/decisions/DR-0012-delegated-merge-authority.md:24)
|
|
500
|
+
* says both reviewers were given the phase's acceptance criteria as their
|
|
501
|
+
* contract and both WALKED OR EXECUTED them. What is reachable from the verdict
|
|
502
|
+
* documents alone is that each one declares a `review-contract` and carries a
|
|
503
|
+
* non-empty `criteria[]` whose entries each record `met`. Whether that walk
|
|
504
|
+
* COVERS every acceptance criterion the plan declares is a comparison against a
|
|
505
|
+
* different document and it is the shipped Kind B check
|
|
506
|
+
* `verdict-criteria-complete`, which resolves a `plan.yaml` out of its context.
|
|
507
|
+
* This repository has no such document, so running it here would make this row
|
|
508
|
+
* permanently error about the instrument rather than about the merge. The row's
|
|
509
|
+
* sentence therefore says which half it established, and the other half is
|
|
510
|
+
* recorded as residue in delivery/work-history/m4-p12.md rather than implied.
|
|
511
|
+
*/
|
|
512
|
+
export function judgeCriteriaWalked(verdicts) {
|
|
513
|
+
const faults = [];
|
|
514
|
+
for (const verdict of verdicts) {
|
|
515
|
+
const contract = String(verdict.record["review-contract"] ?? "");
|
|
516
|
+
if (contract === "") {
|
|
517
|
+
faults.push(`${verdict.path} declares no review-contract`);
|
|
518
|
+
}
|
|
519
|
+
const criteria = verdict.record["criteria"];
|
|
520
|
+
if (!Array.isArray(criteria) || criteria.length === 0) {
|
|
521
|
+
faults.push(`${verdict.path} walks no acceptance criterion`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const unwalked = criteria.filter((entry) => entry?.met === undefined);
|
|
525
|
+
if (unwalked.length > 0) {
|
|
526
|
+
faults.push(`${verdict.path} carries ${String(unwalked.length)} criterion entr(ies) with no met field`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (faults.length > 0) {
|
|
530
|
+
return { ok: false, sentence: faults.join("; ") };
|
|
531
|
+
}
|
|
532
|
+
const contracts = verdicts.map((verdict) => String(verdict.record["review-contract"] ?? ""));
|
|
533
|
+
return {
|
|
534
|
+
ok: true,
|
|
535
|
+
sentence: `all ${String(verdicts.length)} verdict(s) declare a review-contract (${contracts.join(", ")}) ` +
|
|
536
|
+
"and walk at least one acceptance criterion with a recorded met; COMPLETENESS against the " +
|
|
537
|
+
"plan's acceptance list is the separate check verdict-criteria-complete and is NOT asserted here",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Run ONE registered check over the verdicts for this head.
|
|
542
|
+
*
|
|
543
|
+
* A check that is NOT REGISTERED is `error`, never a green with the comparison
|
|
544
|
+
* skipped, which is the whole of the "green because it could not look" class
|
|
545
|
+
* applied to this gate's own instrument.
|
|
546
|
+
*/
|
|
547
|
+
function runRegisteredCheck(id, verdicts, contextDirectory) {
|
|
548
|
+
const selected = registeredChecks().filter((check) => check.id === id);
|
|
549
|
+
if (selected.length === 0) {
|
|
550
|
+
return {
|
|
551
|
+
status: "error",
|
|
552
|
+
sentence: `0 registered check(s) named ${id}, so nothing evaluated this condition; a condition with ` +
|
|
553
|
+
"no check behind it is a condition nobody looked at",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const messages = new Set();
|
|
557
|
+
for (const verdict of verdicts) {
|
|
558
|
+
for (const check of selected) {
|
|
559
|
+
const outcome = check.run(verdict.record, contextDirectory);
|
|
560
|
+
for (const violation of outcome.violations) {
|
|
561
|
+
messages.add(`${violation.pointer} ${violation.message}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (messages.size > 0) {
|
|
566
|
+
return { status: "red", sentence: [...messages].sort().join(" | ") };
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
status: "green",
|
|
570
|
+
sentence: `${id} reported no violation over the ${String(verdicts.length)} verdict(s) committed for ` +
|
|
571
|
+
"this head",
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/* -------------------------------------------------------------------- */
|
|
575
|
+
/* The gate */
|
|
576
|
+
/* -------------------------------------------------------------------- */
|
|
577
|
+
function emit(resultPath, fields, rows) {
|
|
578
|
+
const result = makeGateResult(fields);
|
|
579
|
+
const refusal = refuseOpenForWrite(resultPath);
|
|
580
|
+
if (refusal !== undefined) {
|
|
581
|
+
process.stderr.write(`tiphys gates ${GATE_ID}: ${refusal}\n`);
|
|
582
|
+
return EXIT_GATE_ERROR;
|
|
583
|
+
}
|
|
584
|
+
const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
|
|
585
|
+
if (!written.ok) {
|
|
586
|
+
process.stderr.write(`tiphys gates ${GATE_ID}: ${written.reason}\n`);
|
|
587
|
+
return EXIT_GATE_ERROR;
|
|
588
|
+
}
|
|
589
|
+
process.stdout.write(`${result.gate}: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
|
|
590
|
+
for (const row of rows) {
|
|
591
|
+
process.stdout.write(`${renderRow(row)}\n`);
|
|
592
|
+
}
|
|
593
|
+
if (result.detail !== "") {
|
|
594
|
+
process.stdout.write(`${result.detail}\n`);
|
|
595
|
+
}
|
|
596
|
+
return exitCodeForStatus(result.status);
|
|
597
|
+
}
|
|
598
|
+
function read(path) {
|
|
599
|
+
const result = readRegularFileIfPresent(path);
|
|
600
|
+
if (result.kind === "read") {
|
|
601
|
+
return { kind: "read", body: result.body };
|
|
602
|
+
}
|
|
603
|
+
if (result.kind === "absent") {
|
|
604
|
+
return { kind: "absent" };
|
|
605
|
+
}
|
|
606
|
+
return { kind: "refused", reason: result.reason };
|
|
607
|
+
}
|
|
608
|
+
/** Every branch ruleset, with its rules, or the reason none could be read. */
|
|
609
|
+
async function readRulesets(apiBase, slug) {
|
|
610
|
+
const listUrl = `${apiBase}/repos/${slug}/rulesets?includes_parents=true`;
|
|
611
|
+
const listed = readJsonBody(listUrl, await requestJson(listUrl));
|
|
612
|
+
if (!listed.ok) {
|
|
613
|
+
return { ok: false, reason: listed.reason };
|
|
614
|
+
}
|
|
615
|
+
if (!Array.isArray(listed.value)) {
|
|
616
|
+
return { ok: false, reason: `GET ${listUrl} answered a body that is not an array of rulesets` };
|
|
617
|
+
}
|
|
618
|
+
const rulesets = [];
|
|
619
|
+
for (const entry of listed.value) {
|
|
620
|
+
if (String(entry["target"] ?? "branch") !== "branch") {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
/* THE DETAIL DOCUMENT IS AUTHORITATIVE WHEN IT IS FETCHED, AND THAT IS NOT
|
|
624
|
+
TIDINESS. The real list endpoint answers with SUMMARIES carrying no
|
|
625
|
+
`rules`, so the rules always come from the detail; reading `enforcement`
|
|
626
|
+
off the summary while reading the rules off the detail would be one
|
|
627
|
+
verdict assembled from two documents that can disagree. Whichever
|
|
628
|
+
document supplied the rules supplies the enforcement word too. */
|
|
629
|
+
let source = entry;
|
|
630
|
+
if (!Array.isArray(entry["rules"])) {
|
|
631
|
+
const detailUrl = `${apiBase}/repos/${slug}/rulesets/${String(entry["id"] ?? "")}`;
|
|
632
|
+
const detail = readJsonBody(detailUrl, await requestJson(detailUrl));
|
|
633
|
+
if (!detail.ok) {
|
|
634
|
+
return { ok: false, reason: detail.reason };
|
|
635
|
+
}
|
|
636
|
+
source = detail.value;
|
|
637
|
+
if (!Array.isArray(source["rules"])) {
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
reason: `GET ${detailUrl} answered a ruleset carrying no rules array, so what it enforces is unknown`,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
rulesets.push({
|
|
645
|
+
id: String(source["id"] ?? entry["id"] ?? ""),
|
|
646
|
+
name: String(source["name"] ?? entry["name"] ?? "(unnamed)"),
|
|
647
|
+
enforcement: String(source["enforcement"] ?? "(none reported)"),
|
|
648
|
+
rules: source["rules"],
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return { ok: true, rulesets };
|
|
652
|
+
}
|
|
653
|
+
export async function runGate(flags) {
|
|
654
|
+
const startedAt = now();
|
|
655
|
+
const resultPath = flags.result;
|
|
656
|
+
const head = flags.head.toLowerCase();
|
|
657
|
+
const phase = flags.phase.toLowerCase();
|
|
658
|
+
const contextDirectory = absolute(flags.context ?? process.cwd());
|
|
659
|
+
const apiBase = (flags["api-base"] ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
660
|
+
const slug = flags.repo ?? slugFromGit(contextDirectory) ?? "";
|
|
661
|
+
const shared = { gate: GATE_ID, unitLabel: UNIT_LABEL, startedAt };
|
|
662
|
+
if (slug === "") {
|
|
663
|
+
return emit(resultPath, {
|
|
664
|
+
...shared,
|
|
665
|
+
status: "error",
|
|
666
|
+
units: 0,
|
|
667
|
+
endedAt: now(),
|
|
668
|
+
detail: "no repository could be established: `git -C <context> remote get-url origin` named none " +
|
|
669
|
+
"and --repo <owner/name> was not supplied. Without one, condition 4 and the " +
|
|
670
|
+
"branch-protection encoding have nothing to ask about",
|
|
671
|
+
}, []);
|
|
672
|
+
}
|
|
673
|
+
/* THE PROBE COMES FIRST (plan step 1 and criterion 2). An unreachable API is
|
|
674
|
+
`error` with units 0 and a reason naming the failure, and the test asserts
|
|
675
|
+
the STATUS WORD rather than the detail string, because the mutant this arm
|
|
676
|
+
exists against is a request wrapped in a catch that returns "unknown" and
|
|
677
|
+
reports green. CLAUDE.md standing warning 6 says REST reachability here is
|
|
678
|
+
a thing to PROBE at the start of a run that depends on it, in either
|
|
679
|
+
direction, so this is the probe and not an assumption. */
|
|
680
|
+
const probeUrl = `${apiBase}/repos/${slug}`;
|
|
681
|
+
const probe = readJsonBody(probeUrl, await requestJson(probeUrl));
|
|
682
|
+
if (!probe.ok) {
|
|
683
|
+
return emit(resultPath, {
|
|
684
|
+
...shared,
|
|
685
|
+
status: "error",
|
|
686
|
+
units: 0,
|
|
687
|
+
endedAt: now(),
|
|
688
|
+
detail: `the GitHub REST API could not be reached, so DR-0012 condition 4 and the ` +
|
|
689
|
+
`branch-protection encoding were not evaluated and no merge verdict was reached: ${probe.reason}`,
|
|
690
|
+
}, []);
|
|
691
|
+
}
|
|
692
|
+
/* THE CORPUS, READ THROUGH THE SHIPPED PRIMITIVES (plan step 6). Every
|
|
693
|
+
refusal below is `error` rather than red, and each is the one
|
|
694
|
+
`scripts/check-dual-review.mjs` already makes at the same layer: a merge
|
|
695
|
+
gate that cannot establish the regime, cannot read the DR-0038 declaration,
|
|
696
|
+
or cannot examine a document that looks like a verdict has NOT reached a
|
|
697
|
+
verdict (M2-C-3). */
|
|
698
|
+
const source = resolveCorpusSource(contextDirectory);
|
|
699
|
+
const regime = missingRegimeDocument(contextDirectory, source);
|
|
700
|
+
if (regime !== undefined) {
|
|
701
|
+
return emit(resultPath, { ...shared, status: "error", units: 0, endedAt: now(), detail: regime.reason }, []);
|
|
702
|
+
}
|
|
703
|
+
const families = readReviewFamilies(contextDirectory);
|
|
704
|
+
if (families.kind === "error") {
|
|
705
|
+
return emit(resultPath, { ...shared, status: "error", units: 0, endedAt: now(), detail: families.reason }, []);
|
|
706
|
+
}
|
|
707
|
+
const corpus = loadCommittedVerdicts(contextDirectory, source);
|
|
708
|
+
if (!corpus.ok) {
|
|
709
|
+
return emit(resultPath, { ...shared, status: "error", units: 0, endedAt: now(), detail: corpus.reason }, []);
|
|
710
|
+
}
|
|
711
|
+
if (corpus.unexaminable.length > 0) {
|
|
712
|
+
return emit(resultPath, {
|
|
713
|
+
...shared,
|
|
714
|
+
status: "error",
|
|
715
|
+
units: 0,
|
|
716
|
+
endedAt: now(),
|
|
717
|
+
detail: `${String(corpus.unexaminable.length)} document(s) could not be examined, so whether a ` +
|
|
718
|
+
`review refusing this head is among them is unknown: ` +
|
|
719
|
+
corpus.unexaminable.map((diagnostic) => diagnostic.message).join("; "),
|
|
720
|
+
}, []);
|
|
721
|
+
}
|
|
722
|
+
/* THE SECOND CALL SITE OF THE EQUALITY MECHANISM, FOUND BY DERIVATION AND
|
|
723
|
+
NOT BY A REVIEW (DR-0047 sweep round 2).
|
|
724
|
+
`scripts/check-dual-review.mjs` selected its corpus by comparing the
|
|
725
|
+
DECLARED head to the RUN head with `===`, and no real flow satisfies that:
|
|
726
|
+
reviewers read commit X, committing their verdicts produces X+1, and CI
|
|
727
|
+
audits X+1. This line was the same comparison, spelled once more, so this
|
|
728
|
+
gate's precondition ("a merge is being proposed at this head, evidenced by
|
|
729
|
+
a committed verdict naming it") could never be met either and every run
|
|
730
|
+
reported not-applicable.
|
|
731
|
+
|
|
732
|
+
The relation is now ancestry constrained to a paperwork-only gap, which is
|
|
733
|
+
the same rule and the same function both gates read it from, so the two
|
|
734
|
+
cannot drift into two answers about one question. An EQUAL head still
|
|
735
|
+
passes and touches git not at all (`relateDeclaredHead` answers that case
|
|
736
|
+
before any spawn), which matters here because `--head` comes from the CI
|
|
737
|
+
event and need not be an object in this checkout. */
|
|
738
|
+
const admitted = [];
|
|
739
|
+
const excluded = [];
|
|
740
|
+
const forHead = [];
|
|
741
|
+
for (const entry of corpus.verdicts) {
|
|
742
|
+
const declared = String(entry.record["head"] ?? "").toLowerCase();
|
|
743
|
+
const relation = relateDeclaredHead(contextDirectory, declared, head);
|
|
744
|
+
if (relation.kind === "same" || relation.kind === "evidence-only-ancestor") {
|
|
745
|
+
admitted.push({ path: entry.path, declared, relation });
|
|
746
|
+
forHead.push({ path: entry.path, record: entry.record });
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
excluded.push({ path: entry.path, declared, relation });
|
|
750
|
+
}
|
|
751
|
+
if (forHead.length === 0) {
|
|
752
|
+
/* SC-011: the ONE not-applicable arm, and it carries an EVALUATED
|
|
753
|
+
precondition rather than a silence. A head with no verdict naming it is
|
|
754
|
+
not a merge waiting on six conditions. */
|
|
755
|
+
const precondition = {
|
|
756
|
+
id: PRECONDITION_ID,
|
|
757
|
+
met: false,
|
|
758
|
+
reason: `no committed verdict document names head ${head}, so no merge is being proposed at this ` +
|
|
759
|
+
"head and DR-0012's conditions have no subject",
|
|
760
|
+
evidence: [
|
|
761
|
+
`${String(corpus.verdicts.length)} committed verdict document(s) were read and examined`,
|
|
762
|
+
`head under evaluation: ${head}`,
|
|
763
|
+
/* EVERY EXCLUDED DOCUMENT IS NAMED WITH THE ROUTE THAT EXCLUDED IT.
|
|
764
|
+
"There is no verdict here" and "there are two approving verdicts and
|
|
765
|
+
each reviewed something else" are different facts, and printing the
|
|
766
|
+
first for both is the fail-open direction `describeOffHeadVerdicts`
|
|
767
|
+
exists to close one gate along. */
|
|
768
|
+
...describeOffHeadVerdicts(excluded, head).map((line) => `EXCLUDED ${line}`),
|
|
769
|
+
],
|
|
770
|
+
};
|
|
771
|
+
return emit(resultPath, {
|
|
772
|
+
...shared,
|
|
773
|
+
status: "not-applicable",
|
|
774
|
+
units: 0,
|
|
775
|
+
endedAt: now(),
|
|
776
|
+
precondition,
|
|
777
|
+
detail: precondition.reason,
|
|
778
|
+
}, []);
|
|
779
|
+
}
|
|
780
|
+
const rows = [];
|
|
781
|
+
/* THE ADMISSION ROUTE IS A ROW, NOT A FOOTNOTE. Every other condition here
|
|
782
|
+
gets a row because a reader has to be able to see what was asserted; the
|
|
783
|
+
corpus SELECTION decides what all six conditions are about, so a run whose
|
|
784
|
+
verdicts were admitted by ANCESTRY rather than by naming this commit must
|
|
785
|
+
say so in the same place. Its status is green because selection succeeded:
|
|
786
|
+
a selection that found nothing does not reach this line at all, it reaches
|
|
787
|
+
the not-applicable arm above with every excluded document named. */
|
|
788
|
+
rows.push({
|
|
789
|
+
id: "verdict-selection",
|
|
790
|
+
clause: "DR-0047 the verdicts selected are evidence about THIS head",
|
|
791
|
+
status: "green",
|
|
792
|
+
head,
|
|
793
|
+
sentence: `${String(admitted.length)} verdict(s) admitted and ${String(excluded.length)} excluded; ` +
|
|
794
|
+
describeAdmittedVerdicts(admitted, head).join(" | ") +
|
|
795
|
+
(excluded.length === 0
|
|
796
|
+
? ""
|
|
797
|
+
: ` | EXCLUDED: ${describeOffHeadVerdicts(excluded, head).join(" | ")}`),
|
|
798
|
+
});
|
|
799
|
+
const condition1 = runRegisteredCheck(DECORRELATION_CHECK_ID, forHead, contextDirectory);
|
|
800
|
+
rows.push({
|
|
801
|
+
id: "condition-1",
|
|
802
|
+
clause: "DR-0012:22 two decorrelated clean-room reviews of this head",
|
|
803
|
+
status: condition1.status,
|
|
804
|
+
head,
|
|
805
|
+
sentence: condition1.sentence,
|
|
806
|
+
});
|
|
807
|
+
const condition2 = runRegisteredCheck(PAIR_CHECK_ID, forHead, contextDirectory);
|
|
808
|
+
rows.push({
|
|
809
|
+
id: "condition-2",
|
|
810
|
+
clause: "DR-0012:23 no unresolved finding at medium or above",
|
|
811
|
+
status: condition2.status,
|
|
812
|
+
head,
|
|
813
|
+
sentence: condition2.sentence,
|
|
814
|
+
});
|
|
815
|
+
const condition3 = judgeCriteriaWalked(forHead);
|
|
816
|
+
rows.push({
|
|
817
|
+
id: "condition-3",
|
|
818
|
+
clause: "DR-0012:24 the acceptance criteria were the reviewers' contract",
|
|
819
|
+
status: condition3.ok ? "green" : "red",
|
|
820
|
+
head,
|
|
821
|
+
sentence: condition3.sentence,
|
|
822
|
+
});
|
|
823
|
+
const checkRunsUrl = `${apiBase}/repos/${slug}/commits/${head}/check-runs`;
|
|
824
|
+
const checkRuns = readJsonBody(checkRunsUrl, await requestJson(checkRunsUrl));
|
|
825
|
+
if (!checkRuns.ok) {
|
|
826
|
+
rows.push({
|
|
827
|
+
id: "condition-4",
|
|
828
|
+
clause: "DR-0012:25 CI green on the EXACT head",
|
|
829
|
+
status: "error",
|
|
830
|
+
head,
|
|
831
|
+
sentence: checkRuns.reason,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
const listed = checkRuns.value.check_runs;
|
|
836
|
+
const judged = judgeCheckRuns(head, Array.isArray(listed) ? listed : [], REQUIRED_CHECK_CONTEXT);
|
|
837
|
+
rows.push({
|
|
838
|
+
id: "condition-4",
|
|
839
|
+
clause: "DR-0012:25 CI green on the EXACT head",
|
|
840
|
+
status: judged.ok ? "green" : "red",
|
|
841
|
+
head,
|
|
842
|
+
sentence: judged.sentence,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
const scopeRecordPath = absolute(flags["scope-record"] ??
|
|
846
|
+
(flags.evidence === undefined
|
|
847
|
+
? join(contextDirectory, "scope", "result.json")
|
|
848
|
+
: join(dirname(absolute(flags.evidence)), "scope", "result.json")));
|
|
849
|
+
const scope = judgeScopeRecord(scopeRecordPath, read(scopeRecordPath));
|
|
850
|
+
rows.push({
|
|
851
|
+
id: "condition-5",
|
|
852
|
+
clause: "DR-0012:26 the scope audit passes",
|
|
853
|
+
status: scope.ok ? "green" : "red",
|
|
854
|
+
head,
|
|
855
|
+
sentence: scope.sentence,
|
|
856
|
+
});
|
|
857
|
+
const arbitrationDirectory = absolute(flags.arbitrations ?? join(contextDirectory, "delivery", "review"));
|
|
858
|
+
const arbitrationPath = join(arbitrationDirectory, `arbitration-${phase}.md`);
|
|
859
|
+
const arbitration = judgeArbitration(arbitrationPath, head, forHead.map((verdict) => verdict.path), read(arbitrationPath));
|
|
860
|
+
rows.push({
|
|
861
|
+
id: "condition-6",
|
|
862
|
+
clause: "DR-0012:27 a recorded arbitration over BOTH verdicts at this head",
|
|
863
|
+
status: arbitration.ok ? "green" : "red",
|
|
864
|
+
head,
|
|
865
|
+
sentence: arbitration.sentence,
|
|
866
|
+
});
|
|
867
|
+
const rulesets = await readRulesets(apiBase, slug);
|
|
868
|
+
if (!rulesets.ok) {
|
|
869
|
+
rows.push({
|
|
870
|
+
id: "branch-protection",
|
|
871
|
+
clause: "R-064 the ruleset encoding, R-065a reported as DATA",
|
|
872
|
+
status: "error",
|
|
873
|
+
head,
|
|
874
|
+
sentence: rulesets.reason,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
const judged = judgeRulesets(rulesets.rulesets);
|
|
879
|
+
rows.push({
|
|
880
|
+
id: "branch-protection",
|
|
881
|
+
clause: "R-064 the ruleset encoding, R-065a reported as DATA",
|
|
882
|
+
status: judged.ok ? "green" : "red",
|
|
883
|
+
head,
|
|
884
|
+
sentence: judged.sentence,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/* THE GATE WORD, AND THE ONE PLACE A ROW AND THE GATE DELIBERATELY DIFFER.
|
|
888
|
+
An absent scope record leaves condition 5's row RED (criterion 4) while the
|
|
889
|
+
gate itself is ERROR (plan step 4), because the instrument was not there to
|
|
890
|
+
read. Both facts are printed. */
|
|
891
|
+
const scopeInstrumentMissing = scope.instrument === "missing";
|
|
892
|
+
const status = scopeInstrumentMissing ? "error" : gateStatusForRows(rows);
|
|
893
|
+
return emit(resultPath, {
|
|
894
|
+
...shared,
|
|
895
|
+
status,
|
|
896
|
+
units: rows.length,
|
|
897
|
+
endedAt: now(),
|
|
898
|
+
detail: `DR-0012 at head ${head}, phase ${phase}: ` +
|
|
899
|
+
rows.map((row) => `${row.id}=${row.status}`).join(" ") +
|
|
900
|
+
(scopeInstrumentMissing
|
|
901
|
+
? "; the gate word is error rather than red because the scope gate record was ABSENT, " +
|
|
902
|
+
"so condition 5 could not be looked at (plan step 4); the row stays red because the " +
|
|
903
|
+
"condition is not satisfied either way (criterion 4)"
|
|
904
|
+
: ""),
|
|
905
|
+
}, rows);
|
|
906
|
+
}
|
|
907
|
+
export async function main(argv) {
|
|
908
|
+
const flags = parseFlags(argv);
|
|
909
|
+
if (flags === undefined) {
|
|
910
|
+
return usageError();
|
|
911
|
+
}
|
|
912
|
+
const missing = ["result", "head", "phase"].filter((name) => flags[name] === undefined);
|
|
913
|
+
if (missing.length > 0) {
|
|
914
|
+
return usageError(`${GATE_ID} requires ${missing.map((name) => `--${name}`).join(" ")}`);
|
|
915
|
+
}
|
|
916
|
+
return runGate(flags);
|
|
917
|
+
}
|
|
918
|
+
const entry = process.argv[1];
|
|
919
|
+
if (entry !== undefined && pathsIdentifySameObject(fileURLToPath(import.meta.url), entry)) {
|
|
920
|
+
/* The same second layer src/gates/scope.ts and src/gates/gate-classes.ts
|
|
921
|
+
carry: an uncaught throw would exit 1, which is EXIT_RED, and a crash
|
|
922
|
+
reported as a red verdict is indistinguishable from a real one to a
|
|
923
|
+
consumer reading the exit code. */
|
|
924
|
+
try {
|
|
925
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
926
|
+
}
|
|
927
|
+
catch (error) {
|
|
928
|
+
process.stderr.write(`tiphys gates ${GATE_ID}: ${singleLine(error.message ?? String(error))}\n`);
|
|
929
|
+
process.exitCode = EXIT_GATE_ERROR;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
export { GATE_ID, PRECONDITION_ID, UNIT_LABEL };
|