probatio 0.1.0 → 0.1.3

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.
@@ -386,8 +386,8 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
386
386
  if (spec.kind === "swift")
387
387
  return tag(runSwift(pkg, timeoutMs, env), spec.command, wantsNames, selection);
388
388
  if (spec.kind === "maven") {
389
- const tests = selection.map(surefireTest).filter((name) => name.length > 0);
390
- const args = ["-B", "test", ...(tests.length > 0 ? [`-Dtest=${tests.join(",")}`] : [])];
389
+ const tests = surefireArg(uniqueSurefire(selection));
390
+ const args = ["-B", "test", ...(tests.length > 0 ? [`-Dtest=${tests}`] : [])];
391
391
  const whole = wantsNames && tests.length === 0;
392
392
  // Surefire leaves the previous run's XML in place. A confirm that did not
393
393
  // re-execute a class would otherwise read that class as failed again.
@@ -400,8 +400,11 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
400
400
  }
401
401
  if (spec.kind === "dotnet-exe")
402
402
  return tag(runDotnetExe(pkg, spec, files, timeoutMs, env), spec.command, wantsNames, selection);
403
- if (spec.kind === "mocha")
404
- return tag(runMocha(pkg, files, timeoutMs, env), spec.command, wantsNames, selection);
403
+ if (spec.kind === "mocha") {
404
+ const grep = selection.length > 0 ? selection.map(escapeRegExp).join("|") : "";
405
+ const command = grep ? `${spec.command} --grep ${JSON.stringify(grep)}` : spec.command;
406
+ return tag(runMocha(pkg, files, timeoutMs, env, selection), command, false, selection);
407
+ }
405
408
  // A tty is only for a suite that runs `docker run -it` (tini). Everything else is bash.
406
409
  if (spec.kind === "script") {
407
410
  const launched = scriptInvocation(pkg);
@@ -465,12 +468,47 @@ function goRunFilter(names, pattern) {
465
468
  return null;
466
469
  return `^(${names.map(escapeRegExp).join("|")})$`;
467
470
  }
471
+ function uniqueSurefire(names) {
472
+ const out = [];
473
+ const seen = new Set();
474
+ for (const name of names) {
475
+ const selector = surefireTest(name);
476
+ if (!selector || seen.has(selector))
477
+ continue;
478
+ seen.add(selector);
479
+ out.push(selector);
480
+ }
481
+ return out;
482
+ }
483
+ /** Surefire 2.12 splits `-Dtest` on commas and then reads `#method+method`. `Class#a,Class#b` runs only `a`. */
484
+ export function surefireArg(tests) {
485
+ const groups = new Map();
486
+ for (const test of tests) {
487
+ const hash = test.indexOf("#");
488
+ const klass = hash === -1 ? test : test.slice(0, hash);
489
+ const method = hash === -1 ? "" : test.slice(hash + 1);
490
+ const methods = groups.get(klass);
491
+ if (methods) {
492
+ if (method)
493
+ methods.push(method);
494
+ }
495
+ else {
496
+ groups.set(klass, method ? [method] : []);
497
+ }
498
+ }
499
+ return [...groups.entries()]
500
+ .map(([klass, methods]) => (methods.length > 0 ? `${klass}#${methods.join("+")}` : klass))
501
+ .join(",");
502
+ }
468
503
  function surefireTest(name) {
469
504
  const leaf = name.split("::").pop() ?? name;
470
505
  const dot = leaf.lastIndexOf(".");
471
506
  const body = dot <= 0 ? leaf : `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
472
- // testShut[0] collects nothing. method(Parser)[1] keeps the parameter types.
473
- return body.replace(/\[[^\]]*\]$/, "");
507
+ // testShut[0] matches nothing. A bare testShut matches on Surefire 3.6 and runs
508
+ // zero tests on 3.5. testShut* matches every invocation on both. method(Parser)[1] keeps the types.
509
+ const invocation = /\[[^\]]*\]$/.test(body);
510
+ const method = body.replace(/\[[^\]]*\]$/, "");
511
+ return invocation ? `${method}*` : method;
474
512
  }
475
513
  function escapeRegExp(value) {
476
514
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -481,18 +519,46 @@ function runShell(pkg, command, timeoutMs, env) {
481
519
  const child = /\b(java|javac|mvn)\b/.test(command) ? javaEnv(env) : env;
482
520
  return spawnCollected("sh", ["-c", command], pkg, { ...child, PYTHONDONTWRITEBYTECODE: "1" }, timeoutMs);
483
521
  }
522
+ /**
523
+ * Node's `--test-timeout` caps the file, not only a test that left the option unset.
524
+ * A test that sets a longer `timeout` is cancelled, and the failure name is the file path.
525
+ * The flag stays at least that long, and it stays inside the suite cap.
526
+ */
527
+ export function nodeTestTimeoutMs(pkg, files, testTimeoutMs, suiteTimeoutMs) {
528
+ let declared = 0;
529
+ for (const rel of files) {
530
+ let text = "";
531
+ try {
532
+ text = readFileSync(path.join(pkg, rel), "utf8");
533
+ }
534
+ catch {
535
+ continue;
536
+ }
537
+ for (const match of text.matchAll(/timeout\s*:\s*(\d[\d_]*)/g)) {
538
+ const value = Number(match[1].replaceAll("_", ""));
539
+ if (Number.isFinite(value) && value > declared)
540
+ declared = value;
541
+ }
542
+ }
543
+ const floor = Number.isFinite(testTimeoutMs) && testTimeoutMs > 0 ? testTimeoutMs : 0;
544
+ let chosen = Math.max(floor, declared);
545
+ if (Number.isFinite(suiteTimeoutMs) && suiteTimeoutMs > 0 && chosen > suiteTimeoutMs)
546
+ chosen = suiteTimeoutMs;
547
+ return chosen > 0 ? chosen : floor;
548
+ }
484
549
  async function runNode(pkg, files, pattern, timeoutMs, env, hooks) {
485
550
  const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
486
551
  const bin = existsSync(tsx) ? tsx : process.execPath;
487
552
  const prefix = existsSync(tsx) ? [] : ["--experimental-strip-types"];
488
553
  const hook = env.PROBATIO_COVERAGE_MAP ? fileURLToPath(new URL("./node-coverage.mjs", import.meta.url)) : null;
554
+ const fileTimeout = nodeTestTimeoutMs(pkg, files, hooks.testTimeoutMs, timeoutMs);
489
555
  const result = await spawnCollected(bin, [
490
556
  ...prefix,
491
557
  ...(hook ? ["--import", hook] : []),
492
558
  "--test",
493
559
  `--test-reporter=${hooks.reporterPath}`,
494
560
  `--test-concurrency=${hooks.concurrency}`,
495
- `--test-timeout=${hooks.testTimeoutMs}`,
561
+ `--test-timeout=${fileTimeout}`,
496
562
  ...(pattern ? [`--test-name-pattern=${pattern}`] : []),
497
563
  ...files,
498
564
  ], pkg, env, timeoutMs);
@@ -545,7 +611,7 @@ function runPython(pkg, files, pattern, timeoutMs, env, script, nodeIds) {
545
611
  const targets = nodeIds && nodeIds.length > 0 ? nodeIds : files;
546
612
  return spawnCollected("python3", [reporter, ...targets, ...(pattern ? ["--pattern", pattern] : [])], pkg, next, timeoutMs);
547
613
  }
548
- async function runMocha(pkg, files, timeoutMs, env) {
614
+ async function runMocha(pkg, files, timeoutMs, env, names = null) {
549
615
  const mocha = ["mocha.js", "mocha"]
550
616
  .map((name) => path.join(pkg, "node_modules", "mocha", "bin", name))
551
617
  .find((file) => existsSync(file));
@@ -556,6 +622,10 @@ async function runMocha(pkg, files, timeoutMs, env) {
556
622
  return prepared;
557
623
  const config = readMochaConfig(pkg);
558
624
  const args = [mocha, ...buildMochaArgs(pkg, files)];
625
+ if (names && names.length > 0)
626
+ args.push("--grep", names.map(escapeRegExp).join("|"));
627
+ if (env.PROBATIO_COVERAGE_MAP)
628
+ args.push("--require", fileURLToPath(new URL("./mocha-coverage.cjs", import.meta.url)));
559
629
  if (config && config.require.includes("esm")) {
560
630
  // The `esm` loader throws on current Node before any test runs. Drop that one require and keep the rest of the project's mocha config.
561
631
  const requires = config.require.filter((name) => name !== "esm");
@@ -729,7 +799,8 @@ function scriptInvocation(pkg) {
729
799
  if (!suiteNeedsTty(pkg))
730
800
  return { bin: "bash", args: ["run_tests.sh"], command: "bash run_tests.sh" };
731
801
  if (gnuScript()) {
732
- const args = ["-q", "-c", "bash run_tests.sh", "/dev/null"];
802
+ // -e returns the child status. Without it, util-linux script exits 0 and a failing suite looks green.
803
+ const args = ["-q", "-e", "-c", "bash run_tests.sh", "/dev/null"];
733
804
  return { bin: "script", args, command: shown("script", args) };
734
805
  }
735
806
  const args = ["-q", "/dev/null", "bash", "run_tests.sh"];
@@ -1368,6 +1439,11 @@ function mavenReport(pkg, text, code) {
1368
1439
  }
1369
1440
  return parseSurefire(pkg) ?? parseMavenText(text);
1370
1441
  }
1442
+ /** An attribute on the opening tag. `classname` must not be read as `name`. */
1443
+ export function surefireAttr(fragment, key) {
1444
+ const open = fragment.slice(0, fragment.indexOf(">") === -1 ? fragment.length : fragment.indexOf(">"));
1445
+ return new RegExp(`(?:^|\\s)${key}="([^"]*)"`).exec(open)?.[1] ?? "";
1446
+ }
1371
1447
  function parseSurefire(pkg) {
1372
1448
  const dir = path.join(pkg, "target", "surefire-reports");
1373
1449
  if (!existsSync(dir))
@@ -1379,11 +1455,11 @@ function parseSurefire(pkg) {
1379
1455
  continue;
1380
1456
  const xml = readFileSync(path.join(dir, name), "utf8");
1381
1457
  for (const part of xml.split("<testcase ").slice(1)) {
1382
- const test = /name="([^"]+)"/.exec(part)?.[1];
1383
- const klass = /classname="([^"]+)"/.exec(part)?.[1] ?? "";
1458
+ const test = surefireAttr(part, "name");
1459
+ const klass = surefireAttr(part, "classname");
1384
1460
  if (!test)
1385
1461
  continue;
1386
- const id = `${klass}.${test}`;
1462
+ const id = klass ? `${klass}.${test}` : test;
1387
1463
  names.push(id);
1388
1464
  if (part.includes("<failure") || part.includes("<error"))
1389
1465
  failed.push({ name: id, file: klass, line: 0 });
@@ -105,7 +105,7 @@ export function tallyRun(outDir) {
105
105
  command: "mutate.tally",
106
106
  summary,
107
107
  next,
108
- nextCall: keep.length > 0 ? { argv: ["mutate", "run", ...keep.flatMap((id) => ["--only-test", id])] } : null,
108
+ nextCall: resumeArgv(outDir, keep),
109
109
  keep,
110
110
  drop,
111
111
  gaps,
@@ -117,6 +117,43 @@ export function tallyRun(outDir) {
117
117
  writeFileSync(fullPath, `${JSON.stringify(complete, null, 2)}\n`);
118
118
  return { ...complete, gaps: shown, rest: gaps.length - shown.length };
119
119
  }
120
+ function resumeArgv(outDir, keep) {
121
+ if (keep.length === 0)
122
+ return null;
123
+ let stored = {};
124
+ try {
125
+ stored = JSON.parse(readFileSync(path.join(outDir, "run.json"), "utf8"));
126
+ }
127
+ catch {
128
+ stored = {};
129
+ }
130
+ const argv = ["mutate", "run"];
131
+ if (stored.package)
132
+ argv.push("--package", stored.package);
133
+ for (const dir of stored.patches ?? [])
134
+ argv.push("--patches", dir);
135
+ argv.push("--out", stored.out || outDir);
136
+ if (stored.repo)
137
+ argv.push("--repo", stored.repo);
138
+ if (stored.commit)
139
+ argv.push("--commit", stored.commit);
140
+ if (typeof stored.workers === "number")
141
+ argv.push("--workers", String(stored.workers));
142
+ if (stored.confirm === false)
143
+ argv.push("--no-confirm");
144
+ if (stored.testsDir && stored.testsDir !== "tests")
145
+ argv.push("--tests-dir", stored.testsDir);
146
+ if (typeof stored.suiteTimeoutMs === "number")
147
+ argv.push("--suite-timeout-ms", String(stored.suiteTimeoutMs));
148
+ if (typeof stored.testTimeoutMs === "number")
149
+ argv.push("--test-timeout-ms", String(stored.testTimeoutMs));
150
+ if (stored.direction && stored.direction !== "auto")
151
+ argv.push("--direction", stored.direction);
152
+ if (stored.suiteCommand)
153
+ argv.push("--suite-command", stored.suiteCommand);
154
+ argv.push(...keep.flatMap((id) => ["--only-test", id]));
155
+ return { argv };
156
+ }
120
157
  function readResults(dir) {
121
158
  const out = [];
122
159
  for (const name of readdirSync(dir)) {
@@ -0,0 +1,13 @@
1
+ /** `--experimental-strip-types` needs this. Older Node is refused before a command runs. */
2
+ export const MIN_NODE = "22.6";
3
+ export function nodeTooOld(version) {
4
+ const match = /^(\d+)\.(\d+)/.exec(version);
5
+ const required = `Node ${version} is too old. Probatio needs Node ${MIN_NODE} or newer.`;
6
+ if (!match)
7
+ return required;
8
+ const major = Number(match[1]);
9
+ const minor = Number(match[2]);
10
+ if (major > 22 || (major === 22 && minor >= 6))
11
+ return null;
12
+ return required;
13
+ }
@@ -1,13 +1,19 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { loadSealed } from "../memory/state.js";
4
+ /** One path segment. Rejects slashes and `..` so a claim cannot leave the queue directory. */
5
+ export function safeFilePart(value) {
6
+ if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..")
7
+ return null;
8
+ return value;
9
+ }
4
10
  export function seedQueue(root, ids) {
5
11
  const sealed = new Set(loadSealed(root));
6
12
  const queue = path.join(root, "queue");
7
13
  mkdirSync(queue, { recursive: true });
8
14
  const written = [];
9
15
  for (const id of [...ids].sort()) {
10
- if (sealed.has(id))
16
+ if (sealed.has(id) || !safeFilePart(id))
11
17
  continue;
12
18
  const dest = path.join(queue, `${id}.json`);
13
19
  if (existsSync(dest))
@@ -19,23 +25,23 @@ export function seedQueue(root, ids) {
19
25
  }
20
26
  /** Claim by atomic rename. A second claim fails because the queue file is already gone. */
21
27
  export function claimItem(root, id, agent, now, leaseMs) {
28
+ if (!safeFilePart(id) || !safeFilePart(agent))
29
+ return { ok: false, summary: "agent and id must be a single file name.", file: null };
22
30
  const source = path.join(root, "queue", `${id}.json`);
23
31
  const claimed = path.join(root, "claimed");
24
32
  const dest = path.join(claimed, `${agent}-${id}.json`);
25
33
  if (!existsSync(source))
26
34
  return { ok: false, summary: `${id} is not in the queue.`, file: null };
27
35
  mkdirSync(claimed, { recursive: true });
28
- const body = JSON.parse(readFileSync(source, "utf8"));
29
- body.id = id;
30
- body.agent = agent;
31
- body.leasedUntil = now + leaseMs;
32
- writeFileSync(source, `${JSON.stringify(body)}\n`);
36
+ // Rename first. Writing the body before the rename lets the other claim overwrite the winner.
33
37
  try {
34
38
  renameSync(source, dest);
35
39
  }
36
40
  catch {
37
41
  return { ok: false, summary: `${id} was claimed by someone else.`, file: null };
38
42
  }
43
+ const body = { id, agent, leasedUntil: now + leaseMs };
44
+ writeFileSync(dest, `${JSON.stringify(body)}\n`);
39
45
  return { ok: true, summary: `${agent} claimed ${id} until ${body.leasedUntil}.`, file: dest };
40
46
  }
41
47
  /** Move claims whose lease has expired back to the queue. */
@@ -53,7 +59,7 @@ export function reapClaims(root, now) {
53
59
  const body = JSON.parse(readFileSync(file, "utf8"));
54
60
  if (typeof body.leasedUntil !== "number" || body.leasedUntil > now)
55
61
  continue;
56
- if (!body.id)
62
+ if (!body.id || !safeFilePart(body.id))
57
63
  continue;
58
64
  renameSync(file, path.join(queue, `${body.id}.json`));
59
65
  returned.push(body.id);
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { SCHEMA_VERSION } from "../contract.js";
4
4
  import { asTable } from "../golden/check.js";
@@ -23,12 +23,18 @@ export async function verifyChange(options) {
23
23
  const ranges = parseUnified(diff.stdout);
24
24
  const names = git(repo, ["diff", "--name-only", "--no-renames", options.base, options.commit, ...scope]);
25
25
  const changed = names.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
26
+ const status = git(repo, ["status", "--porcelain", ...scope]);
27
+ const dirty = status.stdout.trim().length > 0;
28
+ if (dirty && (options.base === options.commit || changed.length === 0)) {
29
+ return fail(options.outDir, "The working tree has uncommitted changes. verify-change did not score them. Commit the edit, or pass --base and --commit that name different commits with a diff.");
30
+ }
26
31
  const goldenContract = goldenDiffs(repo, options.base, options.commit, changed);
27
32
  const packageFiles = [];
28
33
  const chosen = [];
34
+ const libImported = testsImportLib(packageDir);
29
35
  for (const [file, lines] of ranges) {
30
36
  const rel = toPackage(prefix, file);
31
- if (!rel || !rel.startsWith("src/") || rel.endsWith(".d.ts"))
37
+ if (!rel || !isVerifySource(rel, packageDir, libImported))
32
38
  continue;
33
39
  packageFiles.push(rel);
34
40
  const text = showFile(repo, options.commit, file);
@@ -269,3 +275,66 @@ function toPackage(prefix, file) {
269
275
  return null;
270
276
  return file.slice(prefix.length + 1);
271
277
  }
278
+ const SKIPPED_SOURCE = new Set(["node_modules", "dist", "docs", "tests", "test"]);
279
+ /** src/, a lib/ tree the tests import, or a Go file beside go.mod. Tests, docs, dist, and node_modules stay out. */
280
+ function isVerifySource(rel, packageDir, libImported) {
281
+ if (rel.endsWith(".d.ts"))
282
+ return false;
283
+ const parts = rel.split("/");
284
+ if (parts.some((part) => SKIPPED_SOURCE.has(part)))
285
+ return false;
286
+ if (rel.startsWith("src/"))
287
+ return true;
288
+ if (libImported && rel.startsWith("lib/"))
289
+ return true;
290
+ if (!rel.includes("/") && rel.endsWith(".go") && !rel.endsWith("_test.go") && existsSync(path.join(packageDir, "go.mod")))
291
+ return true;
292
+ return false;
293
+ }
294
+ function testsImportLib(packageDir) {
295
+ const pattern = /(?:require\s*\(\s*|from\s+)['"](?:@[^'"]+\/)?(?:\.\.\/|\.\/)*lib\//;
296
+ for (const dirName of ["tests", "test"]) {
297
+ const dir = path.join(packageDir, dirName);
298
+ if (!existsSync(dir))
299
+ continue;
300
+ if (walkText(dir).some((text) => pattern.test(text)))
301
+ return true;
302
+ }
303
+ return false;
304
+ }
305
+ function walkText(dir) {
306
+ const out = [];
307
+ const visit = (current) => {
308
+ let entries;
309
+ try {
310
+ entries = readdirSync(current);
311
+ }
312
+ catch {
313
+ return;
314
+ }
315
+ for (const name of entries) {
316
+ if (name === "node_modules" || name === "dist")
317
+ continue;
318
+ const full = path.join(current, name);
319
+ let info;
320
+ try {
321
+ info = statSync(full);
322
+ }
323
+ catch {
324
+ continue;
325
+ }
326
+ if (info.isDirectory())
327
+ visit(full);
328
+ else if (info.isFile() && info.size < 1_000_000) {
329
+ try {
330
+ out.push(readFileSync(full, "utf8"));
331
+ }
332
+ catch {
333
+ // An unreadable test file does not count as an import of lib/.
334
+ }
335
+ }
336
+ }
337
+ };
338
+ visit(dir);
339
+ return out;
340
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "probatio": "dist/cli.js"
7
+ "probatio": "dist/cli.js",
8
+ "probatio-mcp": "dist/mcp.js"
8
9
  },
9
10
  "files": [
10
11
  "dist"
@@ -19,12 +20,13 @@
19
20
  "url": "https://github.com/IronAdamant/Probatio/issues"
20
21
  },
21
22
  "scripts": {
22
- "build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
23
+ "build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/precise-lines.mjs src/mutate/child-lines.mjs src/mutate/mocha-coverage.cjs src/mutate/node-batch.mjs src/mutate/node-batch-loader.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
24
+ "prepack": "npm run build",
23
25
  "test": "tsx --test tests/*.test.ts",
24
26
  "probatio": "tsx src/cli.ts"
25
27
  },
26
28
  "engines": {
27
- "node": ">=22"
29
+ "node": ">=22.6"
28
30
  },
29
31
  "dependencies": {
30
32
  "typescript": "^5.7.2"