@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,1018 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { EX_USAGE } from "../cli.js";
|
|
7
|
+
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
8
|
+
import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
9
|
+
import { formatDiagnostics, loadSchema, validate } from "./validate.js";
|
|
10
|
+
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
11
|
+
/**
|
|
12
|
+
* THE SCOPE AUDITOR (kernel plan M2, M2-P4).
|
|
13
|
+
*
|
|
14
|
+
* Makes "every changed path is on the phase's declared list, or a declared
|
|
15
|
+
* extra, or one of the two standing extras" a check with an exit code,
|
|
16
|
+
* derived from the diff rather than trusted to a prose deviation section.
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS IS A STANDALONE SCRIPT AND NOT A `tiphys gates` SUBCOMMAND. This
|
|
19
|
+
* phase's files-to-touch list (delivery/plan/phase-declarations/m2-p4.json,
|
|
20
|
+
* committed on `main` before this branch existed, read at M2-C-1) does not
|
|
21
|
+
* include src/cli.ts or src/commands/gates.ts, and M2-D-19's concurrency
|
|
22
|
+
* structure runs seven phases against the same shared files without
|
|
23
|
+
* re-serializing them. So this module is both the gate's logic and its own
|
|
24
|
+
* entry point, invoked directly as `node src/gates/scope.ts ...args`, the
|
|
25
|
+
* way `gates.manifest.json`'s `manifest-self-check` entry invokes
|
|
26
|
+
* `bin/tiphys.ts` for the ONE gate that phase's file list does own.
|
|
27
|
+
*
|
|
28
|
+
* THE ANTI-WIDENING PROPERTY (criterion 5, this phase's reason to exist).
|
|
29
|
+
* The declaration is read from the MERGE BASE of `--base` and `--head` via
|
|
30
|
+
* `git show <mergeBaseSha>:<path>`, which reads the git object database and
|
|
31
|
+
* never touches the working tree at that path, so an implementer's own
|
|
32
|
+
* edit to the declaration on the branch being audited cannot change what
|
|
33
|
+
* this run considers authorized THROUGH THE WORKING TREE. The merge base is
|
|
34
|
+
* computed explicitly with `git merge-base`, and diffs are computed against
|
|
35
|
+
* THAT commit, never against `--base` directly and never against `main`'s
|
|
36
|
+
* current tip: if `main` has advanced past the true fork point by the time
|
|
37
|
+
* this runs (another phase merged in the interim), diffing directly against
|
|
38
|
+
* `--base` would misattribute every path `main` changed afterward to this
|
|
39
|
+
* branch. `--base`/`--head` are still both required (a check that cannot
|
|
40
|
+
* compute a merge base has not reached a verdict, M2-C-3).
|
|
41
|
+
*
|
|
42
|
+
* CORRECTION, fix round 1 (CR-1046): reading via the object database is NOT
|
|
43
|
+
* by itself enough to make the property above a structural fact about this
|
|
44
|
+
* module's code, because `--base` is supplied by the party under audit.
|
|
45
|
+
* Before this round, `--base` could name a ref on the AUDITED BRANCH ITSELF
|
|
46
|
+
* (for example `HEAD~1` after committing a widened declaration), which
|
|
47
|
+
* makes `git merge-base(--base, --head)` resolve to a commit that is only
|
|
48
|
+
* reachable from that branch, so the "merge base" blob read is the widened
|
|
49
|
+
* one, not main's. This was reproduced live (clean-room hazard CR-1045,
|
|
50
|
+
* witness W2) before this round closed it. The property now holds because
|
|
51
|
+
* of TWO checks together, not one: the object-database read above, AND the
|
|
52
|
+
* merge-base-vs-trunk validation immediately below, which this round added.
|
|
53
|
+
*
|
|
54
|
+
* CROSS-CHECKING EVERY CALLER-SUPPLIED INPUT AGAINST THE BRANCH UNDER AUDIT
|
|
55
|
+
* (fix round 1, CR-1030/CR-1045, the mechanism, not the three
|
|
56
|
+
* witnesses that exposed it). Before this round `--phase`, `--base` and
|
|
57
|
+
* `--head` were forwarded to the diff/declaration machinery exactly as
|
|
58
|
+
* supplied, so the audited party chose which declaration governed it
|
|
59
|
+
* (`--phase` naming a different, more permissive, already-committed
|
|
60
|
+
* declaration), which commit's blob the merge base pinned (`--base` forked
|
|
61
|
+
* onto the audited branch), and how much of the branch's own history was
|
|
62
|
+
* measured (`--head` set short of the real tip). All three are now
|
|
63
|
+
* cross-checked against a property of the branch under audit that the
|
|
64
|
+
* caller does not get to redefine per invocation:
|
|
65
|
+
* - `--phase` must equal the loaded declaration's own `id` field;
|
|
66
|
+
* - the branch this process actually has checked out (`currentBranch`)
|
|
67
|
+
* must equal the loaded declaration's own `branch` field;
|
|
68
|
+
* - `--head`, resolved, must equal the commit this process actually has
|
|
69
|
+
* checked out (`git rev-parse HEAD`), so a `--head` that is an ancestor
|
|
70
|
+
* of the real tip is rejected rather than silently narrowing the diff;
|
|
71
|
+
* - the merge base must be an ancestor of the repository's configured
|
|
72
|
+
* trunk (`origin/main`, falling back to a local `main` where there is
|
|
73
|
+
* no `origin` remote, for example a scratch test repository), so a
|
|
74
|
+
* merge base forked onto the audited branch itself is rejected.
|
|
75
|
+
* A divergence in any of the four is `error`, naming what was expected and
|
|
76
|
+
* what was supplied. None of the four can be satisfied by editing anything
|
|
77
|
+
* on the audited branch, because each is checked against either the
|
|
78
|
+
* declaration's own OWN fields (read from the object database at the merge
|
|
79
|
+
* base, already anti-widened) or against a reference this process resolves
|
|
80
|
+
* itself (the real checkout, the real trunk), never against a second
|
|
81
|
+
* caller-supplied string.
|
|
82
|
+
*
|
|
83
|
+
* M2-C-6, applied. `--result` and the optional `--evidence` side artifact
|
|
84
|
+
* are opened only through `refuseOpenForWrite`. The phase-declaration
|
|
85
|
+
* schema document, shipped with this gate, is read through
|
|
86
|
+
* `readRegularFileIfPresent`, the same pattern src/gates/manifest.ts uses
|
|
87
|
+
* for its own two schema documents. The declaration ITSELF is never opened
|
|
88
|
+
* as a filesystem path at all: it is read out of the git object database by
|
|
89
|
+
* `git show`, which cannot be blocked by a named pipe sitting at that path
|
|
90
|
+
* in the working tree, because a git ref lookup never touches the working
|
|
91
|
+
* tree's inode at all. This is a stronger guarantee than classifyEntry
|
|
92
|
+
* would give a filesystem read, and it exists here as a side effect of the
|
|
93
|
+
* anti-widening design, not as a second implementation of the primitive.
|
|
94
|
+
*
|
|
95
|
+
* THE ANTI-WIDENING PROPERTY IS DELIBERATELY WEAKENED FOR ADDITIONS, AND
|
|
96
|
+
* SAYING SO IS PART OF THE CHANGE (M3-P11 change B, DR-0031). From this
|
|
97
|
+
* phase on, the declaration is read from BOTH the merge base and the head.
|
|
98
|
+
* A removal is still refused outright and the merge base is still the
|
|
99
|
+
* yardstick for `id` and `branch`, so every cross-check above is unchanged.
|
|
100
|
+
* But an entry ADDED on the head is now ALLOWED, and the gate's protection
|
|
101
|
+
* against it is that the addition is PRINTED BY NAME on stdout and recorded
|
|
102
|
+
* in `scope-audit.json`, for a human reviewer to sign off. That is a real
|
|
103
|
+
* reduction in what this gate refuses, traded for the ability to land a
|
|
104
|
+
* declaration amendment with the phase that needs it rather than in a
|
|
105
|
+
* separate pull request. It is a trade, not a free improvement, and a
|
|
106
|
+
* reviewer who ignores the printed line gets no protection from it at all.
|
|
107
|
+
*
|
|
108
|
+
* FIX ROUND 1 STRENGTHENED THE VISIBLE HALF THREE WAYS, because a hard
|
|
109
|
+
* control relaxed into a visible one is only as good as the visibility, and
|
|
110
|
+
* two clean-room reviewers found the visibility defective in three
|
|
111
|
+
* independent places. The note now says when an addition is a DIRECTORY
|
|
112
|
+
* PREFIX rather than a single file (M-1); `tiphys gates run` relays every
|
|
113
|
+
* gate row rather than non-green ones only, so the note reaches stdout on
|
|
114
|
+
* the GREEN arm, which is the only arm this is about (C-1); and a phase
|
|
115
|
+
* branch that touches any path under the declarations directory other than
|
|
116
|
+
* its own declaration is RED, so the removal refusal is no longer scoped to
|
|
117
|
+
* one file while the grant can reach every file (C-2).
|
|
118
|
+
*
|
|
119
|
+
* RENAMES AND DELETIONS (criteria 3 and 4). `git diff --name-status`
|
|
120
|
+
* reports a rename as one line carrying both the old and the new path; this
|
|
121
|
+
* module treats a rename or copy as touching BOTH names, so an old path
|
|
122
|
+
* that leaves the declared set and a new path that never entered it are
|
|
123
|
+
* each auditable on their own. A deletion is one line carrying only the
|
|
124
|
+
* path that stopped existing, audited the same way an addition or a
|
|
125
|
+
* modification is: present in the diff, so it must be declared.
|
|
126
|
+
*/
|
|
127
|
+
const USAGE = "usage: node src/gates/scope.ts --declarations <dir> --result <file> " +
|
|
128
|
+
"[--evidence <dir>] --base <ref> [--head <ref>] --phase <id>";
|
|
129
|
+
const VALUE_FLAGS = [
|
|
130
|
+
"--declarations",
|
|
131
|
+
"--result",
|
|
132
|
+
"--evidence",
|
|
133
|
+
"--base",
|
|
134
|
+
"--head",
|
|
135
|
+
"--phase",
|
|
136
|
+
];
|
|
137
|
+
function parseFlags(args) {
|
|
138
|
+
const flags = {};
|
|
139
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
140
|
+
const flag = args[index];
|
|
141
|
+
const value = args[index + 1];
|
|
142
|
+
if (flag === undefined || !VALUE_FLAGS.includes(flag)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (value === undefined || value.startsWith("--")) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const key = flag.slice(2);
|
|
149
|
+
flags[key] = value;
|
|
150
|
+
index += 1;
|
|
151
|
+
}
|
|
152
|
+
return flags;
|
|
153
|
+
}
|
|
154
|
+
function usageError(message) {
|
|
155
|
+
if (message !== undefined) {
|
|
156
|
+
process.stderr.write(`tiphys gates scope: ${message}\n`);
|
|
157
|
+
}
|
|
158
|
+
process.stderr.write(`${USAGE}\n`);
|
|
159
|
+
return EX_USAGE;
|
|
160
|
+
}
|
|
161
|
+
/* -------------------------------------------------------------------- */
|
|
162
|
+
/* The phase-declaration schema document (M2-C-6: opened through the */
|
|
163
|
+
/* delivered primitive, the same pattern src/gates/manifest.ts uses). */
|
|
164
|
+
/* -------------------------------------------------------------------- */
|
|
165
|
+
const schemaDirectory = new URL("./schemas/", import.meta.url);
|
|
166
|
+
let cachedSchema;
|
|
167
|
+
function declarationSchema() {
|
|
168
|
+
if (cachedSchema !== undefined) {
|
|
169
|
+
return cachedSchema;
|
|
170
|
+
}
|
|
171
|
+
const path = fileURLToPath(new URL("phase-declaration.schema.json", schemaDirectory));
|
|
172
|
+
const read = readRegularFileIfPresent(path);
|
|
173
|
+
if (read.kind !== "read") {
|
|
174
|
+
throw new Error(read.kind === "absent"
|
|
175
|
+
? `schema document ${path} is missing from this installation`
|
|
176
|
+
: read.reason);
|
|
177
|
+
}
|
|
178
|
+
const parsed = JSON.parse(read.body);
|
|
179
|
+
const loaded = loadSchema(parsed, "phase-declaration.schema.json");
|
|
180
|
+
if (!loaded.ok) {
|
|
181
|
+
throw new Error(loaded.reason);
|
|
182
|
+
}
|
|
183
|
+
cachedSchema = loaded.schema;
|
|
184
|
+
return cachedSchema;
|
|
185
|
+
}
|
|
186
|
+
/* -------------------------------------------------------------------- */
|
|
187
|
+
/* git plumbing, pinned against REAL captured output (work history). */
|
|
188
|
+
/* -------------------------------------------------------------------- */
|
|
189
|
+
function runGit(cwd, args) {
|
|
190
|
+
return spawnSync("git", args, { cwd });
|
|
191
|
+
}
|
|
192
|
+
function bufferToUtf8(value) {
|
|
193
|
+
if (value === null || value === undefined) {
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
return typeof value === "string" ? value : value.toString("utf8");
|
|
197
|
+
}
|
|
198
|
+
function resolveMergeBase(cwd, base, head) {
|
|
199
|
+
const result = runGit(cwd, ["merge-base", base, head]);
|
|
200
|
+
if (result.error !== undefined) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: `git merge-base ${base} ${head} could not be run: ${singleLine(String(result.error))}`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (result.status !== 0) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: `git merge-base ${base} ${head} exited ${String(result.status)}: ${singleLine(bufferToUtf8(result.stderr))}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const sha = bufferToUtf8(result.stdout).trim();
|
|
213
|
+
if (sha === "") {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
reason: `git merge-base ${base} ${head} produced no output`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, sha };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Resolve any ref-ish string (a sha, a branch, `HEAD`, `HEAD~1`, ...) to the
|
|
223
|
+
* commit sha it names, or a reason it could not be resolved as a commit.
|
|
224
|
+
* Fix round 1 (CR-1030/CR-1045, W3): used to compare the CALLER-SUPPLIED
|
|
225
|
+
* `--head` against the commit this process actually has checked out, so a
|
|
226
|
+
* `--head` set short of the real tip is caught rather than silently
|
|
227
|
+
* narrowing the diff.
|
|
228
|
+
*/
|
|
229
|
+
function resolveRef(cwd, ref) {
|
|
230
|
+
const result = runGit(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
231
|
+
if (result.error !== undefined) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
reason: `git rev-parse --verify ${ref} could not be run: ${singleLine(String(result.error))}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (result.status !== 0) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
reason: `git rev-parse --verify ${ref} exited ${String(result.status)}: ${singleLine(bufferToUtf8(result.stderr))}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const sha = bufferToUtf8(result.stdout).trim();
|
|
244
|
+
if (sha === "") {
|
|
245
|
+
return { ok: false, reason: `git rev-parse --verify ${ref} produced no output` };
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, sha };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The one trunk reference the merge base is checked against (fix round 1,
|
|
251
|
+
* CR-1030/CR-1045, W2). Tried in order: a real checkout of this repository
|
|
252
|
+
* carries an `origin` remote (`.github/workflows/gates.yml` fetches full
|
|
253
|
+
* history), while a scratch repository built for this suite, or a plain
|
|
254
|
+
* local clone, typically does not. Either way this kernel has exactly one
|
|
255
|
+
* branch it calls trunk, so the first candidate that resolves to a real
|
|
256
|
+
* commit is it; a repository with neither is one this check cannot be
|
|
257
|
+
* performed against, which is `error`, never a guessed pass.
|
|
258
|
+
*/
|
|
259
|
+
const TRUNK_CANDIDATES = ["origin/main", "main"];
|
|
260
|
+
function resolveTrunk(cwd) {
|
|
261
|
+
const failures = [];
|
|
262
|
+
for (const ref of TRUNK_CANDIDATES) {
|
|
263
|
+
const resolved = resolveRef(cwd, ref);
|
|
264
|
+
if (resolved.ok) {
|
|
265
|
+
return { ok: true, ref, sha: resolved.sha };
|
|
266
|
+
}
|
|
267
|
+
failures.push(`${ref} (${resolved.reason})`);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
reason: `could not resolve a trunk reference to validate the merge base against, tried ` +
|
|
272
|
+
`${failures.join(", ")}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** `git merge-base --is-ancestor`: exit 0 is yes, exit 1 is no, anything else is error. */
|
|
276
|
+
function isAncestorOf(cwd, ancestor, descendant) {
|
|
277
|
+
const result = runGit(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
278
|
+
if (result.error !== undefined) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: `git merge-base --is-ancestor ${ancestor} ${descendant} could not be run: ` +
|
|
282
|
+
`${singleLine(String(result.error))}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (result.status === 0) {
|
|
286
|
+
return { ok: true, isAncestor: true };
|
|
287
|
+
}
|
|
288
|
+
if (result.status === 1) {
|
|
289
|
+
return { ok: true, isAncestor: false };
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: `git merge-base --is-ancestor ${ancestor} ${descendant} exited ${String(result.status)}: ` +
|
|
294
|
+
`${singleLine(bufferToUtf8(result.stderr))}`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* `git diff --name-status <mergeBase> <head>`. Never `<base> <head>` and
|
|
299
|
+
* never against `main`'s tip: see the module comment on the anti-widening
|
|
300
|
+
* property. A rename or copy line (`R###` / `C###`) carries the old path
|
|
301
|
+
* and the new path tab-separated on one line; every other status carries
|
|
302
|
+
* exactly one path. Pinned against the captured shape in this phase's work
|
|
303
|
+
* history, not against a hand-written example (MECHANISMS.md, "Deciding
|
|
304
|
+
* what another program will do by pattern-matching the text of a file it
|
|
305
|
+
* consumes").
|
|
306
|
+
*/
|
|
307
|
+
function computeTouchedPaths(cwd, mergeBase, head) {
|
|
308
|
+
const result = runGit(cwd, ["diff", "--name-status", mergeBase, head]);
|
|
309
|
+
if (result.error !== undefined) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
reason: `git diff --name-status ${mergeBase} ${head} could not be run: ${singleLine(String(result.error))}`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (result.status !== 0) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
reason: `git diff --name-status ${mergeBase} ${head} exited ${String(result.status)}: ${singleLine(bufferToUtf8(result.stderr))}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const lines = bufferToUtf8(result.stdout)
|
|
322
|
+
.split("\n")
|
|
323
|
+
.filter((line) => line !== "");
|
|
324
|
+
const paths = [];
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
const fields = line.split("\t");
|
|
327
|
+
const status = fields[0] ?? "";
|
|
328
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
329
|
+
const oldPath = fields[1];
|
|
330
|
+
const newPath = fields[2];
|
|
331
|
+
if (oldPath === undefined || newPath === undefined) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
reason: `git diff --name-status produced an unparseable rename/copy line: ${JSON.stringify(line)}`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
paths.push({ path: oldPath, status });
|
|
338
|
+
paths.push({ path: newPath, status });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const path = fields[1];
|
|
342
|
+
if (path === undefined) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
reason: `git diff --name-status produced an unparseable line: ${JSON.stringify(line)}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
paths.push({ path, status });
|
|
349
|
+
}
|
|
350
|
+
return { ok: true, paths };
|
|
351
|
+
}
|
|
352
|
+
/** Best-effort branch name for a red detail line; never load-bearing for logic. */
|
|
353
|
+
function currentBranch(cwd) {
|
|
354
|
+
const result = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
355
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
356
|
+
return "<unknown>";
|
|
357
|
+
}
|
|
358
|
+
const name = bufferToUtf8(result.stdout).trim();
|
|
359
|
+
return name === "" ? "<unknown>" : name;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Text git emits for "this path does not exist at this commit", captured
|
|
363
|
+
* for the direct case (this phase's work history) and, for the second
|
|
364
|
+
* shape, taken from git's own documented message for the same condition
|
|
365
|
+
* reached through an unstaged path, RECORDED as an unreproduced derivation
|
|
366
|
+
* gap in the work history rather than asserted as witnessed. Every other
|
|
367
|
+
* nonzero `git show` outcome is ERROR, never a guessed RED (M2-C-3).
|
|
368
|
+
*/
|
|
369
|
+
const DECLARATION_ABSENT_PATTERNS = [/does not exist in/, /exists on disk, but not in/];
|
|
370
|
+
/**
|
|
371
|
+
* Read `<declarationsDir>/<phase>.json` out of the MERGE BASE commit, never
|
|
372
|
+
* out of the working tree and never out of `--head`. This is the one
|
|
373
|
+
* function the anti-widening property depends on.
|
|
374
|
+
*/
|
|
375
|
+
function loadDeclarationAtMergeBase(cwd, mergeBase, declarationsDir, phase) {
|
|
376
|
+
return loadDeclarationAtCommit(cwd, mergeBase, declarationsDir, phase);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The same object-database read at ANY commit (M3-P11 change B). The
|
|
380
|
+
* merge-base wrapper above keeps its name and its exported identity because
|
|
381
|
+
* the anti-widening property is a statement about THAT call, and a reader
|
|
382
|
+
* following the property should land on a function whose name says which
|
|
383
|
+
* side it reads.
|
|
384
|
+
*/
|
|
385
|
+
function loadDeclarationAtCommit(cwd, commit, declarationsDir, phase) {
|
|
386
|
+
const relPath = `${declarationsDir.replace(/\/+$/, "")}/${phase}.json`;
|
|
387
|
+
const ref = `${commit}:${relPath}`;
|
|
388
|
+
const mergeBase = commit;
|
|
389
|
+
const result = runGit(cwd, ["show", ref]);
|
|
390
|
+
if (result.error !== undefined) {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
kind: "error",
|
|
394
|
+
reason: `git show ${ref} could not be run: ${singleLine(String(result.error))}`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (result.status !== 0) {
|
|
398
|
+
const stderr = bufferToUtf8(result.stderr);
|
|
399
|
+
if (DECLARATION_ABSENT_PATTERNS.some((pattern) => pattern.test(stderr))) {
|
|
400
|
+
return { ok: false, kind: "missing", path: relPath };
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
kind: "error",
|
|
405
|
+
reason: `git show ${ref} exited ${String(result.status)}: ${singleLine(stderr)}`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const buffer = result.stdout instanceof Buffer ? result.stdout : Buffer.from(bufferToUtf8(result.stdout));
|
|
409
|
+
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
410
|
+
let parsed;
|
|
411
|
+
try {
|
|
412
|
+
parsed = JSON.parse(buffer.toString("utf8"));
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
kind: "error",
|
|
418
|
+
reason: `declaration ${relPath} at merge base ${mergeBase} does not parse as JSON: ${error.message}`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const diagnostics = formatDiagnostics(validate(declarationSchema(), parsed));
|
|
422
|
+
if (diagnostics.length > 0) {
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
kind: "error",
|
|
426
|
+
reason: `declaration ${relPath} at merge base ${mergeBase} is not a valid phase declaration: ${diagnostics.join("; ")}`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, declaration: parsed, sha256, path: relPath };
|
|
430
|
+
}
|
|
431
|
+
/** A declared entry is a literal path, or (trailing slash) a directory prefix. */
|
|
432
|
+
function isAllowed(path, allowed) {
|
|
433
|
+
return allowed.some((entry) => (entry.endsWith("/") ? path.startsWith(entry) : path === entry));
|
|
434
|
+
}
|
|
435
|
+
/* -------------------------------------------------------------------- */
|
|
436
|
+
/* M3-P11 change A: a phase's OWN evidence is a standing extra. */
|
|
437
|
+
/* -------------------------------------------------------------------- */
|
|
438
|
+
/**
|
|
439
|
+
* WHY (DR-0031, delivery/plan/m3-p11-phase-spec.md:142). The standing
|
|
440
|
+
* pre-authorized extras were `test/behaviors.json` and the phase work
|
|
441
|
+
* history, so the only way to land a clean-room review or a delta
|
|
442
|
+
* verification without reddening this gate was a SEPARATE pull request.
|
|
443
|
+
* That is not a theoretical cost. Measured on `main` at bdec27d, while
|
|
444
|
+
* M3-P9 was still open: the two clean-room reviews and the delta
|
|
445
|
+
* verification for M3-P9 were PRESENT and `AGENTS.md` was ABSENT, so `main`
|
|
446
|
+
* carried review evidence for code it did not contain.
|
|
447
|
+
*
|
|
448
|
+
* WHAT COUNTS AS THE PHASE'S OWN EVIDENCE, and the rule is mechanical
|
|
449
|
+
* because the alternative is a reviewer's judgment about a filename. A
|
|
450
|
+
* changed path qualifies when all three hold:
|
|
451
|
+
*
|
|
452
|
+
* 1. it sits DIRECTLY under `delivery/review/` or
|
|
453
|
+
* `delivery/verification/`, one level only, so nothing under
|
|
454
|
+
* `delivery/review/evidence/` is swept in by a directory prefix;
|
|
455
|
+
* 2. its basename begins with one of the evidence-kind prefixes below,
|
|
456
|
+
* possibly the empty one;
|
|
457
|
+
* 3. what follows that prefix is the phase id, followed by a BOUNDARY:
|
|
458
|
+
* `-`, `.`, or the end of the basename.
|
|
459
|
+
*
|
|
460
|
+
* RULE 3'S BOUNDARY IS LOAD-BEARING AND IT IS NOT DECORATION. Phase ids in
|
|
461
|
+
* this project are not prefix-free: `m3-p1` is a proper string prefix of
|
|
462
|
+
* `m3-p11`. Without the boundary, the M3-P1 branch would silently own every
|
|
463
|
+
* M3-P11 review document, which is precisely the cross-phase leak the
|
|
464
|
+
* "another phase's evidence still reddens" arm of criterion 8 exists to
|
|
465
|
+
* refuse.
|
|
466
|
+
*
|
|
467
|
+
* THE PREFIX LIST IS DERIVED, NOT INVENTED. `clean-room-` and the empty
|
|
468
|
+
* prefix come from criterion 8 and from the spec's own list;
|
|
469
|
+
* `arbitration-` comes from the spec's list; `verification-` comes from
|
|
470
|
+
* CLAUDE.md's durability table, which names
|
|
471
|
+
* `delivery/review/verification-<phase>-fix-round.md` as a required
|
|
472
|
+
* artifact. Names in use on `main` that are NOT covered (`final-review-`,
|
|
473
|
+
* `open-call-`) are left out deliberately: nothing binding requires them,
|
|
474
|
+
* and a phase that needs one declares it. That limit is recorded in
|
|
475
|
+
* delivery/work-history/m3-p11.md rather than left to be discovered.
|
|
476
|
+
*/
|
|
477
|
+
const PHASE_EVIDENCE_DIRECTORIES = ["delivery/review/", "delivery/verification/"];
|
|
478
|
+
const PHASE_EVIDENCE_PREFIXES = ["", "clean-room-", "arbitration-", "verification-"];
|
|
479
|
+
function isPhaseOwnEvidence(path, phase) {
|
|
480
|
+
const id = phase.toLowerCase();
|
|
481
|
+
if (id === "") {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
const directory = PHASE_EVIDENCE_DIRECTORIES.find((entry) => path.startsWith(entry));
|
|
485
|
+
if (directory === undefined) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
const basename = path.slice(directory.length);
|
|
489
|
+
if (basename === "" || basename.includes("/")) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
const lower = basename.toLowerCase();
|
|
493
|
+
return PHASE_EVIDENCE_PREFIXES.some((prefix) => {
|
|
494
|
+
if (!lower.startsWith(prefix)) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
const rest = lower.slice(prefix.length);
|
|
498
|
+
if (!rest.startsWith(id)) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
const after = rest.slice(id.length);
|
|
502
|
+
return after === "" || after.startsWith("-") || after.startsWith(".");
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Compare the merge-base declaration with the head's, field by field.
|
|
507
|
+
*
|
|
508
|
+
* THE ASYMMETRY IS THE POINT (DR-0031,
|
|
509
|
+
* delivery/plan/m3-p11-phase-spec.md:156). Reading the declaration from the
|
|
510
|
+
* head alone would let a phase grant itself scope, which removes the only
|
|
511
|
+
* check on scope there is; reading it from the merge base alone means a
|
|
512
|
+
* declaration amendment can never ride with the phase that needs it, and
|
|
513
|
+
* three of one day's ten pull requests existed for nothing else. So an
|
|
514
|
+
* ADDITION is allowed and reported as a loud NAMED diff for a reviewer to
|
|
515
|
+
* sign off, and a REMOVAL stays hard.
|
|
516
|
+
*
|
|
517
|
+
* `id` and `branch` are compared as scalars and any difference is a
|
|
518
|
+
* REMOVAL, never an addition: they are the two fields the anti-widening
|
|
519
|
+
* cross-checks are anchored to, and a phase that renames its own anchor has
|
|
520
|
+
* changed which declaration governs it.
|
|
521
|
+
*/
|
|
522
|
+
function compareDeclarations(base, head) {
|
|
523
|
+
const added = [];
|
|
524
|
+
const removed = [];
|
|
525
|
+
for (const field of ["filesToTouch", "declaredExtras", "citations"]) {
|
|
526
|
+
const baseSet = new Set(base[field]);
|
|
527
|
+
const headSet = new Set(head[field]);
|
|
528
|
+
for (const entry of headSet) {
|
|
529
|
+
if (!baseSet.has(entry)) {
|
|
530
|
+
added.push(`${field} ${entry}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
for (const entry of baseSet) {
|
|
534
|
+
if (!headSet.has(entry)) {
|
|
535
|
+
removed.push(`${field} ${entry}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
for (const field of ["id", "branch"]) {
|
|
540
|
+
if (base[field] !== head[field]) {
|
|
541
|
+
removed.push(`${field} ${base[field]} (the head declares ${head[field]})`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return { added: added.sort(), removed: removed.sort() };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* One added entry, rendered for the line a reviewer actually reads.
|
|
548
|
+
*
|
|
549
|
+
* FIX ROUND 1, finding M-1. `isAllowed` above treats a trailing slash as a
|
|
550
|
+
* DIRECTORY PREFIX matching every path beneath it, so `filesToTouch src/`
|
|
551
|
+
* grants scope over an entire tree, present and future. Printed by the
|
|
552
|
+
* unannotated diff it was a string the same shape and roughly the same
|
|
553
|
+
* length as a single-file addition, and change B's whole protection is a
|
|
554
|
+
* human scanning that line for something anomalous. Disclosure that does not
|
|
555
|
+
* distinguish "one file" from "everything under this tree" is not disclosure
|
|
556
|
+
* at the strength the refusal it replaced had.
|
|
557
|
+
*
|
|
558
|
+
* The delta itself stays unannotated data (`scope-audit.json` records the
|
|
559
|
+
* raw entries); the annotation is applied where the sentence is composed.
|
|
560
|
+
*/
|
|
561
|
+
function describeAddition(entry) {
|
|
562
|
+
return entry.endsWith("/")
|
|
563
|
+
? `${entry} (DIRECTORY PREFIX: grants every current and future path under it, not one file)`
|
|
564
|
+
: entry;
|
|
565
|
+
}
|
|
566
|
+
/* -------------------------------------------------------------------- */
|
|
567
|
+
/* Emitting the gate's own record (M2-C-6: --result opened through the */
|
|
568
|
+
/* delivered primitive, same pattern as src/commands/gates.ts's emit()). */
|
|
569
|
+
/* -------------------------------------------------------------------- */
|
|
570
|
+
function emit(resultPath, fields) {
|
|
571
|
+
const result = makeGateResult(fields);
|
|
572
|
+
const refusal = refuseOpenForWrite(resultPath);
|
|
573
|
+
if (refusal !== undefined) {
|
|
574
|
+
process.stderr.write(`tiphys gates scope: ${refusal}\n`);
|
|
575
|
+
return EXIT_GATE_ERROR;
|
|
576
|
+
}
|
|
577
|
+
const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
|
|
578
|
+
if (!written.ok) {
|
|
579
|
+
process.stderr.write(`tiphys gates scope: ${written.reason}\n`);
|
|
580
|
+
return EXIT_GATE_ERROR;
|
|
581
|
+
}
|
|
582
|
+
const status = result.status;
|
|
583
|
+
process.stdout.write(`${result.gate}: ${status} (${String(result.units)} ${result.unitLabel})\n`);
|
|
584
|
+
if (result.detail !== "") {
|
|
585
|
+
process.stdout.write(`${result.detail}\n`);
|
|
586
|
+
}
|
|
587
|
+
return exitCodeForStatus(status);
|
|
588
|
+
}
|
|
589
|
+
/** Best-effort evidence side artifact; a failure to write it does not fail the gate. */
|
|
590
|
+
function writeEvidenceFile(evidenceDir, name, body) {
|
|
591
|
+
if (evidenceDir === undefined) {
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
const path = join(evidenceDir, name);
|
|
595
|
+
const refusal = refuseOpenForWrite(path);
|
|
596
|
+
if (refusal !== undefined) {
|
|
597
|
+
process.stderr.write(`tiphys gates scope: could not write evidence ${path}: ${refusal}\n`);
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
const written = runStep(`writing ${path}`, () => writeFileSync(path, body));
|
|
601
|
+
if (!written.ok) {
|
|
602
|
+
process.stderr.write(`tiphys gates scope: ${written.reason}\n`);
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
return name;
|
|
606
|
+
}
|
|
607
|
+
function now() {
|
|
608
|
+
return new Date().toISOString();
|
|
609
|
+
}
|
|
610
|
+
/* -------------------------------------------------------------------- */
|
|
611
|
+
/* The audit itself. */
|
|
612
|
+
/* -------------------------------------------------------------------- */
|
|
613
|
+
export function main(argv) {
|
|
614
|
+
const flags = parseFlags(argv);
|
|
615
|
+
if (flags === undefined) {
|
|
616
|
+
return usageError();
|
|
617
|
+
}
|
|
618
|
+
const missing = ["declarations", "result", "base", "phase"].filter((name) => flags[name] === undefined);
|
|
619
|
+
if (missing.length > 0) {
|
|
620
|
+
return usageError(`scope requires ${missing.map((name) => `--${name}`).join(" ")}`);
|
|
621
|
+
}
|
|
622
|
+
const cwd = process.cwd();
|
|
623
|
+
const startedAt = now();
|
|
624
|
+
const declarationsDir = flags.declarations;
|
|
625
|
+
const resultPath = flags.result;
|
|
626
|
+
const evidenceDir = flags.evidence;
|
|
627
|
+
const base = flags.base;
|
|
628
|
+
const head = flags.head ?? "HEAD";
|
|
629
|
+
const phase = flags.phase;
|
|
630
|
+
const shared = { gate: "scope", unitLabel: "changed paths audited", startedAt };
|
|
631
|
+
// CR-1047: the failure path below reads a shipped schema document
|
|
632
|
+
// (`declarationSchema`, invoked from `loadDeclarationAtMergeBase`), which
|
|
633
|
+
// THROWS on a missing schema, a schema outside the closed keyword set, or
|
|
634
|
+
// an irregular entry (a named pipe) at the schema path. Node's default
|
|
635
|
+
// uncaught-exception exit code is 1, identical to EXIT_RED, so before this
|
|
636
|
+
// wrap a crash there was indistinguishable from a genuine red verdict to
|
|
637
|
+
// any consumer reading only the exit code. Wrapping produces a clean
|
|
638
|
+
// `error` record instead, on the same standalone path this module's own
|
|
639
|
+
// USAGE line documents (the `tiphys gates run` ingest already degrades a
|
|
640
|
+
// crashing subprocess to `error`; this is the backstop for running this
|
|
641
|
+
// script directly, which has no such wrapper of its own until now).
|
|
642
|
+
try {
|
|
643
|
+
const mergeBaseResult = resolveMergeBase(cwd, base, head);
|
|
644
|
+
if (!mergeBaseResult.ok) {
|
|
645
|
+
return emit(resultPath, {
|
|
646
|
+
...shared,
|
|
647
|
+
status: "error",
|
|
648
|
+
units: 0,
|
|
649
|
+
endedAt: now(),
|
|
650
|
+
detail: mergeBaseResult.reason,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const mergeBase = mergeBaseResult.sha;
|
|
654
|
+
// Fix round 1, CR-1030/CR-1045 mechanism, member W3: --head is supplied
|
|
655
|
+
// by the party under audit; a --head set to an ancestor of the real tip
|
|
656
|
+
// hides later commits from the diff without changing the merge base at
|
|
657
|
+
// all (the merge base depends only on the fork point, not on which
|
|
658
|
+
// descendant of it --head names), so this cannot be caught by the
|
|
659
|
+
// trunk-ancestry check below. Cross-check the resolved --head against a
|
|
660
|
+
// property the caller does not supply: the commit this process actually
|
|
661
|
+
// has checked out.
|
|
662
|
+
const actualHeadResult = resolveRef(cwd, "HEAD");
|
|
663
|
+
if (!actualHeadResult.ok) {
|
|
664
|
+
return emit(resultPath, {
|
|
665
|
+
...shared,
|
|
666
|
+
status: "error",
|
|
667
|
+
units: 0,
|
|
668
|
+
endedAt: now(),
|
|
669
|
+
detail: `could not resolve the commit actually checked out: ${actualHeadResult.reason}`,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const resolvedHeadResult = resolveRef(cwd, head);
|
|
673
|
+
if (!resolvedHeadResult.ok) {
|
|
674
|
+
return emit(resultPath, {
|
|
675
|
+
...shared,
|
|
676
|
+
status: "error",
|
|
677
|
+
units: 0,
|
|
678
|
+
endedAt: now(),
|
|
679
|
+
detail: `could not resolve --head ${head}: ${resolvedHeadResult.reason}`,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (resolvedHeadResult.sha !== actualHeadResult.sha) {
|
|
683
|
+
return emit(resultPath, {
|
|
684
|
+
...shared,
|
|
685
|
+
status: "error",
|
|
686
|
+
units: 0,
|
|
687
|
+
endedAt: now(),
|
|
688
|
+
detail: `--head ${head} resolves to ${resolvedHeadResult.sha}, but the commit actually checked ` +
|
|
689
|
+
`out in this working tree is ${actualHeadResult.sha}; refusing to audit a diff whose ` +
|
|
690
|
+
"right-hand side is not what is really checked out, which could omit later commits",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
// Fix round 1, CR-1030/CR-1045 mechanism, member W2: --base is equally
|
|
694
|
+
// caller-supplied. A merge base forked onto the audited branch itself
|
|
695
|
+
// (for example `--base HEAD~1` after committing a widened declaration)
|
|
696
|
+
// makes the object-database read above resolve the BRANCH's own blob,
|
|
697
|
+
// not main's, defeating the anti-widening property this phase exists
|
|
698
|
+
// for. Cross-check the merge base against a reference this process
|
|
699
|
+
// resolves itself, not a second caller-supplied string.
|
|
700
|
+
const trunkResult = resolveTrunk(cwd);
|
|
701
|
+
if (!trunkResult.ok) {
|
|
702
|
+
return emit(resultPath, {
|
|
703
|
+
...shared,
|
|
704
|
+
status: "error",
|
|
705
|
+
units: 0,
|
|
706
|
+
endedAt: now(),
|
|
707
|
+
detail: trunkResult.reason,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
const ancestorResult = isAncestorOf(cwd, mergeBase, trunkResult.sha);
|
|
711
|
+
if (!ancestorResult.ok) {
|
|
712
|
+
return emit(resultPath, {
|
|
713
|
+
...shared,
|
|
714
|
+
status: "error",
|
|
715
|
+
units: 0,
|
|
716
|
+
endedAt: now(),
|
|
717
|
+
detail: ancestorResult.reason,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
if (!ancestorResult.isAncestor) {
|
|
721
|
+
return emit(resultPath, {
|
|
722
|
+
...shared,
|
|
723
|
+
status: "error",
|
|
724
|
+
units: 0,
|
|
725
|
+
endedAt: now(),
|
|
726
|
+
detail: `merge base ${mergeBase} (of --base ${base} and --head ${head}) is not an ancestor of ` +
|
|
727
|
+
`the configured trunk ${trunkResult.ref} (${trunkResult.sha}); this is the shape of a ` +
|
|
728
|
+
"merge base forked onto the branch under audit rather than the true fork point with main",
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const declLoad = loadDeclarationAtMergeBase(cwd, mergeBase, declarationsDir, phase);
|
|
732
|
+
const branch = currentBranch(cwd);
|
|
733
|
+
if (!declLoad.ok) {
|
|
734
|
+
if (declLoad.kind === "error") {
|
|
735
|
+
return emit(resultPath, {
|
|
736
|
+
...shared,
|
|
737
|
+
status: "error",
|
|
738
|
+
units: 0,
|
|
739
|
+
endedAt: now(),
|
|
740
|
+
detail: declLoad.reason,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
// declLoad.kind === "missing": the branch-matches precondition already
|
|
744
|
+
// established (at the runner) that this branch matches the phase
|
|
745
|
+
// pattern, but no declaration exists for it at the merge base.
|
|
746
|
+
return emit(resultPath, {
|
|
747
|
+
...shared,
|
|
748
|
+
status: "red",
|
|
749
|
+
units: 0,
|
|
750
|
+
endedAt: now(),
|
|
751
|
+
detail: `branch ${branch} (phase ${phase}) matches the phase pattern but no phase declaration ` +
|
|
752
|
+
`exists at ${declLoad.path} in the merge base ${mergeBase} of --base ${base} and --head ${head}; ` +
|
|
753
|
+
"the declaration must be committed to main before the phase branch is created",
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
const declaration = declLoad.declaration;
|
|
757
|
+
const declarationSha256 = declLoad.sha256;
|
|
758
|
+
const declarationPath = declLoad.path;
|
|
759
|
+
// Fix round 1, CR-1030/CR-1045 mechanism, member W1 (the yardstick
|
|
760
|
+
// swap): --phase selects WHICH declaration governs this run. Before this
|
|
761
|
+
// round nothing checked that the declaration read back actually claims
|
|
762
|
+
// to be for that phase or that branch, so a caller could name a
|
|
763
|
+
// different, more permissive, already-committed declaration and have it
|
|
764
|
+
// silently accepted for this branch's diff. Declaration ids are the
|
|
765
|
+
// plan's uppercase spelling (schema pattern `^M[0-9]+-P[0-9]+$`); --phase
|
|
766
|
+
// is the lowercase, hyphenated filename form (a deviation this phase
|
|
767
|
+
// already records), so the comparison is case-normalized, not literal.
|
|
768
|
+
if (phase.toUpperCase() !== declaration.id) {
|
|
769
|
+
return emit(resultPath, {
|
|
770
|
+
...shared,
|
|
771
|
+
status: "error",
|
|
772
|
+
units: 0,
|
|
773
|
+
endedAt: now(),
|
|
774
|
+
detail: `--phase ${phase} does not match declaration ${declarationPath}'s own id ${declaration.id} ` +
|
|
775
|
+
`(read from merge base ${mergeBase}); refusing to audit this diff against a declaration ` +
|
|
776
|
+
"that does not claim to be the one --phase named",
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (branch !== declaration.branch) {
|
|
780
|
+
return emit(resultPath, {
|
|
781
|
+
...shared,
|
|
782
|
+
status: "error",
|
|
783
|
+
units: 0,
|
|
784
|
+
endedAt: now(),
|
|
785
|
+
detail: `the current branch ${branch} does not match declaration ${declarationPath}'s own branch ` +
|
|
786
|
+
`${declaration.branch} (read from merge base ${mergeBase}); refusing to audit a branch ` +
|
|
787
|
+
"against a declaration that does not claim to govern it",
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
// M3-P11 change B. The head's declaration is read as well, from the
|
|
791
|
+
// object database at the commit this process has already proven is the
|
|
792
|
+
// real checkout. An ADDITION is allowed and named; a REMOVAL is red.
|
|
793
|
+
const headDeclLoad = loadDeclarationAtCommit(cwd, actualHeadResult.sha, declarationsDir, phase);
|
|
794
|
+
if (!headDeclLoad.ok && headDeclLoad.kind === "error") {
|
|
795
|
+
return emit(resultPath, {
|
|
796
|
+
...shared,
|
|
797
|
+
status: "error",
|
|
798
|
+
units: 0,
|
|
799
|
+
endedAt: now(),
|
|
800
|
+
detail: `reading the head declaration: ${headDeclLoad.reason}`,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (!headDeclLoad.ok) {
|
|
804
|
+
// The declaration exists at the merge base and NOT at the head: the
|
|
805
|
+
// branch deleted it. That is the largest possible removal, so it takes
|
|
806
|
+
// the removal arm rather than being treated as "no delta".
|
|
807
|
+
return emit(resultPath, {
|
|
808
|
+
...shared,
|
|
809
|
+
status: "red",
|
|
810
|
+
units: 0,
|
|
811
|
+
endedAt: now(),
|
|
812
|
+
detail: `declaration ${declarationPath} exists at merge base ${mergeBase} but not at head ` +
|
|
813
|
+
`${actualHeadResult.sha}; a phase branch may ADD to its own declaration, and this ` +
|
|
814
|
+
"branch removed the whole of it",
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
const delta = compareDeclarations(declaration, headDeclLoad.declaration);
|
|
818
|
+
if (delta.removed.length > 0) {
|
|
819
|
+
return emit(resultPath, {
|
|
820
|
+
...shared,
|
|
821
|
+
status: "red",
|
|
822
|
+
units: 0,
|
|
823
|
+
endedAt: now(),
|
|
824
|
+
detail: `declaration ${declarationPath} REMOVES ${String(delta.removed.length)} entry/entries at ` +
|
|
825
|
+
`head ${actualHeadResult.sha} that are present at merge base ${mergeBase}: ` +
|
|
826
|
+
`${delta.removed.join(", ")}; a phase branch may ADD to its own declaration, never remove ` +
|
|
827
|
+
"from it, because a removal narrows what a later audit will check",
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
const touchedResult = computeTouchedPaths(cwd, mergeBase, head);
|
|
831
|
+
if (!touchedResult.ok) {
|
|
832
|
+
return emit(resultPath, {
|
|
833
|
+
...shared,
|
|
834
|
+
status: "error",
|
|
835
|
+
units: 0,
|
|
836
|
+
endedAt: now(),
|
|
837
|
+
detail: touchedResult.reason,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const touched = touchedResult.paths;
|
|
841
|
+
const standingExtras = ["test/behaviors.json", `delivery/work-history/${phase}.md`];
|
|
842
|
+
// The allowed set is the UNION of the two sides. Only additions can reach
|
|
843
|
+
// here: a removal already returned red above, so the union never grants
|
|
844
|
+
// less than the merge base did.
|
|
845
|
+
const headDeclaration = headDeclLoad.declaration;
|
|
846
|
+
const allowed = [
|
|
847
|
+
...new Set([
|
|
848
|
+
...declaration.filesToTouch,
|
|
849
|
+
...declaration.declaredExtras,
|
|
850
|
+
...headDeclaration.filesToTouch,
|
|
851
|
+
...headDeclaration.declaredExtras,
|
|
852
|
+
...standingExtras,
|
|
853
|
+
]),
|
|
854
|
+
];
|
|
855
|
+
// FIX ROUND 1, finding C-2. The removal refusal above reads exactly ONE
|
|
856
|
+
// file, `<declarationsDir>/<phase>.json`, on both sides. Every other
|
|
857
|
+
// phase's declaration is, to this gate, an ordinary path: allow it into
|
|
858
|
+
// scope by any route and the branch may NARROW it, with no delta check at
|
|
859
|
+
// all, and the narrowing lands on main and governs that phase's later
|
|
860
|
+
// audit. Change B made that reachable without a separate merged pull
|
|
861
|
+
// request, which is the friction it was written to remove and was also
|
|
862
|
+
// the review point. Measured green in a scratch repository by a clean-room
|
|
863
|
+
// reviewer before this round.
|
|
864
|
+
//
|
|
865
|
+
// The rule is a property of the DIRECTORY, not of the route that granted
|
|
866
|
+
// access, so it covers a directory-prefix addition, an explicitly listed
|
|
867
|
+
// foreign declaration, and a grant that was already in the merge base
|
|
868
|
+
// alike. It is deliberately blunt: ANY touched path under the
|
|
869
|
+
// declarations directory other than this phase's own declaration is red,
|
|
870
|
+
// including a non-declaration file such as that directory's README, which
|
|
871
|
+
// a phase branch has no business editing either. Paperwork branches do
|
|
872
|
+
// not match the phase-branch pattern, so this gate is not applicable to
|
|
873
|
+
// them and they remain the place such edits are made.
|
|
874
|
+
const declarationsPrefix = `${declarationsDir.replace(/\/+$/, "")}/`;
|
|
875
|
+
const foreignDeclarations = [
|
|
876
|
+
...new Set(touched
|
|
877
|
+
.map((entry) => entry.path)
|
|
878
|
+
.filter((path) => path.startsWith(declarationsPrefix) && path !== declarationPath)),
|
|
879
|
+
].sort();
|
|
880
|
+
const violations = [
|
|
881
|
+
...new Set(touched
|
|
882
|
+
.filter((entry) => !isAllowed(entry.path, allowed) && !isPhaseOwnEvidence(entry.path, phase))
|
|
883
|
+
.map((entry) => entry.path)),
|
|
884
|
+
].sort();
|
|
885
|
+
const declaredLiterals = [
|
|
886
|
+
...new Set([
|
|
887
|
+
...declaration.filesToTouch,
|
|
888
|
+
...declaration.declaredExtras,
|
|
889
|
+
...headDeclaration.filesToTouch,
|
|
890
|
+
...headDeclaration.declaredExtras,
|
|
891
|
+
]),
|
|
892
|
+
].filter((entry) => !entry.endsWith("/"));
|
|
893
|
+
const touchedSet = new Set(touched.map((entry) => entry.path));
|
|
894
|
+
const underTouched = declaredLiterals.filter((entry) => !touchedSet.has(entry)).sort();
|
|
895
|
+
const evidenceName = writeEvidenceFile(evidenceDir, "scope-audit.json", `${JSON.stringify({
|
|
896
|
+
phase,
|
|
897
|
+
base,
|
|
898
|
+
head,
|
|
899
|
+
mergeBase,
|
|
900
|
+
declarationPath,
|
|
901
|
+
declarationSha256,
|
|
902
|
+
headDeclarationSha256: headDeclLoad.sha256,
|
|
903
|
+
declarationDelta: delta,
|
|
904
|
+
touchedPaths: touched,
|
|
905
|
+
allowed,
|
|
906
|
+
violations,
|
|
907
|
+
foreignDeclarations,
|
|
908
|
+
underTouched,
|
|
909
|
+
}, null, 2)}\n`);
|
|
910
|
+
const evidence = evidenceName === undefined ? [] : [evidenceName];
|
|
911
|
+
const units = touched.length;
|
|
912
|
+
// M3-P11 criterion 9. The addition is PRINTED BY NAME, on both the green
|
|
913
|
+
// and the red arm, because a silent pass is the exact failure this change
|
|
914
|
+
// would otherwise introduce: the gate would stop refusing a widened
|
|
915
|
+
// declaration and say nothing about it, which is worse than either the
|
|
916
|
+
// old hard red or an honest named note. `emit` writes `detail` to stdout,
|
|
917
|
+
// so putting it here is what makes it a printed line rather than only a
|
|
918
|
+
// field in a record somebody might read.
|
|
919
|
+
//
|
|
920
|
+
// FIX ROUND 1, findings C-1 and M-1. Two things were wrong with the note
|
|
921
|
+
// as first written, and both are the same mechanism: a compensating
|
|
922
|
+
// control is worth what it is READ at, and this one was weaker in two
|
|
923
|
+
// independent ways than the hard refusal it replaced.
|
|
924
|
+
//
|
|
925
|
+
// M-1, WHAT IS GRANTED WAS NOT LEGIBLE. A directory prefix printed
|
|
926
|
+
// exactly like a single file. `describeAddition` now says which it is.
|
|
927
|
+
//
|
|
928
|
+
// C-1, THE NOTE DID NOT REACH THE READER on the green arm, which is
|
|
929
|
+
// the only arm criterion 9 is about, because `tiphys gates run` printed
|
|
930
|
+
// non-green rows only. That half is fixed in src/commands/gates.ts,
|
|
931
|
+
// which now relays EVERY row; it is named here so the next reader of
|
|
932
|
+
// this note knows its delivery depends on a second file.
|
|
933
|
+
const directoryPrefixCount = delta.added.filter((entry) => entry.endsWith("/")).length;
|
|
934
|
+
const amendmentNote = delta.added.length > 0
|
|
935
|
+
? ` DECLARATION AMENDED AT HEAD: ${String(delta.added.length)} entry/entries ADDED at ` +
|
|
936
|
+
`head ${actualHeadResult.sha} that are absent from the merge-base declaration, allowed ` +
|
|
937
|
+
`and NAMED here for a reviewer to sign off (this gate does not sign them off)` +
|
|
938
|
+
(directoryPrefixCount > 0
|
|
939
|
+
? `, ${String(directoryPrefixCount)} of them a DIRECTORY PREFIX rather than a single file`
|
|
940
|
+
: "") +
|
|
941
|
+
`: ${delta.added.map(describeAddition).join(", ")}.`
|
|
942
|
+
: "";
|
|
943
|
+
const underTouchNote = underTouched.length > 0
|
|
944
|
+
? ` (${String(underTouched.length)} declared path(s) not touched: ${underTouched.join(", ")})`
|
|
945
|
+
: "";
|
|
946
|
+
if (foreignDeclarations.length > 0) {
|
|
947
|
+
return emit(resultPath, {
|
|
948
|
+
...shared,
|
|
949
|
+
status: "red",
|
|
950
|
+
units,
|
|
951
|
+
endedAt: now(),
|
|
952
|
+
detail: `this branch changes ${String(foreignDeclarations.length)} path(s) under ` +
|
|
953
|
+
`${declarationsPrefix} that are not its own declaration ${declarationPath}: ` +
|
|
954
|
+
`${foreignDeclarations.join(", ")}; a phase branch may ADD to its OWN declaration and ` +
|
|
955
|
+
"nothing else here, because the removal refusal reads only this phase's file and a " +
|
|
956
|
+
"change to another phase's declaration is therefore unchecked and lands on main " +
|
|
957
|
+
"governing that phase's later audit" +
|
|
958
|
+
`${underTouchNote}${amendmentNote}`,
|
|
959
|
+
evidence,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
if (violations.length > 0) {
|
|
963
|
+
return emit(resultPath, {
|
|
964
|
+
...shared,
|
|
965
|
+
status: "red",
|
|
966
|
+
units,
|
|
967
|
+
endedAt: now(),
|
|
968
|
+
detail: `touched path(s) outside the declared scope: ${violations.join(", ")} ` +
|
|
969
|
+
`(declaration ${declarationPath} at merge base ${mergeBase}, sha256 ${declarationSha256})` +
|
|
970
|
+
`${underTouchNote}${amendmentNote}`,
|
|
971
|
+
evidence,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
return emit(resultPath, {
|
|
975
|
+
...shared,
|
|
976
|
+
status: "green",
|
|
977
|
+
units,
|
|
978
|
+
endedAt: now(),
|
|
979
|
+
detail: `${String(units)} changed path(s) audited against declaration ${declarationPath} ` +
|
|
980
|
+
`at merge base ${mergeBase} (sha256 ${declarationSha256})${underTouchNote}${amendmentNote}`,
|
|
981
|
+
evidence,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
return emit(resultPath, {
|
|
986
|
+
...shared,
|
|
987
|
+
status: "error",
|
|
988
|
+
units: 0,
|
|
989
|
+
endedAt: now(),
|
|
990
|
+
detail: `scope gate crashed before reaching a verdict: ${singleLine(error.message ?? String(error))}`,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Auto-run only when this module is the DIRECTLY INVOKED entry point, never
|
|
996
|
+
* on import. Tests import this module (computed-URL dynamic import, CLAUDE.md
|
|
997
|
+
* warning 4) to exercise `main` and the pure helpers without spawning a
|
|
998
|
+
* process, and a module that ran its CLI as a side effect of being loaded
|
|
999
|
+
* would call `process.exit` out from under the test runner.
|
|
1000
|
+
*/
|
|
1001
|
+
const invokedDirectly = process.argv[1] !== undefined &&
|
|
1002
|
+
pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1]);
|
|
1003
|
+
if (invokedDirectly) {
|
|
1004
|
+
// CR-1047, second layer: `main` already wraps its own body, but this
|
|
1005
|
+
// catches anything that could escape from outside that wrap (flag
|
|
1006
|
+
// parsing, `usageError`, or a future change to either) so the standalone
|
|
1007
|
+
// entry point this module documents never exits 1 (EXIT_RED) on an
|
|
1008
|
+
// uncaught throw, which is indistinguishable from a genuine red verdict
|
|
1009
|
+
// to a consumer reading only the exit code (CR-801 recurring).
|
|
1010
|
+
try {
|
|
1011
|
+
process.exitCode = main(process.argv.slice(2));
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
process.stderr.write(`tiphys gates scope: ${singleLine(error.message ?? String(error))}\n`);
|
|
1015
|
+
process.exitCode = EXIT_GATE_ERROR;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
export { compareDeclarations, computeTouchedPaths, currentBranch, isAllowed, isAncestorOf, isPhaseOwnEvidence, loadDeclarationAtCommit, loadDeclarationAtMergeBase, resolveMergeBase, resolveRef, resolveTrunk, };
|