@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,1373 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, singleLine, } from "../task.js";
7
+ import { comparePins, describePinDifference, takePin } from "../gates/pin.js";
8
+ import { describeMember, memberTouchedFiles } from "./spec.js";
9
+ /**
10
+ * Run git with a pinned C locale (verification-m1-p3-fix-round.md U-8: a
11
+ * message-text classification with no locale pin silently stops matching in
12
+ * a localized environment).
13
+ */
14
+ /* THE DEFAULT maxBuffer IS 1 MiB AND A PHASE DIFF EXCEEDS IT, which turns this
15
+ helper's ENOBUFS into a gate `error` on exactly the pull requests that carry
16
+ the most to check. Measured 2026-08-15 on the M3 exit-test bundle:
17
+ `git diff -U0 --no-renames origin/main...HEAD` produced 9,120,827 bytes and
18
+ `red-witness` reported
19
+ `error: ... could not be run: Error: spawnSync git ENOBUFS`.
20
+
21
+ A cap is still wanted rather than none, so that a runaway diff fails loudly
22
+ instead of exhausting memory; 256 MiB is far above any real diff and far
23
+ below that risk. The failure remains an `error` with its reason, never a
24
+ green, which is M2-C-3. */
25
+ const GIT_MAX_BUFFER_BYTES = 256 * 1024 * 1024;
26
+ export function gitIn(cwd, args) {
27
+ const result = spawnSync("git", args, {
28
+ cwd,
29
+ encoding: "utf8",
30
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
31
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
32
+ });
33
+ if (result.error !== undefined) {
34
+ return {
35
+ ok: false,
36
+ status: null,
37
+ stdout: "",
38
+ stderr: "",
39
+ reason: `git ${args.join(" ")} could not be run: ${singleLine(String(result.error))}`,
40
+ };
41
+ }
42
+ const ok = result.status === 0;
43
+ return {
44
+ ok,
45
+ status: result.status,
46
+ stdout: result.stdout ?? "",
47
+ stderr: result.stderr ?? "",
48
+ reason: ok
49
+ ? ""
50
+ : `git ${args.join(" ")} exited ${String(result.status)}: ${singleLine(result.stderr ?? "")}`,
51
+ };
52
+ }
53
+ function escapeRegExp(value) {
54
+ return value.replace(/[.*+?^${}()|[\]\\\-]/g, "\\$&");
55
+ }
56
+ /**
57
+ * Compute the phase diff base...head: changed files with status and the
58
+ * new-side line ranges of every changed hunk.
59
+ */
60
+ export function computePhaseDiff(repoRoot, base, head) {
61
+ const baseResolved = gitIn(repoRoot, ["rev-parse", "--verify", `${base}^{commit}`]);
62
+ if (!baseResolved.ok) {
63
+ return {
64
+ ok: false,
65
+ reason: `the base revision ${base} does not resolve in this repository: ` +
66
+ `${baseResolved.reason}. The red-witness gate requires full history ` +
67
+ `(fetch-depth: 0); see the M2-P2 work history, CI depth requirement.`,
68
+ };
69
+ }
70
+ const headResolved = gitIn(repoRoot, ["rev-parse", "--verify", `${head}^{commit}`]);
71
+ if (!headResolved.ok) {
72
+ return {
73
+ ok: false,
74
+ reason: `the head revision ${head} does not resolve in this repository: ${headResolved.reason}`,
75
+ };
76
+ }
77
+ const baseSha = baseResolved.stdout.trim();
78
+ const headSha = headResolved.stdout.trim();
79
+ const mergeBase = gitIn(repoRoot, ["merge-base", baseSha, headSha]);
80
+ const mergeBaseSha = mergeBase.ok ? mergeBase.stdout.trim() : baseSha;
81
+ const names = gitIn(repoRoot, [
82
+ "diff",
83
+ "--name-status",
84
+ "--no-renames",
85
+ `${baseSha}...${headSha}`,
86
+ ]);
87
+ if (!names.ok) {
88
+ return { ok: false, reason: names.reason };
89
+ }
90
+ const files = new Map();
91
+ for (const line of names.stdout.split("\n")) {
92
+ if (line.trim() === "") {
93
+ continue;
94
+ }
95
+ const [status, ...rest] = line.split("\t");
96
+ const path = rest.join("\t");
97
+ if (status === undefined || path === "") {
98
+ continue;
99
+ }
100
+ files.set(path, { path, status, hunks: [] });
101
+ }
102
+ const hunkOutput = gitIn(repoRoot, ["diff", "-U0", "--no-renames", `${baseSha}...${headSha}`]);
103
+ if (!hunkOutput.ok) {
104
+ return { ok: false, reason: hunkOutput.reason };
105
+ }
106
+ let current;
107
+ for (const line of hunkOutput.stdout.split("\n")) {
108
+ const plus = /^\+\+\+ b\/(.+)$/.exec(line);
109
+ if (plus !== null) {
110
+ current = files.get(plus[1]);
111
+ continue;
112
+ }
113
+ if (line.startsWith("+++ /dev/null")) {
114
+ current = undefined;
115
+ continue;
116
+ }
117
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
118
+ if (hunk !== null && current !== undefined) {
119
+ const start = Number(hunk[1]);
120
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
121
+ if (count > 0) {
122
+ current.hunks.push([start, start + count - 1]);
123
+ }
124
+ }
125
+ }
126
+ return { ok: true, diff: { baseSha, headSha, mergeBaseSha, files } };
127
+ }
128
+ /**
129
+ * The spawn grep of rule (f), over the changed files' head-state contents.
130
+ * Exactly the plan's four tokens (M2-P2 step 4 rule (f)): a wider pattern
131
+ * (for example a bare `exec(`) false-positives on `RegExp.exec` calls and
132
+ * would derive a capture obligation from a file that spawns nothing.
133
+ */
134
+ export const SPAWN_GREP = /child_process|execFile|spawnSync|execSync/;
135
+ /**
136
+ * The shell form of rule (f)'s derivation (CR-H2). A POSIX shell script that
137
+ * spawns another program and PARSES its output contains none of the four JS
138
+ * tokens, so `SPAWN_GREP` is blind to exactly where M1's V-2 lived
139
+ * (`bin/fm-*.sh` classifying git contention output). A shell spawn-and-parse
140
+ * is derived as the conjunction of two signals over the script text:
141
+ * - SPAWN: another program's output is captured (a pipeline `|`, a command
142
+ * substitution `$(...)` or backticks, or stdin consumed by `read`/`while
143
+ * read`);
144
+ * - PARSE: that output is classified or transformed (`grep`, `awk`, `sed`,
145
+ * `cut`, `tr`, a `case` branch, or a `[[ ... =~ ... ]]` regex test).
146
+ * The conjunction is deliberately narrow: a script that only runs a command
147
+ * for its exit status, with no capture and no classifier, is not "in that
148
+ * state" and is not burdened with a capture obligation. What this does NOT
149
+ * cover is stated in the work history (rule (f), shell residue).
150
+ */
151
+ export const SHELL_SPAWN = /\|[^|]|\$\(|`|(?:^|\s)read\s/m;
152
+ export const SHELL_PARSE = /(?:^|[\s|(])(?:grep|awk|sed|cut|tr)\b|(?:^|\s)case\s|=~/m;
153
+ /** True when the shell script text both spawns a program and parses its output. */
154
+ export function shellSpawnsAndParses(text) {
155
+ return SHELL_SPAWN.test(text) && SHELL_PARSE.test(text);
156
+ }
157
+ /**
158
+ * Parse a PINNED tap stream. The format is a controlled input: the child
159
+ * was invoked with `--test-reporter tap`, so a stream that does not open
160
+ * with `TAP version` is a reporter-pin failure naming both formats, never
161
+ * something to parse anyway.
162
+ */
163
+ export function parseTapStream(text) {
164
+ const lines = text.split("\n");
165
+ let first = "";
166
+ for (const line of lines) {
167
+ if (line.trim() !== "") {
168
+ first = line.trim();
169
+ break;
170
+ }
171
+ }
172
+ if (!first.startsWith("TAP version")) {
173
+ return {
174
+ ok: false,
175
+ reason: `expected the pinned reporter format tap (a stream opening with ` +
176
+ `"TAP version") but observed a stream opening with ` +
177
+ `${JSON.stringify(first.slice(0, 80))}`,
178
+ };
179
+ }
180
+ const tests = [];
181
+ for (const line of lines) {
182
+ const match = /^\s*(not )?ok\s+\d+\s+-\s+(.*)$/.exec(line);
183
+ if (match === null) {
184
+ continue;
185
+ }
186
+ let name = match[2];
187
+ let skipped = false;
188
+ const directive = / # (SKIP|TODO)\b/i.exec(name);
189
+ if (directive !== null) {
190
+ skipped = true;
191
+ name = name.slice(0, directive.index);
192
+ }
193
+ tests.push({ name: name.trim(), ok: match[1] === undefined, skipped });
194
+ }
195
+ return { ok: true, tests };
196
+ }
197
+ const STRING_LITERAL = /(["'`])((?:(?!\1)[^\\]|\\.)*)\1/g;
198
+ // The read callee the detector targets: `readFile` / `readFileSync`, reached
199
+ // either as a bare identifier (a destructured named import) OR through a
200
+ // namespace member chain (`fs.readFileSync`, `fs.promises.readFile`, an
201
+ // aliased `fsp.readFile`). CR-1500: the namespace-qualified form is the
202
+ // DOMINANT real-world idiom and round one recognised only the bare form, so a
203
+ // single deleting text-asserting member shipped green under it. The prefix is
204
+ // zero or more `<ident>.` segments, which is still the SAME builtin, not an
205
+ // idiom widening: it resolves the callee, never the meaning of the read. What
206
+ // it deliberately does NOT reach is stated in the work history (a callee bound
207
+ // to another variable, `const rf = fs.readFileSync`; callback-style reads).
208
+ const READ_CALLEE = String.raw `(?:[A-Za-z_$][\w$]*\s*\.\s*)*readFile(?:Sync)?`;
209
+ // A read call whose result is bound to a name, so the assertions ON that
210
+ // name can be found: `const body = readFileSync(...)`, `let body = await
211
+ // fs.promises.readFile(...)`. Sync AND async, bare AND namespace-qualified,
212
+ // are covered. A trailing `.trim()` or similar leaves the binding intact.
213
+ const READ_BINDING = new RegExp(String.raw `(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?` +
214
+ READ_CALLEE +
215
+ String.raw `\s*\(([^)]*)\)`, "g");
216
+ // A bare-string binding, so a read whose path is held in a variable
217
+ // (`readFileSync(P)` with `const P = "....yml"`, CR-H1 member F) resolves to
218
+ // the document rather than vanishing.
219
+ const STRING_BINDING = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])((?:(?!\2)[^\\]|\\.)*)\2\s*;?/g;
220
+ const EQUAL_FORMS = "(?:equal|strictEqual|deepEqual|deepStrictEqual|notEqual|notStrictEqual|notDeepEqual)";
221
+ // Extension-less root documents: a CLOSED, named vocabulary of well-known
222
+ // files whose path carries neither `/` nor `.` (CR-1501). Without this a
223
+ // witness reading `Makefile` or `LICENSE` and asserting its text escaped the
224
+ // detector, because the "no slash and no dot" test rejected the path as a
225
+ // bare token. This is a denylist-shaped gap, so it is DERIVED from the
226
+ // closed set of conventional extension-less root files rather than guessed by
227
+ // pattern; any extension-less name OUTSIDE this set stays behaviour and is
228
+ // named as residue in the work history rather than chased.
229
+ const EXTENSIONLESS_ROOT_DOCS = new Set([
230
+ "Makefile",
231
+ "Dockerfile",
232
+ "Containerfile",
233
+ "Jenkinsfile",
234
+ "Vagrantfile",
235
+ "Rakefile",
236
+ "Gemfile",
237
+ "Procfile",
238
+ "Brewfile",
239
+ "LICENSE",
240
+ "LICENCE",
241
+ "NOTICE",
242
+ "COPYING",
243
+ "AUTHORS",
244
+ "CONTRIBUTORS",
245
+ "CODEOWNERS",
246
+ "README",
247
+ "CHANGELOG",
248
+ ]);
249
+ /** A document read the CR-661 class cares about: a path outside src/ and test/. */
250
+ function isDocumentPathLiteral(value) {
251
+ if (value.startsWith("src/") || value.startsWith("test/")) {
252
+ return false;
253
+ }
254
+ if (!value.includes("/") && !value.includes(".")) {
255
+ // Extension-less: only a recognised root-document name (the closed set
256
+ // above) counts. Any other bare token (a variable-like word, a scratch
257
+ // label) stays behaviour.
258
+ return EXTENSIONLESS_ROOT_DOCS.has(value);
259
+ }
260
+ return true;
261
+ }
262
+ /**
263
+ * The document path a read call targets, or undefined when it is not
264
+ * statically resolvable to a document literal. A string literal in the
265
+ * call's arguments wins; failing that, a bare identifier bound earlier to a
266
+ * string literal (member F). A runtime path (a `join(...)`, a `mkdtemp`
267
+ * result) resolves to undefined ON PURPOSE: it cannot be told apart from a
268
+ * scratch temp read, so flagging it would redden legitimate behaviour
269
+ * witnesses (the atomic determinism fixture reads a temp `state.json`).
270
+ */
271
+ function documentPathFromArgs(args, varToPath) {
272
+ for (const literal of args.matchAll(STRING_LITERAL)) {
273
+ const value = literal[2];
274
+ if (isDocumentPathLiteral(value)) {
275
+ return value;
276
+ }
277
+ }
278
+ const ident = /^\s*([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(args);
279
+ if (ident !== null) {
280
+ return varToPath.get(ident[1]);
281
+ }
282
+ return undefined;
283
+ }
284
+ /**
285
+ * Whether a read-result variable is TEXT-ASSERTED directly, and any
286
+ * statically extractable pattern. Tied to the variable so a read passed to a
287
+ * project function (`isTransient(body)`, the retry witness) is behaviour, not
288
+ * a text assertion, and does not falsely redden. Covers the assert forms the
289
+ * plan names: `assert.match`/`assert.doesNotMatch` (regex over the body),
290
+ * `body.includes`/`body.indexOf` (membership, `assert.ok` wrapper included by
291
+ * matching the method call itself), and the equality family over the whole
292
+ * body (CR-H1: `assert.equal` over a document is among the strongest text
293
+ * assertions). A form the detector recognises but cannot extract a literal
294
+ * from (a variable regex, `assert.match(body, wanted)`) still marks
295
+ * text-asserting, with no pattern: fail conservative (CR-H1).
296
+ */
297
+ function textAssertionsOnVar(source, varName) {
298
+ const v = escapeRegExp(varName);
299
+ const patterns = [];
300
+ let asserted = false;
301
+ const matchLiteral = new RegExp(`assert\\.(?:match|doesNotMatch)\\s*\\(\\s*${v}\\s*,\\s*/((?:[^/\\\\]|\\\\.)+)/`, "g");
302
+ for (const m of source.matchAll(matchLiteral)) {
303
+ asserted = true;
304
+ patterns.push(m[1]);
305
+ }
306
+ if (new RegExp(`assert\\.(?:match|doesNotMatch)\\s*\\(\\s*${v}\\s*,`).test(source)) {
307
+ asserted = true;
308
+ }
309
+ const membershipLiteral = new RegExp(`\\b${v}\\s*\\.\\s*(?:includes|indexOf)\\s*\\(\\s*(["'\`])((?:(?!\\1)[^\\\\]|\\\\.)*)\\1`, "g");
310
+ for (const m of source.matchAll(membershipLiteral)) {
311
+ asserted = true;
312
+ patterns.push(escapeRegExp(m[2]));
313
+ }
314
+ if (new RegExp(`\\b${v}\\s*\\.\\s*(?:includes|indexOf)\\s*\\(`).test(source)) {
315
+ asserted = true;
316
+ }
317
+ const equalLiteral = new RegExp(`assert\\.${EQUAL_FORMS}\\s*\\(\\s*${v}\\s*,\\s*(["'\`])((?:(?!\\1)[^\\\\]|\\\\.)*)\\1`, "g");
318
+ for (const m of source.matchAll(equalLiteral)) {
319
+ asserted = true;
320
+ patterns.push(escapeRegExp(m[2]));
321
+ }
322
+ if (new RegExp(`assert\\.${EQUAL_FORMS}\\s*\\(\\s*${v}\\s*,`).test(source)) {
323
+ asserted = true;
324
+ }
325
+ return { asserted, patterns };
326
+ }
327
+ /**
328
+ * An inline read asserted without an intermediate variable:
329
+ * `assert.equal(readFileSync(path), EXPECTED)`,
330
+ * `assert.match(readFileSync(path), /re/)`. Only flags when the path
331
+ * resolves to a document literal, so an inline temp read stays behaviour.
332
+ */
333
+ function inlineTextAssertedReads(source, varToPath) {
334
+ const found = [];
335
+ const inline = new RegExp(`assert\\.(?:match|doesNotMatch|${EQUAL_FORMS})\\s*\\(\\s*(?:await\\s+)?` +
336
+ READ_CALLEE +
337
+ `\\s*\\(([^)]*)\\)\\s*,\\s*([^)]*)\\)`, "g");
338
+ for (const m of source.matchAll(inline)) {
339
+ const doc = documentPathFromArgs(m[1], varToPath);
340
+ if (doc === undefined) {
341
+ continue;
342
+ }
343
+ const patterns = [];
344
+ const rest = m[2];
345
+ const regexLiteral = /^\s*\/((?:[^/\\]|\\.)+)\//.exec(rest);
346
+ if (regexLiteral !== null) {
347
+ patterns.push(regexLiteral[1]);
348
+ }
349
+ else {
350
+ const stringLiteral = /^\s*(["'`])((?:(?!\1)[^\\]|\\.)*)\1/.exec(rest);
351
+ if (stringLiteral !== null) {
352
+ patterns.push(escapeRegExp(stringLiteral[2]));
353
+ }
354
+ }
355
+ found.push({ doc, patterns });
356
+ }
357
+ return found;
358
+ }
359
+ /**
360
+ * Rule (g)'s text-assertion detection, DERIVED from the named tests'
361
+ * sources rather than declared: a source that reads a document from a path
362
+ * outside `src/` and `test/` (a workflow, a manifest, a configuration
363
+ * document) and ASSERTS THAT DOCUMENT'S TEXT is text-asserting. The
364
+ * assertion is tied to the read result (a variable binding or an inline
365
+ * read), never a free-floating assert form, so a read consumed by a project
366
+ * function stays behaviour. Detection is syntactic and fails CONSERVATIVELY:
367
+ * a recognised assert form whose pattern is not statically extractable still
368
+ * marks the witness text-asserting (subject to rule (g)) rather than
369
+ * returning false. The derivation's limits are stated in the work history.
370
+ */
371
+ export function deriveTextAssertions(sources) {
372
+ const documents = new Set();
373
+ const patterns = new Set();
374
+ let textAsserting = false;
375
+ for (const source of sources) {
376
+ const varToPath = new Map();
377
+ for (const m of source.matchAll(STRING_BINDING)) {
378
+ const value = m[3];
379
+ if (isDocumentPathLiteral(value)) {
380
+ varToPath.set(m[1], value);
381
+ }
382
+ }
383
+ for (const bind of source.matchAll(READ_BINDING)) {
384
+ const varName = bind[1];
385
+ const doc = documentPathFromArgs(bind[2], varToPath);
386
+ const forms = textAssertionsOnVar(source, varName);
387
+ if (!forms.asserted) {
388
+ continue;
389
+ }
390
+ // The result is text-asserted. Flag only when the document path is a
391
+ // resolvable non-src/test literal (a runtime path cannot be told from
392
+ // a scratch read); patterns are recorded regardless, for the
393
+ // preservation check.
394
+ if (doc !== undefined) {
395
+ textAsserting = true;
396
+ documents.add(doc);
397
+ for (const p of forms.patterns) {
398
+ patterns.add(p);
399
+ }
400
+ }
401
+ }
402
+ for (const hit of inlineTextAssertedReads(source, varToPath)) {
403
+ textAsserting = true;
404
+ documents.add(hit.doc);
405
+ for (const p of hit.patterns) {
406
+ patterns.add(p);
407
+ }
408
+ }
409
+ }
410
+ return {
411
+ textAsserting,
412
+ documents: [...documents].sort(),
413
+ patterns: [...patterns].sort(),
414
+ };
415
+ }
416
+ /**
417
+ * Resolve named tests to the repo-relative test files whose head-state
418
+ * source contains the name literally. A name found nowhere is a spec
419
+ * defect the caller turns into a red reason.
420
+ */
421
+ export function resolveNamedTests(tests, testFiles) {
422
+ const files = new Set();
423
+ const missing = [];
424
+ for (const name of tests) {
425
+ let found = false;
426
+ for (const [path, source] of testFiles) {
427
+ if (source.includes(name)) {
428
+ files.add(path);
429
+ found = true;
430
+ }
431
+ }
432
+ if (!found) {
433
+ missing.push(name);
434
+ }
435
+ }
436
+ return { files: [...files].sort(), missing };
437
+ }
438
+ /**
439
+ * The 1-based line numbers a find text occupies, across every occurrence,
440
+ * multi-line finds included (a single-line scan misses those, and rule (d)
441
+ * plus the unreached-arm report both need the true span).
442
+ */
443
+ export function findOccurrenceLines(body, find) {
444
+ const lines = new Set();
445
+ let from = 0;
446
+ for (;;) {
447
+ const at = body.indexOf(find, from);
448
+ if (at < 0) {
449
+ break;
450
+ }
451
+ const startLine = body.slice(0, at).split("\n").length;
452
+ const spanned = find.split("\n").length;
453
+ for (let offset = 0; offset < spanned; offset += 1) {
454
+ lines.add(startLine + offset);
455
+ }
456
+ from = at + Math.max(find.length, 1);
457
+ }
458
+ return [...lines].sort((a, b) => a - b);
459
+ }
460
+ /**
461
+ * Resolve a baseline in a clone from the FETCHED remote, never a stale
462
+ * local ref: `refs/remotes/origin/<ref>` after the fetch wins; a literal
463
+ * sha or tag that resolves to a commit is accepted as itself, because a
464
+ * sha cannot be stale.
465
+ */
466
+ function resolveBaseline(cloneDir, ref) {
467
+ const remote = gitIn(cloneDir, ["rev-parse", "--verify", `refs/remotes/origin/${ref}^{commit}`]);
468
+ if (remote.ok) {
469
+ return { sha: remote.stdout.trim() };
470
+ }
471
+ const literal = gitIn(cloneDir, ["rev-parse", "--verify", `${ref}^{commit}`]);
472
+ if (literal.ok && /^[0-9a-f]{4,40}$/.test(ref)) {
473
+ return { sha: literal.stdout.trim() };
474
+ }
475
+ return {
476
+ reason: `baseline ${ref} does not resolve against the fetched remote ` +
477
+ `(refs/remotes/origin/${ref})${literal.ok ? " and is not a sha" : ""}: ${remote.reason}`,
478
+ };
479
+ }
480
+ function applyMember(cloneDir, member, headSha, namedTestFiles) {
481
+ if (member.kind === "baseline-ref") {
482
+ const baseline = resolveBaseline(cloneDir, member.ref);
483
+ if (baseline.sha === undefined) {
484
+ return { ok: false, reason: baseline.reason };
485
+ }
486
+ const checkout = gitIn(cloneDir, ["checkout", "--detach", "--force", baseline.sha]);
487
+ if (!checkout.ok) {
488
+ return { ok: false, reason: checkout.reason };
489
+ }
490
+ // The head-authored named tests are what must be red against the
491
+ // baseline (blueprint section 4), so they are restored from head.
492
+ const restore = gitIn(cloneDir, ["checkout", headSha, "--", ...namedTestFiles]);
493
+ if (!restore.ok) {
494
+ return { ok: false, reason: restore.reason };
495
+ }
496
+ const diff = gitIn(cloneDir, ["diff", headSha]);
497
+ return {
498
+ ok: true,
499
+ baselineSha: baseline.sha,
500
+ appliedDiff: diff.ok ? diff.stdout : `git diff failed: ${diff.reason}`,
501
+ };
502
+ }
503
+ if (member.kind === "patch") {
504
+ const body = gitIn(cloneDir, ["show", `${headSha}:${member.patch}`]);
505
+ if (!body.ok) {
506
+ return {
507
+ ok: false,
508
+ reason: `patch ${member.patch} does not exist at the audited head: ${body.reason}`,
509
+ };
510
+ }
511
+ // A unique stage name, per MECHANISMS.md "Atomic file replacement":
512
+ // never a fixed or pid-derived name (C-2 forbids pid identity).
513
+ const patchPath = join(cloneDir, "..", `patch-${randomUUID()}.patch`);
514
+ const refusal = refuseOpenForWrite(patchPath);
515
+ if (refusal !== undefined) {
516
+ return { ok: false, reason: refusal };
517
+ }
518
+ writeFileSync(patchPath, body.stdout);
519
+ const applied = gitIn(cloneDir, ["apply", "--whitespace=nowarn", patchPath]);
520
+ rmSync(patchPath, { force: true });
521
+ if (!applied.ok) {
522
+ return { ok: false, reason: `patch ${member.patch} does not apply: ${applied.reason}` };
523
+ }
524
+ const diff = gitIn(cloneDir, ["diff"]);
525
+ return { ok: true, appliedDiff: diff.ok ? diff.stdout : "" };
526
+ }
527
+ const target = join(cloneDir, member.file);
528
+ const read = readRegularFileIfPresent(target);
529
+ if (read.kind !== "read") {
530
+ return {
531
+ ok: false,
532
+ reason: read.kind === "absent"
533
+ ? `mutation target ${member.file} does not exist in the clone`
534
+ : read.reason,
535
+ };
536
+ }
537
+ if (!read.body.includes(member.find)) {
538
+ return {
539
+ ok: false,
540
+ reason: `mutation find text ${JSON.stringify(member.find)} does not occur in ` +
541
+ `${member.file}`,
542
+ };
543
+ }
544
+ const lines = findOccurrenceLines(read.body, member.find);
545
+ const mutated = read.body.split(member.find).join(member.replace);
546
+ const refusal = refuseOpenForWrite(target);
547
+ if (refusal !== undefined) {
548
+ return { ok: false, reason: refusal };
549
+ }
550
+ writeFileSync(target, mutated);
551
+ const diff = gitIn(cloneDir, ["diff"]);
552
+ return {
553
+ ok: true,
554
+ appliedDiff: diff.ok ? diff.stdout : "",
555
+ mutatedLines: { file: member.file, lines },
556
+ };
557
+ }
558
+ /**
559
+ * Roots to pin inside a clone: the source and test roots that exist as REAL
560
+ * directories. lstat is a probe, not an open, the same two syscalls
561
+ * classifyEntry itself performs (the argument src/gates/pin.ts records).
562
+ */
563
+ function pinRoots(cloneDir) {
564
+ const roots = [];
565
+ for (const name of ["src", "test", "bin"]) {
566
+ const path = join(cloneDir, name);
567
+ try {
568
+ if (lstatSync(path).isDirectory()) {
569
+ roots.push(path);
570
+ }
571
+ }
572
+ catch {
573
+ // Absent root: simply not pinned; takePinSafe reports if none exist.
574
+ }
575
+ }
576
+ return roots;
577
+ }
578
+ const TEST_RUN_TIMEOUT_MS = 120_000;
579
+ function runNamedTests(cloneDir, tests, testFilePaths) {
580
+ const argv = ["--test", "--test-reporter", "tap"];
581
+ for (const name of tests) {
582
+ argv.push("--test-name-pattern", `^${escapeRegExp(name)}$`);
583
+ }
584
+ // The pattern flags PRECEDE the positional paths (CLAUDE.md warning 7).
585
+ argv.push(...testFilePaths);
586
+ // The child must not inherit the parent's node:test context OR its
587
+ // reporter selection. Two distinct leaks, one scrub:
588
+ // NODE_TEST_*: when this harness itself runs inside `node --test` (its
589
+ // own suite does), the inherited NODE_TEST_CONTEXT makes the child print
590
+ // "run() is being called recursively" and skip every file while exiting
591
+ // 0, which would read as an empty reporter stream.
592
+ // NODE_OPTIONS: the suite gate (src/gates/suite.ts) requests its pinned
593
+ // reporter by setting a child-scoped NODE_OPTIONS
594
+ // (--test-reporter=<data url> --test-reporter-destination=<its stream>)
595
+ // on the top-level `npm test` run it spawns. That option is meant for
596
+ // THAT run alone, but NODE_OPTIONS is inherited by every descendant, so
597
+ // a nested `node --test` spawned here would pick it up. This child is
598
+ // already invoked with `--test-reporter tap` in argv; combined with the
599
+ // inherited reporter it becomes two reporters against one destination,
600
+ // which node rejects at startup with ERR_INVALID_ARG_VALUE ("--test-
601
+ // reporter must match the number of --test-reporter-destination"), the
602
+ // child exits 1 producing no tap stream, and parseTapStream then fails.
603
+ // The reporter is a top-level-run-scoped input, exactly like
604
+ // NODE_TEST_CONTEXT, so it is scrubbed the same way. The child's own
605
+ // reporter is set explicitly in argv and owes nothing to the ambient env.
606
+ const env = { NO_COLOR: "1", FORCE_COLOR: "0" };
607
+ for (const [name, value] of Object.entries(process.env)) {
608
+ if (value === undefined || name.startsWith("NODE_TEST") || name === "NODE_OPTIONS") {
609
+ continue;
610
+ }
611
+ env[name] = value;
612
+ }
613
+ const child = spawnSync(process.execPath, argv, {
614
+ cwd: cloneDir,
615
+ encoding: "utf8",
616
+ timeout: TEST_RUN_TIMEOUT_MS,
617
+ env,
618
+ });
619
+ if (child.error !== undefined) {
620
+ return {
621
+ problem: `the test child could not be run: ${singleLine(String(child.error))}`,
622
+ };
623
+ }
624
+ if (child.signal !== null && child.signal !== undefined) {
625
+ return {
626
+ problem: `the test child was terminated by ${child.signal} ` +
627
+ `(harness timeout ${String(TEST_RUN_TIMEOUT_MS)}ms)`,
628
+ };
629
+ }
630
+ const parsed = parseTapStream(child.stdout ?? "");
631
+ if (!parsed.ok) {
632
+ return {
633
+ problem: `${parsed.reason} (test child exited ` +
634
+ `${String(child.status)}; stderr: ` +
635
+ `${singleLine((child.stderr ?? "").slice(0, 400))})`,
636
+ };
637
+ }
638
+ const failed = [];
639
+ const passed = [];
640
+ const missing = [];
641
+ for (const name of tests) {
642
+ const points = parsed.tests.filter((point) => point.name === name);
643
+ if (points.some((point) => !point.ok)) {
644
+ failed.push(name);
645
+ continue;
646
+ }
647
+ if (points.some((point) => point.ok && !point.skipped)) {
648
+ passed.push(name);
649
+ continue;
650
+ }
651
+ missing.push(name);
652
+ }
653
+ const exitCode = child.status ?? -1;
654
+ return {
655
+ run: {
656
+ exitCode,
657
+ red: exitCode !== 0 && failed.length === tests.length,
658
+ failedNamedTests: failed,
659
+ passedNamedTests: passed,
660
+ missingNamedTests: missing,
661
+ },
662
+ };
663
+ }
664
+ function takePinSafe(roots) {
665
+ if (roots.length === 0) {
666
+ return { problem: "the clone has no src, test or bin root to pin (M2-C-5)" };
667
+ }
668
+ try {
669
+ return { pin: takePin(roots) };
670
+ }
671
+ catch (error) {
672
+ return { problem: singleLine(error.message ?? String(error)) };
673
+ }
674
+ }
675
+ function pinPair(start, end) {
676
+ return { start, end, equal: comparePins(start, end).length === 0 };
677
+ }
678
+ function pinProblem(record, phase) {
679
+ if (record.equal) {
680
+ return undefined;
681
+ }
682
+ const differences = comparePins(record.start, record.end);
683
+ return (`the tree changed during the ${phase} run: ` +
684
+ differences.map(describePinDifference).join("; "));
685
+ }
686
+ /**
687
+ * Create a scratch clone of the audited repository at the audited head,
688
+ * with `origin` pointing at the audited repository's own origin when it
689
+ * has one (so baselines resolve against the real remote after a fetch,
690
+ * never a stale local ref), else at the audited repository itself.
691
+ */
692
+ export function makeClone(repoRoot, headSha, scratchRoot, label) {
693
+ const dir = join(scratchRoot, label);
694
+ const cloned = gitIn(scratchRoot, ["clone", "--quiet", repoRoot, dir]);
695
+ if (!cloned.ok) {
696
+ return { reason: `scratch clone failed: ${cloned.reason}` };
697
+ }
698
+ const originUrl = gitIn(repoRoot, ["remote", "get-url", "origin"]);
699
+ if (originUrl.ok) {
700
+ const url = originUrl.stdout.trim();
701
+ const set = gitIn(dir, ["remote", "set-url", "origin", url]);
702
+ if (!set.ok) {
703
+ return { reason: set.reason };
704
+ }
705
+ }
706
+ const fetched = gitIn(dir, ["fetch", "--quiet", "origin"]);
707
+ if (!fetched.ok) {
708
+ return { reason: `fetch of origin failed in the scratch clone: ${fetched.reason}` };
709
+ }
710
+ const checkout = gitIn(dir, ["checkout", "--detach", "--force", "--quiet", headSha]);
711
+ if (!checkout.ok) {
712
+ return { reason: `checkout of the audited head failed: ${checkout.reason}` };
713
+ }
714
+ // A git clone carries SOURCE, never installed dependencies, and this clone is
715
+ // where the audited suite is executed. So the clone must be able to resolve
716
+ // whatever the audited head imports, and `node_modules` is the only part of
717
+ // that which git does not carry.
718
+ //
719
+ // This was latent until it was not. The kernel had ZERO production
720
+ // dependencies through M1 and M2, so a dependency-free clone ran the suite
721
+ // correctly and nothing here was wrong. M3-P1 adds the kernel's first two
722
+ // (`ajv` and `yaml`, DR-0013), and from that commit forward a bare clone
723
+ // cannot even load the test file:
724
+ //
725
+ // Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'yaml' imported from
726
+ // <clone>/test/validate.test.ts
727
+ //
728
+ // The hazard is worse than a broken run, which is why this fails CLOSED
729
+ // rather than warning. A member is judged by whether the named test is RED
730
+ // under the dangerous state and GREEN without it. A clone that cannot resolve
731
+ // an import is red for EVERY member and every control, so an unresolvable
732
+ // clone does not report "the harness is broken", it reports red, which is the
733
+ // same observation a genuine witness produces. Guessing here would let a
734
+ // witness appear to guard a behavior it never exercised.
735
+ //
736
+ // A SYMLINK rather than `npm ci`: the install is environment state, not
737
+ // source, and every member gets its own clone (M2-C-4 isolation), so paying a
738
+ // network install per member is both slow and a new failure mode on an
739
+ // offline runner. The link is read-only in practice; nothing in a member's
740
+ // evaluation writes through it.
741
+ //
742
+ // Consequence, stated rather than hidden: the clone resolves against the
743
+ // AUDITED REPOSITORY'S installed tree, so a member whose patch edits
744
+ // `package.json` dependencies is evaluated against the parent's modules, not
745
+ // its own. No current witness does that. A member that needs different
746
+ // dependencies is out of scope for this mechanism and would need a real
747
+ // install; the failure would be visible as an unresolved import rather than
748
+ // as a silent wrong answer.
749
+ const parentModules = join(repoRoot, "node_modules");
750
+ // lstat, not stat, and the type is ESTABLISHED before anything is read
751
+ // through it (CLAUDE.md's recorded mechanism: reading a path whose type has
752
+ // not been established). A directory or a symlink to one both serve.
753
+ let parentModulesIsUsable = false;
754
+ try {
755
+ const st = lstatSync(parentModules);
756
+ parentModulesIsUsable = st.isDirectory() || st.isSymbolicLink();
757
+ }
758
+ catch {
759
+ parentModulesIsUsable = false;
760
+ }
761
+ if (parentModulesIsUsable) {
762
+ try {
763
+ symlinkSync(parentModules, join(dir, "node_modules"), "dir");
764
+ }
765
+ catch (error) {
766
+ return {
767
+ reason: `linking node_modules into the scratch clone failed: ${singleLine(String(error))}`,
768
+ };
769
+ }
770
+ return { dir };
771
+ }
772
+ // No installed tree in the audited repository. That is only a defect if the
773
+ // audited head actually NEEDS one, so the predicate is "declares runtime
774
+ // dependencies and has none installed", never the bare absence.
775
+ //
776
+ // Getting this wrong in the strict direction is not theoretical: refusing on
777
+ // absence alone breaks this harness's OWN fixtures, which are deliberately
778
+ // minimal repositories with no dependencies and nothing to resolve. Eight
779
+ // tests in test/witness.test.ts reddened on the first attempt at this check,
780
+ // which is the suite doing its job.
781
+ const declared = readRegularFileIfPresent(join(dir, "package.json"));
782
+ let needsModules = false;
783
+ if (declared.kind === "read") {
784
+ try {
785
+ const parsed = JSON.parse(declared.body);
786
+ const deps = parsed.dependencies;
787
+ needsModules =
788
+ typeof deps === "object" && deps !== null && Object.keys(deps).length > 0;
789
+ }
790
+ catch {
791
+ // An unparseable package.json is not this function's error to raise; the
792
+ // suite that runs in the clone will say so far more precisely.
793
+ needsModules = false;
794
+ }
795
+ }
796
+ if (needsModules) {
797
+ return {
798
+ reason: `the audited head declares runtime dependencies but the audited repository ` +
799
+ `has no installed tree at ${parentModules}, so the scratch clone cannot ` +
800
+ "resolve the imports of the suite it must run. Run `npm ci` in the audited " +
801
+ "repository first. Refusing rather than running a clone whose every member " +
802
+ "would be red for the wrong reason",
803
+ };
804
+ }
805
+ return { dir };
806
+ }
807
+ function restoreHead(cloneDir, headSha) {
808
+ const reset = gitIn(cloneDir, ["reset", "--hard", "--quiet", headSha]);
809
+ if (!reset.ok) {
810
+ return reset.reason;
811
+ }
812
+ const detach = gitIn(cloneDir, ["checkout", "--detach", "--force", "--quiet", headSha]);
813
+ if (!detach.ok) {
814
+ return detach.reason;
815
+ }
816
+ return undefined;
817
+ }
818
+ /** Patterns that still match some document's content, for rule (g). */
819
+ function matchingPatterns(patterns, documents, readDocument) {
820
+ const matched = new Set();
821
+ const contents = documents
822
+ .map((path) => readDocument(path))
823
+ .filter((body) => body !== undefined);
824
+ for (const source of patterns) {
825
+ let expression;
826
+ try {
827
+ expression = new RegExp(source, "m");
828
+ }
829
+ catch {
830
+ continue;
831
+ }
832
+ if (contents.some((body) => expression.test(body))) {
833
+ matched.add(source);
834
+ }
835
+ }
836
+ return matched;
837
+ }
838
+ /**
839
+ * Apply a patch member to the asserted documents ALONE, in a throwaway
840
+ * directory, so rule (g)'s preservation check can read the mutated text
841
+ * without a full clone. `git apply` works on plain files outside a
842
+ * repository; the patch is restricted to the documents with `--include`.
843
+ */
844
+ function applyPatchToDocuments(inputs, patchPath, documents, readDocumentAtHead) {
845
+ const shown = gitIn(inputs.repoRoot, ["show", `${inputs.headSha}:${patchPath}`]);
846
+ if (!shown.ok) {
847
+ return undefined;
848
+ }
849
+ const stage = mkdtempSync(join(inputs.scratchRoot, "rule-g-"));
850
+ try {
851
+ for (const document of documents) {
852
+ const body = readDocumentAtHead(document);
853
+ if (body === undefined) {
854
+ continue;
855
+ }
856
+ const target = join(stage, document);
857
+ mkdirSync(dirname(target), { recursive: true });
858
+ writeFileSync(target, body);
859
+ }
860
+ const patchFile = join(stage, "member.patch");
861
+ writeFileSync(patchFile, shown.stdout);
862
+ const args = ["apply", "--whitespace=nowarn"];
863
+ for (const document of documents) {
864
+ args.push(`--include=${document}`);
865
+ }
866
+ args.push(patchFile);
867
+ const applied = gitIn(stage, args);
868
+ if (!applied.ok) {
869
+ return undefined;
870
+ }
871
+ const mutated = new Map();
872
+ for (const document of documents) {
873
+ const read = readRegularFileIfPresent(join(stage, document));
874
+ if (read.kind === "read") {
875
+ mutated.set(document, read.body);
876
+ }
877
+ }
878
+ return mutated;
879
+ }
880
+ finally {
881
+ rmSync(stage, { recursive: true, force: true });
882
+ }
883
+ }
884
+ /**
885
+ * Refusal rules (a) to (g), evaluated BEFORE any test runs (M2-P2 step 4).
886
+ * Each produces a red reason naming its source. Rules (d) and (f) are
887
+ * derived from the phase diff (M2R-001, M2-D-15): the gate does not rest on
888
+ * the implementer's own declaration where the fact is derivable.
889
+ */
890
+ function evaluateRefusalRules(spec, inputs, namedTestFiles, derivation) {
891
+ const reasons = [];
892
+ const preservation = new Map();
893
+ const readPatch = (path) => {
894
+ const shown = gitIn(inputs.repoRoot, ["show", `${inputs.headSha}:${path}`]);
895
+ return shown.ok ? shown.stdout : undefined;
896
+ };
897
+ // (b) behavior resolution.
898
+ if (!inputs.behaviors.has(spec.behavior)) {
899
+ reasons.push(`rule (b): behavior ${spec.behavior} does not resolve in test/behaviors.json`);
900
+ }
901
+ // (a) a bare absent-feature baseline is not the dangerous state (T-003).
902
+ if (spec.class === "destructive" || spec.class === "classification") {
903
+ for (let index = 0; index < spec.dangerousStates.length; index += 1) {
904
+ const member = spec.dangerousStates[index];
905
+ if (member.kind === "baseline-ref") {
906
+ reasons.push(`rule (a): class ${spec.class} with member ${String(index)} of kind ` +
907
+ `baseline-ref; a bare absent-feature baseline is not the dangerous ` +
908
+ `state (T-003)`);
909
+ }
910
+ }
911
+ }
912
+ // (e) derived class from the destructiveCommands list.
913
+ const testSources = namedTestFiles.map((path) => inputs.testFiles.get(path) ?? "");
914
+ const derivedEntries = inputs.destructiveCommands.filter((entry) => testSources.some((source) => source.includes(entry)));
915
+ if (derivedEntries.length > 0 && spec.class !== "destructive") {
916
+ reasons.push(`rule (e): the named tests invoke ${derivedEntries.join(", ")} from the ` +
917
+ `manifest destructiveCommands list, so the derived class is destructive ` +
918
+ `and the declared class ${spec.class} is weaker`);
919
+ }
920
+ // (c) capture citations.
921
+ if (spec.consumesExternalOutput !== undefined) {
922
+ const consumes = spec.consumesExternalOutput;
923
+ if (consumes.captures.length === 0) {
924
+ reasons.push("rule (c): consumesExternalOutput declared but no capture cited");
925
+ }
926
+ let referenced = false;
927
+ for (const capture of consumes.captures) {
928
+ const path = join(inputs.repoRoot, capture);
929
+ const read = readRegularFileIfPresent(path);
930
+ if (read.kind === "absent") {
931
+ reasons.push(`rule (c): cited capture ${capture} is missing`);
932
+ continue;
933
+ }
934
+ if (read.kind === "refused") {
935
+ reasons.push(`rule (c): cited capture ${capture}: ${read.reason}`);
936
+ continue;
937
+ }
938
+ if (read.body.trim() === "") {
939
+ reasons.push(`rule (c): cited capture ${capture} is empty`);
940
+ continue;
941
+ }
942
+ const basename = capture.split("/").pop();
943
+ if (testSources.some((source) => source.includes(basename))) {
944
+ referenced = true;
945
+ }
946
+ }
947
+ if (consumes.captures.length > 0 && !referenced) {
948
+ reasons.push("rule (c): no cited capture's basename is referenced from the named tests' sources");
949
+ }
950
+ }
951
+ // (f) derived capture obligation from the spawn grep over changed files.
952
+ //
953
+ // THE SCOPE IS PER MEMBER, exactly as rule (d)'s is eleven lines down, and
954
+ // for the same reason. `7b18144` fixed that scope for rule (d) and left its
955
+ // sibling reading EVERY member of the spec, so an obligation derived from a
956
+ // file THIS PHASE changed was imposed on members the phase never authored,
957
+ // and could not have been discharged when they were written.
958
+ //
959
+ // MEASURED, M4-P11 at `a7d007f`: giving `src/checks.ts` its first subprocess
960
+ // call turned it into a spawning changed file and reddened THIRTY-NINE
961
+ // pre-existing witness specs over checklist parsing, verdict vocabulary,
962
+ // mode enums and hazard resolution. None of them had anything to do with git
963
+ // output; none of them could have declared a capture, because `src/checks.ts`
964
+ // spawned nothing when they were written. A required gate went red for every
965
+ // spec in a file's neighbourhood because one unrelated line was added.
966
+ //
967
+ // WHAT THE RULE STILL BINDS, so this is a scope correction and not a defang:
968
+ // a member THIS PHASE added or changed that touches a spawning changed file
969
+ // takes the capture obligation in full. Editing a member makes it owned, so
970
+ // a phase cannot launder an old member into an exemption by rewriting it.
971
+ const touched = new Set();
972
+ for (let index = 0; index < spec.dangerousStates.length; index += 1) {
973
+ if (!inputs.phaseOwnedMembers.has(index)) {
974
+ continue;
975
+ }
976
+ const member = spec.dangerousStates[index];
977
+ for (const file of memberTouchedFiles(member, readPatch)) {
978
+ touched.add(file);
979
+ }
980
+ }
981
+ const spawningTouched = inputs.spawningChangedFiles.filter((file) => touched.has(file));
982
+ if (spawningTouched.length > 0 && spec.consumesExternalOutput === undefined) {
983
+ reasons.push(`rule (f): the phase diff touches ${spawningTouched.join(", ")}, which ` +
984
+ `the spawn/parse derivation (JS: ${SPAWN_GREP.source}; shell *.sh: ` +
985
+ `spawn-and-parse) matched, so consumesExternalOutput is required and ` +
986
+ `this witness omits it`);
987
+ }
988
+ // (d) diff intersection, for the members THIS PHASE AUTHORED.
989
+ //
990
+ // The scope is per MEMBER, never per spec file: `inputs.phaseOwnedMembers`
991
+ // carries the indices the phase added or changed relative to the merge base.
992
+ // A member a sibling edit dragged into the file's diff is not owned and
993
+ // takes no obligation from this rule. The property rule (d) protects is
994
+ // unchanged: a member this phase AUTHORED must intersect this phase's diff,
995
+ // so a phase still cannot add a dangerous state about unrelated code.
996
+ for (let index = 0; index < spec.dangerousStates.length; index += 1) {
997
+ if (!inputs.phaseOwnedMembers.has(index)) {
998
+ continue;
999
+ }
1000
+ const member = spec.dangerousStates[index];
1001
+ if (member.kind === "baseline-ref") {
1002
+ continue;
1003
+ }
1004
+ const files = memberTouchedFiles(member, readPatch);
1005
+ const changedTouched = files.filter((file) => inputs.diff.files.has(file));
1006
+ if (changedTouched.length === 0) {
1007
+ reasons.push(`rule (d): declared dangerous state does not intersect the phase diff ` +
1008
+ `(member ${String(index)}, ${describeMember(member)})`);
1009
+ continue;
1010
+ }
1011
+ if (member.kind === "mutation") {
1012
+ const diffFile = inputs.diff.files.get(member.file);
1013
+ const shown = gitIn(inputs.repoRoot, [
1014
+ "show",
1015
+ `${inputs.headSha}:${member.file}`,
1016
+ ]);
1017
+ let insideHunk = false;
1018
+ if (diffFile !== undefined && shown.ok) {
1019
+ const occupied = findOccurrenceLines(shown.stdout, member.find);
1020
+ insideHunk = occupied.some((lineNo) => diffFile.hunks.some(([start, end]) => lineNo >= start && lineNo <= end));
1021
+ }
1022
+ if (!insideHunk) {
1023
+ reasons.push(`rule (d): declared dangerous state does not intersect the phase diff ` +
1024
+ `(member ${String(index)}, mutation of ${member.file} touches no ` +
1025
+ `line inside a changed hunk)`);
1026
+ }
1027
+ }
1028
+ }
1029
+ // (g) one witness is not a class (M2-D-17).
1030
+ const needsClassRules = spec.class === "classification" || derivation.textAsserting;
1031
+ if (needsClassRules) {
1032
+ if (spec.dangerousStates.length < 2) {
1033
+ reasons.push(`rule (g): ${spec.class === "classification"
1034
+ ? "a classification witness"
1035
+ : "a text-asserting witness"} must declare at least two structurally different dangerousStates ` +
1036
+ `members and this witness declares ${String(spec.dangerousStates.length)} ` +
1037
+ `(single-member collapse; one witness is not a class)`);
1038
+ }
1039
+ else {
1040
+ // Structural distinctness is DERIVED: two mutations touching the same
1041
+ // line, or two byte-identical patches after path normalization, are
1042
+ // one member.
1043
+ for (let a = 0; a < spec.dangerousStates.length; a += 1) {
1044
+ for (let b = a + 1; b < spec.dangerousStates.length; b += 1) {
1045
+ const first = spec.dangerousStates[a];
1046
+ const second = spec.dangerousStates[b];
1047
+ if (first.kind === "mutation" &&
1048
+ second.kind === "mutation" &&
1049
+ first.file === second.file &&
1050
+ first.find === second.find) {
1051
+ reasons.push(`rule (g): members ${String(a)} and ${String(b)} mutate the same ` +
1052
+ `text of ${first.file} and count as one member (collapse)`);
1053
+ }
1054
+ if (first.kind === "patch" && second.kind === "patch") {
1055
+ const bodyA = readPatch(first.patch) ?? "";
1056
+ const bodyB = readPatch(second.patch) ?? "";
1057
+ const normalize = (body) => body
1058
+ .split("\n")
1059
+ .filter((line) => !/^(diff --git|index |--- |\+\+\+ )/.test(line))
1060
+ .join("\n");
1061
+ if (bodyA !== "" && normalize(bodyA) === normalize(bodyB)) {
1062
+ reasons.push(`rule (g): members ${String(a)} and ${String(b)} are ` +
1063
+ `byte-identical patches after path normalisation and count as ` +
1064
+ `one member (collapse)`);
1065
+ }
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+ // (g), strong form for text-asserting witnesses: at least one member must
1072
+ // PRESERVE every asserted string while inverting the behaviour. Verified
1073
+ // mechanically by re-running the assertions' own patterns against the
1074
+ // mutated documents.
1075
+ if (derivation.textAsserting && spec.dangerousStates.length >= 1) {
1076
+ const readDocumentAtHead = (path) => {
1077
+ const shown = gitIn(inputs.repoRoot, ["show", `${inputs.headSha}:${path}`]);
1078
+ return shown.ok ? shown.stdout : undefined;
1079
+ };
1080
+ const headMatched = matchingPatterns(derivation.patterns, derivation.documents, readDocumentAtHead);
1081
+ let anyPreserving = false;
1082
+ for (let index = 0; index < spec.dangerousStates.length; index += 1) {
1083
+ const member = spec.dangerousStates[index];
1084
+ let readMutated;
1085
+ if (member.kind === "mutation") {
1086
+ readMutated = (path) => {
1087
+ const body = readDocumentAtHead(path);
1088
+ if (body === undefined) {
1089
+ return undefined;
1090
+ }
1091
+ return path === member.file ? body.split(member.find).join(member.replace) : body;
1092
+ };
1093
+ }
1094
+ else if (member.kind === "patch") {
1095
+ const patched = applyPatchToDocuments(inputs, member.patch, derivation.documents, readDocumentAtHead);
1096
+ if (patched !== undefined) {
1097
+ readMutated = (path) => patched.get(path);
1098
+ }
1099
+ }
1100
+ if (readMutated === undefined) {
1101
+ // A baseline-ref member replaces the whole tree (it cannot preserve
1102
+ // head text while inverting behaviour), and a patch that failed to
1103
+ // apply to the documents preserved nothing establishable.
1104
+ preservation.set(index, false);
1105
+ continue;
1106
+ }
1107
+ const stillMatched = matchingPatterns(derivation.patterns, derivation.documents, readMutated);
1108
+ const preserves = [...headMatched].every((pattern) => stillMatched.has(pattern));
1109
+ preservation.set(index, preserves);
1110
+ if (preserves) {
1111
+ anyPreserving = true;
1112
+ }
1113
+ }
1114
+ if (!anyPreserving) {
1115
+ reasons.push("rule (g): every declared member removes the asserted text; none inverts the behaviour");
1116
+ }
1117
+ }
1118
+ return { reasons, preservation };
1119
+ }
1120
+ /**
1121
+ * Evaluate one witness spec end to end. Refusal rules first; then, per
1122
+ * declared dangerous-state member, a scratch clone, the member applied, the
1123
+ * named tests run red `repeats` times, head restored, the named tests run
1124
+ * green, everything pinned and recorded.
1125
+ */
1126
+ export function evaluateWitness(spec, specPath, inputs) {
1127
+ const resolved = resolveNamedTests(spec.tests, inputs.testFiles);
1128
+ const derivation = deriveTextAssertions(resolved.files.map((path) => inputs.testFiles.get(path) ?? ""));
1129
+ const evaluation = {
1130
+ witness: spec.id,
1131
+ specPath,
1132
+ behavior: spec.behavior,
1133
+ status: "green",
1134
+ reasons: [],
1135
+ textAsserting: derivation.textAsserting,
1136
+ assertedPatterns: derivation.patterns,
1137
+ testFiles: resolved.files,
1138
+ members: [],
1139
+ };
1140
+ if (resolved.missing.length > 0) {
1141
+ evaluation.status = "red";
1142
+ evaluation.reasons.push(`named test(s) not found in any test file at the audited head: ` +
1143
+ resolved.missing.map((name) => JSON.stringify(name)).join(", "));
1144
+ return evaluation;
1145
+ }
1146
+ const rules = evaluateRefusalRules(spec, inputs, resolved.files, derivation);
1147
+ if (rules.reasons.length > 0) {
1148
+ evaluation.status = "red";
1149
+ evaluation.reasons.push(...rules.reasons);
1150
+ return evaluation;
1151
+ }
1152
+ if (spec.consumesExternalOutput !== undefined) {
1153
+ evaluation.captures = [];
1154
+ for (const capture of spec.consumesExternalOutput.captures) {
1155
+ const read = readRegularFileIfPresent(join(inputs.repoRoot, capture));
1156
+ if (read.kind === "read") {
1157
+ evaluation.captures.push({
1158
+ path: capture,
1159
+ sha256: createHash("sha256").update(read.body).digest("hex"),
1160
+ provenance: spec.consumesExternalOutput.provenance,
1161
+ });
1162
+ }
1163
+ }
1164
+ }
1165
+ for (let index = 0; index < spec.dangerousStates.length; index += 1) {
1166
+ const member = spec.dangerousStates[index];
1167
+ const record = evaluateMember(spec, member, index, inputs, resolved.files, rules);
1168
+ evaluation.members.push(record);
1169
+ if (record.problem !== undefined) {
1170
+ evaluation.status = "error";
1171
+ evaluation.reasons.push(`member ${String(index)} (${describeMember(member)}): ${record.problem}`);
1172
+ continue;
1173
+ }
1174
+ const rate = record.rate;
1175
+ const memberRed = spec.deterministic
1176
+ ? rate.red === rate.total
1177
+ : rate.red > 0;
1178
+ if (!memberRed) {
1179
+ if (evaluation.status !== "error") {
1180
+ evaluation.status = "red";
1181
+ }
1182
+ if (record.unreachedArm !== undefined) {
1183
+ evaluation.reasons.push(`member ${String(index)} (${describeMember(member)}): no named test ` +
1184
+ `reaches this arm (${record.unreachedArm.file} line(s) ` +
1185
+ `${record.unreachedArm.lines.join(", ")}; stayed green: ` +
1186
+ `${record.unreachedArm.greenTests.join(", ")})`);
1187
+ }
1188
+ else {
1189
+ evaluation.reasons.push(`member ${String(index)} (${describeMember(member)}): red in ` +
1190
+ `${String(rate.red)} of ${String(rate.total)} repetitions` +
1191
+ (spec.deterministic
1192
+ ? " where deterministic true requires every repetition red"
1193
+ : " where at least one red repetition is required"));
1194
+ }
1195
+ }
1196
+ if (record.headGreen === false) {
1197
+ if (evaluation.status !== "error") {
1198
+ evaluation.status = "red";
1199
+ }
1200
+ evaluation.reasons.push(`member ${String(index)} (${describeMember(member)}): the named tests ` +
1201
+ `are not green at the audited head`);
1202
+ }
1203
+ }
1204
+ return evaluation;
1205
+ }
1206
+ function evaluateMember(spec, member, index, inputs, namedTestFiles, rules) {
1207
+ const record = {
1208
+ index,
1209
+ member,
1210
+ description: describeMember(member),
1211
+ runs: [],
1212
+ };
1213
+ const preserves = rules.preservation.get(index);
1214
+ if (preserves !== undefined) {
1215
+ record.preservesAssertedText = preserves;
1216
+ }
1217
+ const label = `${spec.id}-m${String(index)}`;
1218
+ const clone = makeClone(inputs.repoRoot, inputs.headSha, inputs.scratchRoot, label);
1219
+ if (clone.dir === undefined) {
1220
+ record.problem = clone.reason;
1221
+ return record;
1222
+ }
1223
+ const cloneDir = clone.dir;
1224
+ try {
1225
+ // Record the baseline this run resolved from the fetched remote, for
1226
+ // every member kind (M2-P2 criterion 10 asserts the recorded value).
1227
+ const baseline = resolveBaseline(cloneDir, inputs.baselineRef);
1228
+ if (baseline.sha !== undefined) {
1229
+ record.baselineSha = baseline.sha;
1230
+ }
1231
+ const applied = applyMember(cloneDir, member, inputs.headSha, namedTestFiles);
1232
+ if (!applied.ok) {
1233
+ record.problem = applied.reason;
1234
+ return record;
1235
+ }
1236
+ if (applied.baselineSha !== undefined) {
1237
+ record.baselineSha = applied.baselineSha;
1238
+ }
1239
+ record.appliedDiff = applied.appliedDiff ?? "";
1240
+ if (applied.mutatedLines !== undefined) {
1241
+ record.mutatedLines = applied.mutatedLines;
1242
+ }
1243
+ const roots = pinRoots(cloneDir);
1244
+ const startRed = takePinSafe(roots);
1245
+ if (startRed.pin === undefined) {
1246
+ record.problem = startRed.problem;
1247
+ return record;
1248
+ }
1249
+ const total = spec.repeats;
1250
+ let redCount = 0;
1251
+ // "Stayed green": passed in EVERY repetition (M2-P2 criterion 4a).
1252
+ const stayedGreen = new Set(spec.tests);
1253
+ for (let repetition = 0; repetition < total; repetition += 1) {
1254
+ const outcome = runNamedTests(cloneDir, spec.tests, namedTestFiles);
1255
+ if (outcome.run === undefined) {
1256
+ record.problem = outcome.problem;
1257
+ return record;
1258
+ }
1259
+ record.runs.push(outcome.run);
1260
+ if (outcome.run.red) {
1261
+ redCount += 1;
1262
+ }
1263
+ for (const name of spec.tests) {
1264
+ if (!outcome.run.passedNamedTests.includes(name)) {
1265
+ stayedGreen.delete(name);
1266
+ }
1267
+ }
1268
+ }
1269
+ const endRed = takePinSafe(roots);
1270
+ if (endRed.pin === undefined) {
1271
+ record.problem = endRed.problem;
1272
+ return record;
1273
+ }
1274
+ record.redPins = pinPair(startRed.pin, endRed.pin);
1275
+ const redPinProblem = pinProblem(record.redPins, "dangerous-state");
1276
+ if (redPinProblem !== undefined) {
1277
+ record.problem = redPinProblem;
1278
+ return record;
1279
+ }
1280
+ record.rate = { red: redCount, total };
1281
+ if (redCount === 0 && member.kind === "mutation" && record.mutatedLines !== undefined) {
1282
+ record.unreachedArm = {
1283
+ file: record.mutatedLines.file,
1284
+ lines: record.mutatedLines.lines,
1285
+ greenTests: [...stayedGreen].sort(),
1286
+ };
1287
+ }
1288
+ const restore = restoreHead(cloneDir, inputs.headSha);
1289
+ if (restore !== undefined) {
1290
+ record.problem = restore;
1291
+ return record;
1292
+ }
1293
+ const startGreen = takePinSafe(roots);
1294
+ if (startGreen.pin === undefined) {
1295
+ record.problem = startGreen.problem;
1296
+ return record;
1297
+ }
1298
+ const greenOutcome = runNamedTests(cloneDir, spec.tests, namedTestFiles);
1299
+ if (greenOutcome.run === undefined) {
1300
+ record.problem = greenOutcome.problem;
1301
+ return record;
1302
+ }
1303
+ record.runs.push(greenOutcome.run);
1304
+ if (inputs.hooks?.betweenPins !== undefined) {
1305
+ inputs.hooks.betweenPins(cloneDir);
1306
+ }
1307
+ const endGreen = takePinSafe(roots);
1308
+ if (endGreen.pin === undefined) {
1309
+ record.problem = endGreen.problem;
1310
+ return record;
1311
+ }
1312
+ record.greenPins = pinPair(startGreen.pin, endGreen.pin);
1313
+ const greenPinProblem = pinProblem(record.greenPins, "head");
1314
+ if (greenPinProblem !== undefined) {
1315
+ record.problem = greenPinProblem;
1316
+ return record;
1317
+ }
1318
+ record.headGreen =
1319
+ greenOutcome.run.exitCode === 0 &&
1320
+ greenOutcome.run.passedNamedTests.length === spec.tests.length;
1321
+ return record;
1322
+ }
1323
+ finally {
1324
+ rmSync(cloneDir, { recursive: true, force: true });
1325
+ }
1326
+ }
1327
+ /** Create the scratch root this harness owns; the caller removes it. */
1328
+ export function makeScratchRoot() {
1329
+ return mkdtempSync(join(tmpdir(), "tiphys-witness-"));
1330
+ }
1331
+ /** Remove a scratch root created by makeScratchRoot. */
1332
+ export function removeScratchRoot(root) {
1333
+ rmSync(root, { recursive: true, force: true });
1334
+ }
1335
+ /**
1336
+ * Read the head-state sources of every test file under `test/`, from git
1337
+ * rather than the working tree, because on pull_request events the checkout
1338
+ * is a synthetic merge commit and the audited head is `--head`.
1339
+ */
1340
+ export function readTestFilesAtHead(repoRoot, headSha) {
1341
+ const listed = gitIn(repoRoot, [
1342
+ "ls-tree",
1343
+ "-r",
1344
+ "--name-only",
1345
+ headSha,
1346
+ "--",
1347
+ "test/",
1348
+ ]);
1349
+ if (!listed.ok) {
1350
+ return { ok: false, reason: listed.reason };
1351
+ }
1352
+ const files = new Map();
1353
+ for (const path of listed.stdout.split("\n")) {
1354
+ if (!path.endsWith(".test.ts")) {
1355
+ continue;
1356
+ }
1357
+ const shown = gitIn(repoRoot, ["show", `${headSha}:${path}`]);
1358
+ if (!shown.ok) {
1359
+ return { ok: false, reason: shown.reason };
1360
+ }
1361
+ files.set(path, shown.stdout);
1362
+ }
1363
+ return { ok: true, files };
1364
+ }
1365
+ /** Resolve the absolute path of a repository root, refusing a non-directory. */
1366
+ export function resolveRepoRoot(path) {
1367
+ const absolute = resolve(path);
1368
+ const probe = gitIn(absolute, ["rev-parse", "--show-toplevel"]);
1369
+ if (!probe.ok) {
1370
+ return { reason: `${absolute} is not a git repository: ${probe.reason}` };
1371
+ }
1372
+ return { root: probe.stdout.trim() };
1373
+ }