bite-check 0.4.1__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.
recent.py ADDED
@@ -0,0 +1,1060 @@
1
+ """
2
+ Bite: did your coding agent's recent "done" commits really do what they said?
3
+
4
+ bite check the last 40 commits (step 2 needs an API key)
5
+ bite --free step 1 only: no key, no cost
6
+ bite --last 80 --suspects 3 --budget 1.00
7
+ bite --serious only fix claims about data, money, logins, security, crashes or leaks
8
+ bite --any check commits even when none of them claims a fix
9
+ bite --free-checks 10 how many commits step 1 checks (default 6)
10
+ bite --again test claims again even if an earlier run already tested them
11
+ bite --all same as plain bite (kept so older commands still work)
12
+ bite --roast the same results, worded a little more gently
13
+ bite --since main only commits that main doesn't have (the pull request check uses this)
14
+ (from a clone without installing: python recent.py, python recent.py --free, ...)
15
+
16
+ Step 1 (free): for each commit that changed code, the change is taken back out and the tests are run. If they
17
+ still pass, no test shows the change does anything ("unguarded"). If the commit added or changed tests and
18
+ those still pass without its code change, they test nothing it did ("TEST GUARDS NOTHING"). Commits that
19
+ claim a fix come first.
20
+ Step 2 (API key, spending capped by --budget): for the top suspects, a fresh worker turns the commit's own claim into one
21
+ small test (with the odd inputs the claim covers) on a copy of the project at that commit. Bite then runs the
22
+ test itself, twice, at that commit and on today's code, and a second fresh check must agree that the test asks
23
+ only for what the commit claimed and fails for a real reason. Only then is it shown as a false "done".
24
+
25
+ Results are written to bite_proof/ in the repo: report.html (one page to open in a browser), and each
26
+ failing test plus the command that runs it.
27
+ Settings: .bite.ini in the repo (optional; first line [kit], then test_command = ...). claim_command runs one
28
+ test file ({file}); without it Bite adds the file to test_command (for dotnet test it adds a --filter instead).
29
+ test_covers = the paths the test command can reach (globs, comma-separated, e.g. Assets/Sim/*, tests/*): commits
30
+ that touch none of them are listed as outside the tests' reach, not checked. Code in a language the test command
31
+ doesn't run (TypeScript under pytest) is listed as code the tests can't run, not checked. free_test_command = a
32
+ faster subset of the tests for step 1 (step 2 still uses test_command).
33
+
34
+ Step 2 runs on Anthropic's Agent SDK, so it needs an Anthropic API key and never uses a Claude login or plan:
35
+ Anthropic's rules for tools built on the Agent SDK (https://code.claude.com/docs/en/agent-sdk/overview) say they
36
+ must use API keys. The commits it checks can come from any coding AI or person.
37
+ """
38
+
39
+ import ast
40
+ import asyncio
41
+ import io
42
+ import json
43
+ import os
44
+ import re
45
+ import shlex
46
+ import shutil
47
+ import subprocess
48
+ import sys
49
+ import tarfile
50
+ import tempfile
51
+ import time
52
+ from pathlib import Path
53
+
54
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
55
+ import bite # noqa: E402
56
+ import repo # noqa: E402
57
+
58
+ CLAIM = re.compile(r"\b(fix(e[sd])?|handle[sd]?|prevent[sd]?|correct(s|ed)?|resolve[sd]?|guard|avoid[sd]?|"
59
+ r"support[sd]?|make[sd]? .* work|no longer|now (works|handles|returns|accepts|respects))\b", re.I)
60
+ NOT_CODE = re.compile(r"\b(docs?|readme|typo|changelog|bump|release|version|lint|format(ting)?|ci|workflow)\b", re.I)
61
+ # Claims about these come first: a miss there loses data or money, locks people out, or crashes.
62
+ MATTERS = re.compile(r"\b(sav(e|es|ed|ing)|writ(e|es|ing)|delet\w*|remov\w*|overwrit\w*|data|loss|lost|corrupt\w*|"
63
+ r"migrat\w*|money|price|pric\w*|payment|billing|invoice|amount|balance|auth\w*|login|password|"
64
+ r"token|permission\w*|secur\w*|crash\w*|hang\w*|leak\w*|race|deadlock)\b", re.I)
65
+ SPENT = [0.0]
66
+ # A change built for another OS can't be checked here: its code path never runs, so every test passes without it.
67
+ PLATFORM_WORDS = {"win": r"windows|win32|powershell|ntfs|max_path|userprofile|\.ps1\b|cmd\.exe",
68
+ "mac": r"macos|mac os|darwin|apfs|osx", "linux": r"linux"}
69
+ HERE = "win" if sys.platform.startswith("win") else "mac" if sys.platform == "darwin" else "linux"
70
+ # Tidy-up commits: nothing in them is meant to change behaviour, so "nothing fails without it" isn't news.
71
+ TIDY = re.compile(r"^(chore|style|ci|refactor|build|docs?|tests?)(\([^)]*\))?!?:|^(refactor\w*|renam\w*|clean(s|ed)? ?up|docs?|"
72
+ r"document\w*)\b|\b(lint|mypy|pyright|tidy|tidies|sort(ed)? imports|"
73
+ r"reformat\w*|formatting|whitespace|typos?)\b", re.I)
74
+ # A change for another Python version can't be checked on this one ("py3.8 fix", "for Python 3.14"); "3.12+" covers later ones.
75
+ PY_VERSION = re.compile(r"\b(?:py(?:thon)?\s?|for |drop(?:s|ped|ping)? |on )3\.(\d{1,2})\b(\+| and (?:later|newer|up))?")
76
+ # Speed-ups: tests check results, not speed, so the slower old code passing them is expected.
77
+ SPEED = re.compile(r"^perf(\([^)]*\))?!?:|\b(faster|speed(s|ing)?[ -]?up|lazy|lazily|optimi[sz]\w*|performance)\b", re.I)
78
+ # dotnet test exits 0 when its --filter matches no test: that's a test that never ran, not a claim that holds.
79
+ RAN_NOTHING = re.compile(r"No test matches the given testcase filter|No test is available in")
80
+ DOTNET_FILTER = '--filter "FullyQualifiedName~BiteClaim"'
81
+ ROAST = [False] # --roast: the same results, worded a little more gently
82
+ # Earned when every change Bite checked had a test that noticed it; a repo can show it in its README.
83
+ BADGE = "[![Bite-proof ✅](https://img.shields.io/badge/Bite--proof-%E2%9C%85-black)](https://github.com/connabyss/bite)"
84
+
85
+
86
+ def ran_nothing(cmd: str, out: str) -> bool:
87
+ """A run that passed without running any test (dotnet test on a project that isn't a test project, a filter
88
+ that matched nothing) shows nothing about the code."""
89
+ if RAN_NOTHING.search(out):
90
+ return True
91
+ return bool(re.search(r"\bdotnet(\.exe)?\s+test\b", cmd)) and not re.search(r"Total(?: tests)?:\s*[1-9]", out)
92
+
93
+
94
+ LFS_HEAD = b"version https://git-lfs.github.com/spec/v1"
95
+ LFS_WARNED = set()
96
+
97
+
98
+ # ---- which commits ---------------------------------------------------------------
99
+
100
+ def commits(root: Path, last: int, since: str = "") -> list:
101
+ # With since, only commits since doesn't have: a pull request that merged its base back in would otherwise
102
+ # list the base's newer commits first, crowding out its own.
103
+ span = [f"{since}..HEAD"] if since else []
104
+ raw = repo.git(root, "log", f"-n{last}", "--no-merges", "--format=%H%x1f%an%x1f%ae%x1f%s%x1f%b%x1e", *span) or ""
105
+ out = []
106
+ for rec in raw.split("\x1e"):
107
+ parts = rec.strip("\n").split("\x1f")
108
+ parts += [""] * (5 - len(parts)) # git's output is stripped, so the last body can lose its separator
109
+ if not parts[0]:
110
+ continue
111
+ sha, _name, _mail, subject, body = parts
112
+ out.append({"sha": sha, "subject": subject.strip(), "body": body.strip()})
113
+ return out
114
+
115
+
116
+ def skip_reason(subject: str, said: str) -> str:
117
+ """Why this commit can't say anything here, or "" when it can."""
118
+ text = said.lower()
119
+ built_for = {os_ for os_, words in PLATFORM_WORDS.items() if re.search(rf"\b({words})", text)}
120
+ if "case-insensitive file" in text:
121
+ built_for |= {"win", "mac"}
122
+ if built_for and HERE not in built_for:
123
+ names = {"win": "Windows", "mac": "macOS", "linux": "Linux"}
124
+ return f"built for {'/'.join(sorted(names[o] for o in built_for))}, can't be checked on {names[HERE]}"
125
+ for_py = [(int(m.group(1)), bool(m.group(2))) for m in PY_VERSION.finditer(text)]
126
+ here = sys.version_info[1]
127
+ if for_py and not any(v == here or (later and here > v) for v, later in for_py):
128
+ versions = "/".join(f"3.{v}" + ("+" if later else "") for v, later in dict.fromkeys(for_py))
129
+ return f"built for Python {versions}, can't be checked on Python 3.{here}"
130
+ if TIDY.search(subject):
131
+ return "a tidy-up, so there's nothing new to check"
132
+ if SPEED.search(subject) and not re.search(r"\bfix", subject, re.I):
133
+ return "a speed-up, and tests check results, not speed"
134
+ return ""
135
+
136
+
137
+ def tests_touch(root: Path, c: dict) -> bool:
138
+ """The commit's own tests name the code it changed: a changed file's module or class, or a function or class
139
+ the change defines. Tests that name none of them were written for other code."""
140
+ names = set()
141
+ for rel in c["code"]:
142
+ names.add(Path(rel).stem)
143
+ diff = repo.git(root, "diff", f"{c['sha']}^", c["sha"], "--", rel) or ""
144
+ lines = re.findall(r"(?m)^(?:[-+](?![-+])|@@.*?@@)(.*)$", diff) # changed lines and hunk headers
145
+ for ln in lines:
146
+ names |= set(re.findall(r"^\s*(?:export\s+|async\s+|pub\s+)*(?:def|class|function|func|fn|struct|interface|enum|record)\s+(\w+)", ln))
147
+ names |= set(re.findall(r"^\s*(?:public|private|internal|protected)\b[\w<>\[\], ?]*?\s(\w+)\s*[({=;]", ln))
148
+ names = {n for n in names if len(n) > 2 and n not in {"__init__", "index", "main", "utils", "util", "test"}}
149
+ for t in c.get("test_code", []):
150
+ src = repo.git(root, "show", f"{c['sha']}:{t}") or ""
151
+ if any(re.search(rf"\b{re.escape(n)}\b", src) for n in names):
152
+ return True
153
+ return not names
154
+
155
+
156
+ def comment_only(root: Path, sha: str, rel: str) -> bool:
157
+ """The change to this file is comments or whitespace only: undoing it can't make a test fail."""
158
+ def code(rev):
159
+ b = repo.git(root, "cat-file", "blob", f"{rev}:{rel}", raw=True)
160
+ if b is None:
161
+ return None
162
+ suf = Path(rel).suffix.lower()
163
+ return [t for t in ("".join(bite.strip_comment(ln, suf).split()) for ln in b.decode("utf-8", "replace").splitlines()) if t]
164
+ old, new = code(f"{sha}^"), code(sha)
165
+ if old is not None and old == new:
166
+ return True
167
+ return Path(rel).suffix.lower() == ".py" and old is not None and same_python(root, sha, rel)
168
+
169
+
170
+ # Decorators and base classes that never read type hints. Any other (dataclass, BaseModel, a web route) may, so
171
+ # hints under them count as code.
172
+ HINT_BLIND = {"staticmethod", "classmethod", "property", "setter", "getter", "deleter", "abstractmethod", "overload",
173
+ "override", "final", "cached_property", "lru_cache", "cache", "wraps", "contextmanager", "object", "ABC"}
174
+
175
+
176
+ def last_name(node) -> str:
177
+ node = node.func if isinstance(node, ast.Call) else node
178
+ return node.attr if isinstance(node, ast.Attribute) else getattr(node, "id", "")
179
+
180
+
181
+ def drop_hints(t: ast.AST) -> None:
182
+ """Type hints out: they don't change what runs, except where a decorator or base class reads them."""
183
+ def walk(node, blind):
184
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
185
+ blind = blind and all(last_name(d) in HINT_BLIND for d in node.decorator_list) and all(
186
+ last_name(b) in HINT_BLIND for b in getattr(node, "bases", []))
187
+ if blind and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
188
+ node.returns = None
189
+ a = node.args
190
+ for arg in a.posonlyargs + a.args + a.kwonlyargs + [a.vararg, a.kwarg]:
191
+ if arg is not None:
192
+ arg.annotation = None
193
+ if isinstance(node, ast.If) and last_name(node.test) == "TYPE_CHECKING":
194
+ node.body = [ast.Pass()] # imports for the type checker only
195
+ for field, value in ast.iter_fields(node):
196
+ if isinstance(value, list):
197
+ out = []
198
+ for v in value:
199
+ if blind and isinstance(v, ast.AnnAssign) and v.value is not None and isinstance(v.target, ast.Name):
200
+ v = ast.Assign(targets=[v.target], value=v.value)
201
+ if isinstance(v, ast.AST):
202
+ walk(v, blind)
203
+ out.append(v)
204
+ setattr(node, field, out)
205
+ elif isinstance(value, ast.AST):
206
+ walk(value, blind)
207
+ walk(t, True)
208
+ # Imports from the standard library that nothing uses now were only there for the hints.
209
+ used = {n.id for n in ast.walk(t) if isinstance(n, ast.Name)}
210
+ for node in ast.walk(t):
211
+ body = getattr(node, "body", None)
212
+ if isinstance(body, list):
213
+ node.body = [s for s in body if not (
214
+ isinstance(s, (ast.Import, ast.ImportFrom)) and (getattr(s, "module", None) or s.names[0].name).split(".")[0]
215
+ in sys.stdlib_module_names | {"typing_extensions"} and not getattr(s, "level", 0)
216
+ and not any((a.asname or a.name).split(".")[0] in used for a in s.names))] or [ast.Pass()]
217
+
218
+
219
+ def same_python(root: Path, sha: str, rel: str) -> bool:
220
+ """The Python file does the same before and after: only docstrings, type hints or layout changed."""
221
+ def tree(rev):
222
+ src = repo.git(root, "cat-file", "blob", f"{rev}:{rel}", raw=True)
223
+ try:
224
+ t = ast.parse(src or b"")
225
+ except (SyntaxError, ValueError):
226
+ return None
227
+ for node in ast.walk(t):
228
+ body = getattr(node, "body", None)
229
+ if (isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and body
230
+ and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant)
231
+ and isinstance(body[0].value.value, str)):
232
+ node.body = body[1:] or [ast.Pass()]
233
+ drop_hints(t)
234
+ return ast.dump(t)
235
+ old = tree(f"{sha}^")
236
+ return old is not None and old == tree(sha)
237
+
238
+
239
+ def files_of(root: Path, c: dict, cfg) -> None:
240
+ names = (repo.git(root, "diff-tree", "--no-commit-id", "-r", "--name-only", "--no-renames", c["sha"]) or "").split("\n")
241
+ names = [n for n in names if n]
242
+ c["tests"] = [n for n in names if bite.is_test_file(n, cfg)]
243
+ c["code"] = [n for n in names if n not in c["tests"] and Path(n).suffix.lower() in repo.CODE_SUFFIXES
244
+ and not repo.is_setup(n, cfg) and not comment_only(root, c["sha"], n)]
245
+ # Code in a language the test command doesn't run can't fail a test: taking it out says nothing about it.
246
+ runs = repo.runs_language(cfg.test_cmd)[1]
247
+ c["other_lang"] = [n for n in c["code"] if runs and Path(n).suffix.lower() not in runs]
248
+ c["code"] = [n for n in c["code"] if n not in c["other_lang"]]
249
+ stat = repo.git(root, "diff", "--shortstat", f"{c['sha']}^", c["sha"], "--", *c["code"]) if c["code"] else ""
250
+ c["lines"] = sum(int(x) for x in re.findall(r"(\d+) (?:insertion|deletion)", stat or ""))
251
+ claims = bool(CLAIM.search(c["subject"])) and not NOT_CODE.search(c["subject"])
252
+ c["claims"] = claims
253
+ # Trailers out first: "Co-Authored-By" would match auth in every co-authored commit.
254
+ said = c["subject"] + " " + re.sub(r"(?im)^[\w-]+-by:.*$", "", c["body"])[:400]
255
+ c["serious"] = claims and bool(MATTERS.search(said))
256
+ c["skip"] = skip_reason(c["subject"], said)
257
+ c["test_code"] = [n for n in c["tests"] if Path(n).suffix.lower() in repo.CODE_SUFFIXES]
258
+ # No test is suspect, and so is a new test (it may check nothing the change did): the free pass tells which.
259
+ c["score"] = ((3 if claims else 0) + (2 if not c["tests"] or c["test_code"] else 0) + (1 if 0 < c["lines"] <= 80 else 0)
260
+ + (2 if MATTERS.search(said) else 0))
261
+
262
+
263
+ def export(root: Path, sha: str, cfg) -> Path:
264
+ """The project as it was at this commit, in a fresh folder (git archive, so nothing of yours is touched)."""
265
+ dest = Path(tempfile.mkdtemp(prefix="bite_recent_")) / root.name
266
+ dest.mkdir()
267
+ data = subprocess.run(["git", "archive", "--format=tar", sha], cwd=root, capture_output=True).stdout
268
+ subprocess.run(["tar", "-x", "-C", str(dest)], input=data, capture_output=True)
269
+ lfs_fill(root, dest, data)
270
+ for name in cfg.copy_ignore: # nothing to do for most repos: ignored files aren't committed
271
+ for p in dest.rglob(name):
272
+ shutil.rmtree(p, ignore_errors=True) if p.is_dir() else None
273
+ return dest
274
+
275
+
276
+ def lfs_fill(root: Path, dest: Path, tar: bytes) -> int:
277
+ """git archive writes Git LFS files as small pointer files. Put the real content back, from the repo's own LFS
278
+ store or with git-lfs; warn once about any left as pointers, since a test that reads them fails on the copy.
279
+ Returns how many are still pointers."""
280
+ pointers = []
281
+ with tarfile.open(fileobj=io.BytesIO(tar)) as t:
282
+ for m in t:
283
+ if m.isfile() and m.size < 1024:
284
+ head = t.extractfile(m).read()
285
+ if head.startswith(LFS_HEAD):
286
+ pointers.append((m.name, head))
287
+ if not pointers:
288
+ return 0
289
+ store = Path(repo.git(root, "rev-parse", "--path-format=absolute", "--git-common-dir") or root / ".git") / "lfs" / "objects"
290
+ left, smudge = [], True
291
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
292
+ for name, ptr in pointers:
293
+ oid = re.search(rb"oid sha256:([0-9a-f]{64})", ptr)
294
+ obj = store / oid.group(1)[:2].decode() / oid.group(1)[2:4].decode() / oid.group(1).decode() if oid else None
295
+ if obj and obj.is_file():
296
+ bite.safe_write(dest, dest / name, obj.read_bytes())
297
+ continue
298
+ real = b""
299
+ try: # not downloaded here yet: git-lfs can fetch it, if it's installed (after one failure, stop trying)
300
+ r = subprocess.run(["git", "lfs", "smudge", "--", name], cwd=root, input=ptr, capture_output=True,
301
+ timeout=120, env=env) if smudge else None
302
+ real = r.stdout if r and r.returncode == 0 else b""
303
+ except (OSError, subprocess.TimeoutExpired):
304
+ pass
305
+ smudge = smudge and bool(real)
306
+ if real and not real.startswith(LFS_HEAD):
307
+ bite.safe_write(dest, dest / name, real)
308
+ else:
309
+ left.append(name)
310
+ if left and str(root) not in LFS_WARNED:
311
+ LFS_WARNED.add(str(root))
312
+ print(f" note: {len(left)} Git LFS file(s) are only pointer files in Bite's copies (e.g. {left[0]}): install "
313
+ "git-lfs and run `git lfs pull` so tests that read them work there.")
314
+ return len(left)
315
+
316
+
317
+ def at(cfg, folder: Path):
318
+ import copy as _copy
319
+ c = _copy.copy(cfg)
320
+ c.project = folder.resolve()
321
+ return c
322
+
323
+
324
+ # ---- step 1: free ----------------------------------------------------------------
325
+
326
+ def preflight(cfg) -> str:
327
+ """Why the tests can't run here at all, in words that name the fix, or "" when nothing is plainly missing.
328
+ Checked once before any copy is made, so a missing runner isn't reported as "exit 1" on every commit."""
329
+ if cfg.contain: # the tests run in the box, which brings its own programs
330
+ return ""
331
+ for cmd in dict.fromkeys(c for c in (cfg.free_cmd, cfg.test_cmd) if c):
332
+ if re.search(r"&&|\|\||[|;]|\{", cmd):
333
+ continue # a shell line: its first word may not be a program
334
+ try:
335
+ words = shlex.split(cmd, posix=not bite.WINDOWS)
336
+ except ValueError:
337
+ continue
338
+ first = words[0].strip('"') if words else ""
339
+ if not first or first.lower() in {"cd", "set", "env", "call", "start"}:
340
+ continue
341
+ if not shutil.which(first):
342
+ return (f"Bite can't find {first!r}, the first word of the test command ({cmd}). "
343
+ "Install it, or set test_command in .bite.ini to the command that runs your tests.")
344
+ if words[1:3] == ["-m", "pytest"] and subprocess.run([first, "-c", "import pytest"],
345
+ capture_output=True).returncode != 0:
346
+ return (f"pytest isn't installed for this Python ({shutil.which(first)}), so your tests can't run. "
347
+ f"Install it, then run Bite again:\n {first} -m pip install pytest")
348
+ return ""
349
+
350
+
351
+ def undo_check(root: Path, c: dict, cfg) -> str:
352
+ """'unguarded' when the tests still pass with this commit's code change taken back out; 'TEST GUARDS NOTHING'
353
+ when that includes the tests the commit itself added or changed (they're kept: only the code is undone)."""
354
+ folder = export(root, c["sha"], cfg)
355
+ fcfg = at(cfg, folder)
356
+ if getattr(cfg, "free_cmd", ""): # the faster subset, for this step only
357
+ fcfg.test_cmd, fcfg.box_test_cmd = cfg.free_cmd, ""
358
+ try:
359
+ now = bite.run_tests(fcfg, folder)
360
+ if not now["ok"]:
361
+ errs = [ln.strip(" E>") for ln in now["out"].splitlines() if re.search(r"\b\w+(?:Error|Exception)\b:|No module named ", ln)]
362
+ c["why"] = (errs[-1] if errs else now["why"])[:160]
363
+ return "tests fail at this commit"
364
+ if ran_nothing(bite.tests_cmd(fcfg), now["out"]):
365
+ return "no tests ran at this commit"
366
+ for rel in c["code"]:
367
+ old = repo.git(root, "cat-file", "blob", f"{c['sha']}^:{rel}", raw=True)
368
+ t = folder / rel
369
+ if old is None and t.suffix == ".py" and t.name != "__init__.py":
370
+ # A file the commit created: deleting it would let Python find an installed copy (pip install -e .)
371
+ # and import that instead, so the tests pass. A file that refuses to load stands in for it.
372
+ bite.safe_write(folder, t, b'raise ImportError("Bite took this file out: the commit it checks made it")\n')
373
+ elif old is None:
374
+ t.unlink(missing_ok=True)
375
+ else:
376
+ bite.safe_write(folder, t, old)
377
+ undone = bite.run_tests(fcfg, folder)
378
+ if undone["ok"]: # would be flagged: first rule out the usual harmless reasons
379
+ if c.get("skip"):
380
+ return f"not flagged: {c['skip']}"
381
+ if c.get("test_code") and not tests_touch(root, c):
382
+ return "not flagged: its tests are about other code"
383
+ if closed_since(root, c, cfg):
384
+ return "closed since: today's tests catch it"
385
+ return "TEST GUARDS NOTHING" if c.get("test_code") else "unguarded"
386
+ c["caught"] = caught_by(undone["out"])
387
+ return "guarded" if not bite.load_failure(undone["out"]) else "guarded (tests use the new code)"
388
+ finally:
389
+ shutil.rmtree(folder.parent, ignore_errors=True)
390
+
391
+
392
+ # The names of failing tests, as pytest, unittest, go test, cargo test, jest and dotnet test print them.
393
+ FAILED_TEST = re.compile(r"^(?:FAILED|ERROR) \S+::(?:\S+::)*([\w\[\]-]+)|^(?:FAIL|ERROR): (\w+) \(|--- FAIL: (\S+)|"
394
+ r"^test (\S+) \.\.\. FAILED|✕ (.+?)(?: \(\d+ ?ms\))?$|^\s*Failed (\S+) \[", re.M)
395
+
396
+
397
+ def caught_by(out: str) -> str:
398
+ """The test(s) that failed, for the report: "`test_x`", "`test_x` and 2 more", or "" when none is named."""
399
+ names = list(dict.fromkeys(next(g for g in m.groups() if g).strip() for m in FAILED_TEST.finditer(out)))
400
+ if not names:
401
+ return ""
402
+ return f"`{names[0]}`" + (f" and {len(names) - 1} more" if len(names) > 1 else "")
403
+
404
+
405
+ def closed_since(root: Path, c: dict, cfg) -> bool:
406
+ """A later commit may have closed the gap: on today's code, take this commit's change back out (if it still
407
+ applies) and see whether today's tests now fail where they passed before."""
408
+ head = repo.git(root, "rev-parse", "HEAD")
409
+ if not head or head == c["sha"]:
410
+ return False
411
+ diff = repo.git(root, "diff", "--binary", f"{c['sha']}^", c["sha"], "--", *c["code"], raw=True)
412
+ folder = export(root, head, cfg)
413
+ fcfg = at(cfg, folder)
414
+ if getattr(cfg, "free_cmd", ""):
415
+ fcfg.test_cmd, fcfg.box_test_cmd = cfg.free_cmd, ""
416
+ try:
417
+ if not diff or subprocess.run(["git", "apply", "-R", "--check", "-"], cwd=folder, input=diff,
418
+ capture_output=True).returncode != 0:
419
+ return False # today's code moved on: can't tell
420
+ now = bite.run_tests(fcfg, folder)
421
+ if not now["ok"] or ran_nothing(bite.tests_cmd(fcfg), now["out"]):
422
+ return False
423
+ subprocess.run(["git", "apply", "-R", "-"], cwd=folder, input=diff, capture_output=True)
424
+ undone = bite.run_tests(fcfg, folder)
425
+ c["caught"] = caught_by(undone["out"])
426
+ return not undone["ok"] and not bite.load_failure(undone["out"])
427
+ finally:
428
+ shutil.rmtree(folder.parent, ignore_errors=True)
429
+
430
+
431
+ # ---- step 2: the claim, turned into a test ----------------------------------------
432
+
433
+ WORKER_SYSTEM = """You check one claim a coding agent made in a commit message. You are on a copy of the project
434
+ exactly as it was at that commit. Your job: write ONE small test that checks what the commit message says is now
435
+ true, and run it.
436
+
437
+ Rules:
438
+ - Test only what the message (and the diff's evident intent) claims. Don't test other features or ask for more.
439
+ - Be a skeptic, not a fan: the commit's own tests already cover the happy path. Look for where the claim is
440
+ WIDER than the change: the message says "handles X" but the diff covers one form of X; it says "every" or
441
+ "always" but one branch, caller, file format or platform path was missed; a sibling function with the same
442
+ bug was left alone; the fix works once but not on the second call, a reload, or a round trip. Test those.
443
+ - Include the odd inputs the claim plainly covers: empty, None, zero, unicode, duplicates, boundaries.
444
+ Small and readable, under 40 lines.
445
+ - Use the project's own test framework and conventions. Put the test in ONE new file whose name contains
446
+ bite_claim and that the project's test runner picks up (for pytest: test_bite_claim.py), next to the
447
+ project's other tests. Don't edit any other file. For .NET (dotnet test): BiteClaimTests.cs with a test
448
+ class named BiteClaimTests, inside the folder of an existing test project so that project compiles it.
449
+ - Run it: `pytest <your file>` runs only your test if pytest is the runner; for .NET,
450
+ `dotnet test <the test project> --filter "FullyQualifiedName~BiteClaim"`; `bite-test` runs the whole suite. If it fails, read the failure and make sure the TEST is right (right import, right API, right
451
+ expectation from the claim) before you conclude the code is wrong. A failure from your own mistake is not a finding.
452
+ - Finish with one line of JSON and nothing after it:
453
+ {"test_file": "<path relative to the project>", "claim": "<the claim in your words>", "result": "fails" or "holds", "why": "<one sentence>"}"""
454
+
455
+ JUDGE_PROMPT = """A commit said:
456
+ ---
457
+ {msg}
458
+ ---
459
+ Its diff (code files):
460
+ ---
461
+ {diff}
462
+ ---
463
+ A checker wrote this test of that claim:
464
+ ---
465
+ {test}
466
+ ---
467
+ Run on the code at that commit, the test fails like this:
468
+ ---
469
+ {fail}
470
+ ---
471
+ Answer strictly. Is this a fair test of what the commit claimed (it doesn't ask for more than the claim or the
472
+ change's evident intent), and does it fail because the code really doesn't do what was claimed (not because the
473
+ test is wrong, uses a wrong API, or expects behaviour nobody promised)?
474
+ Then, if REAL, how much it matters: +1 each for: real users would hit it; it loses or corrupts data or money;
475
+ it would be embarrassing if a user found it.
476
+ Reply with one line of JSON only: {{"verdict": "REAL" or "NOT", "matters": 0-3, "bug": "<the false claim in one plain sentence>",
477
+ "why": "<one sentence>"}}"""
478
+
479
+
480
+ def last_json(text: str) -> dict:
481
+ for m in reversed(list(re.finditer(r"\{[^{}]*\}", text or ""))):
482
+ try:
483
+ return json.loads(m.group(0))
484
+ except ValueError:
485
+ continue
486
+ return {}
487
+
488
+
489
+ async def _agent(options, prompt: str):
490
+ from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, ResultMessage, TextBlock
491
+ said, cost = [], 0.0
492
+ async with ClaudeSDKClient(options=options) as client:
493
+ await client.query(prompt)
494
+ async for msg in client.receive_response():
495
+ if isinstance(msg, AssistantMessage):
496
+ t = [b.text for b in msg.content if isinstance(b, TextBlock) and b.text.strip()]
497
+ if t:
498
+ said = t
499
+ elif isinstance(msg, ResultMessage):
500
+ cost = msg.total_cost_usd or 0.0
501
+ SPENT[0] += cost
502
+ return "\n".join(said), cost
503
+
504
+
505
+ def worker(cfg, folder: Path, c: dict, diff: str, budget: float) -> dict:
506
+ from claude_agent_sdk import ClaudeAgentOptions
507
+ wcfg = at(cfg, folder)
508
+ opts = ClaudeAgentOptions(cwd=str(folder), model=cfg.model, system_prompt=WORKER_SYSTEM, max_turns=cfg.max_turns,
509
+ max_budget_usd=budget, allowed_tools=[], disallowed_tools=["WebFetch", "WebSearch", "Task"],
510
+ can_use_tool=bite.make_permission(wcfg, log=lambda *a: None), permission_mode="default",
511
+ setting_sources=[], env={"ANTHROPIC_API_KEY": bite.load_key()})
512
+ prompt = f"Commit message:\n{c['subject']}\n\n{c['body']}\n\nThe code change in it:\n{diff}"
513
+ text, _ = asyncio.run(_agent(opts, prompt))
514
+ return last_json(text)
515
+
516
+
517
+ def judge(cfg, c: dict, diff: str, test: str, fail: str) -> dict:
518
+ from claude_agent_sdk import ClaudeAgentOptions
519
+
520
+ async def nothing(*a):
521
+ from claude_agent_sdk import PermissionResultDeny
522
+ return PermissionResultDeny(message="No tools: answer from what you were given.")
523
+ opts = ClaudeAgentOptions(model=cfg.judge_model, max_turns=2, max_budget_usd=cfg.judge_budget, allowed_tools=[],
524
+ can_use_tool=nothing, permission_mode="default", setting_sources=[],
525
+ env={"ANTHROPIC_API_KEY": bite.load_key()},
526
+ system_prompt="You check another checker's work. Default to NOT when unsure.")
527
+ msg = f"{c['subject']}\n\n{c['body']}"[:3000]
528
+ text, _ = asyncio.run(_agent(opts, JUDGE_PROMPT.format(msg=msg, diff=diff[:8000], test=test[:4000], fail=fail[:3000])))
529
+ return last_json(text)
530
+
531
+
532
+ def claim_command(cfg, rel: str) -> str:
533
+ """Bite runs the claim test with the project's own test command (never the worker's command line)."""
534
+ if cfg.claim_cmd:
535
+ return cfg.claim_cmd.replace("{file}", rel)
536
+ cmd = bite.tests_cmd(cfg)
537
+ if re.search(r"\bdotnet(\.exe)?\s+test\b", cmd): # dotnet test takes no file: pick the claim's class by name
538
+ cmd = re.sub(r'\s--filter(\s+|=)("[^"]*"|\S+)', "", cmd)
539
+ return f"{cmd} {DOTNET_FILTER}"
540
+ if "pytest" in cmd: # keep the runner and its options, drop the folders it was given: only this file runs
541
+ toks = cmd.split()
542
+ i = next(i for i, t in enumerate(toks) if "pytest" in t)
543
+ keep = toks[:i + 1] + [t for t in toks[i + 1:] if t.startswith("-")]
544
+ for opt in ("-p", "-c", "-k", "-m", "--rootdir"): # options that take a value
545
+ keep += [toks[j + 1] for j in range(i + 1, len(toks) - 1) if toks[j] == opt and toks[j + 1] not in keep]
546
+ return " ".join(keep) + " " + rel
547
+ return f"{cmd} {rel}"
548
+
549
+
550
+ def run_claim(cfg, folder: Path, test_rel: str, test_src: bytes, cmd: str) -> tuple:
551
+ """(exit code, output) of the claim test placed into this copy, run by Bite itself."""
552
+ t = folder / test_rel
553
+ t.parent.mkdir(parents=True, exist_ok=True)
554
+ bite.safe_write(folder, t, test_src)
555
+ return bite.locked_run(at(cfg, folder), cmd, folder)
556
+
557
+
558
+ def try_claim(root: Path, c: dict, cfg, budget: float) -> dict:
559
+ res = {"sha": c["sha"], "subject": c["subject"], "status": "holds"}
560
+ diff = repo.git(root, "diff", f"{c['sha']}^", c["sha"], "--", *c["code"]) or ""
561
+ folder = export(root, c["sha"], cfg)
562
+ try:
563
+ w = worker(cfg, folder, c, diff[:12000], budget)
564
+ rel = (w.get("test_file") or "").strip().replace("\\", "/")
565
+ res["worker"] = w
566
+ tf = bite.inside(folder, str(folder / rel)) if rel else None
567
+ if not tf or not tf.is_file() or "biteclaim" not in re.sub(r"[_\-\s]", "", tf.name.lower()):
568
+ res["status"] = "no test written"
569
+ return res
570
+ rel = tf.relative_to(folder.resolve()).as_posix()
571
+ cmd = claim_command(cfg, rel)
572
+ src = tf.read_bytes()
573
+ res.update(test_file=rel, run=cmd, test=src.decode("utf-8", "replace"))
574
+ finally:
575
+ shutil.rmtree(folder.parent, ignore_errors=True)
576
+
577
+ # Bite runs it itself, on clean copies: twice at the commit, then on today's code.
578
+ outs = []
579
+ for _ in range(2):
580
+ f = export(root, c["sha"], cfg)
581
+ try:
582
+ outs.append(run_claim(cfg, f, rel, src, cmd))
583
+ finally:
584
+ shutil.rmtree(f.parent, ignore_errors=True)
585
+ if any(rc in cfg.ok_codes and ran_nothing(cmd, out) for rc, out in outs):
586
+ res["status"] = "test didn't load or timed out"
587
+ return res
588
+ if any(rc in cfg.ok_codes for rc, _ in outs):
589
+ res["status"] = "holds" if all(rc in cfg.ok_codes for rc, _ in outs) else "flaky"
590
+ return res
591
+ if any(rc is None for rc, _ in outs) or bite.load_failure(outs[0][1]):
592
+ res["status"] = "test didn't load or timed out"
593
+ return res
594
+ fail = repo.failure_excerpt(outs[0][1], 25)
595
+ res["fail"] = fail
596
+ v = judge(cfg, c, diff, res["test"], fail)
597
+ res["judge"] = v
598
+ if (v.get("verdict") or "").upper() != "REAL":
599
+ res["status"] = "second check disagreed"
600
+ return res
601
+ head = repo.git(root, "rev-parse", "HEAD")
602
+ f = export(root, head, cfg)
603
+ try:
604
+ rc, out = run_claim(cfg, f, rel, src, cmd)
605
+ finally:
606
+ shutil.rmtree(f.parent, ignore_errors=True)
607
+ res["today"] = ("fixed since" if rc in cfg.ok_codes else "can't tell (test doesn't load on today's code)"
608
+ if rc is None or bite.load_failure(out) else "STILL BROKEN")
609
+ res["status"] = "FALSE DONE"
610
+ res["matters"] = int(v.get("matters") or 0)
611
+ res["bug"] = v.get("bug") or w.get("why", "")
612
+ return res
613
+
614
+
615
+ # ---- report ----------------------------------------------------------------------
616
+
617
+ def proof_dir(root: Path) -> Path:
618
+ """bite_proof/ in the repo, kept out of git: it's Bite's output, not the project's."""
619
+ d = root / "bite_proof"
620
+ d.mkdir(exist_ok=True)
621
+ ign = d / ".gitignore"
622
+ if not ign.exists():
623
+ ign.write_text("# Bite's output: not part of the project. Delete this file to commit the proof.\n*\n", encoding="utf-8")
624
+ return d
625
+
626
+
627
+ def save_log(root: Path, tried: list, cs: list) -> None:
628
+ """Written after every check, so a run stopped partway keeps what it found."""
629
+ (proof_dir(root) / "recent_log.json").write_text(json.dumps({"tried": tried, "free": [
630
+ {k: c.get(k) for k in ("sha", "subject", "undo", "score")} for c in cs]}, indent=1), encoding="utf-8")
631
+
632
+
633
+ SETUP_LINK = "https://github.com/connabyss/bite/blob/main/AGENTS.md#setup-once-your-person-says-yes-they-only-paste-a-key-and-only-for-the-paid-step"
634
+
635
+
636
+ def say(c: dict, newest: str = "") -> tuple[str, str, str]:
637
+ """One free-check result in plain words, the same in the terminal, the report, the job page and the pull request
638
+ comment: (kind, short label, one or two sentences). Each answers one question: if this change disappeared, would
639
+ any test notice? kind is held, gap, odd, fail, none or skip. newest is the pull request's newest commit: tests
640
+ failing on an older commit are skipped, since they often get fixed later."""
641
+ u, caught = c.get("undo", ""), c.get("caught", "")
642
+ one = caught and " more" not in caught
643
+ if u.startswith("closed since"):
644
+ return "held", "✅ a later test notices", f"✅ A test added in a later commit notices it now{f' ({caught})' if caught else ''}."
645
+ if u == "guarded (tests use the new code)":
646
+ return "held", "✅ tests crash without it", "✅ Without this change your tests crash, so they'd notice if it went."
647
+ if u.startswith("guarded"):
648
+ return ("held", f"✅ noticed by {caught.replace('`', '') or 'your tests'}",
649
+ f"✅ {(('The test ' if one else 'The tests ') + caught) if caught else 'Your tests'} noticed it was gone. "
650
+ f"If this change breaks later, "
651
+ f"{'that test' if one else 'they'} will warn you.")
652
+ if u.startswith("unguarded"):
653
+ return ("gap", "⚠️ NO TEST NOTICED", "⚠️ " + ("If this fix wandered off, nothing would notice. A test would. " if ROAST[0] else
654
+ "No test noticed it was gone. If it breaks later, nothing warns you. ")
655
+ + "**Next:** add a test that fails without it.")
656
+ if u == "TEST GUARDS NOTHING":
657
+ mine = ", ".join(f"`{t}`" for t in c.get("test_code", []))
658
+ return ("odd", "⚠️ ITS TESTS DIDN'T NOTICE", f"⚠️ {'These' if ROAST[0] else 'This commit added'} tests"
659
+ f"{f' ({mine})' if mine else ''}" + (" are looking the other way. " if ROAST[0] else
660
+ ", but they didn't notice it was gone either, so they check something else. ")
661
+ + "**Next:** make one fail without the change.")
662
+ if u == "tests fail at this commit":
663
+ if newest and c["sha"] != newest:
664
+ return ("skip", "➖ tests failed here, skipped", "➖ Your tests failed at this commit, so Bite skipped it "
665
+ "(a later commit may have fixed them).")
666
+ return ("fail", "❌ tests already fail", "❌ Your tests already fail here, so Bite can't ask its question. "
667
+ "**Next:** fix them first.")
668
+ if u == "no tests ran at this commit":
669
+ return ("none", "⚠️ no tests found", "⚠️ Bite couldn't find any tests to run. **Next:** add one, or "
670
+ f"[point Bite at your tests]({SETUP_LINK}).")
671
+ if u.startswith("not flagged: "):
672
+ return "skip", "➖ skipped: " + u[len("not flagged: "):], "➖ Skipped: " + u[len("not flagged: "):] + "."
673
+ return "skip", "➖ couldn't check: " + u, "➖ Couldn't check: " + u + "."
674
+
675
+
676
+ def bite_proof(kinds: list, tried: list = ()) -> bool:
677
+ """Every change Bite checked had a test that noticed it (skipped ones aside), and no claim tested was false."""
678
+ return "held" in kinds and set(kinds) <= {"held", "skip"} and not any(r.get("status") == "FALSE DONE" for r in tried)
679
+
680
+
681
+ def flat(text: str) -> str:
682
+ """say()'s sentence without its Markdown, for the terminal and the report page."""
683
+ return re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", text).replace("**", "").replace("`", "")
684
+
685
+
686
+ def commit_link(root: Path) -> str:
687
+ """https://github.com/owner/name/commit/ for a GitHub origin, else "" (the report then shows plain shas)."""
688
+ url = repo.git(root, "remote", "get-url", "origin") or ""
689
+ m = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$", url)
690
+ return f"https://github.com/{m.group(1)}/commit/" if m else ""
691
+
692
+
693
+ def write_html(root: Path, cs: list, tried: list, seconds: float) -> Path:
694
+ """One page that says what was checked and how each commit did, to open in a browser or screenshot."""
695
+ import html
696
+ from datetime import datetime
697
+ e = html.escape
698
+ link = commit_link(root)
699
+ checked = [c for c in cs if c.get("undo")]
700
+ kinds = [say(c)[0] for c in checked]
701
+ false_done = [r for r in tried if r.get("status") == "FALSE DONE"]
702
+ sha = lambda x: (f'<a href="{e(link + x)}">{x[:8]}</a>' if link else x[:8]) # noqa: E731
703
+ rows = "".join(f'<tr class="{k}"><td><code>{sha(c["sha"])}</code></td><td>{e(c["subject"][:90])}</td>'
704
+ f'<td><span class="dot"></span>{e(flat(w)[2:].strip())}' + (f'<div class="why">{e(c["why"])}</div>' if c.get("why") else "")
705
+ + "</td></tr>" for c in checked for k, _, w in [say(c)])
706
+ claims = "".join(f'<tr class="{"odd" if r["status"] == "FALSE DONE" else "held" if r["status"] == "holds" else "skip"}">'
707
+ f'<td><code>{sha(r["sha"])}</code></td><td>{e(r["subject"][:90])}</td><td><span class="dot"></span>'
708
+ + (f'False "done": {e(r.get("bug", ""))} Today\'s code: {e(r.get("today", ""))}.'
709
+ if r["status"] == "FALSE DONE" else "The claim held" if r["status"] == "holds" else e(r["status"]))
710
+ + "</td></tr>" for r in tried)
711
+ tiles = [(len(checked), "commits checked", ""), (kinds.count("held"), "a test noticed", "held"),
712
+ (kinds.count("gap") + kinds.count("odd") + kinds.count("none"), "no test noticed", "gap"),
713
+ (kinds.count("skip") + kinds.count("fail"), "couldn't check", "skip")]
714
+ if tried:
715
+ tiles.append((len(false_done), f"false \"done\" of {len(tried)} claims tested", "odd"))
716
+ page = f"""<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
717
+ <title>Bite report: {e(root.name)}</title>
718
+ <style>
719
+ :root{{--bg:#fbfaf7;--fg:#1d1d1b;--mute:#6b6a65;--line:#e4e1d8;--held:#2e7d4f;--gap:#b7791f;--odd:#c0392b;--skip:#8a8a85}}
720
+ @media (prefers-color-scheme:dark){{:root{{--bg:#161615;--fg:#ecebe6;--mute:#9a9992;--line:#2c2b29;--held:#5fbf86;--gap:#e0a84a;--odd:#ef6b5b;--skip:#7d7c77}}}}
721
+ body{{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}}
722
+ main{{max-width:920px;margin:0 auto;padding:32px 16px}} h1{{margin:0;font-size:28px}} h2{{font-size:18px;margin:32px 0 8px}}
723
+ .sub{{color:var(--mute)}} a{{color:inherit}} code{{font:13px ui-monospace,Consolas,monospace}}
724
+ .tiles{{display:flex;flex-wrap:wrap;gap:12px;margin:24px 0}} .tile{{flex:1 1 140px;border:1px solid var(--line);border-radius:10px;padding:12px 14px}}
725
+ .tile b{{display:block;font-size:30px;line-height:1.1}} .tile.held b{{color:var(--held)}} .tile.gap b{{color:var(--gap)}}
726
+ .tile.odd b{{color:var(--odd)}} .tile.skip b{{color:var(--skip)}}
727
+ table{{width:100%;border-collapse:collapse}} td{{padding:8px 6px;border-top:1px solid var(--line);vertical-align:top}}
728
+ td:first-child{{white-space:nowrap}} .dot{{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:7px;background:var(--skip)}}
729
+ .held .dot{{background:var(--held)}} .gap .dot,.none .dot{{background:var(--gap)}} .odd .dot,.fail .dot{{background:var(--odd)}}
730
+ .why{{color:var(--mute);font-size:13px}} .proof{{border:1px solid var(--held);border-radius:10px;padding:12px 14px}}
731
+ .proof b{{color:var(--held)}} footer{{margin-top:32px;color:var(--mute);font-size:13px}}
732
+ </style>
733
+ <main>
734
+ <h1>Bite report: {e(root.name)}</h1>
735
+ <div class="sub">{datetime.now():%Y-%m-%d %H:%M} &middot; did the recent "fixed" commits hold? &middot; {seconds:.0f}s, ${SPENT[0]:.2f}</div>
736
+ {f'<p class="proof"><b>Bite-proof. Nice work.</b> Every change Bite checked had a test that noticed it. Badge for your README:<br><code>{e(BADGE)}</code></p>' if bite_proof(kinds, tried) else ""}
737
+ <div class="tiles">{"".join(f'<div class="tile {k}"><b>{n}</b>{e(t)}</div>' for n, t, k in tiles)}</div>
738
+ {f'<h2>Claims tested</h2><table>{claims}</table>' if tried else ""}
739
+ <h2>If this change disappeared, would any test notice?</h2>
740
+ {f'<table>{rows}</table>' if rows else '<p class="sub">No commits were checked.</p>'}
741
+ <footer>"Held" means a test fails when the change is taken out. It doesn't prove the commit has no other bugs.
742
+ Details and rerun commands are in the other files in this folder. Made by <a href="https://github.com/connabyss/bite">Bite</a>.</footer>
743
+ </main>
744
+ """
745
+ out = proof_dir(root) / "report.html"
746
+ out.write_text(page, encoding="utf-8")
747
+ return out
748
+
749
+
750
+ # The cat logo at the start of every comment's heading, so Bite's comments look like Bite's. Hosted on main, not in
751
+ # the comment, because GitHub won't show images written into a comment.
752
+ CAT = ('<img src="https://raw.githubusercontent.com/connabyss/bite/main/assets/bite-avatar.png" '
753
+ 'width="24" height="24" alt="Bite" align="top">')
754
+
755
+
756
+ def write_comment(root: Path, cs: list, none: str = "", error: str = "", newest: str = "") -> Path:
757
+ """The free check in plain words for a pull request comment (the GitHub Action posts it): a verdict, what to
758
+ do next, then one line per commit. none says why nothing was checked, error why Bite couldn't start, and newest
759
+ is the pull request's newest commit: only its failing tests decide the verdict."""
760
+ import html
761
+ link = commit_link(root)
762
+ checked = [c for c in cs if c.get("undo")]
763
+ said = [say(c, newest) for c in checked]
764
+ kinds = [k for k, _, _ in said]
765
+ bad = [c for c, k in zip(checked, kinds) if k in ("gap", "odd")]
766
+ sha = lambda c: f"[`{c['sha'][:8]}`]({link}{c['sha']})" if link else f"`{c['sha'][:8]}`" # noqa: E731
767
+ why = lambda c: f" Why: {html.escape(c['why'], quote=False).replace('`', chr(39))}." if c.get("why") else "" # noqa: E731
768
+ # The verdict first; the commit's own title after it, quoted and small, so it doesn't read as Bite talking.
769
+ line = lambda c, k, w: (f"- {w}" + (why(c) if k == "fail" or "tests failed here" in w else "") # noqa: E731
770
+ + f"<br><sub>Commit {sha(c)}: “{html.escape(c['subject'][:70], quote=False)}”</sub>")
771
+ lines = ["<!-- bite-check -->"]
772
+ if error:
773
+ lines += ["### ❌ Bite couldn't start", "", html.escape(error, quote=False), "",
774
+ "**Next:** fix that and push again. Bite checks again on every push."]
775
+ elif "fail" in kinds:
776
+ lines += ["### ❌ Bite: your tests already fail", "",
777
+ "Bite can't ask its question while your tests fail on the newest commit.", "",
778
+ "**Next:** fix them first. Bite checks again on your next push."]
779
+ elif bad:
780
+ one = len(bad) == 1
781
+ lines += [f"### ⚠️ Bite: no test noticed {len(bad)} change{'' if one else 's'}", "",
782
+ f"Bite took {'the ⚠️ change' if one else 'each ⚠️ change'} below out and every test still passed. If "
783
+ f"{'it' if one else 'they'} broke later, nothing would warn you. That's fine for a tidy-up, a rename or docs.", "",
784
+ "**Next:** if the change should do something, add a test that fails without it."]
785
+ elif "none" in kinds and "held" not in kinds:
786
+ lines += ["### ⚠️ Bite: no tests found", "", "Bite couldn't find any tests to run, so nothing would notice if this code broke.", "",
787
+ f"**Next:** add a test, or [point Bite at your tests]({SETUP_LINK})."]
788
+ elif "held" in kinds:
789
+ lines += ["### ✅ Bite-proof. Nice work." if bite_proof(kinds) else "### ✅ Bite: a test noticed every change", "",
790
+ "Bite took each change out, one at a time, and a test noticed every time. If one breaks later, that "
791
+ "test will warn you."]
792
+ if bite_proof(kinds):
793
+ lines += ["", f"Badge for your README: `{BADGE}`"]
794
+ elif checked:
795
+ lines += ["### ➖ Bite: nothing here to check", "", "Bite skipped every commit here. Each one says why."]
796
+ else:
797
+ lines += ["### ➖ Bite: nothing here to check", "",
798
+ none or "No commit in this pull request changed code: only docs, settings or tests."]
799
+ if checked:
800
+ lines += [""] + [line(c, k, w) for c, (k, _, w) in zip(checked, said)]
801
+ total, last = os.environ.get("BITE_PR_COMMITS", ""), os.environ.get("BITE_CHECKED_COMMITS", "")
802
+ if total.isdigit() and last.isdigit() and int(total) > int(last) and not error:
803
+ lines += ["", f"Bite looked at the newest {last} of this pull request's {total} commits."]
804
+ # Every comment ends the same way: the fold, the same few words on how Bite checks, and the link.
805
+ lines += ["", "<details><summary>How Bite checks</summary>", "",
806
+ "Bite asks one question about each change: if it disappeared, would any test notice? It takes the change "
807
+ "out, runs your tests, and puts it back. "
808
+ + ("The steps to rerun each check yourself are on the job's summary page. " if bad else "")
809
+ + "[What is Bite?](https://github.com/connabyss/bite)", "", "</details>"]
810
+ lines[1] = lines[1].replace("### ", f"### {CAT} ", 1)
811
+ p = proof_dir(root) / "COMMENT.md"
812
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
813
+ return p
814
+
815
+
816
+ def undo_steps(root: Path, sha: str, files: list) -> list:
817
+ """The commands that take a commit's code change back out: restore the files it edited, delete the ones it
818
+ created. `git checkout <sha>^ -- <file>` fails for a file the commit added, so it can't carry those."""
819
+ restored, created = [], []
820
+ for rel in files:
821
+ target = restored if repo.git(root, "cat-file", "blob", f"{sha}^:{rel}", raw=True) is not None else created
822
+ target.append(rel)
823
+ steps = []
824
+ if created:
825
+ steps.append("rm -f " + " ".join(created))
826
+ if restored:
827
+ steps.append(f"git checkout {sha}^ -- {' '.join(restored)}")
828
+ return steps
829
+
830
+
831
+ def write_free(root: Path, cfg, flagged: list) -> Path:
832
+ """The free pass's findings, each with a rerun recipe and a note to hand to the user's own coding AI."""
833
+ lines = ["# Bite: changes no test noticed", "",
834
+ "Bite took each change below out, kept the tests, and ran them. Every test still passed, so nothing would "
835
+ "warn you if the change broke. This check is free; no AI was used.", ""]
836
+ for i, c in enumerate(flagged, 1):
837
+ sha = c["sha"]
838
+ steps = undo_steps(root, sha, c["code"])
839
+ lines += [f"## {i}. {sha[:8]} {c['subject']}", "", f"- What Bite saw: {say(c)[2][3:]}",
840
+ f"- Code it changed: {', '.join(c['code'])}", "", "Check it yourself (a throwaway copy; your checkout "
841
+ "isn't touched):", "", "```", f"git worktree add ../bite_check {sha}", "cd ../bite_check",
842
+ *steps, f"{cfg.test_cmd}", "# every test passes: no test noticed the change was gone",
843
+ f"cd {root.as_posix()}", "git worktree remove --force ../bite_check", "```", "", "Hand this to your coding AI:", "", "```",
844
+ f"Commit {sha[:8]} (\"{c['subject']}\") changed {', '.join(c['code'])}. With that change undone, "
845
+ f"the tests still pass ({'its own tests: ' + ', '.join(c['test_code']) if c.get('test_code') else 'no test covers it'}). "
846
+ "Write one small test of what this commit changed, next to the existing tests. Show that it fails "
847
+ f"with the change undone ({'; '.join(steps)} in a throwaway worktree) and passes "
848
+ "with it. Don't change the code.", "```", ""]
849
+ p = proof_dir(root) / "FREE_REPORT.md"
850
+ p.write_text("\n".join(lines), encoding="utf-8")
851
+ return p
852
+
853
+
854
+ def write_proof(root: Path, found: list) -> Path:
855
+ d = proof_dir(root)
856
+ lines = ["# Bite: false \"done\" commits", ""]
857
+ for i, r in enumerate(found, 1):
858
+ name = f"{r['sha'][:8]}_{Path(r['test_file']).name}"
859
+ (d / name).write_text(r["test"], encoding="utf-8")
860
+ lines += [f"## {i}. {r['sha'][:8]} {r['subject']}", "", f"- What's wrong: {r['bug']}",
861
+ f"- Today's code: {r['today']}", f"- Matters: {r['matters']}/3", f"- Test: [{name}]({name})",
862
+ f"- Run it: copy it to `{r['test_file']}`, then `{r['run']}`", "", "```", r["fail"].strip(), "```", ""]
863
+ (d / "REPORT.md").write_text("\n".join(lines), encoding="utf-8")
864
+ return d
865
+
866
+ # Every option main() accepts; each one must be in the help text above (the self-test checks).
867
+ FLAGS = {"--free", "--all", "--any", "--serious", "--again", "--roast"}
868
+ VALUED = {"--last": int, "--free-checks": int, "--suspects": int, "--budget": float, "--since": str}
869
+
870
+
871
+ def main(argv=None):
872
+ argv = list(sys.argv[1:] if argv is None else argv)
873
+ if "-h" in argv or "--help" in argv:
874
+ print(__doc__.strip())
875
+ return 0
876
+ i = 0
877
+ while i < len(argv): # a typo must not fall through to the paid step
878
+ if argv[i] in VALUED and i + 1 < len(argv):
879
+ kind = VALUED[argv[i]]
880
+ try:
881
+ kind(argv[i + 1])
882
+ except ValueError:
883
+ need = "a whole number" if kind is int else "a number"
884
+ print(f"Bite: {argv[i]} needs {need}, got {argv[i + 1]!r}.")
885
+ return 2
886
+ i += 2
887
+ elif argv[i] in FLAGS:
888
+ i += 1
889
+ else:
890
+ print(f"Bite: unknown option {argv[i]!r}. Run bite --help for the options.")
891
+ return 2
892
+ opt = lambda k, d: type(d)(argv[argv.index(k) + 1]) if k in argv else d # noqa: E731
893
+ ROAST[0] = "--roast" in argv
894
+ if not shutil.which("git"):
895
+ print("Bite: git isn't installed (or isn't on PATH). Install it from https://git-scm.com, "
896
+ "then open a new terminal window.")
897
+ return 1
898
+ root = repo.repo_root(os.getcwd())
899
+ if root is None:
900
+ print("Bite: not inside a git repository.")
901
+ return 1
902
+ try:
903
+ cfg = repo.load_cfg(root)
904
+ except repo.ConfigError as e:
905
+ print(f"Bite: {e}")
906
+ write_comment(root, [], error=str(e))
907
+ return 1
908
+ cfg.claim_cmd = cfg.free_cmd = ""
909
+ covers = []
910
+ ini = Path(os.environ["BITE_CONFIG"]) if os.environ.get("BITE_CONFIG") else root / ".bite.ini"
911
+ if ini.is_file():
912
+ import configparser
913
+ cp = configparser.ConfigParser(interpolation=None)
914
+ cp.read(ini, encoding="utf-8")
915
+ kit = cp["kit"] if "kit" in cp else {}
916
+ cfg.claim_cmd = kit.get("claim_command", "").strip()
917
+ cfg.free_cmd = kit.get("free_test_command", "").strip()
918
+ covers = [g.strip().replace("\\", "/") for g in kit.get("test_covers", "").split(",") if g.strip()]
919
+ if not cfg.test_cmd:
920
+ print("Bite: no test command found; set test_command in .bite.ini")
921
+ write_comment(root, [], error="Bite found no test command. Set test_command in .bite.ini.")
922
+ return 1
923
+ if not cfg.programs: # the worker may run the test runner and the project's language, nothing new
924
+ first = cfg.test_cmd.split()[0]
925
+ cfg.programs = sorted({first, "python", "python3", "pytest", "node", "npx", "go", "cargo", "dotnet"} - {"npx"})
926
+ missing = preflight(cfg)
927
+ if missing:
928
+ print(f"Bite: {missing}")
929
+ write_comment(root, [], error=missing)
930
+ return 1
931
+ since = opt("--since", "")
932
+ if since and repo.git(root, "rev-parse", "--verify", "-q", f"{since}^{{commit}}") is None:
933
+ print(f"Bite: --since {since} isn't a commit or branch in this repository.")
934
+ write_comment(root, [], error=f"Bite couldn't find {since} (--since) in this repository.")
935
+ return 1
936
+ t0 = time.time()
937
+ cs = commits(root, opt("--last", 40), since) # --all is still accepted: every commit is the default now
938
+ for c in cs:
939
+ files_of(root, c, cfg)
940
+ other = [c for c in cs if c["other_lang"] and not c["code"]]
941
+ cs = [c for c in cs if c["code"]]
942
+ newest = cs[0]["sha"] if cs else "" # git log lists the newest first
943
+ print(f"Bite: {len(cs)} commits changed code in the last {opt('--last', 40)}.", flush=True)
944
+ lang = repo.runs_language(cfg.test_cmd)[0]
945
+ if other:
946
+ print(f" {len(other)} changed only code your tests can't run (they run {lang}), not checked: "
947
+ + ", ".join(c["sha"][:8] for c in other), flush=True)
948
+ if not cs:
949
+ print("Nothing to check. Try a larger --last.")
950
+ write_comment(root, [], none=f"The code changed here isn't {lang}, and your tests only run {lang}, so they "
951
+ "can't notice it." if other else "")
952
+ return 0
953
+ n_out = 0
954
+ if covers: # a change the test command can't reach can't fail a test: that says nothing about the change
955
+ import fnmatch
956
+ out = [c for c in cs if not any(fnmatch.fnmatch(f, g) for f in c["code"] for g in covers)]
957
+ cs = [c for c in cs if c not in out]
958
+ n_out = len(out)
959
+ if out:
960
+ print(f" {len(out)} outside the tests' reach (test_covers), not checked: "
961
+ + ", ".join(c["sha"][:8] for c in out), flush=True)
962
+ if not cs:
963
+ print("Nothing the tests can reach. Check test_covers in .bite.ini.")
964
+ write_comment(root, [], none="The code changed here is outside test_covers in .bite.ini, so the tests can't reach it.")
965
+ return 0
966
+ free_only = "--free" in argv
967
+ if not any(c["claims"] for c in cs) and "--any" not in argv:
968
+ cs = [c for c in cs if c["test_code"]]
969
+ print(f"No fix claims found in the last {opt('--last', 40)} commits, so there's nothing to hold them to"
970
+ + (f". Checking the {len(cs)} that added tests: do those tests check the change?" if cs else
971
+ ". (--any checks them anyway.)"), flush=True)
972
+ if not cs:
973
+ write_comment(root, [], none="No commit in this pull request says it fixes something, so there's no claim to check.")
974
+ return 0
975
+ free_only = True # no claim to turn into a test
976
+ if "--serious" in argv: # only fix claims about data, money, logins, security, crashes or leaks
977
+ cs = [c for c in cs if c.get("serious")]
978
+ print(f" {len(cs)} of them claim a fix about data, money, logins, security, crashes or leaks.")
979
+ if not cs:
980
+ print("Nothing serious to check. Run without --serious to test the other claims.")
981
+ write_comment(root, [], none="No commit claims a fix about data, money, logins, security, crashes or leaks.")
982
+ return 0
983
+ cs.sort(key=lambda c: -c["score"])
984
+ logf = root / "bite_proof" / "recent_log.json"
985
+ try: # read before the free pass saves over it
986
+ kept = json.loads(logf.read_text(encoding="utf-8")).get("tried", [])
987
+ except (OSError, ValueError):
988
+ kept = []
989
+ free = cs[:opt("--free-checks", 6)]
990
+ print(f" free check of {len(free)}: each runs the tests twice (with and without the change)", flush=True)
991
+ for c in free:
992
+ c["undo"] = undo_check(root, c, cfg)
993
+ if c["undo"].startswith("unguarded"):
994
+ c["score"] += 2
995
+ elif c["undo"] == "TEST GUARDS NOTHING":
996
+ c["score"] += 3
997
+ print(f" {c['sha'][:8]} {say(c)[1]:<28} {c['subject'][:70]}", flush=True)
998
+ if c.get("why"): # say what broke, so it's clear whether it's the project or the copy
999
+ print(f" why: {c['why']}", flush=True)
1000
+ save_log(root, kept, cs)
1001
+ cs.sort(key=lambda c: -c["score"])
1002
+ found, tried = [], []
1003
+ before = [] if "--again" in argv else kept # claims tested on an earlier run aren't paid for again
1004
+ done = {r["sha"] for r in before}
1005
+ found = [r for r in before if r.get("status") == "FALSE DONE"]
1006
+ cs = [c for c in cs if c["sha"] not in done]
1007
+ if before:
1008
+ print(f" {len(done)} claims were tested on an earlier run ({len(found)} false); testing the next ones")
1009
+ if not free_only:
1010
+ budget = opt("--budget", 1.0)
1011
+ n = opt("--suspects", 3)
1012
+ for c in cs[:n]:
1013
+ left = budget - SPENT[0]
1014
+ if left < 0.15:
1015
+ print(" budget used up")
1016
+ break
1017
+ print(f" testing the claim of {c['sha'][:8]} ...", flush=True)
1018
+ r = try_claim(root, c, cfg, min(left - 0.05, budget / n + 0.10))
1019
+ tried.append(r)
1020
+ print(f" {r['status']}" + (f": {r.get('today')}" if r["status"] == "FALSE DONE" else ""), flush=True)
1021
+ save_log(root, before + tried, cs)
1022
+ if r["status"] == "FALSE DONE":
1023
+ found.append(r)
1024
+ found.sort(key=lambda r: (r["today"] != "STILL BROKEN", -r["matters"]))
1025
+ print()
1026
+ if found:
1027
+ top = found[0]
1028
+ print(f"FALSE \"DONE\": commit {top['sha'][:8]} said \"{top['subject']}\".")
1029
+ print(f" {top['bug']}")
1030
+ print(f" Today's code: {top['today']}.")
1031
+ print(" The test that shows it:\n" + "\n".join(" " + ln for ln in top["fail"].strip().splitlines()[:10]))
1032
+ d = write_proof(root, found)
1033
+ tried = before + tried
1034
+ print(f"\n{len(found)} false \"done\" in {len(tried)} claims tested. Proof and run commands: {d / 'REPORT.md'}")
1035
+ elif tried or before:
1036
+ tried = before + tried
1037
+ print(f"All {len(tried)} claims tested held up: " + "; ".join(f"{r['sha'][:8]} {r['status']}" for r in tried))
1038
+ flagged = [c for c in cs if c.get("undo") == "TEST GUARDS NOTHING" or c.get("undo", "").startswith("unguarded")]
1039
+ for c in flagged:
1040
+ print(f"{say(c)[1]}: {c['sha'][:8]} \"{c['subject'][:70]}\": {flat(say(c)[2])[2:].strip()}")
1041
+ checked = [c for c in cs if c.get("undo")]
1042
+ held = [c for c in checked if c["undo"].startswith(("guarded", "closed since"))]
1043
+ print(f"\nChecked {len(checked)} commit(s): a test noticed {len(held)}, no test noticed {len(flagged)}, "
1044
+ f"{len(checked) - len(held) - len(flagged)} couldn't be checked or skipped"
1045
+ + (f"; {n_out} outside the tests' reach" if n_out else "")
1046
+ + (f"; {len(other)} in code your tests can't run" if other else "") + ".")
1047
+ if bite_proof([say(c)[0] for c in checked], tried):
1048
+ print(f"\nBite-proof. Nice work. Every change Bite checked had a test that noticed it.\n"
1049
+ f"Badge for your README: {BADGE}")
1050
+ if flagged:
1051
+ print(f"Each change no test noticed, with a check you can rerun and a note for your coding AI: {write_free(root, cfg, flagged)}")
1052
+ print(f"\n{time.time() - t0:.0f}s, ${SPENT[0]:.2f}")
1053
+ save_log(root, tried, cs)
1054
+ write_comment(root, cs, newest=newest)
1055
+ print(f"One-page report (open it in your browser): {write_html(root, cs, tried, time.time() - t0)}")
1056
+ return 0
1057
+
1058
+
1059
+ if __name__ == "__main__":
1060
+ sys.exit(main())