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