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,1956 @@
|
|
|
1
|
+
"""Offline, source-bound measurements of immutable submitted snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import fnmatch
|
|
7
|
+
import hashlib
|
|
8
|
+
import math
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from pluginbench.code_diff import (
|
|
21
|
+
_SOURCE_SUFFIXES,
|
|
22
|
+
_is_source_path,
|
|
23
|
+
_is_test_path,
|
|
24
|
+
_marker_path,
|
|
25
|
+
analyze_patch,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
EXCLUDED_NAMES = frozenset({".git", ".agents", ".claude", "node_modules", "__pycache__"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _digest(data: bytes) -> str:
|
|
32
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _object(value: object, label: str) -> dict[str, object]:
|
|
36
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
37
|
+
raise ValueError(f"{label} must be an object")
|
|
38
|
+
return {key: item for key, item in value.items()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _strings(value: object, label: str) -> list[str]:
|
|
42
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
43
|
+
raise ValueError(f"{label} must be a string list")
|
|
44
|
+
if len(set(value)) != len(value):
|
|
45
|
+
raise ValueError(f"{label} contains duplicate identifiers")
|
|
46
|
+
return [item for item in value]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _number(value: object, label: str) -> float:
|
|
50
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
51
|
+
raise ValueError(f"{label} must be a number")
|
|
52
|
+
if not math.isfinite(value):
|
|
53
|
+
raise ValueError(f"{label} must be finite")
|
|
54
|
+
return float(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _integer(value: object, label: str) -> int:
|
|
58
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
59
|
+
raise ValueError(f"{label} must be a nonnegative integer")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load(path: Path) -> dict[str, object]:
|
|
64
|
+
return _object(json.loads(path.read_text(encoding="utf-8")), str(path))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def inspect_snapshot(root: Path) -> dict[str, object]:
|
|
68
|
+
"""Hash every included file; reject links instead of following them."""
|
|
69
|
+
if root.is_symlink() or not root.is_dir():
|
|
70
|
+
raise ValueError(f"snapshot must be a directory, not a link: {root}")
|
|
71
|
+
files: dict[str, str] = {}
|
|
72
|
+
excluded: list[str] = []
|
|
73
|
+
source_lines = test_lines = 0
|
|
74
|
+
|
|
75
|
+
def visit(directory: Path) -> None:
|
|
76
|
+
nonlocal source_lines, test_lines
|
|
77
|
+
for path in sorted(directory.iterdir()):
|
|
78
|
+
relative = path.relative_to(root).as_posix()
|
|
79
|
+
if path.is_symlink():
|
|
80
|
+
raise ValueError(f"snapshot contains a symlink: {relative}")
|
|
81
|
+
if path.name in EXCLUDED_NAMES:
|
|
82
|
+
excluded.append(relative)
|
|
83
|
+
elif path.is_dir():
|
|
84
|
+
visit(path)
|
|
85
|
+
elif path.is_file():
|
|
86
|
+
data = path.read_bytes()
|
|
87
|
+
files[relative] = _digest(data)
|
|
88
|
+
try:
|
|
89
|
+
lines = len(data.decode("utf-8").splitlines())
|
|
90
|
+
except UnicodeDecodeError:
|
|
91
|
+
continue
|
|
92
|
+
if _is_test_path(relative):
|
|
93
|
+
test_lines += lines
|
|
94
|
+
elif _is_source_path(relative):
|
|
95
|
+
source_lines += lines
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"snapshot contains a nonregular file: {relative}")
|
|
98
|
+
|
|
99
|
+
visit(root)
|
|
100
|
+
encoded = json.dumps(files, sort_keys=True, separators=(",", ":")).encode()
|
|
101
|
+
return {
|
|
102
|
+
"digest": _digest(encoded),
|
|
103
|
+
"files": files,
|
|
104
|
+
"excluded_paths": excluded,
|
|
105
|
+
"exclusion_names": sorted(EXCLUDED_NAMES),
|
|
106
|
+
"source_lines": source_lines,
|
|
107
|
+
"test_lines": test_lines,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _changed_line_ranges(patch: str) -> dict[str, object]:
|
|
112
|
+
"""Record zero-context Git hunk ranges, including zero-width edit anchors."""
|
|
113
|
+
ranges: dict[str, dict[str, list[list[int]]]] = {}
|
|
114
|
+
old_path = new_path = None
|
|
115
|
+
in_hunk = False
|
|
116
|
+
for line in patch.splitlines():
|
|
117
|
+
if line.startswith("diff --git "):
|
|
118
|
+
old_path = new_path = None
|
|
119
|
+
in_hunk = False
|
|
120
|
+
elif not in_hunk and line.startswith("--- "):
|
|
121
|
+
old_path = _marker_path(line[4:])
|
|
122
|
+
elif not in_hunk and line.startswith("+++ "):
|
|
123
|
+
new_path = _marker_path(line[4:])
|
|
124
|
+
elif line.startswith("@@ "):
|
|
125
|
+
in_hunk = True
|
|
126
|
+
match = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line)
|
|
127
|
+
if match is None:
|
|
128
|
+
raise ValueError("invalid snapshot diff hunk")
|
|
129
|
+
for path, side, start, count in (
|
|
130
|
+
(old_path, "before", match[1], match[2]),
|
|
131
|
+
(new_path, "after", match[3], match[4]),
|
|
132
|
+
):
|
|
133
|
+
if path is not None:
|
|
134
|
+
ranges.setdefault(path, {"before": [], "after": []})[side].append(
|
|
135
|
+
[int(start), int(count) if count is not None else 1]
|
|
136
|
+
)
|
|
137
|
+
return dict(ranges)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
DUPLICATION_WINDOW = 6
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _test_duplication(patch: str) -> dict[str, object]:
|
|
144
|
+
"""Added test lines inside a run of consecutive added lines that repeats elsewhere in added tests."""
|
|
145
|
+
hunks: list[list[str]] = []
|
|
146
|
+
new_path: str | None = None
|
|
147
|
+
for line in patch.splitlines():
|
|
148
|
+
if line.startswith("diff --git "):
|
|
149
|
+
new_path = None
|
|
150
|
+
elif line.startswith("+++ "):
|
|
151
|
+
new_path = _marker_path(line[4:])
|
|
152
|
+
elif line.startswith("@@ "):
|
|
153
|
+
hunks.append([])
|
|
154
|
+
elif (
|
|
155
|
+
line.startswith("+")
|
|
156
|
+
and new_path is not None
|
|
157
|
+
and _is_test_path(new_path)
|
|
158
|
+
and Path(new_path).suffix.lower() in _SOURCE_SUFFIXES
|
|
159
|
+
and hunks
|
|
160
|
+
):
|
|
161
|
+
text = " ".join(line[1:].split())
|
|
162
|
+
if text:
|
|
163
|
+
hunks[-1].append(text)
|
|
164
|
+
windows: Counter[tuple[str, ...]] = Counter(
|
|
165
|
+
tuple(hunk[index : index + DUPLICATION_WINDOW])
|
|
166
|
+
for hunk in hunks
|
|
167
|
+
for index in range(len(hunk) - DUPLICATION_WINDOW + 1)
|
|
168
|
+
)
|
|
169
|
+
duplicated = 0
|
|
170
|
+
for hunk in hunks:
|
|
171
|
+
marked = [False] * len(hunk)
|
|
172
|
+
for index in range(len(hunk) - DUPLICATION_WINDOW + 1):
|
|
173
|
+
if windows[tuple(hunk[index : index + DUPLICATION_WINDOW])] > 1:
|
|
174
|
+
marked[index : index + DUPLICATION_WINDOW] = [True] * DUPLICATION_WINDOW
|
|
175
|
+
duplicated += sum(marked)
|
|
176
|
+
return {
|
|
177
|
+
"window_lines": DUPLICATION_WINDOW,
|
|
178
|
+
"added_test_lines": sum(len(hunk) for hunk in hunks),
|
|
179
|
+
"duplicated_added_test_lines": duplicated,
|
|
180
|
+
"method": "whitespace-normalized non-blank added lines in test source files (fixture and snapshot data excluded); a line is duplicated when it belongs to a run of consecutive added lines that appears more than once in added test code; copies of existing tests are not detected",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def compare_snapshots(before: Path, after: Path) -> dict[str, object]:
|
|
185
|
+
"""Use the existing patch analyzer, with a canonical Git diff of filtered copies."""
|
|
186
|
+
snapshots = [inspect_snapshot(before), inspect_snapshot(after)]
|
|
187
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-assess-") as directory:
|
|
188
|
+
temporary = Path(directory)
|
|
189
|
+
for root, snapshot, name in zip((before, after), snapshots, ("a", "b"), strict=True):
|
|
190
|
+
target = temporary / name
|
|
191
|
+
target.mkdir()
|
|
192
|
+
for relative in _object(snapshot["files"], "snapshot files"):
|
|
193
|
+
destination = target / relative
|
|
194
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
shutil.copyfile(root / relative, destination)
|
|
196
|
+
result = subprocess.run(
|
|
197
|
+
[
|
|
198
|
+
"git",
|
|
199
|
+
"diff",
|
|
200
|
+
"--no-index",
|
|
201
|
+
"--no-prefix",
|
|
202
|
+
"--binary",
|
|
203
|
+
"--no-ext-diff",
|
|
204
|
+
"--no-textconv",
|
|
205
|
+
"--no-color",
|
|
206
|
+
"--no-renames",
|
|
207
|
+
"--unified=0",
|
|
208
|
+
"--inter-hunk-context=0",
|
|
209
|
+
"--",
|
|
210
|
+
"a",
|
|
211
|
+
"b",
|
|
212
|
+
],
|
|
213
|
+
cwd=temporary,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
check=False,
|
|
216
|
+
)
|
|
217
|
+
if result.returncode not in {0, 1}:
|
|
218
|
+
raise ValueError(f"snapshot diff failed: {result.stderr.decode(errors='replace')}")
|
|
219
|
+
patch = result.stdout.decode(errors="surrogateescape")
|
|
220
|
+
metrics: dict[str, object] = dict(analyze_patch(patch))
|
|
221
|
+
metrics["changed_line_ranges"] = _changed_line_ranges(patch)
|
|
222
|
+
metrics["test_duplication"] = _test_duplication(patch)
|
|
223
|
+
for root, original in zip((before, after), snapshots, strict=True):
|
|
224
|
+
if inspect_snapshot(root)["digest"] != original["digest"]:
|
|
225
|
+
raise ValueError("snapshot changed during analysis")
|
|
226
|
+
metrics.update(
|
|
227
|
+
before_digest=snapshots[0]["digest"],
|
|
228
|
+
after_digest=snapshots[1]["digest"],
|
|
229
|
+
before_source_lines=snapshots[0]["source_lines"],
|
|
230
|
+
after_source_lines=snapshots[1]["source_lines"],
|
|
231
|
+
before_test_lines=snapshots[0]["test_lines"],
|
|
232
|
+
after_test_lines=snapshots[1]["test_lines"],
|
|
233
|
+
new_paths=sorted(
|
|
234
|
+
set(_object(snapshots[1]["files"], "files"))
|
|
235
|
+
- set(_object(snapshots[0]["files"], "files"))
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
return metrics
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _scoped_lines(
|
|
242
|
+
*,
|
|
243
|
+
before: Path,
|
|
244
|
+
after: Path,
|
|
245
|
+
scope: list[str],
|
|
246
|
+
change: dict[str, object],
|
|
247
|
+
kind: Literal["source", "test"],
|
|
248
|
+
) -> dict[str, object]:
|
|
249
|
+
"""Measure explicit line scope directly, reusing an already bound source diff."""
|
|
250
|
+
if (
|
|
251
|
+
not isinstance(scope, list)
|
|
252
|
+
or not scope
|
|
253
|
+
or any(not isinstance(pattern, str) or not pattern for pattern in scope)
|
|
254
|
+
):
|
|
255
|
+
raise ValueError("code-size scope must be an explicit nonempty string list")
|
|
256
|
+
normalized = sorted({pattern.removeprefix("./") for pattern in scope})
|
|
257
|
+
if any(
|
|
258
|
+
not pattern or Path(pattern).is_absolute() or ".." in Path(pattern).parts or "\\" in pattern
|
|
259
|
+
for pattern in normalized
|
|
260
|
+
):
|
|
261
|
+
raise ValueError("code-size scope requires relative source patterns")
|
|
262
|
+
snapshots = [inspect_snapshot(before), inspect_snapshot(after)]
|
|
263
|
+
for side, snapshot in zip(("before", "after"), snapshots, strict=True):
|
|
264
|
+
if change.get(f"{side}_digest") != snapshot["digest"]:
|
|
265
|
+
raise ValueError("source diff does not bind to the inspected snapshot")
|
|
266
|
+
ranges = _object(change.get("changed_line_ranges"), "changed-line ranges")
|
|
267
|
+
inventories = [_object(snapshot["files"], "snapshot files") for snapshot in snapshots]
|
|
268
|
+
populations: list[dict[str, int]] = []
|
|
269
|
+
classify = _is_source_path if kind == "source" else _is_test_path
|
|
270
|
+
for root, files in zip((before, after), inventories, strict=True):
|
|
271
|
+
lines: dict[str, int] = {}
|
|
272
|
+
for path in sorted(files):
|
|
273
|
+
if not classify(path) or not any(
|
|
274
|
+
fnmatch.fnmatchcase(path, pattern) for pattern in normalized
|
|
275
|
+
):
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
data = (root / path).read_bytes()
|
|
279
|
+
except OSError as error:
|
|
280
|
+
raise ValueError("snapshot changed during code-size analysis") from error
|
|
281
|
+
if _digest(data) != files[path]:
|
|
282
|
+
raise ValueError("snapshot changed during code-size analysis")
|
|
283
|
+
try:
|
|
284
|
+
lines[path] = len(data.decode("utf-8").splitlines())
|
|
285
|
+
except UnicodeDecodeError as error:
|
|
286
|
+
if kind == "test" and Path(path).suffix.lower() not in _SOURCE_SUFFIXES:
|
|
287
|
+
continue
|
|
288
|
+
# A file the change leaves byte-identical adds and removes no lines,
|
|
289
|
+
# whatever its encoding.
|
|
290
|
+
if inventories[0].get(path) == inventories[1].get(path):
|
|
291
|
+
continue
|
|
292
|
+
raise ValueError(f"scoped source must be UTF-8 text: {path}") from error
|
|
293
|
+
populations.append(lines)
|
|
294
|
+
added = removed = 0
|
|
295
|
+
before_files, after_files = inventories
|
|
296
|
+
for path in sorted(set(populations[0]) | set(populations[1])):
|
|
297
|
+
if before_files.get(path) != after_files.get(path) and path not in ranges:
|
|
298
|
+
raise ValueError(f"bound source diff omits changed scoped path: {path}")
|
|
299
|
+
row = _object(ranges.get(path, {"before": [], "after": []}), "file ranges")
|
|
300
|
+
if set(row) != {"before", "after"}:
|
|
301
|
+
raise ValueError("changed-line ranges require before and after")
|
|
302
|
+
counts = []
|
|
303
|
+
for side, population in zip(("before", "after"), populations, strict=True):
|
|
304
|
+
items = row[side]
|
|
305
|
+
if not isinstance(items, list):
|
|
306
|
+
raise ValueError("changed-line ranges must be a list")
|
|
307
|
+
count_total = 0
|
|
308
|
+
previous_end = 0
|
|
309
|
+
for item in items:
|
|
310
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
311
|
+
raise ValueError("changed-line range requires start and count")
|
|
312
|
+
start, count = (_integer(value, "changed-line range") for value in item)
|
|
313
|
+
if (count and start <= previous_end) or start + max(count - 1, 0) > population.get(
|
|
314
|
+
path, 0
|
|
315
|
+
):
|
|
316
|
+
raise ValueError("changed-line range escapes scoped text or overlaps")
|
|
317
|
+
if count:
|
|
318
|
+
previous_end = start + count - 1
|
|
319
|
+
count_total += count
|
|
320
|
+
counts.append(count_total)
|
|
321
|
+
removed += counts[0]
|
|
322
|
+
added += counts[1]
|
|
323
|
+
for root, original in zip((before, after), snapshots, strict=True):
|
|
324
|
+
if inspect_snapshot(root)["digest"] != original["digest"]:
|
|
325
|
+
raise ValueError("snapshot changed during code-size analysis")
|
|
326
|
+
return {
|
|
327
|
+
"status": "available",
|
|
328
|
+
"method": f"scoped-{kind}-lines-v1",
|
|
329
|
+
"scope": normalized,
|
|
330
|
+
"before_digest": snapshots[0]["digest"],
|
|
331
|
+
"after_digest": snapshots[1]["digest"],
|
|
332
|
+
"before_paths": sorted(populations[0]),
|
|
333
|
+
"after_paths": sorted(populations[1]),
|
|
334
|
+
f"before_{kind}_lines": sum(populations[0].values()),
|
|
335
|
+
f"after_{kind}_lines": sum(populations[1].values()),
|
|
336
|
+
f"{kind}_lines_added": added,
|
|
337
|
+
f"{kind}_lines_removed": removed,
|
|
338
|
+
"counting_semantics": f"UTF-8 splitlines totals from snapshot inventory paths matched by fnmatchcase; shared {kind} classification; additions/removals sum supplied zero-context Git hunk counts in scope; no analyzer file-discovery or generated-directory filtering",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def scoped_code_size(
|
|
343
|
+
*, before: Path, after: Path, scope: list[str], change: dict[str, object]
|
|
344
|
+
) -> dict[str, object]:
|
|
345
|
+
return _scoped_lines(before=before, after=after, scope=scope, change=change, kind="source")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def scoped_test_size(
|
|
349
|
+
*, before: Path, after: Path, scope: list[str], change: dict[str, object]
|
|
350
|
+
) -> dict[str, object]:
|
|
351
|
+
return _scoped_lines(before=before, after=after, scope=scope, change=change, kind="test")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _source_path(path: str, files: set[str], scope: list[str]) -> None:
|
|
355
|
+
if path not in files or not any(fnmatch.fnmatchcase(path, pattern) for pattern in scope):
|
|
356
|
+
raise ValueError(f"measurement path escapes source-bound scope: {path}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _coordinates(
|
|
360
|
+
value: object, label: str, files: set[str], scope: list[str]
|
|
361
|
+
) -> tuple[set[str], set[str]]:
|
|
362
|
+
row = _object(value, label)
|
|
363
|
+
if not {"total", "covered"} <= set(row) or set(row) - {
|
|
364
|
+
"total",
|
|
365
|
+
"covered",
|
|
366
|
+
"unit",
|
|
367
|
+
"weights",
|
|
368
|
+
"files_measured",
|
|
369
|
+
}:
|
|
370
|
+
raise ValueError(f"{label} requires total and covered coordinate lists")
|
|
371
|
+
total = set(_strings(row["total"], f"{label}.total"))
|
|
372
|
+
covered = set(_strings(row["covered"], f"{label}.covered"))
|
|
373
|
+
for coordinate in total:
|
|
374
|
+
if ":" not in coordinate:
|
|
375
|
+
raise ValueError("coverage coordinates require relative-path:coordinate")
|
|
376
|
+
_source_path(coordinate.split(":", 1)[0], files, scope)
|
|
377
|
+
if not covered <= total:
|
|
378
|
+
raise ValueError(f"{label} covered coordinates escape the fixed denominator")
|
|
379
|
+
return total, covered
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _percent(numerator: int, denominator: int) -> float | None:
|
|
383
|
+
return 100 * numerator / denominator if denominator else None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _marginal(value: object, measured: set[str], label: str) -> list[str] | None:
|
|
387
|
+
if value is None:
|
|
388
|
+
return None
|
|
389
|
+
rows = _object(value, label)
|
|
390
|
+
per_test = {name: set(_strings(items, f"{label}.{name}")) for name, items in rows.items()}
|
|
391
|
+
if any(not items <= measured for items in per_test.values()):
|
|
392
|
+
raise ValueError(f"{label} includes unmeasured coordinates")
|
|
393
|
+
return sorted(
|
|
394
|
+
name
|
|
395
|
+
for name, items in per_test.items()
|
|
396
|
+
if not items - set().union(*(other for key, other in per_test.items() if key != name))
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _coverage_limits(
|
|
401
|
+
report: dict[str, object], files: set[str], scope: list[str]
|
|
402
|
+
) -> dict[str, object]:
|
|
403
|
+
limits = _object(report.get("unavailable_populations", {}), "unavailable populations")
|
|
404
|
+
for kind, value in limits.items():
|
|
405
|
+
row = _object(value, "unavailable population")
|
|
406
|
+
if kind not in {"lines", "statements", "branches", "functions"} or not isinstance(
|
|
407
|
+
row.get("reason"), str
|
|
408
|
+
):
|
|
409
|
+
raise ValueError("Unavailable coverage requires a known unit and reason")
|
|
410
|
+
for path in _strings(row.get("files"), "unavailable coverage files"):
|
|
411
|
+
_source_path(path, files, scope)
|
|
412
|
+
return limits
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _coverage(report: dict[str, object], files: set[str], scope: list[str]) -> dict[str, object]:
|
|
416
|
+
test_scope = (
|
|
417
|
+
[name.removeprefix("./") for name in _strings(report["test_scope"], "test scope")]
|
|
418
|
+
if report.get("test_scope") is not None
|
|
419
|
+
else None
|
|
420
|
+
)
|
|
421
|
+
excluded_tests = [
|
|
422
|
+
name.removeprefix("./")
|
|
423
|
+
for name in _strings(report.get("excluded_test_files", []), "excluded test files")
|
|
424
|
+
]
|
|
425
|
+
populations = {
|
|
426
|
+
kind: _coordinates(report[kind], kind, files, scope)
|
|
427
|
+
for kind in ("lines", "statements", "branches", "functions")
|
|
428
|
+
if kind in report
|
|
429
|
+
}
|
|
430
|
+
if not populations:
|
|
431
|
+
raise ValueError("coverage requires at least one explicit measured population")
|
|
432
|
+
covered_branches = populations.get("branches", (set(), set()))[1]
|
|
433
|
+
measured_value = report.get("files_measured")
|
|
434
|
+
measured = (
|
|
435
|
+
set(_strings(measured_value, "files_measured")) if measured_value is not None else set()
|
|
436
|
+
)
|
|
437
|
+
for path in measured:
|
|
438
|
+
_source_path(path, files, scope)
|
|
439
|
+
expected = {
|
|
440
|
+
path
|
|
441
|
+
for path in files
|
|
442
|
+
if any(fnmatch.fnmatchcase(path, pattern) for pattern in scope)
|
|
443
|
+
and _is_source_path(path)
|
|
444
|
+
and Path(path).suffix.lower() in _SOURCE_SUFFIXES
|
|
445
|
+
and not path.endswith((".d.ts", ".d.mts", ".d.cts"))
|
|
446
|
+
}
|
|
447
|
+
units: dict[str, object] = {}
|
|
448
|
+
for kind, singular in (
|
|
449
|
+
("lines", "line"),
|
|
450
|
+
("statements", "statement"),
|
|
451
|
+
("branches", "branch"),
|
|
452
|
+
("functions", "function"),
|
|
453
|
+
):
|
|
454
|
+
if kind not in populations:
|
|
455
|
+
units.update(
|
|
456
|
+
{
|
|
457
|
+
f"{singular}_{suffix}": None
|
|
458
|
+
for suffix in ("total", "covered", "percent", "denominator_digest")
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
units[f"{singular}_population_complete"] = False
|
|
462
|
+
continue
|
|
463
|
+
total, covered = populations[kind]
|
|
464
|
+
row = _object(report[kind], kind)
|
|
465
|
+
if row.get("unit", singular) != singular:
|
|
466
|
+
raise ValueError(f"coverage {kind} uses an incompatible unit")
|
|
467
|
+
weights = _object(row.get("weights", {key: 1 for key in total}), f"{kind}.weights")
|
|
468
|
+
if set(weights) != total:
|
|
469
|
+
raise ValueError("coverage weights must cover the complete coordinate population")
|
|
470
|
+
counts = {key: _integer(value, "coverage weight") for key, value in weights.items()}
|
|
471
|
+
denominator = sum(counts.values())
|
|
472
|
+
numerator = sum(counts[key] for key in covered)
|
|
473
|
+
unit_files = set(
|
|
474
|
+
_strings(row.get("files_measured", sorted(measured)), f"{kind}.files_measured")
|
|
475
|
+
)
|
|
476
|
+
for path in unit_files:
|
|
477
|
+
_source_path(path, files, scope)
|
|
478
|
+
units.update(
|
|
479
|
+
{
|
|
480
|
+
f"{singular}_total": denominator,
|
|
481
|
+
f"{singular}_covered": numerator,
|
|
482
|
+
f"{singular}_percent": _percent(numerator, denominator),
|
|
483
|
+
f"{singular}_denominator_digest": _digest(
|
|
484
|
+
json.dumps(sorted(counts.items())).encode()
|
|
485
|
+
),
|
|
486
|
+
f"{singular}_population_complete": bool(expected) and expected <= unit_files,
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
lines_total = populations.get("lines", (set(), set()))[0]
|
|
490
|
+
per_test_numbits = None
|
|
491
|
+
if report.get("per_test_numbits") is not None:
|
|
492
|
+
per_test_numbits = {
|
|
493
|
+
name: {
|
|
494
|
+
path: str(encoded)
|
|
495
|
+
for path, encoded in _object(files, f"per_test_numbits.{name}").items()
|
|
496
|
+
}
|
|
497
|
+
for name, files in _object(report["per_test_numbits"], "per_test_numbits").items()
|
|
498
|
+
}
|
|
499
|
+
if any(
|
|
500
|
+
not _numbit_coordinates(files) <= lines_total for files in per_test_numbits.values()
|
|
501
|
+
):
|
|
502
|
+
raise ValueError("per_test_numbits includes unmeasured line coordinates")
|
|
503
|
+
return {
|
|
504
|
+
**units,
|
|
505
|
+
# Coordinates support changed-line coverage; per-test lines support redundancy.
|
|
506
|
+
"coordinates": {
|
|
507
|
+
unit: {"total": sorted(populations[kind][0]), "covered": sorted(populations[kind][1])}
|
|
508
|
+
for kind, unit in (("lines", "line"), ("branches", "branch"))
|
|
509
|
+
if kind in populations
|
|
510
|
+
},
|
|
511
|
+
# Compact per-test lines (coverage.py numbits per file); decoded only where needed.
|
|
512
|
+
"per_test_numbits": per_test_numbits,
|
|
513
|
+
"available_units": [
|
|
514
|
+
unit
|
|
515
|
+
for kind, unit in (
|
|
516
|
+
("lines", "line"),
|
|
517
|
+
("statements", "statement"),
|
|
518
|
+
("branches", "branch"),
|
|
519
|
+
("functions", "function"),
|
|
520
|
+
)
|
|
521
|
+
if kind in populations
|
|
522
|
+
],
|
|
523
|
+
"unavailable_populations": _coverage_limits(report, files, scope),
|
|
524
|
+
"format": report.get("format"),
|
|
525
|
+
"test_command_exit_code": report.get("test_command_exit_code"),
|
|
526
|
+
"population_complete_for_declared_scope": bool(expected) and expected <= measured,
|
|
527
|
+
"files_measured": sorted(measured),
|
|
528
|
+
"unmeasured_source_files": sorted(expected - measured),
|
|
529
|
+
"per_test_detection": "unique contribution among supplied tests only; not proof of redundancy",
|
|
530
|
+
"per_test_count": len(_object(report["per_test"], "per_test"))
|
|
531
|
+
if report.get("per_test") is not None
|
|
532
|
+
else None,
|
|
533
|
+
"zero_unique_branch_contribution_tests": _marginal(
|
|
534
|
+
report.get("per_test"), covered_branches, "per_test"
|
|
535
|
+
),
|
|
536
|
+
"test_scope": test_scope,
|
|
537
|
+
"excluded_test_files": excluded_tests,
|
|
538
|
+
"selected_test_files": report.get("selected_test_files"),
|
|
539
|
+
"reference_test_files": report.get("reference_test_files"),
|
|
540
|
+
"selection_policy": report.get("selection_policy"),
|
|
541
|
+
**({"clock": report["clock"]} if "clock" in report else {}),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
_DEAD_FIELDS = (
|
|
546
|
+
"unused_files",
|
|
547
|
+
"unused_exports",
|
|
548
|
+
"unused_types",
|
|
549
|
+
"unused_dependencies",
|
|
550
|
+
"unused_dev_dependencies",
|
|
551
|
+
"unused_optional_dependencies",
|
|
552
|
+
"unused_enum_members",
|
|
553
|
+
"unused_class_members",
|
|
554
|
+
"unused_catalog_entries",
|
|
555
|
+
"unused_dependency_overrides",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
_DEAD_IDENTITY_FIELDS = {
|
|
559
|
+
"unused_files": ("path",),
|
|
560
|
+
"unused_exports": ("path", "export_name"),
|
|
561
|
+
"unused_types": ("path", "export_name"),
|
|
562
|
+
"unused_dependencies": ("path", "package_name"),
|
|
563
|
+
"unused_dev_dependencies": ("path", "package_name"),
|
|
564
|
+
"unused_optional_dependencies": ("path", "package_name"),
|
|
565
|
+
"unused_enum_members": ("path", "parent_name", "member_name", "kind"),
|
|
566
|
+
"unused_class_members": ("path", "parent_name", "member_name", "kind"),
|
|
567
|
+
"unused_catalog_entries": ("path", "catalog_name", "entry_name"),
|
|
568
|
+
"unused_dependency_overrides": ("path", "raw_key"),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _declaration_context(root: Path, row: dict[str, object]) -> str | None:
|
|
573
|
+
line = row.get("line")
|
|
574
|
+
if type(line) is not int or line < 1:
|
|
575
|
+
return None
|
|
576
|
+
path = row["path"]
|
|
577
|
+
assert isinstance(path, str)
|
|
578
|
+
try:
|
|
579
|
+
source = (root / path).read_bytes().decode("utf-8")
|
|
580
|
+
except (OSError, UnicodeDecodeError):
|
|
581
|
+
return None
|
|
582
|
+
lines = source.splitlines(keepends=True)
|
|
583
|
+
if line > len(lines):
|
|
584
|
+
return None
|
|
585
|
+
context = lines[line - 1].strip()
|
|
586
|
+
start = row.get("span_start")
|
|
587
|
+
if start is not None:
|
|
588
|
+
offset = len("".join(lines[: line - 1]).encode("utf-8"))
|
|
589
|
+
if type(start) is not int or not offset <= start < offset + len(
|
|
590
|
+
lines[line - 1].encode("utf-8")
|
|
591
|
+
):
|
|
592
|
+
return None
|
|
593
|
+
return _digest(context.encode("utf-8")) if context else None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _dead_identities(
|
|
597
|
+
check: dict[str, object], files: set[str], root: Path
|
|
598
|
+
) -> list[dict[str, str]] | None:
|
|
599
|
+
"""Retain bound declaration context for pair-aware collision disambiguation."""
|
|
600
|
+
findings = []
|
|
601
|
+
for category, fields in _DEAD_IDENTITY_FIELDS.items():
|
|
602
|
+
items = check.get(category)
|
|
603
|
+
if not isinstance(items, list):
|
|
604
|
+
return None
|
|
605
|
+
for item in items:
|
|
606
|
+
if not isinstance(item, dict):
|
|
607
|
+
return None
|
|
608
|
+
row = _object(item, category)
|
|
609
|
+
if any(not isinstance(row.get(key), str) or not row[key] for key in fields):
|
|
610
|
+
return None
|
|
611
|
+
if row["path"] not in files:
|
|
612
|
+
raise ValueError(f"dead finding path escapes bound snapshot: {row['path']}")
|
|
613
|
+
identity = {"category": category, **{key: str(row[key]) for key in fields}}
|
|
614
|
+
context = _declaration_context(root, row)
|
|
615
|
+
if context is not None:
|
|
616
|
+
identity["declaration_context"] = context
|
|
617
|
+
findings.append(identity)
|
|
618
|
+
return findings
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _list_count(report: dict[str, object], key: str) -> int | None:
|
|
622
|
+
value = report.get(key)
|
|
623
|
+
return len(value) if isinstance(value, list) else None
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
_OXC_VERSION = "0.126.0"
|
|
627
|
+
_OXC_COMMIT = "836fe9d51c11981162461216f37a067fc14441a7"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _validated_fallow_inputs(
|
|
631
|
+
report: dict[str, object], config: dict[str, object], root: Path, inputs: set[str]
|
|
632
|
+
) -> bool:
|
|
633
|
+
"""Pinned producer execution plus strict primary parses closes silent read/parse omissions."""
|
|
634
|
+
validation = report.get("syntax_validation")
|
|
635
|
+
if not isinstance(validation, dict) or report.get("version") != "3.18.0":
|
|
636
|
+
return False
|
|
637
|
+
if any(
|
|
638
|
+
validation.get(key) != expected
|
|
639
|
+
for key, expected in {
|
|
640
|
+
"schema_version": 1,
|
|
641
|
+
"parser": "oxc-parser",
|
|
642
|
+
"version": _OXC_VERSION,
|
|
643
|
+
"commit": _OXC_COMMIT,
|
|
644
|
+
"producer_version": "3.18.0",
|
|
645
|
+
"parser_options": "fallow-3.18.0-default-primary",
|
|
646
|
+
"package_integrity": "sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==",
|
|
647
|
+
}.items()
|
|
648
|
+
):
|
|
649
|
+
return False
|
|
650
|
+
# This audited producer path shares discovery with dupes. Other modes need
|
|
651
|
+
# their own observer proof, especially per-analysis production overrides.
|
|
652
|
+
if set(config) - {"$schema", "entry", "production", "ignorePatterns", "health", "duplicates"}:
|
|
653
|
+
return False
|
|
654
|
+
if config.get("production") is not True:
|
|
655
|
+
return False
|
|
656
|
+
for section in ("health", "duplicates"):
|
|
657
|
+
value = config.get(section, {})
|
|
658
|
+
if not isinstance(value, dict) or "production" in value:
|
|
659
|
+
return False
|
|
660
|
+
duplicates = config.get("duplicates", {})
|
|
661
|
+
assert isinstance(duplicates, dict)
|
|
662
|
+
# Default duplicate options are the audited eligibility path. Do not claim
|
|
663
|
+
# completeness for unexamined nested limits, near-clone modes or overrides.
|
|
664
|
+
if duplicates:
|
|
665
|
+
return False
|
|
666
|
+
executions = validation.get("producer_execution")
|
|
667
|
+
validator_execution = validation.get("validator_execution")
|
|
668
|
+
if (
|
|
669
|
+
not isinstance(executions, list)
|
|
670
|
+
or len(executions) != 2
|
|
671
|
+
or not isinstance(validator_execution, dict)
|
|
672
|
+
or validator_execution.get("exit_code") != 0
|
|
673
|
+
):
|
|
674
|
+
return False
|
|
675
|
+
for index, execution in enumerate(executions):
|
|
676
|
+
if not isinstance(execution, dict) or execution.get("exit_code") not in (0, 1):
|
|
677
|
+
return False
|
|
678
|
+
argv = execution.get("command")
|
|
679
|
+
if not isinstance(argv, list) or not all(isinstance(item, str) for item in argv):
|
|
680
|
+
return False
|
|
681
|
+
# Exact built-in command shape excludes diff/workspace/top truncation.
|
|
682
|
+
if (
|
|
683
|
+
len(argv) != 10 + index * 2
|
|
684
|
+
or argv[0] != "fallow"
|
|
685
|
+
or argv[1] != "--root"
|
|
686
|
+
or argv[3] != "--config"
|
|
687
|
+
):
|
|
688
|
+
return False
|
|
689
|
+
if argv[5:10] != ["--format", "json", "--quiet", "--explain", "--no-cache"]:
|
|
690
|
+
return False
|
|
691
|
+
if index and argv[-2:] != ["list", "--files"]:
|
|
692
|
+
return False
|
|
693
|
+
if not isinstance(validation.get("lock_digest"), str) or not str(
|
|
694
|
+
validation["lock_digest"]
|
|
695
|
+
).startswith("sha256:"):
|
|
696
|
+
return False
|
|
697
|
+
binding = validation.get("binding")
|
|
698
|
+
if (
|
|
699
|
+
not isinstance(binding, dict)
|
|
700
|
+
or len(binding) != 1
|
|
701
|
+
or any(
|
|
702
|
+
not isinstance(value, dict)
|
|
703
|
+
or value.get("version") != _OXC_VERSION
|
|
704
|
+
or not value.get("integrity")
|
|
705
|
+
for value in binding.values()
|
|
706
|
+
)
|
|
707
|
+
):
|
|
708
|
+
return False
|
|
709
|
+
for key in ("dependency_hashes", "validator_files"):
|
|
710
|
+
hashes = validation.get(key)
|
|
711
|
+
if (
|
|
712
|
+
not isinstance(hashes, dict)
|
|
713
|
+
or not hashes
|
|
714
|
+
or any(
|
|
715
|
+
not isinstance(value, str) or not value.startswith("sha256:")
|
|
716
|
+
for value in hashes.values()
|
|
717
|
+
)
|
|
718
|
+
):
|
|
719
|
+
return False
|
|
720
|
+
rows = validation.get("files")
|
|
721
|
+
if not isinstance(rows, list) or len(rows) != len(inputs):
|
|
722
|
+
return False
|
|
723
|
+
observed = set()
|
|
724
|
+
for row in rows:
|
|
725
|
+
if not isinstance(row, dict) or row.get("path") not in inputs:
|
|
726
|
+
return False
|
|
727
|
+
path = row["path"]
|
|
728
|
+
assert isinstance(path, str)
|
|
729
|
+
if path in observed or row.get("status") != "valid" or row.get("errors") != []:
|
|
730
|
+
return False
|
|
731
|
+
try:
|
|
732
|
+
data = (root / path).read_bytes()
|
|
733
|
+
data.decode("utf-8")
|
|
734
|
+
except (OSError, UnicodeDecodeError):
|
|
735
|
+
return False
|
|
736
|
+
if row.get("source_digest") != _digest(data):
|
|
737
|
+
return False
|
|
738
|
+
observed.add(path)
|
|
739
|
+
return observed == inputs
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _clone_identities(dupes: dict[str, object], files: set[str], root: Path) -> list[str] | None:
|
|
743
|
+
groups = dupes.get("clone_groups")
|
|
744
|
+
if not isinstance(groups, list):
|
|
745
|
+
return None
|
|
746
|
+
identities = []
|
|
747
|
+
for group in groups:
|
|
748
|
+
if not isinstance(group, dict) or not isinstance(group.get("instances"), list):
|
|
749
|
+
return None
|
|
750
|
+
members = []
|
|
751
|
+
for instance in group["instances"]:
|
|
752
|
+
if not isinstance(instance, dict):
|
|
753
|
+
return None
|
|
754
|
+
path, fragment = instance.get("file"), instance.get("fragment")
|
|
755
|
+
if (
|
|
756
|
+
not isinstance(path, str)
|
|
757
|
+
or path not in files
|
|
758
|
+
or not isinstance(fragment, str)
|
|
759
|
+
or not fragment
|
|
760
|
+
):
|
|
761
|
+
return None
|
|
762
|
+
try:
|
|
763
|
+
source = (root / path).read_bytes().decode("utf-8")
|
|
764
|
+
except (OSError, UnicodeDecodeError):
|
|
765
|
+
return None
|
|
766
|
+
if fragment not in source:
|
|
767
|
+
return None
|
|
768
|
+
members.append((path, _digest(fragment.encode("utf-8"))))
|
|
769
|
+
if len(members) < 2:
|
|
770
|
+
return None
|
|
771
|
+
identities.append(_digest(json.dumps(sorted(members), separators=(",", ":")).encode()))
|
|
772
|
+
return identities if len(identities) == len(set(identities)) else None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _analysis_functions(
|
|
776
|
+
report: dict[str, object], *, analyzed: set[str], root: Path, complete: dict[str, bool]
|
|
777
|
+
) -> list[dict[str, object]]:
|
|
778
|
+
raw = report.get("functions")
|
|
779
|
+
if not isinstance(raw, list):
|
|
780
|
+
raise ValueError("analysis requires function rows")
|
|
781
|
+
functions = []
|
|
782
|
+
seen = set()
|
|
783
|
+
for value in raw:
|
|
784
|
+
row = _object(value, "analysis function")
|
|
785
|
+
path = row.get("path")
|
|
786
|
+
if (
|
|
787
|
+
not isinstance(path, str)
|
|
788
|
+
or path not in analyzed
|
|
789
|
+
or not isinstance(row.get("name"), str)
|
|
790
|
+
):
|
|
791
|
+
raise ValueError("analysis function escapes analyzed source")
|
|
792
|
+
first = _integer(row.get("line"), "function line")
|
|
793
|
+
last = _integer(row.get("end_line"), "function end line")
|
|
794
|
+
if (
|
|
795
|
+
not 1 <= first <= last <= len((root / path).read_text().splitlines())
|
|
796
|
+
or row.get("line_count") != last - first + 1
|
|
797
|
+
):
|
|
798
|
+
raise ValueError("analysis function span escapes source")
|
|
799
|
+
identity = (path, first, row.get("col"), row["name"])
|
|
800
|
+
if identity in seen:
|
|
801
|
+
raise ValueError("duplicate analysis function")
|
|
802
|
+
seen.add(identity)
|
|
803
|
+
for key in ("cognitive", "cyclomatic"):
|
|
804
|
+
if complete[key]:
|
|
805
|
+
_integer(row.get(key), key)
|
|
806
|
+
elif row.get(key) is not None:
|
|
807
|
+
raise ValueError("unavailable analysis metric must not provide scores")
|
|
808
|
+
functions.append(row)
|
|
809
|
+
return functions
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _python_declarations(source: str) -> dict[tuple[int, str], list[dict[str, object]]]:
|
|
813
|
+
"""Bind declarations to lexical scope and structure, never occurrence ordinals."""
|
|
814
|
+
declarations: dict[tuple[int, str], list[dict[str, object]]] = {}
|
|
815
|
+
scopes: Counter[tuple[str, ...]] = Counter()
|
|
816
|
+
|
|
817
|
+
def visit(node: ast.AST, scope: tuple[str, ...]) -> None:
|
|
818
|
+
symbol = None
|
|
819
|
+
role = None
|
|
820
|
+
line = getattr(node, "lineno", None)
|
|
821
|
+
outer_scope = scope
|
|
822
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
823
|
+
overload_signature = None
|
|
824
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any(
|
|
825
|
+
(isinstance(item, ast.Name) and item.id == "overload")
|
|
826
|
+
or (isinstance(item, ast.Attribute) and item.attr == "overload")
|
|
827
|
+
for item in node.decorator_list
|
|
828
|
+
):
|
|
829
|
+
overload_signature = [
|
|
830
|
+
ast.dump(node.args),
|
|
831
|
+
ast.dump(node.returns) if node.returns else None,
|
|
832
|
+
]
|
|
833
|
+
token = json.dumps(
|
|
834
|
+
[
|
|
835
|
+
type(node).__name__,
|
|
836
|
+
node.name,
|
|
837
|
+
[ast.dump(item) for item in node.decorator_list],
|
|
838
|
+
overload_signature,
|
|
839
|
+
]
|
|
840
|
+
)
|
|
841
|
+
scope = (*scope, token)
|
|
842
|
+
scopes[scope] += 1
|
|
843
|
+
symbol, role = node.name, "class" if isinstance(node, ast.ClassDef) else "function"
|
|
844
|
+
if node.decorator_list:
|
|
845
|
+
line = node.decorator_list[0].lineno
|
|
846
|
+
elif isinstance(node, ast.Lambda):
|
|
847
|
+
scope = (*scope, "Lambda:" + _digest(ast.dump(node).encode()))
|
|
848
|
+
scopes[scope] += 1
|
|
849
|
+
elif isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
850
|
+
scope = (*scope, "unsupported:" + type(node).__name__)
|
|
851
|
+
elif isinstance(node, ast.arg):
|
|
852
|
+
symbol, role = node.arg, "parameter"
|
|
853
|
+
elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
854
|
+
symbol, role = node.id, "variable"
|
|
855
|
+
elif isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store):
|
|
856
|
+
symbol, role = node.attr, "attribute"
|
|
857
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
858
|
+
for alias in node.names:
|
|
859
|
+
declarations.setdefault(
|
|
860
|
+
(node.lineno, alias.asname or alias.name.split(".")[0]), []
|
|
861
|
+
).append({"declaration_scope": list(scope), "declaration_role": "import"})
|
|
862
|
+
elif isinstance(node, ast.ExceptHandler) and node.name:
|
|
863
|
+
symbol, role = node.name, "variable"
|
|
864
|
+
if symbol is not None:
|
|
865
|
+
assert isinstance(line, int)
|
|
866
|
+
declarations.setdefault((line, symbol), []).append(
|
|
867
|
+
{"declaration_scope": list(scope), "declaration_role": role}
|
|
868
|
+
)
|
|
869
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
870
|
+
arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
|
|
871
|
+
arguments += [item for item in (node.args.vararg, node.args.kwarg) if item is not None]
|
|
872
|
+
for argument in arguments:
|
|
873
|
+
visit(argument, scope)
|
|
874
|
+
if argument.annotation is not None:
|
|
875
|
+
visit(argument.annotation, outer_scope)
|
|
876
|
+
for expression in [*node.args.defaults, *node.args.kw_defaults]:
|
|
877
|
+
if expression is not None:
|
|
878
|
+
visit(expression, outer_scope)
|
|
879
|
+
if isinstance(node, ast.Lambda):
|
|
880
|
+
visit(node.body, scope)
|
|
881
|
+
else:
|
|
882
|
+
for decorator in node.decorator_list:
|
|
883
|
+
visit(decorator, outer_scope)
|
|
884
|
+
if node.returns is not None:
|
|
885
|
+
visit(node.returns, outer_scope)
|
|
886
|
+
for statement in node.body:
|
|
887
|
+
visit(statement, scope)
|
|
888
|
+
elif isinstance(node, ast.ClassDef):
|
|
889
|
+
for class_child in [*node.bases, *node.keywords, *node.decorator_list]:
|
|
890
|
+
visit(class_child, outer_scope)
|
|
891
|
+
for statement in node.body:
|
|
892
|
+
visit(statement, scope)
|
|
893
|
+
elif not isinstance(node, ast.arg):
|
|
894
|
+
for child in ast.iter_child_nodes(node):
|
|
895
|
+
visit(child, scope)
|
|
896
|
+
|
|
897
|
+
visit(ast.parse(source), ())
|
|
898
|
+
lines = source.splitlines()
|
|
899
|
+
contexts = Counter(
|
|
900
|
+
json.dumps(
|
|
901
|
+
[row["declaration_scope"], row["declaration_role"], symbol, lines[line - 1].strip()]
|
|
902
|
+
)
|
|
903
|
+
for (line, symbol), rows in declarations.items()
|
|
904
|
+
for row in rows
|
|
905
|
+
)
|
|
906
|
+
bindings = Counter(
|
|
907
|
+
json.dumps([row["declaration_scope"], row["declaration_role"], symbol])
|
|
908
|
+
for (_, symbol), rows in declarations.items()
|
|
909
|
+
for row in rows
|
|
910
|
+
)
|
|
911
|
+
for (line, symbol), rows in declarations.items():
|
|
912
|
+
for row in rows:
|
|
913
|
+
scope = row["declaration_scope"]
|
|
914
|
+
assert isinstance(scope, list)
|
|
915
|
+
row["declaration_context_required"] = (
|
|
916
|
+
bindings[json.dumps([scope, row["declaration_role"], symbol])] > 1
|
|
917
|
+
)
|
|
918
|
+
row["declaration_bound"] = contexts[
|
|
919
|
+
json.dumps([scope, row["declaration_role"], symbol, lines[line - 1].strip()])
|
|
920
|
+
] == 1 and not any(
|
|
921
|
+
token.startswith("unsupported:") or scopes[tuple(scope[: index + 1])] > 1
|
|
922
|
+
for index, token in enumerate(scope)
|
|
923
|
+
)
|
|
924
|
+
return declarations
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _analysis_candidates(
|
|
928
|
+
report: dict[str, object], *, analyzed: set[str], root: Path
|
|
929
|
+
) -> tuple[list[object], list[dict[str, object]]]:
|
|
930
|
+
candidates = report.get("unused_candidates")
|
|
931
|
+
if not isinstance(candidates, list):
|
|
932
|
+
raise ValueError("analysis requires unused candidate rows")
|
|
933
|
+
identities = []
|
|
934
|
+
python_sources: dict[str, dict[tuple[int, str], list[dict[str, object]]]] = {}
|
|
935
|
+
for value in candidates:
|
|
936
|
+
row = _object(value, "unused candidate")
|
|
937
|
+
path = row.get("path")
|
|
938
|
+
if not isinstance(path, str) or path not in analyzed:
|
|
939
|
+
raise ValueError("unused candidate escapes analyzed source")
|
|
940
|
+
line = _integer(row.get("line"), "candidate line")
|
|
941
|
+
lines = (root / path).read_text().splitlines()
|
|
942
|
+
if not 1 <= line <= len(lines):
|
|
943
|
+
raise ValueError("unused candidate line escapes source")
|
|
944
|
+
if not all(isinstance(row.get(key), str) and row[key] for key in ("category", "symbol")):
|
|
945
|
+
raise ValueError("unused candidate requires rule and symbol")
|
|
946
|
+
identity = {
|
|
947
|
+
"category": row["category"],
|
|
948
|
+
"path": path,
|
|
949
|
+
"symbol": row["symbol"],
|
|
950
|
+
"declaration_context": lines[line - 1].strip(),
|
|
951
|
+
}
|
|
952
|
+
if report.get("language") == "python":
|
|
953
|
+
if path not in python_sources:
|
|
954
|
+
try:
|
|
955
|
+
python_sources[path] = _python_declarations((root / path).read_text())
|
|
956
|
+
except SyntaxError:
|
|
957
|
+
python_sources[path] = {}
|
|
958
|
+
roles = {
|
|
959
|
+
"vulture-unused-variable": {"variable", "parameter"},
|
|
960
|
+
"vulture-unused-attribute": {"attribute"},
|
|
961
|
+
"vulture-unused-import": {"import"},
|
|
962
|
+
"vulture-unused-class": {"class"},
|
|
963
|
+
"vulture-unused-function": {"function"},
|
|
964
|
+
"vulture-unused-method": {"function"},
|
|
965
|
+
"vulture-unused-property": {"function"},
|
|
966
|
+
}.get(str(row["category"]), set())
|
|
967
|
+
bindings = [
|
|
968
|
+
item
|
|
969
|
+
for item in python_sources[path].get((line, str(row["symbol"])), [])
|
|
970
|
+
if item["declaration_role"] in roles
|
|
971
|
+
]
|
|
972
|
+
identity.update(bindings[0] if len(bindings) == 1 else {"declaration_bound": False})
|
|
973
|
+
identities.append(identity)
|
|
974
|
+
return candidates, identities
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _analysis(
|
|
978
|
+
report: dict[str, object],
|
|
979
|
+
files: set[str],
|
|
980
|
+
scope: list[str],
|
|
981
|
+
root: Path,
|
|
982
|
+
config: dict[str, object],
|
|
983
|
+
) -> dict[str, object]:
|
|
984
|
+
if report.get("version") != "language-analysis-v1" or report.get("language") != config.get(
|
|
985
|
+
"language"
|
|
986
|
+
):
|
|
987
|
+
raise ValueError("language analysis version or language differs from frozen configuration")
|
|
988
|
+
if report.get("tools") != config.get("tool_identities"):
|
|
989
|
+
raise ValueError("analysis tools differ from frozen configuration")
|
|
990
|
+
if report.get("settings") != config.get("settings") or report.get(
|
|
991
|
+
"supported_suffixes"
|
|
992
|
+
) != config.get("supported_suffixes"):
|
|
993
|
+
raise ValueError("analysis settings or source language differ from frozen configuration")
|
|
994
|
+
suffixes = _strings(report.get("supported_suffixes"), "analysis suffixes")
|
|
995
|
+
expected = {
|
|
996
|
+
path
|
|
997
|
+
for path in files
|
|
998
|
+
if Path(path).suffix in suffixes
|
|
999
|
+
and not _is_test_path(path)
|
|
1000
|
+
and any(fnmatch.fnmatchcase(path, pattern) for pattern in scope)
|
|
1001
|
+
}
|
|
1002
|
+
analyzed = set(_strings(report.get("analyzed_paths"), "analyzed paths"))
|
|
1003
|
+
if analyzed != expected:
|
|
1004
|
+
raise ValueError("analysis paths differ from the declared source population")
|
|
1005
|
+
statuses = _object(report.get("metrics"), "analysis metrics")
|
|
1006
|
+
complete = {
|
|
1007
|
+
key: _object(statuses.get(key), key).get("status") == "available"
|
|
1008
|
+
for key in ("cognitive", "cyclomatic", "dead_code")
|
|
1009
|
+
}
|
|
1010
|
+
functions = _analysis_functions(report, analyzed=analyzed, root=root, complete=complete)
|
|
1011
|
+
candidates, identities = _analysis_candidates(report, analyzed=analyzed, root=root)
|
|
1012
|
+
return {
|
|
1013
|
+
**_function_population(functions),
|
|
1014
|
+
"complexity_complete_for_declared_scope": complete["cognitive"] and complete["cyclomatic"],
|
|
1015
|
+
"metric_statuses": statuses,
|
|
1016
|
+
"dead_code": len(candidates) if complete["dead_code"] else None,
|
|
1017
|
+
"dead_finding_identities": identities if complete["dead_code"] else None,
|
|
1018
|
+
"dead_code_complete_for_declared_scope": complete["dead_code"],
|
|
1019
|
+
"dead_code_detection": "unused candidates under the named analyzer and build settings; not semantic deadness or absence proof",
|
|
1020
|
+
"analyzed_paths": sorted(analyzed),
|
|
1021
|
+
"unused_candidates": candidates,
|
|
1022
|
+
"language": report["language"],
|
|
1023
|
+
"tools": report["tools"],
|
|
1024
|
+
"settings": report.get("settings"),
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _fallow_production_excluded(path: str) -> bool:
|
|
1029
|
+
# Pinned Fallow 3.18.0 discover/walk.rs uses literal directory separators.
|
|
1030
|
+
parts = Path(path).parts
|
|
1031
|
+
if any(
|
|
1032
|
+
part in {"__tests__", "__mocks__", "__snapshots__", "__fixtures__", "test", "tests"}
|
|
1033
|
+
for part in parts[:-1]
|
|
1034
|
+
):
|
|
1035
|
+
return True
|
|
1036
|
+
patterns = (
|
|
1037
|
+
"*.test.*",
|
|
1038
|
+
"*.spec.*",
|
|
1039
|
+
"*.e2e.*",
|
|
1040
|
+
"*.e2e-spec.*",
|
|
1041
|
+
"*.bench.*",
|
|
1042
|
+
"*.fixture.*",
|
|
1043
|
+
"*.stories.*",
|
|
1044
|
+
"*.story.*",
|
|
1045
|
+
".*.js",
|
|
1046
|
+
".*.ts",
|
|
1047
|
+
".*.mjs",
|
|
1048
|
+
".*.cjs",
|
|
1049
|
+
)
|
|
1050
|
+
return any(fnmatch.fnmatchcase(parts[-1], pattern) for pattern in patterns) or (
|
|
1051
|
+
len(parts) == 1 and fnmatch.fnmatchcase(parts[-1], "*.config.*")
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _fallow(
|
|
1056
|
+
report: dict[str, object],
|
|
1057
|
+
boundary_rules: bool,
|
|
1058
|
+
files: set[str],
|
|
1059
|
+
scope: list[str],
|
|
1060
|
+
root: Path,
|
|
1061
|
+
config: dict[str, object],
|
|
1062
|
+
config_digest: str,
|
|
1063
|
+
) -> dict[str, object]:
|
|
1064
|
+
if report.get("kind") != "combined":
|
|
1065
|
+
raise ValueError("Fallow evidence must be a combined report")
|
|
1066
|
+
check = _object(report.get("check"), "fallow.check")
|
|
1067
|
+
dupes = _object(report.get("dupes"), "fallow.dupes")
|
|
1068
|
+
health = _object(report.get("health"), "fallow.health")
|
|
1069
|
+
findings = health.get("findings")
|
|
1070
|
+
if not isinstance(findings, list):
|
|
1071
|
+
raise ValueError("fallow.health.findings must be a list")
|
|
1072
|
+
functions: list[dict[str, object]] = []
|
|
1073
|
+
for item in findings:
|
|
1074
|
+
row = _object(item, "function")
|
|
1075
|
+
if not isinstance(row.get("path"), str) or not isinstance(row.get("name"), str):
|
|
1076
|
+
raise ValueError("Fallow functions require path and name")
|
|
1077
|
+
path = row["path"]
|
|
1078
|
+
assert isinstance(path, str)
|
|
1079
|
+
_source_path(path, files, scope)
|
|
1080
|
+
_integer(row.get("line"), "function.line")
|
|
1081
|
+
if row.get("col") is not None:
|
|
1082
|
+
_integer(row["col"], "function.col")
|
|
1083
|
+
functions.append(
|
|
1084
|
+
{
|
|
1085
|
+
key: row.get(key)
|
|
1086
|
+
for key in ("path", "name", "line", "col", "cognitive", "cyclomatic", "line_count")
|
|
1087
|
+
}
|
|
1088
|
+
)
|
|
1089
|
+
for key in ("cognitive", "cyclomatic", "line_count"):
|
|
1090
|
+
_integer(row.get(key), f"function.{key}")
|
|
1091
|
+
summary_value = health.get("summary", {})
|
|
1092
|
+
summary = _object(summary_value, "fallow.health.summary")
|
|
1093
|
+
scoped_files = {
|
|
1094
|
+
path
|
|
1095
|
+
for path in files
|
|
1096
|
+
if any(fnmatch.fnmatchcase(path, pattern) for pattern in scope)
|
|
1097
|
+
and not any(
|
|
1098
|
+
fnmatch.fnmatchcase(path, pattern)
|
|
1099
|
+
for pattern in _strings(config.get("ignorePatterns", []), "Fallow ignored paths")
|
|
1100
|
+
)
|
|
1101
|
+
and (config.get("production") is not True or not _fallow_production_excluded(path))
|
|
1102
|
+
}
|
|
1103
|
+
supported_suffixes = {".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}
|
|
1104
|
+
scoped_source_files = {
|
|
1105
|
+
path for path in scoped_files if Path(path).suffix.lower() in supported_suffixes
|
|
1106
|
+
}
|
|
1107
|
+
unsupported_source_files = {
|
|
1108
|
+
path
|
|
1109
|
+
for path in scoped_files
|
|
1110
|
+
if Path(path).suffix.lower() in _SOURCE_SUFFIXES
|
|
1111
|
+
and Path(path).suffix.lower() not in supported_suffixes
|
|
1112
|
+
}
|
|
1113
|
+
identities = {(row["path"], row["name"], row["line"], row["col"]) for row in functions}
|
|
1114
|
+
complete_complexity = (
|
|
1115
|
+
bool(scoped_source_files)
|
|
1116
|
+
and not unsupported_source_files
|
|
1117
|
+
and all(type(summary.get(key)) is int for key in ("functions_analyzed", "files_analyzed"))
|
|
1118
|
+
and summary.get("functions_analyzed") == len(functions)
|
|
1119
|
+
and len(identities) == len(functions)
|
|
1120
|
+
and summary.get("files_analyzed") == len(scoped_source_files)
|
|
1121
|
+
)
|
|
1122
|
+
duplicate_stats = _object(dupes.get("stats", {}), "fallow.dupes.stats")
|
|
1123
|
+
dead_counts = {key: _list_count(check, key) for key in _DEAD_FIELDS}
|
|
1124
|
+
values = list(dead_counts.values())
|
|
1125
|
+
total_dead = sum(value for value in values if value is not None) if None not in values else None
|
|
1126
|
+
boundaries = ("boundary_violations", "boundary_coverage_violations", "boundary_call_violations")
|
|
1127
|
+
boundary_counts = [_list_count(check, key) for key in boundaries]
|
|
1128
|
+
boundary_total = (
|
|
1129
|
+
sum(value for value in boundary_counts if value is not None)
|
|
1130
|
+
if None not in boundary_counts and boundary_rules
|
|
1131
|
+
else None
|
|
1132
|
+
)
|
|
1133
|
+
# A separate discovery receipt from the same explicit config closes the file population.
|
|
1134
|
+
discovery = report.get("discovery")
|
|
1135
|
+
discovered = discovery.get("files") if isinstance(discovery, dict) else None
|
|
1136
|
+
entries = check.get("entry_points")
|
|
1137
|
+
rooted = (
|
|
1138
|
+
isinstance(entries, dict) and type(entries.get("total")) is int and entries["total"] > 0
|
|
1139
|
+
)
|
|
1140
|
+
complete_graph = (
|
|
1141
|
+
bool(scoped_source_files)
|
|
1142
|
+
and not unsupported_source_files
|
|
1143
|
+
and isinstance(discovered, list)
|
|
1144
|
+
and all(isinstance(path, str) for path in discovered)
|
|
1145
|
+
and isinstance(discovery, dict)
|
|
1146
|
+
and discovery.get("file_count") == len(discovered)
|
|
1147
|
+
and len(discovered) == len(set(discovered))
|
|
1148
|
+
and set(discovered) == scoped_source_files
|
|
1149
|
+
and check.get("unresolved_imports") == []
|
|
1150
|
+
and rooted
|
|
1151
|
+
)
|
|
1152
|
+
validation = report.get("syntax_validation")
|
|
1153
|
+
syntax_valid = (
|
|
1154
|
+
isinstance(validation, dict)
|
|
1155
|
+
and validation.get("config_digest") == config_digest
|
|
1156
|
+
and isinstance(discovered, list)
|
|
1157
|
+
and all(isinstance(path, str) for path in discovered)
|
|
1158
|
+
and set(discovered) == scoped_source_files
|
|
1159
|
+
and isinstance(discovery, dict)
|
|
1160
|
+
and discovery.get("file_count") == len(discovered)
|
|
1161
|
+
and len(discovered) == len(set(discovered))
|
|
1162
|
+
and _validated_fallow_inputs(report, config, root, scoped_source_files)
|
|
1163
|
+
)
|
|
1164
|
+
if not syntax_valid:
|
|
1165
|
+
complete_complexity = complete_graph = False
|
|
1166
|
+
|
|
1167
|
+
def graph_incomplete_reason() -> str | None:
|
|
1168
|
+
if complete_graph:
|
|
1169
|
+
return None
|
|
1170
|
+
prefix = "dead-code graph is incomplete: "
|
|
1171
|
+
if not scoped_source_files:
|
|
1172
|
+
return prefix + "no scoped source files found"
|
|
1173
|
+
if unsupported_source_files:
|
|
1174
|
+
paths = sorted(unsupported_source_files)
|
|
1175
|
+
preview = ", ".join(paths[:3])
|
|
1176
|
+
if len(paths) > 3:
|
|
1177
|
+
preview += f", and {len(paths) - 3} more"
|
|
1178
|
+
return prefix + f"unsupported source files in scope ({preview})"
|
|
1179
|
+
if not (
|
|
1180
|
+
isinstance(discovered, list)
|
|
1181
|
+
and all(isinstance(path, str) for path in discovered)
|
|
1182
|
+
and isinstance(discovery, dict)
|
|
1183
|
+
and discovery.get("file_count") == len(discovered)
|
|
1184
|
+
and len(discovered) == len(set(discovered))
|
|
1185
|
+
and set(discovered) == scoped_source_files
|
|
1186
|
+
):
|
|
1187
|
+
return prefix + "the discovered files differ from the scoped source files"
|
|
1188
|
+
unresolved = check.get("unresolved_imports")
|
|
1189
|
+
if unresolved != []:
|
|
1190
|
+
if not isinstance(unresolved, list) or not unresolved:
|
|
1191
|
+
return prefix + "unresolved imports were not reported as an empty list"
|
|
1192
|
+
imports = [
|
|
1193
|
+
f"{item.get('path', 'unknown file')} imports {item.get('specifier', 'unknown target')}"
|
|
1194
|
+
if isinstance(item, dict)
|
|
1195
|
+
else "unknown import"
|
|
1196
|
+
for item in unresolved[:3]
|
|
1197
|
+
]
|
|
1198
|
+
if len(unresolved) > 3:
|
|
1199
|
+
imports.append(f"and {len(unresolved) - 3} more")
|
|
1200
|
+
noun = "import" if len(unresolved) == 1 else "imports"
|
|
1201
|
+
return prefix + f"{len(unresolved)} unresolved {noun} ({', '.join(imports)})"
|
|
1202
|
+
if not rooted:
|
|
1203
|
+
return prefix + "no entry point found"
|
|
1204
|
+
return prefix + "syntax validation failed"
|
|
1205
|
+
|
|
1206
|
+
graph_reason = graph_incomplete_reason()
|
|
1207
|
+
eligible = duplicate_stats.get("total_files")
|
|
1208
|
+
groups = dupes.get("clone_groups")
|
|
1209
|
+
complete_clones = (
|
|
1210
|
+
bool(scoped_source_files)
|
|
1211
|
+
and not unsupported_source_files
|
|
1212
|
+
and syntax_valid
|
|
1213
|
+
and type(eligible) is int
|
|
1214
|
+
and 0 <= eligible <= len(scoped_source_files)
|
|
1215
|
+
and isinstance(groups, list)
|
|
1216
|
+
and duplicate_stats.get("clone_groups") == len(groups)
|
|
1217
|
+
)
|
|
1218
|
+
return {
|
|
1219
|
+
**_function_population(functions),
|
|
1220
|
+
"complexity_complete_for_declared_scope": complete_complexity,
|
|
1221
|
+
"complexity_detection": "complete for declared source scope"
|
|
1222
|
+
if complete_complexity
|
|
1223
|
+
else "reported functions only; configured thresholds and overrides can omit functions",
|
|
1224
|
+
"scoped_supported_files": sorted(scoped_source_files),
|
|
1225
|
+
"scoped_unsupported_files": sorted(unsupported_source_files),
|
|
1226
|
+
"dead_code_complete_for_declared_scope": complete_graph,
|
|
1227
|
+
**({"dead_code_incomplete_reason": graph_reason} if graph_reason is not None else {}),
|
|
1228
|
+
"cycles_complete_for_declared_scope": complete_graph,
|
|
1229
|
+
"boundary_violations_complete_for_declared_scope": complete_graph and boundary_rules,
|
|
1230
|
+
"graph_detection": "matching discovery population, declared entries, no unresolved imports; static findings only"
|
|
1231
|
+
if complete_graph
|
|
1232
|
+
else "reported findings only; scoped graph population or import resolution incomplete",
|
|
1233
|
+
"clone_groups_complete_for_declared_scope": complete_clones,
|
|
1234
|
+
"clone_detection": "complete configured Fallow analysis; eligibility count producer-reported"
|
|
1235
|
+
if complete_clones
|
|
1236
|
+
else "reported findings only; scoped clone-file population is unsupported or unmeasured",
|
|
1237
|
+
"dead_code": total_dead,
|
|
1238
|
+
"dead_code_categories": dead_counts,
|
|
1239
|
+
"dead_finding_identities": _dead_identities(check, files, root),
|
|
1240
|
+
"clone_input_file_count": len(scoped_source_files),
|
|
1241
|
+
"clone_eligible_stream_count": eligible,
|
|
1242
|
+
"clone_finding_identities": _clone_identities(dupes, files, root),
|
|
1243
|
+
"clone_groups": _list_count(dupes, "clone_groups"),
|
|
1244
|
+
"cycles": _list_count(check, "circular_dependencies"),
|
|
1245
|
+
"boundary_violations": boundary_total,
|
|
1246
|
+
"boundary_rules_declared": boundary_rules,
|
|
1247
|
+
"unresolved_imports": _list_count(check, "unresolved_imports"),
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
_EVIDENCE_KINDS = ("fallow", "analysis", "coverage")
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def evaluate_evidence(
|
|
1255
|
+
snapshot_root: Path,
|
|
1256
|
+
evidence_refs: dict[str, str] | None,
|
|
1257
|
+
base_dir: Path,
|
|
1258
|
+
) -> dict[str, object]:
|
|
1259
|
+
"""Import bound reports, without running analyzers or mutating submitted source."""
|
|
1260
|
+
snapshot = inspect_snapshot(snapshot_root)
|
|
1261
|
+
refs = evidence_refs or {}
|
|
1262
|
+
files = set(_object(snapshot["files"], "snapshot files"))
|
|
1263
|
+
if set(refs) - set(_EVIDENCE_KINDS):
|
|
1264
|
+
raise ValueError("unsupported evidence kind")
|
|
1265
|
+
result: dict[str, object] = {"snapshot_digest": snapshot["digest"]}
|
|
1266
|
+
for kind in _EVIDENCE_KINDS:
|
|
1267
|
+
if kind not in refs:
|
|
1268
|
+
result[kind] = {
|
|
1269
|
+
"status": "unavailable",
|
|
1270
|
+
"reason": "no source-bound report",
|
|
1271
|
+
"metrics": None,
|
|
1272
|
+
}
|
|
1273
|
+
continue
|
|
1274
|
+
envelope_path = base_dir / refs[kind]
|
|
1275
|
+
envelope = _load(envelope_path)
|
|
1276
|
+
required = {
|
|
1277
|
+
"schema_version",
|
|
1278
|
+
"snapshot_digest",
|
|
1279
|
+
"tool",
|
|
1280
|
+
"tool_version",
|
|
1281
|
+
"config",
|
|
1282
|
+
"config_digest",
|
|
1283
|
+
"scope",
|
|
1284
|
+
"report",
|
|
1285
|
+
"report_digest",
|
|
1286
|
+
}
|
|
1287
|
+
if required - set(envelope) or set(envelope) - required - {"boundary_rules_declared"}:
|
|
1288
|
+
raise ValueError(f"invalid {kind} evidence envelope fields")
|
|
1289
|
+
if (
|
|
1290
|
+
type(envelope["schema_version"]) is not int
|
|
1291
|
+
or envelope["schema_version"] != 1
|
|
1292
|
+
or envelope["snapshot_digest"] != snapshot["digest"]
|
|
1293
|
+
):
|
|
1294
|
+
raise ValueError(f"{kind} evidence does not bind to this snapshot")
|
|
1295
|
+
for key in ("tool", "tool_version", "config", "config_digest", "report", "report_digest"):
|
|
1296
|
+
if not isinstance(envelope[key], str) or not envelope[key]:
|
|
1297
|
+
raise ValueError(f"{kind}.{key} must be a nonempty string")
|
|
1298
|
+
scope = _strings(envelope["scope"], f"{kind}.scope")
|
|
1299
|
+
if not scope:
|
|
1300
|
+
raise ValueError(f"{kind} analysis scope must be explicit")
|
|
1301
|
+
config_name = envelope["config"]
|
|
1302
|
+
assert isinstance(config_name, str)
|
|
1303
|
+
config_path = envelope_path.parent / config_name
|
|
1304
|
+
if _digest(config_path.read_bytes()) != envelope["config_digest"]:
|
|
1305
|
+
raise ValueError(f"{kind} config digest mismatch")
|
|
1306
|
+
config = _load(config_path)
|
|
1307
|
+
if config.get("extends"):
|
|
1308
|
+
raise ValueError("evidence requires fully resolved JSON configuration without extends")
|
|
1309
|
+
report_name = envelope["report"]
|
|
1310
|
+
assert isinstance(report_name, str)
|
|
1311
|
+
report_path = envelope_path.parent / report_name
|
|
1312
|
+
if _digest(report_path.read_bytes()) != envelope["report_digest"]:
|
|
1313
|
+
raise ValueError(f"{kind} report digest mismatch")
|
|
1314
|
+
report = _load(report_path)
|
|
1315
|
+
boundary_rules = envelope.get("boundary_rules_declared", False)
|
|
1316
|
+
if not isinstance(boundary_rules, bool):
|
|
1317
|
+
raise ValueError("boundary_rules_declared must be boolean")
|
|
1318
|
+
if kind == "fallow" and boundary_rules:
|
|
1319
|
+
boundaries = _object(config.get("boundaries", {}), "config.boundaries")
|
|
1320
|
+
calls = _object(boundaries.get("calls", {}), "config.boundaries.calls")
|
|
1321
|
+
coverage = _object(boundaries.get("coverage", {}), "config.boundaries.coverage")
|
|
1322
|
+
boundary_rules = bool(
|
|
1323
|
+
boundaries.get("preset")
|
|
1324
|
+
or boundaries.get("rules")
|
|
1325
|
+
or calls.get("forbidden")
|
|
1326
|
+
or coverage.get("requireAllFiles")
|
|
1327
|
+
)
|
|
1328
|
+
if kind == "fallow":
|
|
1329
|
+
if report.get("version") != envelope["tool_version"] or envelope["tool"] != "fallow":
|
|
1330
|
+
raise ValueError("Fallow report version/tool disagrees with evidence envelope")
|
|
1331
|
+
metrics = _fallow(
|
|
1332
|
+
report,
|
|
1333
|
+
boundary_rules,
|
|
1334
|
+
files,
|
|
1335
|
+
scope,
|
|
1336
|
+
snapshot_root,
|
|
1337
|
+
config,
|
|
1338
|
+
str(envelope["config_digest"]),
|
|
1339
|
+
)
|
|
1340
|
+
elif kind == "analysis":
|
|
1341
|
+
if envelope["tool"] != "language-analysis" or envelope["tool_version"] != report.get(
|
|
1342
|
+
"version"
|
|
1343
|
+
):
|
|
1344
|
+
raise ValueError("language analysis envelope disagrees with report")
|
|
1345
|
+
metrics = _analysis(report, files, scope, snapshot_root, config)
|
|
1346
|
+
elif kind == "coverage":
|
|
1347
|
+
if report.get("clock") != config.get("clock"):
|
|
1348
|
+
raise ValueError("coverage clock differs from its bound configuration")
|
|
1349
|
+
if report.get("source_scope") is not None and sorted(
|
|
1350
|
+
_strings(report["source_scope"], "coverage source scope")
|
|
1351
|
+
) != sorted(scope):
|
|
1352
|
+
raise ValueError("coverage source scope differs from its bound envelope")
|
|
1353
|
+
metrics = _coverage(report, files, scope)
|
|
1354
|
+
provenance = {
|
|
1355
|
+
key: envelope[key] for key in ("tool", "tool_version", "config_digest", "scope")
|
|
1356
|
+
}
|
|
1357
|
+
if kind == "analysis":
|
|
1358
|
+
provenance.update(
|
|
1359
|
+
language=report.get("language"),
|
|
1360
|
+
analyzers=report.get("tools"),
|
|
1361
|
+
metric_settings=report.get("settings"),
|
|
1362
|
+
)
|
|
1363
|
+
if kind == "fallow" and isinstance(report.get("syntax_validation"), dict):
|
|
1364
|
+
validation = _object(report["syntax_validation"], "syntax validation")
|
|
1365
|
+
provenance["syntax_observer"] = {
|
|
1366
|
+
key: validation.get(key)
|
|
1367
|
+
for key in (
|
|
1368
|
+
"parser",
|
|
1369
|
+
"version",
|
|
1370
|
+
"commit",
|
|
1371
|
+
"parser_options",
|
|
1372
|
+
"lock_digest",
|
|
1373
|
+
"dependency_hashes",
|
|
1374
|
+
"validator_files",
|
|
1375
|
+
)
|
|
1376
|
+
}
|
|
1377
|
+
result[kind] = {
|
|
1378
|
+
"status": "available",
|
|
1379
|
+
"provenance": provenance,
|
|
1380
|
+
"metrics": metrics,
|
|
1381
|
+
"evidence_basis": "externally supplied source-bound report; execution not independently attested",
|
|
1382
|
+
}
|
|
1383
|
+
return result
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
@dataclass(frozen=True)
|
|
1387
|
+
class EvidenceBinding:
|
|
1388
|
+
snapshot: Path
|
|
1389
|
+
report: Path
|
|
1390
|
+
config: Path
|
|
1391
|
+
tool: str
|
|
1392
|
+
tool_version: str
|
|
1393
|
+
scope: tuple[str, ...]
|
|
1394
|
+
boundary_rules_declared: bool = False
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def bind_evidence(binding: EvidenceBinding) -> dict[str, object]:
|
|
1398
|
+
"""Build a receipt for existing local reports; this does not attest tool execution."""
|
|
1399
|
+
config = _load(binding.config)
|
|
1400
|
+
if config.get("extends"):
|
|
1401
|
+
raise ValueError("evidence requires fully resolved JSON configuration without extends")
|
|
1402
|
+
if not binding.tool or not binding.tool_version or not binding.scope:
|
|
1403
|
+
raise ValueError("tool, version and source scope must be explicit")
|
|
1404
|
+
return {
|
|
1405
|
+
"schema_version": 1,
|
|
1406
|
+
"snapshot_digest": inspect_snapshot(binding.snapshot)["digest"],
|
|
1407
|
+
"tool": binding.tool,
|
|
1408
|
+
"tool_version": binding.tool_version,
|
|
1409
|
+
"config": str(binding.config.resolve()),
|
|
1410
|
+
"config_digest": _digest(binding.config.read_bytes()),
|
|
1411
|
+
"scope": list(binding.scope),
|
|
1412
|
+
"report": str(binding.report.resolve()),
|
|
1413
|
+
"report_digest": _digest(binding.report.read_bytes()),
|
|
1414
|
+
"boundary_rules_declared": binding.boundary_rules_declared,
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _function_population(functions: list[dict[str, object]]) -> dict[str, object]:
|
|
1419
|
+
return {
|
|
1420
|
+
"functions": functions,
|
|
1421
|
+
"function_count_reported": len(functions),
|
|
1422
|
+
"complexity_distribution": {
|
|
1423
|
+
key: sorted(_integer(row[key], key) for row in functions if row.get(key) is not None)
|
|
1424
|
+
for key in ("cognitive", "cyclomatic", "line_count")
|
|
1425
|
+
},
|
|
1426
|
+
**{
|
|
1427
|
+
f"max_{name}_reported": max(
|
|
1428
|
+
(_integer(row[field], field) for row in functions if row.get(field) is not None),
|
|
1429
|
+
default=None,
|
|
1430
|
+
)
|
|
1431
|
+
if all(row.get(field) is not None for row in functions)
|
|
1432
|
+
else None
|
|
1433
|
+
for name, field in (
|
|
1434
|
+
("cognitive", "cognitive"),
|
|
1435
|
+
("cyclomatic", "cyclomatic"),
|
|
1436
|
+
("unit_size", "line_count"),
|
|
1437
|
+
)
|
|
1438
|
+
},
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _overlaps_change(row: dict[str, object], ranges: object) -> bool:
|
|
1443
|
+
if not isinstance(ranges, list):
|
|
1444
|
+
return False
|
|
1445
|
+
first = _integer(row["line"], "function.line")
|
|
1446
|
+
last = first + _integer(row["line_count"], "function.line_count") - 1
|
|
1447
|
+
for item in ranges:
|
|
1448
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
1449
|
+
raise ValueError("changed-line range requires start and count")
|
|
1450
|
+
start, count = (_integer(value, "changed-line range") for value in item)
|
|
1451
|
+
# A zero-width edit is after start, so insertions before/after a function
|
|
1452
|
+
# do not turn an unchanged, shifted declaration into a changed function.
|
|
1453
|
+
if (count and first <= start + count - 1 and start <= last) or (
|
|
1454
|
+
count == 0 and first <= start < last
|
|
1455
|
+
):
|
|
1456
|
+
return True
|
|
1457
|
+
return False
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
HOTSPOT_COGNITIVE = 15
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _code_added(
|
|
1464
|
+
*,
|
|
1465
|
+
before_all: list[dict[str, object]],
|
|
1466
|
+
after_all: list[dict[str, object]],
|
|
1467
|
+
changed_paths: set[str],
|
|
1468
|
+
new_paths: list[str],
|
|
1469
|
+
complete: bool,
|
|
1470
|
+
) -> dict[str, object]:
|
|
1471
|
+
"""Net source complexity and structure an attempt added, independent of inherited code."""
|
|
1472
|
+
method = "all source functions in changed files after the edit minus the same files before; unchanged functions cancel, new functions count in full, deleted ones subtract; tests and Go closure rows inside a declaration (already in its score) excluded"
|
|
1473
|
+
limitations = "added and grown functions match on path and name with line numbers removed; nested functions, overloads, renames and Go methods sharing a name on different receivers in one file can misclassify them; net sums do not depend on matching"
|
|
1474
|
+
if not complete:
|
|
1475
|
+
return {
|
|
1476
|
+
"status": "incomplete",
|
|
1477
|
+
"complete_for_declared_scope": False,
|
|
1478
|
+
"reason": "complexity population incomplete on one side",
|
|
1479
|
+
"method": method,
|
|
1480
|
+
"limitations": limitations,
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
def enclosed(row: dict[str, object], rows: list[dict[str, object]]) -> bool:
|
|
1484
|
+
# Go closure rows inside another function are already counted in that function's score.
|
|
1485
|
+
if not str(row["name"]).startswith("closure@"):
|
|
1486
|
+
return False
|
|
1487
|
+
first = _integer(row["line"], "function line")
|
|
1488
|
+
return any(
|
|
1489
|
+
other is not row
|
|
1490
|
+
and other["path"] == row["path"]
|
|
1491
|
+
and _integer(other["line"], "function line")
|
|
1492
|
+
<= first
|
|
1493
|
+
< _integer(other["line"], "function line") + _integer(other["line_count"], "line count")
|
|
1494
|
+
and (other["line"], other["line_count"]) != (row["line"], row["line_count"])
|
|
1495
|
+
for other in rows
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
def source(rows: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
1499
|
+
return [
|
|
1500
|
+
row
|
|
1501
|
+
for row in rows
|
|
1502
|
+
if str(row["path"]) in changed_paths
|
|
1503
|
+
and not _is_test_path(str(row["path"]))
|
|
1504
|
+
and not enclosed(row, rows)
|
|
1505
|
+
]
|
|
1506
|
+
|
|
1507
|
+
def identity(row: dict[str, object]) -> tuple[str, str]:
|
|
1508
|
+
# Go method names embed their line ("method@47:Name"); moving a method must not rename it.
|
|
1509
|
+
return (str(row["path"]), re.sub(r"@\d+(?::\d+)?:?", "@", str(row["name"])))
|
|
1510
|
+
|
|
1511
|
+
def total(rows: list[dict[str, object]], key: str) -> int:
|
|
1512
|
+
return sum(_integer(row[key], key) for row in rows)
|
|
1513
|
+
|
|
1514
|
+
before_rows, after_rows = source(before_all), source(after_all)
|
|
1515
|
+
previous: dict[tuple[str, str], int] = {}
|
|
1516
|
+
for row in before_rows:
|
|
1517
|
+
previous[identity(row)] = max(
|
|
1518
|
+
previous.get(identity(row), -1), _integer(row["cognitive"], "cognitive")
|
|
1519
|
+
)
|
|
1520
|
+
analyzed_paths = {str(row["path"]) for row in after_rows}
|
|
1521
|
+
return {
|
|
1522
|
+
"status": "available",
|
|
1523
|
+
"net_cognitive": total(after_rows, "cognitive") - total(before_rows, "cognitive"),
|
|
1524
|
+
"net_cyclomatic": total(after_rows, "cyclomatic") - total(before_rows, "cyclomatic"),
|
|
1525
|
+
"functions_added": sum(identity(row) not in previous for row in after_rows),
|
|
1526
|
+
"source_files_added": sum(
|
|
1527
|
+
_is_source_path(path) and path in analyzed_paths for path in new_paths
|
|
1528
|
+
),
|
|
1529
|
+
"hotspots_added": sum(
|
|
1530
|
+
_integer(row["cognitive"], "cognitive") > HOTSPOT_COGNITIVE
|
|
1531
|
+
and _integer(row["cognitive"], "cognitive") > previous.get(identity(row), -1)
|
|
1532
|
+
for row in after_rows
|
|
1533
|
+
),
|
|
1534
|
+
"hotspot_threshold": HOTSPOT_COGNITIVE,
|
|
1535
|
+
"function_count_reported": len(after_rows),
|
|
1536
|
+
"complete_for_declared_scope": True,
|
|
1537
|
+
"method": method,
|
|
1538
|
+
"limitations": limitations,
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _function_changes(
|
|
1543
|
+
left: dict[str, object], right: dict[str, object], change: dict[str, object] | None
|
|
1544
|
+
) -> dict[str, object]:
|
|
1545
|
+
unavailable = {"status": "unavailable", "reason": "bound source diff or function rows missing"}
|
|
1546
|
+
if change is None or not all(isinstance(row.get("functions"), list) for row in (left, right)):
|
|
1547
|
+
return {
|
|
1548
|
+
"changed_functions": unavailable,
|
|
1549
|
+
"new_file_functions": unavailable,
|
|
1550
|
+
"code_added": unavailable,
|
|
1551
|
+
}
|
|
1552
|
+
ranges = _object(change.get("changed_line_ranges"), "changed-line ranges")
|
|
1553
|
+
populations = []
|
|
1554
|
+
complete = all(
|
|
1555
|
+
row.get("complexity_complete_for_declared_scope") is True for row in (left, right)
|
|
1556
|
+
)
|
|
1557
|
+
for side, metrics in (("before", left), ("after", right)):
|
|
1558
|
+
raw = metrics["functions"]
|
|
1559
|
+
assert isinstance(raw, list)
|
|
1560
|
+
functions = [_object(row, "function") for row in raw]
|
|
1561
|
+
changed = [
|
|
1562
|
+
row
|
|
1563
|
+
for row in functions
|
|
1564
|
+
if _overlaps_change(
|
|
1565
|
+
row, _object(ranges.get(str(row["path"]), {}), "file ranges").get(side)
|
|
1566
|
+
)
|
|
1567
|
+
]
|
|
1568
|
+
populations.append(
|
|
1569
|
+
{
|
|
1570
|
+
**_function_population(changed),
|
|
1571
|
+
"complete_for_declared_scope": metrics.get("complexity_complete_for_declared_scope")
|
|
1572
|
+
is True,
|
|
1573
|
+
}
|
|
1574
|
+
)
|
|
1575
|
+
raw_after = right["functions"]
|
|
1576
|
+
assert isinstance(raw_after, list)
|
|
1577
|
+
new_paths = set(_strings(change.get("new_paths"), "new paths"))
|
|
1578
|
+
new_functions = [
|
|
1579
|
+
_object(row, "function")
|
|
1580
|
+
for row in raw_after
|
|
1581
|
+
if _object(row, "function")["path"] in new_paths
|
|
1582
|
+
]
|
|
1583
|
+
status = "available" if complete else "incomplete"
|
|
1584
|
+
before_all = left["functions"]
|
|
1585
|
+
assert isinstance(before_all, list)
|
|
1586
|
+
return {
|
|
1587
|
+
"code_added": _code_added(
|
|
1588
|
+
before_all=[_object(row, "function") for row in before_all],
|
|
1589
|
+
after_all=[_object(row, "function") for row in raw_after],
|
|
1590
|
+
changed_paths=set(ranges),
|
|
1591
|
+
new_paths=sorted(new_paths),
|
|
1592
|
+
complete=complete,
|
|
1593
|
+
),
|
|
1594
|
+
"changed_functions": {
|
|
1595
|
+
"status": status,
|
|
1596
|
+
"before": populations[0],
|
|
1597
|
+
"after": populations[1],
|
|
1598
|
+
"method": "reported function line..line+line_count-1 overlaps a zero-context Git hunk on that snapshot; zero-width edits strictly inside the span count; includes tests when scoped",
|
|
1599
|
+
"limitations": "line-level overlap, not AST identity or semantic change; enclosing/nested spans may both count, same-line changes may overcount, moves/renames count as changes; before/after populations are not matched functions",
|
|
1600
|
+
},
|
|
1601
|
+
"new_file_functions": {
|
|
1602
|
+
"status": "available"
|
|
1603
|
+
if right.get("complexity_complete_for_declared_scope") is True
|
|
1604
|
+
else "incomplete",
|
|
1605
|
+
"after": {
|
|
1606
|
+
**_function_population(new_functions),
|
|
1607
|
+
"complete_for_declared_scope": right.get("complexity_complete_for_declared_scope")
|
|
1608
|
+
is True,
|
|
1609
|
+
},
|
|
1610
|
+
"paths": sorted(new_paths),
|
|
1611
|
+
"method": "reported functions in paths absent from before; no new-function classification inside existing files",
|
|
1612
|
+
},
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def _dead_changes(left: dict[str, object], right: dict[str, object]) -> dict[str, object]:
|
|
1617
|
+
identities = [row.get("dead_finding_identities") for row in (left, right)]
|
|
1618
|
+
if not all(
|
|
1619
|
+
row.get("dead_code_complete_for_declared_scope") is True for row in (left, right)
|
|
1620
|
+
) or not all(isinstance(value, list) for value in identities):
|
|
1621
|
+
return {
|
|
1622
|
+
"status": "unavailable",
|
|
1623
|
+
"reason": "complete graph populations and unambiguous declaration identities required on both snapshots",
|
|
1624
|
+
"introduced_count": None,
|
|
1625
|
+
"resolved_count": None,
|
|
1626
|
+
}
|
|
1627
|
+
bases = []
|
|
1628
|
+
for items in identities:
|
|
1629
|
+
assert isinstance(items, list)
|
|
1630
|
+
if any(_object(row, "dead identity").get("declaration_bound") is False for row in items):
|
|
1631
|
+
return {
|
|
1632
|
+
"status": "unavailable",
|
|
1633
|
+
"reason": "Python candidates lack unique source-bound lexical declarations",
|
|
1634
|
+
"introduced_count": None,
|
|
1635
|
+
"resolved_count": None,
|
|
1636
|
+
}
|
|
1637
|
+
bases.append(
|
|
1638
|
+
[
|
|
1639
|
+
{
|
|
1640
|
+
key: value
|
|
1641
|
+
for key, value in _object(row, "dead identity").items()
|
|
1642
|
+
if key not in {"declaration_context", "declaration_context_required"}
|
|
1643
|
+
}
|
|
1644
|
+
for row in items
|
|
1645
|
+
]
|
|
1646
|
+
)
|
|
1647
|
+
collisions = {
|
|
1648
|
+
identity
|
|
1649
|
+
for group in bases
|
|
1650
|
+
for identity, count in Counter(json.dumps(row, sort_keys=True) for row in group).items()
|
|
1651
|
+
if count > 1
|
|
1652
|
+
}
|
|
1653
|
+
for items, group in zip(identities, bases, strict=True):
|
|
1654
|
+
assert isinstance(items, list)
|
|
1655
|
+
collisions.update(
|
|
1656
|
+
json.dumps(base, sort_keys=True)
|
|
1657
|
+
for row, base in zip(items, group, strict=True)
|
|
1658
|
+
if _object(row, "dead identity").get("declaration_context_required") is True
|
|
1659
|
+
)
|
|
1660
|
+
sets = []
|
|
1661
|
+
for items, group in zip(identities, bases, strict=True):
|
|
1662
|
+
assert isinstance(items, list)
|
|
1663
|
+
matched = {}
|
|
1664
|
+
for row, base in zip(items, group, strict=True):
|
|
1665
|
+
identity = dict(base)
|
|
1666
|
+
if json.dumps(base, sort_keys=True) in collisions:
|
|
1667
|
+
context = _object(row, "dead identity").get("declaration_context")
|
|
1668
|
+
if not isinstance(context, str):
|
|
1669
|
+
return {
|
|
1670
|
+
"status": "unavailable",
|
|
1671
|
+
"reason": "colliding declarations lack bound unique context",
|
|
1672
|
+
"introduced_count": None,
|
|
1673
|
+
"resolved_count": None,
|
|
1674
|
+
}
|
|
1675
|
+
identity["declaration_context"] = context
|
|
1676
|
+
encoded = json.dumps(identity, sort_keys=True)
|
|
1677
|
+
if encoded in matched:
|
|
1678
|
+
return {
|
|
1679
|
+
"status": "unavailable",
|
|
1680
|
+
"reason": "colliding declarations lack bound unique context",
|
|
1681
|
+
"introduced_count": None,
|
|
1682
|
+
"resolved_count": None,
|
|
1683
|
+
}
|
|
1684
|
+
matched[encoded] = identity
|
|
1685
|
+
sets.append(matched)
|
|
1686
|
+
introduced = [sets[1][key] for key in sorted(sets[1].keys() - sets[0].keys())]
|
|
1687
|
+
resolved = [sets[0][key] for key in sorted(sets[0].keys() - sets[1].keys())]
|
|
1688
|
+
return {
|
|
1689
|
+
"status": "available",
|
|
1690
|
+
"introduced": introduced,
|
|
1691
|
+
"resolved": resolved,
|
|
1692
|
+
"introduced_count": len(introduced),
|
|
1693
|
+
"resolved_count": len(resolved),
|
|
1694
|
+
"before_count": len(sets[0]),
|
|
1695
|
+
"after_count": len(sets[1]),
|
|
1696
|
+
"method": "category plus declaration path/symbol fields; Python uses AST-bound lexical scopes, structural overload/lambda identities, and declaration roles; colliding identities in either snapshot or Python source additionally use unique bound declaration-line context, excluding coordinates/actions; matching tool/version/config/scope and complete static graph populations required",
|
|
1697
|
+
"limitations": "static finding changes only, not semantic deadness; moves/renames and changed colliding declaration-line content change identity; identical collision contexts remain unknown; externally used APIs and runtime-only references are outside this evidence",
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _clone_changes(left: dict[str, object], right: dict[str, object]) -> dict[str, object]:
|
|
1702
|
+
before, after = left.get("clone_finding_identities"), right.get("clone_finding_identities")
|
|
1703
|
+
if (
|
|
1704
|
+
not left.get("clone_groups_complete_for_declared_scope")
|
|
1705
|
+
or not right.get("clone_groups_complete_for_declared_scope")
|
|
1706
|
+
or not isinstance(before, list)
|
|
1707
|
+
or not isinstance(after, list)
|
|
1708
|
+
):
|
|
1709
|
+
return {
|
|
1710
|
+
"status": "unavailable",
|
|
1711
|
+
"introduced_count": None,
|
|
1712
|
+
"resolved_count": None,
|
|
1713
|
+
"reason": "complete configured analysis or unique source-bound clone identities missing",
|
|
1714
|
+
}
|
|
1715
|
+
return {
|
|
1716
|
+
"status": "available",
|
|
1717
|
+
"introduced_count": len(set(after) - set(before)),
|
|
1718
|
+
"resolved_count": len(set(before) - set(after)),
|
|
1719
|
+
"before_count": len(before),
|
|
1720
|
+
"after_count": len(after),
|
|
1721
|
+
"method": "exact reported clone-group path/content membership, independent of line shifts",
|
|
1722
|
+
"limitations": [
|
|
1723
|
+
"Fragment or membership edits change group identity; these are configured static findings, not semantic redundancy."
|
|
1724
|
+
],
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _coverage_unit_comparison(
|
|
1729
|
+
left: dict[str, object], right: dict[str, object], unit: str
|
|
1730
|
+
) -> dict[str, object]:
|
|
1731
|
+
kind = {
|
|
1732
|
+
"line": "lines",
|
|
1733
|
+
"statement": "statements",
|
|
1734
|
+
"branch": "branches",
|
|
1735
|
+
"function": "functions",
|
|
1736
|
+
}[unit]
|
|
1737
|
+
reasons = []
|
|
1738
|
+
for side, metrics in (("before", left), ("after", right)):
|
|
1739
|
+
limits = _object(metrics.get("unavailable_populations", {}), "unavailable populations")
|
|
1740
|
+
limit = _object(limits.get(kind, {}), "coverage unit limit")
|
|
1741
|
+
complete = metrics.get(
|
|
1742
|
+
f"{unit}_population_complete", metrics.get("population_complete_for_declared_scope")
|
|
1743
|
+
)
|
|
1744
|
+
reason = None
|
|
1745
|
+
if complete is not True and limit.get("reason"):
|
|
1746
|
+
reason = limit["reason"]
|
|
1747
|
+
elif metrics.get(f"{unit}_denominator_digest") is None:
|
|
1748
|
+
reason = (
|
|
1749
|
+
f"{unit} population not supplied by {metrics.get('format') or 'this report format'}"
|
|
1750
|
+
)
|
|
1751
|
+
elif complete is not True:
|
|
1752
|
+
reason = f"{unit} declared source population is partial or completeness is unproven"
|
|
1753
|
+
if reason is not None:
|
|
1754
|
+
reasons.append(f"{side}: {reason}")
|
|
1755
|
+
key = f"{unit}_denominator_digest"
|
|
1756
|
+
if not reasons and (not isinstance(left.get(key), str) or left.get(key) != right.get(key)):
|
|
1757
|
+
reasons.append("fixed denominator differs or is unbound")
|
|
1758
|
+
return {
|
|
1759
|
+
"status": "unavailable" if reasons else "available",
|
|
1760
|
+
"reason": "; ".join(reasons) if reasons else None,
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _numbit_coordinates(files: Mapping[str, object]) -> set[str]:
|
|
1765
|
+
from pluginbench.coverage_report import numbits_to_lines
|
|
1766
|
+
|
|
1767
|
+
return {
|
|
1768
|
+
f"{path}:{line}"
|
|
1769
|
+
for path, encoded in files.items()
|
|
1770
|
+
for line in numbits_to_lines(str(encoded))
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _added_source_lines(change: dict[str, object]) -> set[str]:
|
|
1775
|
+
ranges = _object(change.get("changed_line_ranges"), "changed-line ranges")
|
|
1776
|
+
lines: set[str] = set()
|
|
1777
|
+
for path, sides in ranges.items():
|
|
1778
|
+
if not _is_source_path(path):
|
|
1779
|
+
continue
|
|
1780
|
+
after = _object(sides, "file ranges").get("after") or []
|
|
1781
|
+
if not isinstance(after, list):
|
|
1782
|
+
raise ValueError("changed-line ranges require a list")
|
|
1783
|
+
for item in after:
|
|
1784
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
1785
|
+
raise ValueError("changed-line range requires start and count")
|
|
1786
|
+
start, count = (_integer(value, "changed-line range") for value in item)
|
|
1787
|
+
lines.update(f"{path}:{line}" for line in range(start, start + count))
|
|
1788
|
+
return lines
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def _changed_line_coverage(
|
|
1792
|
+
left: dict[str, object], right: dict[str, object], change: dict[str, object] | None
|
|
1793
|
+
) -> dict[str, object]:
|
|
1794
|
+
"""Coverage of the attempt's own added source lines, and added tests that add no unique coverage."""
|
|
1795
|
+
coordinates = right.get("coordinates")
|
|
1796
|
+
if change is None or not isinstance(coordinates, dict) or "line" not in coordinates:
|
|
1797
|
+
return {"status": "unavailable", "reason": "bound source diff or line coordinates missing"}
|
|
1798
|
+
added = _added_source_lines(change)
|
|
1799
|
+
unmeasured = sorted(
|
|
1800
|
+
set(_strings(right.get("unmeasured_source_files", []), "unmeasured source files"))
|
|
1801
|
+
& {line.rsplit(":", 1)[0] for line in added}
|
|
1802
|
+
)
|
|
1803
|
+
if unmeasured:
|
|
1804
|
+
# Dropping an untested added file from the report would otherwise raise the rate.
|
|
1805
|
+
return {
|
|
1806
|
+
"status": "incomplete",
|
|
1807
|
+
"reason": "added source files missing from the coverage report: "
|
|
1808
|
+
+ ", ".join(unmeasured),
|
|
1809
|
+
}
|
|
1810
|
+
result: dict[str, object] = {
|
|
1811
|
+
"status": "available",
|
|
1812
|
+
"method": "added source lines from the bound diff intersected with the submission's executable and executed coordinates; branches belong to the added line they start on",
|
|
1813
|
+
"limitations": "executing a line does not prove its behavior is asserted; redundant tests are candidates: equal coverage does not establish equal assertions",
|
|
1814
|
+
}
|
|
1815
|
+
for unit in ("line", "branch"):
|
|
1816
|
+
population = _object(coordinates.get(unit, {}), unit)
|
|
1817
|
+
if not population:
|
|
1818
|
+
result.update({f"{unit}_total": None, f"{unit}_covered": None, f"{unit}_percent": None})
|
|
1819
|
+
continue
|
|
1820
|
+
|
|
1821
|
+
def on_added_line(coordinate: str) -> bool:
|
|
1822
|
+
path, _, rest = coordinate.partition(":")
|
|
1823
|
+
return f"{path}:{rest.split(':')[0]}" in added
|
|
1824
|
+
|
|
1825
|
+
total = {item for item in _strings(population["total"], unit) if on_added_line(item)}
|
|
1826
|
+
covered = {item for item in _strings(population["covered"], unit) if item in total}
|
|
1827
|
+
result.update(
|
|
1828
|
+
{
|
|
1829
|
+
f"{unit}_total": len(total),
|
|
1830
|
+
f"{unit}_covered": len(covered),
|
|
1831
|
+
f"{unit}_percent": _percent(len(covered), len(total)),
|
|
1832
|
+
}
|
|
1833
|
+
)
|
|
1834
|
+
result["added_executable_lines"] = result.pop("line_total")
|
|
1835
|
+
result["added_lines_covered"] = result.pop("line_covered")
|
|
1836
|
+
before_tests, after_tests = left.get("per_test_numbits"), right.get("per_test_numbits")
|
|
1837
|
+
if not isinstance(before_tests, dict) or not isinstance(after_tests, dict):
|
|
1838
|
+
result.update(
|
|
1839
|
+
added_tests=None,
|
|
1840
|
+
redundant_added_tests=None,
|
|
1841
|
+
redundant_added_test_names=None,
|
|
1842
|
+
added_tests_without_added_lines=None,
|
|
1843
|
+
redundancy_reason="per-test line coverage was not recorded for both snapshots",
|
|
1844
|
+
)
|
|
1845
|
+
return result
|
|
1846
|
+
reached = {
|
|
1847
|
+
name: _numbit_coordinates(_object(files, f"per_test_numbits.{name}")) & added
|
|
1848
|
+
for name, files in after_tests.items()
|
|
1849
|
+
}
|
|
1850
|
+
new_tests = sorted(set(after_tests) - set(before_tests))
|
|
1851
|
+
reaching = [name for name in new_tests if reached[name]]
|
|
1852
|
+
# Flag the smallest tests first and drop each flagged test from the pool, so removing every
|
|
1853
|
+
# flagged test together keeps added-line coverage unchanged (one of two duplicates stays).
|
|
1854
|
+
remaining = dict(reached)
|
|
1855
|
+
redundant = []
|
|
1856
|
+
for name in sorted(reaching, key=lambda item: (len(reached[item]), item)):
|
|
1857
|
+
others = set().union(*(lines for other, lines in remaining.items() if other != name))
|
|
1858
|
+
if reached[name] <= others:
|
|
1859
|
+
redundant.append(name)
|
|
1860
|
+
del remaining[name]
|
|
1861
|
+
result.update(
|
|
1862
|
+
added_tests=len(new_tests),
|
|
1863
|
+
redundant_added_tests=len(redundant),
|
|
1864
|
+
redundant_added_test_names=sorted(redundant),
|
|
1865
|
+
added_tests_without_added_lines=len(new_tests) - len(reaching),
|
|
1866
|
+
)
|
|
1867
|
+
return result
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def compare_evidence(
|
|
1871
|
+
before: dict[str, object], after: dict[str, object], *, change: dict[str, object] | None = None
|
|
1872
|
+
) -> dict[str, object]:
|
|
1873
|
+
"""Compare measurements only with compatible collection and denominator provenance."""
|
|
1874
|
+
result: dict[str, object] = {}
|
|
1875
|
+
if change is not None and (
|
|
1876
|
+
change.get("before_digest") != before.get("snapshot_digest")
|
|
1877
|
+
or change.get("after_digest") != after.get("snapshot_digest")
|
|
1878
|
+
):
|
|
1879
|
+
raise ValueError("changed-line diff does not bind to evidence snapshots")
|
|
1880
|
+
for kind in _EVIDENCE_KINDS:
|
|
1881
|
+
left = _object(before.get(kind, {}), f"before.{kind}")
|
|
1882
|
+
right = _object(after.get(kind, {}), f"after.{kind}")
|
|
1883
|
+
if not isinstance(left.get("metrics"), dict) or not isinstance(right.get("metrics"), dict):
|
|
1884
|
+
result[kind] = {
|
|
1885
|
+
"status": "unavailable",
|
|
1886
|
+
"reason": "before or after evidence missing",
|
|
1887
|
+
"deltas": None,
|
|
1888
|
+
}
|
|
1889
|
+
continue
|
|
1890
|
+
left_provenance = _object(left.get("provenance"), "before provenance")
|
|
1891
|
+
right_provenance = _object(right.get("provenance"), "after provenance")
|
|
1892
|
+
if left_provenance != right_provenance:
|
|
1893
|
+
result[kind] = {
|
|
1894
|
+
"status": "incomplete",
|
|
1895
|
+
"reason": "tool/version/config/scope differ",
|
|
1896
|
+
"deltas": None,
|
|
1897
|
+
}
|
|
1898
|
+
continue
|
|
1899
|
+
left_metrics = _object(left["metrics"], "before metrics")
|
|
1900
|
+
right_metrics = _object(right["metrics"], "after metrics")
|
|
1901
|
+
deltas: dict[str, float] = {}
|
|
1902
|
+
coverage_units: dict[str, object] = {}
|
|
1903
|
+
if kind == "coverage":
|
|
1904
|
+
for unit in ("line", "statement", "branch", "function"):
|
|
1905
|
+
coverage_units[unit] = _coverage_unit_comparison(left_metrics, right_metrics, unit)
|
|
1906
|
+
for key, left_value in left_metrics.items():
|
|
1907
|
+
if kind == "coverage":
|
|
1908
|
+
unit = key.partition("_")[0]
|
|
1909
|
+
if (
|
|
1910
|
+
unit not in coverage_units
|
|
1911
|
+
or _object(coverage_units[unit], unit)["status"] != "available"
|
|
1912
|
+
):
|
|
1913
|
+
continue
|
|
1914
|
+
right_value = right_metrics.get(key)
|
|
1915
|
+
if (
|
|
1916
|
+
isinstance(left_value, (int, float))
|
|
1917
|
+
and not isinstance(left_value, bool)
|
|
1918
|
+
and isinstance(right_value, (int, float))
|
|
1919
|
+
and not isinstance(right_value, bool)
|
|
1920
|
+
):
|
|
1921
|
+
deltas[key] = float(right_value) - float(left_value)
|
|
1922
|
+
result[kind] = {
|
|
1923
|
+
"status": "available",
|
|
1924
|
+
"deltas": deltas,
|
|
1925
|
+
"interpretation": "net changes in compatible reported metrics; not identity-grounded newly introduced findings",
|
|
1926
|
+
}
|
|
1927
|
+
if kind == "coverage":
|
|
1928
|
+
result[kind] = {
|
|
1929
|
+
**_object(result[kind], kind),
|
|
1930
|
+
"units": coverage_units,
|
|
1931
|
+
"changed_lines": _changed_line_coverage(left_metrics, right_metrics, change),
|
|
1932
|
+
}
|
|
1933
|
+
if not any(
|
|
1934
|
+
_object(unit, "coverage unit")["status"] == "available"
|
|
1935
|
+
for unit in coverage_units.values()
|
|
1936
|
+
):
|
|
1937
|
+
result[kind] = {
|
|
1938
|
+
**_object(result[kind], kind),
|
|
1939
|
+
"status": "incomplete",
|
|
1940
|
+
"reason": "no compatible complete coverage populations; see per-unit reasons",
|
|
1941
|
+
"deltas": None,
|
|
1942
|
+
}
|
|
1943
|
+
if kind in {"fallow", "analysis"}:
|
|
1944
|
+
comparison = _object(result[kind], "static analysis comparison")
|
|
1945
|
+
comparison.update(_function_changes(left_metrics, right_metrics, change))
|
|
1946
|
+
comparison["dead_findings"] = _dead_changes(left_metrics, right_metrics)
|
|
1947
|
+
if kind == "analysis":
|
|
1948
|
+
dead = _object(comparison["dead_findings"], "unused candidate comparison")
|
|
1949
|
+
dead["limitations"] = (
|
|
1950
|
+
"candidate changes under the fixed named analyzer/build settings; not proof of semantic deadness or absence; renamed symbols/scopes, changed decorators/overload signatures/lambda expressions, and changed collision contexts can change identity; indistinguishable redefinitions, same-line declarations, and comprehension scopes remain unknown"
|
|
1951
|
+
)
|
|
1952
|
+
comparison["dead_findings"] = dead
|
|
1953
|
+
if kind == "fallow":
|
|
1954
|
+
comparison["clone_findings"] = _clone_changes(left_metrics, right_metrics)
|
|
1955
|
+
result[kind] = comparison
|
|
1956
|
+
return result
|