probatio 0.3.0 → 0.5.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 (74) hide show
  1. package/README.md +10 -198
  2. package/bin/probatio.js +52 -0
  3. package/package.json +21 -29
  4. package/LICENSE +0 -21
  5. package/dist/cli.js +0 -721
  6. package/dist/contract.js +0 -32
  7. package/dist/flags.js +0 -87
  8. package/dist/golden/check.js +0 -188
  9. package/dist/golden/record.js +0 -395
  10. package/dist/golden/recorder.mjs +0 -78
  11. package/dist/ledger/build.js +0 -348
  12. package/dist/ledger/check.js +0 -133
  13. package/dist/matrix/report.js +0 -256
  14. package/dist/mcp.js +0 -188
  15. package/dist/memory/state.js +0 -222
  16. package/dist/mutate/affected.js +0 -173
  17. package/dist/mutate/child-lines.mjs +0 -39
  18. package/dist/mutate/coverage-map.js +0 -229
  19. package/dist/mutate/csharp-coverage.js +0 -161
  20. package/dist/mutate/fail-reporter.mjs +0 -65
  21. package/dist/mutate/find.js +0 -13
  22. package/dist/mutate/generate.js +0 -156
  23. package/dist/mutate/go-coverage.js +0 -100
  24. package/dist/mutate/ids.js +0 -22
  25. package/dist/mutate/java-coverage.js +0 -217
  26. package/dist/mutate/kill-label.js +0 -34
  27. package/dist/mutate/load-failure.mjs +0 -40
  28. package/dist/mutate/mocha-coverage.cjs +0 -77
  29. package/dist/mutate/node-batch-loader.mjs +0 -99
  30. package/dist/mutate/node-batch.mjs +0 -167
  31. package/dist/mutate/node-coverage.mjs +0 -77
  32. package/dist/mutate/operators.js +0 -181
  33. package/dist/mutate/patch.js +0 -136
  34. package/dist/mutate/precise-lines.mjs +0 -304
  35. package/dist/mutate/probatio-jacoco-run.java +0 -516
  36. package/dist/mutate/project-config.js +0 -58
  37. package/dist/mutate/py-pytest-reporter.py +0 -145
  38. package/dist/mutate/py-reporter.py +0 -79
  39. package/dist/mutate/run.js +0 -1335
  40. package/dist/mutate/rust-coverage.js +0 -144
  41. package/dist/mutate/sealed.js +0 -118
  42. package/dist/mutate/suite-decision.js +0 -130
  43. package/dist/mutate/suites.js +0 -1524
  44. package/dist/mutate/tally.js +0 -240
  45. package/dist/mutate/text-operators.js +0 -637
  46. package/dist/mutate/titles.js +0 -23
  47. package/dist/node-version.js +0 -13
  48. package/dist/swarm/check-kill.js +0 -114
  49. package/dist/swarm/queue.js +0 -68
  50. package/dist/verify/change.js +0 -340
  51. package/schemas/check-kill.schema.json +0 -93
  52. package/schemas/findings.add.schema.json +0 -53
  53. package/schemas/gap.fix.schema.json +0 -53
  54. package/schemas/gap.revert.schema.json +0 -53
  55. package/schemas/golden.check.schema.json +0 -89
  56. package/schemas/golden.compare.schema.json +0 -151
  57. package/schemas/golden.record.schema.json +0 -126
  58. package/schemas/guard.check.schema.json +0 -53
  59. package/schemas/ledger.build.schema.json +0 -132
  60. package/schemas/ledger.check.schema.json +0 -143
  61. package/schemas/matrix.report.schema.json +0 -129
  62. package/schemas/mcp.schema.json +0 -53
  63. package/schemas/mutate.generate.schema.json +0 -127
  64. package/schemas/mutate.help.schema.json +0 -53
  65. package/schemas/mutate.run.schema.json +0 -272
  66. package/schemas/mutate.sealed.schema.json +0 -53
  67. package/schemas/mutate.tally.schema.json +0 -137
  68. package/schemas/queue.claim.schema.json +0 -53
  69. package/schemas/queue.reap.schema.json +0 -71
  70. package/schemas/queue.seed.schema.json +0 -71
  71. package/schemas/schema.schema.json +0 -74
  72. package/schemas/seal.schema.json +0 -69
  73. package/schemas/status.schema.json +0 -103
  74. package/schemas/verify-change.schema.json +0 -222
@@ -1,99 +0,0 @@
1
- // Cache-busts package files so the next mutant in this process loads the patched source.
2
- // The generation lives in a file because the loader thread does not see later env edits.
3
- import { createRequire } from "node:module"
4
- import { readFileSync, realpathSync } from "node:fs"
5
- import path from "node:path"
6
- import { fileURLToPath } from "node:url"
7
-
8
- const require = createRequire(import.meta.url)
9
- const ts = require("typescript")
10
-
11
- function generation() {
12
- const file = process.env.PB_GEN
13
- if (!file) return ""
14
- try {
15
- return readFileSync(file, "utf8").trim()
16
- } catch {
17
- return ""
18
- }
19
- }
20
-
21
- function packageRoot() {
22
- const root = process.env.PB_ROOT
23
- if (!root) return ""
24
- try {
25
- return realpathSync(root)
26
- } catch {
27
- return root
28
- }
29
- }
30
-
31
- function underPackage(abs) {
32
- const root = packageRoot()
33
- if (!root) return false
34
- let real = abs
35
- try {
36
- real = realpathSync(abs)
37
- } catch {
38
- return false
39
- }
40
- return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
41
- }
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
-
50
- export async function resolve(specifier, context, nextResolve) {
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
- }
59
- const gen = generation()
60
- if (!gen) return resolved
61
- let abs
62
- try {
63
- abs = fileURLToPath(resolved.url.split("?")[0])
64
- } catch {
65
- return resolved
66
- }
67
- if (!underPackage(abs)) return resolved
68
- if (abs.split(path.sep).includes("node_modules")) return resolved
69
- const url = new URL(resolved.url.split("?")[0])
70
- url.searchParams.set("pb", gen)
71
- return { url: url.href, shortCircuit: true }
72
- }
73
-
74
- export async function load(url, context, nextLoad) {
75
- if (!url.includes("pb=")) return nextLoad(url, context)
76
- const clean = url.split("?")[0]
77
- let abs
78
- try {
79
- abs = fileURLToPath(clean)
80
- } catch {
81
- return nextLoad(url, context)
82
- }
83
- const source = readFileSync(abs, "utf8")
84
- if (abs.endsWith(".ts") || abs.endsWith(".tsx") || abs.endsWith(".mts")) {
85
- const transpiled = ts.transpileModule(source, {
86
- fileName: abs,
87
- compilerOptions: {
88
- module: ts.ModuleKind.ESNext,
89
- target: ts.ScriptTarget.ES2022,
90
- sourceMap: false,
91
- },
92
- })
93
- return { format: "module", source: transpiled.outputText, shortCircuit: true }
94
- }
95
- if (abs.endsWith(".mjs") || abs.endsWith(".js") || source.includes("import ") || source.includes("export ")) {
96
- return { format: "module", source, shortCircuit: true }
97
- }
98
- return { format: "commonjs", source, shortCircuit: true }
99
- }
@@ -1,167 +0,0 @@
1
- // One suite process for covered mutants of one file. Jobs arrive on stdin.
2
- // A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
3
- import { createInterface } from "node:readline"
4
- import { readFileSync, realpathSync, writeFileSync } from "node:fs"
5
- import { register } from "node:module"
6
- import { run } from "node:test"
7
- import path from "node:path"
8
- import { errorText, staticLoadFailure } from "./load-failure.mjs"
9
- import { openLineSampler } from "./precise-lines.mjs"
10
-
11
- const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
12
- register(loader, { parentURL: import.meta.url })
13
-
14
- let sampler = null
15
- try {
16
- sampler = await openLineSampler()
17
- } catch {
18
- sampler = null
19
- }
20
-
21
- writeFileSync(process.env.PB_READY, `${JSON.stringify({ pid: process.pid })}\n`)
22
-
23
- function escapeRegExp(value) {
24
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
25
- }
26
-
27
- let generation = 0
28
-
29
- async function runJob(job) {
30
- generation += 1
31
- writeFileSync(process.env.PB_GEN, String(generation))
32
- if (sampler) await sampler.reset()
33
- const files = (job.tests || []).map((rel) => path.join(job.root, rel))
34
- const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
35
- const failed = []
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))
43
- const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
44
- const stream = run({
45
- files,
46
- isolation: "none",
47
- timeout: testTimeout,
48
- ...(patterns.length > 0 ? { testNamePatterns: patterns } : {}),
49
- })
50
- let lastEvent = Date.now()
51
- const started = Date.now()
52
- let ended = false
53
- let timer = null
54
- const collected = (async () => {
55
- for await (const event of stream) {
56
- lastEvent = Date.now()
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
65
- const name = typeof data.name === "string" ? data.name : ""
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
79
- if (!names.includes(name)) names.push(name)
80
- if (event.type === "test:fail") {
81
- const error = data.details && data.details.error
82
- const text = error ? `${error.failureType || ""} ${error.message || ""} ${error.cause || ""}` : ""
83
- const fileTimeout = /testTimeoutFailure|timed out/i.test(text) && fileNameIs(name, data.file || "")
84
- failed.push({ name, file: data.file || "", line: data.line || 0, ...(fileTimeout ? { fileTimeout: true } : {}) })
85
- }
86
- }
87
- ended = true
88
- })()
89
- let dirty = false
90
- try {
91
- dirty = await Promise.race([
92
- collected.then(() => false),
93
- new Promise((resolve) => {
94
- timer = setInterval(() => {
95
- if (ended) {
96
- resolve(false)
97
- return
98
- }
99
- const idle = Date.now() - lastEvent
100
- const overdue = Date.now() - started >= testTimeout && idle >= 400 && names.length > 0
101
- if (!overdue) return
102
- resolve(true)
103
- }, 50)
104
- timer.unref()
105
- }),
106
- ])
107
- } finally {
108
- if (timer) clearInterval(timer)
109
- }
110
- if (dirty && failed.length === 0) {
111
- for (const rel of job.tests || []) {
112
- const posix = rel.split(path.sep).join("/")
113
- let file = path.join(job.root, rel)
114
- try {
115
- file = realpathSync(file)
116
- } catch {
117
- // The reporter uses the path it opened. A missing file keeps the joined path.
118
- }
119
- failed.push({ name: posix, file, line: 1, fileTimeout: true })
120
- if (!names.includes(posix)) names.push(posix)
121
- }
122
- }
123
- if (sampler && !dirty) await sampler.reset()
124
- const pattern = (job.names || []).join("|")
125
- const command = ["node", "--test", ...(pattern ? [`--test-name-pattern=${pattern}`] : []), ...(job.tests || [])].join(" ")
126
- return { id: job.id, pid: process.pid, failed, names, command, dirty }
127
- }
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
-
141
- function fileNameIs(name, file) {
142
- const base = String(file).split(/[/\\]/).pop()
143
- if (!base) return false
144
- return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
145
- }
146
-
147
- const lines = createInterface({ input: process.stdin })
148
- for await (const line of lines) {
149
- if (!line.trim()) continue
150
- const job = JSON.parse(line)
151
- // node:test with isolation "none" waits until the event loop is idle.
152
- // An open readline on stdin is that handle, so the job would never finish.
153
- lines.pause()
154
- process.stdin.unref()
155
- try {
156
- const reply = await runJob(job)
157
- writeFileSync(job.replyFile, `${JSON.stringify(reply)}\n`)
158
- // An open handle after the tests finished is the same file timeout node --test reports.
159
- // The process is dirty, so it ends and the next mutant starts clean.
160
- if (reply.dirty) process.exit(0)
161
- } catch (err) {
162
- writeFileSync(job.replyFile, `${JSON.stringify({ id: job.id, pid: process.pid, failed: [], names: [], error: err instanceof Error ? err.message : String(err) })}\n`)
163
- } finally {
164
- if (!process.stdin.destroyed) process.stdin.ref()
165
- lines.resume()
166
- }
167
- }
@@ -1,77 +0,0 @@
1
- // Baseline-only per-test line map for node --test. Loaded with --import in each
2
- // test-file process. Writes one shard; the runner merges shards after the suite.
3
- // takePreciseCoverage resets counters, so each sample is only what ran since the last take.
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.
7
- import { randomBytes } from "node:crypto"
8
- import { mkdirSync, writeFileSync } from "node:fs"
9
- import { after, afterEach, before } from "node:test"
10
- import path from "node:path"
11
- import { exposeChildLines, openLineSampler, takeChildHits } from "./precise-lines.mjs"
12
-
13
- const mapPath = process.env.PROBATIO_COVERAGE_MAP
14
- if (mapPath) {
15
- let sampler = null
16
- try {
17
- sampler = await openLineSampler((rel) => rel.endsWith("node-coverage.mjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("child-lines.mjs"))
18
- } catch (err) {
19
- writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
20
- }
21
-
22
- if (sampler) {
23
- exposeChildLines()
24
- /** @type {Map<string, Map<number, Set<string>>>} */
25
- const byFile = new Map()
26
- /** @type {Array<{ file: string, line: number }>} */
27
- let prelude = []
28
-
29
- function add(hits, name) {
30
- if (!name) return
31
- for (const hit of hits) {
32
- let lines = byFile.get(hit.file)
33
- if (!lines) {
34
- lines = new Map()
35
- byFile.set(hit.file, lines)
36
- }
37
- let names = lines.get(hit.line)
38
- if (!names) {
39
- names = new Set()
40
- lines.set(hit.line, names)
41
- }
42
- names.add(name)
43
- }
44
- }
45
-
46
- before(async () => {
47
- try {
48
- prelude = await sampler.positiveLines()
49
- } catch (err) {
50
- writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
51
- }
52
- })
53
-
54
- afterEach(async (t) => {
55
- try {
56
- add(prelude, t.name)
57
- add(await sampler.positiveLines(), t.name)
58
- add(takeChildHits(mapPath), t.name)
59
- } catch (err) {
60
- writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
61
- }
62
- })
63
-
64
- after(() => {
65
- if (byFile.size === 0) return
66
- const files = {}
67
- for (const [file, lines] of byFile) {
68
- const bucket = {}
69
- for (const [line, names] of lines) bucket[String(line)] = [...names].sort()
70
- files[file] = bucket
71
- }
72
- const dir = `${mapPath}.parts`
73
- mkdirSync(dir, { recursive: true })
74
- writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ files }))
75
- })
76
- }
77
- }
@@ -1,181 +0,0 @@
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
- };
9
- const SWAP = {
10
- [ts.SyntaxKind.AmpersandAmpersandToken]: { op: "and-to-or", replacement: "||" },
11
- [ts.SyntaxKind.BarBarToken]: { op: "or-to-and", replacement: "&&" },
12
- [ts.SyntaxKind.EqualsEqualsEqualsToken]: { op: "eq-to-neq", replacement: "!==" },
13
- [ts.SyntaxKind.ExclamationEqualsEqualsToken]: { op: "neq-to-eq", replacement: "===" },
14
- [ts.SyntaxKind.LessThanToken]: { op: "lt-to-le", replacement: "<=" },
15
- [ts.SyntaxKind.LessThanEqualsToken]: { op: "le-to-lt", replacement: "<" },
16
- [ts.SyntaxKind.GreaterThanToken]: { op: "gt-to-ge", replacement: ">=" },
17
- [ts.SyntaxKind.GreaterThanEqualsToken]: { op: "ge-to-gt", replacement: ">" },
18
- };
19
- /**
20
- * Operator mutants in code only. Strings, comments, and type positions are not code.
21
- * A condition that contains a string (`name === "x"`) is still code: the span is not
22
- * itself inside the string.
23
- */
24
- export function findMutants(file, text, operators = "core") {
25
- const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
26
- const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind);
27
- const raw = [];
28
- const visit = (node) => {
29
- if (isTypePosition(node))
30
- return;
31
- const point = pointAt(source, file, text, node);
32
- if (point)
33
- raw.push(point);
34
- if (operators === "wide") {
35
- const extra = widePointAt(source, file, text, node);
36
- if (extra)
37
- raw.push(extra);
38
- }
39
- ts.forEachChild(node, visit);
40
- };
41
- visit(source);
42
- const hidden = hiddenSpans(source, text, scriptKind);
43
- const points = [];
44
- const violations = [];
45
- for (const point of raw) {
46
- const inside = hidden.some((span) => point.start >= span.start && point.end <= span.end);
47
- if (inside)
48
- violations.push(point);
49
- else
50
- points.push(point);
51
- }
52
- return { points, violations };
53
- }
54
- function isTypePosition(node) {
55
- return (ts.isTypeNode(node) ||
56
- ts.isTypeAliasDeclaration(node) ||
57
- ts.isInterfaceDeclaration(node) ||
58
- ts.isTypeParameterDeclaration(node) ||
59
- ts.isIndexSignatureDeclaration(node));
60
- }
61
- function pointAt(source, file, text, node) {
62
- if (ts.isIfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
63
- return negate(source, file, text, node.expression, "negate-condition");
64
- }
65
- if (ts.isConditionalExpression(node)) {
66
- return negate(source, file, text, node.condition, "negate-ternary");
67
- }
68
- if (ts.isForStatement(node) && node.condition) {
69
- return negate(source, file, text, node.condition, "negate-condition");
70
- }
71
- if (ts.isBinaryExpression(node)) {
72
- const swap = SWAP[node.operatorToken.kind];
73
- if (!swap)
74
- return undefined;
75
- const start = node.operatorToken.getStart(source);
76
- const end = node.operatorToken.getEnd();
77
- return make(source, file, text, start, end, swap.op, swap.replacement);
78
- }
79
- if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
80
- const start = node.getStart(source);
81
- const end = node.getEnd();
82
- const original = text.slice(start, end);
83
- const replacement = original === "true" ? "false" : "true";
84
- const op = original === "true" ? "true-to-false" : "false-to-true";
85
- return make(source, file, text, start, end, op, replacement);
86
- }
87
- if (ts.isPrefixUnaryExpression(node) &&
88
- node.operator === ts.SyntaxKind.ExclamationToken &&
89
- !ts.isPrefixUnaryExpression(node.operand)) {
90
- const start = node.getStart(source);
91
- const end = node.operand.getStart(source);
92
- return make(source, file, text, start, end, "drop-not", "");
93
- }
94
- return undefined;
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
- }
143
- function negate(source, file, text, expr, op) {
144
- const start = expr.getStart(source);
145
- const end = expr.getEnd();
146
- return make(source, file, text, start, end, op, `!(${text.slice(start, end)})`);
147
- }
148
- function make(source, file, text, start, end, op, replacement) {
149
- const { line } = source.getLineAndCharacterOfPosition(start);
150
- return { file, start, end, line: line + 1, op, original: text.slice(start, end), replacement };
151
- }
152
- function hiddenSpans(source, text, scriptKind) {
153
- const literals = [];
154
- const visit = (node) => {
155
- if (ts.isStringLiteral(node) ||
156
- ts.isNoSubstitutionTemplateLiteral(node) ||
157
- ts.isTemplateHead(node) ||
158
- ts.isTemplateMiddle(node) ||
159
- ts.isTemplateTail(node) ||
160
- ts.isRegularExpressionLiteral(node) ||
161
- ts.isJsxText(node)) {
162
- literals.push({ start: node.getStart(source), end: node.getEnd() });
163
- }
164
- ts.forEachChild(node, visit);
165
- };
166
- visit(source);
167
- const variant = scriptKind === ts.ScriptKind.TSX ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard;
168
- const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, variant, text);
169
- const comments = [];
170
- let token = scanner.scan();
171
- while (token !== ts.SyntaxKind.EndOfFileToken) {
172
- const leading = ts.getLeadingCommentRanges(text, scanner.getTokenStart()) ?? [];
173
- const trailing = ts.getTrailingCommentRanges(text, scanner.getTokenEnd()) ?? [];
174
- for (const comment of [...leading, ...trailing])
175
- comments.push({ start: comment.pos, end: comment.end });
176
- token = scanner.scan();
177
- }
178
- // A `//` inside a template is text, not a comment. Drop ranges that begin in a literal.
179
- const real = comments.filter((comment) => !literals.some((span) => comment.start >= span.start && comment.start < span.end));
180
- return [...literals, ...real];
181
- }
@@ -1,136 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
- export function readHeader(raw) {
6
- const lines = raw.split(/\r?\n/);
7
- let direction = null;
8
- let index = 0;
9
- while (index < lines.length && !lines[index].startsWith("---") && !lines[index].startsWith("diff ")) {
10
- const match = lines[index].match(/direction=(forward|reverse)/);
11
- if (match)
12
- direction = match[1];
13
- index++;
14
- }
15
- const body = lines.slice(index).join("\n");
16
- return { direction, body: body.endsWith("\n") || body.length === 0 ? body : `${body}\n` };
17
- }
18
- /** `diff -u` patch that applies onto the original and produces the mutant. */
19
- export function forwardDiff(rel, original, mutated) {
20
- const dir = mkdtempSync(path.join(tmpdir(), "probatio-diff-"));
21
- try {
22
- const before = path.join(dir, "before");
23
- const after = path.join(dir, "after");
24
- writeFileSync(before, original);
25
- writeFileSync(after, mutated);
26
- const diff = spawnSync("diff", ["-u", "-L", `a/${rel}`, "-L", `b/${rel}`, before, after], { encoding: "utf8" });
27
- if (diff.status === 0)
28
- return "";
29
- if (diff.status !== 1)
30
- throw new Error(diff.stderr.trim() || "diff failed");
31
- const body = diff.stdout.endsWith("\n") ? diff.stdout : `${diff.stdout}\n`;
32
- return `# probatio-mutant direction=forward meaning=apply-to-introduce-the-bug\n${body}`;
33
- }
34
- finally {
35
- rmSync(dir, { recursive: true, force: true });
36
- }
37
- }
38
- export function filesInDiff(raw) {
39
- const body = readHeader(raw).body;
40
- const files = [];
41
- const chunks = body.split(/^diff --git .*$/m);
42
- const pieces = chunks.length > 1 ? chunks.slice(1) : [body];
43
- for (const piece of pieces) {
44
- const plus = piece.match(/^\+\+\+ ([^\t\n]+)/m);
45
- const hunk = piece.match(/^@@ -\d+(?:,\d+)? \+(\d+)/m);
46
- if (!plus)
47
- continue;
48
- let file = plus[1];
49
- if (file === "/dev/null")
50
- continue;
51
- // git diff -R swaps the labels, so the path may be a/ or b/.
52
- if (file.startsWith("a/") || file.startsWith("b/"))
53
- file = file.slice(2);
54
- files.push({ file, line: hunk ? Number(hunk[1]) : 1 });
55
- }
56
- return files;
57
- }
58
- /**
59
- * Original lines a patch removes or replaces.
60
- * A unified hunk starts on context, so the hunk header is not the edited line.
61
- */
62
- export function changedLines(raw) {
63
- const body = readHeader(raw).body;
64
- const chunks = body.split(/^diff --git .*$/m);
65
- const pieces = chunks.length > 1 ? chunks.slice(1) : [body];
66
- const found = [];
67
- for (const piece of pieces) {
68
- const plus = piece.match(/^\+\+\+ ([^\t\n]+)/m);
69
- if (!plus)
70
- continue;
71
- let file = plus[1];
72
- if (file === "/dev/null")
73
- continue;
74
- if (file.startsWith("a/") || file.startsWith("b/"))
75
- file = file.slice(2);
76
- let oldLine = 0;
77
- let inHunk = false;
78
- for (const line of piece.split(/\r?\n/)) {
79
- const hunk = /^@@ -(\d+)(?:,\d+)? \+\d+/.exec(line);
80
- if (hunk) {
81
- oldLine = Number(hunk[1]);
82
- inHunk = true;
83
- continue;
84
- }
85
- if (!inHunk)
86
- continue;
87
- if (line.startsWith("+") || line.startsWith("\\"))
88
- continue;
89
- if (line.startsWith("-")) {
90
- found.push({ file, line: oldLine });
91
- oldLine += 1;
92
- continue;
93
- }
94
- if (line.startsWith(" ")) {
95
- oldLine += 1;
96
- continue;
97
- }
98
- inHunk = false;
99
- }
100
- }
101
- return found;
102
- }
103
- export function git(repo, args, input) {
104
- const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", input });
105
- const stderr = [result.stderr ?? "", result.error?.message ?? ""].filter((part) => part.length > 0).join("\n");
106
- return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr };
107
- }
108
- export function patchApplies(repo, raw, direction) {
109
- const args = ["apply", "--check", "--whitespace=nowarn", ...(direction === "reverse" ? ["-R"] : []), "-"];
110
- return git(repo, args, readHeader(raw).body).status === 0;
111
- }
112
- export function chooseDirection(repo, raw, requested) {
113
- if (requested !== "auto")
114
- return requested;
115
- const header = readHeader(raw).direction;
116
- if (header)
117
- return header;
118
- if (patchApplies(repo, raw, "forward"))
119
- return "forward";
120
- if (patchApplies(repo, raw, "reverse"))
121
- return "reverse";
122
- return null;
123
- }
124
- export function applyPatch(repo, raw, direction) {
125
- const args = ["apply", "--whitespace=nowarn", ...(direction === "reverse" ? ["-R"] : []), "-"];
126
- const result = git(repo, args, readHeader(raw).body);
127
- if (result.status === 0)
128
- return { ok: true };
129
- return { ok: false, error: (result.stderr || result.stdout).trim() || "apply failed" };
130
- }
131
- /** Put a throwaway worktree back. A diff label is not trusted for the path list. */
132
- export function restoreTree(repo) {
133
- git(repo, ["checkout", "--", "."]);
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"]);
136
- }