@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.1
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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/suite/seed.py
CHANGED
|
@@ -1,421 +1,421 @@
|
|
|
1
|
-
"""Generate seeded-bug tasks from real private code, each paired with a control.
|
|
2
|
-
|
|
3
|
-
python suite/seed.py --count 6 # six functions -> six bug + six control drafts
|
|
4
|
-
python suite/seed.py --count 6 --seed 42 # reproducible
|
|
5
|
-
python suite/seed.py --repo myapp --count 4 # a key in config/repos.json
|
|
6
|
-
python suite/seed.py --hard --count 4 --kinds args,off-by-one,remove-call
|
|
7
|
-
|
|
8
|
-
WHY
|
|
9
|
-
---
|
|
10
|
-
Review is the work the bench hands out most, and hand-built review tasks are
|
|
11
|
-
slow to make: every answer key had to be found and verified first. A seeded
|
|
12
|
-
bug has its answer key by construction. Take a real function from a private
|
|
13
|
-
repository — so no model has seen it — change ONE thing so it no longer does
|
|
14
|
-
what its author intended, and ask which line changed. Fresh variants are
|
|
15
|
-
unlimited.
|
|
16
|
-
|
|
17
|
-
EVERY BUG HAS A CONTROL
|
|
18
|
-
-----------------------
|
|
19
|
-
The same function, untouched, where the right answer is NO FAULT. Without it a
|
|
20
|
-
model that always names a line would look like a good reviewer; with it,
|
|
21
|
-
claiming a fault in clean code is scored as invention, which is the failure
|
|
22
|
-
that costs the most.
|
|
23
|
-
|
|
24
|
-
WHAT IS CHANGED
|
|
25
|
-
---------------
|
|
26
|
-
Ten kinds (--kinds picks some; the run's kind is chosen first, at random, so
|
|
27
|
-
common ones do not crowd out rare ones):
|
|
28
|
-
compare < <= > >= == != flipped to its neighbour
|
|
29
|
-
boolop and <-> or
|
|
30
|
-
return-bool a returned True/False
|
|
31
|
-
not a `not` removed after if/elif/while
|
|
32
|
-
number a small int (-2..2) moved by one or set to 0
|
|
33
|
-
args two adjacent positional arguments swapped
|
|
34
|
-
off-by-one range(n) -> range(n - 1) or range(1, n); x[i:] -> x[i + 1:]; x[:j] -> x[:j - 1]
|
|
35
|
-
break-continue break <-> continue
|
|
36
|
-
remove-call a call statement deleted (`pass` where the block would be empty)
|
|
37
|
-
augassign += <-> -=, *= -> /=
|
|
38
|
-
The first four are single tokens found with `tokenize`; the rest (ideas from
|
|
39
|
-
the operator lists of mutmut and cosmic-ray, no code taken) are found with
|
|
40
|
-
`ast`. Every edit replaces text on one line, or deletes one line, so the
|
|
41
|
-
author's formatting survives: no style tell, the leak the panel warned about.
|
|
42
|
-
Without --kinds, function mode uses the four token kinds (its prompt names
|
|
43
|
-
them) and --hard uses all ten.
|
|
44
|
-
|
|
45
|
-
Changes that rarely alter behaviour are not offered: code in an
|
|
46
|
-
`if __name__ == "__main__":` block or after a return/raise/break/continue,
|
|
47
|
-
keyword-argument and default values, calls to print/logging/sleep, and
|
|
48
|
-
argument swaps for max/min/gcd/lcm/hypot.
|
|
49
|
-
|
|
50
|
-
The prompt numbers the lines, because a model that miscounts has not reviewed
|
|
51
|
-
badly, and asks for `FAULT: line <n>` or `NO FAULT` on the first line, scored
|
|
52
|
-
by exact regex (a deleted line accepts the line before or after the gap).
|
|
53
|
-
Seeded and control tasks are `"shape": "seeded"` and stay out of the routing
|
|
54
|
-
recall for now.
|
|
55
|
-
|
|
56
|
-
DRAFTS
|
|
57
|
-
------
|
|
58
|
-
Written as drafts. A change can still leave behaviour unchanged (a branch
|
|
59
|
-
that is unreachable, a comparison of equal values), which would make the key
|
|
60
|
-
wrong; read each bug task's `why` before removing "draft": true.
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
from __future__ import annotations
|
|
64
|
-
|
|
65
|
-
import argparse
|
|
66
|
-
import ast
|
|
67
|
-
import io
|
|
68
|
-
import json
|
|
69
|
-
import pathlib
|
|
70
|
-
import random
|
|
71
|
-
import re
|
|
72
|
-
import sys
|
|
73
|
-
import tokenize
|
|
74
|
-
|
|
75
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
76
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
77
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
78
|
-
TASKS = DATA / "suite" / "tasks"
|
|
79
|
-
sys.path.insert(0, str(ROOT / "suite"))
|
|
80
|
-
from run import DEFAULT_REPO, REPOS # noqa: E402
|
|
81
|
-
|
|
82
|
-
FLIP = {"<": "<=", "<=": "<", ">": ">=", ">=": ">", "==": "!=", "!=": "==",
|
|
83
|
-
"and": "or", "or": "and", "True": "False", "False": "True"}
|
|
84
|
-
SKIP_DIRS = ("test", "migrations", "vendor", "__pycache__", ".venv", "build", "dist")
|
|
85
|
-
# Each kind as the function prompt names it (--hard names none).
|
|
86
|
-
KINDS = {
|
|
87
|
-
"compare": "a comparison operator",
|
|
88
|
-
"boolop": "`and`/`or`",
|
|
89
|
-
"return-bool": "a returned True/False",
|
|
90
|
-
"not": "a removed `not`",
|
|
91
|
-
"number": "a small number",
|
|
92
|
-
"args": "two adjacent arguments swapped",
|
|
93
|
-
"off-by-one": "an off-by-one in a range() or slice",
|
|
94
|
-
"break-continue": "`break` and `continue` swapped",
|
|
95
|
-
"remove-call": "a removed call",
|
|
96
|
-
"augassign": "an augmented assignment operator",
|
|
97
|
-
}
|
|
98
|
-
TOKEN_KINDS = ("compare", "boolop", "return-bool", "not")
|
|
99
|
-
SYMMETRIC = {"max", "min", "gcd", "lcm", "hypot"}
|
|
100
|
-
QUIET = re.compile(r"(^|\.)(print|echo|write|log|debug|info|warn|warning|error|exception|critical|sleep)$")
|
|
101
|
-
AUG = {"+=": "-=", "-=": "+=", "*=": "/="}
|
|
102
|
-
AUG_OP = {"Add": "+=", "Sub": "-=", "Mult": "*="}
|
|
103
|
-
# An expression that `x - 1` would not simply shift by one.
|
|
104
|
-
LOOSE = (ast.IfExp, ast.Lambda, ast.BoolOp, ast.Compare, ast.NamedExpr, ast.Starred)
|
|
105
|
-
# --hard: the whole file, and no hint at what kind of change to look for. The
|
|
106
|
-
# first batch (2026-10-17) saturated: three of four models found 6/6 seeded
|
|
107
|
-
# bugs and passed 6/6 controls with the function alone and the hint.
|
|
108
|
-
HARD_PROMPT = ("The Python file below is either exactly as its author wrote it, or {change}"
|
|
109
|
-
" so that the code no longer does what the author intended."
|
|
110
|
-
" Lines are numbered.\n\n"
|
|
111
|
-
"Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or"
|
|
112
|
-
" `NO FAULT` if nothing was changed. Then explain in at most three sentences."
|
|
113
|
-
" Plain ASCII.\n\n{code}")
|
|
114
|
-
PROMPT = ("The Python function below is either exactly as its author wrote it, or {change}"
|
|
115
|
-
" so that it no longer does what the author intended ({hint}). Lines are numbered.\n\n"
|
|
116
|
-
"Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or `NO FAULT`"
|
|
117
|
-
" if nothing was changed. Then explain in at most three sentences. Plain ASCII.\n\n"
|
|
118
|
-
"{code}")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def prompt_for(hard: bool, kinds: list[str], code: str) -> str:
|
|
122
|
-
"""One wording per run, never per task, so the prompt cannot give away a
|
|
123
|
-
task's kind. With the four token kinds it is the wording of the first batches."""
|
|
124
|
-
if set(kinds) <= set(TOKEN_KINDS):
|
|
125
|
-
change = "ONE token in it has been changed"
|
|
126
|
-
elif "remove-call" in kinds:
|
|
127
|
-
change = "ONE small change has been made to it (one line edited, or one line removed)"
|
|
128
|
-
else:
|
|
129
|
-
change = "ONE small change has been made to one line"
|
|
130
|
-
gap = " (for a removed line, the line just before or after the gap)" if "remove-call" in kinds else ""
|
|
131
|
-
if hard:
|
|
132
|
-
return HARD_PROMPT.format(change=change, gap=gap, code=code)
|
|
133
|
-
hints = [KINDS[k] for k in kinds]
|
|
134
|
-
hint = hints[0] if len(hints) == 1 else ", ".join(hints[:-1]) + ", or " + hints[-1]
|
|
135
|
-
return PROMPT.format(change=change, hint=hint, gap=gap, code=code)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def files(repo: pathlib.Path):
|
|
139
|
-
"""(relative path, "file", every line) for --hard: whole files of 100-400 lines."""
|
|
140
|
-
for path in sorted(repo.rglob("*.py")):
|
|
141
|
-
rel = path.relative_to(repo)
|
|
142
|
-
if any(part.startswith(SKIP_DIRS) for part in rel.parts):
|
|
143
|
-
continue
|
|
144
|
-
try:
|
|
145
|
-
lines = path.read_text(encoding="utf-8").splitlines()
|
|
146
|
-
except (UnicodeDecodeError, OSError):
|
|
147
|
-
continue
|
|
148
|
-
if 100 <= len(lines) <= 400:
|
|
149
|
-
yield str(rel).replace("\\", "/"), "file", lines
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def functions(repo: pathlib.Path):
|
|
153
|
-
"""(relative path, name, source lines) for every function of 12-60 lines."""
|
|
154
|
-
for path in sorted(repo.rglob("*.py")):
|
|
155
|
-
rel = path.relative_to(repo)
|
|
156
|
-
if any(part.startswith(SKIP_DIRS) for part in rel.parts):
|
|
157
|
-
continue
|
|
158
|
-
try:
|
|
159
|
-
text = path.read_text(encoding="utf-8")
|
|
160
|
-
tree = ast.parse(text)
|
|
161
|
-
except (SyntaxError, UnicodeDecodeError, OSError):
|
|
162
|
-
continue
|
|
163
|
-
lines = text.splitlines()
|
|
164
|
-
for node in ast.walk(tree):
|
|
165
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
166
|
-
start = (node.decorator_list[0].lineno if node.decorator_list else node.lineno) - 1
|
|
167
|
-
body = lines[start:node.end_lineno]
|
|
168
|
-
if 12 <= len(body) <= 60:
|
|
169
|
-
yield str(rel).replace("\\", "/"), node.name, body
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def candidates(body: list[str], kinds=tuple(KINDS)) -> list[tuple[str, int, int, str, str | None]]:
|
|
173
|
-
"""(kind, line index, column, old text, new text) for each possible change;
|
|
174
|
-
new text None deletes the line."""
|
|
175
|
-
src_lines = _dedent(body)
|
|
176
|
-
src = "\n".join(src_lines) + "\n"
|
|
177
|
-
try:
|
|
178
|
-
tree = ast.parse(src)
|
|
179
|
-
toks = list(tokenize.generate_tokens(io.StringIO(src).readline))
|
|
180
|
-
except (SyntaxError, tokenize.TokenError, IndentationError):
|
|
181
|
-
return []
|
|
182
|
-
indent = _indent(body)
|
|
183
|
-
out = []
|
|
184
|
-
for i, t in enumerate(toks):
|
|
185
|
-
prev = toks[i - 1].string if i else ""
|
|
186
|
-
kind = None
|
|
187
|
-
if t.type == tokenize.OP and t.string in FLIP:
|
|
188
|
-
kind, old, new = "compare", t.string, FLIP[t.string]
|
|
189
|
-
elif t.type == tokenize.NAME and t.string in ("and", "or"):
|
|
190
|
-
kind, old, new = "boolop", t.string, FLIP[t.string]
|
|
191
|
-
elif t.type == tokenize.NAME and t.string in ("True", "False") and prev == "return":
|
|
192
|
-
kind, old, new = "return-bool", t.string, FLIP[t.string]
|
|
193
|
-
elif t.type == tokenize.NAME and t.string == "not" and prev in ("if", "elif", "while"):
|
|
194
|
-
kind, old, new = "not", "not ", ""
|
|
195
|
-
if kind:
|
|
196
|
-
out.append((kind, t.start[0] - 1, t.start[1] + indent, old, new))
|
|
197
|
-
out += [(k, ln, col + indent, old, new) for k, ln, col, old, new in _ast_candidates(tree, src_lines)]
|
|
198
|
-
dead = _dead_lines(tree)
|
|
199
|
-
return [c for c in out if c[0] in kinds and c[1] + 1 not in dead]
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _ast_candidates(tree: ast.AST, lines: list[str]) -> list[tuple[str, int, int, str, str | None]]:
|
|
203
|
-
parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
|
|
204
|
-
|
|
205
|
-
def seg(n):
|
|
206
|
-
"""(line index, start, end, text) of a node that sits on one line."""
|
|
207
|
-
if n.lineno != n.end_lineno:
|
|
208
|
-
return None
|
|
209
|
-
ln = lines[n.lineno - 1]
|
|
210
|
-
a, b = _col(ln, n.col_offset), _col(ln, n.end_col_offset)
|
|
211
|
-
return n.lineno - 1, a, b, ln[a:b]
|
|
212
|
-
|
|
213
|
-
def inside(n, kind):
|
|
214
|
-
while n in parent:
|
|
215
|
-
n = parent[n]
|
|
216
|
-
if isinstance(n, kind):
|
|
217
|
-
return True
|
|
218
|
-
return False
|
|
219
|
-
|
|
220
|
-
out = []
|
|
221
|
-
for n in ast.walk(tree):
|
|
222
|
-
if isinstance(n, ast.Constant) and type(n.value) is int and abs(n.value) <= 2:
|
|
223
|
-
p = parent.get(n)
|
|
224
|
-
s = seg(n)
|
|
225
|
-
digits = isinstance(p, ast.Call) and ast.unparse(p.func) == "round" and n in p.args[1:]
|
|
226
|
-
if (s and s[3] == str(n.value) and not isinstance(p, (ast.keyword, ast.arguments))
|
|
227
|
-
and not digits and not inside(n, ast.JoinedStr)):
|
|
228
|
-
for v in sorted({n.value + 1, n.value - 1, 0} - {n.value}):
|
|
229
|
-
if v > 0 or not isinstance(p, ast.UnaryOp): # no -0, no --1
|
|
230
|
-
out.append(("number", s[0], s[1], s[3], str(v)))
|
|
231
|
-
if isinstance(n, ast.Call):
|
|
232
|
-
name = ast.unparse(n.func)
|
|
233
|
-
if name.split(".")[-1] not in SYMMETRIC and not any(isinstance(a, ast.Starred) for a in n.args):
|
|
234
|
-
for x, y in zip(n.args, n.args[1:]):
|
|
235
|
-
sx, sy = seg(x), seg(y)
|
|
236
|
-
if sx and sy and sx[0] == sy[0] and sx[3] != sy[3]:
|
|
237
|
-
ln = lines[sx[0]]
|
|
238
|
-
out.append(("args", sx[0], sx[1], ln[sx[1]:sy[2]], sy[3] + ln[sx[2]:sy[1]] + sx[3]))
|
|
239
|
-
if name == "range" and len(n.args) == 1 and not n.keywords and not isinstance(n.args[0], LOOSE):
|
|
240
|
-
s = seg(n.args[0])
|
|
241
|
-
if s:
|
|
242
|
-
out.append(("off-by-one", s[0], s[1], s[3], _shift(n.args[0], s[3], -1)))
|
|
243
|
-
out.append(("off-by-one", s[0], s[1], s[3], f"1, {s[3]}"))
|
|
244
|
-
if isinstance(n, ast.Slice) and n.step is None and (n.lower is None) != (n.upper is None):
|
|
245
|
-
bound, by = (n.lower, 1) if n.lower is not None else (n.upper, -1)
|
|
246
|
-
s = None if isinstance(bound, LOOSE) else seg(bound)
|
|
247
|
-
if s:
|
|
248
|
-
out.append(("off-by-one", s[0], s[1], s[3], _shift(bound, s[3], by)))
|
|
249
|
-
if isinstance(n, (ast.Break, ast.Continue)):
|
|
250
|
-
s = seg(n)
|
|
251
|
-
if s:
|
|
252
|
-
out.append(("break-continue", s[0], s[1], s[3], "continue" if s[3] == "break" else "break"))
|
|
253
|
-
if isinstance(n, ast.Expr) and isinstance(n.value, ast.Call) and not QUIET.search(ast.unparse(n.value.func)):
|
|
254
|
-
s = seg(n)
|
|
255
|
-
if s and lines[s[0]].strip() == s[3]:
|
|
256
|
-
p = parent[n]
|
|
257
|
-
block = next((getattr(p, f) for f in ("body", "orelse", "finalbody")
|
|
258
|
-
if any(x is n for x in getattr(p, f, []))), [n, n])
|
|
259
|
-
if len(block) == 1:
|
|
260
|
-
out.append(("remove-call", s[0], s[1], s[3], "pass"))
|
|
261
|
-
else:
|
|
262
|
-
out.append(("remove-call", s[0], 0, lines[s[0]], None))
|
|
263
|
-
if isinstance(n, ast.AugAssign) and type(n.op).__name__ in AUG_OP \
|
|
264
|
-
and n.target.end_lineno == n.value.lineno == n.lineno:
|
|
265
|
-
ln = lines[n.lineno - 1]
|
|
266
|
-
op = AUG_OP[type(n.op).__name__]
|
|
267
|
-
k = ln.find(op, _col(ln, n.target.end_col_offset), _col(ln, n.value.col_offset))
|
|
268
|
-
if k >= 0:
|
|
269
|
-
out.append(("augassign", n.lineno - 1, k, op, AUG[op]))
|
|
270
|
-
return out
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _shift(node: ast.AST, text: str, by: int) -> str:
|
|
274
|
-
"""`text` moved by one: a literal is computed (`v[:0]`, never the tell-tale
|
|
275
|
-
`v[:1 - 1]`), anything else gets `+ 1` / `- 1`."""
|
|
276
|
-
value = node.value if isinstance(node, ast.Constant) else None
|
|
277
|
-
if isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant) \
|
|
278
|
-
and isinstance(node.op, (ast.USub, ast.UAdd)) and type(node.operand.value) is int:
|
|
279
|
-
value = -node.operand.value if isinstance(node.op, ast.USub) else node.operand.value
|
|
280
|
-
if type(value) is int:
|
|
281
|
-
return str(value + by)
|
|
282
|
-
return f"{text} {'+' if by > 0 else '-'} 1"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def _dead_lines(tree: ast.AST) -> set[int]:
|
|
286
|
-
"""Lines where a change rarely changes behaviour: an `if __name__ ==
|
|
287
|
-
"__main__":` block, and statements after a return/raise/break/continue."""
|
|
288
|
-
dead = set()
|
|
289
|
-
for n in ast.walk(tree):
|
|
290
|
-
if (isinstance(n, ast.If) and isinstance(n.test, ast.Compare)
|
|
291
|
-
and isinstance(n.test.left, ast.Name) and n.test.left.id == "__name__"):
|
|
292
|
-
dead.update(range(n.lineno, n.end_lineno + 1))
|
|
293
|
-
for field in ("body", "orelse", "finalbody"):
|
|
294
|
-
stmts = getattr(n, field, None)
|
|
295
|
-
if not isinstance(stmts, list):
|
|
296
|
-
continue
|
|
297
|
-
stop = next((i for i, s in enumerate(stmts)
|
|
298
|
-
if isinstance(s, (ast.Return, ast.Raise, ast.Break, ast.Continue))), None)
|
|
299
|
-
for s in stmts[stop + 1:] if stop is not None else []:
|
|
300
|
-
dead.update(range(s.lineno, s.end_lineno + 1))
|
|
301
|
-
return dead
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _col(line: str, byte_offset: int) -> int:
|
|
305
|
-
"""ast columns count UTF-8 bytes; slicing needs characters."""
|
|
306
|
-
return len(line.encode("utf-8")[:byte_offset].decode("utf-8", "ignore"))
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def apply(body: list[str], cand: tuple) -> list[str] | None:
|
|
310
|
-
"""The changed lines, or None if the text is not where the candidate says."""
|
|
311
|
-
_, line, col, old, new = cand
|
|
312
|
-
if new is None:
|
|
313
|
-
return body[:line] + body[line + 1:]
|
|
314
|
-
if body[line][col:col + len(old)] != old:
|
|
315
|
-
return None
|
|
316
|
-
out = list(body)
|
|
317
|
-
out[line] = out[line][:col] + new + out[line][col + len(old):]
|
|
318
|
-
return out
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def answer_lines(cand: tuple, length: int) -> list[int]:
|
|
322
|
-
"""Lines a correct answer may name: the changed one, or either side of a deleted one."""
|
|
323
|
-
_, line, _, _, new = cand
|
|
324
|
-
if new is None:
|
|
325
|
-
return [n for n in (line, line + 1) if 1 <= n <= length]
|
|
326
|
-
return [line + 1]
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def parses(body: list[str]) -> bool:
|
|
330
|
-
try:
|
|
331
|
-
ast.parse("\n".join(_dedent(body)) + "\n")
|
|
332
|
-
return True
|
|
333
|
-
except SyntaxError:
|
|
334
|
-
return False
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _indent(body: list[str]) -> int:
|
|
338
|
-
return len(body[0]) - len(body[0].lstrip())
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _dedent(body: list[str]) -> list[str]:
|
|
342
|
-
n = _indent(body)
|
|
343
|
-
return [ln[n:] if ln[:n].strip() == "" else ln for ln in body]
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def numbered(body: list[str]) -> str:
|
|
347
|
-
width = len(str(len(body)))
|
|
348
|
-
return "\n".join(f"{i:>{width}}| {ln}" for i, ln in enumerate(body, 1))
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def write(task_id: str, spec: dict) -> None:
|
|
352
|
-
d = TASKS / task_id
|
|
353
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
354
|
-
(d / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def main() -> None:
|
|
358
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
359
|
-
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
360
|
-
ap.add_argument("--repo", default=DEFAULT_REPO, choices=sorted(REPOS),
|
|
361
|
-
help="a key in config/repos.json")
|
|
362
|
-
ap.add_argument("--count", type=int, default=6)
|
|
363
|
-
ap.add_argument("--seed", type=int, default=None)
|
|
364
|
-
ap.add_argument("--hard", action="store_true",
|
|
365
|
-
help="whole files of 100-400 lines, and no hint at the kind of change")
|
|
366
|
-
ap.add_argument("--kinds", help=f"comma-separated, from: {', '.join(KINDS)}")
|
|
367
|
-
args = ap.parse_args()
|
|
368
|
-
wanted = args.kinds.split(",") if args.kinds else (list(KINDS) if args.hard else list(TOKEN_KINDS))
|
|
369
|
-
unknown = set(wanted) - set(KINDS)
|
|
370
|
-
if unknown:
|
|
371
|
-
sys.exit(f"unknown kind(s): {', '.join(sorted(unknown))}; choose from {', '.join(KINDS)}")
|
|
372
|
-
kinds = [k for k in KINDS if k in wanted]
|
|
373
|
-
rng = random.Random(args.seed)
|
|
374
|
-
source_of = files if args.hard else functions
|
|
375
|
-
pool = [(rel, name, body, c) for rel, name, body in source_of(REPOS[args.repo])
|
|
376
|
-
if (c := candidates(body, kinds))]
|
|
377
|
-
taken = {json.loads(p.read_text(encoding="utf-8")).get("source", "")
|
|
378
|
-
for p in TASKS.glob("seeded-*/task.json")}
|
|
379
|
-
pool = [p for p in pool if f"{p[0]}::{p[1]}" not in taken]
|
|
380
|
-
if not pool:
|
|
381
|
-
sys.exit("no unused function with a change of these kinds")
|
|
382
|
-
for rel, name, body, cands in rng.sample(pool, min(args.count, len(pool))):
|
|
383
|
-
bugged = cand = None
|
|
384
|
-
while cands and bugged is None:
|
|
385
|
-
kind = rng.choice(sorted({c[0] for c in cands})) # kind first, so rare kinds get picked
|
|
386
|
-
cand = rng.choice([c for c in cands if c[0] == kind])
|
|
387
|
-
cands.remove(cand)
|
|
388
|
-
b = apply(body, cand)
|
|
389
|
-
bugged = b if b and b != body and parses(b) else None
|
|
390
|
-
if bugged is None:
|
|
391
|
-
continue
|
|
392
|
-
kind, line, _, old, new = cand
|
|
393
|
-
slug = ("hard-" if args.hard else "") + re.sub(r"[^a-z0-9]+", "-", f"{pathlib.Path(rel).stem}-{name}".lower()).strip("-")[:40]
|
|
394
|
-
source = f"{rel}::{name}"
|
|
395
|
-
common = {"draft": True, "shape": "seeded", "repo": args.repo, "source": source,
|
|
396
|
-
"score_in": "first_line", "min_answer_chars": 8}
|
|
397
|
-
n = line + 1
|
|
398
|
-
keys = answer_lines(cand, len(bugged))
|
|
399
|
-
change = "(line removed)" if new is None else repr(new.strip() or "(removed)")
|
|
400
|
-
write(f"seeded-{slug}-bug", {
|
|
401
|
-
**common, "kind": kind, "id": f"seeded-{slug}-bug", "title": "Seeded bug: name the changed line",
|
|
402
|
-
"why": (f"Seeded in {source}, line {n} ({kind}): {old.strip()!r} -> {change}.\n"
|
|
403
|
-
f" before: {body[line].strip()}\n"
|
|
404
|
-
f" after: {'(line removed)' if new is None else bugged[line].strip()}\n"
|
|
405
|
-
"Check the change alters behaviour before removing draft."),
|
|
406
|
-
"prompt": prompt_for(args.hard, kinds, numbered(bugged)),
|
|
407
|
-
"must_find": [{"regex": rf"^\W*fault:\s*line\s*(?:{'|'.join(map(str, keys))})\b",
|
|
408
|
-
"label": f"fault: line {' or '.join(map(str, keys))}"}],
|
|
409
|
-
"must_not_claim": ["no fault"]})
|
|
410
|
-
write(f"seeded-{slug}-control", {
|
|
411
|
-
**common, "id": f"seeded-{slug}-control", "title": "Seeded-bug control: say NO FAULT",
|
|
412
|
-
"why": f"Untouched {source}; the paired bug task changed line {n}. The answer is NO FAULT.",
|
|
413
|
-
"prompt": prompt_for(args.hard, kinds, numbered(body)),
|
|
414
|
-
"must_find": [{"any_of": ["no fault"]}],
|
|
415
|
-
"must_not_claim": [{"regex": r"^\W*fault:\s*line", "label": "fault: line"}]})
|
|
416
|
-
print(f" {source}: line {n} {kind} {old.strip()!r} -> {change} -> seeded-{slug}-bug / -control")
|
|
417
|
-
print("drafts written; review each bug task's `why`, then remove \"draft\": true")
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if __name__ == "__main__":
|
|
421
|
-
main()
|
|
1
|
+
"""Generate seeded-bug tasks from real private code, each paired with a control.
|
|
2
|
+
|
|
3
|
+
python suite/seed.py --count 6 # six functions -> six bug + six control drafts
|
|
4
|
+
python suite/seed.py --count 6 --seed 42 # reproducible
|
|
5
|
+
python suite/seed.py --repo myapp --count 4 # a key in config/repos.json
|
|
6
|
+
python suite/seed.py --hard --count 4 --kinds args,off-by-one,remove-call
|
|
7
|
+
|
|
8
|
+
WHY
|
|
9
|
+
---
|
|
10
|
+
Review is the work the bench hands out most, and hand-built review tasks are
|
|
11
|
+
slow to make: every answer key had to be found and verified first. A seeded
|
|
12
|
+
bug has its answer key by construction. Take a real function from a private
|
|
13
|
+
repository — so no model has seen it — change ONE thing so it no longer does
|
|
14
|
+
what its author intended, and ask which line changed. Fresh variants are
|
|
15
|
+
unlimited.
|
|
16
|
+
|
|
17
|
+
EVERY BUG HAS A CONTROL
|
|
18
|
+
-----------------------
|
|
19
|
+
The same function, untouched, where the right answer is NO FAULT. Without it a
|
|
20
|
+
model that always names a line would look like a good reviewer; with it,
|
|
21
|
+
claiming a fault in clean code is scored as invention, which is the failure
|
|
22
|
+
that costs the most.
|
|
23
|
+
|
|
24
|
+
WHAT IS CHANGED
|
|
25
|
+
---------------
|
|
26
|
+
Ten kinds (--kinds picks some; the run's kind is chosen first, at random, so
|
|
27
|
+
common ones do not crowd out rare ones):
|
|
28
|
+
compare < <= > >= == != flipped to its neighbour
|
|
29
|
+
boolop and <-> or
|
|
30
|
+
return-bool a returned True/False
|
|
31
|
+
not a `not` removed after if/elif/while
|
|
32
|
+
number a small int (-2..2) moved by one or set to 0
|
|
33
|
+
args two adjacent positional arguments swapped
|
|
34
|
+
off-by-one range(n) -> range(n - 1) or range(1, n); x[i:] -> x[i + 1:]; x[:j] -> x[:j - 1]
|
|
35
|
+
break-continue break <-> continue
|
|
36
|
+
remove-call a call statement deleted (`pass` where the block would be empty)
|
|
37
|
+
augassign += <-> -=, *= -> /=
|
|
38
|
+
The first four are single tokens found with `tokenize`; the rest (ideas from
|
|
39
|
+
the operator lists of mutmut and cosmic-ray, no code taken) are found with
|
|
40
|
+
`ast`. Every edit replaces text on one line, or deletes one line, so the
|
|
41
|
+
author's formatting survives: no style tell, the leak the panel warned about.
|
|
42
|
+
Without --kinds, function mode uses the four token kinds (its prompt names
|
|
43
|
+
them) and --hard uses all ten.
|
|
44
|
+
|
|
45
|
+
Changes that rarely alter behaviour are not offered: code in an
|
|
46
|
+
`if __name__ == "__main__":` block or after a return/raise/break/continue,
|
|
47
|
+
keyword-argument and default values, calls to print/logging/sleep, and
|
|
48
|
+
argument swaps for max/min/gcd/lcm/hypot.
|
|
49
|
+
|
|
50
|
+
The prompt numbers the lines, because a model that miscounts has not reviewed
|
|
51
|
+
badly, and asks for `FAULT: line <n>` or `NO FAULT` on the first line, scored
|
|
52
|
+
by exact regex (a deleted line accepts the line before or after the gap).
|
|
53
|
+
Seeded and control tasks are `"shape": "seeded"` and stay out of the routing
|
|
54
|
+
recall for now.
|
|
55
|
+
|
|
56
|
+
DRAFTS
|
|
57
|
+
------
|
|
58
|
+
Written as drafts. A change can still leave behaviour unchanged (a branch
|
|
59
|
+
that is unreachable, a comparison of equal values), which would make the key
|
|
60
|
+
wrong; read each bug task's `why` before removing "draft": true.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import argparse
|
|
66
|
+
import ast
|
|
67
|
+
import io
|
|
68
|
+
import json
|
|
69
|
+
import pathlib
|
|
70
|
+
import random
|
|
71
|
+
import re
|
|
72
|
+
import sys
|
|
73
|
+
import tokenize
|
|
74
|
+
|
|
75
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
76
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
77
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
78
|
+
TASKS = DATA / "suite" / "tasks"
|
|
79
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
80
|
+
from run import DEFAULT_REPO, REPOS # noqa: E402
|
|
81
|
+
|
|
82
|
+
FLIP = {"<": "<=", "<=": "<", ">": ">=", ">=": ">", "==": "!=", "!=": "==",
|
|
83
|
+
"and": "or", "or": "and", "True": "False", "False": "True"}
|
|
84
|
+
SKIP_DIRS = ("test", "migrations", "vendor", "__pycache__", ".venv", "build", "dist")
|
|
85
|
+
# Each kind as the function prompt names it (--hard names none).
|
|
86
|
+
KINDS = {
|
|
87
|
+
"compare": "a comparison operator",
|
|
88
|
+
"boolop": "`and`/`or`",
|
|
89
|
+
"return-bool": "a returned True/False",
|
|
90
|
+
"not": "a removed `not`",
|
|
91
|
+
"number": "a small number",
|
|
92
|
+
"args": "two adjacent arguments swapped",
|
|
93
|
+
"off-by-one": "an off-by-one in a range() or slice",
|
|
94
|
+
"break-continue": "`break` and `continue` swapped",
|
|
95
|
+
"remove-call": "a removed call",
|
|
96
|
+
"augassign": "an augmented assignment operator",
|
|
97
|
+
}
|
|
98
|
+
TOKEN_KINDS = ("compare", "boolop", "return-bool", "not")
|
|
99
|
+
SYMMETRIC = {"max", "min", "gcd", "lcm", "hypot"}
|
|
100
|
+
QUIET = re.compile(r"(^|\.)(print|echo|write|log|debug|info|warn|warning|error|exception|critical|sleep)$")
|
|
101
|
+
AUG = {"+=": "-=", "-=": "+=", "*=": "/="}
|
|
102
|
+
AUG_OP = {"Add": "+=", "Sub": "-=", "Mult": "*="}
|
|
103
|
+
# An expression that `x - 1` would not simply shift by one.
|
|
104
|
+
LOOSE = (ast.IfExp, ast.Lambda, ast.BoolOp, ast.Compare, ast.NamedExpr, ast.Starred)
|
|
105
|
+
# --hard: the whole file, and no hint at what kind of change to look for. The
|
|
106
|
+
# first batch (2026-10-17) saturated: three of four models found 6/6 seeded
|
|
107
|
+
# bugs and passed 6/6 controls with the function alone and the hint.
|
|
108
|
+
HARD_PROMPT = ("The Python file below is either exactly as its author wrote it, or {change}"
|
|
109
|
+
" so that the code no longer does what the author intended."
|
|
110
|
+
" Lines are numbered.\n\n"
|
|
111
|
+
"Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or"
|
|
112
|
+
" `NO FAULT` if nothing was changed. Then explain in at most three sentences."
|
|
113
|
+
" Plain ASCII.\n\n{code}")
|
|
114
|
+
PROMPT = ("The Python function below is either exactly as its author wrote it, or {change}"
|
|
115
|
+
" so that it no longer does what the author intended ({hint}). Lines are numbered.\n\n"
|
|
116
|
+
"Your first line must be exactly `FAULT: line <n>`, naming the changed line{gap}, or `NO FAULT`"
|
|
117
|
+
" if nothing was changed. Then explain in at most three sentences. Plain ASCII.\n\n"
|
|
118
|
+
"{code}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def prompt_for(hard: bool, kinds: list[str], code: str) -> str:
|
|
122
|
+
"""One wording per run, never per task, so the prompt cannot give away a
|
|
123
|
+
task's kind. With the four token kinds it is the wording of the first batches."""
|
|
124
|
+
if set(kinds) <= set(TOKEN_KINDS):
|
|
125
|
+
change = "ONE token in it has been changed"
|
|
126
|
+
elif "remove-call" in kinds:
|
|
127
|
+
change = "ONE small change has been made to it (one line edited, or one line removed)"
|
|
128
|
+
else:
|
|
129
|
+
change = "ONE small change has been made to one line"
|
|
130
|
+
gap = " (for a removed line, the line just before or after the gap)" if "remove-call" in kinds else ""
|
|
131
|
+
if hard:
|
|
132
|
+
return HARD_PROMPT.format(change=change, gap=gap, code=code)
|
|
133
|
+
hints = [KINDS[k] for k in kinds]
|
|
134
|
+
hint = hints[0] if len(hints) == 1 else ", ".join(hints[:-1]) + ", or " + hints[-1]
|
|
135
|
+
return PROMPT.format(change=change, hint=hint, gap=gap, code=code)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def files(repo: pathlib.Path):
|
|
139
|
+
"""(relative path, "file", every line) for --hard: whole files of 100-400 lines."""
|
|
140
|
+
for path in sorted(repo.rglob("*.py")):
|
|
141
|
+
rel = path.relative_to(repo)
|
|
142
|
+
if any(part.startswith(SKIP_DIRS) for part in rel.parts):
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
146
|
+
except (UnicodeDecodeError, OSError):
|
|
147
|
+
continue
|
|
148
|
+
if 100 <= len(lines) <= 400:
|
|
149
|
+
yield str(rel).replace("\\", "/"), "file", lines
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def functions(repo: pathlib.Path):
|
|
153
|
+
"""(relative path, name, source lines) for every function of 12-60 lines."""
|
|
154
|
+
for path in sorted(repo.rglob("*.py")):
|
|
155
|
+
rel = path.relative_to(repo)
|
|
156
|
+
if any(part.startswith(SKIP_DIRS) for part in rel.parts):
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
text = path.read_text(encoding="utf-8")
|
|
160
|
+
tree = ast.parse(text)
|
|
161
|
+
except (SyntaxError, UnicodeDecodeError, OSError):
|
|
162
|
+
continue
|
|
163
|
+
lines = text.splitlines()
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
166
|
+
start = (node.decorator_list[0].lineno if node.decorator_list else node.lineno) - 1
|
|
167
|
+
body = lines[start:node.end_lineno]
|
|
168
|
+
if 12 <= len(body) <= 60:
|
|
169
|
+
yield str(rel).replace("\\", "/"), node.name, body
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def candidates(body: list[str], kinds=tuple(KINDS)) -> list[tuple[str, int, int, str, str | None]]:
|
|
173
|
+
"""(kind, line index, column, old text, new text) for each possible change;
|
|
174
|
+
new text None deletes the line."""
|
|
175
|
+
src_lines = _dedent(body)
|
|
176
|
+
src = "\n".join(src_lines) + "\n"
|
|
177
|
+
try:
|
|
178
|
+
tree = ast.parse(src)
|
|
179
|
+
toks = list(tokenize.generate_tokens(io.StringIO(src).readline))
|
|
180
|
+
except (SyntaxError, tokenize.TokenError, IndentationError):
|
|
181
|
+
return []
|
|
182
|
+
indent = _indent(body)
|
|
183
|
+
out = []
|
|
184
|
+
for i, t in enumerate(toks):
|
|
185
|
+
prev = toks[i - 1].string if i else ""
|
|
186
|
+
kind = None
|
|
187
|
+
if t.type == tokenize.OP and t.string in FLIP:
|
|
188
|
+
kind, old, new = "compare", t.string, FLIP[t.string]
|
|
189
|
+
elif t.type == tokenize.NAME and t.string in ("and", "or"):
|
|
190
|
+
kind, old, new = "boolop", t.string, FLIP[t.string]
|
|
191
|
+
elif t.type == tokenize.NAME and t.string in ("True", "False") and prev == "return":
|
|
192
|
+
kind, old, new = "return-bool", t.string, FLIP[t.string]
|
|
193
|
+
elif t.type == tokenize.NAME and t.string == "not" and prev in ("if", "elif", "while"):
|
|
194
|
+
kind, old, new = "not", "not ", ""
|
|
195
|
+
if kind:
|
|
196
|
+
out.append((kind, t.start[0] - 1, t.start[1] + indent, old, new))
|
|
197
|
+
out += [(k, ln, col + indent, old, new) for k, ln, col, old, new in _ast_candidates(tree, src_lines)]
|
|
198
|
+
dead = _dead_lines(tree)
|
|
199
|
+
return [c for c in out if c[0] in kinds and c[1] + 1 not in dead]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ast_candidates(tree: ast.AST, lines: list[str]) -> list[tuple[str, int, int, str, str | None]]:
|
|
203
|
+
parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
|
|
204
|
+
|
|
205
|
+
def seg(n):
|
|
206
|
+
"""(line index, start, end, text) of a node that sits on one line."""
|
|
207
|
+
if n.lineno != n.end_lineno:
|
|
208
|
+
return None
|
|
209
|
+
ln = lines[n.lineno - 1]
|
|
210
|
+
a, b = _col(ln, n.col_offset), _col(ln, n.end_col_offset)
|
|
211
|
+
return n.lineno - 1, a, b, ln[a:b]
|
|
212
|
+
|
|
213
|
+
def inside(n, kind):
|
|
214
|
+
while n in parent:
|
|
215
|
+
n = parent[n]
|
|
216
|
+
if isinstance(n, kind):
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
out = []
|
|
221
|
+
for n in ast.walk(tree):
|
|
222
|
+
if isinstance(n, ast.Constant) and type(n.value) is int and abs(n.value) <= 2:
|
|
223
|
+
p = parent.get(n)
|
|
224
|
+
s = seg(n)
|
|
225
|
+
digits = isinstance(p, ast.Call) and ast.unparse(p.func) == "round" and n in p.args[1:]
|
|
226
|
+
if (s and s[3] == str(n.value) and not isinstance(p, (ast.keyword, ast.arguments))
|
|
227
|
+
and not digits and not inside(n, ast.JoinedStr)):
|
|
228
|
+
for v in sorted({n.value + 1, n.value - 1, 0} - {n.value}):
|
|
229
|
+
if v > 0 or not isinstance(p, ast.UnaryOp): # no -0, no --1
|
|
230
|
+
out.append(("number", s[0], s[1], s[3], str(v)))
|
|
231
|
+
if isinstance(n, ast.Call):
|
|
232
|
+
name = ast.unparse(n.func)
|
|
233
|
+
if name.split(".")[-1] not in SYMMETRIC and not any(isinstance(a, ast.Starred) for a in n.args):
|
|
234
|
+
for x, y in zip(n.args, n.args[1:]):
|
|
235
|
+
sx, sy = seg(x), seg(y)
|
|
236
|
+
if sx and sy and sx[0] == sy[0] and sx[3] != sy[3]:
|
|
237
|
+
ln = lines[sx[0]]
|
|
238
|
+
out.append(("args", sx[0], sx[1], ln[sx[1]:sy[2]], sy[3] + ln[sx[2]:sy[1]] + sx[3]))
|
|
239
|
+
if name == "range" and len(n.args) == 1 and not n.keywords and not isinstance(n.args[0], LOOSE):
|
|
240
|
+
s = seg(n.args[0])
|
|
241
|
+
if s:
|
|
242
|
+
out.append(("off-by-one", s[0], s[1], s[3], _shift(n.args[0], s[3], -1)))
|
|
243
|
+
out.append(("off-by-one", s[0], s[1], s[3], f"1, {s[3]}"))
|
|
244
|
+
if isinstance(n, ast.Slice) and n.step is None and (n.lower is None) != (n.upper is None):
|
|
245
|
+
bound, by = (n.lower, 1) if n.lower is not None else (n.upper, -1)
|
|
246
|
+
s = None if isinstance(bound, LOOSE) else seg(bound)
|
|
247
|
+
if s:
|
|
248
|
+
out.append(("off-by-one", s[0], s[1], s[3], _shift(bound, s[3], by)))
|
|
249
|
+
if isinstance(n, (ast.Break, ast.Continue)):
|
|
250
|
+
s = seg(n)
|
|
251
|
+
if s:
|
|
252
|
+
out.append(("break-continue", s[0], s[1], s[3], "continue" if s[3] == "break" else "break"))
|
|
253
|
+
if isinstance(n, ast.Expr) and isinstance(n.value, ast.Call) and not QUIET.search(ast.unparse(n.value.func)):
|
|
254
|
+
s = seg(n)
|
|
255
|
+
if s and lines[s[0]].strip() == s[3]:
|
|
256
|
+
p = parent[n]
|
|
257
|
+
block = next((getattr(p, f) for f in ("body", "orelse", "finalbody")
|
|
258
|
+
if any(x is n for x in getattr(p, f, []))), [n, n])
|
|
259
|
+
if len(block) == 1:
|
|
260
|
+
out.append(("remove-call", s[0], s[1], s[3], "pass"))
|
|
261
|
+
else:
|
|
262
|
+
out.append(("remove-call", s[0], 0, lines[s[0]], None))
|
|
263
|
+
if isinstance(n, ast.AugAssign) and type(n.op).__name__ in AUG_OP \
|
|
264
|
+
and n.target.end_lineno == n.value.lineno == n.lineno:
|
|
265
|
+
ln = lines[n.lineno - 1]
|
|
266
|
+
op = AUG_OP[type(n.op).__name__]
|
|
267
|
+
k = ln.find(op, _col(ln, n.target.end_col_offset), _col(ln, n.value.col_offset))
|
|
268
|
+
if k >= 0:
|
|
269
|
+
out.append(("augassign", n.lineno - 1, k, op, AUG[op]))
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _shift(node: ast.AST, text: str, by: int) -> str:
|
|
274
|
+
"""`text` moved by one: a literal is computed (`v[:0]`, never the tell-tale
|
|
275
|
+
`v[:1 - 1]`), anything else gets `+ 1` / `- 1`."""
|
|
276
|
+
value = node.value if isinstance(node, ast.Constant) else None
|
|
277
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant) \
|
|
278
|
+
and isinstance(node.op, (ast.USub, ast.UAdd)) and type(node.operand.value) is int:
|
|
279
|
+
value = -node.operand.value if isinstance(node.op, ast.USub) else node.operand.value
|
|
280
|
+
if type(value) is int:
|
|
281
|
+
return str(value + by)
|
|
282
|
+
return f"{text} {'+' if by > 0 else '-'} 1"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _dead_lines(tree: ast.AST) -> set[int]:
|
|
286
|
+
"""Lines where a change rarely changes behaviour: an `if __name__ ==
|
|
287
|
+
"__main__":` block, and statements after a return/raise/break/continue."""
|
|
288
|
+
dead = set()
|
|
289
|
+
for n in ast.walk(tree):
|
|
290
|
+
if (isinstance(n, ast.If) and isinstance(n.test, ast.Compare)
|
|
291
|
+
and isinstance(n.test.left, ast.Name) and n.test.left.id == "__name__"):
|
|
292
|
+
dead.update(range(n.lineno, n.end_lineno + 1))
|
|
293
|
+
for field in ("body", "orelse", "finalbody"):
|
|
294
|
+
stmts = getattr(n, field, None)
|
|
295
|
+
if not isinstance(stmts, list):
|
|
296
|
+
continue
|
|
297
|
+
stop = next((i for i, s in enumerate(stmts)
|
|
298
|
+
if isinstance(s, (ast.Return, ast.Raise, ast.Break, ast.Continue))), None)
|
|
299
|
+
for s in stmts[stop + 1:] if stop is not None else []:
|
|
300
|
+
dead.update(range(s.lineno, s.end_lineno + 1))
|
|
301
|
+
return dead
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _col(line: str, byte_offset: int) -> int:
|
|
305
|
+
"""ast columns count UTF-8 bytes; slicing needs characters."""
|
|
306
|
+
return len(line.encode("utf-8")[:byte_offset].decode("utf-8", "ignore"))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def apply(body: list[str], cand: tuple) -> list[str] | None:
|
|
310
|
+
"""The changed lines, or None if the text is not where the candidate says."""
|
|
311
|
+
_, line, col, old, new = cand
|
|
312
|
+
if new is None:
|
|
313
|
+
return body[:line] + body[line + 1:]
|
|
314
|
+
if body[line][col:col + len(old)] != old:
|
|
315
|
+
return None
|
|
316
|
+
out = list(body)
|
|
317
|
+
out[line] = out[line][:col] + new + out[line][col + len(old):]
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def answer_lines(cand: tuple, length: int) -> list[int]:
|
|
322
|
+
"""Lines a correct answer may name: the changed one, or either side of a deleted one."""
|
|
323
|
+
_, line, _, _, new = cand
|
|
324
|
+
if new is None:
|
|
325
|
+
return [n for n in (line, line + 1) if 1 <= n <= length]
|
|
326
|
+
return [line + 1]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parses(body: list[str]) -> bool:
|
|
330
|
+
try:
|
|
331
|
+
ast.parse("\n".join(_dedent(body)) + "\n")
|
|
332
|
+
return True
|
|
333
|
+
except SyntaxError:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _indent(body: list[str]) -> int:
|
|
338
|
+
return len(body[0]) - len(body[0].lstrip())
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _dedent(body: list[str]) -> list[str]:
|
|
342
|
+
n = _indent(body)
|
|
343
|
+
return [ln[n:] if ln[:n].strip() == "" else ln for ln in body]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def numbered(body: list[str]) -> str:
|
|
347
|
+
width = len(str(len(body)))
|
|
348
|
+
return "\n".join(f"{i:>{width}}| {ln}" for i, ln in enumerate(body, 1))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def write(task_id: str, spec: dict) -> None:
|
|
352
|
+
d = TASKS / task_id
|
|
353
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
(d / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main() -> None:
|
|
358
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
359
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
360
|
+
ap.add_argument("--repo", default=DEFAULT_REPO, choices=sorted(REPOS),
|
|
361
|
+
help="a key in config/repos.json")
|
|
362
|
+
ap.add_argument("--count", type=int, default=6)
|
|
363
|
+
ap.add_argument("--seed", type=int, default=None)
|
|
364
|
+
ap.add_argument("--hard", action="store_true",
|
|
365
|
+
help="whole files of 100-400 lines, and no hint at the kind of change")
|
|
366
|
+
ap.add_argument("--kinds", help=f"comma-separated, from: {', '.join(KINDS)}")
|
|
367
|
+
args = ap.parse_args()
|
|
368
|
+
wanted = args.kinds.split(",") if args.kinds else (list(KINDS) if args.hard else list(TOKEN_KINDS))
|
|
369
|
+
unknown = set(wanted) - set(KINDS)
|
|
370
|
+
if unknown:
|
|
371
|
+
sys.exit(f"unknown kind(s): {', '.join(sorted(unknown))}; choose from {', '.join(KINDS)}")
|
|
372
|
+
kinds = [k for k in KINDS if k in wanted]
|
|
373
|
+
rng = random.Random(args.seed)
|
|
374
|
+
source_of = files if args.hard else functions
|
|
375
|
+
pool = [(rel, name, body, c) for rel, name, body in source_of(REPOS[args.repo])
|
|
376
|
+
if (c := candidates(body, kinds))]
|
|
377
|
+
taken = {json.loads(p.read_text(encoding="utf-8")).get("source", "")
|
|
378
|
+
for p in TASKS.glob("seeded-*/task.json")}
|
|
379
|
+
pool = [p for p in pool if f"{p[0]}::{p[1]}" not in taken]
|
|
380
|
+
if not pool:
|
|
381
|
+
sys.exit("no unused function with a change of these kinds")
|
|
382
|
+
for rel, name, body, cands in rng.sample(pool, min(args.count, len(pool))):
|
|
383
|
+
bugged = cand = None
|
|
384
|
+
while cands and bugged is None:
|
|
385
|
+
kind = rng.choice(sorted({c[0] for c in cands})) # kind first, so rare kinds get picked
|
|
386
|
+
cand = rng.choice([c for c in cands if c[0] == kind])
|
|
387
|
+
cands.remove(cand)
|
|
388
|
+
b = apply(body, cand)
|
|
389
|
+
bugged = b if b and b != body and parses(b) else None
|
|
390
|
+
if bugged is None:
|
|
391
|
+
continue
|
|
392
|
+
kind, line, _, old, new = cand
|
|
393
|
+
slug = ("hard-" if args.hard else "") + re.sub(r"[^a-z0-9]+", "-", f"{pathlib.Path(rel).stem}-{name}".lower()).strip("-")[:40]
|
|
394
|
+
source = f"{rel}::{name}"
|
|
395
|
+
common = {"draft": True, "shape": "seeded", "repo": args.repo, "source": source,
|
|
396
|
+
"score_in": "first_line", "min_answer_chars": 8}
|
|
397
|
+
n = line + 1
|
|
398
|
+
keys = answer_lines(cand, len(bugged))
|
|
399
|
+
change = "(line removed)" if new is None else repr(new.strip() or "(removed)")
|
|
400
|
+
write(f"seeded-{slug}-bug", {
|
|
401
|
+
**common, "kind": kind, "id": f"seeded-{slug}-bug", "title": "Seeded bug: name the changed line",
|
|
402
|
+
"why": (f"Seeded in {source}, line {n} ({kind}): {old.strip()!r} -> {change}.\n"
|
|
403
|
+
f" before: {body[line].strip()}\n"
|
|
404
|
+
f" after: {'(line removed)' if new is None else bugged[line].strip()}\n"
|
|
405
|
+
"Check the change alters behaviour before removing draft."),
|
|
406
|
+
"prompt": prompt_for(args.hard, kinds, numbered(bugged)),
|
|
407
|
+
"must_find": [{"regex": rf"^\W*fault:\s*line\s*(?:{'|'.join(map(str, keys))})\b",
|
|
408
|
+
"label": f"fault: line {' or '.join(map(str, keys))}"}],
|
|
409
|
+
"must_not_claim": ["no fault"]})
|
|
410
|
+
write(f"seeded-{slug}-control", {
|
|
411
|
+
**common, "id": f"seeded-{slug}-control", "title": "Seeded-bug control: say NO FAULT",
|
|
412
|
+
"why": f"Untouched {source}; the paired bug task changed line {n}. The answer is NO FAULT.",
|
|
413
|
+
"prompt": prompt_for(args.hard, kinds, numbered(body)),
|
|
414
|
+
"must_find": [{"any_of": ["no fault"]}],
|
|
415
|
+
"must_not_claim": [{"regex": r"^\W*fault:\s*line", "label": "fault: line"}]})
|
|
416
|
+
print(f" {source}: line {n} {kind} {old.strip()!r} -> {change} -> seeded-{slug}-bug / -control")
|
|
417
|
+
print("drafts written; review each bug task's `why`, then remove \"draft\": true")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
if __name__ == "__main__":
|
|
421
|
+
main()
|