@tiphys/kernel 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,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, };