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
bite.py
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bite's engine for recent.py: reading the settings, running a project's tests (optionally in a Docker box),
|
|
3
|
+
copying a project safely, and the command filter that keeps the claim-checking worker inside its copy.
|
|
4
|
+
You shouldn't need to edit this file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import configparser
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
import shlex
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
import subprocess
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
KIT_DIR = Path(__file__).resolve().parent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
for _s in (sys.stdout, sys.stderr): # a Windows console or log file can't print every character a worker writes
|
|
43
|
+
try:
|
|
44
|
+
_s.reconfigure(errors="replace")
|
|
45
|
+
except (AttributeError, ValueError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
CONFIG_FILE = Path(os.environ["BITE_CONFIG"]).resolve() if os.environ.get("BITE_CONFIG") else KIT_DIR / "bite_config.ini"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
KEY_FILE = KIT_DIR / "key.txt"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _list(value: str) -> list:
|
|
56
|
+
return [v.strip() for v in re.split(r"[,\n]", value or "") if v.strip()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
DEFAULT_FAIL = r"\bFAIL|SCRIPT ERROR|SHADER ERROR|Parse Error|parse error|ignoring script|Traceback"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Config:
|
|
63
|
+
def __init__(self, path: Path = CONFIG_FILE):
|
|
64
|
+
if not path.exists():
|
|
65
|
+
raise SystemExit(f"No {path.name} found. Settings are optional: run bite in your repo "
|
|
66
|
+
"to guess the test command, or create .bite.ini there with a [kit] section.")
|
|
67
|
+
cp = configparser.ConfigParser(interpolation=None)
|
|
68
|
+
cp.read(path, encoding="utf-8")
|
|
69
|
+
g = cp["kit"] if "kit" in cp else {}
|
|
70
|
+
self.project = Path(os.path.expandvars(os.path.expanduser(g.get("project_folder", "")))).resolve()
|
|
71
|
+
mb = g.get("mailbox_folder", "") or str(KIT_DIR / "mailbox")
|
|
72
|
+
self.mailbox = Path(os.path.expandvars(os.path.expanduser(mb))).resolve()
|
|
73
|
+
self.about = g.get("about_project", "").strip()
|
|
74
|
+
self.model = g.get("model", "sonnet").strip() or "sonnet"
|
|
75
|
+
self.budget = float(g.get("budget_per_run_usd", "3.00"))
|
|
76
|
+
self.job_budget = float(g.get("budget_per_job_usd", "1.00"))
|
|
77
|
+
self.max_turns = int(g.get("max_turns_per_job", "30"))
|
|
78
|
+
self.poll = float(g.get("poll_seconds", "5"))
|
|
79
|
+
self.programs = _list(g.get("allowed_programs", ""))
|
|
80
|
+
self.secrets = [s.lower() for s in _list(g.get("secret_files", "key.txt, .env"))]
|
|
81
|
+
self.protected = [p.lower() for p in _list(g.get("protected_files", ""))]
|
|
82
|
+
self.extra_rules = g.get("extra_rules", "").strip()
|
|
83
|
+
self.org_id = g.get("expected_org_id", "").strip()
|
|
84
|
+
self.test_cmd = g.get("test_command", "").strip()
|
|
85
|
+
self.fail_pattern = g.get("test_fail_pattern", "").strip() or DEFAULT_FAIL
|
|
86
|
+
self.count_pattern = g.get("test_count_pattern", "").strip()
|
|
87
|
+
self.test_glob = _list(g.get("test_files", "")) or DEFAULT_TEST_FILES
|
|
88
|
+
self.test_timeout = int(g.get("test_timeout_seconds", "600"))
|
|
89
|
+
self.copy_ignore = _list(g.get("bite_copy_ignore", "")) or ["__pycache__", "node_modules"]
|
|
90
|
+
self.bite_prepare = g.get("bite_prepare_command", "").strip()
|
|
91
|
+
lk = g.get("heavy_lock_folder", "").strip()
|
|
92
|
+
self.lock_dir = Path(os.path.expandvars(os.path.expanduser(lk))).resolve() if lk else None
|
|
93
|
+
self.lane = g.get("proof_lane", "cpu").strip() or "cpu"
|
|
94
|
+
self.look_cmd = g.get("look_command", "").strip()
|
|
95
|
+
self.look_lane = g.get("look_lane", "gpu").strip() or "gpu"
|
|
96
|
+
self.judge_model = g.get("judge_model", "sonnet").strip() or "sonnet"
|
|
97
|
+
self.judge_budget = float(g.get("judge_budget_usd", "0.25"))
|
|
98
|
+
self.proofs = dict(cp["proofs"]) if "proofs" in cp else {}
|
|
99
|
+
self.sandbox = g.get("sandbox", "copy").strip().lower() != "off"
|
|
100
|
+
self.sandbox_ignore = _list(g.get("sandbox_ignore", "")) or ["__pycache__", "node_modules"]
|
|
101
|
+
self.apply_when = g.get("apply_when", "pass").strip().lower() or "pass"
|
|
102
|
+
self.review_only = _list(g.get("review_only_files", "")) or REVIEW_ONLY_DEFAULT
|
|
103
|
+
self.contain = g.get("contain_command", "").strip()
|
|
104
|
+
self.box_image = g.get("box_image", "python:3.12").strip() or "python:3.12"
|
|
105
|
+
self.box_setup = g.get("box_setup_command", "").strip()
|
|
106
|
+
self.box_test_cmd = g.get("box_test_command", "").strip()
|
|
107
|
+
self.retries = int(g.get("retries_per_job", "2") or 0)
|
|
108
|
+
self.mutants = int(g.get("planted_bugs", "4") or 0)
|
|
109
|
+
self.mutant_seconds = int(g.get("planted_bugs_seconds", "300") or 300)
|
|
110
|
+
self.repeats = int(g.get("repeat_runs", "0") or 0)
|
|
111
|
+
self.ok_codes = {int(x) for x in _list(g.get("ok_exit_codes", "0")) if x.strip().lstrip("-").isdigit()} or {0}
|
|
112
|
+
self.day_budget = float(g.get("budget_per_day_usd", "0") or 0)
|
|
113
|
+
self.sync_before = g.get("sync_before", "").strip()
|
|
114
|
+
self.sync_after = g.get("sync_after", "").strip()
|
|
115
|
+
self.sync_seconds = float(g.get("sync_seconds", "60") or 60)
|
|
116
|
+
self.facts_file = g.get("facts_file", "").strip()
|
|
117
|
+
n = cp["night"] if "night" in cp else {}
|
|
118
|
+
self.intent_file = n.get("intent_file", "").strip()
|
|
119
|
+
self.crowd_cmd = n.get("crowd_command", "").strip()
|
|
120
|
+
self.personas = _list(n.get("personas", "")) or ["speedrunner", "first_timer", "masher", "quitter"]
|
|
121
|
+
self.crowd_seeds = int(n.get("crowd_seeds", "20") or 20)
|
|
122
|
+
self.boredom_seconds = float(n.get("boredom_seconds", "45") or 45)
|
|
123
|
+
self.immune_folder = (n.get("immune_folder", "").strip() or guess_immune(self.project)).strip("/")
|
|
124
|
+
self.night_fixes = int(n.get("max_fixes", "5") or 5)
|
|
125
|
+
self.night_budget = float(n.get("budget_per_night_usd", "0") or 0) or self.budget
|
|
126
|
+
self.night_without_box = n.get("night_without_box", "no").strip().lower() in ("yes", "true", "1", "on")
|
|
127
|
+
self.run_without_box = g.get("run_without_box", "no").strip().lower() in ("yes", "true", "1", "on")
|
|
128
|
+
# persona: event it lives for, e.g. humor_hunter: funny (the hook logs a "funny" event per joke shown)
|
|
129
|
+
self.seeks = {k.strip(): v.strip() for k, v in (x.split(":", 1) for x in _list(n.get("persona_seeks", "")) if ":" in x)}
|
|
130
|
+
self.seek_seconds = float(n.get("seek_seconds", "120") or 120)
|
|
131
|
+
pn = cp["panel"] if "panel" in cp else {}
|
|
132
|
+
self.panel = _list(pn.get("members", ""))
|
|
133
|
+
self.panel_files = _list(pn.get("material_files", ""))
|
|
134
|
+
self.panel_cmd = pn.get("material_command", "").strip()
|
|
135
|
+
self.panel_stills = pn.get("stills_folder", "").strip()
|
|
136
|
+
self.panel_taste = pn.get("taste_file", "").strip()
|
|
137
|
+
self.panel_briefs = {k[6:].strip(): v.strip() for k, v in pn.items() if k.startswith("brief ")}
|
|
138
|
+
self.scratch = _list(g.get("scratch_files", "")) or [
|
|
139
|
+
"__pycache__/*", "*.pyc", ".pytest_cache/*", "node_modules/*", ".godot/*", "*.import", "*.log",
|
|
140
|
+
"*.tmp", ".cache/*", "build/*", "dist/*", "target/*", "bin/*", "obj/*", ".hypothesis/*", ".mypy_cache/*",
|
|
141
|
+
".ruff_cache/*", ".tox/*", ".nox/*", ".coverage", ".coverage.*", "htmlcov/*", ".nyc_output/*", "coverage/*", "coverage.xml", "coverage.json", "lcov.info", "*.lcov", "junit*.xml", "test-results/*"]
|
|
142
|
+
self.test_setup = _list(g.get("test_setup_files", "")) or [
|
|
143
|
+
"conftest.py", "pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml", "package.json",
|
|
144
|
+
"run_tests*", "*test_runner*", "*testrunner*", "tests/helpers/*", "test/helpers/*"]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def inbox(self):
|
|
148
|
+
return self.mailbox / "to_agent"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def outbox(self):
|
|
152
|
+
return self.mailbox / "from_agent"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
SAFE_COMMANDS = {
|
|
156
|
+
"cd", "ls", "dir", "cat", "head", "tail", "grep", "egrep", "rg", "wc", "echo", "printf", "pwd",
|
|
157
|
+
"date", "sleep", "which", "where", "test", "sort", "uniq", "diff", "file", "stat", "tr", "cut",
|
|
158
|
+
"basename", "dirname", "true", "tasklist", "find", "du", "realpath", "readlink", "type",
|
|
159
|
+
"for", "do", "done", "then", "fi", "if", "else",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
REVIEW_ONLY_DEFAULT = [ # files that can run code or steer agents: a person looks first (any depth)
|
|
164
|
+
".claude/*", "CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", ".mcp.json", ".cursor/*", ".github/*",
|
|
165
|
+
".gitlab-ci.yml", ".circleci/*", ".vscode/*", ".idea/*", ".husky/*", ".githooks/*",
|
|
166
|
+
".pre-commit-config.yaml", ".gitattributes", ".gitmodules", ".envrc", ".npmrc", ".yarnrc*",
|
|
167
|
+
".pypirc", "Makefile", "*.mk", "Dockerfile", "docker-compose*", "sitecustomize.py",
|
|
168
|
+
"usercustomize.py", "*.pth", "*.bat", "*.cmd", "*.ps1", "*.sh", "*.desktop", "*.lnk"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
SAFE_GIT = {"status", "log", "diff", "show", "ls-files", "rev-parse", "blame", "shortlog", "describe", "grep", "branch"}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
DANGER = re.compile(
|
|
175
|
+
r"(\brm\b|\brmdir\b|\bdel\b|\brd\b|\bmv\b|(?<![-=%])\bformat\b|\bshutdown\b|\btaskkill\b|\bsudo\b"
|
|
176
|
+
r"|\bcurl\b|\bwget\b|\bpip3?\b|\bnpm\b|\bnpx\b|\bwinget\b|\bchoco\b"
|
|
177
|
+
r"|\bgit\s+(push|reset|clean|rebase|checkout|restore|remote|config|commit|add|stash|merge|pull)"
|
|
178
|
+
r"|--force|--hard|Remove-Item|rmtree|os\.remove|os\.unlink|shutil\.|\bsubprocess\b"
|
|
179
|
+
r"|\bfind\b[^|;&]*(-delete|-exec|-ok|-fprint|-fls)"
|
|
180
|
+
r"|--output|--to-file|--ext-diff|--pre\b|--set\b|\bsort\b[^|;&]*\s-\w*o"
|
|
181
|
+
r"|\bdate\b[^|;&]*\s-s\b|(?-i:\bgit\s+(-\S+\s+)*-[cp]\b)|--compress-program|--open-files-in-pager|--paginate|(?-i:\s-O\S*)|/proc\b|/dev/(tcp|udp)\b)",
|
|
182
|
+
re.IGNORECASE,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def split_commands(s: str) -> list:
|
|
187
|
+
"""Split on && || ; | and newlines, but not inside quotes."""
|
|
188
|
+
parts, cur, q, i = [], "", "", 0
|
|
189
|
+
while i < len(s):
|
|
190
|
+
c = s[i]
|
|
191
|
+
if q:
|
|
192
|
+
cur += c
|
|
193
|
+
if c == q:
|
|
194
|
+
q = ""
|
|
195
|
+
elif c in "'\"":
|
|
196
|
+
q = c
|
|
197
|
+
cur += c
|
|
198
|
+
elif c in "&|" and s[i:i + 2] in ("&&", "||"):
|
|
199
|
+
parts.append(cur)
|
|
200
|
+
cur = ""
|
|
201
|
+
i += 1
|
|
202
|
+
elif c in ";|&\n":
|
|
203
|
+
parts.append(cur)
|
|
204
|
+
cur = ""
|
|
205
|
+
else:
|
|
206
|
+
cur += c
|
|
207
|
+
i += 1
|
|
208
|
+
parts.append(cur)
|
|
209
|
+
return parts
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
ENV_PEEK = re.compile(r"environ|\{[^}\s]*(,|\.\.)[^}\s]*\}", re.I) # /proc/self/environ, {k,x}ey.txt
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def unquoted(cmd: str) -> str:
|
|
216
|
+
"""The command with the inside of each quoted word blanked, except words that are options
|
|
217
|
+
("--output=x" stays visible, so quoting a flag hides nothing)."""
|
|
218
|
+
def keep(m):
|
|
219
|
+
inner = m.group(0)[1:-1]
|
|
220
|
+
return m.group(0) if inner.lstrip().startswith("-") or "/proc" in inner else '""'
|
|
221
|
+
return re.sub(r"'[^']*'|\"[^\"]*\"", keep, cmd)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def var_refs_ok(cmd: str) -> bool:
|
|
225
|
+
"""$NAME is refused (it could print the API key or reach another folder), except the
|
|
226
|
+
variable of a `for NAME in ...` loop in the same command. Inside '...' nothing expands."""
|
|
227
|
+
loop_vars = set(re.findall(r"\bfor\s+([A-Za-z_]\w*)\s+in\b", cmd))
|
|
228
|
+
q, i = "", 0
|
|
229
|
+
while i < len(cmd):
|
|
230
|
+
c = cmd[i]
|
|
231
|
+
if q == "'":
|
|
232
|
+
q = "" if c == "'" else q
|
|
233
|
+
elif c == "'" and not q:
|
|
234
|
+
q = "'"
|
|
235
|
+
elif c == '"':
|
|
236
|
+
q = "" if q == '"' else '"'
|
|
237
|
+
elif c == "\\":
|
|
238
|
+
i += 1
|
|
239
|
+
elif c == "$" and not q and i + 1 < len(cmd) and cmd[i + 1] in "'\"":
|
|
240
|
+
return False # $'\x6bey.txt' spells key.txt
|
|
241
|
+
elif c == "$" and i + 1 < len(cmd) and re.match(r"[A-Za-z0-9_{(!@#*?$-]", cmd[i + 1]):
|
|
242
|
+
m = re.match(r"\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))", cmd[i:])
|
|
243
|
+
if not m or (m.group(1) or m.group(2)) not in loop_vars:
|
|
244
|
+
return False
|
|
245
|
+
i += 1
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def path_ok(tok: str, here: Path, cfg: Config) -> bool:
|
|
250
|
+
"""A word that names a path must stay inside the project (the job's copy of it)."""
|
|
251
|
+
if tok.startswith("-"):
|
|
252
|
+
if "=" not in tok:
|
|
253
|
+
return True
|
|
254
|
+
tok = tok.split("=", 1)[1]
|
|
255
|
+
elif "=" in tok and not path_ok(tok.split("=", 1)[1], here, cfg):
|
|
256
|
+
return False # -o cache_dir=/anywhere: the value after = can be a path too
|
|
257
|
+
if not tok:
|
|
258
|
+
return True
|
|
259
|
+
if tok.startswith("~"):
|
|
260
|
+
return False
|
|
261
|
+
base = re.split(r"[*?\[]", tok, maxsplit=1)[0] or "."
|
|
262
|
+
globbed = base != tok
|
|
263
|
+
if globbed and any(c == ".." or (c.startswith(".") and fnmatch.fnmatchcase("..", c)) for c in re.split(r"[/\\]", tok)):
|
|
264
|
+
return False # */../../x or .*/x (.* matches ..): the part before the pattern is inside, the pattern climbs out
|
|
265
|
+
if re.match(r"^[A-Za-z]:[\\/]?", base) or base.startswith(("/", "\\")):
|
|
266
|
+
if not re.match(r"^[A-Za-z]:", base) or WINDOWS:
|
|
267
|
+
target = Path(base)
|
|
268
|
+
if inside(cfg.project, str(target)) is not None:
|
|
269
|
+
return True
|
|
270
|
+
if globbed:
|
|
271
|
+
return False # /pro[c]/... or /e*/passwd: a pattern aimed outside the project
|
|
272
|
+
first = Path(target.anchor) / target.parts[1] if len(target.parts) > 1 else Path(target.anchor)
|
|
273
|
+
return not first.exists() # "/api/" is a search word; "/etc/x" and "C:\Users" are places
|
|
274
|
+
return False
|
|
275
|
+
if not re.search(r"[/\\]", base) and base not in ("..", "."):
|
|
276
|
+
return True # a plain name in the current folder (or a search word)
|
|
277
|
+
return inside(cfg.project, str(here / base)) is not None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def segment_ok(seg: str, cfg: Config, here: list) -> bool:
|
|
281
|
+
try:
|
|
282
|
+
toks = shlex.split(seg.strip(), posix=True)
|
|
283
|
+
except ValueError:
|
|
284
|
+
return False
|
|
285
|
+
if toks and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[0]):
|
|
286
|
+
return False # GIT_PAGER=, GIT_EXTERNAL_DIFF=, LD_PRELOAD=... turn any tool into "run this"
|
|
287
|
+
while toks and toks[0] in ("do", "then", "else"): # before timeout: "do timeout 580 ..."
|
|
288
|
+
toks.pop(0)
|
|
289
|
+
if toks and toks[0] == "timeout":
|
|
290
|
+
toks = toks[1:]
|
|
291
|
+
if toks and re.match(r"^\d+[smh]?$", toks[0]):
|
|
292
|
+
toks = toks[1:]
|
|
293
|
+
if not toks:
|
|
294
|
+
return True
|
|
295
|
+
for i, t in enumerate(toks):
|
|
296
|
+
low = t.lower()
|
|
297
|
+
if any(sec in low for sec in cfg.secrets) or is_secret(Path(low.replace("\\", "/")).name, cfg):
|
|
298
|
+
return False # catches 'key'.txt and key''.txt once the quotes are gone
|
|
299
|
+
glob = Path(low.replace("\\", "/")).name
|
|
300
|
+
if re.search(r"[*?\[]", glob):
|
|
301
|
+
lead = re.split(r"[*?\[]", glob, maxsplit=1)[0]
|
|
302
|
+
if any(fnmatch.fnmatch(sec, glob) and (lead or not cfg.sandbox) for sec in cfg.secrets):
|
|
303
|
+
return False # k*.txt aims at key.txt (and with no copy, even *.txt could reach it)
|
|
304
|
+
if i and not path_ok(t, here[0], cfg):
|
|
305
|
+
return False # the program itself is checked below; its arguments must stay inside
|
|
306
|
+
head = toks[0].replace("\\", "/")
|
|
307
|
+
name = head.rsplit("/", 1)[-1]
|
|
308
|
+
if name == "cd":
|
|
309
|
+
if len(toks) != 2:
|
|
310
|
+
return False # bare cd goes home, cd - goes back out
|
|
311
|
+
new = inside(cfg.project, str(here[0] / toks[1]))
|
|
312
|
+
if new is None:
|
|
313
|
+
return False
|
|
314
|
+
here[0] = new
|
|
315
|
+
return True
|
|
316
|
+
if name == "uniq" and len([t for t in toks[1:] if not t.startswith("-")]) > 1:
|
|
317
|
+
return False # uniq IN OUT writes OUT
|
|
318
|
+
if name in SAFE_COMMANDS:
|
|
319
|
+
return True
|
|
320
|
+
if name in ("git", "git.exe"):
|
|
321
|
+
args = toks[1:]
|
|
322
|
+
while len(args) > 1 and args[0] == "-C": # git -C sub status: sub was path-checked above
|
|
323
|
+
args = args[2:]
|
|
324
|
+
rest = [t for t in args if not t.startswith("-") and "=" not in t]
|
|
325
|
+
if rest and rest[0] == "branch":
|
|
326
|
+
return all(t in ("branch", "-a", "-r", "-v", "-vv", "--list", "--show-current", "--all") for t in args)
|
|
327
|
+
return bool(rest) and rest[0] in SAFE_GIT
|
|
328
|
+
if name.lower() in ("python", "python3", "py", "bash", "sh", "cmd", "powershell", "pwsh"):
|
|
329
|
+
return False # no scripts: they could do anything
|
|
330
|
+
return any(fnmatch.fnmatch(name.lower(), p.lower()) for p in cfg.programs)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def command_ok(cmd: str, cfg: Config) -> bool:
|
|
334
|
+
if any(t in cmd for t in ("<<", ">", "`", "$(")):
|
|
335
|
+
return False # no heredocs, redirects or command substitution
|
|
336
|
+
low = cmd.lower()
|
|
337
|
+
if any(s in low for s in cfg.secrets):
|
|
338
|
+
return False
|
|
339
|
+
bare = unquoted(cmd) # "rm" or "format(" inside quotes is a search word, not a command
|
|
340
|
+
if DANGER.search(bare) or ENV_PEEK.search(bare) or not var_refs_ok(cmd):
|
|
341
|
+
return False
|
|
342
|
+
here = [cfg.project]
|
|
343
|
+
return all(segment_ok(seg, cfg, here) for seg in split_commands(cmd))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def uses_program(cmd: str) -> bool:
|
|
347
|
+
"""True if the command runs an allowed program, not just a read-only built-in or git."""
|
|
348
|
+
for seg in split_commands(cmd):
|
|
349
|
+
try:
|
|
350
|
+
toks = shlex.split(seg.strip(), posix=True)
|
|
351
|
+
except ValueError:
|
|
352
|
+
return True
|
|
353
|
+
while toks and (toks[0] in ("do", "then", "else", "timeout") or re.match(r"^\d+[smh]?$", toks[0])):
|
|
354
|
+
toks.pop(0)
|
|
355
|
+
if toks and toks[0].replace("\\", "/").rsplit("/", 1)[-1] not in SAFE_COMMANDS | {"git", "git.exe"}:
|
|
356
|
+
return True
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def guess_immune(project: Path) -> str:
|
|
361
|
+
"""Where the night's immune tests go when the config doesn't say: inside the folder the project's
|
|
362
|
+
own test run collects (a project with testpaths = test never sees tests/immune)."""
|
|
363
|
+
for name, pat in (("pytest.ini", r"(?ms)^\[pytest\].*?^testpaths\s*=\s*([^\s#;]+)"),
|
|
364
|
+
("pyproject.toml", r"(?ms)^\[tool\.pytest\.ini_options\].*?^testpaths\s*=\s*\[?\s*[\"']?([^\"'\s,\]]+)"),
|
|
365
|
+
("tox.ini", r"(?ms)^\[pytest\].*?^testpaths\s*=\s*([^\s#;]+)"),
|
|
366
|
+
("setup.cfg", r"(?ms)^\[tool:pytest\].*?^testpaths\s*=\s*([^\s#;]+)")):
|
|
367
|
+
try:
|
|
368
|
+
m = re.search(pat, (project / name).read_text(encoding="utf-8", errors="replace"))
|
|
369
|
+
except OSError:
|
|
370
|
+
continue
|
|
371
|
+
if m and (project / m.group(1)).is_dir():
|
|
372
|
+
return m.group(1).strip("./").rstrip("/") + "/immune"
|
|
373
|
+
if not (project / "tests").is_dir() and (project / "test").is_dir():
|
|
374
|
+
return "test/immune"
|
|
375
|
+
return "tests/immune"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def program_wrapper(cfg: Config, cmd: str) -> str:
|
|
379
|
+
"""The worker's program runs under the kit: no API key in its environment, killed with
|
|
380
|
+
everything it started if it overruns, and one per lane when a heavy-run lock is set."""
|
|
381
|
+
secs = max(30, min(cfg.test_timeout, 590)) # the Bash tool moves runs past 600 s to the background
|
|
382
|
+
return f"{shlex.quote(sys.executable)} {shlex.quote(str(KIT_DIR / 'bite.py'))} --run {secs} -- {shlex.quote(cmd)}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def contain(cfg: Config, cmd: str, folder: Path, posix: bool | None = None) -> str:
|
|
386
|
+
"""With contain_command set, a command that runs project code goes into that box
|
|
387
|
+
(for example a container with no network and only the job's copy mounted).
|
|
388
|
+
posix: the command is handed to a bash-like shell (the worker's), not cmd.exe."""
|
|
389
|
+
if not cfg.contain:
|
|
390
|
+
return cmd
|
|
391
|
+
if posix is None:
|
|
392
|
+
posix = not WINDOWS
|
|
393
|
+
quoted = shlex.quote(cmd) if posix else '"' + cmd.replace('"', '\\"') + '"'
|
|
394
|
+
box = cfg.contain
|
|
395
|
+
if box.lstrip().startswith("docker run ") and "--name" not in box and "--rm" in box: # without --rm, a reused name would clash
|
|
396
|
+
# Named, so a timeout can remove it: killing the docker client leaves the box running,
|
|
397
|
+
# and a stuck box's test process then blocks every later proof ("one at a time").
|
|
398
|
+
_BOX_N[0] += 1
|
|
399
|
+
box = box.replace("docker run ", f"docker run --name bite-{os.getpid()}-{_BOX_N[0]} ", 1)
|
|
400
|
+
return box.replace("{project}", str(folder)).replace("{cmd}", quoted)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
_BOX_N = [0]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def inside(folder: Path, file_path: str) -> Path | None:
|
|
407
|
+
try:
|
|
408
|
+
target = Path(folder, file_path).resolve()
|
|
409
|
+
target.relative_to(folder.resolve())
|
|
410
|
+
return target
|
|
411
|
+
except (ValueError, OSError):
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def kit_owned(target: Path) -> bool:
|
|
416
|
+
try:
|
|
417
|
+
target.relative_to(KIT_DIR)
|
|
418
|
+
return True
|
|
419
|
+
except ValueError:
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def edit_ok(file_path: str, cfg: Config) -> bool:
|
|
424
|
+
target = inside(cfg.project, file_path)
|
|
425
|
+
if target is None or kit_owned(target):
|
|
426
|
+
return False
|
|
427
|
+
name = target.name.lower()
|
|
428
|
+
if ".git" in target.parts or is_secret(name, cfg) or name in cfg.protected:
|
|
429
|
+
return False
|
|
430
|
+
if not cfg.sandbox and review_only(target.relative_to(cfg.project.resolve()).as_posix(), cfg):
|
|
431
|
+
return False # no copy to hold it for review, so it is not written at all
|
|
432
|
+
return name not in ("bite.py", "bite_config.ini", "key.txt")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def review_only(rel: str, cfg: Config) -> bool:
|
|
436
|
+
parts = rel.replace("\\", "/").lower().split("/")
|
|
437
|
+
for pat in (p.lower() for p in cfg.review_only):
|
|
438
|
+
if pat.endswith("/*") and pat[:-2] in parts[:-1]:
|
|
439
|
+
return True # .claude/* also catches sub/.claude/settings.json
|
|
440
|
+
if fnmatch.fnmatch("/".join(parts), pat) or fnmatch.fnmatch(parts[-1], pat):
|
|
441
|
+
return True
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def is_secret(name: str, cfg: Config) -> bool:
|
|
446
|
+
"""Secret names match in any case, and .env also covers .env.local and friends."""
|
|
447
|
+
n = name.lower()
|
|
448
|
+
return any(n == s or n.startswith(s + ".") or fnmatch.fnmatch(n, s) for s in cfg.secrets)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def read_ok(tool_input: dict, cfg: Config) -> bool:
|
|
452
|
+
p = tool_input.get("file_path") or tool_input.get("path") or str(cfg.project)
|
|
453
|
+
names = [p, tool_input.get("glob") or ""] # a search WORD may mention .env; a path or glob may not
|
|
454
|
+
if any(is_secret(Path(n.replace("\\", "/").lower()).name, cfg) or any(s in n.lower() for s in cfg.secrets)
|
|
455
|
+
for n in names if n):
|
|
456
|
+
return False
|
|
457
|
+
if ".git" in Path(p).parts:
|
|
458
|
+
return False # git internals (and any hidden tests kept there) stay unseen
|
|
459
|
+
return inside(cfg.project, p) is not None # the job text is in the prompt; the mailbox stays shut
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
JOB_EDITS = {} # path -> the file's bytes before the agent first touched it (None = it was new)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
RUN_BASE = {} # (project, relative path) -> the file as it was the first time a worker touched it this run
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def remember_original(cfg: Config, file_path: str):
|
|
469
|
+
target = inside(cfg.project, file_path)
|
|
470
|
+
if target is not None and str(target) not in JOB_EDITS:
|
|
471
|
+
JOB_EDITS[str(target)] = target.read_bytes() if target.is_file() else None
|
|
472
|
+
RUN_BASE.setdefault((str(getattr(cfg, "origin", cfg.project)), target.relative_to(cfg.project).as_posix()),
|
|
473
|
+
JOB_EDITS[str(target)])
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def refusal_hint(tool: str, cmd: str, cfg: Config) -> str:
|
|
477
|
+
"""Say what to use instead, so a refusal costs one turn, not five."""
|
|
478
|
+
if tool != "Bash":
|
|
479
|
+
return f"{tool} isn't available to workers. Use Read/Glob/Grep to look and Edit/Write inside the project."
|
|
480
|
+
if any(t in cmd for t in (">", "<<")):
|
|
481
|
+
return "No redirects or heredocs: write files with the Edit or Write tool."
|
|
482
|
+
if "$" in cmd:
|
|
483
|
+
return "No variables ($NAME): write the value out, or use a for loop's own variable."
|
|
484
|
+
if re.search(r"(^|\s)(~|/|\.\.|[A-Za-z]:\\)", cmd):
|
|
485
|
+
return "Paths must stay inside the project folder (your working directory)."
|
|
486
|
+
if re.match(r"\s*(python3?|py|bash|sh|cmd|powershell|pwsh|node)\b", cmd):
|
|
487
|
+
return ("No scripts. To check behaviour, write a test file and let the kit run the tests"
|
|
488
|
+
+ (f", or run an allowed program ({', '.join(cfg.programs)})." if cfg.programs else "."))
|
|
489
|
+
if re.search(r"\bgit\b", cmd):
|
|
490
|
+
return "Only read-only git (status, log, diff, show, blame, grep, ls-files). The planners commit."
|
|
491
|
+
if re.search(r"\b(rm|del|mv|cp)\b", cmd):
|
|
492
|
+
return "No deleting or moving files. Edit or Write a file instead; leave deletions to the person."
|
|
493
|
+
return ("Only read-only commands (ls, cat, grep, find, head, wc, diff, sort...), read-only git"
|
|
494
|
+
+ (f" and {', '.join(cfg.programs)}" if cfg.programs else "") + " work.")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def make_permission(cfg: Config, log=print):
|
|
498
|
+
async def permission(tool_name, tool_input, context):
|
|
499
|
+
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|
500
|
+
if tool_name in ("Read", "Glob", "Grep"):
|
|
501
|
+
if read_ok(tool_input, cfg):
|
|
502
|
+
return PermissionResultAllow(updated_input=tool_input)
|
|
503
|
+
log(f" [REFUSED] {tool_name} outside the project or a secret file")
|
|
504
|
+
return PermissionResultDeny(message="Only files inside the project may be read, and never secret files.")
|
|
505
|
+
if tool_name in ("Edit", "Write", "NotebookEdit") and edit_ok(
|
|
506
|
+
tool_input.get("file_path") or tool_input.get("notebook_path") or "", cfg):
|
|
507
|
+
log(f" [edit] {tool_input.get('file_path', '')}")
|
|
508
|
+
remember_original(cfg, tool_input.get("file_path") or tool_input.get("notebook_path") or "")
|
|
509
|
+
return PermissionResultAllow(updated_input=tool_input)
|
|
510
|
+
if tool_name in ("TodoWrite",):
|
|
511
|
+
return PermissionResultAllow(updated_input=tool_input)
|
|
512
|
+
if tool_name == "Bash" and tool_input.get("run_in_background"):
|
|
513
|
+
log(" [REFUSED] background command")
|
|
514
|
+
return PermissionResultDeny(message="Background commands are refused. Run it in the foreground and wait.")
|
|
515
|
+
if tool_name == "Bash" and cfg.test_cmd and tool_input.get("command", "").strip() == "bite-test":
|
|
516
|
+
log(" [run] bite-test (the kit's own test command)")
|
|
517
|
+
command = tests_cmd(cfg).replace("{project}", str(cfg.project)).replace("{seed}", SEED[0])
|
|
518
|
+
if cfg.contain: # run by the kit's --run, so quoted for this OS's shell (cmd.exe on Windows)
|
|
519
|
+
command = contain(cfg, command, cfg.project)
|
|
520
|
+
return PermissionResultAllow(updated_input={**tool_input, "command": program_wrapper(cfg, command)})
|
|
521
|
+
if tool_name == "Bash" and command_ok(tool_input.get("command", ""), cfg):
|
|
522
|
+
first = (tool_input.get("command", "").strip().splitlines() or [""])[0]
|
|
523
|
+
log(f" [run] {first[:110]}")
|
|
524
|
+
command = tool_input["command"]
|
|
525
|
+
wrapped = uses_program(tool_input["command"])
|
|
526
|
+
if cfg.contain: # with a box, every worker command runs in it
|
|
527
|
+
# a wrapped command ends up in this OS's shell (cmd.exe on Windows), the rest in the
|
|
528
|
+
# worker's bash, where Git Bash on Windows would turn -w /work into a Windows path
|
|
529
|
+
command = contain(cfg, command, cfg.project, posix=None if wrapped else True)
|
|
530
|
+
if WINDOWS and not wrapped:
|
|
531
|
+
command = "MSYS_NO_PATHCONV=1 " + command
|
|
532
|
+
if wrapped:
|
|
533
|
+
command = program_wrapper(cfg, command)
|
|
534
|
+
if command != tool_input["command"]:
|
|
535
|
+
tool_input = {**tool_input, "command": command}
|
|
536
|
+
return PermissionResultAllow(updated_input=tool_input)
|
|
537
|
+
target = tool_input.get("command") or tool_input.get("file_path") or tool_input.get("url") or ""
|
|
538
|
+
log(f" [REFUSED] {tool_name}: {str(target)[:110]}")
|
|
539
|
+
return PermissionResultDeny(message="Refused. " + refusal_hint(tool_name, str(target), cfg) +
|
|
540
|
+
" Don't work around it; if it's truly needed, say in your report what the "
|
|
541
|
+
"person would need to run.")
|
|
542
|
+
return permission
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
DEFAULT_TEST_FILES = ["test_*", "*_test.*", "test.*", "tests.*", "*.test.*", "*.spec.*", "*_spec.*",
|
|
546
|
+
"tests/*", "test/*", "__tests__/*", "spec/*",
|
|
547
|
+
"*tests.cs", "*test.cs", "*tests.fs", "*test.fs", "*.tests/*", "*.test/*"] # C#/F# (.NET) names
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def is_test_file(rel: str, cfg: Config) -> bool:
|
|
551
|
+
"""A pattern without a slash names a file (test_*); one with a slash names a folder (tests/*), which
|
|
552
|
+
must be a whole folder name, so src/testkit/core.py and src/testfixtures/x.py stay code and get the proof."""
|
|
553
|
+
rel = rel.replace("\\", "/").lower()
|
|
554
|
+
name = rel.rsplit("/", 1)[-1]
|
|
555
|
+
for g in (g.lower().replace("\\", "/") for g in cfg.test_glob):
|
|
556
|
+
if "/" not in g:
|
|
557
|
+
if fnmatch.fnmatchcase(name, g):
|
|
558
|
+
return True
|
|
559
|
+
elif any(fnmatch.fnmatchcase(tail, g) for tail in
|
|
560
|
+
["/".join(rel.split("/")[i:]) for i in range(rel.count("/") + 1)]):
|
|
561
|
+
return True
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
WINDOWS = os.name == "nt"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def run_cmd(cmd: str, cwd: Path, timeout: int):
|
|
569
|
+
"""Run one command; on timeout kill it AND its children by PID (a killed shell would
|
|
570
|
+
otherwise leave the game engine running). Returns (exit code or None on timeout, output)."""
|
|
571
|
+
flags = subprocess.CREATE_NEW_PROCESS_GROUP if WINDOWS else 0
|
|
572
|
+
env = {k: v for k, v in os.environ.items() if not k.upper().startswith(("ANTHROPIC_", "CLAUDE_"))}
|
|
573
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1" # stale bytecode once faked a pass
|
|
574
|
+
env["BITE_SEED"] = SEED[0]
|
|
575
|
+
env.update(EXTRA_ENV)
|
|
576
|
+
src = Path(cwd) / "src"
|
|
577
|
+
if (src.is_dir() and not (src / "__init__.py").exists()
|
|
578
|
+
and any((d / "__init__.py").exists() for d in src.iterdir() if d.is_dir())):
|
|
579
|
+
# A src/ layout installed with pip install -e . would load the checkout's code, not this copy's.
|
|
580
|
+
env["PYTHONPATH"] = os.pathsep.join(x for x in (str(src), env.get("PYTHONPATH", "")) if x)
|
|
581
|
+
p = subprocess.Popen(cmd, shell=True, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
|
|
582
|
+
text=True, errors="replace", creationflags=flags, start_new_session=not WINDOWS)
|
|
583
|
+
try:
|
|
584
|
+
out, _ = p.communicate(timeout=timeout)
|
|
585
|
+
return p.returncode, out or ""
|
|
586
|
+
except subprocess.TimeoutExpired:
|
|
587
|
+
if WINDOWS:
|
|
588
|
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(p.pid)], capture_output=True)
|
|
589
|
+
else:
|
|
590
|
+
import signal
|
|
591
|
+
try:
|
|
592
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
593
|
+
except OSError:
|
|
594
|
+
pass
|
|
595
|
+
for name in re.findall(r"--name (bite-\d+-\d+)", cmd): # the box outlives its killed client
|
|
596
|
+
try:
|
|
597
|
+
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=60)
|
|
598
|
+
except (OSError, subprocess.SubprocessError):
|
|
599
|
+
pass # the timeout is still reported; a box left behind shows in docker ps
|
|
600
|
+
out, _ = p.communicate()
|
|
601
|
+
return None, out or ""
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class HeavyLock:
|
|
605
|
+
def __init__(self, folder, lane: str, max_hold: int, who: str = "bite"):
|
|
606
|
+
self.path = Path(folder) / f"{lane}.lock" if folder else None
|
|
607
|
+
self.lane, self.max_hold, self.who = lane, max_hold, who
|
|
608
|
+
|
|
609
|
+
def _holder(self):
|
|
610
|
+
try:
|
|
611
|
+
pid, start, _who = self.path.read_text(encoding="utf-8").split(" ", 2)
|
|
612
|
+
return int(pid), float(start), _who.strip()
|
|
613
|
+
except (OSError, ValueError):
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
def acquire(self, wait: int) -> str:
|
|
617
|
+
"""'' when held; otherwise why not. Takes over a lock whose holder died or overran."""
|
|
618
|
+
if not self.path:
|
|
619
|
+
return ""
|
|
620
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
621
|
+
end = time.time() + wait
|
|
622
|
+
while True:
|
|
623
|
+
try:
|
|
624
|
+
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
625
|
+
os.write(fd, f"{os.getpid()} {time.time():.0f} {self.who}".encode())
|
|
626
|
+
os.close(fd)
|
|
627
|
+
return ""
|
|
628
|
+
except FileExistsError:
|
|
629
|
+
h = self._holder()
|
|
630
|
+
if h is None or not pid_alive(h[0]) or time.time() - h[1] > self.max_hold + 60:
|
|
631
|
+
self.path.unlink(missing_ok=True) # dead or overran its own limit: take over
|
|
632
|
+
continue
|
|
633
|
+
if time.time() >= end:
|
|
634
|
+
return f"{self.lane} lane busy ({h[2]}, pid {h[0]}) for over {wait}s"
|
|
635
|
+
time.sleep(3)
|
|
636
|
+
|
|
637
|
+
def release(self):
|
|
638
|
+
if self.path:
|
|
639
|
+
h = self._holder()
|
|
640
|
+
if h and h[0] == os.getpid():
|
|
641
|
+
self.path.unlink(missing_ok=True)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def locked_run(cfg: Config, cmd: str, cwd: Path, lane: str = ""):
|
|
645
|
+
"""run_cmd under the lane lock (if configured), with the watchdog timeout."""
|
|
646
|
+
lock = HeavyLock(cfg.lock_dir, lane or cfg.lane, cfg.test_timeout)
|
|
647
|
+
why = lock.acquire(cfg.test_timeout)
|
|
648
|
+
if why:
|
|
649
|
+
return None, f"not run: {why}"
|
|
650
|
+
try:
|
|
651
|
+
return run_cmd(contain(cfg, cmd, cwd), cwd, cfg.test_timeout)
|
|
652
|
+
finally:
|
|
653
|
+
lock.release()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
EXTRA_ENV = {} # e.g. BITE_PERSONA and BITE_OUT for a crowd run
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
SEED = ["0"] # BITE_SEED for the next test run: 0 = the normal run, 1..N = the repeat runs
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
AUTO_COUNTS = [ # common runners' summary lines, so the test count works without test_count_pattern
|
|
663
|
+
(r"(?:^=+ .*|^)\b\d+ (?:passed|failed|errors?)\b.* in [\d.]+s\b", r"\b(\d+) (?:passed|failed|errors?|xfailed|xpassed)\b"), # pytest
|
|
664
|
+
(r"^Ran (\d+) tests? in ", r"^Ran (\d+) tests? in "), # unittest
|
|
665
|
+
(r"^Tests:.* (\d+) total", r"^Tests:.* (\d+) total"), # jest / vitest
|
|
666
|
+
(r"^\s*(\d+) (?:passing|failing|pending)\b", r"^\s*(\d+) (?:passing|failing|pending)\b"), # mocha
|
|
667
|
+
(r"^\s*(?:#|ℹ) tests (\d+)\s*$", r"^\s*(?:#|ℹ) tests (\d+)"), # node --test, tap, tape
|
|
668
|
+
(r"^\s*Tests\s+.*\((\d+)\)\s*$", r"\((\d+)\)\s*$"), # vitest
|
|
669
|
+
(r"^\s*(\d+) tests? (?:passed|failed)\b", r"^\s*(\d+) tests? (?:passed|failed)\b"), # ava
|
|
670
|
+
(r"^\s*Total:\s+(\d+)\s*$", r"(\d+)"), # uvu
|
|
671
|
+
(r"^test result: \w+\. (\d+) passed", r"(\d+) (?:passed|failed)(?=;)"), # cargo
|
|
672
|
+
]
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def auto_count(out: str):
|
|
676
|
+
for find, add in AUTO_COUNTS:
|
|
677
|
+
lines = [ln for ln in out.splitlines() if re.search(find, ln, re.M)]
|
|
678
|
+
if lines:
|
|
679
|
+
return sum(int(x) for ln in lines for x in re.findall(add, ln))
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def tests_cmd(cfg: Config) -> str:
|
|
684
|
+
"""Inside the box the project is at /work and only the copy is mounted, so a test command that
|
|
685
|
+
names files outside the project (a Windows runner script, say) needs its own box version."""
|
|
686
|
+
return cfg.box_test_cmd if cfg.contain and cfg.box_test_cmd else cfg.test_cmd
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def run_tests(cfg: Config, folder: Path) -> dict:
|
|
690
|
+
cmd = tests_cmd(cfg).replace("{project}", str(folder)).replace("{seed}", SEED[0])
|
|
691
|
+
rc, out = locked_run(cfg, cmd, folder)
|
|
692
|
+
if rc is None:
|
|
693
|
+
why = out if out.startswith("not run:") else f"timed out after {cfg.test_timeout}s (test_command must exit on its own)"
|
|
694
|
+
return {"ok": False, "why": why,
|
|
695
|
+
"fails": [], "count": None, "out": out}
|
|
696
|
+
fails = [ln.strip() for ln in out.splitlines() if re.search(cfg.fail_pattern, ln)]
|
|
697
|
+
count = None
|
|
698
|
+
if cfg.count_pattern:
|
|
699
|
+
m = re.findall(cfg.count_pattern, out)
|
|
700
|
+
count = sum(int(x) for x in m) if m else 0
|
|
701
|
+
else:
|
|
702
|
+
count = auto_count(out)
|
|
703
|
+
ok = rc in cfg.ok_codes and not fails and (count is None or count > 0)
|
|
704
|
+
why = (f"exit {rc}" if rc else "") or (fails[0][:160] if fails else "") or ("no tests ran" if count == 0 else "")
|
|
705
|
+
return {"ok": ok, "why": why, "fails": fails, "count": count, "out": out}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def safe_write(root: Path, target: Path, data: bytes):
|
|
709
|
+
"""Write a file inside root, never through a symlink (the file or any folder above it)."""
|
|
710
|
+
rel = target.relative_to(root)
|
|
711
|
+
cur = root
|
|
712
|
+
for part in rel.parts:
|
|
713
|
+
cur = cur / part
|
|
714
|
+
if cur.is_symlink():
|
|
715
|
+
raise OSError(f"refused to write through a link: {rel.as_posix()}")
|
|
716
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
717
|
+
target.write_bytes(data)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
LOAD_ERRORS = re.compile(r"ImportError|ModuleNotFoundError|SyntaxError|IndentationError|error(?:s)? during collection|"
|
|
721
|
+
r"Cannot find module|Parse Error|Failed to load script|could not compile|unresolved import|"
|
|
722
|
+
r"error CS\d{4}|error FS\d{4}")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
CHECK_FAILS = re.compile(r"(?m)^\s*FAIL\b|AssertionError|\bassert\b|Expected|expected|FAILED .*::|✕|\bFAIL\b.*(?:!=|==|got|want)|panicked at")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def load_failure(out: str) -> bool:
|
|
729
|
+
"""True when a failing run failed only because code couldn't load, with no failing check in it."""
|
|
730
|
+
return bool(LOAD_ERRORS.search(out)) and not CHECK_FAILS.search(out)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
HASH_COMMENTS = {".py", ".pyw", ".rb", ".sh", ".gd", ".r", ".pl", ".toml", ".yaml", ".yml", ".ini", ".cfg"}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def strip_comment(line: str, suffix: str) -> str:
|
|
737
|
+
"""Cut a trailing comment the way the file's language writes one: in Python // is floor division,
|
|
738
|
+
and in any language a # or // inside a string (a URL) isn't a comment."""
|
|
739
|
+
out, quote, i = [], "", 0
|
|
740
|
+
hash_ = suffix in HASH_COMMENTS
|
|
741
|
+
while i < len(line):
|
|
742
|
+
c = line[i]
|
|
743
|
+
if quote:
|
|
744
|
+
if c == "\\":
|
|
745
|
+
out.append(line[i:i + 2]); i += 2; continue
|
|
746
|
+
if c == quote:
|
|
747
|
+
quote = ""
|
|
748
|
+
elif c in "'\"`":
|
|
749
|
+
quote = c
|
|
750
|
+
elif (hash_ and c == "#") or (not hash_ and line.startswith("//", i)):
|
|
751
|
+
break
|
|
752
|
+
out.append(c)
|
|
753
|
+
i += 1
|
|
754
|
+
return "".join(out)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def pid_alive(pid: int) -> bool:
|
|
758
|
+
if WINDOWS:
|
|
759
|
+
out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
760
|
+
capture_output=True, text=True).stdout
|
|
761
|
+
return f'"{pid}"' in out
|
|
762
|
+
try:
|
|
763
|
+
os.kill(pid, 0)
|
|
764
|
+
return True
|
|
765
|
+
except OSError:
|
|
766
|
+
return False
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def load_key() -> str:
|
|
770
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
771
|
+
return os.environ["ANTHROPIC_API_KEY"]
|
|
772
|
+
if KEY_FILE.exists():
|
|
773
|
+
return clean_key(KEY_FILE.read_text(encoding="utf-8-sig"))
|
|
774
|
+
raise SystemExit("No API key. Set ANTHROPIC_API_KEY, or put your key in key.txt next to bite.py.")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def guess_test_command(folder: Path) -> str:
|
|
778
|
+
"""The usual test command for what's in the folder, or "" if nothing is obvious."""
|
|
779
|
+
import json as _json
|
|
780
|
+
f = Path(folder)
|
|
781
|
+
if (f / "package.json").is_file():
|
|
782
|
+
try:
|
|
783
|
+
t = (_json.loads((f / "package.json").read_text(encoding="utf-8")).get("scripts") or {}).get("test", "")
|
|
784
|
+
except ValueError:
|
|
785
|
+
t = ""
|
|
786
|
+
if t and "no test specified" not in t:
|
|
787
|
+
return "npm test"
|
|
788
|
+
if (f / "Cargo.toml").is_file():
|
|
789
|
+
return "cargo test"
|
|
790
|
+
if (f / "go.mod").is_file():
|
|
791
|
+
return "go test ./..."
|
|
792
|
+
if (f / "ProjectSettings" / "ProjectVersion.txt").is_file():
|
|
793
|
+
return "" # Unity: its tests run inside the Unity editor (-runTests), not with dotnet test
|
|
794
|
+
dotnet = dotnet_target(f)
|
|
795
|
+
if dotnet:
|
|
796
|
+
return "dotnet test " + (f'"{dotnet}"' if " " in dotnet else dotnet)
|
|
797
|
+
if any((f / n).exists() for n in ("pytest.ini", "conftest.py", "tox.ini")) or \
|
|
798
|
+
any(f.glob("test*/test_*.py")) or any(f.glob("test_*.py")) or any(f.glob("*/tests/test_*.py")):
|
|
799
|
+
return ("python" if WINDOWS else "python3") + " -m pytest -q"
|
|
800
|
+
return ""
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def dotnet_target(folder: Path) -> str:
|
|
804
|
+
"""The solution or project file dotnet test should run (it needs one named when there are several)."""
|
|
805
|
+
f = Path(folder)
|
|
806
|
+
for found in (sorted(f.glob("*.sln")) + sorted(f.glob("*.slnx")), sorted(f.glob("*.csproj")) + sorted(f.glob("*.fsproj"))):
|
|
807
|
+
if found:
|
|
808
|
+
return found[0].name
|
|
809
|
+
tests = sorted(p for pat in ("*/*.csproj", "*/*/*.csproj", "*/*.fsproj", "*/*/*.fsproj") for p in f.glob(pat)
|
|
810
|
+
if "test" in p.name.lower())
|
|
811
|
+
return tests[0].relative_to(f).as_posix() if len(tests) == 1 else ""
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def clean_key(text: str) -> str:
|
|
815
|
+
return text.replace("\ufeff", "").strip().strip("'\" ").split()[0] if text.strip() else ""
|
|
816
|
+
|
|
817
|
+
|