probatio 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +93 -8
  2. package/dist/cli.js +136 -6
  3. package/dist/contract.js +2 -1
  4. package/dist/golden/record.js +395 -0
  5. package/dist/golden/recorder.mjs +78 -0
  6. package/dist/ledger/build.js +21 -0
  7. package/dist/ledger/check.js +133 -0
  8. package/dist/mcp.js +76 -27
  9. package/dist/mutate/child-lines.mjs +23 -18
  10. package/dist/mutate/fail-reporter.mjs +21 -0
  11. package/dist/mutate/find.js +3 -3
  12. package/dist/mutate/generate.js +5 -2
  13. package/dist/mutate/load-failure.mjs +40 -0
  14. package/dist/mutate/mocha-coverage.cjs +7 -1
  15. package/dist/mutate/node-batch-loader.mjs +15 -1
  16. package/dist/mutate/node-batch.mjs +40 -3
  17. package/dist/mutate/node-coverage.mjs +7 -32
  18. package/dist/mutate/operators.js +60 -1
  19. package/dist/mutate/patch.js +2 -1
  20. package/dist/mutate/precise-lines.mjs +147 -31
  21. package/dist/mutate/probatio-jacoco-run.java +2 -2
  22. package/dist/mutate/project-config.js +58 -0
  23. package/dist/mutate/run.js +100 -19
  24. package/dist/mutate/sealed.js +54 -0
  25. package/dist/mutate/suite-decision.js +2 -1
  26. package/dist/mutate/suites.js +87 -155
  27. package/dist/mutate/tally.js +54 -26
  28. package/dist/mutate/text-operators.js +92 -2
  29. package/dist/swarm/check-kill.js +14 -7
  30. package/dist/verify/change.js +1 -1
  31. package/package.json +8 -4
  32. package/schemas/check-kill.schema.json +93 -0
  33. package/schemas/findings.add.schema.json +53 -0
  34. package/schemas/gap.fix.schema.json +53 -0
  35. package/schemas/gap.revert.schema.json +53 -0
  36. package/schemas/golden.check.schema.json +89 -0
  37. package/schemas/golden.compare.schema.json +151 -0
  38. package/schemas/golden.record.schema.json +126 -0
  39. package/schemas/guard.check.schema.json +53 -0
  40. package/schemas/ledger.build.schema.json +132 -0
  41. package/schemas/ledger.check.schema.json +143 -0
  42. package/schemas/matrix.report.schema.json +129 -0
  43. package/schemas/mcp.schema.json +53 -0
  44. package/schemas/mutate.generate.schema.json +127 -0
  45. package/schemas/mutate.help.schema.json +53 -0
  46. package/schemas/mutate.run.schema.json +272 -0
  47. package/schemas/mutate.sealed.schema.json +53 -0
  48. package/schemas/mutate.tally.schema.json +137 -0
  49. package/schemas/queue.claim.schema.json +53 -0
  50. package/schemas/queue.reap.schema.json +71 -0
  51. package/schemas/queue.seed.schema.json +71 -0
  52. package/schemas/schema.schema.json +74 -0
  53. package/schemas/seal.schema.json +69 -0
  54. package/schemas/status.schema.json +103 -0
  55. package/schemas/verify-change.schema.json +222 -0
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -7,7 +7,7 @@ import { nodeTooOld } from "./node-version.js";
7
7
  const tooOld = nodeTooOld(process.versions.node);
8
8
  if (tooOld) {
9
9
  process.stdout.write(`${JSON.stringify({
10
- schemaVersion: 1,
10
+ schemaVersion: 2,
11
11
  ok: false,
12
12
  command: "mcp",
13
13
  summary: tooOld,
@@ -16,6 +16,8 @@ if (tooOld) {
16
16
  })}\n`);
17
17
  process.exit(1);
18
18
  }
19
+ /** Protocol versions this server speaks. The client's choice wins when it is one of these. */
20
+ const PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18"];
19
21
  const here = path.dirname(fileURLToPath(import.meta.url));
20
22
  const version = readVersion();
21
23
  const tool = {
@@ -28,6 +30,9 @@ const tool = {
28
30
  },
29
31
  };
30
32
  let buffer = "";
33
+ let closing = false;
34
+ /** Tool calls still running, by request id. A cancel kills the child and drops the reply. */
35
+ const running = new Map();
31
36
  process.stdin.setEncoding("utf8");
32
37
  process.stdin.on("data", (chunk) => {
33
38
  buffer += chunk;
@@ -35,7 +40,10 @@ process.stdin.on("data", (chunk) => {
35
40
  });
36
41
  process.stdin.on("end", () => {
37
42
  drain(true);
38
- process.exit(0);
43
+ closing = true;
44
+ // A call already running still gets its reply before the server exits.
45
+ if (running.size === 0)
46
+ process.exit(0);
39
47
  });
40
48
  function drain(flush = false) {
41
49
  const lines = buffer.split("\n");
@@ -57,6 +65,22 @@ function drain(flush = false) {
57
65
  function handle(message) {
58
66
  if (!message.method)
59
67
  return;
68
+ if (message.method === "notifications/cancelled") {
69
+ const key = message.params?.requestId;
70
+ if (key === undefined)
71
+ return;
72
+ const child = running.get(String(key));
73
+ running.delete(String(key));
74
+ if (child?.pid) {
75
+ try {
76
+ process.kill(-child.pid, "SIGTERM");
77
+ }
78
+ catch {
79
+ child.kill("SIGTERM");
80
+ }
81
+ }
82
+ return;
83
+ }
60
84
  if (message.id === undefined || message.id === null)
61
85
  return;
62
86
  if (message.method === "initialize") {
@@ -64,7 +88,7 @@ function handle(message) {
64
88
  jsonrpc: "2.0",
65
89
  id: message.id,
66
90
  result: {
67
- protocolVersion: "2024-11-05",
91
+ protocolVersion: PROTOCOLS.includes(message.params?.protocolVersion ?? "") ? message.params?.protocolVersion : PROTOCOLS[0],
68
92
  capabilities: { tools: {} },
69
93
  serverInfo: { name: "probatio", version },
70
94
  tools: [tool],
@@ -86,29 +110,7 @@ function handle(message) {
86
110
  send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "argv must be an array of strings" } });
87
111
  return;
88
112
  }
89
- const cli = cliCommand();
90
- const child = spawnSync(cli.bin, [...cli.args, ...argv], { encoding: "utf8" });
91
- const text = child.stdout ?? "";
92
- let envelope = null;
93
- try {
94
- envelope = JSON.parse(text);
95
- }
96
- catch {
97
- envelope = null;
98
- }
99
- if (!envelope || typeof envelope !== "object") {
100
- send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "the command did not return one JSON object" } });
101
- return;
102
- }
103
- send({
104
- jsonrpc: "2.0",
105
- id: message.id,
106
- result: {
107
- content: [{ type: "text", text: text.trim() }],
108
- structuredContent: envelope,
109
- isError: envelope.ok !== true,
110
- },
111
- });
113
+ call(message.id, argv);
112
114
  return;
113
115
  }
114
116
  send({
@@ -117,6 +119,53 @@ function handle(message) {
117
119
  error: { code: -32601, message: `method not found: ${message.method}` },
118
120
  });
119
121
  }
122
+ /** One CLI command per call. It runs beside other calls, so ping and cancel are answered meanwhile. */
123
+ function call(id, argv) {
124
+ const key = String(id);
125
+ const cli = cliCommand();
126
+ // Its own process group, so a cancel also stops the suite the command started.
127
+ const child = spawn(cli.bin, [...cli.args, ...argv], { stdio: ["ignore", "pipe", "ignore"], detached: true });
128
+ running.set(key, child);
129
+ let text = "";
130
+ child.stdout?.setEncoding("utf8");
131
+ child.stdout?.on("data", (chunk) => {
132
+ text += chunk;
133
+ });
134
+ const settle = (failure) => {
135
+ // A cancelled call is no longer in the map. Its reply is dropped.
136
+ const live = running.get(key) === child;
137
+ running.delete(key);
138
+ if (live)
139
+ reply(id, text, failure);
140
+ if (closing && running.size === 0)
141
+ process.exit(0);
142
+ };
143
+ child.on("error", (error) => settle(error.message));
144
+ child.on("close", () => settle(null));
145
+ }
146
+ function reply(id, text, failure) {
147
+ let envelope = null;
148
+ try {
149
+ envelope = JSON.parse(text);
150
+ }
151
+ catch {
152
+ envelope = null;
153
+ }
154
+ if (!envelope || typeof envelope !== "object") {
155
+ const message = failure ? `the command did not start: ${failure}` : "the command did not return one JSON object";
156
+ send({ jsonrpc: "2.0", id, error: { code: -32000, message } });
157
+ return;
158
+ }
159
+ send({
160
+ jsonrpc: "2.0",
161
+ id,
162
+ result: {
163
+ content: [{ type: "text", text: text.trim() }],
164
+ structuredContent: envelope,
165
+ isError: envelope.ok !== true,
166
+ },
167
+ });
168
+ }
120
169
  function cliCommand() {
121
170
  const compiled = path.join(here, "cli.js");
122
171
  if (existsSync(compiled))
@@ -1,34 +1,39 @@
1
- // Nameless line dump for a plain script. This is not a node:test process and it
1
+ // Nameless line dump for a node child of a test. This is not a node:test process and it
2
2
  // does not write a coverage shard. The parent test's afterEach reads the dump.
3
+ // The test-process collector puts this file in NODE_OPTIONS, so the child needs no import.
4
+ // The dump is written on `exit`, which also runs after process.exit(), so it is synchronous.
3
5
  import { randomBytes } from "node:crypto"
4
6
  import { mkdirSync, writeFileSync } from "node:fs"
5
7
  import path from "node:path"
6
- import { openLineSampler } from "./precise-lines.mjs"
8
+ import { openSyncLineSampler } from "./precise-lines.mjs"
7
9
 
8
10
  const mapPath = process.env.PROBATIO_COVERAGE_MAP
9
- if (mapPath) {
11
+ if (mapPath && !globalThis.__probatioChildLines) {
12
+ globalThis.__probatioChildLines = true
10
13
  let sampler = null
11
14
  try {
12
- sampler = await openLineSampler((rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"))
15
+ sampler = openSyncLineSampler(
16
+ (rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"),
17
+ process.env.PROBATIO_PACKAGE_ROOT || process.cwd(),
18
+ )
13
19
  } catch (err) {
14
20
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
15
21
  }
16
22
  if (sampler) {
17
- let wrote = false
18
- const dump = () => {
19
- if (wrote) return
20
- wrote = true
21
- sampler.positiveLines().then((hits) => {
22
- const dir = `${mapPath}.children`
23
+ process.on("exit", () => {
24
+ // A node:test process that loaded this through an inherited NODE_OPTIONS has its own
25
+ // hooks. Its hits are already named. A nameless dump would land on the wrong test.
26
+ if (globalThis.__probatioTestHooks) return
27
+ try {
28
+ const hits = sampler.positiveLines()
29
+ // A child started by a mapped test writes where that test's process reads.
30
+ // A child that was not (a script that imports this file itself) uses the shared dir.
31
+ const dir = process.env.PROBATIO_CHILD_DIR || `${mapPath}.children`
23
32
  mkdirSync(dir, { recursive: true })
24
- writeFileSync(
25
- path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`),
26
- JSON.stringify({ hits }),
27
- )
28
- }).catch((err) => {
33
+ writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ hits }))
34
+ } catch (err) {
29
35
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
30
- })
31
- }
32
- process.on("beforeExit", dump)
36
+ }
37
+ })
33
38
  }
34
39
  }
@@ -1,12 +1,21 @@
1
1
  // node --test custom reporter. One JSON object on stdout: failures with the file
2
2
  // the runner itself recorded. TAP headings are not used.
3
+ import { staticLoadFailure } from "./load-failure.mjs"
4
+
3
5
  export default async function* reporter(source) {
6
+ // A file that fails to load prints its error on stderr before the file-level failure.
7
+ const stderr = new Map()
4
8
  const failed = []
5
9
  const names = []
6
10
  let tests = 0
7
11
  let pass = 0
8
12
  let fail = 0
9
13
  for await (const event of source) {
14
+ if (event.type === "test:stderr") {
15
+ const key = String(event.data?.file ?? "")
16
+ stderr.set(key, `${stderr.get(key) ?? ""}${String(event.data?.message ?? "")}`)
17
+ continue
18
+ }
10
19
  if (event.type === "test:pass" || event.type === "test:fail") {
11
20
  const data = event.data ?? {}
12
21
  if (data.details && data.details.type === "suite") continue
@@ -18,6 +27,12 @@ export default async function* reporter(source) {
18
27
  // An open handle is reported as a timeout whose name is the file path.
19
28
  // failureType lives on the error; the text is on error.cause, not message.
20
29
  if (fileTimedOut(name, file, data.details?.error)) failure.fileTimeout = true
30
+ // The file itself failed and no test in it ran: it did not load.
31
+ else if (isFileName(name, file)) {
32
+ failure.fileLoad = true
33
+ // It did not link (removed export, missing module, no parse): no test code ran.
34
+ if (staticLoadFailure(stderr.get(file) ?? "")) failure.staticLoad = true
35
+ }
21
36
  failed.push(failure)
22
37
  }
23
38
  } else if (event.type === "test:diagnostic") {
@@ -33,6 +48,12 @@ export default async function* reporter(source) {
33
48
  yield JSON.stringify({ tests, pass, fail, failed, names })
34
49
  }
35
50
 
51
+ function isFileName(name, file) {
52
+ const base = String(file).split(/[/\\]/).pop()
53
+ if (!base) return false
54
+ return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
55
+ }
56
+
36
57
  function fileTimedOut(name, file, error) {
37
58
  if (!error || typeof error !== "object") return false
38
59
  const cause = typeof error.cause === "string" ? error.cause : ""
@@ -1,10 +1,10 @@
1
1
  import { findMutants } from "./operators.js";
2
2
  import { findTextMutants, textKind } from "./text-operators.js";
3
3
  /** TypeScript stays on the TypeScript parser. Every other recognized source uses its own finder. */
4
- export function findInSource(file, text) {
4
+ export function findInSource(file, text, operators = "core") {
5
5
  if (textKind(file))
6
- return findTextMutants(file, text);
7
- return findMutants(file, text);
6
+ return findTextMutants(file, text, operators);
7
+ return findMutants(file, text, operators);
8
8
  }
9
9
  export function isGeneratedSource(name) {
10
10
  if (textKind(name))
@@ -32,7 +32,7 @@ export function generateMutants(options) {
32
32
  source = loaded.source;
33
33
  const text = loaded.text;
34
34
  const rel = posix(path.relative(options.packageDir, file));
35
- const found = findInSource(rel, text);
35
+ const found = findInSource(rel, text, options.operators ?? "core");
36
36
  violations.push(...found.violations);
37
37
  const ordered = shuffle(found.points, rng);
38
38
  const slice = options.perFile === null ? ordered.slice(options.skip) : ordered.slice(options.skip, options.skip + options.perFile);
@@ -60,7 +60,7 @@ export function generateMutants(options) {
60
60
  writeFileSync(path.join(mutantsDir, patch), diff);
61
61
  mutants.push({ id, file: item.rel, line: item.point.line, op: item.point.op, patch });
62
62
  }
63
- writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, mutants }, null, 2)}\n`);
63
+ writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, operators: options.operators ?? "core", mutants }, null, 2)}\n`);
64
64
  return { mutants, violations, filesVisited, budgetHit, source };
65
65
  }
66
66
  function fileText(packageDir, absFile, options) {
@@ -81,6 +81,9 @@ function fileText(packageDir, absFile, options) {
81
81
  function walkSources(dir, over) {
82
82
  if (!statExists(dir))
83
83
  return { files: [], stopped: false };
84
+ // `--src src/text.ts` mutates that one file.
85
+ if (statSync(dir).isFile())
86
+ return { files: isSource(path.basename(dir)) ? [dir] : [], stopped: false };
84
87
  const out = [];
85
88
  let stopped = false;
86
89
  const walk = (current) => {
@@ -0,0 +1,40 @@
1
+ // A test file that failed before any test ran either did not link or threw while loading.
2
+ // Did not link: a removed export, a missing module, a file that does not parse. No code ran.
3
+ // A compiled language rejects the same change at compile time, so that mutant is unviable.
4
+ // Threw while loading: the file's own top-level code ran the program and it broke. That is a kill.
5
+
6
+ const STATIC_ERROR = /^(?:SyntaxError|(?:Type)?Error \[ERR_(?:MODULE_NOT_FOUND|UNKNOWN_FILE_EXTENSION|UNSUPPORTED_DIR_IMPORT|PACKAGE_PATH_NOT_EXPORTED)\]):/
7
+
8
+ /**
9
+ * `text` is the error as printed: a `Name: message` line, then `at` frames.
10
+ * Static when the error is a link or parse error and no frame is in the project's own code.
11
+ * A SyntaxError thrown by running code (JSON.parse at the top of a test) has a frame in that file.
12
+ */
13
+ export function staticLoadFailure(text) {
14
+ const lines = String(text).split(/\r?\n/)
15
+ const head = lines.findIndex((line) => STATIC_ERROR.test(line.trim()))
16
+ if (head === -1) return false
17
+ for (const raw of lines.slice(head + 1)) {
18
+ const line = raw.trim()
19
+ if (!line.startsWith("at ")) break
20
+ if (projectFrame(line)) return false
21
+ }
22
+ return true
23
+ }
24
+
25
+ /** The error object a node:test event carries, printed the way node prints it. */
26
+ export function errorText(error) {
27
+ if (!error || typeof error !== "object") return ""
28
+ const name = typeof error.name === "string" ? error.name : "Error"
29
+ const code = typeof error.code === "string" ? ` [${error.code}]` : ""
30
+ const message = typeof error.message === "string" ? error.message : ""
31
+ const stack = typeof error.stack === "string" ? error.stack.split("\n").slice(1).join("\n") : ""
32
+ return `${name}${code}: ${message}\n${stack}`
33
+ }
34
+
35
+ function projectFrame(frame) {
36
+ if (/\bnode:/.test(frame) || frame.includes("<anonymous>")) return false
37
+ const location = /(?:file:\/\/)?(\/[^\s)]+|[A-Za-z]:\\[^\s)]+):\d+:\d+\)?$/.exec(frame)
38
+ if (!location) return false
39
+ return !location[1].split(/[\\/]/).includes("node_modules")
40
+ }
@@ -12,6 +12,8 @@ if (mapPath) {
12
12
  let prelude = []
13
13
  /** @type {{ positiveLines: () => Promise<Array<{ file: string, line: number }>> } | null} */
14
14
  let sampler = null
15
+ /** @type {((mapPath: string) => Array<{ file: string, line: number }>) | null} */
16
+ let takeChildHits = null
15
17
 
16
18
  function add(hits, name) {
17
19
  if (!name) return
@@ -40,7 +42,10 @@ if (mapPath) {
40
42
  async beforeAll() {
41
43
  try {
42
44
  const opened = await import("./precise-lines.mjs")
43
- sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs"))
45
+ sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs") || rel.endsWith("child-lines.mjs"))
46
+ // A node child of a Mocha test is mapped under that test, the same as under node:test.
47
+ opened.exposeChildLines()
48
+ takeChildHits = opened.takeChildHits
44
49
  prelude = await sampler.positiveLines()
45
50
  } catch (err) {
46
51
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
@@ -52,6 +57,7 @@ if (mapPath) {
52
57
  try {
53
58
  add(prelude, title)
54
59
  add(await sampler.positiveLines(), title)
60
+ if (takeChildHits) add(takeChildHits(mapPath), title)
55
61
  } catch (err) {
56
62
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
57
63
  }
@@ -40,8 +40,22 @@ function underPackage(abs) {
40
40
  return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
41
41
  }
42
42
 
43
+ /** TypeScript with NodeNext writes `./limit.js` for `limit.ts`. tsx resolves that, so this loader does too. */
44
+ function typeScriptSibling(specifier) {
45
+ if (!/^(\.{1,2}\/|\/|file:)/.test(specifier)) return null
46
+ const swapped = specifier.replace(/\.js$/, ".ts").replace(/\.mjs$/, ".mts").replace(/\.cjs$/, ".cts").replace(/\.jsx$/, ".tsx")
47
+ return swapped === specifier ? null : swapped
48
+ }
49
+
43
50
  export async function resolve(specifier, context, nextResolve) {
44
- const resolved = await nextResolve(specifier, context)
51
+ let resolved
52
+ try {
53
+ resolved = await nextResolve(specifier, context)
54
+ } catch (error) {
55
+ const sibling = typeScriptSibling(specifier)
56
+ if (!sibling) throw error
57
+ resolved = await nextResolve(sibling, context)
58
+ }
45
59
  const gen = generation()
46
60
  if (!gen) return resolved
47
61
  let abs
@@ -1,10 +1,11 @@
1
1
  // One suite process for covered mutants of one file. Jobs arrive on stdin.
2
2
  // A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
3
3
  import { createInterface } from "node:readline"
4
- import { realpathSync, writeFileSync } from "node:fs"
4
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs"
5
5
  import { register } from "node:module"
6
6
  import { run } from "node:test"
7
7
  import path from "node:path"
8
+ import { errorText, staticLoadFailure } from "./load-failure.mjs"
8
9
  import { openLineSampler } from "./precise-lines.mjs"
9
10
 
10
11
  const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
@@ -33,6 +34,12 @@ async function runJob(job) {
33
34
  const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
34
35
  const failed = []
35
36
  const names = []
37
+ // run() with isolation "none" ignores testNamePatterns on Node 22, so every test in the file runs.
38
+ // Only a requested test, or a subtest of one, is a result. Another test failing is not a kill.
39
+ const wanted = new Set((job.names || []).filter((name) => name.length > 0))
40
+ const stack = []
41
+ const requested = (name, nesting) =>
42
+ wanted.size === 0 || wanted.has(name) || stack.slice(0, nesting).some((parent) => wanted.has(parent))
36
43
  const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
37
44
  const stream = run({
38
45
  files,
@@ -47,10 +54,28 @@ async function runJob(job) {
47
54
  const collected = (async () => {
48
55
  for await (const event of stream) {
49
56
  lastEvent = Date.now()
50
- if (event.type !== "test:pass" && event.type !== "test:fail") continue
51
57
  const data = event.data || {}
58
+ const nesting = Number.isInteger(data.nesting) ? data.nesting : 0
59
+ if (event.type === "test:start") {
60
+ stack.length = nesting
61
+ stack[nesting] = typeof data.name === "string" ? data.name : ""
62
+ continue
63
+ }
64
+ if (event.type !== "test:pass" && event.type !== "test:fail") continue
52
65
  const name = typeof data.name === "string" ? data.name : ""
53
- if (!name || files.includes(name)) continue
66
+ if (!name) continue
67
+ if (files.includes(name)) {
68
+ // The file itself failed: it did not load (a removed export, a throw at import time).
69
+ // No test in it ran. That is a failure of the file, the same as node --test reports it.
70
+ if (event.type === "test:fail" && fileHasRequested(name, wanted)) {
71
+ const posix = path.relative(job.root, name).split(path.sep).join("/")
72
+ const linked = !staticLoadFailure(errorText(data.details && data.details.error))
73
+ failed.push({ name: posix, file: name, line: 1, fileLoad: true, ...(linked ? {} : { staticLoad: true }) })
74
+ if (!names.includes(posix)) names.push(posix)
75
+ }
76
+ continue
77
+ }
78
+ if (!requested(name, nesting)) continue
54
79
  if (!names.includes(name)) names.push(name)
55
80
  if (event.type === "test:fail") {
56
81
  const error = data.details && data.details.error
@@ -101,6 +126,18 @@ async function runJob(job) {
101
126
  return { id: job.id, pid: process.pid, failed, names, command, dirty }
102
127
  }
103
128
 
129
+ /** A file with no requested test in it does not speak for the requested tests. */
130
+ function fileHasRequested(file, wanted) {
131
+ if (wanted.size === 0) return true
132
+ let text = ""
133
+ try {
134
+ text = readFileSync(file, "utf8")
135
+ } catch {
136
+ return true
137
+ }
138
+ return [...wanted].some((name) => text.includes(name))
139
+ }
140
+
104
141
  function fileNameIs(name, file) {
105
142
  const base = String(file).split(/[/\\]/).pop()
106
143
  if (!base) return false
@@ -1,13 +1,14 @@
1
1
  // Baseline-only per-test line map for node --test. Loaded with --import in each
2
2
  // test-file process. Writes one shard; the runner merges shards after the suite.
3
3
  // takePreciseCoverage resets counters, so each sample is only what ran since the last take.
4
- // A plain-script child does not run these hooks. It may leave a nameless hit list in
5
- // ${map}.children. afterEach stores that list under the parent test before the shard is written.
4
+ // A node child of a test does not run these hooks. exposeChildLines puts child-lines.mjs in
5
+ // its NODE_OPTIONS, so it leaves a nameless hit list in ${map}.children when it exits.
6
+ // afterEach stores that list under the parent test before the shard is written.
6
7
  import { randomBytes } from "node:crypto"
7
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
8
+ import { mkdirSync, writeFileSync } from "node:fs"
8
9
  import { after, afterEach, before } from "node:test"
9
10
  import path from "node:path"
10
- import { openLineSampler } from "./precise-lines.mjs"
11
+ import { exposeChildLines, openLineSampler, takeChildHits } from "./precise-lines.mjs"
11
12
 
12
13
  const mapPath = process.env.PROBATIO_COVERAGE_MAP
13
14
  if (mapPath) {
@@ -19,6 +20,7 @@ if (mapPath) {
19
20
  }
20
21
 
21
22
  if (sampler) {
23
+ exposeChildLines()
22
24
  /** @type {Map<string, Map<number, Set<string>>>} */
23
25
  const byFile = new Map()
24
26
  /** @type {Array<{ file: string, line: number }>} */
@@ -41,33 +43,6 @@ if (mapPath) {
41
43
  }
42
44
  }
43
45
 
44
- function takeChildHits() {
45
- const dir = `${mapPath}.children`
46
- if (!existsSync(dir)) return []
47
- /** @type {Array<{ file: string, line: number }>} */
48
- const hits = []
49
- for (const name of readdirSync(dir)) {
50
- if (!name.endsWith(".json")) continue
51
- const full = path.join(dir, name)
52
- try {
53
- const parsed = JSON.parse(readFileSync(full, "utf8"))
54
- const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
55
- for (const hit of list) {
56
- if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
57
- hits.push({ file: hit.file, line: hit.line })
58
- }
59
- } catch {
60
- // A torn dump is ignored. The parent does not invent a line.
61
- }
62
- try {
63
- unlinkSync(full)
64
- } catch {
65
- // The next test must not inherit this dump.
66
- }
67
- }
68
- return hits
69
- }
70
-
71
46
  before(async () => {
72
47
  try {
73
48
  prelude = await sampler.positiveLines()
@@ -80,7 +55,7 @@ if (mapPath) {
80
55
  try {
81
56
  add(prelude, t.name)
82
57
  add(await sampler.positiveLines(), t.name)
83
- add(takeChildHits(), t.name)
58
+ add(takeChildHits(mapPath), t.name)
84
59
  } catch (err) {
85
60
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
86
61
  }
@@ -1,4 +1,11 @@
1
1
  import ts from "typescript";
2
+ const ARITHMETIC = {
3
+ [ts.SyntaxKind.PlusToken]: { op: "add-to-sub", replacement: "-" },
4
+ [ts.SyntaxKind.MinusToken]: { op: "sub-to-add", replacement: "+" },
5
+ [ts.SyntaxKind.AsteriskToken]: { op: "mul-to-div", replacement: "/" },
6
+ [ts.SyntaxKind.SlashToken]: { op: "div-to-mul", replacement: "*" },
7
+ [ts.SyntaxKind.PercentToken]: { op: "mod-to-mul", replacement: "*" },
8
+ };
2
9
  const SWAP = {
3
10
  [ts.SyntaxKind.AmpersandAmpersandToken]: { op: "and-to-or", replacement: "||" },
4
11
  [ts.SyntaxKind.BarBarToken]: { op: "or-to-and", replacement: "&&" },
@@ -14,7 +21,7 @@ const SWAP = {
14
21
  * A condition that contains a string (`name === "x"`) is still code: the span is not
15
22
  * itself inside the string.
16
23
  */
17
- export function findMutants(file, text) {
24
+ export function findMutants(file, text, operators = "core") {
18
25
  const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
19
26
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind);
20
27
  const raw = [];
@@ -24,6 +31,11 @@ export function findMutants(file, text) {
24
31
  const point = pointAt(source, file, text, node);
25
32
  if (point)
26
33
  raw.push(point);
34
+ if (operators === "wide") {
35
+ const extra = widePointAt(source, file, text, node);
36
+ if (extra)
37
+ raw.push(extra);
38
+ }
27
39
  ts.forEachChild(node, visit);
28
40
  };
29
41
  visit(source);
@@ -81,6 +93,53 @@ function pointAt(source, file, text, node) {
81
93
  }
82
94
  return undefined;
83
95
  }
96
+ /** Arithmetic, constants, and a dropped call. Only in the wide set. */
97
+ function widePointAt(source, file, text, node) {
98
+ if (ts.isBinaryExpression(node)) {
99
+ const swap = ARITHMETIC[node.operatorToken.kind];
100
+ if (!swap)
101
+ return undefined;
102
+ // `"a" + n` builds a string. Swapping it for `-` changes the type, not the arithmetic.
103
+ if (node.operatorToken.kind === ts.SyntaxKind.PlusToken && (stringish(node.left) || stringish(node.right)))
104
+ return undefined;
105
+ const start = node.operatorToken.getStart(source);
106
+ return make(source, file, text, start, node.operatorToken.getEnd(), swap.op, swap.replacement);
107
+ }
108
+ if (ts.isNumericLiteral(node) && /^\d+$/.test(node.text) && node.getText(source) === node.text) {
109
+ // A property name or an enum key written as a number is a name, not a value.
110
+ if (ts.isPropertyAssignment(node.parent) && node.parent.name === node)
111
+ return undefined;
112
+ if (ts.isEnumMember(node.parent) && node.parent.name === node)
113
+ return undefined;
114
+ const start = node.getStart(source);
115
+ const value = node.text;
116
+ if (value === "0")
117
+ return make(source, file, text, start, node.getEnd(), "const-zero-to-one", "1");
118
+ if (value === "1")
119
+ return make(source, file, text, start, node.getEnd(), "const-one-to-zero", "0");
120
+ if (value.length <= 15)
121
+ return make(source, file, text, start, node.getEnd(), "const-inc", String(Number(value) + 1));
122
+ return undefined;
123
+ }
124
+ if (ts.isExpressionStatement(node) && ts.isBlock(node.parent)) {
125
+ const call = ts.isAwaitExpression(node.expression) ? node.expression.expression : node.expression;
126
+ if (!ts.isCallExpression(call) || call.expression.kind === ts.SyntaxKind.SuperKeyword)
127
+ return undefined;
128
+ // `void 0` keeps the statement valid wherever it stands. The call and its effects are gone.
129
+ const start = node.expression.getStart(source);
130
+ return make(source, file, text, start, node.expression.getEnd(), "drop-call", "void 0");
131
+ }
132
+ return undefined;
133
+ }
134
+ function stringish(node) {
135
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node))
136
+ return true;
137
+ if (ts.isParenthesizedExpression(node))
138
+ return stringish(node.expression);
139
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
140
+ return stringish(node.left) || stringish(node.right);
141
+ return false;
142
+ }
84
143
  function negate(source, file, text, expr, op) {
85
144
  const start = expr.getStart(source);
86
145
  const end = expr.getEnd();
@@ -131,5 +131,6 @@ export function applyPatch(repo, raw, direction) {
131
131
  /** Put a throwaway worktree back. A diff label is not trusted for the path list. */
132
132
  export function restoreTree(repo) {
133
133
  git(repo, ["checkout", "--", "."]);
134
- git(repo, ["clean", "-fd", "-e", "node_modules"]);
134
+ // .probatio.json may be the user's uncommitted copy, put there when the worktree was made.
135
+ git(repo, ["clean", "-fd", "-e", "node_modules", "-e", ".probatio.json"]);
135
136
  }