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.
Files changed (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. 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