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.
Files changed (48) hide show
  1. proofcheck/__init__.py +9 -0
  2. proofcheck/__main__.py +6 -0
  3. proofcheck/ablation.py +638 -0
  4. proofcheck/agent/__init__.py +59 -0
  5. proofcheck/agent/card.py +138 -0
  6. proofcheck/agent/cli.py +327 -0
  7. proofcheck/agent/config.py +223 -0
  8. proofcheck/agent/llm_tools.py +399 -0
  9. proofcheck/agent/loop.py +149 -0
  10. proofcheck/agent/prompts.py +265 -0
  11. proofcheck/agent/propose.py +516 -0
  12. proofcheck/agent/ruststatic.py +286 -0
  13. proofcheck/agent/schema.py +226 -0
  14. proofcheck/agent/static.py +417 -0
  15. proofcheck/agent/target.py +332 -0
  16. proofcheck/agent/tools.py +421 -0
  17. proofcheck/agent/verify.py +542 -0
  18. proofcheck/benchmark.py +98 -0
  19. proofcheck/card.py +378 -0
  20. proofcheck/carddata.py +166 -0
  21. proofcheck/cargo.py +590 -0
  22. proofcheck/check.py +154 -0
  23. proofcheck/ci.py +65 -0
  24. proofcheck/claims.py +339 -0
  25. proofcheck/cli.py +335 -0
  26. proofcheck/config.py +375 -0
  27. proofcheck/evidence.py +221 -0
  28. proofcheck/failtopass.py +474 -0
  29. proofcheck/git.py +192 -0
  30. proofcheck/github_api.py +299 -0
  31. proofcheck/httpclient.py +44 -0
  32. proofcheck/init_cmd.py +535 -0
  33. proofcheck/junit.py +98 -0
  34. proofcheck/llm.py +185 -0
  35. proofcheck/proc.py +150 -0
  36. proofcheck/projects.py +385 -0
  37. proofcheck/repro.py +248 -0
  38. proofcheck/result.py +404 -0
  39. proofcheck/runners.py +418 -0
  40. proofcheck/rustsrc.py +447 -0
  41. proofcheck/scope.py +137 -0
  42. proofcheck/unittest_junit.py +136 -0
  43. proofcheck_ai-1.0.0.dist-info/METADATA +691 -0
  44. proofcheck_ai-1.0.0.dist-info/RECORD +48 -0
  45. proofcheck_ai-1.0.0.dist-info/WHEEL +5 -0
  46. proofcheck_ai-1.0.0.dist-info/entry_points.txt +2 -0
  47. proofcheck_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m proofcheck``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ )