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,591 @@
|
|
|
1
|
+
"""Execute declared measurements on disposable copies and bind their receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
15
|
+
|
|
16
|
+
from pluginbench.assessment_metrics import (
|
|
17
|
+
EvidenceBinding,
|
|
18
|
+
bind_evidence,
|
|
19
|
+
evaluate_evidence,
|
|
20
|
+
inspect_snapshot,
|
|
21
|
+
)
|
|
22
|
+
from pluginbench import components, isolation
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Collector(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
kind: Literal["fallow", "analysis", "coverage"]
|
|
29
|
+
config: Path
|
|
30
|
+
scope: list[str] = Field(default_factory=lambda: ["src/**"], min_length=1)
|
|
31
|
+
command: list[str] | None = None
|
|
32
|
+
tool_version: str | None = None
|
|
33
|
+
syntax_command: list[str] | None = None
|
|
34
|
+
timeout_seconds: int = Field(default=120, ge=1, le=7200)
|
|
35
|
+
failure_protocol: Literal["coverage-exit-v1"] | None = None
|
|
36
|
+
report_format: Literal["lcov", "coverage-py-json", "go-coverprofile"] | None = None
|
|
37
|
+
report_path: str | None = None
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def supported_command(self) -> Collector:
|
|
41
|
+
if self.kind == "coverage" and self.command is None:
|
|
42
|
+
raise ValueError("coverage requires an adapter command")
|
|
43
|
+
if self.kind in {"fallow", "analysis"} and self.command is not None:
|
|
44
|
+
raise ValueError("Source analysis uses the built-in collector, not a custom command")
|
|
45
|
+
if self.syntax_command is not None and (self.kind != "fallow" or not self.syntax_command):
|
|
46
|
+
raise ValueError("syntax_command is an optional Fallow parser validator")
|
|
47
|
+
if self.command is not None and (not self.command or not self.tool_version):
|
|
48
|
+
raise ValueError("custom collectors require command and tool_version")
|
|
49
|
+
if self.failure_protocol is not None and (self.kind != "coverage" or self.command is None):
|
|
50
|
+
raise ValueError("coverage failure protocol requires a custom coverage adapter")
|
|
51
|
+
if self.report_format is not None:
|
|
52
|
+
from pathlib import PurePosixPath
|
|
53
|
+
|
|
54
|
+
report = PurePosixPath(self.report_path or ".")
|
|
55
|
+
if (
|
|
56
|
+
self.kind != "coverage"
|
|
57
|
+
or self.failure_protocol is not None
|
|
58
|
+
or report.is_absolute()
|
|
59
|
+
or ".." in report.parts
|
|
60
|
+
or report.as_posix() == "."
|
|
61
|
+
or "\\" in (self.report_path or "")
|
|
62
|
+
):
|
|
63
|
+
raise ValueError("native coverage requires a safe relative report path")
|
|
64
|
+
elif self.report_path is not None:
|
|
65
|
+
raise ValueError("report_path requires an explicit report format")
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _json(path: Path, data: object) -> None:
|
|
70
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _names_file(path: Path) -> bool:
|
|
74
|
+
"""Whether path is a file; an argument too long to be a path, such as a script, is not."""
|
|
75
|
+
try:
|
|
76
|
+
return path.is_file()
|
|
77
|
+
except OSError:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_collector_command(base: Path, command: list[str]) -> list[str]:
|
|
82
|
+
"""Resolve local arguments without dereferencing a venv executable symlink."""
|
|
83
|
+
resolved = []
|
|
84
|
+
for index, argument in enumerate(command):
|
|
85
|
+
expanded = Path(argument).expanduser()
|
|
86
|
+
candidate = expanded if expanded.is_absolute() else base / expanded
|
|
87
|
+
if index == 0:
|
|
88
|
+
explicit_path = "/" in argument or argument.startswith("~")
|
|
89
|
+
resolved.append(
|
|
90
|
+
str(candidate.absolute()) if explicit_path and _names_file(candidate) else argument
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
resolved.append(str(candidate.resolve()) if _names_file(candidate) else argument)
|
|
94
|
+
return resolved
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def freeze_collectors(collectors: list[Collector], frozen_dir: Path) -> list[Collector]:
|
|
98
|
+
result = []
|
|
99
|
+
for index, collector in enumerate(collectors):
|
|
100
|
+
destination = frozen_dir / str(index)
|
|
101
|
+
destination.mkdir(parents=True)
|
|
102
|
+
config = json.loads(collector.config.read_text())
|
|
103
|
+
if not isinstance(config, dict) or config.get("extends"):
|
|
104
|
+
raise ValueError("collector config must be resolved JSON without extends")
|
|
105
|
+
shutil.copy2(collector.config, destination / "config.json")
|
|
106
|
+
commands = {}
|
|
107
|
+
for field in ("command", "syntax_command"):
|
|
108
|
+
original = getattr(collector, field)
|
|
109
|
+
command = list(original) if original else None
|
|
110
|
+
if command and not (field == "command" and collector.report_format is not None):
|
|
111
|
+
for position, argument in enumerate(command[1:], 1):
|
|
112
|
+
source = Path(argument)
|
|
113
|
+
if source.is_file():
|
|
114
|
+
if source.is_symlink():
|
|
115
|
+
raise ValueError("collector script cannot be a symlink")
|
|
116
|
+
target = destination / f"{field}-{position}{source.suffix}"
|
|
117
|
+
shutil.copy2(source, target)
|
|
118
|
+
command[position] = str(target.resolve())
|
|
119
|
+
commands[field] = command
|
|
120
|
+
result.append(
|
|
121
|
+
collector.model_copy(update={"config": destination / "config.json", **commands})
|
|
122
|
+
)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _outside_job(path: Path, root: Path) -> bool:
|
|
127
|
+
"""Only canonical frozen paths can declare reads outside a submitted job."""
|
|
128
|
+
try:
|
|
129
|
+
canonical = path.resolve(strict=True)
|
|
130
|
+
return path.absolute() == canonical and not any(
|
|
131
|
+
canonical.is_relative_to(job) for job in (root.absolute(), root.resolve(strict=True))
|
|
132
|
+
)
|
|
133
|
+
except (OSError, RuntimeError):
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _execute(
|
|
138
|
+
argv: list[str],
|
|
139
|
+
root: Path,
|
|
140
|
+
timeout: int,
|
|
141
|
+
*,
|
|
142
|
+
output: Path | None = None,
|
|
143
|
+
config: Path | None = None,
|
|
144
|
+
) -> subprocess.CompletedProcess[str]:
|
|
145
|
+
env = {key: value for key, value in os.environ.items() if key in {"PATH", "LANG", "LC_ALL"}}
|
|
146
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
147
|
+
readable: list[Path] = []
|
|
148
|
+
if config is not None and config.is_file() and _outside_job(config, root):
|
|
149
|
+
readable.extend(isolation.referenced_paths(json.loads(config.read_text())))
|
|
150
|
+
for argument in argv[1:]:
|
|
151
|
+
script = Path(argument)
|
|
152
|
+
if (
|
|
153
|
+
script.is_absolute()
|
|
154
|
+
and script.suffix == ".py"
|
|
155
|
+
and script.is_file()
|
|
156
|
+
and _outside_job(script, root)
|
|
157
|
+
):
|
|
158
|
+
try:
|
|
159
|
+
readable.extend(isolation.verifier_paths(script))
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
# Dynamic PAYLOAD declarations grant nothing; execution still records their result.
|
|
162
|
+
pass
|
|
163
|
+
return isolation.run(
|
|
164
|
+
argv,
|
|
165
|
+
cwd=root,
|
|
166
|
+
env=env,
|
|
167
|
+
timeout=timeout,
|
|
168
|
+
readable=[path for path in readable if _outside_job(path, root)],
|
|
169
|
+
writable=[output] if output else [],
|
|
170
|
+
receipt=output / "runtime-isolation.json" if output else None,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class CollectorUnavailable(RuntimeError):
|
|
175
|
+
"""A collector could not execute; its output is not a measurement."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def observer_identity() -> dict[str, str]:
|
|
179
|
+
name = "coverage_report.py"
|
|
180
|
+
return {name: hashlib.sha256(Path(__file__).with_name(name).read_bytes()).hexdigest()}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def container_runtime(collector: Collector) -> dict[str, object] | None:
|
|
184
|
+
"""The saved runtime of the Docker container this collector starts, if it starts one.
|
|
185
|
+
|
|
186
|
+
Native coverage starts one, and so does Rust analysis for its unused diagnostics.
|
|
187
|
+
"""
|
|
188
|
+
if collector.kind == "fallow" or (
|
|
189
|
+
collector.kind == "coverage" and collector.report_format is None
|
|
190
|
+
):
|
|
191
|
+
return None
|
|
192
|
+
config = json.loads(collector.config.read_text())
|
|
193
|
+
if not isinstance(config, dict):
|
|
194
|
+
return None # Measuring it refuses the profile by name.
|
|
195
|
+
if collector.kind == "analysis" and config.get("language") != "rust":
|
|
196
|
+
return None
|
|
197
|
+
identity = config.get("native_task_identity")
|
|
198
|
+
runtime = identity.get("isolation") if isinstance(identity, dict) else None
|
|
199
|
+
return runtime if isinstance(runtime, dict) else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _assert_observer(collector: Collector) -> None:
|
|
203
|
+
assert collector.report_format is not None
|
|
204
|
+
config = json.loads(collector.config.read_text())
|
|
205
|
+
if not isinstance(config, dict):
|
|
206
|
+
raise components.ObserverError(f"it is not a JSON object: {components.OBSERVER_NEXT_STEP}")
|
|
207
|
+
version = config.get("observer_version")
|
|
208
|
+
if version is None:
|
|
209
|
+
version = components.saved_version("coverage_observer", config.get("implementation") or {})
|
|
210
|
+
current = components.VERSIONS["coverage_observer"]
|
|
211
|
+
if version != current:
|
|
212
|
+
raise components.ObserverError(
|
|
213
|
+
f"coverage observer version {version} froze this profile, and this PluginBench "
|
|
214
|
+
f"measures with version {current}: {components.OBSERVER_NEXT_STEP}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def collector_identity(collector: Collector) -> dict[str, object]:
|
|
219
|
+
"""Bind failures to the same frozen config, scope and adapter as successes."""
|
|
220
|
+
return {
|
|
221
|
+
"kind": collector.kind,
|
|
222
|
+
"config_digest": "sha256:" + hashlib.sha256(collector.config.read_bytes()).hexdigest(),
|
|
223
|
+
"scope": collector.scope,
|
|
224
|
+
"tool_version": collector.tool_version,
|
|
225
|
+
"command": collector.command,
|
|
226
|
+
"syntax_command": collector.syntax_command,
|
|
227
|
+
"timeout_seconds": collector.timeout_seconds,
|
|
228
|
+
"failure_protocol": collector.failure_protocol,
|
|
229
|
+
"report_format": collector.report_format,
|
|
230
|
+
"report_path": collector.report_path,
|
|
231
|
+
"scripts": {
|
|
232
|
+
argument: "sha256:" + hashlib.sha256(Path(argument).read_bytes()).hexdigest()
|
|
233
|
+
for command in (
|
|
234
|
+
collector.command if collector.report_format is None else None,
|
|
235
|
+
collector.syntax_command,
|
|
236
|
+
)
|
|
237
|
+
for argument in (command or [])[1:]
|
|
238
|
+
if Path(argument).is_file()
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _recorded_execute(
|
|
244
|
+
argv: list[str],
|
|
245
|
+
root: Path,
|
|
246
|
+
timeout: int,
|
|
247
|
+
destination: Path,
|
|
248
|
+
receipts: list[dict[str, object]],
|
|
249
|
+
*,
|
|
250
|
+
config: Path | None = None,
|
|
251
|
+
) -> subprocess.CompletedProcess[str]:
|
|
252
|
+
filesystem_receipt = destination / "runtime-isolation.json"
|
|
253
|
+
try:
|
|
254
|
+
result = _execute(argv, root, timeout, output=destination, config=config)
|
|
255
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
256
|
+
receipts.append(
|
|
257
|
+
{
|
|
258
|
+
"command": argv,
|
|
259
|
+
"exit_code": None,
|
|
260
|
+
"error": str(exc),
|
|
261
|
+
"stdout": str(getattr(exc, "stdout", "") or ""),
|
|
262
|
+
"stderr": str(getattr(exc, "stderr", "") or ""),
|
|
263
|
+
"filesystem": json.loads(filesystem_receipt.read_text())
|
|
264
|
+
if filesystem_receipt.is_file()
|
|
265
|
+
else None,
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
_json(destination / "execution.json", receipts)
|
|
269
|
+
raise CollectorUnavailable(str(exc)) from exc
|
|
270
|
+
receipts.append(
|
|
271
|
+
{
|
|
272
|
+
"command": argv,
|
|
273
|
+
"exit_code": result.returncode,
|
|
274
|
+
"stdout": result.stdout,
|
|
275
|
+
"stderr": result.stderr,
|
|
276
|
+
"filesystem": json.loads(filesystem_receipt.read_text())
|
|
277
|
+
if filesystem_receipt.is_file()
|
|
278
|
+
else None,
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
_json(destination / "execution.json", receipts)
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _fallow(root: Path, collector: Collector, destination: Path) -> tuple[dict[str, object], str]:
|
|
286
|
+
argv = [
|
|
287
|
+
"fallow",
|
|
288
|
+
"--root",
|
|
289
|
+
str(root),
|
|
290
|
+
"--config",
|
|
291
|
+
str(collector.config.resolve()),
|
|
292
|
+
"--format",
|
|
293
|
+
"json",
|
|
294
|
+
"--quiet",
|
|
295
|
+
"--explain",
|
|
296
|
+
"--no-cache",
|
|
297
|
+
]
|
|
298
|
+
receipts: list[dict[str, object]] = []
|
|
299
|
+
outputs = []
|
|
300
|
+
for suffix in ([], ["list", "--files"]):
|
|
301
|
+
execution = _recorded_execute(
|
|
302
|
+
argv + suffix,
|
|
303
|
+
root,
|
|
304
|
+
collector.timeout_seconds,
|
|
305
|
+
destination,
|
|
306
|
+
receipts,
|
|
307
|
+
config=collector.config,
|
|
308
|
+
)
|
|
309
|
+
if execution.returncode not in (0, 1):
|
|
310
|
+
raise CollectorUnavailable(f"Fallow exited {execution.returncode}: {execution.stderr}")
|
|
311
|
+
outputs.append(json.loads(execution.stdout))
|
|
312
|
+
report, discovery = outputs
|
|
313
|
+
if (
|
|
314
|
+
not isinstance(report, dict)
|
|
315
|
+
or report.get("kind") != "combined"
|
|
316
|
+
or not isinstance(discovery, dict)
|
|
317
|
+
):
|
|
318
|
+
raise ValueError("unexpected Fallow output")
|
|
319
|
+
report["discovery"] = discovery
|
|
320
|
+
if collector.syntax_command:
|
|
321
|
+
inputs = destination / "syntax-inputs.json"
|
|
322
|
+
output = destination / "syntax-validation.json"
|
|
323
|
+
_json(inputs, discovery)
|
|
324
|
+
syntax_argv = [
|
|
325
|
+
argument.replace("{snapshot}", str(root))
|
|
326
|
+
.replace("{inputs}", str(inputs.resolve()))
|
|
327
|
+
.replace("{output}", str(output.resolve()))
|
|
328
|
+
for argument in collector.syntax_command
|
|
329
|
+
]
|
|
330
|
+
execution = _recorded_execute(
|
|
331
|
+
syntax_argv,
|
|
332
|
+
root,
|
|
333
|
+
collector.timeout_seconds,
|
|
334
|
+
destination,
|
|
335
|
+
receipts,
|
|
336
|
+
config=collector.config,
|
|
337
|
+
)
|
|
338
|
+
receipt = receipts[-1]
|
|
339
|
+
if execution.returncode:
|
|
340
|
+
raise CollectorUnavailable(
|
|
341
|
+
f"syntax validator exited {execution.returncode}: {execution.stderr}"
|
|
342
|
+
)
|
|
343
|
+
validation = json.loads(output.read_text())
|
|
344
|
+
if not isinstance(validation, dict):
|
|
345
|
+
raise ValueError("syntax validation must be an object")
|
|
346
|
+
from hashlib import sha256
|
|
347
|
+
|
|
348
|
+
validation["config_digest"] = "sha256:" + sha256(collector.config.read_bytes()).hexdigest()
|
|
349
|
+
validation["producer_version"] = report.get("version")
|
|
350
|
+
validation["producer_execution"] = receipts[:2]
|
|
351
|
+
validation["validator_execution"] = receipt
|
|
352
|
+
validation["validator_files"] = {
|
|
353
|
+
str(index): "sha256:" + sha256(Path(argument).read_bytes()).hexdigest()
|
|
354
|
+
for index, argument in enumerate(collector.syntax_command[1:], 1)
|
|
355
|
+
if Path(argument).is_file()
|
|
356
|
+
}
|
|
357
|
+
report["syntax_validation"] = validation
|
|
358
|
+
return report, str(report["version"])
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _native_coverage(
|
|
362
|
+
root: Path, collector: Collector, destination: Path
|
|
363
|
+
) -> tuple[dict[str, object], str]:
|
|
364
|
+
from pluginbench.coverage_report import CoveragePopulationUnavailable, parse_coverage
|
|
365
|
+
from pluginbench.harbor_backend import run_native_command
|
|
366
|
+
|
|
367
|
+
config = json.loads(collector.config.read_text())
|
|
368
|
+
identity = config.get("native_task_identity")
|
|
369
|
+
if not isinstance(identity, dict):
|
|
370
|
+
raise ValueError("native coverage requires a frozen task identity")
|
|
371
|
+
assert collector.command and collector.report_path and collector.tool_version
|
|
372
|
+
assert collector.report_format is not None
|
|
373
|
+
sidecar_path = Path(collector.report_path + ".per-test.json")
|
|
374
|
+
if collector.report_format == "lcov" and (root / sidecar_path).exists():
|
|
375
|
+
raise ValueError("LCOV per-test sidecar must not exist in the frozen input snapshot")
|
|
376
|
+
execution = run_native_command(
|
|
377
|
+
snapshot=root,
|
|
378
|
+
task_identity=identity,
|
|
379
|
+
destination=destination,
|
|
380
|
+
command=collector.command,
|
|
381
|
+
report_path=collector.report_path,
|
|
382
|
+
timeout=collector.timeout_seconds,
|
|
383
|
+
)
|
|
384
|
+
if execution.get("timed_out"):
|
|
385
|
+
raise CollectorUnavailable("coverage command failed or timed out; see execution.json")
|
|
386
|
+
if not execution.get("report_collected") or not (destination / "report.raw").is_file():
|
|
387
|
+
status = execution.get("exit_code")
|
|
388
|
+
reason = (
|
|
389
|
+
"coverage tool/command unavailable"
|
|
390
|
+
if status == 127
|
|
391
|
+
else f"test command exited {status}; no usable coverage report produced"
|
|
392
|
+
)
|
|
393
|
+
raise CollectorUnavailable(reason + "; see execution.json")
|
|
394
|
+
workdir = identity.get("workdir")
|
|
395
|
+
test_scope = config.get("test_scope")
|
|
396
|
+
if (
|
|
397
|
+
not isinstance(workdir, str)
|
|
398
|
+
or not isinstance(test_scope, list)
|
|
399
|
+
or not all(isinstance(pattern, str) for pattern in test_scope)
|
|
400
|
+
):
|
|
401
|
+
raise ValueError("native coverage identity requires workdir and explicit test_scope")
|
|
402
|
+
if collector.report_format == "lcov":
|
|
403
|
+
# The native source export already retains sibling report artifacts.
|
|
404
|
+
sidecar = destination / "after" / sidecar_path
|
|
405
|
+
if sidecar.is_file():
|
|
406
|
+
shutil.copyfile(sidecar, destination / "report.raw.per-test.json")
|
|
407
|
+
try:
|
|
408
|
+
report = parse_coverage(
|
|
409
|
+
collector.report_format,
|
|
410
|
+
destination / "report.raw",
|
|
411
|
+
snapshot=root,
|
|
412
|
+
source_scope=collector.scope,
|
|
413
|
+
test_scope=test_scope,
|
|
414
|
+
workdir=workdir,
|
|
415
|
+
)
|
|
416
|
+
except CoveragePopulationUnavailable as exc:
|
|
417
|
+
raise CollectorUnavailable(str(exc)) from exc
|
|
418
|
+
if (
|
|
419
|
+
collector.report_format == "coverage-py-json"
|
|
420
|
+
and report.get("tool_version") != collector.tool_version
|
|
421
|
+
):
|
|
422
|
+
raise ValueError("coverage.py output version differs from frozen collector")
|
|
423
|
+
report["test_command_exit_code"] = execution.get("exit_code")
|
|
424
|
+
return report, collector.tool_version
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _run_collector(
|
|
428
|
+
root: Path, collector: Collector, destination: Path, baseline: Path | None
|
|
429
|
+
) -> tuple[dict[str, object], str, str]:
|
|
430
|
+
"""Collect one measure; a profile this build's observer cannot measure is named."""
|
|
431
|
+
try:
|
|
432
|
+
return _observed(root, collector, destination, baseline)
|
|
433
|
+
except components.UnknownSourceError as error:
|
|
434
|
+
raise ValueError(
|
|
435
|
+
f"Measurement profile {collector.config}: {error}; {components.OBSERVER_NEXT_STEP}"
|
|
436
|
+
) from error
|
|
437
|
+
except components.ObserverError as error:
|
|
438
|
+
raise ValueError(f"Measurement profile {collector.config}: {error}") from error
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _observed(
|
|
442
|
+
root: Path, collector: Collector, destination: Path, baseline: Path | None
|
|
443
|
+
) -> tuple[dict[str, object], str, str]:
|
|
444
|
+
if collector.report_format is None:
|
|
445
|
+
return _collect(root, collector, destination, baseline)
|
|
446
|
+
_assert_observer(collector)
|
|
447
|
+
implementation = observer_identity()
|
|
448
|
+
try:
|
|
449
|
+
return _collect(root, collector, destination, baseline)
|
|
450
|
+
finally:
|
|
451
|
+
# The profile binds a version, not bytes: an edit during collection must not mix
|
|
452
|
+
# two implementations in one result.
|
|
453
|
+
if observer_identity() != implementation:
|
|
454
|
+
raise components.ObserverError(
|
|
455
|
+
"the coverage observer's bytes changed during execution: measure again with "
|
|
456
|
+
"an unchanged PluginBench"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _collect(
|
|
461
|
+
root: Path, collector: Collector, destination: Path, baseline: Path | None
|
|
462
|
+
) -> tuple[dict[str, object], str, str]:
|
|
463
|
+
if collector.kind == "fallow":
|
|
464
|
+
report, version = _fallow(root, collector, destination)
|
|
465
|
+
return report, version, "fallow"
|
|
466
|
+
if collector.kind == "analysis":
|
|
467
|
+
from pluginbench.language_analysis import collect_language_analysis
|
|
468
|
+
|
|
469
|
+
report, version = collect_language_analysis(
|
|
470
|
+
root,
|
|
471
|
+
json.loads(collector.config.read_text()),
|
|
472
|
+
destination,
|
|
473
|
+
timeout=collector.timeout_seconds,
|
|
474
|
+
)
|
|
475
|
+
return report, version, "language-analysis"
|
|
476
|
+
if collector.report_format is not None:
|
|
477
|
+
report, version = _native_coverage(root, collector, destination)
|
|
478
|
+
return report, version, str(collector.report_format)
|
|
479
|
+
assert collector.command and collector.tool_version
|
|
480
|
+
output = destination / "report.json"
|
|
481
|
+
argv = [
|
|
482
|
+
argument.replace("{snapshot}", str(root))
|
|
483
|
+
.replace("{output}", str(output.resolve()))
|
|
484
|
+
.replace("{before}", str(baseline) if baseline is not None else "")
|
|
485
|
+
for argument in collector.command
|
|
486
|
+
]
|
|
487
|
+
receipts: list[dict[str, object]] = []
|
|
488
|
+
try:
|
|
489
|
+
execution = _recorded_execute(
|
|
490
|
+
argv,
|
|
491
|
+
root,
|
|
492
|
+
collector.timeout_seconds,
|
|
493
|
+
destination,
|
|
494
|
+
receipts,
|
|
495
|
+
config=collector.config,
|
|
496
|
+
)
|
|
497
|
+
except CollectorUnavailable as exc:
|
|
498
|
+
if collector.failure_protocol is not None:
|
|
499
|
+
raise ValueError("trusted coverage adapter failure was not classified") from exc
|
|
500
|
+
raise
|
|
501
|
+
if execution.returncode:
|
|
502
|
+
if collector.failure_protocol is not None and execution.returncode != 3:
|
|
503
|
+
raise ValueError(
|
|
504
|
+
f"trusted coverage adapter rejected evidence (exit {execution.returncode}): {execution.stderr}"
|
|
505
|
+
)
|
|
506
|
+
raise CollectorUnavailable(f"collector exited {execution.returncode}: {execution.stderr}")
|
|
507
|
+
report = json.loads(output.read_text())
|
|
508
|
+
return report, collector.tool_version, collector.command[0]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def collect_measurements(
|
|
512
|
+
snapshot: Path, evidence_dir: Path, collectors: list[Collector], *, before: Path | None = None
|
|
513
|
+
) -> dict[str, Path]:
|
|
514
|
+
results: dict[str, Path] = {}
|
|
515
|
+
for collector in collectors:
|
|
516
|
+
destination = evidence_dir / collector.kind
|
|
517
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
518
|
+
identity = collector_identity(collector)
|
|
519
|
+
source_digest = inspect_snapshot(snapshot)["digest"]
|
|
520
|
+
before_digest = inspect_snapshot(before)["digest"] if before is not None else None
|
|
521
|
+
try:
|
|
522
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-measure-") as temporary:
|
|
523
|
+
root = (Path(temporary) / "source").resolve()
|
|
524
|
+
shutil.copytree(snapshot, root)
|
|
525
|
+
baseline = None
|
|
526
|
+
if (
|
|
527
|
+
collector.report_format is None
|
|
528
|
+
and collector.command
|
|
529
|
+
and any("{before}" in item for item in collector.command)
|
|
530
|
+
):
|
|
531
|
+
if before is None:
|
|
532
|
+
raise ValueError("collector command requires a bound before snapshot")
|
|
533
|
+
baseline = Path(temporary) / "before"
|
|
534
|
+
shutil.copytree(before, baseline)
|
|
535
|
+
try:
|
|
536
|
+
report, version, tool = _run_collector(root, collector, destination, baseline)
|
|
537
|
+
finally:
|
|
538
|
+
# Failed commands are not exempt from source/profile integrity checks.
|
|
539
|
+
if inspect_snapshot(root)["digest"] != source_digest:
|
|
540
|
+
raise ValueError("collector modified measured source")
|
|
541
|
+
if (
|
|
542
|
+
baseline is not None
|
|
543
|
+
and inspect_snapshot(baseline)["digest"] != before_digest
|
|
544
|
+
):
|
|
545
|
+
raise ValueError("collector modified its before snapshot")
|
|
546
|
+
if inspect_snapshot(snapshot)["digest"] != source_digest:
|
|
547
|
+
raise ValueError("collector modified archived measured source")
|
|
548
|
+
if before is not None and inspect_snapshot(before)["digest"] != before_digest:
|
|
549
|
+
raise ValueError("collector modified archived before snapshot")
|
|
550
|
+
if collector_identity(collector) != identity:
|
|
551
|
+
raise ValueError("collector modified its frozen profile")
|
|
552
|
+
_json(destination / "report.json", report)
|
|
553
|
+
shutil.copy2(collector.config, destination / "config.json")
|
|
554
|
+
envelope = bind_evidence(
|
|
555
|
+
EvidenceBinding(
|
|
556
|
+
snapshot=snapshot,
|
|
557
|
+
report=destination / "report.json",
|
|
558
|
+
config=destination / "config.json",
|
|
559
|
+
tool=tool,
|
|
560
|
+
tool_version=version,
|
|
561
|
+
scope=tuple(collector.scope),
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
envelope["config"], envelope["report"] = "config.json", "report.json"
|
|
565
|
+
_json(destination / "envelope.json", envelope)
|
|
566
|
+
evaluate_evidence(
|
|
567
|
+
snapshot,
|
|
568
|
+
{collector.kind: str((destination / "envelope.json").resolve())},
|
|
569
|
+
evidence_dir.resolve(),
|
|
570
|
+
)
|
|
571
|
+
results[collector.kind] = destination / "envelope.json"
|
|
572
|
+
except CollectorUnavailable as exc:
|
|
573
|
+
if not (destination / "execution.json").exists():
|
|
574
|
+
_json(destination / "execution.json", [])
|
|
575
|
+
_json(
|
|
576
|
+
destination / "failure.json",
|
|
577
|
+
{
|
|
578
|
+
"schema_version": 1,
|
|
579
|
+
"status": "unavailable",
|
|
580
|
+
"reason": str(exc),
|
|
581
|
+
"snapshot_digest": source_digest,
|
|
582
|
+
"before_digest": before_digest,
|
|
583
|
+
"collector": identity,
|
|
584
|
+
"execution": {
|
|
585
|
+
"path": "execution.json",
|
|
586
|
+
"sha256": "sha256:"
|
|
587
|
+
+ hashlib.sha256((destination / "execution.json").read_bytes()).hexdigest(),
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
)
|
|
591
|
+
return results
|