probatio 0.0.0-stage → 0.0.1
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 +83 -2
- package/dist/cli.js +498 -0
- package/dist/contract.js +29 -0
- package/dist/flags.js +87 -0
- package/dist/golden/check.js +188 -0
- package/dist/ledger/build.js +327 -0
- package/dist/matrix/report.js +256 -0
- package/dist/mcp.js +119 -0
- package/dist/memory/state.js +222 -0
- package/dist/mutate/affected.js +173 -0
- package/dist/mutate/coverage-map.js +172 -0
- package/dist/mutate/fail-reporter.mjs +44 -0
- package/dist/mutate/find.js +13 -0
- package/dist/mutate/generate.js +132 -0
- package/dist/mutate/ids.js +22 -0
- package/dist/mutate/kill-label.js +34 -0
- package/dist/mutate/node-coverage.mjs +245 -0
- package/dist/mutate/operators.js +122 -0
- package/dist/mutate/patch.js +135 -0
- package/dist/mutate/py-pytest-reporter.py +145 -0
- package/dist/mutate/py-reporter.py +79 -0
- package/dist/mutate/run.js +864 -0
- package/dist/mutate/suite-decision.js +122 -0
- package/dist/mutate/suites.js +1299 -0
- package/dist/mutate/tally.js +163 -0
- package/dist/mutate/text-operators.js +547 -0
- package/dist/mutate/titles.js +23 -0
- package/dist/swarm/check-kill.js +78 -0
- package/dist/swarm/queue.js +62 -0
- package/dist/verify/change.js +261 -0
- package/package.json +33 -4
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
6
|
+
import { Session } from "node:inspector/promises"
|
|
7
|
+
import { findSourceMap } from "node:module"
|
|
8
|
+
import { after, afterEach, before } from "node:test"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
|
|
12
|
+
const B64 = new Int32Array(128)
|
|
13
|
+
for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
|
|
14
|
+
|
|
15
|
+
function decodeSegments(row) {
|
|
16
|
+
const segments = []
|
|
17
|
+
let i = 0
|
|
18
|
+
while (i < row.length) {
|
|
19
|
+
const fields = []
|
|
20
|
+
while (i < row.length && row.charCodeAt(i) !== 44) {
|
|
21
|
+
let value = 0
|
|
22
|
+
let shift = 0
|
|
23
|
+
let digit = 0
|
|
24
|
+
do {
|
|
25
|
+
digit = B64[row.charCodeAt(i++)]
|
|
26
|
+
value |= (digit & 31) << shift
|
|
27
|
+
shift += 5
|
|
28
|
+
} while (digit & 32)
|
|
29
|
+
fields.push(value & 1 ? -(value >> 1) : value >> 1)
|
|
30
|
+
}
|
|
31
|
+
if (row.charCodeAt(i) === 44) i++
|
|
32
|
+
if (fields.length > 0) segments.push(fields)
|
|
33
|
+
}
|
|
34
|
+
return segments
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
38
|
+
if (mapPath) {
|
|
39
|
+
const session = new Session()
|
|
40
|
+
let started = false
|
|
41
|
+
try {
|
|
42
|
+
session.connect()
|
|
43
|
+
await session.post("Profiler.enable")
|
|
44
|
+
await session.post("Debugger.enable")
|
|
45
|
+
await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
|
|
46
|
+
started = true
|
|
47
|
+
} catch (err) {
|
|
48
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (started) {
|
|
52
|
+
const textCache = new Map()
|
|
53
|
+
const indexCache = new Map()
|
|
54
|
+
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
55
|
+
const byFile = new Map()
|
|
56
|
+
/** @type {Array<{ file: string, line: number }>} */
|
|
57
|
+
let prelude = []
|
|
58
|
+
|
|
59
|
+
async function scriptText(script) {
|
|
60
|
+
if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
|
|
61
|
+
let text = ""
|
|
62
|
+
try {
|
|
63
|
+
const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
|
|
64
|
+
text = got.scriptSource || ""
|
|
65
|
+
} catch {
|
|
66
|
+
text = ""
|
|
67
|
+
}
|
|
68
|
+
textCache.set(script.scriptId, text)
|
|
69
|
+
return text
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function relPath(url) {
|
|
73
|
+
if (!url || url.startsWith("node:")) return null
|
|
74
|
+
let abs
|
|
75
|
+
try {
|
|
76
|
+
abs = fileURLToPath(url.split("?")[0].split("#")[0])
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
|
|
81
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
|
|
82
|
+
if (rel.split("/").includes("node_modules")) return null
|
|
83
|
+
if (rel.endsWith("node-coverage.mjs")) return null
|
|
84
|
+
return rel
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function indexFor(scriptId, text, sm) {
|
|
88
|
+
if (indexCache.has(scriptId)) return indexCache.get(scriptId)
|
|
89
|
+
const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
|
|
90
|
+
const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
|
|
91
|
+
indexCache.set(scriptId, lines)
|
|
92
|
+
return lines
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasLine(lines) {
|
|
96
|
+
for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function linesFromNewlines(text, sm) {
|
|
101
|
+
const lines = new Int32Array(text.length + 1)
|
|
102
|
+
if (!sm) {
|
|
103
|
+
let line = 1
|
|
104
|
+
for (let i = 0; i < text.length; i++) {
|
|
105
|
+
lines[i] = line
|
|
106
|
+
if (text.charCodeAt(i) === 10) line++
|
|
107
|
+
}
|
|
108
|
+
return lines
|
|
109
|
+
}
|
|
110
|
+
// Node 22 reports SourceMap.findEntry originalLine as 0-based.
|
|
111
|
+
let genLine = 0
|
|
112
|
+
let col = 0
|
|
113
|
+
for (let i = 0; i < text.length; i++) {
|
|
114
|
+
const entry = sm.findEntry(genLine, col)
|
|
115
|
+
if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
|
|
116
|
+
if (text.charCodeAt(i) === 10) {
|
|
117
|
+
genLine++
|
|
118
|
+
col = 0
|
|
119
|
+
} else {
|
|
120
|
+
col++
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return lines
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function linesFromMappings(text, mappings) {
|
|
127
|
+
const lines = new Int32Array(text.length + 1)
|
|
128
|
+
const starts = [0]
|
|
129
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
|
|
130
|
+
let carryLine = 0
|
|
131
|
+
const rows = mappings.split(";")
|
|
132
|
+
for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
|
|
133
|
+
const start = starts[genLine]
|
|
134
|
+
const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
|
|
135
|
+
let col = 0
|
|
136
|
+
let cursor = start
|
|
137
|
+
let active = -1
|
|
138
|
+
for (const fields of decodeSegments(rows[genLine])) {
|
|
139
|
+
col += fields[0]
|
|
140
|
+
const at = Math.min(next, start + Math.max(0, col))
|
|
141
|
+
if (active >= 0) {
|
|
142
|
+
for (let i = cursor; i < at; i++) lines[i] = active + 1
|
|
143
|
+
}
|
|
144
|
+
cursor = at
|
|
145
|
+
if (fields.length >= 4) {
|
|
146
|
+
carryLine += fields[2]
|
|
147
|
+
active = carryLine
|
|
148
|
+
} else {
|
|
149
|
+
active = -1
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (active >= 0) {
|
|
153
|
+
for (let i = cursor; i < next; i++) lines[i] = active + 1
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return lines
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function applyRanges(text, script, lines) {
|
|
160
|
+
const ranges = []
|
|
161
|
+
for (const fn of script.functions || []) {
|
|
162
|
+
for (const range of fn.ranges || []) ranges.push(range)
|
|
163
|
+
}
|
|
164
|
+
// Outer ranges first. A nested block then overwrites its own offsets, so a dead
|
|
165
|
+
// branch stays at 0 while a condition earlier on that same line can stay covered.
|
|
166
|
+
ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
|
|
167
|
+
const offsets = new Int32Array(text.length)
|
|
168
|
+
for (const range of ranges) {
|
|
169
|
+
const end = Math.min(text.length, range.endOffset)
|
|
170
|
+
for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
|
|
171
|
+
}
|
|
172
|
+
const counts = new Map()
|
|
173
|
+
for (let i = 0; i < text.length; i++) {
|
|
174
|
+
const line = lines[i]
|
|
175
|
+
if (line <= 0 || offsets[i] <= 0) continue
|
|
176
|
+
if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
|
|
177
|
+
}
|
|
178
|
+
return counts
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function positiveLines() {
|
|
182
|
+
const cov = await session.post("Profiler.takePreciseCoverage")
|
|
183
|
+
/** @type {Array<{ file: string, line: number }>} */
|
|
184
|
+
const found = []
|
|
185
|
+
for (const script of cov.result || []) {
|
|
186
|
+
const file = relPath(script.url)
|
|
187
|
+
if (!file) continue
|
|
188
|
+
const text = await scriptText(script)
|
|
189
|
+
if (!text) continue
|
|
190
|
+
const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
|
|
191
|
+
for (const [line, count] of counts) {
|
|
192
|
+
if (count > 0) found.push({ file, line })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return found
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function add(hits, name) {
|
|
199
|
+
if (!name) return
|
|
200
|
+
for (const hit of hits) {
|
|
201
|
+
let lines = byFile.get(hit.file)
|
|
202
|
+
if (!lines) {
|
|
203
|
+
lines = new Map()
|
|
204
|
+
byFile.set(hit.file, lines)
|
|
205
|
+
}
|
|
206
|
+
let names = lines.get(hit.line)
|
|
207
|
+
if (!names) {
|
|
208
|
+
names = new Set()
|
|
209
|
+
lines.set(hit.line, names)
|
|
210
|
+
}
|
|
211
|
+
names.add(name)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
before(async () => {
|
|
216
|
+
try {
|
|
217
|
+
prelude = await positiveLines()
|
|
218
|
+
} catch (err) {
|
|
219
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
afterEach(async (t) => {
|
|
224
|
+
try {
|
|
225
|
+
add(prelude, t.name)
|
|
226
|
+
add(await positiveLines(), t.name)
|
|
227
|
+
} catch (err) {
|
|
228
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
after(() => {
|
|
233
|
+
if (byFile.size === 0) return
|
|
234
|
+
const files = {}
|
|
235
|
+
for (const [file, lines] of byFile) {
|
|
236
|
+
const bucket = {}
|
|
237
|
+
for (const [line, names] of lines) bucket[String(line)] = [...names].sort()
|
|
238
|
+
files[file] = bucket
|
|
239
|
+
}
|
|
240
|
+
const dir = `${mapPath}.parts`
|
|
241
|
+
mkdirSync(dir, { recursive: true })
|
|
242
|
+
writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ files }))
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
const SWAP = {
|
|
3
|
+
[ts.SyntaxKind.AmpersandAmpersandToken]: { op: "and-to-or", replacement: "||" },
|
|
4
|
+
[ts.SyntaxKind.BarBarToken]: { op: "or-to-and", replacement: "&&" },
|
|
5
|
+
[ts.SyntaxKind.EqualsEqualsEqualsToken]: { op: "eq-to-neq", replacement: "!==" },
|
|
6
|
+
[ts.SyntaxKind.ExclamationEqualsEqualsToken]: { op: "neq-to-eq", replacement: "===" },
|
|
7
|
+
[ts.SyntaxKind.LessThanToken]: { op: "lt-to-le", replacement: "<=" },
|
|
8
|
+
[ts.SyntaxKind.LessThanEqualsToken]: { op: "le-to-lt", replacement: "<" },
|
|
9
|
+
[ts.SyntaxKind.GreaterThanToken]: { op: "gt-to-ge", replacement: ">=" },
|
|
10
|
+
[ts.SyntaxKind.GreaterThanEqualsToken]: { op: "ge-to-gt", replacement: ">" },
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Operator mutants in code only. Strings, comments, and type positions are not code.
|
|
14
|
+
* A condition that contains a string (`name === "x"`) is still code: the span is not
|
|
15
|
+
* itself inside the string.
|
|
16
|
+
*/
|
|
17
|
+
export function findMutants(file, text) {
|
|
18
|
+
const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
19
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind);
|
|
20
|
+
const raw = [];
|
|
21
|
+
const visit = (node) => {
|
|
22
|
+
if (isTypePosition(node))
|
|
23
|
+
return;
|
|
24
|
+
const point = pointAt(source, file, text, node);
|
|
25
|
+
if (point)
|
|
26
|
+
raw.push(point);
|
|
27
|
+
ts.forEachChild(node, visit);
|
|
28
|
+
};
|
|
29
|
+
visit(source);
|
|
30
|
+
const hidden = hiddenSpans(source, text, scriptKind);
|
|
31
|
+
const points = [];
|
|
32
|
+
const violations = [];
|
|
33
|
+
for (const point of raw) {
|
|
34
|
+
const inside = hidden.some((span) => point.start >= span.start && point.end <= span.end);
|
|
35
|
+
if (inside)
|
|
36
|
+
violations.push(point);
|
|
37
|
+
else
|
|
38
|
+
points.push(point);
|
|
39
|
+
}
|
|
40
|
+
return { points, violations };
|
|
41
|
+
}
|
|
42
|
+
function isTypePosition(node) {
|
|
43
|
+
return (ts.isTypeNode(node) ||
|
|
44
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
45
|
+
ts.isInterfaceDeclaration(node) ||
|
|
46
|
+
ts.isTypeParameterDeclaration(node) ||
|
|
47
|
+
ts.isIndexSignatureDeclaration(node));
|
|
48
|
+
}
|
|
49
|
+
function pointAt(source, file, text, node) {
|
|
50
|
+
if (ts.isIfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
|
|
51
|
+
return negate(source, file, text, node.expression, "negate-condition");
|
|
52
|
+
}
|
|
53
|
+
if (ts.isConditionalExpression(node)) {
|
|
54
|
+
return negate(source, file, text, node.condition, "negate-ternary");
|
|
55
|
+
}
|
|
56
|
+
if (ts.isForStatement(node) && node.condition) {
|
|
57
|
+
return negate(source, file, text, node.condition, "negate-condition");
|
|
58
|
+
}
|
|
59
|
+
if (ts.isBinaryExpression(node)) {
|
|
60
|
+
const swap = SWAP[node.operatorToken.kind];
|
|
61
|
+
if (!swap)
|
|
62
|
+
return undefined;
|
|
63
|
+
const start = node.operatorToken.getStart(source);
|
|
64
|
+
const end = node.operatorToken.getEnd();
|
|
65
|
+
return make(source, file, text, start, end, swap.op, swap.replacement);
|
|
66
|
+
}
|
|
67
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
|
|
68
|
+
const start = node.getStart(source);
|
|
69
|
+
const end = node.getEnd();
|
|
70
|
+
const original = text.slice(start, end);
|
|
71
|
+
const replacement = original === "true" ? "false" : "true";
|
|
72
|
+
const op = original === "true" ? "true-to-false" : "false-to-true";
|
|
73
|
+
return make(source, file, text, start, end, op, replacement);
|
|
74
|
+
}
|
|
75
|
+
if (ts.isPrefixUnaryExpression(node) &&
|
|
76
|
+
node.operator === ts.SyntaxKind.ExclamationToken &&
|
|
77
|
+
!ts.isPrefixUnaryExpression(node.operand)) {
|
|
78
|
+
const start = node.getStart(source);
|
|
79
|
+
const end = node.operand.getStart(source);
|
|
80
|
+
return make(source, file, text, start, end, "drop-not", "");
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function negate(source, file, text, expr, op) {
|
|
85
|
+
const start = expr.getStart(source);
|
|
86
|
+
const end = expr.getEnd();
|
|
87
|
+
return make(source, file, text, start, end, op, `!(${text.slice(start, end)})`);
|
|
88
|
+
}
|
|
89
|
+
function make(source, file, text, start, end, op, replacement) {
|
|
90
|
+
const { line } = source.getLineAndCharacterOfPosition(start);
|
|
91
|
+
return { file, start, end, line: line + 1, op, original: text.slice(start, end), replacement };
|
|
92
|
+
}
|
|
93
|
+
function hiddenSpans(source, text, scriptKind) {
|
|
94
|
+
const literals = [];
|
|
95
|
+
const visit = (node) => {
|
|
96
|
+
if (ts.isStringLiteral(node) ||
|
|
97
|
+
ts.isNoSubstitutionTemplateLiteral(node) ||
|
|
98
|
+
ts.isTemplateHead(node) ||
|
|
99
|
+
ts.isTemplateMiddle(node) ||
|
|
100
|
+
ts.isTemplateTail(node) ||
|
|
101
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
102
|
+
ts.isJsxText(node)) {
|
|
103
|
+
literals.push({ start: node.getStart(source), end: node.getEnd() });
|
|
104
|
+
}
|
|
105
|
+
ts.forEachChild(node, visit);
|
|
106
|
+
};
|
|
107
|
+
visit(source);
|
|
108
|
+
const variant = scriptKind === ts.ScriptKind.TSX ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard;
|
|
109
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, variant, text);
|
|
110
|
+
const comments = [];
|
|
111
|
+
let token = scanner.scan();
|
|
112
|
+
while (token !== ts.SyntaxKind.EndOfFileToken) {
|
|
113
|
+
const leading = ts.getLeadingCommentRanges(text, scanner.getTokenStart()) ?? [];
|
|
114
|
+
const trailing = ts.getTrailingCommentRanges(text, scanner.getTokenEnd()) ?? [];
|
|
115
|
+
for (const comment of [...leading, ...trailing])
|
|
116
|
+
comments.push({ start: comment.pos, end: comment.end });
|
|
117
|
+
token = scanner.scan();
|
|
118
|
+
}
|
|
119
|
+
// A `//` inside a template is text, not a comment. Drop ranges that begin in a literal.
|
|
120
|
+
const real = comments.filter((comment) => !literals.some((span) => comment.start >= span.start && comment.start < span.end));
|
|
121
|
+
return [...literals, ...real];
|
|
122
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
git(repo, ["clean", "-fd", "-e", "node_modules"]);
|
|
135
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run pytest and print one Probatio report as a single JSON line.
|
|
3
|
+
|
|
4
|
+
When PROBATIO_COVERAGE_MAP is set and coverage.py imports, also write a
|
|
5
|
+
per-test line map. A missing coverage module leaves the map unwritten.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
# The sysmon core on this interpreter drops dynamic context switches.
|
|
12
|
+
os.environ.setdefault("COVERAGE_CORE", "ctrace")
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Collector:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.names = []
|
|
20
|
+
self.failed = []
|
|
21
|
+
|
|
22
|
+
def pytest_collectreport(self, report):
|
|
23
|
+
if not report.failed:
|
|
24
|
+
return
|
|
25
|
+
self._add(getattr(report, "nodeid", "") or "pytest", report)
|
|
26
|
+
|
|
27
|
+
def pytest_runtest_logreport(self, report):
|
|
28
|
+
if report.when not in ("setup", "call"):
|
|
29
|
+
return
|
|
30
|
+
if report.when == "setup" and not report.failed:
|
|
31
|
+
return
|
|
32
|
+
name = report.nodeid
|
|
33
|
+
if report.when == "call" and name not in self.names:
|
|
34
|
+
self.names.append(name)
|
|
35
|
+
if report.failed:
|
|
36
|
+
self._add(name, report)
|
|
37
|
+
|
|
38
|
+
def _add(self, name, report):
|
|
39
|
+
if any(item["name"] == name for item in self.failed):
|
|
40
|
+
return
|
|
41
|
+
location = getattr(report, "location", None) or ("", 0, "")
|
|
42
|
+
self.failed.append({"name": name, "file": str(location[0] or ""), "line": int(location[1] or 0)})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ContextSwitch:
|
|
46
|
+
def __init__(self, cov):
|
|
47
|
+
self.cov = cov
|
|
48
|
+
|
|
49
|
+
def pytest_runtest_setup(self, item):
|
|
50
|
+
self.cov.switch_context(item.nodeid)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def start_coverage():
|
|
54
|
+
dest = os.environ.get("PROBATIO_COVERAGE_MAP")
|
|
55
|
+
if not dest:
|
|
56
|
+
return None, None
|
|
57
|
+
try:
|
|
58
|
+
import coverage
|
|
59
|
+
except ImportError:
|
|
60
|
+
return None, None
|
|
61
|
+
data_file = dest + ".data"
|
|
62
|
+
try:
|
|
63
|
+
cov = coverage.Coverage(cover_pylib=False, data_file=data_file)
|
|
64
|
+
cov.start()
|
|
65
|
+
return cov, dest
|
|
66
|
+
except Exception:
|
|
67
|
+
return None, None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_coverage(cov, dest):
|
|
71
|
+
try:
|
|
72
|
+
cov.stop()
|
|
73
|
+
cov.save()
|
|
74
|
+
except Exception:
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
data = cov.get_data()
|
|
78
|
+
except Exception:
|
|
79
|
+
return
|
|
80
|
+
root = os.path.realpath(os.getcwd())
|
|
81
|
+
files = {}
|
|
82
|
+
for filename in data.measured_files():
|
|
83
|
+
full = os.path.realpath(filename)
|
|
84
|
+
if not full.startswith(root + os.sep):
|
|
85
|
+
continue
|
|
86
|
+
if "/site-packages/" in full or "/node_modules/" in full:
|
|
87
|
+
continue
|
|
88
|
+
rel = full[len(root) + 1 :]
|
|
89
|
+
try:
|
|
90
|
+
by_line = data.contexts_by_lineno(filename) or {}
|
|
91
|
+
except Exception:
|
|
92
|
+
continue
|
|
93
|
+
lines = {}
|
|
94
|
+
for lineno, contexts in by_line.items():
|
|
95
|
+
names = [item for item in contexts if item]
|
|
96
|
+
if names:
|
|
97
|
+
lines[str(lineno)] = names
|
|
98
|
+
if lines:
|
|
99
|
+
files[rel] = lines
|
|
100
|
+
with open(dest, "w", encoding="utf-8") as handle:
|
|
101
|
+
json.dump({"files": files}, handle)
|
|
102
|
+
handle.write("\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main():
|
|
106
|
+
args = []
|
|
107
|
+
index = 1
|
|
108
|
+
while index < len(sys.argv):
|
|
109
|
+
if sys.argv[index] == "--pattern":
|
|
110
|
+
args.extend(["-k", sys.argv[index + 1]])
|
|
111
|
+
index += 2
|
|
112
|
+
continue
|
|
113
|
+
args.append(sys.argv[index])
|
|
114
|
+
index += 1
|
|
115
|
+
cov, dest = start_coverage()
|
|
116
|
+
collector = Collector()
|
|
117
|
+
plugins = [collector]
|
|
118
|
+
if cov is not None:
|
|
119
|
+
plugins.append(ContextSwitch(cov))
|
|
120
|
+
code = 1
|
|
121
|
+
try:
|
|
122
|
+
code = pytest.main([*args, "-p", "no:cacheprovider", "--tb=no", "-q"], plugins=plugins) or 0
|
|
123
|
+
except BaseException:
|
|
124
|
+
code = 1
|
|
125
|
+
finally:
|
|
126
|
+
if cov is not None and dest:
|
|
127
|
+
write_coverage(cov, dest)
|
|
128
|
+
names = collector.names or [item["name"] for item in collector.failed]
|
|
129
|
+
failed = collector.failed
|
|
130
|
+
# Exit 4 is a usage error (a bad -k expression). No test ran, so this is not a kill.
|
|
131
|
+
if not names and code != 0:
|
|
132
|
+
names = []
|
|
133
|
+
failed = []
|
|
134
|
+
report = {
|
|
135
|
+
"tests": len(names),
|
|
136
|
+
"pass": len(names) - len(failed),
|
|
137
|
+
"fail": len(failed),
|
|
138
|
+
"failed": failed,
|
|
139
|
+
"names": names,
|
|
140
|
+
}
|
|
141
|
+
sys.stdout.write(json.dumps(report) + "\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|