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.
- bite.py +817 -0
- bite_check-0.4.1.dist-info/METADATA +194 -0
- bite_check-0.4.1.dist-info/RECORD +9 -0
- bite_check-0.4.1.dist-info/WHEEL +5 -0
- bite_check-0.4.1.dist-info/entry_points.txt +2 -0
- bite_check-0.4.1.dist-info/licenses/LICENSE +21 -0
- bite_check-0.4.1.dist-info/top_level.txt +3 -0
- recent.py +1060 -0
- repo.py +104 -0
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 = "[](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} · did the recent "fixed" commits hold? · {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())
|