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,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")
|