@junghanacs/entwurf 0.12.9 → 0.13.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 +135 -248
- package/BASELINE.md +46 -7
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +4 -2
- package/DELIVERY.md +5 -4
- package/README.md +46 -18
- package/VERIFY.md +10 -6
- package/demo/demo.sh +7 -1
- package/docs/setup-clean-host.md +10 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
- package/mcp/entwurf-bridge/src/index.ts +190 -82
- package/package.json +7 -3
- package/pi/settings.reference.json +1 -1
- package/pi-extensions/acp-provider.ts +20 -10
- package/pi-extensions/entwurf-control.ts +69 -27
- package/pi-extensions/lib/acp/augment.ts +60 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
- package/pi-extensions/lib/acp/backend.ts +5 -1
- package/pi-extensions/lib/acp/config.ts +19 -5
- package/pi-extensions/lib/acp/engraving.ts +3 -1
- package/pi-extensions/lib/acp/event-mapper.ts +10 -3
- package/pi-extensions/lib/acp/models.ts +69 -7
- package/pi-extensions/lib/acp/overlay.ts +234 -3
- package/pi-extensions/lib/acp/tool-surface.ts +12 -4
- package/pi-extensions/lib/entwurf-core.ts +26 -9
- package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
- package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
- package/pi-extensions/lib/entwurf-facts.ts +13 -13
- package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
- package/pi-extensions/lib/entwurf-self-address.ts +8 -9
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
- package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
- package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
- package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
- package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
- package/pi-extensions/lib/meta-session.ts +17 -20
- package/run.sh +200 -62
- package/scripts/agy-bridge-config.py +249 -49
- package/scripts/agy-bridge.sh +59 -14
- package/scripts/check-acp-carrier-augment.ts +34 -2
- package/scripts/check-acp-cortex.ts +668 -0
- package/scripts/check-acp-provider-surface.ts +50 -6
- package/scripts/check-acp-sdk-surface.ts +22 -11
- package/scripts/check-acp-session-reuse.ts +64 -1
- package/scripts/check-agy-permission-matrix.py +655 -0
- package/scripts/check-entwurf-deliverability.ts +9 -6
- package/scripts/check-entwurf-fact-provider.ts +7 -7
- package/scripts/check-entwurf-facts.ts +12 -12
- package/scripts/check-entwurf-resume-args.ts +22 -60
- package/scripts/check-entwurf-self-address.ts +187 -4
- package/scripts/check-entwurf-v2-contract.ts +2 -2
- package/scripts/check-entwurf-v2-production.ts +5 -5
- package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
- package/scripts/check-entwurf-v2-surface.ts +327 -14
- package/scripts/check-gate-qualification.ts +787 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-probe-cli-shim.ts +879 -0
- package/scripts/check-probe-ordering.ts +2450 -0
- package/scripts/check-shell-quote.ts +5 -4
- package/scripts/fixtures/probe-cli-shim +20 -0
- package/scripts/fixtures/probe-mcp-server.ts +168 -12
- package/scripts/lib/mutation-qualify.ts +794 -0
- package/scripts/lib/probe-acp-turn.ts +207 -0
- package/scripts/lib/probe-cli-shim.ts +464 -0
- package/scripts/lib/probe-cli-target.ts +165 -0
- package/scripts/lib/probe-event-log.ts +383 -0
- package/scripts/lib/probe-verdict.ts +1213 -0
- package/scripts/mutants/acp-augment.json +30 -0
- package/scripts/mutants/acp-cortex.json +196 -0
- package/scripts/mutants/agy-permission.json +144 -0
- package/scripts/mutants/meta-identity.json +17 -0
- package/scripts/mutants/probe-ordering.json +1032 -0
- package/scripts/mutants/self-address.json +59 -0
- package/scripts/mutants/v2-surface.json +87 -0
- package/scripts/smoke-acp-cortex-live.ts +392 -0
- package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-v2-send-live.ts +33 -18
- package/scripts/smoke-agy-install-state.sh +205 -20
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
- package/scripts/check-entwurf-mailbox-guard.ts +0 -266
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mutation-qualify — the gate-qualification runner core (kill-proof discipline).
|
|
3
|
+
*
|
|
4
|
+
* A gate is a test only if re-planting a closed defect makes that gate fail, for the
|
|
5
|
+
* exact reason it claims to guard. This module carries the minimal machinery to prove
|
|
6
|
+
* that automatically: declarative mutant manifests (one mutant = one closed defect,
|
|
7
|
+
* hand-written, never generated), an isolated SNAPSHOT repo the mutation runs in (the
|
|
8
|
+
* real checkout is never written), and a pure verdict classifier the self-test can
|
|
9
|
+
* exhaust as a truth table.
|
|
10
|
+
*
|
|
11
|
+
* Boundaries (agreed 2026-07-27, GLG+GPT design review — do not widen):
|
|
12
|
+
* - NOT a general mutation platform: no AST operators, no generated mutants, no
|
|
13
|
+
* kill-ratio scoring. Mutants are committed regression memorials only.
|
|
14
|
+
* - Mutation NEVER touches the real checkout. Everything — apply, gate run,
|
|
15
|
+
* restore — happens inside a temp snapshot replicating tracked+untracked files;
|
|
16
|
+
* the caller verifies the origin HEAD + work-surface CONTENT hash are identical
|
|
17
|
+
* before/after (porcelain text alone misses byte drift in already-modified files).
|
|
18
|
+
* - Every unique gate command gets an unmutated CONTROL run before and after its
|
|
19
|
+
* mutants (a gate already red at baseline can produce only fake KILLEDs).
|
|
20
|
+
* - Bounded: each gate run has a hard timeout and its process GROUP is killed.
|
|
21
|
+
* - Evidence is claim IDs + killed mutant IDs, never assertion counts.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
|
|
30
|
+
// ── verdicts ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export type MutantVerdict =
|
|
33
|
+
| "KILLED" // bounded nonzero exit + the claim token on a failure line
|
|
34
|
+
| "SURVIVED" // gate stayed green with the defect planted
|
|
35
|
+
| "WRONG-REASON" // gate went red, but not at the claimed assertion
|
|
36
|
+
| "HANG" // gate exceeded its bound; process group killed
|
|
37
|
+
| "MUTANT-STALE" // find matched 0 times — production drifted; re-derive the mutant
|
|
38
|
+
| "MULTI-MATCH" // find matched >1 — refuse before writing anything
|
|
39
|
+
| "CONTROL-RED" // the unmutated CONTROL failed; no KILLED can be claimed
|
|
40
|
+
| "IMPURE"; // restore/containment failed; evidence is contaminated
|
|
41
|
+
|
|
42
|
+
export interface MutantRunFacts {
|
|
43
|
+
controlPreOk: boolean;
|
|
44
|
+
matchCount: number;
|
|
45
|
+
timedOut: boolean;
|
|
46
|
+
/** child exit code; null when the child was killed (timeout). */
|
|
47
|
+
exitCode: number | null;
|
|
48
|
+
signatureOnFailureLine: boolean;
|
|
49
|
+
restoredOk: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The pure verdict mapping. Order is load-bearing and mirrored by the self-test
|
|
54
|
+
* truth table: control validity first, then match integrity, then purity, then the
|
|
55
|
+
* bounded-run outcome. `restoredOk` outranks the run outcome — a KILLED whose
|
|
56
|
+
* restore failed is contaminated evidence, not a kill.
|
|
57
|
+
*/
|
|
58
|
+
export function classifyMutantRun(f: MutantRunFacts): MutantVerdict {
|
|
59
|
+
if (!f.controlPreOk) return "CONTROL-RED";
|
|
60
|
+
if (f.matchCount === 0) return "MUTANT-STALE";
|
|
61
|
+
if (f.matchCount > 1) return "MULTI-MATCH";
|
|
62
|
+
if (!f.restoredOk) return "IMPURE";
|
|
63
|
+
if (f.timedOut) return "HANG";
|
|
64
|
+
// A null exit outside our own timeout is a SIGNAL CRASH, not a bounded nonzero exit
|
|
65
|
+
// — even with the claim token somewhere in the output it proves nothing about the
|
|
66
|
+
// gate's assertion, so it can never be rounded up to KILLED (P0-2, 2026-07-27).
|
|
67
|
+
if (f.exitCode === null) return "WRONG-REASON";
|
|
68
|
+
if (f.exitCode === 0) return "SURVIVED";
|
|
69
|
+
return f.signatureOnFailureLine ? "KILLED" : "WRONG-REASON";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The claim token counts only on a FAILURE line. Gate labels print on success too
|
|
74
|
+
* (` ok <label>`), so a mutation that trips a LATER assertion would otherwise
|
|
75
|
+
* read its own passing ok-line as the kill signature.
|
|
76
|
+
*/
|
|
77
|
+
export function signatureOnFailureLine(output: string, token: string): boolean {
|
|
78
|
+
return output.split("\n").some((line) => line.includes(token) && !/^\s*ok\b/.test(line));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── manifest schema (fail-loud, exact keys) ─────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export interface MutantSpec {
|
|
84
|
+
/** Stable claim id, ^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$, globally unique across manifests. */
|
|
85
|
+
claim: string;
|
|
86
|
+
title: string;
|
|
87
|
+
/** Repo-relative production subject. Must be tracked in the ORIGIN git index. */
|
|
88
|
+
subject: string;
|
|
89
|
+
/** Exact source lines (joined by \n); must occur exactly once in the subject. */
|
|
90
|
+
find: string[];
|
|
91
|
+
/** The defect-restoring replacement lines (joined by \n); must differ from find. */
|
|
92
|
+
replace: string[];
|
|
93
|
+
/** Gate argv (no shell string), run with cwd = snapshot repo root. */
|
|
94
|
+
gate: string[];
|
|
95
|
+
timeoutSeconds: number;
|
|
96
|
+
/** Always `[QK:<claim>]`; must appear exactly once in signatureSource. */
|
|
97
|
+
signature: string;
|
|
98
|
+
/** Repo-relative gate source file that owns the claim token. */
|
|
99
|
+
signatureSource: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface MutantManifest {
|
|
103
|
+
schemaVersion: 1;
|
|
104
|
+
lane: string;
|
|
105
|
+
mutants: MutantSpec[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CLAIM_RE = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$/;
|
|
109
|
+
const MANIFEST_KEYS = ["schemaVersion", "lane", "mutants"];
|
|
110
|
+
const MUTANT_KEYS = [
|
|
111
|
+
"claim",
|
|
112
|
+
"title",
|
|
113
|
+
"subject",
|
|
114
|
+
"find",
|
|
115
|
+
"replace",
|
|
116
|
+
"gate",
|
|
117
|
+
"timeoutSeconds",
|
|
118
|
+
"signature",
|
|
119
|
+
"signatureSource",
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export class ManifestError extends Error {}
|
|
123
|
+
|
|
124
|
+
function requireExactKeys(obj: Record<string, unknown>, keys: string[], where: string): void {
|
|
125
|
+
for (const k of Object.keys(obj)) {
|
|
126
|
+
if (!keys.includes(k)) throw new ManifestError(`${where}: unknown key \`${k}\``);
|
|
127
|
+
}
|
|
128
|
+
for (const k of keys) {
|
|
129
|
+
if (!(k in obj)) throw new ManifestError(`${where}: missing key \`${k}\``);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requireLines(v: unknown, where: string): string[] {
|
|
134
|
+
if (!Array.isArray(v) || v.length === 0 || !v.every((s) => typeof s === "string")) {
|
|
135
|
+
throw new ManifestError(`${where}: must be a non-empty array of strings`);
|
|
136
|
+
}
|
|
137
|
+
return v as string[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Validate one manifest document; throws ManifestError with the exact defect. */
|
|
141
|
+
export function validateManifest(raw: unknown, name: string): MutantManifest {
|
|
142
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
143
|
+
throw new ManifestError(`${name}: manifest must be a JSON object`);
|
|
144
|
+
}
|
|
145
|
+
const doc = raw as Record<string, unknown>;
|
|
146
|
+
requireExactKeys(doc, MANIFEST_KEYS, name);
|
|
147
|
+
if (doc.schemaVersion !== 1) throw new ManifestError(`${name}: unknown schemaVersion ${String(doc.schemaVersion)}`);
|
|
148
|
+
if (typeof doc.lane !== "string" || doc.lane.length === 0) throw new ManifestError(`${name}: lane must be a string`);
|
|
149
|
+
if (!Array.isArray(doc.mutants) || doc.mutants.length === 0) {
|
|
150
|
+
throw new ManifestError(`${name}: mutants must be a non-empty array`);
|
|
151
|
+
}
|
|
152
|
+
const mutants: MutantSpec[] = [];
|
|
153
|
+
for (const [i, m] of doc.mutants.entries()) {
|
|
154
|
+
const where = `${name} mutants[${i}]`;
|
|
155
|
+
if (typeof m !== "object" || m === null) throw new ManifestError(`${where}: must be an object`);
|
|
156
|
+
const rec = m as Record<string, unknown>;
|
|
157
|
+
requireExactKeys(rec, MUTANT_KEYS, where);
|
|
158
|
+
if (typeof rec.claim !== "string" || !CLAIM_RE.test(rec.claim)) {
|
|
159
|
+
throw new ManifestError(`${where}: claim must match ${CLAIM_RE}`);
|
|
160
|
+
}
|
|
161
|
+
if (typeof rec.title !== "string" || rec.title.length === 0) throw new ManifestError(`${where}: title required`);
|
|
162
|
+
const subject = rec.subject;
|
|
163
|
+
if (typeof subject !== "string" || subject.length === 0) throw new ManifestError(`${where}: subject required`);
|
|
164
|
+
if (path.isAbsolute(subject) || subject.split(/[\\/]/).includes("..")) {
|
|
165
|
+
throw new ManifestError(`${where}: subject must be repo-relative without \`..\` (got ${subject})`);
|
|
166
|
+
}
|
|
167
|
+
if (subject === "node_modules" || subject.startsWith("node_modules/")) {
|
|
168
|
+
throw new ManifestError(`${where}: subject may not live under node_modules (a shared dependency symlink)`);
|
|
169
|
+
}
|
|
170
|
+
const find = requireLines(rec.find, `${where}.find`);
|
|
171
|
+
const replace = requireLines(rec.replace, `${where}.replace`);
|
|
172
|
+
if (find.join("\n") === replace.join("\n")) throw new ManifestError(`${where}: replace must differ from find`);
|
|
173
|
+
const gate = requireLines(rec.gate, `${where}.gate`);
|
|
174
|
+
if (
|
|
175
|
+
typeof rec.timeoutSeconds !== "number" ||
|
|
176
|
+
!Number.isInteger(rec.timeoutSeconds) ||
|
|
177
|
+
rec.timeoutSeconds < 1 ||
|
|
178
|
+
rec.timeoutSeconds > 600
|
|
179
|
+
) {
|
|
180
|
+
throw new ManifestError(`${where}: timeoutSeconds must be an integer in 1..600`);
|
|
181
|
+
}
|
|
182
|
+
const expectedSig = `[QK:${rec.claim}]`;
|
|
183
|
+
if (rec.signature !== expectedSig) {
|
|
184
|
+
throw new ManifestError(`${where}: signature must be exactly ${expectedSig} (got ${String(rec.signature)})`);
|
|
185
|
+
}
|
|
186
|
+
const sigSrc = rec.signatureSource;
|
|
187
|
+
if (typeof sigSrc !== "string" || path.isAbsolute(sigSrc) || sigSrc.split(/[\\/]/).includes("..")) {
|
|
188
|
+
throw new ManifestError(`${where}: signatureSource must be a repo-relative path`);
|
|
189
|
+
}
|
|
190
|
+
mutants.push({
|
|
191
|
+
claim: rec.claim,
|
|
192
|
+
title: rec.title,
|
|
193
|
+
subject,
|
|
194
|
+
find,
|
|
195
|
+
replace,
|
|
196
|
+
gate,
|
|
197
|
+
timeoutSeconds: rec.timeoutSeconds,
|
|
198
|
+
signature: expectedSig,
|
|
199
|
+
signatureSource: sigSrc,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return { schemaVersion: 1, lane: doc.lane, mutants };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface OriginChecks {
|
|
206
|
+
/** claim → true when the subject is tracked in the origin index. */
|
|
207
|
+
subjectTracked: (subject: string) => boolean;
|
|
208
|
+
/** true when the path is a REGULAR non-symlink file whose realpath stays inside the origin. */
|
|
209
|
+
regularContainedFile: (file: string) => boolean;
|
|
210
|
+
/** true when the path is on the origin work surface (tracked or untracked-non-ignored). */
|
|
211
|
+
onWorkSurface: (file: string) => boolean;
|
|
212
|
+
/** how many times a token occurs in the origin bytes of a file (0 when unreadable). */
|
|
213
|
+
tokenCount: (file: string, token: string) => number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Cross-manifest validation against the ORIGIN repo: global claim uniqueness,
|
|
218
|
+
* subject tracked in the origin git index (hardening #3 — "happens to exist in the
|
|
219
|
+
* snapshot" is not the contract), subject AND signatureSource lstat-regular
|
|
220
|
+
* non-symlink with realpath containment (P0-1 — a tracked SYMLINK subject would let
|
|
221
|
+
* the snapshot's read/write follow it OUT of the sandbox), and the claim token
|
|
222
|
+
* present exactly once in its declared gate source. signatureSource is checked
|
|
223
|
+
* against the WORK SURFACE (tracked ∪ untracked-non-ignored — exactly the snapshot
|
|
224
|
+
* replication set) rather than the index alone, so a brand-new gate file can carry
|
|
225
|
+
* its own claims before its first commit.
|
|
226
|
+
*/
|
|
227
|
+
export function validateManifestSet(manifests: MutantManifest[], origin: OriginChecks): MutantSpec[] {
|
|
228
|
+
const all: MutantSpec[] = [];
|
|
229
|
+
const seen = new Map<string, string>();
|
|
230
|
+
for (const man of manifests) {
|
|
231
|
+
for (const m of man.mutants) {
|
|
232
|
+
const prior = seen.get(m.claim);
|
|
233
|
+
if (prior !== undefined) {
|
|
234
|
+
throw new ManifestError(`duplicate claim ${m.claim} (lanes ${prior} and ${man.lane})`);
|
|
235
|
+
}
|
|
236
|
+
seen.set(m.claim, man.lane);
|
|
237
|
+
if (!origin.subjectTracked(m.subject)) {
|
|
238
|
+
throw new ManifestError(`${m.claim}: subject ${m.subject} is not tracked in the origin git index`);
|
|
239
|
+
}
|
|
240
|
+
if (!origin.regularContainedFile(m.subject)) {
|
|
241
|
+
throw new ManifestError(
|
|
242
|
+
`${m.claim}: subject ${m.subject} is not a regular non-symlink file inside the origin (symlink-escape guard)`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (!origin.onWorkSurface(m.signatureSource)) {
|
|
246
|
+
throw new ManifestError(`${m.claim}: signatureSource ${m.signatureSource} is not on the origin work surface`);
|
|
247
|
+
}
|
|
248
|
+
if (!origin.regularContainedFile(m.signatureSource)) {
|
|
249
|
+
throw new ManifestError(
|
|
250
|
+
`${m.claim}: signatureSource ${m.signatureSource} is not a regular non-symlink file inside the origin`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const n = origin.tokenCount(m.signatureSource, m.signature);
|
|
254
|
+
if (n !== 1) {
|
|
255
|
+
throw new ManifestError(
|
|
256
|
+
`${m.claim}: token ${m.signature} occurs ${n}× in ${m.signatureSource} (need exactly 1)`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
all.push(m);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return all;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── snapshot repo ───────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const SNAPSHOT_PREFIX = "entwurf-qualify-";
|
|
268
|
+
|
|
269
|
+
const SNAPSHOT_GIT_ENV = {
|
|
270
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
271
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
272
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
273
|
+
GIT_AUTHOR_NAME: "entwurf-qualify",
|
|
274
|
+
GIT_AUTHOR_EMAIL: "qualify@localhost",
|
|
275
|
+
GIT_COMMITTER_NAME: "entwurf-qualify",
|
|
276
|
+
GIT_COMMITTER_EMAIL: "qualify@localhost",
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function gitIn(dir: string, args: string[]): string {
|
|
280
|
+
const r = spawnSync("git", ["-C", dir, ...args], {
|
|
281
|
+
encoding: "utf8",
|
|
282
|
+
env: { ...process.env, ...SNAPSHOT_GIT_ENV },
|
|
283
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
284
|
+
});
|
|
285
|
+
if (r.status !== 0) {
|
|
286
|
+
throw new Error(`git ${args.join(" ")} failed in ${dir}: ${r.stderr || r.stdout}`);
|
|
287
|
+
}
|
|
288
|
+
return r.stdout;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Sweep stale snapshot dirs left by a SIGKILLed/powered-off run. Only dirs whose
|
|
293
|
+
* recorded runner pid is dead are removed; a dir with no runner.json is removed only
|
|
294
|
+
* once it is old (another runner may be mid-mkdtemp). Residue is inert tmp garbage —
|
|
295
|
+
* the real checkout was never written — so this is tidiness, not recovery.
|
|
296
|
+
*/
|
|
297
|
+
export function sweepStaleSnapshots(tmpRoot: string): string[] {
|
|
298
|
+
const swept: string[] = [];
|
|
299
|
+
let entries: string[];
|
|
300
|
+
try {
|
|
301
|
+
entries = fs.readdirSync(tmpRoot);
|
|
302
|
+
} catch {
|
|
303
|
+
return swept;
|
|
304
|
+
}
|
|
305
|
+
for (const name of entries) {
|
|
306
|
+
if (!name.startsWith(SNAPSHOT_PREFIX)) continue;
|
|
307
|
+
const dir = path.join(tmpRoot, name);
|
|
308
|
+
const marker = path.join(dir, "runner.json");
|
|
309
|
+
let dead = false;
|
|
310
|
+
try {
|
|
311
|
+
const rec = JSON.parse(fs.readFileSync(marker, "utf8")) as { pid?: number };
|
|
312
|
+
if (typeof rec.pid !== "number") dead = true;
|
|
313
|
+
else if (rec.pid !== process.pid) {
|
|
314
|
+
try {
|
|
315
|
+
process.kill(rec.pid, 0);
|
|
316
|
+
} catch {
|
|
317
|
+
dead = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
try {
|
|
322
|
+
dead = Date.now() - fs.statSync(dir).mtimeMs > 60_000;
|
|
323
|
+
} catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (dead) {
|
|
328
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
329
|
+
swept.push(dir);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return swept;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface Snapshot {
|
|
336
|
+
baseDir: string;
|
|
337
|
+
repoDir: string;
|
|
338
|
+
fileCount: number;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Replicate the origin working surface (tracked + untracked, ignored excluded) into
|
|
343
|
+
* an isolated snapshot repo with its own git baseline, so gates that consult
|
|
344
|
+
* `git status --porcelain` keep their purity checks, and the runner can assert the
|
|
345
|
+
* whole snapshot tree afterward. node_modules is shared via a dependency symlink,
|
|
346
|
+
* created after the baseline commit and excluded from git — it can never be a
|
|
347
|
+
* mutation subject (schema refuses it), and the selected qualification children
|
|
348
|
+
* treat that shared dependency tree as read-only and never run a package manager.
|
|
349
|
+
*/
|
|
350
|
+
export function createRepoSnapshot(originDir: string, tmpRoot: string = os.tmpdir()): Snapshot {
|
|
351
|
+
const baseDir = fs.mkdtempSync(path.join(tmpRoot, SNAPSHOT_PREFIX));
|
|
352
|
+
fs.chmodSync(baseDir, 0o700);
|
|
353
|
+
fs.writeFileSync(path.join(baseDir, "runner.json"), JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
|
|
354
|
+
const repoDir = path.join(baseDir, "repo");
|
|
355
|
+
fs.mkdirSync(repoDir, { mode: 0o700 });
|
|
356
|
+
|
|
357
|
+
const listOut = gitIn(originDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
358
|
+
const files = [...new Set(listOut.split("\0").filter((f) => f.length > 0))];
|
|
359
|
+
let copied = 0;
|
|
360
|
+
for (const rel of files) {
|
|
361
|
+
const src = path.join(originDir, rel);
|
|
362
|
+
let st: fs.Stats;
|
|
363
|
+
try {
|
|
364
|
+
st = fs.lstatSync(src);
|
|
365
|
+
} catch {
|
|
366
|
+
continue; // tracked in index but deleted from the worktree — the worktree is authority
|
|
367
|
+
}
|
|
368
|
+
const dst = path.join(repoDir, rel);
|
|
369
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
370
|
+
if (st.isSymbolicLink()) {
|
|
371
|
+
fs.symlinkSync(fs.readlinkSync(src), dst);
|
|
372
|
+
} else if (st.isFile()) {
|
|
373
|
+
fs.copyFileSync(src, dst);
|
|
374
|
+
fs.chmodSync(dst, st.mode & 0o777);
|
|
375
|
+
} else {
|
|
376
|
+
throw new Error(`refusing to snapshot special file ${rel} (mode ${st.mode.toString(8)})`);
|
|
377
|
+
}
|
|
378
|
+
copied++;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
gitIn(repoDir, ["init", "-q"]);
|
|
382
|
+
gitIn(repoDir, ["add", "-A"]);
|
|
383
|
+
gitIn(repoDir, ["-c", "commit.gpgsign=false", "commit", "-q", "-m", "qualification baseline"]);
|
|
384
|
+
|
|
385
|
+
// node_modules AFTER the baseline commit, git-excluded (hardening #2).
|
|
386
|
+
const originModules = path.join(originDir, "node_modules");
|
|
387
|
+
if (fs.existsSync(originModules)) {
|
|
388
|
+
fs.symlinkSync(originModules, path.join(repoDir, "node_modules"));
|
|
389
|
+
fs.appendFileSync(path.join(repoDir, ".git", "info", "exclude"), "node_modules\n");
|
|
390
|
+
}
|
|
391
|
+
return { baseDir, repoDir, fileCount: copied };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Content manifest of the snapshot tree — path+mode+sha256 for files, target for
|
|
396
|
+
* symlinks, bare entries for dirs — excluding .git and node_modules. Porcelain alone
|
|
397
|
+
* misses ignored-path writes; this does not (hardening #4).
|
|
398
|
+
*/
|
|
399
|
+
export function computeTreeManifest(repoDir: string): string {
|
|
400
|
+
const lines: string[] = [];
|
|
401
|
+
const walk = (rel: string): void => {
|
|
402
|
+
const abs = path.join(repoDir, rel);
|
|
403
|
+
for (const name of fs.readdirSync(abs).sort()) {
|
|
404
|
+
if (rel === "" && (name === ".git" || name === "node_modules")) continue;
|
|
405
|
+
const childRel = rel === "" ? name : `${rel}/${name}`;
|
|
406
|
+
const st = fs.lstatSync(path.join(repoDir, childRel));
|
|
407
|
+
if (st.isSymbolicLink()) {
|
|
408
|
+
lines.push(`${childRel}\0link\0${fs.readlinkSync(path.join(repoDir, childRel))}`);
|
|
409
|
+
} else if (st.isDirectory()) {
|
|
410
|
+
lines.push(`${childRel}\0dir`);
|
|
411
|
+
walk(childRel);
|
|
412
|
+
} else if (st.isFile()) {
|
|
413
|
+
const sha = createHash("sha256")
|
|
414
|
+
.update(fs.readFileSync(path.join(repoDir, childRel)))
|
|
415
|
+
.digest("hex");
|
|
416
|
+
lines.push(`${childRel}\0${(st.mode & 0o777).toString(8)}\0${sha}`);
|
|
417
|
+
} else {
|
|
418
|
+
lines.push(`${childRel}\0special`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
walk("");
|
|
423
|
+
return createHash("sha256").update(lines.join("\n")).digest("hex");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── bounded gate execution ──────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
export interface GateRunResult {
|
|
429
|
+
exitCode: number | null;
|
|
430
|
+
timedOut: boolean;
|
|
431
|
+
output: string;
|
|
432
|
+
seconds: number;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const OUTPUT_CAP = 4 * 1024 * 1024;
|
|
436
|
+
|
|
437
|
+
/** Env prefixes the outer fence strips so a gate child starts from a neutral host. */
|
|
438
|
+
const STRIP_ENV_PREFIXES = ["ENTWURF_", "AGY_", "PI_SESSION_ID", "PI_AGENT_ID"];
|
|
439
|
+
|
|
440
|
+
function fencedEnv(invocationDir: string): NodeJS.ProcessEnv {
|
|
441
|
+
const env: NodeJS.ProcessEnv = {};
|
|
442
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
443
|
+
if (STRIP_ENV_PREFIXES.some((p) => k === p || k.startsWith(p))) continue;
|
|
444
|
+
env[k] = v;
|
|
445
|
+
}
|
|
446
|
+
for (const d of ["home", "xdg-data", "xdg-config", "xdg-cache", "xdg-state"]) {
|
|
447
|
+
fs.mkdirSync(path.join(invocationDir, d), { recursive: true });
|
|
448
|
+
}
|
|
449
|
+
env.HOME = path.join(invocationDir, "home");
|
|
450
|
+
// TMPDIR is deliberately INHERITED, not fenced into the invocation dir: gates carry
|
|
451
|
+
// their own mkdtemp+cleanup discipline, and a unix-socket cell (meta-identity's
|
|
452
|
+
// socket-shaped record) must bind under the ~108-byte sun_path limit — an invocation
|
|
453
|
+
// -nested TMPDIR pushed it over and turned the CONTROL red for a runner-side reason.
|
|
454
|
+
env.XDG_DATA_HOME = path.join(invocationDir, "xdg-data");
|
|
455
|
+
env.XDG_CONFIG_HOME = path.join(invocationDir, "xdg-config");
|
|
456
|
+
env.XDG_CACHE_HOME = path.join(invocationDir, "xdg-cache");
|
|
457
|
+
env.XDG_STATE_HOME = path.join(invocationDir, "xdg-state");
|
|
458
|
+
return env;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Run one gate bounded: detached (its own process group), fenced HOME/XDG in a FRESH
|
|
463
|
+
* per-invocation directory (hardening #1; TMPDIR is inherited — see fencedEnv),
|
|
464
|
+
* SIGKILL to the whole group on timeout. The invocation dir lives OUTSIDE the
|
|
465
|
+
* snapshot repo tree and is removed by the caller after the run.
|
|
466
|
+
*/
|
|
467
|
+
export function runGateBounded(opts: {
|
|
468
|
+
cwd: string;
|
|
469
|
+
argv: string[];
|
|
470
|
+
timeoutSeconds: number;
|
|
471
|
+
invocationDir: string;
|
|
472
|
+
}): Promise<GateRunResult> {
|
|
473
|
+
const started = Date.now();
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
const child = spawn(opts.argv[0], opts.argv.slice(1), {
|
|
476
|
+
cwd: opts.cwd,
|
|
477
|
+
env: fencedEnv(opts.invocationDir),
|
|
478
|
+
detached: true,
|
|
479
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
480
|
+
});
|
|
481
|
+
let output = "";
|
|
482
|
+
let timedOut = false;
|
|
483
|
+
const append = (chunk: Buffer): void => {
|
|
484
|
+
if (output.length < OUTPUT_CAP) output += chunk.toString("utf8");
|
|
485
|
+
};
|
|
486
|
+
child.stdout.on("data", append);
|
|
487
|
+
child.stderr.on("data", append);
|
|
488
|
+
const timer = setTimeout(() => {
|
|
489
|
+
timedOut = true;
|
|
490
|
+
try {
|
|
491
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
492
|
+
} catch {
|
|
493
|
+
// group already gone
|
|
494
|
+
}
|
|
495
|
+
}, opts.timeoutSeconds * 1000);
|
|
496
|
+
child.on("error", (err) => {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
reject(err);
|
|
499
|
+
});
|
|
500
|
+
child.on("close", (code) => {
|
|
501
|
+
clearTimeout(timer);
|
|
502
|
+
resolve({ exitCode: code, timedOut, output, seconds: (Date.now() - started) / 1000 });
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── orchestration: CONTROL → mutants → RESTORE → CONTROL per gate group ─────
|
|
508
|
+
|
|
509
|
+
export interface MutantResult {
|
|
510
|
+
claim: string;
|
|
511
|
+
verdict: MutantVerdict;
|
|
512
|
+
seconds: number;
|
|
513
|
+
subjectSha256: string;
|
|
514
|
+
detail: string;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export interface GroupResult {
|
|
518
|
+
gate: string[];
|
|
519
|
+
control: "ok" | "pre-red" | "post-red" | "skipped";
|
|
520
|
+
mutants: MutantResult[];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface QualifyReport {
|
|
524
|
+
groups: GroupResult[];
|
|
525
|
+
treeClean: boolean;
|
|
526
|
+
porcelainClean: boolean;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function reportPassed(report: QualifyReport): boolean {
|
|
530
|
+
return (
|
|
531
|
+
report.treeClean &&
|
|
532
|
+
report.porcelainClean &&
|
|
533
|
+
report.groups.every((g) => g.control === "ok" && g.mutants.every((m) => m.verdict === "KILLED"))
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function sha256File(file: string): string {
|
|
538
|
+
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function countOccurrences(haystack: string, needle: string): number {
|
|
542
|
+
let count = 0;
|
|
543
|
+
let at = haystack.indexOf(needle);
|
|
544
|
+
while (at !== -1) {
|
|
545
|
+
count++;
|
|
546
|
+
at = haystack.indexOf(needle, at + needle.length);
|
|
547
|
+
}
|
|
548
|
+
return count;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Run every mutant grouped by its gate command, with the control-mutant-restore-
|
|
553
|
+
* control state machine. A red CONTROL-PRE aborts the whole group (every mutant
|
|
554
|
+
* reports CONTROL-RED — a baseline-red gate can only produce fake kills); a failed
|
|
555
|
+
* restore or a red CONTROL-POST marks the group IMPURE. The caller compares tree
|
|
556
|
+
* manifests around the whole run.
|
|
557
|
+
*/
|
|
558
|
+
export async function qualifyMutants(
|
|
559
|
+
snapshot: Snapshot,
|
|
560
|
+
mutants: MutantSpec[],
|
|
561
|
+
log: (line: string) => void,
|
|
562
|
+
): Promise<QualifyReport> {
|
|
563
|
+
const preTree = computeTreeManifest(snapshot.repoDir);
|
|
564
|
+
const groups = new Map<string, MutantSpec[]>();
|
|
565
|
+
for (const m of mutants) {
|
|
566
|
+
const key = JSON.stringify(m.gate);
|
|
567
|
+
const list = groups.get(key) ?? [];
|
|
568
|
+
list.push(m);
|
|
569
|
+
groups.set(key, list);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const results: GroupResult[] = [];
|
|
573
|
+
let invocationSeq = 0;
|
|
574
|
+
const runOnce = async (argv: string[], timeoutSeconds: number): Promise<GateRunResult> => {
|
|
575
|
+
const invocationDir = path.join(snapshot.baseDir, `invocation-${invocationSeq++}`);
|
|
576
|
+
fs.mkdirSync(invocationDir, { mode: 0o700 });
|
|
577
|
+
try {
|
|
578
|
+
return await runGateBounded({ cwd: snapshot.repoDir, argv, timeoutSeconds, invocationDir });
|
|
579
|
+
} finally {
|
|
580
|
+
fs.rmSync(invocationDir, { recursive: true, force: true });
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
for (const [key, groupMutants] of groups) {
|
|
585
|
+
const gate = JSON.parse(key) as string[];
|
|
586
|
+
const groupTimeout = Math.max(...groupMutants.map((m) => m.timeoutSeconds));
|
|
587
|
+
const group: GroupResult = { gate, control: "skipped", mutants: [] };
|
|
588
|
+
results.push(group);
|
|
589
|
+
|
|
590
|
+
const pre = await runOnce(gate, groupTimeout);
|
|
591
|
+
const controlPreOk = !pre.timedOut && pre.exitCode === 0;
|
|
592
|
+
log(
|
|
593
|
+
` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
|
|
594
|
+
);
|
|
595
|
+
if (!controlPreOk) {
|
|
596
|
+
group.control = "pre-red";
|
|
597
|
+
for (const m of groupMutants) {
|
|
598
|
+
group.mutants.push({
|
|
599
|
+
claim: m.claim,
|
|
600
|
+
verdict: classifyMutantRun({
|
|
601
|
+
controlPreOk: false,
|
|
602
|
+
matchCount: 1,
|
|
603
|
+
timedOut: false,
|
|
604
|
+
exitCode: null,
|
|
605
|
+
signatureOnFailureLine: false,
|
|
606
|
+
restoredOk: true,
|
|
607
|
+
}),
|
|
608
|
+
seconds: 0,
|
|
609
|
+
subjectSha256: "",
|
|
610
|
+
detail: "control-pre red — no kill can be claimed against a baseline-red gate",
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let groupImpure = false;
|
|
617
|
+
for (const m of groupMutants) {
|
|
618
|
+
const subjectAbs = path.join(snapshot.repoDir, m.subject);
|
|
619
|
+
// P0-1 runtime guard, IN ADDITION to the manifest-time origin check: the
|
|
620
|
+
// snapshot preserves tracked symlinks, and readFileSync/writeFileSync FOLLOW
|
|
621
|
+
// them — a symlink subject would mutate whatever it points at, potentially
|
|
622
|
+
// OUTSIDE the snapshot. Refuse before reading a byte; also pin realpath
|
|
623
|
+
// containment so no path component smuggles the write out. This path is
|
|
624
|
+
// reachable without validateManifestSet (the self-test injects specs
|
|
625
|
+
// directly), so the guard must live here, not only in validation.
|
|
626
|
+
const subjectStat = fs.lstatSync(subjectAbs);
|
|
627
|
+
if (!subjectStat.isFile() || subjectStat.isSymbolicLink()) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`subject ${m.subject} is not a regular non-symlink file in the snapshot — refusing to mutate (symlink-escape guard)`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const repoReal = fs.realpathSync(snapshot.repoDir) + path.sep;
|
|
633
|
+
if (!fs.realpathSync(subjectAbs).startsWith(repoReal)) {
|
|
634
|
+
throw new Error(`subject ${m.subject} resolves outside the snapshot repo — refusing to mutate`);
|
|
635
|
+
}
|
|
636
|
+
const originalBytes = fs.readFileSync(subjectAbs);
|
|
637
|
+
const originalSha = createHash("sha256").update(originalBytes).digest("hex");
|
|
638
|
+
const source = originalBytes.toString("utf8");
|
|
639
|
+
const find = m.find.join("\n");
|
|
640
|
+
const matchCount = countOccurrences(source, find);
|
|
641
|
+
if (matchCount !== 1) {
|
|
642
|
+
const verdict = classifyMutantRun({
|
|
643
|
+
controlPreOk: true,
|
|
644
|
+
matchCount,
|
|
645
|
+
timedOut: false,
|
|
646
|
+
exitCode: null,
|
|
647
|
+
signatureOnFailureLine: false,
|
|
648
|
+
restoredOk: true,
|
|
649
|
+
});
|
|
650
|
+
group.mutants.push({
|
|
651
|
+
claim: m.claim,
|
|
652
|
+
verdict,
|
|
653
|
+
seconds: 0,
|
|
654
|
+
subjectSha256: originalSha,
|
|
655
|
+
detail: `find matched ${matchCount}× in ${m.subject} — nothing was written`,
|
|
656
|
+
});
|
|
657
|
+
log(` claim ${m.claim}: ${verdict} (find matched ${matchCount}×)`);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// A function replacement keeps the declared bytes literal: with a string
|
|
662
|
+
// second argument, String.replace interprets `$&`/`$'`/"$`"/`$$` as
|
|
663
|
+
// substitution patterns, so a replacement carrying one (bash `$$`, ANSI-C
|
|
664
|
+
// `$'…'`) would silently plant DIFFERENT bytes than the manifest declares.
|
|
665
|
+
fs.writeFileSync(
|
|
666
|
+
subjectAbs,
|
|
667
|
+
source.replace(find, () => m.replace.join("\n")),
|
|
668
|
+
);
|
|
669
|
+
let run: GateRunResult;
|
|
670
|
+
try {
|
|
671
|
+
run = await runOnce(m.gate, m.timeoutSeconds);
|
|
672
|
+
} finally {
|
|
673
|
+
fs.writeFileSync(subjectAbs, originalBytes);
|
|
674
|
+
}
|
|
675
|
+
const restoredOk = sha256File(subjectAbs) === originalSha;
|
|
676
|
+
const verdict = classifyMutantRun({
|
|
677
|
+
controlPreOk: true,
|
|
678
|
+
matchCount: 1,
|
|
679
|
+
timedOut: run.timedOut,
|
|
680
|
+
exitCode: run.exitCode,
|
|
681
|
+
signatureOnFailureLine: signatureOnFailureLine(run.output, m.signature),
|
|
682
|
+
restoredOk,
|
|
683
|
+
});
|
|
684
|
+
if (!restoredOk) groupImpure = true;
|
|
685
|
+
group.mutants.push({
|
|
686
|
+
claim: m.claim,
|
|
687
|
+
verdict,
|
|
688
|
+
seconds: run.seconds,
|
|
689
|
+
subjectSha256: originalSha,
|
|
690
|
+
detail: `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}`,
|
|
691
|
+
});
|
|
692
|
+
log(
|
|
693
|
+
` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)`,
|
|
694
|
+
);
|
|
695
|
+
if (!restoredOk) break; // contaminated snapshot — stop the group
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (groupImpure) {
|
|
699
|
+
group.control = "post-red";
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const post = await runOnce(gate, groupTimeout);
|
|
703
|
+
const controlPostOk = !post.timedOut && post.exitCode === 0;
|
|
704
|
+
group.control = controlPostOk ? "ok" : "post-red";
|
|
705
|
+
log(
|
|
706
|
+
` control-post ${gate.join(" ")}: ${controlPostOk ? "green" : `RED (exit=${post.exitCode} timedOut=${post.timedOut}) — restore contamination or gate state leak`} in ${post.seconds.toFixed(1)}s`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const postTree = computeTreeManifest(snapshot.repoDir);
|
|
711
|
+
const porcelain = gitIn(snapshot.repoDir, ["status", "--porcelain"]).trim();
|
|
712
|
+
return { groups: results, treeClean: postTree === preTree, porcelainClean: porcelain === "" };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── origin repo helpers ─────────────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
export function originHead(originDir: string): string {
|
|
718
|
+
return gitIn(originDir, ["rev-parse", "HEAD"]).trim();
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Content hash of the origin WORK SURFACE — every tracked or untracked-non-ignored
|
|
723
|
+
* path with its type, mode, and content sha (symlinks by target). `git status
|
|
724
|
+
* --porcelain` alone cannot see a byte change inside a file that was ALREADY
|
|
725
|
+
* modified (same ` M` row, same porcelain text), so a tripwire built on it would
|
|
726
|
+
* miss exactly the write it exists to catch (P1-5). This one does not.
|
|
727
|
+
*/
|
|
728
|
+
export function originWorkSurfaceSha(originDir: string): string {
|
|
729
|
+
const listOut = gitIn(originDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
730
|
+
const files = [...new Set(listOut.split("\0").filter((f) => f.length > 0))].sort();
|
|
731
|
+
const lines: string[] = [];
|
|
732
|
+
for (const rel of files) {
|
|
733
|
+
const abs = path.join(originDir, rel);
|
|
734
|
+
let st: fs.Stats;
|
|
735
|
+
try {
|
|
736
|
+
st = fs.lstatSync(abs);
|
|
737
|
+
} catch {
|
|
738
|
+
lines.push(`${rel}\0missing`);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (st.isSymbolicLink()) {
|
|
742
|
+
lines.push(`${rel}\0link\0${fs.readlinkSync(abs)}`);
|
|
743
|
+
} else if (st.isFile()) {
|
|
744
|
+
const sha = createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
|
|
745
|
+
lines.push(`${rel}\0${(st.mode & 0o777).toString(8)}\0${sha}`);
|
|
746
|
+
} else {
|
|
747
|
+
lines.push(`${rel}\0special`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return createHash("sha256").update(lines.join("\n")).digest("hex");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function makeOriginChecks(originDir: string): OriginChecks {
|
|
754
|
+
return {
|
|
755
|
+
subjectTracked: (subject: string): boolean => {
|
|
756
|
+
const r = spawnSync("git", ["-C", originDir, "ls-files", "--error-unmatch", "--", subject], {
|
|
757
|
+
stdio: "ignore",
|
|
758
|
+
env: { ...process.env, ...SNAPSHOT_GIT_ENV },
|
|
759
|
+
});
|
|
760
|
+
return r.status === 0;
|
|
761
|
+
},
|
|
762
|
+
regularContainedFile: (file: string): boolean => {
|
|
763
|
+
try {
|
|
764
|
+
const abs = path.join(originDir, file);
|
|
765
|
+
const st = fs.lstatSync(abs);
|
|
766
|
+
if (!st.isFile() || st.isSymbolicLink()) return false;
|
|
767
|
+
return fs.realpathSync(abs).startsWith(fs.realpathSync(originDir) + path.sep);
|
|
768
|
+
} catch {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
onWorkSurface: (file: string): boolean => {
|
|
773
|
+
const r = spawnSync(
|
|
774
|
+
"git",
|
|
775
|
+
["-C", originDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", file],
|
|
776
|
+
{ encoding: "utf8", env: { ...process.env, ...SNAPSHOT_GIT_ENV } },
|
|
777
|
+
);
|
|
778
|
+
return (
|
|
779
|
+
r.status === 0 &&
|
|
780
|
+
r.stdout
|
|
781
|
+
.split("\0")
|
|
782
|
+
.filter((f) => f.length > 0)
|
|
783
|
+
.includes(file)
|
|
784
|
+
);
|
|
785
|
+
},
|
|
786
|
+
tokenCount: (file: string, token: string): number => {
|
|
787
|
+
try {
|
|
788
|
+
return countOccurrences(fs.readFileSync(path.join(originDir, file), "utf8"), token);
|
|
789
|
+
} catch {
|
|
790
|
+
return 0;
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
}
|