probatio 0.0.5 → 0.1.2

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.
@@ -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);
@@ -435,6 +438,10 @@ function pytestLeaf(name) {
435
438
  function pytestPlan(selection) {
436
439
  if (selection.length === 0)
437
440
  return { pattern: null, nodeIds: null };
441
+ // A doubled keep id (file.py::tests/file.py::test_low) has more than one ::.
442
+ // Reducing it to the leaf would run -k test_low and still kill the mutant.
443
+ if (selection.some((name) => name.split("::").length > 2))
444
+ return { pattern: null, nodeIds: selection };
438
445
  const leaves = selection.map(pytestLeaf);
439
446
  if (leaves.every((leaf) => PYTEST_KEYWORD.test(leaf)))
440
447
  return { pattern: leaves.join(" or "), nodeIds: null };
@@ -464,9 +471,9 @@ function goRunFilter(names, pattern) {
464
471
  function surefireTest(name) {
465
472
  const leaf = name.split("::").pop() ?? name;
466
473
  const dot = leaf.lastIndexOf(".");
467
- if (dot <= 0)
468
- return leaf;
469
- return `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
474
+ const body = dot <= 0 ? leaf : `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
475
+ // testShut[0] collects nothing. method(Parser)[1] keeps the parameter types.
476
+ return body.replace(/\[[^\]]*\]$/, "");
470
477
  }
471
478
  function escapeRegExp(value) {
472
479
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -477,18 +484,46 @@ function runShell(pkg, command, timeoutMs, env) {
477
484
  const child = /\b(java|javac|mvn)\b/.test(command) ? javaEnv(env) : env;
478
485
  return spawnCollected("sh", ["-c", command], pkg, { ...child, PYTHONDONTWRITEBYTECODE: "1" }, timeoutMs);
479
486
  }
487
+ /**
488
+ * Node's `--test-timeout` caps the file, not only a test that left the option unset.
489
+ * A test that sets a longer `timeout` is cancelled, and the failure name is the file path.
490
+ * The flag stays at least that long, and it stays inside the suite cap.
491
+ */
492
+ export function nodeTestTimeoutMs(pkg, files, testTimeoutMs, suiteTimeoutMs) {
493
+ let declared = 0;
494
+ for (const rel of files) {
495
+ let text = "";
496
+ try {
497
+ text = readFileSync(path.join(pkg, rel), "utf8");
498
+ }
499
+ catch {
500
+ continue;
501
+ }
502
+ for (const match of text.matchAll(/timeout\s*:\s*(\d[\d_]*)/g)) {
503
+ const value = Number(match[1].replaceAll("_", ""));
504
+ if (Number.isFinite(value) && value > declared)
505
+ declared = value;
506
+ }
507
+ }
508
+ const floor = Number.isFinite(testTimeoutMs) && testTimeoutMs > 0 ? testTimeoutMs : 0;
509
+ let chosen = Math.max(floor, declared);
510
+ if (Number.isFinite(suiteTimeoutMs) && suiteTimeoutMs > 0 && chosen > suiteTimeoutMs)
511
+ chosen = suiteTimeoutMs;
512
+ return chosen > 0 ? chosen : floor;
513
+ }
480
514
  async function runNode(pkg, files, pattern, timeoutMs, env, hooks) {
481
515
  const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
482
516
  const bin = existsSync(tsx) ? tsx : process.execPath;
483
517
  const prefix = existsSync(tsx) ? [] : ["--experimental-strip-types"];
484
518
  const hook = env.PROBATIO_COVERAGE_MAP ? fileURLToPath(new URL("./node-coverage.mjs", import.meta.url)) : null;
519
+ const fileTimeout = nodeTestTimeoutMs(pkg, files, hooks.testTimeoutMs, timeoutMs);
485
520
  const result = await spawnCollected(bin, [
486
521
  ...prefix,
487
522
  ...(hook ? ["--import", hook] : []),
488
523
  "--test",
489
524
  `--test-reporter=${hooks.reporterPath}`,
490
525
  `--test-concurrency=${hooks.concurrency}`,
491
- `--test-timeout=${hooks.testTimeoutMs}`,
526
+ `--test-timeout=${fileTimeout}`,
492
527
  ...(pattern ? [`--test-name-pattern=${pattern}`] : []),
493
528
  ...files,
494
529
  ], pkg, env, timeoutMs);
@@ -541,7 +576,7 @@ function runPython(pkg, files, pattern, timeoutMs, env, script, nodeIds) {
541
576
  const targets = nodeIds && nodeIds.length > 0 ? nodeIds : files;
542
577
  return spawnCollected("python3", [reporter, ...targets, ...(pattern ? ["--pattern", pattern] : [])], pkg, next, timeoutMs);
543
578
  }
544
- async function runMocha(pkg, files, timeoutMs, env) {
579
+ async function runMocha(pkg, files, timeoutMs, env, names = null) {
545
580
  const mocha = ["mocha.js", "mocha"]
546
581
  .map((name) => path.join(pkg, "node_modules", "mocha", "bin", name))
547
582
  .find((file) => existsSync(file));
@@ -552,6 +587,10 @@ async function runMocha(pkg, files, timeoutMs, env) {
552
587
  return prepared;
553
588
  const config = readMochaConfig(pkg);
554
589
  const args = [mocha, ...buildMochaArgs(pkg, files)];
590
+ if (names && names.length > 0)
591
+ args.push("--grep", names.map(escapeRegExp).join("|"));
592
+ if (env.PROBATIO_COVERAGE_MAP)
593
+ args.push("--require", fileURLToPath(new URL("./mocha-coverage.cjs", import.meta.url)));
555
594
  if (config && config.require.includes("esm")) {
556
595
  // 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.
557
596
  const requires = config.require.filter((name) => name !== "esm");
@@ -1148,6 +1187,9 @@ function adapt(kind, pkg, stdout, stderr, code) {
1148
1187
  return parseMocha(stdout);
1149
1188
  if (kind === "make-test" || kind === "command") {
1150
1189
  const text = `${stdout}\n${stderr}`;
1190
+ const ctest = parseCtest(text);
1191
+ if (ctest)
1192
+ return ctest;
1151
1193
  if (/Tests run:\s*\d+/.test(text))
1152
1194
  return parseMakeTest(text);
1153
1195
  if (kind === "make-test")
@@ -1267,6 +1309,32 @@ function parseMocha(stdout) {
1267
1309
  return null;
1268
1310
  }
1269
1311
  }
1312
+ /** CTest prints `Test #N: name` and `N - name (Failed)`. Those names are the kill, not `::command`. */
1313
+ function parseCtest(text) {
1314
+ if (!/Test #\d+:|The following tests FAILED:/.test(text))
1315
+ return null;
1316
+ const names = [];
1317
+ const failed = [];
1318
+ for (const raw of text.split(/\r?\n/)) {
1319
+ const line = raw.trim();
1320
+ const ran = /^\d+\/\d+\s+Test\s+#\d+:\s+(\S+)/.exec(line);
1321
+ if (ran && !names.includes(ran[1]))
1322
+ names.push(ran[1]);
1323
+ if (ran && /\*\*\*Failed/.test(line) && !failed.some((item) => item.name === ran[1])) {
1324
+ failed.push({ name: ran[1], file: "", line: 0 });
1325
+ }
1326
+ const listed = /^\d+\s+-\s+(\S+)\s+\(Failed\)/.exec(line);
1327
+ if (listed && !failed.some((item) => item.name === listed[1]))
1328
+ failed.push({ name: listed[1], file: "", line: 0 });
1329
+ }
1330
+ if (names.length === 0 && failed.length === 0)
1331
+ return null;
1332
+ for (const item of failed) {
1333
+ if (!names.includes(item.name))
1334
+ names.push(item.name);
1335
+ }
1336
+ return { tests: names.length, pass: Math.max(names.length - failed.length, 0), fail: failed.length, failed, names };
1337
+ }
1270
1338
  function parseMakeTest(text) {
1271
1339
  const run = text.match(/Tests run:\s*(\d+)/);
1272
1340
  if (!run)
@@ -105,7 +105,7 @@ export function tallyRun(outDir) {
105
105
  command: "mutate.tally",
106
106
  summary,
107
107
  next,
108
- nextCall: null,
108
+ nextCall: keep.length > 0 ? { argv: ["mutate", "run", ...keep.flatMap((id) => ["--only-test", id])] } : null,
109
109
  keep,
110
110
  drop,
111
111
  gaps,
@@ -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";
@@ -7,7 +7,7 @@ import { findInSource } from "../mutate/find.js";
7
7
  import { mutantId } from "../mutate/ids.js";
8
8
  import { forwardDiff, git } from "../mutate/patch.js";
9
9
  import { runMutants } from "../mutate/run.js";
10
- import { leadSummary } from "../mutate/suite-decision.js";
10
+ import { WHOLE_PROGRAM_TEST, leadSummary } from "../mutate/suite-decision.js";
11
11
  import { discoverSuite } from "../mutate/suites.js";
12
12
  /** Diff-scoped mutants, the tests that can see the edit, and golden contract rows that changed. */
13
13
  export async function verifyChange(options) {
@@ -26,9 +26,10 @@ export async function verifyChange(options) {
26
26
  const goldenContract = goldenDiffs(repo, options.base, options.commit, changed);
27
27
  const packageFiles = [];
28
28
  const chosen = [];
29
+ const libImported = testsImportLib(packageDir);
29
30
  for (const [file, lines] of ranges) {
30
31
  const rel = toPackage(prefix, file);
31
- if (!rel || !rel.startsWith("src/") || rel.endsWith(".d.ts"))
32
+ if (!rel || !isVerifySource(rel, packageDir, libImported))
32
33
  continue;
33
34
  packageFiles.push(rel);
34
35
  const text = showFile(repo, options.commit, file);
@@ -102,15 +103,17 @@ export async function verifyChange(options) {
102
103
  other = results
103
104
  .filter((item) => item.outcome !== "killed" && item.outcome !== "survived")
104
105
  .map((item) => ({ id: item.id, outcome: item.outcome, file: byId.get(item.id)?.file ?? "", line: byId.get(item.id)?.line ?? 0 }));
106
+ const executed = executedNames(results);
107
+ const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed);
108
+ writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
109
+ return envelope;
105
110
  }
106
- else {
107
- notRun.push(...written);
108
- }
109
- const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand);
111
+ notRun.push(...written);
112
+ const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, []);
110
113
  writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
111
114
  return envelope;
112
115
  }
113
- function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
116
+ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed) {
114
117
  const uncovered = other.filter((item) => item.outcome === "no coverage");
115
118
  const summary = ok
116
119
  ? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
@@ -127,6 +130,8 @@ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, golden
127
130
  ran,
128
131
  notRan,
129
132
  suiteCommand,
133
+ executed,
134
+ confirmed: false,
130
135
  caught,
131
136
  missed,
132
137
  other,
@@ -158,6 +163,8 @@ function fail(outDir, summary, extra) {
158
163
  ran: extra?.ran ?? [],
159
164
  notRan: extra?.notRan ?? [],
160
165
  suiteCommand: extra?.suiteCommand ?? "",
166
+ executed: [],
167
+ confirmed: false,
161
168
  caught: [],
162
169
  missed: [],
163
170
  other: [],
@@ -173,14 +180,17 @@ function readOutcomes(outDir, ids) {
173
180
  for (const id of ids) {
174
181
  try {
175
182
  const result = JSON.parse(readFileSync(path.join(file, `${id}.json`), "utf8"));
176
- out.push({ id, outcome: result.outcome });
183
+ out.push({ id, outcome: result.outcome, selectedTests: result.selectedTests ?? [] });
177
184
  }
178
185
  catch {
179
- out.push({ id, outcome: "missing" });
186
+ out.push({ id, outcome: "missing", selectedTests: [] });
180
187
  }
181
188
  }
182
189
  return out;
183
190
  }
191
+ function executedNames(results) {
192
+ return [...new Set(results.flatMap((item) => item.selectedTests))].filter((name) => name.length > 0 && name !== WHOLE_PROGRAM_TEST).sort();
193
+ }
184
194
  function goldenDiffs(repo, base, commit, files) {
185
195
  const found = [];
186
196
  for (const file of files) {
@@ -260,3 +270,66 @@ function toPackage(prefix, file) {
260
270
  return null;
261
271
  return file.slice(prefix.length + 1);
262
272
  }
273
+ const SKIPPED_SOURCE = new Set(["node_modules", "dist", "docs", "tests", "test"]);
274
+ /** src/, a lib/ tree the tests import, or a Go file beside go.mod. Tests, docs, dist, and node_modules stay out. */
275
+ function isVerifySource(rel, packageDir, libImported) {
276
+ if (rel.endsWith(".d.ts"))
277
+ return false;
278
+ const parts = rel.split("/");
279
+ if (parts.some((part) => SKIPPED_SOURCE.has(part)))
280
+ return false;
281
+ if (rel.startsWith("src/"))
282
+ return true;
283
+ if (libImported && rel.startsWith("lib/"))
284
+ return true;
285
+ if (!rel.includes("/") && rel.endsWith(".go") && !rel.endsWith("_test.go") && existsSync(path.join(packageDir, "go.mod")))
286
+ return true;
287
+ return false;
288
+ }
289
+ function testsImportLib(packageDir) {
290
+ const pattern = /(?:require\s*\(\s*|from\s+)['"](?:@[^'"]+\/)?(?:\.\.\/|\.\/)*lib\//;
291
+ for (const dirName of ["tests", "test"]) {
292
+ const dir = path.join(packageDir, dirName);
293
+ if (!existsSync(dir))
294
+ continue;
295
+ if (walkText(dir).some((text) => pattern.test(text)))
296
+ return true;
297
+ }
298
+ return false;
299
+ }
300
+ function walkText(dir) {
301
+ const out = [];
302
+ const visit = (current) => {
303
+ let entries;
304
+ try {
305
+ entries = readdirSync(current);
306
+ }
307
+ catch {
308
+ return;
309
+ }
310
+ for (const name of entries) {
311
+ if (name === "node_modules" || name === "dist")
312
+ continue;
313
+ const full = path.join(current, name);
314
+ let info;
315
+ try {
316
+ info = statSync(full);
317
+ }
318
+ catch {
319
+ continue;
320
+ }
321
+ if (info.isDirectory())
322
+ visit(full);
323
+ else if (info.isFile() && info.size < 1_000_000) {
324
+ try {
325
+ out.push(readFileSync(full, "utf8"));
326
+ }
327
+ catch {
328
+ // An unreadable test file does not count as an import of lib/.
329
+ }
330
+ }
331
+ }
332
+ };
333
+ visit(dir);
334
+ return out;
335
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.0.5",
3
+ "version": "0.1.2",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "url": "https://github.com/IronAdamant/Probatio/issues"
20
20
  },
21
21
  "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/",
22
+ "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/",
23
23
  "test": "tsx --test tests/*.test.ts",
24
24
  "probatio": "tsx src/cli.ts"
25
25
  },