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,616 @@
|
|
|
1
|
+
"""Freeze reusable measurement settings independently of benchmark task names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
import fnmatch
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tomllib
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
17
|
+
|
|
18
|
+
from pluginbench.assessment_metrics import (
|
|
19
|
+
inspect_snapshot,
|
|
20
|
+
evaluate_evidence,
|
|
21
|
+
_dead_changes,
|
|
22
|
+
_object,
|
|
23
|
+
)
|
|
24
|
+
from pluginbench.code_diff import TEST_PATH_PATTERNS, _is_source_path, _is_test_path
|
|
25
|
+
from pluginbench import components
|
|
26
|
+
from pluginbench.collectors import Collector, collect_measurements, observer_identity
|
|
27
|
+
|
|
28
|
+
ROOT = Path(__file__).resolve().parent
|
|
29
|
+
MEASUREMENT_NAMES = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"tokens",
|
|
32
|
+
"lines",
|
|
33
|
+
"cyclomatic",
|
|
34
|
+
"cognitive",
|
|
35
|
+
"dead_code",
|
|
36
|
+
"coverage",
|
|
37
|
+
"review",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
REMOVED_TEST_OVERLAP = (
|
|
41
|
+
"test_overlap was removed: the leave-one-out test-overlap measure is no longer offered. "
|
|
42
|
+
"Redundant added tests, measured from per-test coverage, replace it. "
|
|
43
|
+
"Remove test_overlap from measurements."
|
|
44
|
+
)
|
|
45
|
+
REMOVED_TEST_CONTRIBUTION = (
|
|
46
|
+
"measurement_settings.test_contribution was removed: it configured the leave-one-out "
|
|
47
|
+
"test-overlap measure, which is no longer offered. Redundant added tests, measured from "
|
|
48
|
+
"per-test coverage, replace it. Omit test_contribution or set it to null."
|
|
49
|
+
)
|
|
50
|
+
FALLOW_MEASUREMENTS = {"cyclomatic", "cognitive", "dead_code"}
|
|
51
|
+
PROFILE_VERSION = "native-before-after-v2"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_scopes(scopes: Iterable[list[str] | None]) -> None:
|
|
55
|
+
for scope in scopes:
|
|
56
|
+
if scope and any(
|
|
57
|
+
not pattern or pattern.startswith("/") or ".." in PurePosixPath(pattern).parts
|
|
58
|
+
for pattern in scope
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("measurement scopes require nonempty repository-relative patterns")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CoverageSettings(BaseModel):
|
|
64
|
+
model_config = ConfigDict(extra="forbid")
|
|
65
|
+
|
|
66
|
+
command: list[str] = Field(
|
|
67
|
+
min_length=1, description="Provides the coverage command as a nonempty argument list."
|
|
68
|
+
)
|
|
69
|
+
report_path: str = Field(
|
|
70
|
+
min_length=1,
|
|
71
|
+
description="Names the coverage report relative to the repository without parent traversal.",
|
|
72
|
+
)
|
|
73
|
+
tool_version: str = Field(
|
|
74
|
+
min_length=1,
|
|
75
|
+
description="Records the nonempty coverage tool version in the measurement profile.",
|
|
76
|
+
)
|
|
77
|
+
format: Literal["lcov", "coverage-py-json", "go-coverprofile"] = Field(
|
|
78
|
+
default="lcov", description="Selects the parser for the coverage report."
|
|
79
|
+
)
|
|
80
|
+
timeout_seconds: int = Field(
|
|
81
|
+
default=600,
|
|
82
|
+
ge=1,
|
|
83
|
+
le=7200,
|
|
84
|
+
description="Sets the coverage command timeout in seconds from one to 7200.",
|
|
85
|
+
)
|
|
86
|
+
source_scope: list[str] | None = Field(
|
|
87
|
+
default=None,
|
|
88
|
+
min_length=1,
|
|
89
|
+
description="Selects repository-relative source patterns for this coverage profile.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def safe_report_path(self) -> CoverageSettings:
|
|
94
|
+
report = PurePosixPath(self.report_path)
|
|
95
|
+
if (
|
|
96
|
+
report.is_absolute()
|
|
97
|
+
or ".." in report.parts
|
|
98
|
+
or report.as_posix() in {".", ""}
|
|
99
|
+
or "\\" in self.report_path
|
|
100
|
+
or any(not item.strip() for item in self.command)
|
|
101
|
+
or not self.tool_version.strip()
|
|
102
|
+
):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"coverage requires a relative report path and nonempty command/version"
|
|
105
|
+
)
|
|
106
|
+
_validate_scopes([self.source_scope])
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MeasurementSettings(BaseModel):
|
|
111
|
+
model_config = ConfigDict(extra="forbid")
|
|
112
|
+
|
|
113
|
+
source_scope: list[str] | None = Field(
|
|
114
|
+
default=None,
|
|
115
|
+
min_length=1,
|
|
116
|
+
description="Selects repository-relative source patterns for measurements.",
|
|
117
|
+
)
|
|
118
|
+
test_scope: list[str] | None = Field(
|
|
119
|
+
default=None,
|
|
120
|
+
min_length=1,
|
|
121
|
+
description="Selects repository-relative test patterns for measurements.",
|
|
122
|
+
)
|
|
123
|
+
fallow_config: Path | None = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
description="Selects a Fallow configuration file relative to the evaluation config.",
|
|
126
|
+
)
|
|
127
|
+
coverage: CoverageSettings | None = Field(
|
|
128
|
+
default=None,
|
|
129
|
+
description="Provides the default coverage profile when no repository or language profile matches.",
|
|
130
|
+
)
|
|
131
|
+
coverage_profiles: dict[str, CoverageSettings] = Field(
|
|
132
|
+
default_factory=dict,
|
|
133
|
+
description="Maps repository URLs or language names to coverage profiles, with repository matches taking precedence.",
|
|
134
|
+
)
|
|
135
|
+
# Saved evaluations record this removed setting as null; only null is accepted.
|
|
136
|
+
test_contribution: None = Field(
|
|
137
|
+
default=None,
|
|
138
|
+
description="Retains the removed test contribution setting as null for saved evaluations.",
|
|
139
|
+
)
|
|
140
|
+
require_ready: bool = Field(
|
|
141
|
+
default=False,
|
|
142
|
+
description="Requires every selected measurement to be ready on every task before coding.",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@field_validator("test_contribution", mode="before")
|
|
146
|
+
@classmethod
|
|
147
|
+
def removed_test_contribution(cls, value: object) -> None:
|
|
148
|
+
if value is not None:
|
|
149
|
+
raise ValueError(REMOVED_TEST_CONTRIBUTION)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@model_validator(mode="after")
|
|
153
|
+
def safe_scopes(self) -> MeasurementSettings:
|
|
154
|
+
_validate_scopes([self.source_scope, self.test_scope])
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def selected_measurements(selection: Iterable[str] | None) -> frozenset[str]:
|
|
159
|
+
selected = MEASUREMENT_NAMES if selection is None else frozenset(selection)
|
|
160
|
+
unknown = selected - MEASUREMENT_NAMES
|
|
161
|
+
if unknown:
|
|
162
|
+
raise ValueError("unsupported measurements: " + ", ".join(sorted(unknown)))
|
|
163
|
+
return selected
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def capability_plan(
|
|
167
|
+
task_dir: Path, selected: Iterable[str], settings: MeasurementSettings
|
|
168
|
+
) -> dict[str, dict[str, object]]:
|
|
169
|
+
metadata = tomllib.loads((task_dir / "task.toml").read_text()).get("metadata", {})
|
|
170
|
+
language = str(metadata.get("language", "")).strip().lower()
|
|
171
|
+
selected = selected_measurements(selected)
|
|
172
|
+
is_fallow = language in {"javascript", "typescript", "javascript/typescript", "js", "ts"}
|
|
173
|
+
fallow_reason, fallow_version = (
|
|
174
|
+
_fallow_capability(language)
|
|
175
|
+
if selected & FALLOW_MEASUREMENTS and is_fallow
|
|
176
|
+
else (None, None)
|
|
177
|
+
)
|
|
178
|
+
analysis = {}
|
|
179
|
+
if selected & FALLOW_MEASUREMENTS and not is_fallow:
|
|
180
|
+
from pluginbench.language_analysis import language_capability
|
|
181
|
+
|
|
182
|
+
analysis = language_capability(language)
|
|
183
|
+
coverage = coverage_settings(task_dir, settings)
|
|
184
|
+
result: dict[str, dict[str, object]] = {}
|
|
185
|
+
for name in sorted(selected):
|
|
186
|
+
reason = None
|
|
187
|
+
collector = None
|
|
188
|
+
# A measure with no collector for this task is not configured; a configured one that
|
|
189
|
+
# cannot run (missing or wrong tool) is broken. See readiness_state.
|
|
190
|
+
configured = True
|
|
191
|
+
if name in FALLOW_MEASUREMENTS:
|
|
192
|
+
collector = "fallow" if is_fallow else "analysis"
|
|
193
|
+
reason = fallow_reason if is_fallow else analysis.get(name, {}).get("reason")
|
|
194
|
+
configured = is_fallow or analysis.get(name, {}).get("configured") is not False
|
|
195
|
+
elif name == "coverage":
|
|
196
|
+
collector = "coverage"
|
|
197
|
+
if coverage is None:
|
|
198
|
+
reason = "Coverage requires an explicit test command, report format/path and tool version"
|
|
199
|
+
configured = False
|
|
200
|
+
result[name] = {
|
|
201
|
+
"status": "unavailable" if reason else "available",
|
|
202
|
+
"reason": reason,
|
|
203
|
+
**({"collector": collector} if collector else {}),
|
|
204
|
+
**({"tool_version": fallow_version} if collector == "fallow" else {}),
|
|
205
|
+
**({} if configured else {"configured": False}),
|
|
206
|
+
}
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def readiness_state(row: dict[str, object]) -> str:
|
|
211
|
+
"""ready, not_configured (no collector for this task), or broken (configured but failing)."""
|
|
212
|
+
if row.get("status") == "available":
|
|
213
|
+
return "ready"
|
|
214
|
+
return "not_configured" if row.get("configured") is False else "broken"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _ready_nowhere(matrix: dict[str, dict[str, dict[str, object]]]) -> list[str]:
|
|
218
|
+
"""Selected measures that no task can measure: a run would produce nothing for them."""
|
|
219
|
+
measures = dict.fromkeys(
|
|
220
|
+
name for rows in matrix.values() for name in rows if name in MEASUREMENT_NAMES
|
|
221
|
+
)
|
|
222
|
+
return [
|
|
223
|
+
name
|
|
224
|
+
for name in measures
|
|
225
|
+
if all(rows[name].get("state") != "ready" for rows in matrix.values() if name in rows)
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def blocking_findings(
|
|
230
|
+
matrix: dict[str, dict[str, dict[str, object]]], *, require_ready: bool
|
|
231
|
+
) -> list[str]:
|
|
232
|
+
"""Rows that stop the run before any coding call.
|
|
233
|
+
|
|
234
|
+
A broken check of the coding environment, submission source or reference agents blocks. A
|
|
235
|
+
broken measure blocks only when no task can measure it; otherwise that task is recorded as not
|
|
236
|
+
measured (see not_measured). require_ready makes every row that is not ready block.
|
|
237
|
+
"""
|
|
238
|
+
nowhere = _ready_nowhere(matrix)
|
|
239
|
+
findings = [
|
|
240
|
+
f"{task}: {name} is {row['state']}: {row.get('reason')}"
|
|
241
|
+
for task, rows in matrix.items()
|
|
242
|
+
for name, row in rows.items()
|
|
243
|
+
if row["state"] != "ready"
|
|
244
|
+
and (
|
|
245
|
+
require_ready
|
|
246
|
+
or (name in nowhere if name in MEASUREMENT_NAMES else row["state"] == "broken")
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
return findings + [
|
|
250
|
+
f"{name} is ready on no task, so the run would measure nothing for it"
|
|
251
|
+
for name in nowhere
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def not_measured(matrix: dict[str, dict[str, dict[str, object]]]) -> list[dict[str, object]]:
|
|
256
|
+
"""Broken measures that the run skips on their task while other tasks still measure them."""
|
|
257
|
+
nowhere = _ready_nowhere(matrix)
|
|
258
|
+
return [
|
|
259
|
+
{"task": task, "measure": name, "reason": row.get("reason")}
|
|
260
|
+
for task, rows in matrix.items()
|
|
261
|
+
for name, row in rows.items()
|
|
262
|
+
if name in MEASUREMENT_NAMES and name not in nowhere and row.get("state") == "broken"
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def coverage_settings(task_dir: Path, settings: MeasurementSettings) -> CoverageSettings | None:
|
|
267
|
+
"""Explicit repository profile wins over language profile and shared default."""
|
|
268
|
+
metadata = tomllib.loads((task_dir / "task.toml").read_text()).get("metadata", {})
|
|
269
|
+
repository = str(metadata.get("repository_url", ""))
|
|
270
|
+
language = str(metadata.get("language", "")).lower()
|
|
271
|
+
return settings.coverage_profiles.get(
|
|
272
|
+
repository, settings.coverage_profiles.get(language, settings.coverage)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fallow_capability(language: str) -> tuple[str | None, str | None]:
|
|
277
|
+
if language not in {"javascript", "typescript", "javascript/typescript", "js", "ts"}:
|
|
278
|
+
return (
|
|
279
|
+
"Fallow supports JavaScript/TypeScript; this task does not declare that language",
|
|
280
|
+
None,
|
|
281
|
+
)
|
|
282
|
+
if shutil.which("fallow") is None or shutil.which("node") is None:
|
|
283
|
+
return "Fallow and Node are required for source analysis", None
|
|
284
|
+
try:
|
|
285
|
+
output = subprocess.check_output(["fallow", "--version"], text=True, timeout=5)
|
|
286
|
+
# The npm launcher appends a `verified: ...` status line after the version.
|
|
287
|
+
version = (output.strip().splitlines() or [""])[0].strip()
|
|
288
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
289
|
+
return f"Could not determine Fallow version: {exc}", None
|
|
290
|
+
if version != "fallow 3.18.0":
|
|
291
|
+
return f"Analysis requires Fallow 3.18.0; observed {version}", version
|
|
292
|
+
return None, "3.18.0"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _json(path: Path, value: object) -> None:
|
|
296
|
+
path.write_text(json.dumps(value, indent=2) + "\n")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def dependency_digest(root: Path) -> str:
|
|
300
|
+
digest = hashlib.sha256()
|
|
301
|
+
for path in sorted(root.rglob("*")):
|
|
302
|
+
if path.is_symlink():
|
|
303
|
+
content = b"link:" + os.readlink(path).encode()
|
|
304
|
+
elif path.is_file():
|
|
305
|
+
content = path.read_bytes()
|
|
306
|
+
else:
|
|
307
|
+
continue
|
|
308
|
+
digest.update(path.relative_to(root).as_posix().encode() + b"\0" + content + b"\0")
|
|
309
|
+
return digest.hexdigest()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _observer_cache_safe(root: Path, destination: Path) -> bool:
|
|
313
|
+
return destination.is_relative_to(root) and destination.resolve() == destination
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def prepare_observer(name: str, cache: Path) -> dict[str, str]:
|
|
317
|
+
"""Reuse only an exact locked parser manifest, lock, and installed byte population."""
|
|
318
|
+
if name != "oxc-parser-0.126.0":
|
|
319
|
+
raise ValueError("only the pinned generic syntax observer is supported")
|
|
320
|
+
source = ROOT / "observer_data" / name
|
|
321
|
+
declared = {
|
|
322
|
+
file: (source / file).read_bytes() for file in ("package.json", "package-lock.json")
|
|
323
|
+
}
|
|
324
|
+
lock_digest = hashlib.sha256(declared["package-lock.json"]).hexdigest()
|
|
325
|
+
cache_root = cache.expanduser().resolve()
|
|
326
|
+
destination = cache_root / name / lock_digest
|
|
327
|
+
if not _observer_cache_safe(cache_root, destination) or not _observer_cache_safe(
|
|
328
|
+
cache_root, destination / "node_modules"
|
|
329
|
+
):
|
|
330
|
+
raise ValueError("observer cache descendants must not redirect outside the cache")
|
|
331
|
+
if destination.exists():
|
|
332
|
+
if any((destination / file).read_bytes() != content for file, content in declared.items()):
|
|
333
|
+
raise ValueError("observer cache manifest or lock changed")
|
|
334
|
+
expected = json.loads((destination / "receipt.json").read_text())
|
|
335
|
+
if (
|
|
336
|
+
expected["lock_sha256"] != lock_digest
|
|
337
|
+
or not (destination / "node_modules").is_dir()
|
|
338
|
+
or dependency_digest(destination / "node_modules") != expected["sha256"]
|
|
339
|
+
):
|
|
340
|
+
raise ValueError("observer dependency cache changed")
|
|
341
|
+
else:
|
|
342
|
+
destination.mkdir(parents=True)
|
|
343
|
+
try:
|
|
344
|
+
for file, content in declared.items():
|
|
345
|
+
(destination / file).write_bytes(content)
|
|
346
|
+
subprocess.run(
|
|
347
|
+
["corepack", "npm@10.9.2", "ci", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
348
|
+
cwd=destination,
|
|
349
|
+
env={**os.environ, "npm_config_userconfig": "/dev/null"},
|
|
350
|
+
check=True,
|
|
351
|
+
)
|
|
352
|
+
expected = {
|
|
353
|
+
"lock_sha256": lock_digest,
|
|
354
|
+
"sha256": dependency_digest(destination / "node_modules"),
|
|
355
|
+
}
|
|
356
|
+
_json(destination / "receipt.json", expected)
|
|
357
|
+
except Exception:
|
|
358
|
+
if _observer_cache_safe(cache_root, destination):
|
|
359
|
+
shutil.rmtree(destination)
|
|
360
|
+
raise
|
|
361
|
+
return {"path": str((destination / "node_modules").resolve()), "sha256": expected["sha256"]}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _fallow_config(snapshot: Path, settings: MeasurementSettings) -> dict[str, object]:
|
|
365
|
+
config: dict[str, object] = {}
|
|
366
|
+
candidate = settings.fallow_config
|
|
367
|
+
if candidate is None:
|
|
368
|
+
candidate = next(
|
|
369
|
+
(
|
|
370
|
+
snapshot / name
|
|
371
|
+
for name in (".fallowrc.json", ".fallowrc.jsonc")
|
|
372
|
+
if (snapshot / name).is_file()
|
|
373
|
+
),
|
|
374
|
+
None,
|
|
375
|
+
)
|
|
376
|
+
if candidate is not None:
|
|
377
|
+
if candidate.is_symlink():
|
|
378
|
+
raise ValueError("Fallow settings must not be a symlink")
|
|
379
|
+
raw = json.loads(candidate.read_text())
|
|
380
|
+
if not isinstance(raw, dict) or raw.get("extends"):
|
|
381
|
+
raise ValueError("Fallow settings require fully resolved JSON without extends")
|
|
382
|
+
config.update(raw)
|
|
383
|
+
config["production"] = True
|
|
384
|
+
config["health"] = {"maxCognitive": 0, "maxCyclomatic": 0, "maxUnitSize": 0}
|
|
385
|
+
if "ignorePatterns" not in config:
|
|
386
|
+
config["ignorePatterns"] = list(TEST_PATH_PATTERNS)
|
|
387
|
+
# Root inference remains Fallow's responsibility. Never promote every file to an entry.
|
|
388
|
+
return config
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def prepare_profiles(
|
|
392
|
+
snapshot: Path,
|
|
393
|
+
output: Path,
|
|
394
|
+
selected: Iterable[str],
|
|
395
|
+
settings: MeasurementSettings,
|
|
396
|
+
native_task_identity: dict[str, object],
|
|
397
|
+
) -> list[Collector]:
|
|
398
|
+
selected = selected_measurements(selected)
|
|
399
|
+
task_dir = native_task_identity.get("task_dir")
|
|
400
|
+
if not isinstance(task_dir, str):
|
|
401
|
+
raise ValueError("native measurement identity requires frozen task_dir")
|
|
402
|
+
capabilities = capability_plan(Path(task_dir), selected, settings)
|
|
403
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
source_scope = settings.source_scope or ["*"]
|
|
405
|
+
test_scope = settings.test_scope or ["*"]
|
|
406
|
+
collectors = []
|
|
407
|
+
analysis_available = any(
|
|
408
|
+
capabilities[name]["status"] == "available"
|
|
409
|
+
and capabilities[name].get("collector") == "analysis"
|
|
410
|
+
for name in selected & FALLOW_MEASUREMENTS
|
|
411
|
+
)
|
|
412
|
+
if analysis_available:
|
|
413
|
+
from pluginbench.language_analysis import prepare_analysis_config
|
|
414
|
+
|
|
415
|
+
metadata = tomllib.loads((Path(task_dir) / "task.toml").read_text()).get("metadata", {})
|
|
416
|
+
language = str(metadata.get("language", "")).lower()
|
|
417
|
+
analysis_config = output / "analysis.json"
|
|
418
|
+
_json(
|
|
419
|
+
analysis_config,
|
|
420
|
+
prepare_analysis_config(
|
|
421
|
+
language,
|
|
422
|
+
source_scope=source_scope,
|
|
423
|
+
test_scope=test_scope,
|
|
424
|
+
native_task_identity=native_task_identity,
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
collectors.append(
|
|
428
|
+
Collector(
|
|
429
|
+
kind="analysis",
|
|
430
|
+
config=analysis_config,
|
|
431
|
+
scope=source_scope,
|
|
432
|
+
tool_version="language-analysis-v1",
|
|
433
|
+
timeout_seconds=7200,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
if any(
|
|
437
|
+
capabilities[name]["status"] == "available"
|
|
438
|
+
and capabilities[name].get("collector") == "fallow"
|
|
439
|
+
for name in selected & FALLOW_MEASUREMENTS
|
|
440
|
+
):
|
|
441
|
+
cache = (
|
|
442
|
+
Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "pluginbench/observers"
|
|
443
|
+
)
|
|
444
|
+
parser = prepare_observer("oxc-parser-0.126.0", cache)
|
|
445
|
+
if Path(parser["path"]).is_relative_to(snapshot.resolve()):
|
|
446
|
+
raise ValueError("syntax observer cache must be outside measured source")
|
|
447
|
+
fallow_config = output / "fallow.json"
|
|
448
|
+
_json(fallow_config, _fallow_config(snapshot, settings))
|
|
449
|
+
collectors.append(
|
|
450
|
+
Collector(
|
|
451
|
+
kind="fallow",
|
|
452
|
+
config=fallow_config,
|
|
453
|
+
scope=source_scope,
|
|
454
|
+
syntax_command=[
|
|
455
|
+
"node",
|
|
456
|
+
str(ROOT / "fallow_parse.mjs"),
|
|
457
|
+
str(Path(parser["path"]).parent),
|
|
458
|
+
"{snapshot}",
|
|
459
|
+
"{inputs}",
|
|
460
|
+
"{output}",
|
|
461
|
+
parser["sha256"],
|
|
462
|
+
],
|
|
463
|
+
tool_version="3.18.0",
|
|
464
|
+
timeout_seconds=7200,
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
if "coverage" in selected and capabilities["coverage"]["status"] == "available":
|
|
468
|
+
coverage = coverage_settings(Path(task_dir), settings)
|
|
469
|
+
assert coverage is not None
|
|
470
|
+
coverage_scope = coverage.source_scope or source_scope
|
|
471
|
+
coverage_config = output / "coverage.json"
|
|
472
|
+
_json(
|
|
473
|
+
coverage_config,
|
|
474
|
+
{
|
|
475
|
+
"native_task_identity": native_task_identity,
|
|
476
|
+
"source_scope": coverage_scope,
|
|
477
|
+
"test_scope": test_scope,
|
|
478
|
+
"coverage": coverage.model_dump(mode="json"),
|
|
479
|
+
"observer_version": components.VERSIONS["coverage_observer"],
|
|
480
|
+
"implementation": observer_identity(),
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
collectors.append(
|
|
484
|
+
Collector(
|
|
485
|
+
kind="coverage",
|
|
486
|
+
config=coverage_config,
|
|
487
|
+
scope=coverage_scope,
|
|
488
|
+
command=coverage.command,
|
|
489
|
+
report_format=coverage.format,
|
|
490
|
+
report_path=coverage.report_path,
|
|
491
|
+
tool_version=coverage.tool_version,
|
|
492
|
+
timeout_seconds=coverage.timeout_seconds,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
inventory = inspect_snapshot(snapshot)["files"]
|
|
496
|
+
assert isinstance(inventory, dict)
|
|
497
|
+
_json(output / "measurement-capabilities.json", capabilities)
|
|
498
|
+
_json(
|
|
499
|
+
output / "measurement-provenance.json",
|
|
500
|
+
{
|
|
501
|
+
"measurement_profile_version": PROFILE_VERSION,
|
|
502
|
+
"source_scope": json.dumps(source_scope),
|
|
503
|
+
"test_scope": json.dumps(test_scope),
|
|
504
|
+
"selected_measurements": json.dumps(sorted(selected)),
|
|
505
|
+
"starting_source_paths": sorted(
|
|
506
|
+
path
|
|
507
|
+
for path in inventory
|
|
508
|
+
if _is_source_path(path)
|
|
509
|
+
and any(fnmatch.fnmatchcase(path, pattern) for pattern in source_scope)
|
|
510
|
+
),
|
|
511
|
+
"starting_test_paths": sorted(
|
|
512
|
+
path
|
|
513
|
+
for path in inventory
|
|
514
|
+
if _is_test_path(path)
|
|
515
|
+
and any(fnmatch.fnmatchcase(path, pattern) for pattern in test_scope)
|
|
516
|
+
),
|
|
517
|
+
"capabilities": capabilities,
|
|
518
|
+
},
|
|
519
|
+
)
|
|
520
|
+
return collectors
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _metric_readiness(metric: str, first: dict[str, object]) -> tuple[bool, object]:
|
|
524
|
+
ready, reason = True, None
|
|
525
|
+
if metric in {"cognitive", "cyclomatic", "dead_code"}:
|
|
526
|
+
statuses = first.get("metric_statuses")
|
|
527
|
+
if isinstance(statuses, dict):
|
|
528
|
+
status = statuses.get(metric)
|
|
529
|
+
ready = isinstance(status, dict) and status.get("status") == "available"
|
|
530
|
+
reason = status.get("reason") if isinstance(status, dict) else "missing metric status"
|
|
531
|
+
else:
|
|
532
|
+
key = (
|
|
533
|
+
"dead_code_complete_for_declared_scope"
|
|
534
|
+
if metric == "dead_code"
|
|
535
|
+
else "complexity_complete_for_declared_scope"
|
|
536
|
+
)
|
|
537
|
+
ready = first.get(key) is True
|
|
538
|
+
reason = None if ready else "analysis population is incomplete"
|
|
539
|
+
if metric == "dead_code" and not ready:
|
|
540
|
+
reason = first.get("dead_code_incomplete_reason") or reason
|
|
541
|
+
if metric == "dead_code" and ready:
|
|
542
|
+
comparison = _dead_changes(first, first)
|
|
543
|
+
ready = comparison["status"] == "available"
|
|
544
|
+
reason = comparison.get("reason")
|
|
545
|
+
elif metric == "coverage":
|
|
546
|
+
ready = any(
|
|
547
|
+
first.get(f"{unit}_population_complete") is True
|
|
548
|
+
for unit in ("line", "statement", "branch", "function")
|
|
549
|
+
)
|
|
550
|
+
reason = None if ready else "no complete supported coverage population"
|
|
551
|
+
return ready, reason
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def probe_measurements(
|
|
555
|
+
snapshot: Path,
|
|
556
|
+
output: Path,
|
|
557
|
+
collectors: list[Collector],
|
|
558
|
+
selected: Iterable[str],
|
|
559
|
+
capabilities: dict[str, object],
|
|
560
|
+
) -> dict[str, dict[str, object]]:
|
|
561
|
+
"""Exercise fixed collectors twice before coding; malformed evidence stays fatal."""
|
|
562
|
+
repeats = []
|
|
563
|
+
for index in range(2):
|
|
564
|
+
directory = output / str(index)
|
|
565
|
+
directory.mkdir(parents=True)
|
|
566
|
+
refs = collect_measurements(snapshot, directory, collectors, before=snapshot)
|
|
567
|
+
repeats.append(
|
|
568
|
+
evaluate_evidence(snapshot, {kind: str(path) for kind, path in refs.items()}, directory)
|
|
569
|
+
)
|
|
570
|
+
result = {}
|
|
571
|
+
for metric in sorted(selected_measurements(selected)):
|
|
572
|
+
capability = _object(capabilities[metric], "measurement capability")
|
|
573
|
+
kind = capability.get("collector")
|
|
574
|
+
if not isinstance(kind, str):
|
|
575
|
+
result[metric] = {
|
|
576
|
+
**capability,
|
|
577
|
+
"state": readiness_state(capability),
|
|
578
|
+
"readiness": "not_applicable",
|
|
579
|
+
}
|
|
580
|
+
continue
|
|
581
|
+
measurements = [repeat.get(kind) for repeat in repeats]
|
|
582
|
+
values = [row.get("metrics") if isinstance(row, dict) else None for row in measurements]
|
|
583
|
+
reason = capability.get("reason")
|
|
584
|
+
ready = capability["status"] == "available" and all(
|
|
585
|
+
isinstance(value, dict) for value in values
|
|
586
|
+
)
|
|
587
|
+
if ready:
|
|
588
|
+
first = values[0]
|
|
589
|
+
assert isinstance(first, dict)
|
|
590
|
+
ready, reason = _metric_readiness(metric, first)
|
|
591
|
+
if ready and values[0] != values[1]:
|
|
592
|
+
ready, reason = False, "prepared-input measurements differed across two repeats"
|
|
593
|
+
elif not reason:
|
|
594
|
+
failures = [
|
|
595
|
+
json.loads((output / str(index) / kind / "failure.json").read_text())
|
|
596
|
+
for index in range(2)
|
|
597
|
+
if (output / str(index) / kind / "failure.json").exists()
|
|
598
|
+
]
|
|
599
|
+
reason = (
|
|
600
|
+
"; ".join(str(failure["reason"]) for failure in failures) or "no bound measurement"
|
|
601
|
+
)
|
|
602
|
+
row = {
|
|
603
|
+
**capability,
|
|
604
|
+
"status": "available" if ready else "unavailable",
|
|
605
|
+
"readiness": "passed" if ready else "failed",
|
|
606
|
+
"reason": reason,
|
|
607
|
+
"repeats": 2,
|
|
608
|
+
**(
|
|
609
|
+
{"test_command_exit_code": values[0].get("test_command_exit_code")}
|
|
610
|
+
if metric == "coverage" and isinstance(values[0], dict)
|
|
611
|
+
else {}
|
|
612
|
+
),
|
|
613
|
+
}
|
|
614
|
+
result[metric] = {**row, "state": readiness_state(row)}
|
|
615
|
+
_json(output / "readiness.json", result)
|
|
616
|
+
return result
|