@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.
- package/LICENSE +1 -1
- package/README.md +195 -251
- package/dist/local/analyze/analyzer.js +62 -17
- package/dist/local/analyze/treeSitter/engine.js +185 -22
- package/dist/local/autoProve.js +433 -38
- package/dist/local/cli.js +93 -10
- package/dist/local/cliArgs.js +1 -0
- package/dist/local/generate/runHints.js +9 -4
- package/dist/local/graph/factories.js +5 -2
- package/dist/local/ledger.js +1 -1
- package/dist/local/mcp.js +26 -13
- package/dist/local/operations.js +454 -52
- package/dist/local/pack/coverageReport.js +3 -3
- package/dist/local/proofDoctor.js +312 -0
- package/dist/local/rtm.js +40 -10
- package/dist/local/viz/behaviorReportData.js +79 -7
- package/dist/local/viz/behaviorReportHtml.js +526 -615
- package/dist/local/viz/html.js +1 -1
- package/docs/agent-workflow.md +10 -38
- package/docs/agents/claude-code.md +3 -9
- package/docs/agents/codex.md +3 -20
- package/docs/agents/cursor.md +2 -2
- package/docs/agents/opencode.md +2 -2
- package/docs/agents/vscode.md +2 -2
- package/docs/local-proof-kit.md +52 -19
- package/package.json +39 -6
- package/scripts/spikes/go-dynamic-proof-spike.mjs +637 -0
- package/scripts/spikes/go-mutate.go +182 -0
- package/scripts/spikes/java-dynamic-proof-spike.mjs +571 -0
- package/scripts/spikes/java-mutate.mjs +264 -0
- package/scripts/spikes/python-dynamic-proof-spike.mjs +245 -0
- package/scripts/spikes/python-mutate.py +89 -0
|
@@ -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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
+
}
|