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,915 @@
1
+ """Pinned, isolated source analyzers; unused findings are candidates, not defects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from copy import deepcopy
7
+ import fnmatch
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import shlex
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Callable
18
+
19
+ from pydantic import BaseModel, ConfigDict, model_validator
20
+
21
+ from pluginbench import components, isolation
22
+ from pluginbench.assessment_metrics import inspect_snapshot
23
+ from pluginbench.code_diff import _is_test_path
24
+
25
+ VERSION = "language-analysis-v1"
26
+ SUFFIXES = {"python": ".py", "go": ".go", "rust": ".rs"}
27
+ PINNED = {
28
+ "rca": ("rust-code-analysis-cli", "--version", "rust-code-analysis-cli 0.0.25"),
29
+ "vulture": ("vulture", "--version", "vulture 2.16"),
30
+ "go_analysis": (
31
+ "pluginbench-go-analysis",
32
+ "--version",
33
+ "go-analysis-v1 gocognit=1.3.0 gocyclo=0.6.0",
34
+ ),
35
+ "staticcheck": ("staticcheck", "-version", "staticcheck 2026.2.2 (0.8.2)"),
36
+ "go": ("go", "version", "go version go1.26.4"),
37
+ }
38
+
39
+ ANALYSIS_SETTINGS: dict[str, object] = {
40
+ "vulture_min_confidence": 60,
41
+ "staticcheck_checks": ["U1000"],
42
+ "staticcheck_tests": False,
43
+ "staticcheck_show_ignored": True,
44
+ "go_build": "host platform; no cgo; no downloads; readonly modules",
45
+ "go_cognitive": "pinned declaration API; direct recursion only; closures represented as declarations",
46
+ "rca_function_scores": "space sums minus immediate child-space sums",
47
+ "python_rca_population": "RCA emitted function spaces; anonymous closures have no separate score, not a known zero; enclosing scores may include closure complexity",
48
+ "rustc": "native exact Rust/Cargo 1.92.0 binaries; CLI-pinned compiler and forced environment; force-warn dead_code; offline locked Cargo; wrappers disabled; compiler dep-info closes source population",
49
+ }
50
+
51
+
52
+ def default_tool_paths(language: str) -> dict[str, str]:
53
+ """Use pinned optional observer installations; never install during a measurement."""
54
+ cache = (
55
+ Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
56
+ / "pluginbench/language-tools"
57
+ )
58
+ candidates = {
59
+ "rca": cache / "rca-0.0.25/bin/rust-code-analysis-cli",
60
+ "vulture": cache / "vulture-2.16/bin/vulture",
61
+ "staticcheck": cache / "go/staticcheck",
62
+ }
63
+ result = {name: str(path) for name, path in candidates.items() if path.is_file()}
64
+ if language == "go":
65
+ source = Path(__file__).parent / "observer_data/go-analysis-v1"
66
+ identity = hashlib.sha256(
67
+ b"".join((source / name).read_bytes() for name in ("go.mod", "go.sum", "main.go"))
68
+ ).hexdigest()
69
+ bridge = cache / "go-observer" / identity / "pluginbench-go-analysis"
70
+ if bridge.is_file():
71
+ result["go_analysis"] = str(bridge)
72
+ return result
73
+
74
+
75
+ def language_capability(language: str) -> dict[str, dict[str, object]]:
76
+ if language not in SUFFIXES:
77
+ return {
78
+ name: {
79
+ "status": "unavailable",
80
+ "reason": f"No pinned observer for {language}",
81
+ "configured": False,
82
+ }
83
+ for name in ("cognitive", "cyclomatic", "dead_code")
84
+ }
85
+ tools = probe_language_tools(language)
86
+ complexity = tools["go_analysis" if language == "go" else "rca"]
87
+ if language == "go" and complexity["path"] is None and tools["go"]["status"] == "available":
88
+ complexity = {
89
+ **complexity,
90
+ "status": "available",
91
+ "reason": None,
92
+ "requires_preparation": True,
93
+ "version": PINNED["go_analysis"][2],
94
+ }
95
+ unused = tools.get("vulture" if language == "python" else "staticcheck")
96
+ return {
97
+ "cognitive": {**complexity, "collector": "analysis"},
98
+ "cyclomatic": {**complexity, "collector": "analysis"},
99
+ "dead_code": {**unused, "collector": "analysis"}
100
+ if unused
101
+ else {
102
+ "reason": None,
103
+ "status": "available",
104
+ "requires_preparation": True,
105
+ "version": "rustc 1.92.0",
106
+ "collector": "analysis",
107
+ },
108
+ }
109
+
110
+
111
+ def prepare_analysis_config(
112
+ language: str,
113
+ source_scope: list[str],
114
+ test_scope: list[str],
115
+ native_task_identity: dict[str, object],
116
+ ) -> dict[str, object]:
117
+ tools = default_tool_paths(language)
118
+ if (
119
+ language == "go"
120
+ and "go_analysis" not in tools
121
+ and probe_language_tools(language, tools)["go"]["status"] == "available"
122
+ ):
123
+ cache = (
124
+ Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
125
+ / "pluginbench/language-tools/go-observer"
126
+ )
127
+ tools["go_analysis"] = str(prepare_go_observer(cache))
128
+ identities = probe_language_tools(language, tools)
129
+ if language == "rust" and native_task_identity:
130
+ image = _object(native_task_identity["image_identity"])
131
+ identities["rustc"] = {
132
+ "status": "available",
133
+ "reason": None,
134
+ "path": "native:rustc",
135
+ "version": "rustc 1.92.0 (ded5c06cf 2025-12-08)",
136
+ "cargo_version": "cargo 1.92.0 (344c4567c 2025-10-21)",
137
+ "image_id": image["image_id"],
138
+ }
139
+ return {
140
+ "language": language,
141
+ "source_scope": source_scope,
142
+ "test_scope": test_scope,
143
+ "native_task_identity": native_task_identity,
144
+ "tools": tools,
145
+ "tool_identities": identities,
146
+ "supported_suffixes": [SUFFIXES[language]],
147
+ "settings": deepcopy(ANALYSIS_SETTINGS),
148
+ "observer_version": components.VERSIONS["analysis_observer"],
149
+ "implementation_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
150
+ }
151
+
152
+
153
+ def _interpreter_identity(binary: Path) -> dict[str, object]:
154
+ binary = binary.resolve(strict=True)
155
+ return {
156
+ "path": str(binary),
157
+ "sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
158
+ "version": subprocess.check_output(
159
+ [str(binary), "--version"], text=True, cwd="/", timeout=10
160
+ ).strip(),
161
+ }
162
+
163
+
164
+ def _vulture_identity(path: Path) -> dict[str, object]:
165
+ digest = hashlib.sha256()
166
+ modules = sorted(path.parent.parent.glob("lib/python*/site-packages/vulture*"))
167
+ if not modules:
168
+ raise AnalysisUnavailable(
169
+ "Install pinned Vulture in an explicit venv; its distribution files could not be frozen"
170
+ )
171
+ for module in modules:
172
+ for asset in sorted(module.rglob("*")):
173
+ if asset.is_file() and "__pycache__" not in asset.parts:
174
+ digest.update(
175
+ asset.relative_to(module.parent).as_posix().encode()
176
+ + b"\0"
177
+ + asset.read_bytes()
178
+ + b"\0"
179
+ )
180
+ shebang = path.read_text().splitlines()[0]
181
+ if not shebang.startswith("#!/"):
182
+ raise ValueError("Pinned Vulture requires an absolute interpreter shebang")
183
+ return {
184
+ "assets_sha256": digest.hexdigest(),
185
+ "interpreter": _interpreter_identity(Path(shebang[2:])),
186
+ }
187
+
188
+
189
+ def _probe_binary(name: str, path: str | None) -> dict[str, object]:
190
+ _, flag, expected = PINNED[name]
191
+ reason = None
192
+ observed = None
193
+ if not path:
194
+ reason = f"Install the pinned {expected} observer"
195
+ else:
196
+ try:
197
+ observed = subprocess.check_output(
198
+ [str(Path(path).resolve()), flag],
199
+ text=True,
200
+ timeout=10,
201
+ cwd="/",
202
+ stderr=subprocess.STDOUT,
203
+ ).strip()
204
+ if observed != expected and not (name == "go" and observed.startswith(expected + " ")):
205
+ reason = f"Expected {expected}; observed {observed}"
206
+ except (OSError, subprocess.SubprocessError) as exc:
207
+ reason = f"Could not probe {name}: {exc}"
208
+ result: dict[str, object] = {
209
+ "status": "unavailable" if reason else "available",
210
+ "reason": reason,
211
+ "path": str(Path(path).resolve()) if path else None,
212
+ "version": observed,
213
+ "sha256": hashlib.sha256(Path(path).read_bytes()).hexdigest()
214
+ if path and not reason
215
+ else None,
216
+ }
217
+ if name == "vulture" and path and not reason:
218
+ try:
219
+ result.update(_vulture_identity(Path(path)))
220
+ except AnalysisUnavailable as exc:
221
+ result.update({"status": "unavailable", "reason": str(exc)})
222
+ return result
223
+
224
+
225
+ def probe_language_tools(
226
+ language: str, tools: dict[str, str] | None = None
227
+ ) -> dict[str, dict[str, object]]:
228
+ """Probe trusted executables only; never load the measured project's configuration."""
229
+ if language not in SUFFIXES:
230
+ raise ValueError(f"unsupported analysis language: {language}")
231
+ tools = tools or default_tool_paths(language)
232
+ names = ["go_analysis", "staticcheck", "go"] if language == "go" else ["rca"]
233
+ if language == "python":
234
+ names.append("vulture")
235
+ result = {
236
+ name: _probe_binary(name, tools.get(name) or shutil.which(PINNED[name][0]))
237
+ for name in names
238
+ }
239
+ if language == "python":
240
+ result["python_parser"] = {
241
+ "status": "available",
242
+ "reason": None,
243
+ **_interpreter_identity(Path(sys.executable)),
244
+ "version": sys.version,
245
+ }
246
+ return result
247
+
248
+
249
+ def prepare_go_observer(cache: Path) -> Path:
250
+ """Build only shipped observer source, with locked libraries, outside submissions."""
251
+ source = Path(__file__).parent / "observer_data/go-analysis-v1"
252
+ identity = hashlib.sha256(
253
+ b"".join((source / name).read_bytes() for name in ("go.mod", "go.sum", "main.go"))
254
+ ).hexdigest()
255
+ destination = cache.expanduser().resolve() / identity
256
+ binary = destination / "pluginbench-go-analysis"
257
+ if destination.resolve() != destination or any(
258
+ (destination / name).is_symlink()
259
+ for name in ("pluginbench-go-analysis", "receipt.json", "go.mod", "go.sum", "main.go")
260
+ ):
261
+ raise ValueError("Go observer cache cannot redirect outside its owned directory")
262
+ receipt = destination / "receipt.json"
263
+ if binary.is_file():
264
+ if (
265
+ not receipt.is_file()
266
+ or json.loads(receipt.read_text())
267
+ != {
268
+ "source_sha256": identity,
269
+ "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
270
+ }
271
+ or any(
272
+ (destination / name).read_bytes() != (source / name).read_bytes()
273
+ for name in ("go.mod", "go.sum", "main.go")
274
+ )
275
+ ):
276
+ raise ValueError("Go observer cache bytes changed")
277
+ return binary
278
+ destination.mkdir(parents=True, exist_ok=True)
279
+ for name in ("go.mod", "go.sum", "main.go"):
280
+ shutil.copy2(source / name, destination / name)
281
+ subprocess.run(
282
+ ["go", "build", "-mod=readonly", "-trimpath", "-o", str(binary), "."],
283
+ cwd=destination,
284
+ env={**os.environ, "GOTOOLCHAIN": "local", "GOENV": "off", "CGO_ENABLED": "0"},
285
+ check=True,
286
+ timeout=120,
287
+ )
288
+ receipt.write_text(
289
+ json.dumps(
290
+ {
291
+ "source_sha256": identity,
292
+ "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(),
293
+ }
294
+ )
295
+ + "\n"
296
+ )
297
+ return binary
298
+
299
+
300
+ class AnalysisUnavailable(RuntimeError):
301
+ """An analyzer could not establish its declared source population."""
302
+
303
+
304
+ def _execute(
305
+ argv: list[str], root: Path, destination: Path, readable: list[Path], timeout: int
306
+ ) -> str:
307
+ env = {key: value for key, value in os.environ.items() if key in {"PATH", "LANG", "LC_ALL"}}
308
+ go = shutil.which("go")
309
+ if go:
310
+ env["PATH"] = str(Path(go).resolve().parent) + os.pathsep + env.get("PATH", "")
311
+ env.update(
312
+ {
313
+ "PYTHONDONTWRITEBYTECODE": "1",
314
+ "GOENV": "off",
315
+ "GOTOOLCHAIN": "local",
316
+ "CGO_ENABLED": "0",
317
+ "GOPROXY": "off",
318
+ "GOSUMDB": "off",
319
+ "GOFLAGS": "-mod=readonly",
320
+ "GOCACHE": str(destination / "go-cache"),
321
+ "GOPATH": str(destination / "go-path"),
322
+ }
323
+ )
324
+ index = len(list(destination.glob("execution-*.json")))
325
+ receipt = destination / f"isolation-{index}.json"
326
+ try:
327
+ execution = isolation.run(
328
+ argv,
329
+ cwd=root,
330
+ env=env,
331
+ timeout=timeout,
332
+ readable=readable,
333
+ writable=[destination],
334
+ receipt=receipt,
335
+ )
336
+ except (OSError, subprocess.TimeoutExpired) as exc:
337
+ (destination / f"execution-{index}.json").write_text(
338
+ json.dumps({"argv": argv, "exit_code": None, "error": str(exc)}) + "\n"
339
+ )
340
+ raise AnalysisUnavailable(str(exc)) from exc
341
+ (destination / f"execution-{index}.json").write_text(
342
+ json.dumps(
343
+ {
344
+ "argv": argv,
345
+ "exit_code": execution.returncode,
346
+ "stdout": execution.stdout,
347
+ "stderr": execution.stderr,
348
+ }
349
+ )
350
+ + "\n"
351
+ )
352
+ # Vulture returns 3 for findings; Staticcheck uses 1 for diagnostics.
353
+ if execution.returncode not in (0, 1, 3):
354
+ raise AnalysisUnavailable(f"Analyzer exited {execution.returncode}: {execution.stderr}")
355
+ if execution.returncode and not execution.stdout:
356
+ raise AnalysisUnavailable(f"Analyzer failed: {execution.stderr}")
357
+ return execution.stdout
358
+
359
+
360
+ def _path(raw: str, root: Path) -> str:
361
+ path = Path(raw)
362
+ path = path if path.is_absolute() else root / path
363
+ resolved = path.resolve(strict=True)
364
+ if not resolved.is_relative_to(root.resolve()) or path.is_symlink():
365
+ raise ValueError("Analyzer location escapes measured source")
366
+ return resolved.relative_to(root.resolve()).as_posix()
367
+
368
+
369
+ def _score(value: object) -> int:
370
+ if (
371
+ isinstance(value, bool)
372
+ or not isinstance(value, (int, float))
373
+ or value < 0
374
+ or int(value) != value
375
+ ):
376
+ raise ValueError("Analyzer score must be a nonnegative integer")
377
+ return int(value)
378
+
379
+
380
+ class RCAMetric(BaseModel):
381
+ model_config = ConfigDict(strict=True)
382
+ sum: float
383
+
384
+
385
+ class RCANom(BaseModel):
386
+ model_config = ConfigDict(strict=True)
387
+ functions: float
388
+ closures: float
389
+ total: float
390
+
391
+ @model_validator(mode="after")
392
+ def consistent_counts(self) -> RCANom:
393
+ if _score(self.functions) + _score(self.closures) != _score(self.total):
394
+ raise ValueError("Analyzer function and closure counts must sum to total")
395
+ return self
396
+
397
+
398
+ class RCAMetrics(BaseModel):
399
+ model_config = ConfigDict(strict=True)
400
+ cognitive: RCAMetric
401
+ cyclomatic: RCAMetric
402
+ nom: RCANom
403
+
404
+
405
+ class RCASpace(BaseModel):
406
+ model_config = ConfigDict(strict=True)
407
+ name: str | None
408
+ start_line: int
409
+ end_line: int
410
+ kind: str
411
+ spaces: list[RCASpace]
412
+ metrics: RCAMetrics
413
+
414
+
415
+ def _object(value: object) -> dict[str, object]:
416
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
417
+ raise ValueError("Analyzer evidence must be an object")
418
+ return dict(value)
419
+
420
+
421
+ def _string(value: object) -> str:
422
+ if not isinstance(value, str):
423
+ raise ValueError("Analyzer evidence requires a string")
424
+ return value
425
+
426
+
427
+ def _strings(value: object) -> list[str]:
428
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
429
+ raise ValueError("Analyzer evidence requires a string list")
430
+ return list(value)
431
+
432
+
433
+ def _tool_path(tools: dict[str, dict[str, object]], name: str) -> str:
434
+ return _string(tools[name]["path"])
435
+
436
+
437
+ def _rca_functions(space: RCASpace, path: str) -> list[dict[str, object]]:
438
+ rows: list[dict[str, object]] = []
439
+ if space.kind == "function":
440
+ scores = {
441
+ name: _score(
442
+ getattr(space.metrics, name).sum
443
+ - sum(getattr(child.metrics, name).sum for child in space.spaces)
444
+ )
445
+ for name in ("cognitive", "cyclomatic")
446
+ }
447
+ rows.append(
448
+ {
449
+ "path": path,
450
+ "name": space.name or f"anonymous@{space.start_line}",
451
+ "line": space.start_line,
452
+ "col": None,
453
+ "end_line": space.end_line,
454
+ "line_count": space.end_line - space.start_line + 1,
455
+ **scores,
456
+ }
457
+ )
458
+ for child in space.spaces:
459
+ rows.extend(_rca_functions(child, path))
460
+ return rows
461
+
462
+
463
+ def _complexity(
464
+ root: Path,
465
+ paths: list[str],
466
+ language: str,
467
+ tools: dict[str, dict[str, object]],
468
+ execute: Callable[[list[str]], str],
469
+ ) -> list[dict[str, object]]:
470
+ if language == "go":
471
+ rows = json.loads(
472
+ execute([_tool_path(tools, "go_analysis"), *[str(root / path) for path in paths]])
473
+ )
474
+ if not isinstance(rows, list):
475
+ raise ValueError("Go observer functions must be an object list")
476
+ functions = [_object(row) for row in rows]
477
+ for row in functions:
478
+ row["path"] = _path(_string(row["path"]), root)
479
+ return functions
480
+ rows = []
481
+ for path in paths:
482
+ if language == "python" and not ast.parse((root / path).read_bytes(), filename=path).body:
483
+ continue
484
+ errors = execute([_tool_path(tools, "rca"), "-C", "ERROR", "-p", str(root / path)])
485
+ found = re.search(r"Found nodes: (\d+)", errors)
486
+ if found is None or int(found[1]):
487
+ raise AnalysisUnavailable(f"Pinned parser does not accept {path}")
488
+ space = RCASpace.model_validate_json(
489
+ execute([_tool_path(tools, "rca"), "-m", "-O", "json", "-p", str(root / path)])
490
+ )
491
+ functions = _rca_functions(space, path)
492
+ expected = _score(
493
+ space.metrics.nom.functions if language == "python" else space.metrics.nom.total
494
+ )
495
+ if len(functions) != expected:
496
+ raise AnalysisUnavailable(
497
+ f"Parser did not expose complete declared function spans for {path}"
498
+ )
499
+ rows.extend(functions)
500
+ return rows
501
+
502
+
503
+ def _python_unused(
504
+ root: Path,
505
+ paths: list[str],
506
+ tools: dict[str, dict[str, object]],
507
+ execute: Callable[[list[str]], str],
508
+ ) -> list[dict[str, object]]:
509
+ for path in paths:
510
+ ast.parse((root / path).read_bytes(), filename=path)
511
+ output = execute(
512
+ [
513
+ _tool_path(tools, "vulture"),
514
+ "--config",
515
+ "/dev/null",
516
+ "--min-confidence",
517
+ "60",
518
+ *[str(root / path) for path in paths],
519
+ ]
520
+ )
521
+ rows = []
522
+ for line in output.splitlines():
523
+ match = re.fullmatch(r"(.+):(\d+): (.+) \((\d+)% confidence\)", line)
524
+ if match is None:
525
+ raise AnalysisUnavailable("Vulture did not produce a complete candidate stream")
526
+ message = match[3]
527
+ symbol = re.search(r"'([^']+)'", message)
528
+ rows.append(
529
+ {
530
+ "path": _path(match[1], root),
531
+ "line": int(match[2]),
532
+ "category": "vulture-" + message.split(" '", 1)[0].replace(" ", "-"),
533
+ "symbol": symbol[1] if symbol else message,
534
+ "message": message,
535
+ "confidence": int(match[4]),
536
+ "tool": "vulture",
537
+ }
538
+ )
539
+ return rows
540
+
541
+
542
+ def _json_stream(output: str) -> list[dict[str, object]]:
543
+ decoder = json.JSONDecoder()
544
+ rows = []
545
+ while output.strip():
546
+ row, end = decoder.raw_decode(output.lstrip())
547
+ rows.append(_object(row))
548
+ output = output.lstrip()[end:]
549
+ return rows
550
+
551
+
552
+ def _go_unused(
553
+ root: Path,
554
+ paths: list[str],
555
+ tools: dict[str, dict[str, object]],
556
+ execute: Callable[[list[str]], str],
557
+ ) -> list[dict[str, object]]:
558
+ packages = _json_stream(execute([_tool_path(tools, "go"), "list", "-json", "./..."]))
559
+ included = {
560
+ _path(str(Path(_string(package["Dir"])) / path), root)
561
+ for package in packages
562
+ for path in _strings(package.get("GoFiles", []))
563
+ }
564
+ if not set(paths) <= included:
565
+ raise AnalysisUnavailable(
566
+ "Some declared Go source files are excluded by the active build configuration"
567
+ )
568
+ diagnostics = _json_stream(
569
+ execute(
570
+ [
571
+ _tool_path(tools, "staticcheck"),
572
+ "-f",
573
+ "json",
574
+ "-checks=U1000",
575
+ "-show-ignored",
576
+ "-tests=false",
577
+ "./...",
578
+ ]
579
+ )
580
+ )
581
+ rows = []
582
+ for diagnostic in diagnostics:
583
+ if diagnostic.get("code") != "U1000":
584
+ raise AnalysisUnavailable("Staticcheck failed to load/compile the measured packages")
585
+ location = _object(diagnostic["location"])
586
+ path = _path(_string(location["file"]), root)
587
+ if path not in paths:
588
+ continue
589
+ message = _string(diagnostic["message"])
590
+ rows.append(
591
+ {
592
+ "path": path,
593
+ "line": location["line"],
594
+ "col": location["column"],
595
+ "category": "U1000",
596
+ "symbol": message.split(" is unused")[0],
597
+ "message": message,
598
+ "tool": "staticcheck",
599
+ "ignored": diagnostic.get("ignored", False),
600
+ }
601
+ )
602
+ return rows
603
+
604
+
605
+ RUST_COMMAND = [
606
+ "bash",
607
+ "-c",
608
+ """set -eu
609
+ mkdir -p .pluginbench-measure
610
+ export RUSTUP_TOOLCHAIN=1.92.0
611
+ rust_observer_compiler=$(rustup which --toolchain 1.92.0 rustc)
612
+ rust_observer_cargo=$(rustup which --toolchain 1.92.0 cargo)
613
+ "$rust_observer_compiler" --version > .pluginbench-measure/rust-unused.txt
614
+ "$rust_observer_cargo" --version >> .pluginbench-measure/rust-unused.txt
615
+ export RUSTC="$rust_observer_compiler" RUSTC_WRAPPER= RUSTC_WORKSPACE_WRAPPER=
616
+ export CARGO_ENCODED_RUSTFLAGS=--force-warn=dead_code RUSTFLAGS=--force-warn=dead_code
617
+ cat > /tmp/pluginbench-rust-observer.toml <<TOML
618
+ [build]
619
+ rustc = "$rust_observer_compiler"
620
+ rustc-wrapper = ""
621
+ rustc-workspace-wrapper = ""
622
+ [env]
623
+ RUSTC = { value = "$rust_observer_compiler", force = true }
624
+ RUSTC_WRAPPER = { value = "", force = true }
625
+ RUSTC_WORKSPACE_WRAPPER = { value = "", force = true }
626
+ CARGO_ENCODED_RUSTFLAGS = { value = "--force-warn=dead_code", force = true }
627
+ RUSTFLAGS = { value = "--force-warn=dead_code", force = true }
628
+ LD_PRELOAD = { value = "", force = true }
629
+ LD_LIBRARY_PATH = { value = "", force = true }
630
+ TOML
631
+ "$rust_observer_cargo" --config /tmp/pluginbench-rust-observer.toml check --offline --locked \
632
+ --target-dir /tmp/pluginbench-rust-target --message-format=json \
633
+ >> .pluginbench-measure/rust-unused.txt
634
+ printf '\n__PLUGINBENCH_DEPINFO__\n' >> .pluginbench-measure/rust-unused.txt
635
+ find /tmp/pluginbench-rust-target -name '*.d' -type f -exec cat -- {} + \
636
+ >> .pluginbench-measure/rust-unused.txt
637
+ """,
638
+ ]
639
+
640
+
641
+ def _rust_unused(
642
+ root: Path, paths: list[str], config: dict[str, object], destination: Path, timeout: int
643
+ ) -> list[dict[str, object]]:
644
+ from pluginbench.harbor_backend import run_native_command
645
+
646
+ identity_value = config.get("native_task_identity")
647
+ if not identity_value:
648
+ raise AnalysisUnavailable("Rust unused diagnostics require a frozen native Cargo observer")
649
+ identity = _object(identity_value)
650
+ if not (root / "Cargo.toml").is_file() or not (root / "Cargo.lock").is_file():
651
+ raise AnalysisUnavailable(
652
+ "Rust unused diagnostics require a Cargo manifest and frozen lockfile"
653
+ )
654
+ execution = run_native_command(
655
+ snapshot=root,
656
+ task_identity=identity,
657
+ destination=destination,
658
+ command=RUST_COMMAND,
659
+ report_path=".pluginbench-measure/rust-unused.txt",
660
+ timeout=timeout,
661
+ )
662
+ if (
663
+ execution.get("exit_code") != 0
664
+ or execution.get("timed_out")
665
+ or not execution.get("report_collected")
666
+ ):
667
+ raise AnalysisUnavailable("Native pinned Cargo check failed; inspect its execution receipt")
668
+ return _rust_report(
669
+ (destination / "report.raw").read_text(), root, paths, _string(identity["workdir"])
670
+ )
671
+
672
+
673
+ def _rust_report(raw: str, root: Path, paths: list[str], workdir: str) -> list[dict[str, object]]:
674
+ lines = raw.splitlines()
675
+ if len(lines) < 3 or lines[:2] != [
676
+ "rustc 1.92.0 (ded5c06cf 2025-12-08)",
677
+ "cargo 1.92.0 (344c4567c 2025-10-21)",
678
+ ]:
679
+ raise AnalysisUnavailable(
680
+ "Native Rust compiler version is not the qualified pinned toolchain"
681
+ )
682
+ payload = "\n".join(lines[2:])
683
+ diagnostics_text, marker, depinfo = payload.partition("__PLUGINBENCH_DEPINFO__")
684
+ if not marker:
685
+ raise ValueError("Native Rust diagnostics did not include source population evidence")
686
+ workdir = workdir.rstrip("/")
687
+ compiled = set()
688
+ for line in depinfo.splitlines():
689
+ if ": " not in line:
690
+ continue
691
+ # C build scripts leave Make dep-info beside rustc's and end a continued rule with a
692
+ # backslash. rustc writes each rule on one line, so only that marker is dropped.
693
+ prerequisites = re.sub(r"(?<!\\)\\$", "", line.split(": ", 1)[1])
694
+ try:
695
+ filenames = shlex.split(prerequisites)
696
+ except ValueError as exc:
697
+ raise AnalysisUnavailable(f"Rust dep-info could not be read: {exc}") from exc
698
+ for filename in filenames:
699
+ relative = filename.removeprefix(workdir + "/")
700
+ if relative in paths:
701
+ compiled.add(relative)
702
+ if not set(paths) <= compiled:
703
+ raise AnalysisUnavailable(
704
+ "Some declared Rust source files are outside compiler dep-info for this build configuration"
705
+ )
706
+ return _rust_candidates(diagnostics_text, workdir, root, paths)
707
+
708
+
709
+ def _rust_candidates(
710
+ diagnostics_text: str, workdir: str, root: Path, paths: list[str]
711
+ ) -> list[dict[str, object]]:
712
+ rows = []
713
+ finished = False
714
+ for event in _json_stream(diagnostics_text):
715
+ if event.get("reason") == "build-finished":
716
+ finished = event.get("success") is True
717
+ if event.get("reason") != "compiler-message":
718
+ continue
719
+ message = _object(event["message"])
720
+ code = message.get("code")
721
+ if code is not None and _object(code).get("code") == "dead_code":
722
+ rows.extend(_rust_message_candidates(message, workdir, root, paths))
723
+ if not finished:
724
+ raise AnalysisUnavailable("Native Cargo did not attest a successful complete build")
725
+ unique = {(row["path"], row["line"], row["col"], row["symbol"]): row for row in rows}
726
+ return list(unique.values())
727
+
728
+
729
+ def _rust_message_candidates(
730
+ message: dict[str, object], workdir: str, root: Path, paths: list[str]
731
+ ) -> list[dict[str, object]]:
732
+ rows = []
733
+ spans = message.get("spans")
734
+ if not isinstance(spans, list):
735
+ raise ValueError("Rust diagnostics require source spans")
736
+ for value in spans:
737
+ span = _object(value)
738
+ if span.get("is_primary") is not True:
739
+ continue
740
+ filename = _string(span["file_name"])
741
+ if filename.startswith("/") and not filename.startswith(workdir + "/"):
742
+ continue
743
+ path = _path(filename.removeprefix(workdir + "/"), root)
744
+ if path not in paths:
745
+ continue
746
+ text = _string(message["message"])
747
+ symbol = re.search(r"`([^`]+)`", text)
748
+ rows.append(
749
+ {
750
+ "path": path,
751
+ "line": _score(span["line_start"]),
752
+ "col": _score(span["column_start"]),
753
+ "category": "rustc-dead_code",
754
+ "symbol": symbol[1] if symbol else text,
755
+ "message": text,
756
+ "tool": "rustc",
757
+ }
758
+ )
759
+ return rows
760
+
761
+
762
+ def _collect_language_analysis(
763
+ root: Path,
764
+ config: dict[str, object],
765
+ destination: Path,
766
+ tools: dict[str, dict[str, object]],
767
+ *,
768
+ timeout: int = 120,
769
+ ) -> tuple[dict[str, object], str]:
770
+ """Analyze a disposable snapshot; callers retain/bind its immutable source receipt."""
771
+ root = root.resolve(strict=True)
772
+ destination = destination.resolve()
773
+ language = _string(config["language"])
774
+ suffix = SUFFIXES[language]
775
+ inventory = inspect_snapshot(root)
776
+ files = _object(inventory["files"])
777
+ scope = _strings(config.get("source_scope", ["*"]))
778
+ paths = sorted(
779
+ path
780
+ for path in files
781
+ if Path(path).suffix == suffix
782
+ and not _is_test_path(path)
783
+ and any(fnmatch.fnmatchcase(path, pattern) for pattern in scope)
784
+ )
785
+ destination.mkdir(parents=True, exist_ok=True)
786
+ readable = [
787
+ Path(_string(row["path"])).parent
788
+ for row in tools.values()
789
+ if row["path"] and not _string(row["path"]).startswith("native:")
790
+ ]
791
+ if "vulture" in tools and tools["vulture"]["path"]:
792
+ readable.append(Path(_tool_path(tools, "vulture")).parent.parent)
793
+ go = shutil.which("go")
794
+ if language == "go" and go:
795
+ readable.append(Path(go).resolve().parent.parent)
796
+
797
+ def execute(argv: list[str]) -> str:
798
+ return _execute(argv, root, destination, readable, timeout)
799
+
800
+ functions: list[dict[str, object]] = []
801
+ candidates: list[dict[str, object]] = []
802
+ complexity_tool = tools["go_analysis" if language == "go" else "rca"]
803
+ try:
804
+ if complexity_tool["status"] != "available":
805
+ raise AnalysisUnavailable(complexity_tool["reason"])
806
+ functions = _complexity(root, paths, language, tools, execute) if paths else []
807
+ complexity_status = {"status": "available", "reason": None}
808
+ except (AnalysisUnavailable, SyntaxError) as exc:
809
+ complexity_status = {"status": "unavailable", "reason": str(exc)}
810
+ try:
811
+ if language == "rust":
812
+ candidates = _rust_unused(root, paths, config, destination / "native-rust", timeout)
813
+ else:
814
+ tool = tools["vulture" if language == "python" else "staticcheck"]
815
+ if tool["status"] != "available":
816
+ raise AnalysisUnavailable(tool["reason"])
817
+ if language == "go" and tools["go"]["status"] != "available":
818
+ raise AnalysisUnavailable(tools["go"]["reason"])
819
+ candidates = (
820
+ (_python_unused if language == "python" else _go_unused)(
821
+ root, paths, tools, execute
822
+ )
823
+ if paths
824
+ else []
825
+ )
826
+ unused_status = {"status": "available", "reason": None}
827
+ except (AnalysisUnavailable, SyntaxError) as exc:
828
+ unused_status = {"status": "unavailable", "reason": str(exc)}
829
+ functions.sort(
830
+ key=lambda row: (
831
+ _string(row["path"]),
832
+ _score(row["line"]),
833
+ _score(row["col"] or 0),
834
+ _string(row["name"]),
835
+ )
836
+ )
837
+ candidates.sort(
838
+ key=lambda row: (
839
+ _string(row["path"]),
840
+ _score(row["line"]),
841
+ _string(row["category"]),
842
+ _string(row["symbol"]),
843
+ )
844
+ )
845
+ return {
846
+ "version": VERSION,
847
+ "language": language,
848
+ "tools": tools,
849
+ "supported_suffixes": [suffix],
850
+ "analyzed_paths": paths,
851
+ "functions": functions,
852
+ "unused_candidates": candidates,
853
+ "metrics": {
854
+ "cognitive": complexity_status,
855
+ "cyclomatic": complexity_status,
856
+ "dead_code": unused_status,
857
+ },
858
+ "settings": deepcopy(ANALYSIS_SETTINGS),
859
+ }, VERSION
860
+
861
+
862
+ def _analysis_tools(language: str, config: dict[str, object]) -> dict[str, dict[str, object]]:
863
+ version = config.get("observer_version")
864
+ implementation = config.get("implementation_sha256")
865
+ if version is None and implementation is not None:
866
+ version = components.saved_version(
867
+ "analysis_observer", {"language_analysis.py": implementation}
868
+ )
869
+ current = components.VERSIONS["analysis_observer"]
870
+ if version is not None and version != current:
871
+ raise components.ObserverError(
872
+ f"analysis observer version {version} froze this profile, and this PluginBench "
873
+ f"measures with version {current}: {components.OBSERVER_NEXT_STEP}"
874
+ )
875
+ if config.get("settings", ANALYSIS_SETTINGS) != ANALYSIS_SETTINGS:
876
+ raise ValueError("Analysis settings differ from the qualified observer")
877
+ if config.get("supported_suffixes", [SUFFIXES[language]]) != [SUFFIXES[language]]:
878
+ raise ValueError("Analysis source language population changed after freezing")
879
+ configured = config.get("tools")
880
+ tool_paths = (
881
+ {name: _string(path) for name, path in _object(configured).items()}
882
+ if configured is not None
883
+ else None
884
+ )
885
+ tools = probe_language_tools(language, tool_paths)
886
+ frozen_tools = config.get("tool_identities")
887
+ if language == "rust" and isinstance(frozen_tools, dict) and "rustc" in frozen_tools:
888
+ tools["rustc"] = _object(frozen_tools["rustc"])
889
+ if frozen_tools is not None and frozen_tools != tools:
890
+ raise ValueError("Analysis tool identity changed after freezing")
891
+ return tools
892
+
893
+
894
+ def collect_language_analysis(
895
+ root: Path, config: dict[str, object], destination: Path, *, timeout: int = 120
896
+ ) -> tuple[dict[str, object], str]:
897
+ language = _string(config["language"])
898
+ tools = _analysis_tools(language, config)
899
+ # A profile binds this file by version, not bytes, so the bytes are compared here: an
900
+ # edit during collection must not mix two implementations in one result.
901
+ implementation = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
902
+ before = inspect_snapshot(root)["digest"]
903
+ try:
904
+ return _collect_language_analysis(root, config, destination, tools, timeout=timeout)
905
+ finally:
906
+ if (
907
+ _analysis_tools(language, config) != tools
908
+ or hashlib.sha256(Path(__file__).read_bytes()).hexdigest() != implementation
909
+ ):
910
+ raise components.ObserverError(
911
+ "the analysis observer's bytes changed during execution: measure again with "
912
+ "an unchanged PluginBench"
913
+ )
914
+ if inspect_snapshot(root)["digest"] != before:
915
+ raise ValueError("Analysis modified measured source")