pluginbench 0.5.0__py3-none-any.whl
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.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Normalize coverage formats into independent, source-bound coordinate populations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import fnmatch
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from pluginbench.assessment_metrics import inspect_snapshot
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CoveragePopulationUnavailable(RuntimeError):
|
|
17
|
+
"""The report contains no measured sources or usable coordinate populations."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _integer(value: str) -> int:
|
|
21
|
+
if not re.fullmatch(r"\d+", value):
|
|
22
|
+
raise ValueError(f"Coverage count must be a nonnegative integer: {value!r}")
|
|
23
|
+
return int(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _source_path(value: str, files: set[str], scope: list[str], workdir: str) -> str:
|
|
27
|
+
path = PurePosixPath(value)
|
|
28
|
+
if path.is_absolute():
|
|
29
|
+
try:
|
|
30
|
+
path = path.relative_to(PurePosixPath(workdir))
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise ValueError(f"Coverage source escapes the measured repository: {value}") from exc
|
|
33
|
+
if ".." in path.parts or path.as_posix() not in files:
|
|
34
|
+
raise ValueError(f"Coverage source is absent from the frozen snapshot: {value}")
|
|
35
|
+
relative = path.as_posix()
|
|
36
|
+
if not any(fnmatch.fnmatchcase(relative, pattern) for pattern in scope):
|
|
37
|
+
raise ValueError(f"Coverage source escapes declared scope: {relative}")
|
|
38
|
+
return relative
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class _Record:
|
|
43
|
+
path: str
|
|
44
|
+
line_count: int
|
|
45
|
+
definitions: dict[str, list[int]] = field(default_factory=dict)
|
|
46
|
+
hits: dict[str, list[int]] = field(default_factory=dict)
|
|
47
|
+
branches: dict[str, int] = field(default_factory=dict)
|
|
48
|
+
summaries: dict[str, int] = field(default_factory=dict)
|
|
49
|
+
lines: dict[int, int] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def line(self, value: str) -> int:
|
|
52
|
+
line = _integer(value)
|
|
53
|
+
if not 1 <= line <= self.line_count:
|
|
54
|
+
raise ValueError(f"LCOV coordinate escapes source line population: {self.path}:{line}")
|
|
55
|
+
return line
|
|
56
|
+
|
|
57
|
+
def function(self, key: str, value: str) -> None:
|
|
58
|
+
fields = value.split(",", 1)
|
|
59
|
+
target = self.definitions if key == "FN" else self.hits
|
|
60
|
+
if len(fields) != 2 or not fields[1]:
|
|
61
|
+
raise ValueError(f"Invalid or duplicate LCOV function: {value}")
|
|
62
|
+
coordinate = self.line(fields[0]) if key == "FN" else _integer(fields[0])
|
|
63
|
+
values = target.setdefault(fields[1], [])
|
|
64
|
+
if key == "FN" and coordinate in values:
|
|
65
|
+
raise ValueError(f"Duplicate LCOV function coordinate: {value}")
|
|
66
|
+
values.append(coordinate)
|
|
67
|
+
|
|
68
|
+
def function_coverage(self) -> dict[str, int] | None:
|
|
69
|
+
result = {}
|
|
70
|
+
for name, lines in self.definitions.items():
|
|
71
|
+
covered = {count > 0 for count in self.hits[name]}
|
|
72
|
+
# FNDA carries no source line: mixed hits cannot be paired safely.
|
|
73
|
+
if len(covered) > 1:
|
|
74
|
+
return None
|
|
75
|
+
result.update({f"{line}:{name}": int(True in covered) for line in lines})
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def branch(self, value: str) -> None:
|
|
79
|
+
fields = value.split(",")
|
|
80
|
+
if len(fields) != 4:
|
|
81
|
+
raise ValueError(f"Invalid LCOV branch: {value}")
|
|
82
|
+
coordinate = ":".join(
|
|
83
|
+
[str(self.line(fields[0])), str(_integer(fields[1])), str(_integer(fields[2]))]
|
|
84
|
+
)
|
|
85
|
+
if coordinate in self.branches:
|
|
86
|
+
raise ValueError(f"Duplicate LCOV branch: {value}")
|
|
87
|
+
self.branches[coordinate] = 0 if fields[3] == "-" else _integer(fields[3])
|
|
88
|
+
|
|
89
|
+
def line_hits(self, value: str) -> None:
|
|
90
|
+
fields = value.split(",")
|
|
91
|
+
if len(fields) not in {2, 3}:
|
|
92
|
+
raise ValueError(f"Invalid LCOV line: {value}")
|
|
93
|
+
line = self.line(fields[0])
|
|
94
|
+
if line in self.lines:
|
|
95
|
+
raise ValueError(f"Duplicate LCOV line: {value}")
|
|
96
|
+
self.lines[line] = _integer(fields[1])
|
|
97
|
+
|
|
98
|
+
def add(self, key: str, value: str) -> None:
|
|
99
|
+
if key in {"FN", "FNDA"}:
|
|
100
|
+
self.function(key, value)
|
|
101
|
+
elif key == "BRDA":
|
|
102
|
+
self.branch(value)
|
|
103
|
+
elif key in {"FNF", "FNH", "BRF", "BRH", "LF", "LH"}:
|
|
104
|
+
if key in self.summaries:
|
|
105
|
+
raise ValueError(f"Duplicate LCOV summary: {key}")
|
|
106
|
+
self.summaries[key] = _integer(value)
|
|
107
|
+
elif key == "DA":
|
|
108
|
+
self.line_hits(value)
|
|
109
|
+
elif key != "VER":
|
|
110
|
+
raise ValueError(f"Unsupported LCOV record: {key}")
|
|
111
|
+
|
|
112
|
+
def validate(self) -> None:
|
|
113
|
+
if self.definitions.keys() != self.hits.keys() or any(
|
|
114
|
+
len(lines) != len(self.hits[name]) for name, lines in self.definitions.items()
|
|
115
|
+
):
|
|
116
|
+
raise ValueError(f"LCOV function definitions and hit counts differ: {self.path}")
|
|
117
|
+
observed = {
|
|
118
|
+
"FNF": sum(len(lines) for lines in self.definitions.values()),
|
|
119
|
+
"FNH": sum(count > 0 for counts in self.hits.values() for count in counts),
|
|
120
|
+
"BRF": len(self.branches),
|
|
121
|
+
"BRH": sum(count > 0 for count in self.branches.values()),
|
|
122
|
+
"LF": len(self.lines),
|
|
123
|
+
"LH": sum(count > 0 for count in self.lines.values()),
|
|
124
|
+
}
|
|
125
|
+
if any(value != observed[key] for key, value in self.summaries.items()):
|
|
126
|
+
raise ValueError(f"LCOV summaries disagree with coordinate populations: {self.path}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _record(
|
|
130
|
+
rows: list[str], snapshot: Path, files: set[str], scope: list[str], workdir: str
|
|
131
|
+
) -> _Record:
|
|
132
|
+
if not rows or not rows[0].startswith("SF:"):
|
|
133
|
+
raise ValueError("LCOV record must begin with a source path")
|
|
134
|
+
path = _source_path(rows[0][3:], files, scope, workdir)
|
|
135
|
+
result = _Record(path, len((snapshot / path).read_text().splitlines()))
|
|
136
|
+
for raw in rows[1:]:
|
|
137
|
+
key, separator, value = raw.partition(":")
|
|
138
|
+
if not separator:
|
|
139
|
+
raise ValueError(f"Malformed LCOV record: {raw}")
|
|
140
|
+
result.add(key, value)
|
|
141
|
+
result.validate()
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _records(
|
|
146
|
+
report: Path, snapshot: Path, files: set[str], scope: list[str], workdir: str
|
|
147
|
+
) -> list[_Record]:
|
|
148
|
+
records = []
|
|
149
|
+
seen: set[str] = set()
|
|
150
|
+
rows: list[str] = []
|
|
151
|
+
for raw in report.read_text(encoding="utf-8").splitlines():
|
|
152
|
+
if not raw or raw.startswith("TN:"):
|
|
153
|
+
continue
|
|
154
|
+
if raw != "end_of_record":
|
|
155
|
+
rows.append(raw)
|
|
156
|
+
continue
|
|
157
|
+
record = _record(rows, snapshot, files, scope, workdir)
|
|
158
|
+
if record.path in seen:
|
|
159
|
+
raise ValueError(f"Coverage source is duplicated: {record.path}")
|
|
160
|
+
seen.add(record.path)
|
|
161
|
+
records.append(record)
|
|
162
|
+
rows = []
|
|
163
|
+
if rows:
|
|
164
|
+
raise ValueError("Coverage source record was not terminated")
|
|
165
|
+
if not records:
|
|
166
|
+
raise CoveragePopulationUnavailable("LCOV report contains no measured sources")
|
|
167
|
+
return records
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def parse_lcov(
|
|
171
|
+
report: Path,
|
|
172
|
+
*,
|
|
173
|
+
snapshot: Path,
|
|
174
|
+
source_scope: list[str],
|
|
175
|
+
test_scope: list[str],
|
|
176
|
+
workdir: str,
|
|
177
|
+
) -> dict[str, object]:
|
|
178
|
+
"""Keep available populations independently; missing kinds stay unavailable."""
|
|
179
|
+
inventory = inspect_snapshot(snapshot)["files"]
|
|
180
|
+
assert isinstance(inventory, dict)
|
|
181
|
+
records = _records(report, snapshot, set(inventory), source_scope, workdir)
|
|
182
|
+
result = _metadata("lcov", records, source_scope, test_scope)
|
|
183
|
+
ambiguous = sorted(record.path for record in records if record.function_coverage() is None)
|
|
184
|
+
if ambiguous:
|
|
185
|
+
result["unavailable_populations"] = {
|
|
186
|
+
"functions": {
|
|
187
|
+
"files": ambiguous,
|
|
188
|
+
"reason": "Repeated LCOV function names have mixed hits; FNDA has no source line to identify which function was covered",
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for kind, summaries in (
|
|
192
|
+
("functions", {"FNF", "FNH"}),
|
|
193
|
+
("branches", {"BRF", "BRH"}),
|
|
194
|
+
("lines", {"LF", "LH"}),
|
|
195
|
+
):
|
|
196
|
+
measured = [
|
|
197
|
+
record
|
|
198
|
+
for record in records
|
|
199
|
+
if (summaries <= record.summaries.keys() or (kind == "lines" and record.lines))
|
|
200
|
+
and not (kind == "functions" and record.path in ambiguous)
|
|
201
|
+
]
|
|
202
|
+
if not measured:
|
|
203
|
+
continue
|
|
204
|
+
total: set[str] = set()
|
|
205
|
+
covered: set[str] = set()
|
|
206
|
+
for record in measured:
|
|
207
|
+
if kind == "functions":
|
|
208
|
+
hits = record.function_coverage()
|
|
209
|
+
assert hits is not None
|
|
210
|
+
else:
|
|
211
|
+
hits = (
|
|
212
|
+
record.branches
|
|
213
|
+
if kind == "branches"
|
|
214
|
+
else {str(line): count for line, count in record.lines.items()}
|
|
215
|
+
)
|
|
216
|
+
total.update(f"{record.path}:{coordinate}" for coordinate in hits)
|
|
217
|
+
covered.update(
|
|
218
|
+
f"{record.path}:{coordinate}" for coordinate, count in hits.items() if count
|
|
219
|
+
)
|
|
220
|
+
result[kind] = _population(total, covered, [record.path for record in measured], kind)
|
|
221
|
+
if not any(kind in result for kind in ("lines", "branches", "functions")):
|
|
222
|
+
raise CoveragePopulationUnavailable("LCOV contains no explicit coverage populations")
|
|
223
|
+
per_test = _lcov_per_test(
|
|
224
|
+
report.with_name(report.name + ".per-test.json"),
|
|
225
|
+
records=records,
|
|
226
|
+
inventory=inventory,
|
|
227
|
+
source_scope=source_scope,
|
|
228
|
+
workdir=workdir,
|
|
229
|
+
)
|
|
230
|
+
if per_test:
|
|
231
|
+
result["per_test_numbits"] = per_test
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _lcov_per_test(
|
|
236
|
+
sidecar: Path,
|
|
237
|
+
*,
|
|
238
|
+
records: list[_Record],
|
|
239
|
+
inventory: dict[str, object],
|
|
240
|
+
source_scope: list[str],
|
|
241
|
+
workdir: str,
|
|
242
|
+
) -> dict[str, dict[str, str]]:
|
|
243
|
+
"""Validate file-to-context numbits against LCOV's executed line population."""
|
|
244
|
+
if not sidecar.exists():
|
|
245
|
+
return {}
|
|
246
|
+
data = _json_object(json.loads(sidecar.read_text(), object_pairs_hook=_json_pairs), "sidecar")
|
|
247
|
+
raw_files = _json_object(data.get("per_test_numbits"), "LCOV per-test numbits")
|
|
248
|
+
measured = {record.path: record for record in records}
|
|
249
|
+
seen: set[str] = set()
|
|
250
|
+
per_test: dict[str, dict[str, str]] = {}
|
|
251
|
+
for raw_path, contexts in raw_files.items():
|
|
252
|
+
path = _source_path(raw_path, set(inventory), source_scope, workdir)
|
|
253
|
+
if path not in measured:
|
|
254
|
+
raise ValueError(f"LCOV per-test coverage names an unmeasured source: {path}")
|
|
255
|
+
if path in seen:
|
|
256
|
+
raise ValueError(f"LCOV per-test source is duplicated: {path}")
|
|
257
|
+
seen.add(path)
|
|
258
|
+
covered = {line for line, count in measured[path].lines.items() if count}
|
|
259
|
+
for name, encoded in _json_object(contexts, "LCOV per-test contexts").items():
|
|
260
|
+
if not isinstance(encoded, str):
|
|
261
|
+
raise ValueError("LCOV per-test numbits must be base64 strings")
|
|
262
|
+
lines = numbits_to_lines(encoded)
|
|
263
|
+
if not lines <= covered:
|
|
264
|
+
raise ValueError("LCOV per-test coverage names an unexecuted or unmeasured line")
|
|
265
|
+
if name:
|
|
266
|
+
per_test.setdefault(name, {})[path] = lines_to_numbits(lines)
|
|
267
|
+
return dict(sorted(per_test.items()))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _population(
|
|
271
|
+
total: set[str], covered: set[str], files: list[str], kind: str
|
|
272
|
+
) -> dict[str, object]:
|
|
273
|
+
return {
|
|
274
|
+
"total": sorted(total),
|
|
275
|
+
"covered": sorted(covered),
|
|
276
|
+
"files_measured": sorted(files),
|
|
277
|
+
"unit": {
|
|
278
|
+
"lines": "line",
|
|
279
|
+
"branches": "branch",
|
|
280
|
+
"functions": "function",
|
|
281
|
+
"statements": "statement",
|
|
282
|
+
}[kind],
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _metadata(
|
|
287
|
+
format: str, records: list[_Record], source_scope: list[str], test_scope: list[str]
|
|
288
|
+
) -> dict[str, object]:
|
|
289
|
+
return {
|
|
290
|
+
"files_measured": sorted(record.path for record in records),
|
|
291
|
+
"source_scope": source_scope,
|
|
292
|
+
"test_scope": test_scope,
|
|
293
|
+
"format": format,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _json_object(value: object, label: str) -> dict[str, object]:
|
|
298
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
299
|
+
raise ValueError(f"{label} must be an object")
|
|
300
|
+
return dict(value)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
304
|
+
result: dict[str, object] = {}
|
|
305
|
+
for key, value in pairs:
|
|
306
|
+
if key in result:
|
|
307
|
+
raise ValueError(f"Duplicate coverage JSON key: {key}")
|
|
308
|
+
result[key] = value
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _json_count(value: object) -> int:
|
|
313
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
314
|
+
raise ValueError(f"Coverage count must be a nonnegative integer: {value!r}")
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _python_lines(value: object, record: _Record) -> set[int]:
|
|
319
|
+
if not isinstance(value, list):
|
|
320
|
+
raise ValueError("Python coverage lines must be a list")
|
|
321
|
+
lines = [record.line(str(_json_count(line))) for line in value]
|
|
322
|
+
if len(lines) != len(set(lines)):
|
|
323
|
+
raise ValueError("Duplicate Python coverage line")
|
|
324
|
+
return set(lines)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _python_branches(value: object, record: _Record) -> set[str]:
|
|
328
|
+
if not isinstance(value, list):
|
|
329
|
+
raise ValueError("Python coverage branches must be a list")
|
|
330
|
+
branches: set[str] = set()
|
|
331
|
+
for arc in value:
|
|
332
|
+
if not isinstance(arc, list) or len(arc) != 2:
|
|
333
|
+
raise ValueError("Python coverage branch must contain two line coordinates")
|
|
334
|
+
source = record.line(str(_json_count(arc[0])))
|
|
335
|
+
destination = arc[1]
|
|
336
|
+
if isinstance(destination, bool) or not isinstance(destination, int) or destination == 0:
|
|
337
|
+
raise ValueError("Invalid Python coverage branch destination")
|
|
338
|
+
record.line(str(abs(destination)))
|
|
339
|
+
coordinate = f"{source}:{destination}"
|
|
340
|
+
if coordinate in branches:
|
|
341
|
+
raise ValueError("Duplicate Python coverage branch")
|
|
342
|
+
branches.add(coordinate)
|
|
343
|
+
return branches
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _python_file(
|
|
347
|
+
value: object, record: _Record, branch_coverage: bool
|
|
348
|
+
) -> dict[str, tuple[set[str], set[str]]]:
|
|
349
|
+
data = _json_object(value, "Python coverage file")
|
|
350
|
+
summary = _json_object(data.get("summary"), "Python coverage summary")
|
|
351
|
+
raw_executed = data.get("executed_lines")
|
|
352
|
+
if isinstance(raw_executed, list):
|
|
353
|
+
# coverage.py reports line 0, the module entry, as executed for a file without statements.
|
|
354
|
+
raw_executed = [line for line in raw_executed if line != 0]
|
|
355
|
+
executed = _python_lines(raw_executed, record)
|
|
356
|
+
missing = _python_lines(data.get("missing_lines"), record)
|
|
357
|
+
excluded = _python_lines(data.get("excluded_lines"), record)
|
|
358
|
+
if "statement_lines" in data:
|
|
359
|
+
statements = _python_lines(data["statement_lines"], record)
|
|
360
|
+
if missing != statements - executed or executed & missing:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"Python statement coordinates do not partition into covered and missing"
|
|
363
|
+
)
|
|
364
|
+
executed &= statements
|
|
365
|
+
if executed & missing or (executed | missing) & excluded:
|
|
366
|
+
raise ValueError("Python coverage line populations overlap")
|
|
367
|
+
expected = {
|
|
368
|
+
"num_statements": len(executed | missing),
|
|
369
|
+
"covered_lines": len(executed),
|
|
370
|
+
"missing_lines": len(missing),
|
|
371
|
+
"excluded_lines": len(excluded),
|
|
372
|
+
}
|
|
373
|
+
result = {
|
|
374
|
+
"lines": ({str(line) for line in executed | missing}, {str(line) for line in executed})
|
|
375
|
+
}
|
|
376
|
+
if branch_coverage:
|
|
377
|
+
covered = _python_branches(data.get("executed_branches"), record)
|
|
378
|
+
uncovered = _python_branches(data.get("missing_branches"), record)
|
|
379
|
+
if covered & uncovered:
|
|
380
|
+
raise ValueError("Python coverage branch populations overlap")
|
|
381
|
+
result["branches"] = (covered | uncovered, covered)
|
|
382
|
+
expected.update(
|
|
383
|
+
num_branches=len(covered | uncovered),
|
|
384
|
+
covered_branches=len(covered),
|
|
385
|
+
missing_branches=len(uncovered),
|
|
386
|
+
)
|
|
387
|
+
elif "executed_branches" in data or "missing_branches" in data:
|
|
388
|
+
raise ValueError("Python branch population contradicts report metadata")
|
|
389
|
+
if any(_json_count(summary.get(key)) != count for key, count in expected.items()):
|
|
390
|
+
raise ValueError("Python coverage summaries disagree with coordinate populations")
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def parse_python_coverage(
|
|
395
|
+
report: Path, *, snapshot: Path, source_scope: list[str], test_scope: list[str], workdir: str
|
|
396
|
+
) -> dict[str, object]:
|
|
397
|
+
"""Read coverage.py JSON formats 2/3; excluded lines are outside the denominator."""
|
|
398
|
+
data = _json_object(json.loads(report.read_text(), object_pairs_hook=_json_pairs), "coverage")
|
|
399
|
+
meta = _json_object(data.get("meta"), "Python coverage metadata")
|
|
400
|
+
if _json_count(meta.get("format")) not in {2, 3}:
|
|
401
|
+
raise ValueError("Unsupported coverage.py JSON format")
|
|
402
|
+
if not isinstance(meta.get("branch_coverage"), bool) or not isinstance(
|
|
403
|
+
meta.get("version"), str
|
|
404
|
+
):
|
|
405
|
+
raise ValueError("Python coverage metadata lacks branch mode or tool version")
|
|
406
|
+
branch_coverage = meta["branch_coverage"]
|
|
407
|
+
assert isinstance(branch_coverage, bool)
|
|
408
|
+
statement_source = meta.get("statement_lines_source")
|
|
409
|
+
if statement_source not in (None, "coverage.Coverage.analysis2"):
|
|
410
|
+
raise ValueError("Unsupported Python statement-coordinate source")
|
|
411
|
+
inventory = inspect_snapshot(snapshot)["files"]
|
|
412
|
+
assert isinstance(inventory, dict)
|
|
413
|
+
raw_files = _json_object(data.get("files"), "Python coverage files")
|
|
414
|
+
if not raw_files:
|
|
415
|
+
raise CoveragePopulationUnavailable("Python report contains no measured sources")
|
|
416
|
+
records: list[_Record] = []
|
|
417
|
+
populations: dict[str, tuple[set[str], set[str]]] = {}
|
|
418
|
+
executed_by_path: dict[str, tuple[set[str], set[str], int]] = {}
|
|
419
|
+
for raw_path, raw_file in raw_files.items():
|
|
420
|
+
if ("statement_lines" in _json_object(raw_file, "Python coverage file")) != (
|
|
421
|
+
statement_source is not None
|
|
422
|
+
):
|
|
423
|
+
raise ValueError("Python statement-coordinate source differs from file metadata")
|
|
424
|
+
path = _source_path(raw_path, set(inventory), source_scope, workdir)
|
|
425
|
+
if not path.endswith(".py"):
|
|
426
|
+
raise ValueError(f"Python coverage source is not Python: {path}")
|
|
427
|
+
if path in {record.path for record in records}:
|
|
428
|
+
raise ValueError(f"Python coverage source is duplicated: {path}")
|
|
429
|
+
record = _Record(path, len((snapshot / path).read_text().splitlines()))
|
|
430
|
+
records.append(record)
|
|
431
|
+
file_populations = _python_file(raw_file, record, branch_coverage)
|
|
432
|
+
for kind, (total, covered) in file_populations.items():
|
|
433
|
+
aggregate = populations.setdefault(kind, (set(), set()))
|
|
434
|
+
aggregate[0].update(f"{path}:{coordinate}" for coordinate in total)
|
|
435
|
+
aggregate[1].update(f"{path}:{coordinate}" for coordinate in covered)
|
|
436
|
+
executed_by_path[path] = (*file_populations["lines"], record.line_count)
|
|
437
|
+
result = _metadata("coverage-py-json", records, source_scope, test_scope)
|
|
438
|
+
result["tool_version"] = meta["version"]
|
|
439
|
+
for kind, (total, covered) in populations.items():
|
|
440
|
+
result[kind] = _population(total, covered, [record.path for record in records], kind)
|
|
441
|
+
per_test = _python_per_test(
|
|
442
|
+
data.get("per_test_numbits"), inventory, source_scope, workdir, executed_by_path
|
|
443
|
+
)
|
|
444
|
+
if per_test: # contexts without test names leave per-test coverage unknown
|
|
445
|
+
result["per_test_numbits"] = per_test
|
|
446
|
+
return result
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def lines_to_numbits(lines: Iterable[int]) -> str:
|
|
450
|
+
"""coverage.py numbits layout (bit n = line n), base64-encoded."""
|
|
451
|
+
values = sorted(set(lines))
|
|
452
|
+
bits = bytearray(values[-1] // 8 + 1 if values else 0)
|
|
453
|
+
for line in values:
|
|
454
|
+
bits[line // 8] |= 1 << (line % 8)
|
|
455
|
+
return base64.b64encode(bytes(bits)).decode()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def numbits_to_lines(value: str) -> set[int]:
|
|
459
|
+
bits = base64.b64decode(value, validate=True)
|
|
460
|
+
return {
|
|
461
|
+
index * 8 + bit for index, byte in enumerate(bits) for bit in range(8) if byte >> bit & 1
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _python_per_test(
|
|
466
|
+
value: object,
|
|
467
|
+
inventory: dict[str, object],
|
|
468
|
+
source_scope: list[str],
|
|
469
|
+
workdir: str,
|
|
470
|
+
executed_by_path: dict[str, tuple[set[str], set[str], int]],
|
|
471
|
+
) -> dict[str, dict[str, str]]:
|
|
472
|
+
"""Per-test executed lines from dynamic contexts, kept as compact numbits per file."""
|
|
473
|
+
if value is None:
|
|
474
|
+
return {}
|
|
475
|
+
per_test: dict[str, dict[str, str]] = {}
|
|
476
|
+
for raw_path, contexts in _json_object(value, "Python per-test numbits").items():
|
|
477
|
+
path = _source_path(raw_path, set(inventory), source_scope, workdir)
|
|
478
|
+
if path not in executed_by_path:
|
|
479
|
+
raise ValueError(f"Python per-test coverage names an unmeasured source: {path}")
|
|
480
|
+
total, covered, line_count = executed_by_path[path]
|
|
481
|
+
executed = {int(line) for line in covered}
|
|
482
|
+
missing = {int(line) for line in total} - executed
|
|
483
|
+
for name, encoded in _json_object(contexts, "Python per-test contexts").items():
|
|
484
|
+
if not isinstance(encoded, str):
|
|
485
|
+
raise ValueError("Python per-test numbits must be base64 strings")
|
|
486
|
+
lines = numbits_to_lines(encoded) - {0} # coverage.py's module-entry line
|
|
487
|
+
# Traced non-statement lines (decorators, docstrings) stay outside the population;
|
|
488
|
+
# a statement the report calls missing, or a line outside the file, is an error.
|
|
489
|
+
if lines & missing or any(not 1 <= line <= line_count for line in lines):
|
|
490
|
+
raise ValueError("Python per-test coverage names an unexecuted line")
|
|
491
|
+
statements = lines & executed
|
|
492
|
+
if name and statements:
|
|
493
|
+
per_test.setdefault(name, {})[path] = lines_to_numbits(statements)
|
|
494
|
+
return dict(sorted(per_test.items()))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _go_modules(snapshot: Path, files: set[str]) -> dict[str, list[PurePosixPath]]:
|
|
498
|
+
modules: dict[str, list[PurePosixPath]] = {}
|
|
499
|
+
for name in files:
|
|
500
|
+
if PurePosixPath(name).name != "go.mod":
|
|
501
|
+
continue
|
|
502
|
+
declarations = re.findall(
|
|
503
|
+
r'^\s*module\s+(?:"([^"\n]+)"|([^\s]+))\s*(?://[^\n]*)?$',
|
|
504
|
+
(snapshot / name).read_text(),
|
|
505
|
+
re.M,
|
|
506
|
+
)
|
|
507
|
+
if len(declarations) != 1:
|
|
508
|
+
raise ValueError(f"Cannot establish frozen Go module identity: {name}")
|
|
509
|
+
module = declarations[0][0] or declarations[0][1]
|
|
510
|
+
modules.setdefault(module, []).append(PurePosixPath(name).parent)
|
|
511
|
+
return modules
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _go_source(
|
|
515
|
+
raw_path: str,
|
|
516
|
+
*,
|
|
517
|
+
files: set[str],
|
|
518
|
+
modules: dict[str, list[PurePosixPath]],
|
|
519
|
+
source_scope: list[str],
|
|
520
|
+
workdir: str,
|
|
521
|
+
) -> str:
|
|
522
|
+
if raw_path in files or raw_path.startswith(("/", "./", "../")):
|
|
523
|
+
return _source_path(raw_path, files, source_scope, workdir)
|
|
524
|
+
matches = [module for module in modules if raw_path.startswith(module + "/")]
|
|
525
|
+
if not matches:
|
|
526
|
+
raise ValueError(f"Go source cannot be mapped to a frozen module: {raw_path}")
|
|
527
|
+
module = max(matches, key=len)
|
|
528
|
+
directories = modules[module]
|
|
529
|
+
if len(directories) != 1:
|
|
530
|
+
raise ValueError(f"Go source cannot be mapped to one frozen module: {raw_path}")
|
|
531
|
+
path = (directories[0] / raw_path[len(module) + 1 :]).as_posix()
|
|
532
|
+
return _source_path(path, files, source_scope, workdir)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def parse_go_coverage(
|
|
536
|
+
report: Path, *, snapshot: Path, source_scope: list[str], test_scope: list[str], workdir: str
|
|
537
|
+
) -> dict[str, object]:
|
|
538
|
+
"""Keep basic-block coordinates and numStmt weights; Go measures statements."""
|
|
539
|
+
rows = report.read_text().splitlines()
|
|
540
|
+
if not rows or rows[0] not in {"mode: set", "mode: count", "mode: atomic"}:
|
|
541
|
+
raise ValueError("Invalid Go coverprofile mode")
|
|
542
|
+
inventory = inspect_snapshot(snapshot)["files"]
|
|
543
|
+
assert isinstance(inventory, dict)
|
|
544
|
+
files = set(inventory)
|
|
545
|
+
modules = _go_modules(snapshot, files)
|
|
546
|
+
source_lines: dict[str, list[str]] = {}
|
|
547
|
+
records: dict[str, _Record] = {}
|
|
548
|
+
weights: dict[str, int] = {}
|
|
549
|
+
covered: set[str] = set()
|
|
550
|
+
for row in rows[1:]:
|
|
551
|
+
match = re.fullmatch(r"(.+):([0-9]+)\.([0-9]+),([0-9]+)\.([0-9]+) ([0-9]+) ([0-9]+)", row)
|
|
552
|
+
if not match:
|
|
553
|
+
raise ValueError(f"Malformed Go coverprofile block: {row}")
|
|
554
|
+
raw_path, *values = match.groups()
|
|
555
|
+
path = _go_source(
|
|
556
|
+
raw_path, files=files, modules=modules, source_scope=source_scope, workdir=workdir
|
|
557
|
+
)
|
|
558
|
+
if not path.endswith(".go"):
|
|
559
|
+
raise ValueError(f"Go coverage source is not Go: {path}")
|
|
560
|
+
if path not in records:
|
|
561
|
+
source_lines[path] = (snapshot / path).read_text().splitlines()
|
|
562
|
+
records[path] = _Record(path, len(source_lines[path]))
|
|
563
|
+
lines = source_lines[path]
|
|
564
|
+
record = records[path]
|
|
565
|
+
start, start_col, end, end_col, statements, hits = map(_integer, values)
|
|
566
|
+
record.line(str(start))
|
|
567
|
+
record.line(str(end))
|
|
568
|
+
if not (
|
|
569
|
+
1 <= start_col <= len(lines[start - 1].encode()) + 1
|
|
570
|
+
and 1 <= end_col <= len(lines[end - 1].encode()) + 1
|
|
571
|
+
and (start, start_col) < (end, end_col)
|
|
572
|
+
):
|
|
573
|
+
raise ValueError(f"Go block escapes source span: {row}")
|
|
574
|
+
if rows[0] == "mode: set" and hits not in {0, 1}:
|
|
575
|
+
raise ValueError("Go set-mode hit count must be zero or one")
|
|
576
|
+
coordinate = f"{path}:{start}.{start_col},{end}.{end_col}"
|
|
577
|
+
if coordinate in weights:
|
|
578
|
+
raise ValueError(f"Duplicate Go coverage block: {coordinate}")
|
|
579
|
+
weights[coordinate] = statements
|
|
580
|
+
if hits:
|
|
581
|
+
covered.add(coordinate)
|
|
582
|
+
if not records:
|
|
583
|
+
raise CoveragePopulationUnavailable("Go report contains no measured source blocks")
|
|
584
|
+
result = _metadata("go-coverprofile", list(records.values()), source_scope, test_scope)
|
|
585
|
+
result["mode"] = rows[0][6:]
|
|
586
|
+
population = _population(set(weights), covered, list(records), "statements")
|
|
587
|
+
population["weights"] = weights
|
|
588
|
+
result["statements"] = population
|
|
589
|
+
return result
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def parse_coverage(
|
|
593
|
+
format: str,
|
|
594
|
+
report: Path,
|
|
595
|
+
*,
|
|
596
|
+
snapshot: Path,
|
|
597
|
+
source_scope: list[str],
|
|
598
|
+
test_scope: list[str],
|
|
599
|
+
workdir: str,
|
|
600
|
+
) -> dict[str, object]:
|
|
601
|
+
parsers = {
|
|
602
|
+
"lcov": parse_lcov,
|
|
603
|
+
"coverage-py-json": parse_python_coverage,
|
|
604
|
+
"go-coverprofile": parse_go_coverage,
|
|
605
|
+
}
|
|
606
|
+
if format not in parsers:
|
|
607
|
+
raise ValueError(f"Unsupported coverage format: {format}")
|
|
608
|
+
return parsers[format](
|
|
609
|
+
report, snapshot=snapshot, source_scope=source_scope, test_scope=test_scope, workdir=workdir
|
|
610
|
+
)
|