proofcheck-ai 1.0.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.
- proofcheck/__init__.py +9 -0
- proofcheck/__main__.py +6 -0
- proofcheck/ablation.py +638 -0
- proofcheck/agent/__init__.py +59 -0
- proofcheck/agent/card.py +138 -0
- proofcheck/agent/cli.py +327 -0
- proofcheck/agent/config.py +223 -0
- proofcheck/agent/llm_tools.py +399 -0
- proofcheck/agent/loop.py +149 -0
- proofcheck/agent/prompts.py +265 -0
- proofcheck/agent/propose.py +516 -0
- proofcheck/agent/ruststatic.py +286 -0
- proofcheck/agent/schema.py +226 -0
- proofcheck/agent/static.py +417 -0
- proofcheck/agent/target.py +332 -0
- proofcheck/agent/tools.py +421 -0
- proofcheck/agent/verify.py +542 -0
- proofcheck/benchmark.py +98 -0
- proofcheck/card.py +378 -0
- proofcheck/carddata.py +166 -0
- proofcheck/cargo.py +590 -0
- proofcheck/check.py +154 -0
- proofcheck/ci.py +65 -0
- proofcheck/claims.py +339 -0
- proofcheck/cli.py +335 -0
- proofcheck/config.py +375 -0
- proofcheck/evidence.py +221 -0
- proofcheck/failtopass.py +474 -0
- proofcheck/git.py +192 -0
- proofcheck/github_api.py +299 -0
- proofcheck/httpclient.py +44 -0
- proofcheck/init_cmd.py +535 -0
- proofcheck/junit.py +98 -0
- proofcheck/llm.py +185 -0
- proofcheck/proc.py +150 -0
- proofcheck/projects.py +385 -0
- proofcheck/repro.py +248 -0
- proofcheck/result.py +404 -0
- proofcheck/runners.py +418 -0
- proofcheck/rustsrc.py +447 -0
- proofcheck/scope.py +137 -0
- proofcheck/unittest_junit.py +136 -0
- proofcheck_ai-1.0.0.dist-info/METADATA +691 -0
- proofcheck_ai-1.0.0.dist-info/RECORD +48 -0
- proofcheck_ai-1.0.0.dist-info/WHEEL +5 -0
- proofcheck_ai-1.0.0.dist-info/entry_points.txt +2 -0
- proofcheck_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
- proofcheck_ai-1.0.0.dist-info/top_level.txt +1 -0
proofcheck/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""proofcheck: evidence, not authorship.
|
|
2
|
+
|
|
3
|
+
Check whether a pull request actually does what it claims -- by running its
|
|
4
|
+
tests on the base and head commits, reproducing referenced issues, and
|
|
5
|
+
matching the PR's claims to the evidence -- regardless of who or what wrote it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
__all__ = ["__version__"]
|
proofcheck/__main__.py
ADDED
proofcheck/ablation.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"""Diff evidence map: which changed hunks does the evidence actually depend on?
|
|
2
|
+
|
|
3
|
+
For every hunk of the pull request's non-test changes, proofcheck takes a
|
|
4
|
+
worktree of the head commit, reverse-applies **just that hunk** and re-runs
|
|
5
|
+
only the evidence it already found:
|
|
6
|
+
|
|
7
|
+
* the tests classified ``fail_to_pass`` (they fail on base and pass on head);
|
|
8
|
+
* the issue-reproducer snippets whose behaviour changed between base and head.
|
|
9
|
+
|
|
10
|
+
If any of them now fails (or, for a reproducer, behaves differently from the
|
|
11
|
+
head run), the hunk is **required** by the evidence. If everything still
|
|
12
|
+
passes, the hunk is **not exercised** by any evidence: it changed, but nothing
|
|
13
|
+
that proofcheck measured depends on it. Hunks that cannot be reverted on their
|
|
14
|
+
own, or whose run times out, are **skipped**.
|
|
15
|
+
|
|
16
|
+
This is deterministic and needs no LLM. It is bounded by a hunk cap and a
|
|
17
|
+
total time budget, hunks are processed in a stable order (path, then line),
|
|
18
|
+
and the temporary worktree is always removed, even when a run raises.
|
|
19
|
+
|
|
20
|
+
Notes on the mechanics:
|
|
21
|
+
|
|
22
|
+
* hunks come from ``git diff -U0`` and are reversed with
|
|
23
|
+
``git apply -R --unidiff-zero``;
|
|
24
|
+
* a new file is a single hunk whose reversal deletes the file; a deleted file
|
|
25
|
+
is a single hunk whose reversal recreates it; a pure rename (no content
|
|
26
|
+
change) is a single hunk whose reversal renames the file back; content
|
|
27
|
+
hunks of a renamed file are applied to the new path;
|
|
28
|
+
* binary files, test files and files matching ``ablation.exclude`` are not
|
|
29
|
+
ablated, nor are hunks that lie entirely inside test code of a source file
|
|
30
|
+
(Rust ``#[cfg(test)]`` modules, reported by the runner's ``test_regions``);
|
|
31
|
+
* one worktree is reused for all hunks: after each run the touched paths are
|
|
32
|
+
restored from the head commit and verified clean.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import fnmatch
|
|
38
|
+
import re
|
|
39
|
+
import shutil
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path, PurePosixPath
|
|
44
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
45
|
+
|
|
46
|
+
from . import git as gitmod
|
|
47
|
+
from .junit import PASSED
|
|
48
|
+
from .repro import BEHAVIOR_CHANGED, SnippetRun, compare_runs, extract_python_blocks, run_snippet
|
|
49
|
+
|
|
50
|
+
# Per-hunk statuses
|
|
51
|
+
REQUIRED = "required"
|
|
52
|
+
NOT_EXERCISED = "not_exercised"
|
|
53
|
+
SKIPPED = "skipped"
|
|
54
|
+
|
|
55
|
+
# Overall statuses
|
|
56
|
+
STATUS_RAN = "ran"
|
|
57
|
+
STATUS_NOT_APPLICABLE = "not_applicable" # no fail->pass test or behaviour-changing reproducer
|
|
58
|
+
STATUS_NO_HUNKS = "no_hunks" # nothing to ablate (only tests/docs/binaries changed)
|
|
59
|
+
STATUS_BASELINE_FAILED = "baseline_failed" # the evidence did not hold in the clean ablation worktree
|
|
60
|
+
STATUS_ERROR = "error"
|
|
61
|
+
|
|
62
|
+
HUNK_KINDS = ("modify", "add", "delete", "rename")
|
|
63
|
+
|
|
64
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
65
|
+
|
|
66
|
+
#: Upper bound on evidence items re-run per hunk (keeps one ablation run small).
|
|
67
|
+
MAX_EVIDENCE_TESTS = 50
|
|
68
|
+
MAX_EVIDENCE_SNIPPETS = 5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Hunk:
|
|
73
|
+
"""One independently revertible change."""
|
|
74
|
+
|
|
75
|
+
file: str # path at head (for deletions: the deleted path)
|
|
76
|
+
kind: str # modify | add | delete | rename
|
|
77
|
+
old_start: int
|
|
78
|
+
old_count: int
|
|
79
|
+
new_start: int
|
|
80
|
+
new_count: int
|
|
81
|
+
patch: str # a complete patch containing only this hunk
|
|
82
|
+
old_path: str | None = None
|
|
83
|
+
|
|
84
|
+
def added_lines(self) -> list[str]:
|
|
85
|
+
"""The head-side lines of this hunk (what reverting removes)."""
|
|
86
|
+
body = self.patch.split("\n@@", 1)[-1].split("\n")[1:]
|
|
87
|
+
return [line[1:] for line in body if line.startswith("+")]
|
|
88
|
+
|
|
89
|
+
def paths(self) -> list[str]:
|
|
90
|
+
out = [self.file]
|
|
91
|
+
if self.old_path and self.old_path != self.file:
|
|
92
|
+
out.append(self.old_path)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def location(self) -> str:
|
|
96
|
+
if self.kind == "add":
|
|
97
|
+
return f"{self.file} (new file)"
|
|
98
|
+
if self.kind == "delete":
|
|
99
|
+
return f"{self.file} (deleted)"
|
|
100
|
+
if self.kind == "rename":
|
|
101
|
+
return f"{self.old_path} → {self.file} (rename)"
|
|
102
|
+
if self.new_count == 0:
|
|
103
|
+
return f"{self.file}:{self.new_start} (−{self.old_count})"
|
|
104
|
+
end = self.new_start + self.new_count - 1
|
|
105
|
+
return f"{self.file}:{self.new_start}" + (f"-{end}" if end != self.new_start else "")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class HunkResult:
|
|
110
|
+
hunk: Hunk
|
|
111
|
+
status: str
|
|
112
|
+
broken_by: list[str] = field(default_factory=list)
|
|
113
|
+
reason: str = ""
|
|
114
|
+
duration: float = 0.0
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> dict[str, Any]:
|
|
117
|
+
h = self.hunk
|
|
118
|
+
return {
|
|
119
|
+
"file": h.file,
|
|
120
|
+
"old_path": h.old_path,
|
|
121
|
+
"kind": h.kind,
|
|
122
|
+
"location": h.location(),
|
|
123
|
+
"start": h.new_start,
|
|
124
|
+
"end": h.new_start + max(h.new_count, 1) - 1,
|
|
125
|
+
"added": h.new_count,
|
|
126
|
+
"removed": h.old_count,
|
|
127
|
+
"status": self.status,
|
|
128
|
+
"broken_by": list(self.broken_by[:10]),
|
|
129
|
+
"reason": self.reason,
|
|
130
|
+
"duration": round(self.duration, 3),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Ablation:
|
|
136
|
+
"""Result of the diff evidence map."""
|
|
137
|
+
|
|
138
|
+
status: str
|
|
139
|
+
evidence: list[str] = field(default_factory=list)
|
|
140
|
+
hunks: list[HunkResult] = field(default_factory=list)
|
|
141
|
+
total_hunks: int = 0
|
|
142
|
+
untested_over_cap: int = 0
|
|
143
|
+
budget_s: float = 0.0
|
|
144
|
+
elapsed_s: float = 0.0
|
|
145
|
+
budget_exhausted: bool = False
|
|
146
|
+
notes: list[str] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
def count(self, status: str) -> int:
|
|
149
|
+
return sum(1 for h in self.hunks if h.status == status)
|
|
150
|
+
|
|
151
|
+
def unexercised_paths(self) -> list[str]:
|
|
152
|
+
seen: list[str] = []
|
|
153
|
+
for h in self.hunks:
|
|
154
|
+
if h.status == NOT_EXERCISED and h.hunk.file not in seen:
|
|
155
|
+
seen.append(h.hunk.file)
|
|
156
|
+
return seen
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"status": self.status,
|
|
161
|
+
"evidence": list(self.evidence[:MAX_EVIDENCE_TESTS + MAX_EVIDENCE_SNIPPETS]),
|
|
162
|
+
"hunks": [h.to_dict() for h in self.hunks],
|
|
163
|
+
"total_hunks": self.total_hunks,
|
|
164
|
+
"tested": len(self.hunks),
|
|
165
|
+
"required": self.count(REQUIRED),
|
|
166
|
+
"not_exercised": self.count(NOT_EXERCISED),
|
|
167
|
+
"skipped": self.count(SKIPPED),
|
|
168
|
+
"untested_over_cap": self.untested_over_cap,
|
|
169
|
+
"budget_s": float(self.budget_s),
|
|
170
|
+
"elapsed_s": round(self.elapsed_s, 3),
|
|
171
|
+
"budget_exhausted": self.budget_exhausted,
|
|
172
|
+
"notes": list(self.notes[:10]),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- hunks
|
|
177
|
+
|
|
178
|
+
def _matches(path: str, patterns: Sequence[str]) -> bool:
|
|
179
|
+
name = PurePosixPath(path).name
|
|
180
|
+
return any(fnmatch.fnmatchcase(path, p) or fnmatch.fnmatchcase(name, p) for p in patterns)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def split_file_diff(diff: str) -> tuple[list[str], list[tuple[tuple[int, int, int, int], list[str]]]]:
|
|
184
|
+
"""Split one file's ``-U0`` diff into header lines and ``(numbers, hunk_lines)``."""
|
|
185
|
+
header: list[str] = []
|
|
186
|
+
hunks: list[tuple[tuple[int, int, int, int], list[str]]] = []
|
|
187
|
+
current: list[str] | None = None
|
|
188
|
+
lines = diff.split("\n") # not splitlines(): keep "\r" and form feeds inside lines
|
|
189
|
+
if lines and lines[-1] == "":
|
|
190
|
+
lines.pop()
|
|
191
|
+
for line in lines:
|
|
192
|
+
m = _HUNK_HEADER_RE.match(line)
|
|
193
|
+
if m:
|
|
194
|
+
nums = (int(m.group(1)), int(m.group(2)) if m.group(2) is not None else 1,
|
|
195
|
+
int(m.group(3)), int(m.group(4)) if m.group(4) is not None else 1)
|
|
196
|
+
current = [line]
|
|
197
|
+
hunks.append((nums, current))
|
|
198
|
+
elif current is None:
|
|
199
|
+
header.append(line)
|
|
200
|
+
else:
|
|
201
|
+
current.append(line)
|
|
202
|
+
return header, hunks
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _quote(path: str) -> str:
|
|
206
|
+
"""Path as written in a patch header (git's quoting for unusual names)."""
|
|
207
|
+
if re.search(r'[\s"\\\x00-\x1f\x7f]', path):
|
|
208
|
+
esc = path.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t")
|
|
209
|
+
return f'"{esc}"'
|
|
210
|
+
return path
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _plain_header(path: str) -> str:
|
|
214
|
+
a, b = _quote("a/" + path), _quote("b/" + path)
|
|
215
|
+
return f"diff --git {a} {b}\n--- {a}\n+++ {b}\n"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _in_regions(hunk: Hunk, regions: Sequence[tuple[int, int]]) -> bool:
|
|
219
|
+
"""True if every head line of a modify hunk (or the gap of a pure deletion) lies inside one region."""
|
|
220
|
+
if hunk.kind != "modify":
|
|
221
|
+
return False
|
|
222
|
+
first = hunk.new_start
|
|
223
|
+
last = hunk.new_start + hunk.new_count - 1 if hunk.new_count else hunk.new_start + 1
|
|
224
|
+
return any(lo <= first and last <= hi for lo, hi in regions)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def list_hunks(
|
|
228
|
+
repo: str | Path,
|
|
229
|
+
base: str,
|
|
230
|
+
head: str,
|
|
231
|
+
changed: Sequence[gitmod.ChangedFile],
|
|
232
|
+
is_test_file: Callable[[str], bool],
|
|
233
|
+
exclude: Sequence[str] = (),
|
|
234
|
+
test_regions: Callable[[str, str], Sequence[tuple[int, int]] | None] | None = None,
|
|
235
|
+
skipped_test_hunks: list[str] | None = None,
|
|
236
|
+
) -> list[Hunk]:
|
|
237
|
+
"""All ablatable hunks of the non-test, non-binary changed files, in a stable order.
|
|
238
|
+
|
|
239
|
+
``test_regions(path, head_source)`` returns line ranges of test code inside
|
|
240
|
+
a source file; hunks entirely inside them are left out (their locations
|
|
241
|
+
are appended to ``skipped_test_hunks``).
|
|
242
|
+
"""
|
|
243
|
+
out: list[Hunk] = []
|
|
244
|
+
for f in sorted(changed, key=lambda c: c.path):
|
|
245
|
+
if f.binary or is_test_file(f.path) or (f.old_path and is_test_file(f.old_path)):
|
|
246
|
+
continue
|
|
247
|
+
if _matches(f.path, exclude) or (f.old_path and _matches(f.old_path, exclude)):
|
|
248
|
+
continue
|
|
249
|
+
if f.status not in ("A", "M", "D", "R"):
|
|
250
|
+
continue # copies, type changes: not reverted
|
|
251
|
+
paths = [f.old_path, f.path] if f.status == "R" and f.old_path else [f.path]
|
|
252
|
+
raw = gitmod.git(repo, "diff", "-U0", "--no-color", "--no-ext-diff", "--no-textconv", "-M",
|
|
253
|
+
"--src-prefix=a/", "--dst-prefix=b/", base, head, "--", *paths, binary=True)
|
|
254
|
+
diff = bytes(raw).decode("utf-8", errors="surrogateescape") # round-trips non-UTF-8 bytes into the patch
|
|
255
|
+
if not diff.strip() or "\nGIT binary patch" in diff or "\nBinary files " in diff:
|
|
256
|
+
continue
|
|
257
|
+
header, hunks = split_file_diff(diff)
|
|
258
|
+
head_text = "\n".join(header) + "\n"
|
|
259
|
+
if f.status == "R":
|
|
260
|
+
if not hunks:
|
|
261
|
+
out.append(Hunk(f.path, "rename", 0, 0, 0, 0, head_text, old_path=f.old_path))
|
|
262
|
+
continue
|
|
263
|
+
prefix = _plain_header(f.path)
|
|
264
|
+
for nums, lines in hunks:
|
|
265
|
+
out.append(Hunk(f.path, "modify", *nums, patch=prefix + "\n".join(lines) + "\n", old_path=f.old_path))
|
|
266
|
+
continue
|
|
267
|
+
if f.status in ("A", "D"):
|
|
268
|
+
if len(hunks) != 1:
|
|
269
|
+
continue # empty new/deleted file: nothing meaningful to revert
|
|
270
|
+
nums, lines = hunks[0]
|
|
271
|
+
out.append(Hunk(f.path, "add" if f.status == "A" else "delete", *nums, patch=head_text + "\n".join(lines) + "\n"))
|
|
272
|
+
continue
|
|
273
|
+
prefix = _plain_header(f.path)
|
|
274
|
+
regions: Sequence[tuple[int, int]] | None = None
|
|
275
|
+
if test_regions is not None:
|
|
276
|
+
data = gitmod.show_file(repo, head, f.path)
|
|
277
|
+
regions = test_regions(f.path, data.decode("utf-8", "replace")) if data is not None else None
|
|
278
|
+
for nums, lines in hunks:
|
|
279
|
+
h = Hunk(f.path, "modify", *nums, patch=prefix + "\n".join(lines) + "\n")
|
|
280
|
+
if regions and _in_regions(h, regions):
|
|
281
|
+
if skipped_test_hunks is not None:
|
|
282
|
+
skipped_test_hunks.append(h.location())
|
|
283
|
+
continue
|
|
284
|
+
out.append(h)
|
|
285
|
+
out.sort(key=lambda h: (h.file, h.new_start, h.old_start))
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------- evidence
|
|
290
|
+
|
|
291
|
+
@dataclass
|
|
292
|
+
class SnippetEvidence:
|
|
293
|
+
"""A behaviour-changing issue reproducer and its head-run result."""
|
|
294
|
+
|
|
295
|
+
label: str
|
|
296
|
+
code: str
|
|
297
|
+
head: SnippetRun
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def name(self) -> str:
|
|
301
|
+
return self.label
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def collect_evidence(tests: Any, repros: Sequence[Any], issues: Mapping[str, str]) -> tuple[list[str], list[str], list[SnippetEvidence]]:
|
|
305
|
+
"""Return ``(fail_to_pass test ids, their modules, behaviour-changing snippets)``.
|
|
306
|
+
|
|
307
|
+
``tests`` is a :class:`~proofcheck.failtopass.TestCheck` (or None) and
|
|
308
|
+
``repros`` a list of :class:`~proofcheck.repro.IssueRepro`.
|
|
309
|
+
"""
|
|
310
|
+
ids: list[str] = []
|
|
311
|
+
modules: list[str] = []
|
|
312
|
+
if tests is not None:
|
|
313
|
+
for t in tests.tests:
|
|
314
|
+
if t.touched and t.classification == "fail_to_pass" and "::" in t.test_id:
|
|
315
|
+
ids.append(t.test_id)
|
|
316
|
+
if t.file and t.file not in modules:
|
|
317
|
+
modules.append(t.file)
|
|
318
|
+
ids = sorted(set(ids))[:MAX_EVIDENCE_TESTS]
|
|
319
|
+
snippets: list[SnippetEvidence] = []
|
|
320
|
+
for r in repros:
|
|
321
|
+
body = issues.get(r.issue)
|
|
322
|
+
if body is None:
|
|
323
|
+
continue
|
|
324
|
+
blocks = extract_python_blocks(body)
|
|
325
|
+
for s in r.snippets:
|
|
326
|
+
if s.status != BEHAVIOR_CHANGED or s.head.timed_out or not 1 <= s.index <= len(blocks):
|
|
327
|
+
continue
|
|
328
|
+
snippets.append(SnippetEvidence(f"{r.issue} snippet {s.index}", blocks[s.index - 1], s.head))
|
|
329
|
+
return ids, sorted(modules), snippets[:MAX_EVIDENCE_SNIPPETS]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# --------------------------------------------------------------------------- running
|
|
333
|
+
|
|
334
|
+
class _Budget:
|
|
335
|
+
def __init__(self, seconds: float, clock: Callable[[], float]) -> None:
|
|
336
|
+
self.clock = clock
|
|
337
|
+
self.start = clock()
|
|
338
|
+
self.seconds = seconds
|
|
339
|
+
|
|
340
|
+
def remaining(self) -> float:
|
|
341
|
+
return self.seconds - (self.clock() - self.start)
|
|
342
|
+
|
|
343
|
+
def elapsed(self) -> float:
|
|
344
|
+
return self.clock() - self.start
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _restore(wt: Path, hunk: Hunk) -> None:
|
|
348
|
+
"""Put the paths touched by ``hunk`` back to their head state; raise if that fails."""
|
|
349
|
+
for p in hunk.paths():
|
|
350
|
+
exists_at_head = str(gitmod.git(wt, "ls-tree", "--name-only", "HEAD", "--", p)).strip() != ""
|
|
351
|
+
target = wt / p
|
|
352
|
+
if exists_at_head:
|
|
353
|
+
gitmod.git(wt, "checkout", "--force", "HEAD", "--", p)
|
|
354
|
+
elif target.is_file() or target.is_symlink():
|
|
355
|
+
target.unlink()
|
|
356
|
+
status = str(gitmod.git(wt, "status", "--porcelain", "--untracked-files=all", "--", *hunk.paths())).strip()
|
|
357
|
+
if status:
|
|
358
|
+
raise gitmod.GitError(f"could not restore {', '.join(hunk.paths())} after ablation: {status[:200]}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _at_exact_position(wt: Path, hunk: Hunk) -> bool:
|
|
362
|
+
"""True if the head file has this hunk's lines exactly where the hunk says.
|
|
363
|
+
|
|
364
|
+
``git apply`` searches for a matching location at an offset when the
|
|
365
|
+
expected position does not match. With ``--unidiff-zero`` (no context
|
|
366
|
+
lines) that could revert identical text somewhere else in the file, so the
|
|
367
|
+
exact position is checked first.
|
|
368
|
+
"""
|
|
369
|
+
if hunk.kind != "modify" or hunk.new_count == 0:
|
|
370
|
+
return True
|
|
371
|
+
target = wt / hunk.file
|
|
372
|
+
if not target.is_file():
|
|
373
|
+
return False
|
|
374
|
+
text = target.read_bytes().decode("utf-8", errors="surrogateescape")
|
|
375
|
+
lines = text.split("\n")
|
|
376
|
+
start = hunk.new_start - 1
|
|
377
|
+
return lines[start:start + hunk.new_count] == hunk.added_lines()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _reverse_apply(wt: Path, hunk: Hunk, tmp: Path) -> str | None:
|
|
381
|
+
"""Reverse-apply one hunk in ``wt``. Returns an error message or None."""
|
|
382
|
+
if not _at_exact_position(wt, hunk):
|
|
383
|
+
return f"could not reverse-apply this hunk on its own (head file differs at line {hunk.new_start})"
|
|
384
|
+
patch = tmp / "hunk.patch"
|
|
385
|
+
patch.write_bytes(hunk.patch.encode("utf-8", errors="surrogateescape"))
|
|
386
|
+
try:
|
|
387
|
+
gitmod.git(wt, "apply", "-R", "--unidiff-zero", "--whitespace=nowarn", str(patch))
|
|
388
|
+
except gitmod.GitError as exc:
|
|
389
|
+
return f"could not reverse-apply this hunk on its own ({str(exc).splitlines()[0][:160]})"
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _evaluate(
|
|
394
|
+
runner: Any,
|
|
395
|
+
wt: Path,
|
|
396
|
+
test_ids: Sequence[str],
|
|
397
|
+
modules: Sequence[str],
|
|
398
|
+
snippets: Sequence[SnippetEvidence],
|
|
399
|
+
env: Mapping[str, str],
|
|
400
|
+
environ: Mapping[str, str] | None,
|
|
401
|
+
python: str,
|
|
402
|
+
test_timeout: float,
|
|
403
|
+
repro_timeout: float,
|
|
404
|
+
budget: _Budget,
|
|
405
|
+
) -> tuple[list[str], str | None]:
|
|
406
|
+
"""Run the evidence in ``wt``. Returns ``(broken evidence names, skip reason)``."""
|
|
407
|
+
broken: list[str] = []
|
|
408
|
+
if test_ids:
|
|
409
|
+
left = budget.remaining()
|
|
410
|
+
if left <= 1:
|
|
411
|
+
return [], "time budget exhausted"
|
|
412
|
+
run_selected = getattr(runner, "run_selected", None)
|
|
413
|
+
if run_selected is not None:
|
|
414
|
+
run = run_selected(wt, list(modules), list(test_ids), env, min(test_timeout, left))
|
|
415
|
+
else:
|
|
416
|
+
run = runner.run(wt, list(modules), env, min(test_timeout, left))
|
|
417
|
+
if run.timed_out:
|
|
418
|
+
return [], "test run timed out"
|
|
419
|
+
if not run.ok and not run.outcomes:
|
|
420
|
+
# No report at all (e.g. a module that no longer imports and a runner
|
|
421
|
+
# that stops) -- every test counts as broken.
|
|
422
|
+
broken.extend(test_ids)
|
|
423
|
+
else:
|
|
424
|
+
for tid in test_ids:
|
|
425
|
+
o = run.outcomes.get(tid)
|
|
426
|
+
if o is None or o.outcome != PASSED:
|
|
427
|
+
broken.append(tid)
|
|
428
|
+
for s in snippets:
|
|
429
|
+
left = budget.remaining()
|
|
430
|
+
if left <= 1:
|
|
431
|
+
return broken, None if broken else "time budget exhausted"
|
|
432
|
+
r = run_snippet(s.code, wt, python, min(repro_timeout, left), environ)
|
|
433
|
+
if r.timed_out and not s.head.timed_out:
|
|
434
|
+
if not broken:
|
|
435
|
+
return [], "reproducer timed out"
|
|
436
|
+
continue
|
|
437
|
+
if compare_runs(s.head, r) == BEHAVIOR_CHANGED:
|
|
438
|
+
broken.append(s.name)
|
|
439
|
+
return broken, None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
#: Extensions treated as source code when choosing which hunks to test first.
|
|
443
|
+
CODE_SUFFIXES = frozenset({
|
|
444
|
+
".py", ".pyx", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".go", ".rs", ".java", ".kt", ".rb",
|
|
445
|
+
".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".swift", ".scala", ".php",
|
|
446
|
+
})
|
|
447
|
+
_NON_SOURCE_DIRS = ("examples/", "example/", "docs/", "doc/", "scripts/", "benchmarks/", ".github/")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _module_names(path: str) -> list[str]:
|
|
451
|
+
"""Names a test could use to refer to ``path`` (dotted module, file stem)."""
|
|
452
|
+
p = PurePosixPath(path)
|
|
453
|
+
parts = list(p.with_suffix("").parts)
|
|
454
|
+
if parts and parts[0] in ("src", "lib", "python"):
|
|
455
|
+
parts = parts[1:]
|
|
456
|
+
if parts and parts[-1] in ("__init__", "index", "mod", "lib", "main"):
|
|
457
|
+
parts = parts[:-1]
|
|
458
|
+
names = []
|
|
459
|
+
if len(parts) > 1:
|
|
460
|
+
names.append(".".join(parts))
|
|
461
|
+
names.append("/".join(parts))
|
|
462
|
+
if parts and len(parts[-1]) >= 3:
|
|
463
|
+
names.append(parts[-1])
|
|
464
|
+
return names
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _hunk_rank(hunk: Hunk, evidence_text: str) -> int:
|
|
468
|
+
"""0: source the evidence refers to; 1: other source; 2: everything else (config, examples, data)."""
|
|
469
|
+
path = hunk.file
|
|
470
|
+
if PurePosixPath(path).suffix.lower() not in CODE_SUFFIXES or path.startswith(_NON_SOURCE_DIRS):
|
|
471
|
+
return 2
|
|
472
|
+
for name in _module_names(path):
|
|
473
|
+
if re.search(rf"(?<![\w.]){re.escape(name)}(?![\w])", evidence_text):
|
|
474
|
+
return 0
|
|
475
|
+
return 1
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def prioritize_hunks(hunks: Sequence[Hunk], evidence_texts: Sequence[str]) -> list[Hunk]:
|
|
479
|
+
"""Order hunks so the cap spends its budget on code the evidence can depend on.
|
|
480
|
+
|
|
481
|
+
Source files that the fail→pass tests (or reproducers) mention come first,
|
|
482
|
+
then other source files, then configuration, examples and data. The order
|
|
483
|
+
is stable and deterministic within each group.
|
|
484
|
+
"""
|
|
485
|
+
text = "\n".join(evidence_texts)
|
|
486
|
+
return sorted(hunks, key=lambda h: (_hunk_rank(h, text), h.file, h.new_start, h.old_start))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _evidence_texts(repo: str | Path, head: str, test_files: Sequence[str]) -> list[str]:
|
|
490
|
+
out = []
|
|
491
|
+
for path in test_files[:50]:
|
|
492
|
+
try:
|
|
493
|
+
data = gitmod.show_file(repo, head, path)
|
|
494
|
+
except gitmod.GitError:
|
|
495
|
+
data = None
|
|
496
|
+
if data:
|
|
497
|
+
out.append(data[:200_000].decode("utf-8", "replace"))
|
|
498
|
+
return out
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def run_ablation(
|
|
502
|
+
repo: str | Path,
|
|
503
|
+
base: str,
|
|
504
|
+
head: str,
|
|
505
|
+
changed: Sequence[gitmod.ChangedFile],
|
|
506
|
+
runner: Any,
|
|
507
|
+
tests: Any,
|
|
508
|
+
repros: Sequence[Any],
|
|
509
|
+
issues: Mapping[str, str],
|
|
510
|
+
env: Mapping[str, str],
|
|
511
|
+
*,
|
|
512
|
+
environ: Mapping[str, str] | None = None,
|
|
513
|
+
max_hunks: int = 12,
|
|
514
|
+
time_budget_s: float = 300,
|
|
515
|
+
test_timeout: float = 600,
|
|
516
|
+
repro_timeout: float = 60,
|
|
517
|
+
python: str = "python3",
|
|
518
|
+
exclude: Sequence[str] = (),
|
|
519
|
+
setup: Callable[[Path], str | None] | None = None,
|
|
520
|
+
clock: Callable[[], float] = time.monotonic,
|
|
521
|
+
) -> Ablation:
|
|
522
|
+
"""Build the diff evidence map. Never raises for run failures; returns status ``error`` instead."""
|
|
523
|
+
budget = _Budget(float(time_budget_s), clock)
|
|
524
|
+
test_ids, modules, snippets = collect_evidence(tests, repros, issues)
|
|
525
|
+
evidence = list(test_ids) + [s.name for s in snippets]
|
|
526
|
+
result = Ablation(status=STATUS_NOT_APPLICABLE, evidence=evidence, budget_s=float(time_budget_s))
|
|
527
|
+
if not evidence:
|
|
528
|
+
result.notes.append("No fail→pass test or behaviour-changing issue reproducer to ablate against.")
|
|
529
|
+
return result
|
|
530
|
+
in_tests: list[str] = []
|
|
531
|
+
try:
|
|
532
|
+
hunks = list_hunks(repo, base, head, changed, runner.is_test_file, exclude,
|
|
533
|
+
test_regions=getattr(runner, "test_regions", None), skipped_test_hunks=in_tests)
|
|
534
|
+
except gitmod.GitError as exc:
|
|
535
|
+
result.status = STATUS_ERROR
|
|
536
|
+
result.notes.append(f"could not list hunks: {exc}")
|
|
537
|
+
return result
|
|
538
|
+
result.total_hunks = len(hunks)
|
|
539
|
+
if in_tests:
|
|
540
|
+
result.notes.append(f"{len(in_tests)} hunk(s) inside inline test modules are test code and were not ablated.")
|
|
541
|
+
if not hunks:
|
|
542
|
+
result.status = STATUS_NO_HUNKS
|
|
543
|
+
result.notes.append("No non-test text changes to ablate.")
|
|
544
|
+
return result
|
|
545
|
+
hunks = prioritize_hunks(hunks, _evidence_texts(repo, head, modules) + [s.code for s in snippets])
|
|
546
|
+
cap = max(0, int(max_hunks))
|
|
547
|
+
selected, result.untested_over_cap = hunks[:cap], max(0, len(hunks) - cap)
|
|
548
|
+
if result.untested_over_cap:
|
|
549
|
+
result.notes.append(f"{result.untested_over_cap} more hunk(s) not tested (ablation.max_hunks = {cap}).")
|
|
550
|
+
if not selected:
|
|
551
|
+
result.status = STATUS_RAN
|
|
552
|
+
result.elapsed_s = budget.elapsed()
|
|
553
|
+
return result
|
|
554
|
+
|
|
555
|
+
tmp = Path(tempfile.mkdtemp(prefix="proofcheck-ablation-"))
|
|
556
|
+
try:
|
|
557
|
+
with gitmod.worktree(repo, head, prefix="proofcheck-ablation-") as wt:
|
|
558
|
+
if setup is not None:
|
|
559
|
+
err = setup(wt)
|
|
560
|
+
if err:
|
|
561
|
+
result.notes.append(err)
|
|
562
|
+
# Baseline: the evidence must hold in an unmodified head worktree,
|
|
563
|
+
# otherwise flaky or environment-dependent evidence would make every
|
|
564
|
+
# hunk look "required".
|
|
565
|
+
broken, why = _evaluate(runner, wt, test_ids, modules, snippets, env, environ, python,
|
|
566
|
+
test_timeout, repro_timeout, budget)
|
|
567
|
+
if why or broken:
|
|
568
|
+
result.status = STATUS_BASELINE_FAILED
|
|
569
|
+
result.notes.append("The evidence did not reproduce in a clean head worktree "
|
|
570
|
+
f"({why or 'failing: ' + ', '.join(broken[:3])}); ablation results would not be meaningful.")
|
|
571
|
+
return result
|
|
572
|
+
result.status = STATUS_RAN
|
|
573
|
+
for hunk in selected:
|
|
574
|
+
if budget.remaining() <= 1:
|
|
575
|
+
result.budget_exhausted = True
|
|
576
|
+
result.hunks.append(HunkResult(hunk, SKIPPED, reason="time budget exhausted"))
|
|
577
|
+
continue
|
|
578
|
+
started = clock()
|
|
579
|
+
err = _reverse_apply(wt, hunk, tmp)
|
|
580
|
+
if err:
|
|
581
|
+
_restore(wt, hunk)
|
|
582
|
+
result.hunks.append(HunkResult(hunk, SKIPPED, reason=err, duration=clock() - started))
|
|
583
|
+
continue
|
|
584
|
+
try:
|
|
585
|
+
broken, why = _evaluate(runner, wt, test_ids, modules, snippets, env, environ, python,
|
|
586
|
+
test_timeout, repro_timeout, budget)
|
|
587
|
+
finally:
|
|
588
|
+
_restore(wt, hunk)
|
|
589
|
+
took = clock() - started
|
|
590
|
+
if why:
|
|
591
|
+
if why == "time budget exhausted":
|
|
592
|
+
result.budget_exhausted = True
|
|
593
|
+
result.hunks.append(HunkResult(hunk, SKIPPED, reason=why, duration=took))
|
|
594
|
+
elif broken:
|
|
595
|
+
result.hunks.append(HunkResult(hunk, REQUIRED, broken_by=broken, duration=took))
|
|
596
|
+
else:
|
|
597
|
+
result.hunks.append(HunkResult(hunk, NOT_EXERCISED, duration=took))
|
|
598
|
+
if result.budget_exhausted:
|
|
599
|
+
result.notes.append(f"Time budget of {time_budget_s:g}s exhausted; remaining hunks were skipped.")
|
|
600
|
+
except Exception as exc: # noqa: BLE001 - the map is optional; never lose the main result over it
|
|
601
|
+
result.status = STATUS_ERROR
|
|
602
|
+
result.notes.append(f"ablation aborted: {type(exc).__name__}: {str(exc)[:300]}")
|
|
603
|
+
finally:
|
|
604
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
605
|
+
result.elapsed_s = budget.elapsed()
|
|
606
|
+
return result
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def summary_text(ablation: Mapping[str, Any]) -> str:
|
|
610
|
+
"""One-line summary of a (validated) ``ablation`` result section, or ''."""
|
|
611
|
+
if not ablation or ablation.get("status") != STATUS_RAN:
|
|
612
|
+
return ""
|
|
613
|
+
tested = int(ablation.get("tested", 0))
|
|
614
|
+
req = int(ablation.get("required", 0))
|
|
615
|
+
if not tested:
|
|
616
|
+
return ""
|
|
617
|
+
noun, verb = ("hunk", "is") if tested == 1 else ("hunks", "are")
|
|
618
|
+
untested = int(ablation.get("untested_over_cap", 0) or 0)
|
|
619
|
+
if untested:
|
|
620
|
+
total = int(ablation.get("total_hunks", tested + untested) or tested + untested)
|
|
621
|
+
return f"{req}/{tested} tested {noun} {verb} required by the evidence ({untested} of {total} changed hunks not tested)"
|
|
622
|
+
return f"{req}/{tested} changed {noun} {verb} required by the evidence"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def ablation_scope_flag(ablation: Ablation) -> Any:
|
|
626
|
+
"""A scope note listing files whose changed hunks no evidence depends on (or None)."""
|
|
627
|
+
from .scope import ScopeFlag
|
|
628
|
+
|
|
629
|
+
paths = ablation.unexercised_paths()
|
|
630
|
+
if ablation.status != STATUS_RAN or not paths:
|
|
631
|
+
return None
|
|
632
|
+
n = ablation.count(NOT_EXERCISED)
|
|
633
|
+
return ScopeFlag(
|
|
634
|
+
"unexercised",
|
|
635
|
+
f"Changed, but no evidence depends on it: reverting {'this hunk' if n == 1 else f'each of these {n} hunks'} "
|
|
636
|
+
"leaves every fail→pass test and changed reproducer passing. Check it belongs in this PR.",
|
|
637
|
+
paths,
|
|
638
|
+
)
|