@orangepro/orangepro-mcp 0.1.0 → 0.2.1

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.
@@ -0,0 +1,571 @@
1
+ #!/usr/bin/env node
2
+ // java-dynamic-proof-spike.mjs — Java dynamic-proof MECHANISM (J-1).
3
+ //
4
+ // Proves ONE simplest-shape method 0->1 on a single-module Maven + JUnit 5 project
5
+ // by mutation: it byte-copies the module into a hermetic sandbox, runs one target
6
+ // test via Surefire, mutates the target method body with a signature-derived
7
+ // sentinel (via java-mutate.mjs, tree-sitter Java AST), reruns the SAME test, and
8
+ // classifies from the STRUCTURED surefire report (target/surefire-reports/TEST-*.xml),
9
+ // never from stdout greps. It emits a JSON verdict mirroring the TS/JS and Go spikes:
10
+ // { status: "proven" | "associated_survived" | "unrunnable", ... }.
11
+ //
12
+ // TRUST: no false Proven. `proven` requires (a) baseline COMPILES, the target test
13
+ // PASSES, AND `mvn` exits 0 (a red build is not a clean baseline), (b) the mutant
14
+ // COMPILES, (c) the SAME target test FAILS, (d) the failure is a TRUSTED JUnit
15
+ // ASSERTION — org.opentest4j.AssertionFailedError / MultipleFailuresError by type,
16
+ // or a java.lang.AssertionError whose <failure> stack trace was raised by a trusted
17
+ // assertion API (org.junit./org.opentest4j./org.hamcrest./org.assertj.). An
18
+ // app-defined *AssertionError subclass or a bare `throw new AssertionError()` is NOT
19
+ // trusted. A <error> (NPE / RuntimeException / any other Throwable), a javac compile
20
+ // failure, a @BeforeEach/setup exception before the target, or no-test-run all
21
+ // classify as `unrunnable`, never `proven`. An equivalent-value mutation survives ->
22
+ // `associated_survived`. An ambiguous method name is refused -> `unrunnable`.
23
+ //
24
+ // This is a spike harness only: it writes no graph edges or product artifacts and
25
+ // is NOT wired into autoProve / cert / RTM / the mint path. Pin: Maven + Surefire +
26
+ // JUnit 5 (the Spring Boot default). Gradle / JUnit4 are later parsers.
27
+ import { spawnSync } from "node:child_process";
28
+ import { cpSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import path from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ const DEFAULT_TIMEOUT_MS = 180_000;
34
+
35
+ function usage() {
36
+ return [
37
+ "Usage: node scripts/spikes/java-dynamic-proof-spike.mjs --root <maven-module> --test-class <FQCN or Class> --test-method <name> --target <rel.java> --method <name> [--mode sentinel|equivalent] [--maven-repo-local <dir>] [--json]",
38
+ "",
39
+ "--maven-repo-local points every run at ONE Maven local repo so immutable published deps (JUnit) resolve once. It is still isolated from your ~/.m2, holds no test code, and receives no ambient secrets. Omit it for a fresh per-run repo (maximally hermetic).",
40
+ "",
41
+ "Runs ONE Surefire target test on a byte-copy of a single-module Maven + JUnit 5 project, mutates the target method body via a signature-derived sentinel, reruns the SAME test, and classifies from the structured surefire report.",
42
+ "J-1 scope: SIMPLEST SHAPE ONLY — a concrete non-void return, a single top-level return, no generics, no overloads. Equivalent-value mutations survive (associated_survived). Ambiguous names are refused (unrunnable).",
43
+ "This is a spike harness only; it does not write graph edges or product artifacts and is not wired into prove/RTM/mint."
44
+ ].join("\n");
45
+ }
46
+
47
+ function parseArgs(argv) {
48
+ const args = { json: false, mode: "sentinel" };
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const arg = argv[i];
51
+ if (arg === "--json") {
52
+ args.json = true;
53
+ continue;
54
+ }
55
+ if (!arg.startsWith("--")) {
56
+ throw new Error(`Unexpected positional argument: ${arg}`);
57
+ }
58
+ const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
59
+ const value = argv[i + 1];
60
+ if (value === undefined || value.startsWith("--")) {
61
+ throw new Error(`Missing value for ${arg}`);
62
+ }
63
+ args[key] = value;
64
+ i += 1;
65
+ }
66
+ for (const required of ["root", "testClass", "testMethod", "target", "method"]) {
67
+ if (!Object.prototype.hasOwnProperty.call(args, required)) {
68
+ throw new Error(`Missing required --${required.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}`);
69
+ }
70
+ }
71
+ if (args.mode !== "sentinel" && args.mode !== "equivalent") {
72
+ throw new Error("--mode must be sentinel or equivalent");
73
+ }
74
+ return args;
75
+ }
76
+
77
+ function parseTimeoutMs(value) {
78
+ if (value === undefined) return DEFAULT_TIMEOUT_MS;
79
+ const parsed = Number(value);
80
+ if (!Number.isInteger(parsed) || parsed <= 0) {
81
+ throw new Error("--timeout-ms must be a positive integer");
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ function isSecretEnvKey(key) {
87
+ return /TOKEN|SECRET|PASSWORD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET[_-]?KEY|PASSPHRASE|CREDENTIAL|PIN|AUTH|COOKIE|SESSION/i.test(key);
88
+ }
89
+
90
+ function resolveInside(root, relOrAbs) {
91
+ const resolved = path.resolve(root, relOrAbs);
92
+ const relative = path.relative(root, resolved);
93
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
94
+ throw new Error(`Path escapes root: ${relOrAbs}`);
95
+ }
96
+ return resolved;
97
+ }
98
+
99
+ // Byte-copy the module into a temp sandbox. Never follow symlinks (a source-repo
100
+ // symlink must not leak an outside dir into the sandbox); exclude .git, .orangepro,
101
+ // node_modules (JS concept, irrelevant here), and any stale `target/` build dir so
102
+ // baseline/mutant surefire reports never mix. Local source is copied, never
103
+ // writable-symlinked.
104
+ function copyModuleRoot(root, label) {
105
+ const tmpRoot = mkdtempSync(path.join(tmpdir(), `opro-java-proof-${label}-`));
106
+ const repoRoot = path.join(tmpRoot, "module");
107
+ cpSync(root, repoRoot, {
108
+ recursive: true,
109
+ filter(source) {
110
+ const name = path.basename(source);
111
+ if (source !== root && lstatSync(source).isSymbolicLink()) {
112
+ return false;
113
+ }
114
+ return name !== "node_modules" && name !== ".git" && name !== ".orangepro" && name !== "target";
115
+ }
116
+ });
117
+ return { tmpRoot, repoRoot };
118
+ }
119
+
120
+ // A per-run hermetic Maven local repo inside the sandbox tmp dir, plus a sanitized
121
+ // allowlist env. No ambient secrets are forwarded to `mvn`: only a fixed set of
122
+ // process-control vars is passed, and any secret-looking key is stripped
123
+ // defensively. This keeps proofs deterministic and prevents credentials from
124
+ // reaching repo test code. Note: `mvn` may need network on first run to resolve
125
+ // JUnit; the fixtures use only JUnit 5 (commonly cached). If offline and uncached,
126
+ // the compile/run fails closed (unrunnable), never false-Proven.
127
+ function hermeticEnv(cacheRoot) {
128
+ const env = {
129
+ PATH: process.env.PATH ?? "",
130
+ HOME: process.env.HOME ?? "",
131
+ TMPDIR: process.env.TMPDIR ?? tmpdir(),
132
+ JAVA_HOME: process.env.JAVA_HOME ?? "",
133
+ LANG: process.env.LANG ?? "C",
134
+ CI: "1",
135
+ NO_COLOR: "1"
136
+ };
137
+ for (const key of Object.keys(env)) {
138
+ if (isSecretEnvKey(key)) delete env[key];
139
+ }
140
+ return env;
141
+ }
142
+
143
+ function mvnBin() {
144
+ return process.env.OPRO_MVN_BIN || "mvn";
145
+ }
146
+
147
+ // Resolve the developer's EXISTING Maven local repo so the sandbox module can
148
+ // resolve deps it already downloaded (a real `opro` user has built the repo).
149
+ // `$HOME/.m2/repository` is the Maven default; $MAVEN_REPO_LOCAL overrides it.
150
+ // Result is cached (queried once). Returns null if nothing resolvable exists, in
151
+ // which case we degrade to a per-run empty repo (self-contained fixtures with no
152
+ // external deps still resolve JUnit on first run). The repo is used READ-ONLY:
153
+ // with -o (offline, below) `mvn` never downloads and so never writes new artifacts
154
+ // into it — a missing dep errors out (unrunnable) instead of mutating the repo.
155
+ let cachedMavenRepo;
156
+ function resolveMavenRepo() {
157
+ if (cachedMavenRepo !== undefined) {
158
+ return cachedMavenRepo;
159
+ }
160
+ const dir = process.env.MAVEN_REPO_LOCAL
161
+ || (process.env.HOME ? path.join(process.env.HOME, ".m2", "repository") : "");
162
+ cachedMavenRepo = dir && existsSync(dir) ? dir : null;
163
+ return cachedMavenRepo;
164
+ }
165
+
166
+ // Build-gating plugins that FAIL the build on the sentinel-mutated body's
167
+ // formatting (spring-javaformat), lint (checkstyle/spotless), or environment
168
+ // rules (enforcer). These are PROOF-RUNNER SETUP, not proof semantics: skipping
169
+ // them only lets the baseline+mutant COMPILE and the target test RUN. `compile`
170
+ // and the target `test` are NOT skipped, so Proven still closes ONLY on the
171
+ // trusted JUnit assertion the sentinel mutant trips (the unchanged classifier).
172
+ // Each flag is harmless when the target repo does not use that plugin.
173
+ const BUILD_GATE_SKIPS = [
174
+ "-Dspring-javaformat.skip=true",
175
+ "-Dcheckstyle.skip=true",
176
+ "-Dspotless.check.skip=true",
177
+ "-Denforcer.skip=true"
178
+ ];
179
+
180
+ // A single `mvn test` invocation. `offline` runs -o (used with the reused read-only
181
+ // ~/.m2); otherwise mvn may resolve into `localRepo` (a per-run empty repo).
182
+ function invokeMvn({ repoRoot, testClass, testMethod, timeoutMs, cacheRoot, localRepo, offline }) {
183
+ return spawnSync(
184
+ mvnBin(),
185
+ [
186
+ "-q",
187
+ ...(offline ? ["-o"] : []),
188
+ "test",
189
+ `-Dtest=${testClass}#${testMethod}`,
190
+ "-Dsurefire.failIfNoSpecifiedTests=false",
191
+ `-Dmaven.repo.local=${localRepo}`,
192
+ ...BUILD_GATE_SKIPS,
193
+ "-Dstyle.color=never"
194
+ ],
195
+ {
196
+ cwd: repoRoot,
197
+ encoding: "utf8",
198
+ timeout: timeoutMs,
199
+ env: hermeticEnv(cacheRoot),
200
+ maxBuffer: 32 * 1024 * 1024
201
+ }
202
+ );
203
+ }
204
+
205
+ // Offline mode fails when a plugin/dependency the module pins is not already in the
206
+ // reused ~/.m2 (mvn cannot download in -o). This is a RESOLUTION failure, distinct
207
+ // from a compile error or a test outcome (both of which produce a surefire report):
208
+ // mvn prints the well-known "offline mode … has not been downloaded" marker and no
209
+ // report exists. We detect that to fall back to an online per-run repo — never to
210
+ // reclassify a test result.
211
+ function isOfflineResolutionFailure(result, report) {
212
+ if (report && report.targetCase) return false; // the test ran -> resolution succeeded
213
+ const out = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
214
+ return /offline mode|Cannot access .* in offline mode|PluginResolutionException|Cannot resolve .* in offline mode/i.test(out);
215
+ }
216
+
217
+ // Run ONE target test through Surefire. -Dtest scopes to a single class#method and
218
+ // -Dsurefire.failIfNoSpecifiedTests=false keeps a no-match from erroring the run so
219
+ // we classify from the report, not the mvn exit code. When no explicit
220
+ // --maven-repo-local is given we FIRST reuse the developer's EXISTING ~/.m2 READ-ONLY
221
+ // with -o (offline) so a real repo's already-downloaded deps (Spring/Mockito/JUnit)
222
+ // resolve without any download mutating that repo. If that offline attempt cannot
223
+ // RESOLVE a pinned plugin/dep (a self-contained fixture pinning versions not in
224
+ // ~/.m2), we fall back to the original online per-run-repo behavior — a setup-only
225
+ // retry that never reclassifies a test outcome.
226
+ function runSurefire({ repoRoot, testClass, testMethod, timeoutMs, cacheRoot, mavenRepoLocal }) {
227
+ const started = performance.now();
228
+ // Default: reuse the developer's read-only ~/.m2 and run offline. A caller MAY
229
+ // still pass an explicit --maven-repo-local (an isolated repo); when they do we
230
+ // honor it and DO allow first-run resolution into it. Either way source is
231
+ // byte-copied, never symlinked, and no ambient secrets reach mvn.
232
+ const reuseDevRepo = mavenRepoLocal === undefined ? resolveMavenRepo() : null;
233
+ let localRepo = mavenRepoLocal ?? reuseDevRepo ?? path.join(cacheRoot, "m2repo");
234
+ let offline = Boolean(reuseDevRepo);
235
+ if (!offline) mkdirSync(localRepo, { recursive: true });
236
+ let result = invokeMvn({ repoRoot, testClass, testMethod, timeoutMs, cacheRoot, localRepo, offline });
237
+ let report = readSurefireReport(repoRoot, testClass, testMethod);
238
+ // Offline reuse could not resolve a pinned plugin/dep -> retry online into a
239
+ // per-run empty repo (the pre-reuse behavior). Only fires when NO report exists,
240
+ // so a real test result is never re-run.
241
+ if (offline && isOfflineResolutionFailure(result, report)) {
242
+ localRepo = path.join(cacheRoot, "m2repo");
243
+ offline = false;
244
+ mkdirSync(localRepo, { recursive: true });
245
+ result = invokeMvn({ repoRoot, testClass, testMethod, timeoutMs, cacheRoot, localRepo, offline });
246
+ report = readSurefireReport(repoRoot, testClass, testMethod);
247
+ }
248
+ const elapsedMs = Math.round(performance.now() - started);
249
+ return {
250
+ exitCode: result.status ?? 1,
251
+ signal: result.signal ?? null,
252
+ timedOut: Boolean(result.error && result.error.code === "ETIMEDOUT"),
253
+ stdout: result.stdout ?? "",
254
+ stderr: result.stderr ?? "",
255
+ // A javac compile failure produces NO surefire report for the class.
256
+ compileFailed: detectCompileFailure(result, report),
257
+ report,
258
+ elapsedMs
259
+ };
260
+ }
261
+
262
+ // A compile failure is when maven failed AND surefire never produced a testcase for
263
+ // the target (the class did not build, so no report exists). We rely on the
264
+ // STRUCTURED absence of a testcase, corroborated by the well-known maven markers, so
265
+ // a normal assertion failure (which DOES produce a report) is never mislabeled as a
266
+ // compile error.
267
+ function detectCompileFailure(result, report) {
268
+ if (report && report.targetCase) return false; // the test ran -> it compiled
269
+ const out = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
270
+ return /COMPILATION ERROR|BUILD FAILURE|Compilation failure|cannot find symbol|maven-compiler-plugin/i.test(out)
271
+ || (result.status ?? 1) !== 0;
272
+ }
273
+
274
+ // Read the target's TEST-*.xml surefire report and locate the target <testcase>.
275
+ // Classification reads ONLY this structured XML: each <testcase> carries a
276
+ // <failure type="…"> (assertion) or <error type="…"> (other Throwable), or neither
277
+ // (passed). We do a tolerant, dependency-free XML scan (no XML lib) scoped to the
278
+ // single target testcase.
279
+ function readSurefireReport(repoRoot, testClass, testMethod) {
280
+ const dir = path.join(repoRoot, "target", "surefire-reports");
281
+ if (!existsSync(dir)) return null;
282
+ const simpleClass = testClass.includes(".") ? testClass.slice(testClass.lastIndexOf(".") + 1) : testClass;
283
+ let files;
284
+ try {
285
+ files = readdirSync(dir).filter((f) => /^TEST-.*\.xml$/.test(f));
286
+ } catch {
287
+ return null;
288
+ }
289
+ // Prefer the report whose file name matches the target class; fall back to any.
290
+ const preferred = files.filter((f) => f === `TEST-${testClass}.xml` || f.endsWith(`.${simpleClass}.xml`) || f === `TEST-${simpleClass}.xml`);
291
+ const candidates = preferred.length ? preferred : files;
292
+ for (const file of candidates) {
293
+ let xml;
294
+ try {
295
+ xml = readFileSync(path.join(dir, file), "utf8");
296
+ } catch {
297
+ continue;
298
+ }
299
+ const targetCase = findTestCase(xml, simpleClass, testMethod);
300
+ if (targetCase) return { file, targetCase };
301
+ }
302
+ return null;
303
+ }
304
+
305
+ // Extract the target <testcase name="method" ...>…</testcase> and classify it.
306
+ // Returns { passed, failure, error } where failure/error carry their `type` attr.
307
+ function findTestCase(xml, simpleClass, testMethod) {
308
+ // Match each testcase block (self-closing OR with a body).
309
+ const re = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
310
+ let m;
311
+ while ((m = re.exec(xml)) !== null) {
312
+ const attrs = m[1] || "";
313
+ const inner = m[3] || "";
314
+ const name = attrOf(attrs, "name");
315
+ if (name !== testMethod) continue;
316
+ // Optional classname guard: if present, it should reference the target class.
317
+ const classname = attrOf(attrs, "classname");
318
+ if (classname && simpleClass && !classname.endsWith(simpleClass)) continue;
319
+ const failure = firstTag(inner, "failure");
320
+ const error = firstTag(inner, "error");
321
+ const skipped = /<skipped\b/.test(inner);
322
+ return {
323
+ name,
324
+ classname,
325
+ passed: !failure && !error && !skipped,
326
+ skipped,
327
+ failure: failure
328
+ ? { type: attrOf(failure.attrs, "type"), message: attrOf(failure.attrs, "message"), stack: failure.text }
329
+ : null,
330
+ error: error ? { type: attrOf(error.attrs, "type"), message: attrOf(error.attrs, "message") } : null
331
+ };
332
+ }
333
+ return null;
334
+ }
335
+
336
+ function attrOf(attrs, name) {
337
+ const m = new RegExp(`${name}="([^"]*)"`).exec(attrs);
338
+ return m ? decodeXml(m[1]) : undefined;
339
+ }
340
+
341
+ // Extract the first <tag …> element: its attribute string AND its body text (the
342
+ // decoded stack trace, empty for a self-closing element). The body is needed to
343
+ // attribute a bare java.lang.AssertionError to a trusted assertion API by its stack
344
+ // frames rather than trusting the `type` attr alone.
345
+ function firstTag(inner, tag) {
346
+ const body = new RegExp(`<${tag}\\b([^>]*?)>([\\s\\S]*?)</${tag}>`, "i").exec(inner);
347
+ if (body) return { attrs: body[1] || "", text: decodeXml(body[2] || "") };
348
+ const selfClosing = new RegExp(`<${tag}\\b([^>]*?)/>`, "i").exec(inner);
349
+ if (selfClosing) return { attrs: selfClosing[1] || "", text: "" };
350
+ return null;
351
+ }
352
+
353
+ function decodeXml(s) {
354
+ return String(s)
355
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
356
+ }
357
+
358
+ // Stack frames that identify a REAL assert*/assertThat from a trusted assertion
359
+ // library (JUnit4/5, OpenTest4J, Hamcrest, AssertJ). A bare `throw new
360
+ // AssertionError()` whose top frame is the test class itself has none of these.
361
+ const TRUSTED_ASSERTION_FRAME = /\bat\s+(?:org\.junit\.|org\.opentest4j\.|org\.hamcrest\.|org\.assertj\.)/;
362
+
363
+ // A <failure> is a TRUSTED assertion signal ONLY when it comes from a known
364
+ // JUnit/OpenTest4J assertion source. We do NOT trust `type` alone: an app-defined
365
+ // `com.myapp.FooAssertionError` must not pass just because its simple name ends in
366
+ // "AssertionError". Rules:
367
+ // - org.opentest4j.AssertionFailedError / org.opentest4j.MultipleFailuresError —
368
+ // unambiguous JUnit5/AssertJ types → trusted by type.
369
+ // - java.lang.AssertionError — trusted ONLY when the <failure> stack trace shows a
370
+ // frame from a trusted assertion API (a real assertEquals/assertThat raised it),
371
+ // NOT a bare `throw new AssertionError()` from the test class.
372
+ // - anything else (an app-defined *AssertionError subclass, a manually-thrown
373
+ // java.lang.AssertionError with no trusted frame, an NPE, a plain
374
+ // RuntimeException) is REJECTED.
375
+ function isAssertionFailure(failure) {
376
+ if (!failure || typeof failure.type !== "string") return false;
377
+ const type = failure.type;
378
+ if (type === "org.opentest4j.AssertionFailedError" || type === "org.opentest4j.MultipleFailuresError") {
379
+ return true;
380
+ }
381
+ if (type === "java.lang.AssertionError") {
382
+ return TRUSTED_ASSERTION_FRAME.test(String(failure.stack ?? ""));
383
+ }
384
+ return false;
385
+ }
386
+
387
+ function failureSummary(run) {
388
+ const tc = run.report?.targetCase;
389
+ if (tc?.failure) return redactSecrets(`${tc.failure.type ?? "failure"}: ${tc.failure.message ?? ""}`.trim());
390
+ if (tc?.error) return redactSecrets(`${tc.error.type ?? "error"}: ${tc.error.message ?? ""}`.trim());
391
+ if (run.compileFailed) {
392
+ const out = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
393
+ const line = out.split(/\r?\n/).find((l) => /ERROR|error:|cannot find symbol|BUILD FAILURE/i.test(l));
394
+ return line ? redactSecrets(line.trim()) : "compile failure";
395
+ }
396
+ const stderr = String(run.stderr ?? "").trim();
397
+ return stderr ? redactSecrets(stderr.split("\n", 1)[0]) : null;
398
+ }
399
+
400
+ function redactSecrets(text) {
401
+ return String(text)
402
+ .replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET[_-]?KEY|PASSPHRASE|CREDENTIAL|PIN|AUTH|COOKIE|SESSION)[A-Z0-9_]*=)[^\s'"]+/gi, "$1[REDACTED]")
403
+ .replace(/(:\/\/[^:/@\s]+:)[^@/\s]+(@)/g, "$1[REDACTED]$2")
404
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]");
405
+ }
406
+
407
+ // Run the AST mutator (node java-mutate.mjs). It prints MUTATE_ERROR:<code> to
408
+ // stderr on refusal; we classify on that marker.
409
+ function mutateMethod({ targetAbs, method, mode, timeoutMs }) {
410
+ const helper = path.join(path.dirname(fileURLToPath(import.meta.url)), "java-mutate.mjs");
411
+ const result = spawnSync(process.execPath, [helper, "--file", targetAbs, "--func", method, "--mode", mode], {
412
+ encoding: "utf8",
413
+ timeout: timeoutMs,
414
+ maxBuffer: 8 * 1024 * 1024
415
+ });
416
+ const stderr = String(result.stderr ?? "");
417
+ const marker = /MUTATE_ERROR:(\d+)/.exec(stderr);
418
+ if (marker) {
419
+ return { ok: false, code: Number(marker[1]), message: redactSecrets(stderr.split("\n").filter(Boolean).slice(-1)[0] ?? "") };
420
+ }
421
+ if ((result.status ?? 1) !== 0) {
422
+ return { ok: false, code: 2, message: redactSecrets((stderr.split("\n", 1)[0] || "mutation failed").trim()) };
423
+ }
424
+ return { ok: true };
425
+ }
426
+
427
+ function mutateErrorReason(code) {
428
+ switch (code) {
429
+ case 3: return "target method name is ambiguous (more than one overload)";
430
+ case 4: return "target method was not found";
431
+ case 5: return "target is out of scope for J-1 (void, constructor, generic, type-variable return, or not a single top-level return)";
432
+ case 6: return "target return type has no type-compatible sentinel (not mutable)";
433
+ default: return "mutation could not be applied";
434
+ }
435
+ }
436
+
437
+ // A GREEN baseline requires the target testcase to pass AND `mvn` to exit 0. A run
438
+ // where the target testcase passes but the overall build is RED (another failing
439
+ // test in the class, a verify-phase failure, etc.) is NOT a clean baseline —
440
+ // treating it as green would let the mutant "flip" a run that was never trustworthy.
441
+ // Fail closed to unrunnable.
442
+ function baselinePassed(run) {
443
+ return !run.timedOut
444
+ && !run.compileFailed
445
+ && run.exitCode === 0
446
+ && Boolean(run.report?.targetCase?.passed);
447
+ }
448
+
449
+ function classify({ baseline, mutant }) {
450
+ // (a) baseline must compile AND the target test must pass.
451
+ if (!baselinePassed(baseline)) {
452
+ return { status: "unrunnable", proven: false, reason: "baseline target test did not compile+pass" };
453
+ }
454
+ // (b) mutant must compile.
455
+ if (mutant.compileFailed) {
456
+ return { status: "unrunnable", proven: false, reason: "mutant did not compile" };
457
+ }
458
+ if (mutant.timedOut) {
459
+ return { status: "unrunnable", proven: false, reason: "mutant timed out" };
460
+ }
461
+ const tc = mutant.report?.targetCase;
462
+ if (!tc) {
463
+ return { status: "unrunnable", proven: false, reason: "mutant produced no report for the target test" };
464
+ }
465
+ // Equivalent-value mutation: the target test still passes -> survives.
466
+ if (tc.passed) {
467
+ return { status: "associated_survived", proven: false, reason: "mutated target did not change the test outcome" };
468
+ }
469
+ // (d) a NON-assertion failure (<error>, or a <failure> that is not an assertion)
470
+ // is NOT a trusted signal — reject it.
471
+ if (tc.error) {
472
+ return { status: "unrunnable", proven: false, reason: `mutant threw ${tc.error.type ?? "a non-assertion error"}, not a test assertion` };
473
+ }
474
+ if (tc.failure && !isAssertionFailure(tc.failure)) {
475
+ return { status: "unrunnable", proven: false, reason: `mutant failed with ${tc.failure.type ?? "a non-assertion failure"}, not an assertion` };
476
+ }
477
+ // (c) the SAME target test failed with a trusted ASSERTION.
478
+ if (tc.failure && isAssertionFailure(tc.failure)) {
479
+ return { status: "proven", proven: true, reason: "baseline passed and the mutant failed the same target test with an assertion" };
480
+ }
481
+ return { status: "unrunnable", proven: false, reason: "mutant did not fail the target test with a trusted assertion" };
482
+ }
483
+
484
+ function summarizeRun(run) {
485
+ const tc = run.report?.targetCase ?? null;
486
+ return {
487
+ exitCode: run.exitCode,
488
+ timedOut: run.timedOut,
489
+ elapsedMs: run.elapsedMs,
490
+ compileFailed: run.compileFailed,
491
+ targetTestPassed: Boolean(tc?.passed),
492
+ targetTestFailed: Boolean(tc && !tc.passed && !tc.skipped),
493
+ failureType: tc?.failure?.type ?? tc?.error?.type ?? null,
494
+ isAssertion: tc?.failure ? isAssertionFailure(tc.failure) : false,
495
+ failureSummary: failureSummary(run)
496
+ };
497
+ }
498
+
499
+ function main() {
500
+ const args = parseArgs(process.argv.slice(2));
501
+ const root = path.resolve(args.root);
502
+ const targetAbs = resolveInside(root, args.target);
503
+ const targetRel = path.relative(root, targetAbs);
504
+ const timeoutMs = parseTimeoutMs(args.timeoutMs);
505
+
506
+ const baselineCopy = copyModuleRoot(root, "baseline");
507
+ const mutantCopy = copyModuleRoot(root, "mutant");
508
+ try {
509
+ const baseline = runSurefire({
510
+ repoRoot: baselineCopy.repoRoot,
511
+ testClass: args.testClass,
512
+ testMethod: args.testMethod,
513
+ timeoutMs,
514
+ cacheRoot: baselineCopy.tmpRoot,
515
+ mavenRepoLocal: args.mavenRepoLocal
516
+ });
517
+
518
+ const mutation = mutateMethod({
519
+ targetAbs: path.join(mutantCopy.repoRoot, targetRel),
520
+ method: args.method,
521
+ mode: args.mode,
522
+ timeoutMs
523
+ });
524
+
525
+ let verdict;
526
+ let mutant = null;
527
+ if (!mutation.ok) {
528
+ verdict = { status: "unrunnable", proven: false, reason: mutateErrorReason(mutation.code) };
529
+ } else {
530
+ mutant = runSurefire({
531
+ repoRoot: mutantCopy.repoRoot,
532
+ testClass: args.testClass,
533
+ testMethod: args.testMethod,
534
+ timeoutMs,
535
+ cacheRoot: mutantCopy.tmpRoot,
536
+ mavenRepoLocal: args.mavenRepoLocal
537
+ });
538
+ verdict = classify({ baseline, mutant });
539
+ }
540
+
541
+ const output = {
542
+ ...verdict,
543
+ mode: args.mode,
544
+ testClass: args.testClass,
545
+ testMethod: args.testMethod,
546
+ target: targetRel,
547
+ method: args.method,
548
+ baseline: summarizeRun(baseline),
549
+ mutant: mutant ? summarizeRun(mutant) : { skipped: true, reason: mutation.message ?? null },
550
+ medianProofMs: mutant ? Math.round((baseline.elapsedMs + mutant.elapsedMs) / 2) : baseline.elapsedMs
551
+ };
552
+
553
+ if (args.json) {
554
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
555
+ } else {
556
+ process.stdout.write(`${output.status}: ${output.reason}\n`);
557
+ process.stdout.write(`baseline=${baseline.exitCode} mutant=${mutant ? mutant.exitCode : "skipped"} median_ms=${output.medianProofMs}\n`);
558
+ }
559
+ process.exitCode = output.status === "unrunnable" ? 2 : 0;
560
+ } finally {
561
+ rmSync(baselineCopy.tmpRoot, { recursive: true, force: true });
562
+ rmSync(mutantCopy.tmpRoot, { recursive: true, force: true });
563
+ }
564
+ }
565
+
566
+ try {
567
+ main();
568
+ } catch (error) {
569
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${usage()}\n`);
570
+ process.exitCode = 1;
571
+ }