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.
@@ -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()