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.
- package/README.md +10 -198
- package/bin/probatio.js +52 -0
- package/package.json +21 -29
- package/LICENSE +0 -21
- package/dist/cli.js +0 -721
- package/dist/contract.js +0 -32
- package/dist/flags.js +0 -87
- package/dist/golden/check.js +0 -188
- package/dist/golden/record.js +0 -395
- package/dist/golden/recorder.mjs +0 -78
- package/dist/ledger/build.js +0 -348
- package/dist/ledger/check.js +0 -133
- package/dist/matrix/report.js +0 -256
- package/dist/mcp.js +0 -188
- package/dist/memory/state.js +0 -222
- package/dist/mutate/affected.js +0 -173
- package/dist/mutate/child-lines.mjs +0 -39
- package/dist/mutate/coverage-map.js +0 -229
- package/dist/mutate/csharp-coverage.js +0 -161
- package/dist/mutate/fail-reporter.mjs +0 -65
- package/dist/mutate/find.js +0 -13
- package/dist/mutate/generate.js +0 -156
- package/dist/mutate/go-coverage.js +0 -100
- package/dist/mutate/ids.js +0 -22
- package/dist/mutate/java-coverage.js +0 -217
- package/dist/mutate/kill-label.js +0 -34
- package/dist/mutate/load-failure.mjs +0 -40
- package/dist/mutate/mocha-coverage.cjs +0 -77
- package/dist/mutate/node-batch-loader.mjs +0 -99
- package/dist/mutate/node-batch.mjs +0 -167
- package/dist/mutate/node-coverage.mjs +0 -77
- package/dist/mutate/operators.js +0 -181
- package/dist/mutate/patch.js +0 -136
- package/dist/mutate/precise-lines.mjs +0 -304
- package/dist/mutate/probatio-jacoco-run.java +0 -516
- package/dist/mutate/project-config.js +0 -58
- package/dist/mutate/py-pytest-reporter.py +0 -145
- package/dist/mutate/py-reporter.py +0 -79
- package/dist/mutate/run.js +0 -1335
- package/dist/mutate/rust-coverage.js +0 -144
- package/dist/mutate/sealed.js +0 -118
- package/dist/mutate/suite-decision.js +0 -130
- package/dist/mutate/suites.js +0 -1524
- package/dist/mutate/tally.js +0 -240
- package/dist/mutate/text-operators.js +0 -637
- package/dist/mutate/titles.js +0 -23
- package/dist/node-version.js +0 -13
- package/dist/swarm/check-kill.js +0 -114
- package/dist/swarm/queue.js +0 -68
- package/dist/verify/change.js +0 -340
- package/schemas/check-kill.schema.json +0 -93
- package/schemas/findings.add.schema.json +0 -53
- package/schemas/gap.fix.schema.json +0 -53
- package/schemas/gap.revert.schema.json +0 -53
- package/schemas/golden.check.schema.json +0 -89
- package/schemas/golden.compare.schema.json +0 -151
- package/schemas/golden.record.schema.json +0 -126
- package/schemas/guard.check.schema.json +0 -53
- package/schemas/ledger.build.schema.json +0 -132
- package/schemas/ledger.check.schema.json +0 -143
- package/schemas/matrix.report.schema.json +0 -129
- package/schemas/mcp.schema.json +0 -53
- package/schemas/mutate.generate.schema.json +0 -127
- package/schemas/mutate.help.schema.json +0 -53
- package/schemas/mutate.run.schema.json +0 -272
- package/schemas/mutate.sealed.schema.json +0 -53
- package/schemas/mutate.tally.schema.json +0 -137
- package/schemas/queue.claim.schema.json +0 -53
- package/schemas/queue.reap.schema.json +0 -71
- package/schemas/queue.seed.schema.json +0 -71
- package/schemas/schema.schema.json +0 -74
- package/schemas/seal.schema.json +0 -69
- package/schemas/status.schema.json +0 -103
- 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
|
-
}
|
package/dist/mutate/operators.js
DELETED
|
@@ -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
|
-
}
|
package/dist/mutate/patch.js
DELETED
|
@@ -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
|
-
}
|