@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.
- package/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,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
|
+
}
|