py-harness-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- finetune/__init__.py +1 -0
- finetune/agent_system.py +41 -0
- finetune/agent_traces.py +157 -0
- finetune/everyday.py +30 -0
- finetune/hf_ollama.py +158 -0
- finetune/huggingface_store.py +144 -0
- finetune/models.py +74 -0
- finetune/paths.py +11 -0
- finetune/python_vibe.py +788 -0
- finetune/splits.py +54 -0
- finetune/systems.py +9 -0
- harness/__init__.py +42 -0
- harness/__main__.py +8 -0
- harness/act/__init__.py +6 -0
- harness/act/autofix/__init__.py +110 -0
- harness/act/autofix/additions.py +217 -0
- harness/act/autofix/conflicts.py +124 -0
- harness/act/autofix/cover.py +419 -0
- harness/act/autofix/mechanical.py +151 -0
- harness/act/autofix/missing_imports.py +50 -0
- harness/act/autofix/moves.py +439 -0
- harness/act/autofix/names.py +339 -0
- harness/act/autofix/scaffold.py +224 -0
- harness/act/code.py +157 -0
- harness/act/gate.py +229 -0
- harness/act/parse.py +247 -0
- harness/act/patch_fix.py +138 -0
- harness/act/tools.py +244 -0
- harness/agent/__init__.py +11 -0
- harness/agent/dispatch.py +235 -0
- harness/agent/loop.py +699 -0
- harness/agent/options.py +144 -0
- harness/agent/policy.py +856 -0
- harness/agent/prompt.py +170 -0
- harness/cli.py +393 -0
- harness/editor_kit.py +265 -0
- harness/guard/__init__.py +6 -0
- harness/guard/fallbacks.py +6 -0
- harness/guard/loop_guard.py +57 -0
- harness/guard/python_vibe.py +68 -0
- harness/guard/run.py +41 -0
- harness/guard/types.py +19 -0
- harness/locate.py +767 -0
- harness/mcp_stdio.py +306 -0
- harness/memory/__init__.py +5 -0
- harness/memory/conversation.py +104 -0
- harness/model/__init__.py +6 -0
- harness/model/chat_backend.py +100 -0
- harness/model/engine.py +165 -0
- harness/model/ollama_generate.py +60 -0
- harness/model/openai_generate.py +156 -0
- harness/model/outbound.py +83 -0
- harness/model/route.py +90 -0
- harness/observe/__init__.py +6 -0
- harness/observe/eval_gate.py +80 -0
- harness/observe/eval_loop.py +185 -0
- harness/observe/eval_tasks.py +399 -0
- harness/observe/report_md.py +102 -0
- harness/observe/trace_record.py +79 -0
- harness/openai_api.py +81 -0
- harness/paths.py +88 -0
- harness/py.typed +0 -0
- harness/scan/__init__.py +6 -0
- harness/scan/app_spec.py +338 -0
- harness/scan/design.py +112 -0
- harness/scan/existing.py +131 -0
- harness/scan/layout.py +254 -0
- harness/scan/names.py +308 -0
- harness/scan/project_brief.py +287 -0
- harness/scan/project_docs.py +42 -0
- harness/scan/project_scan.py +49 -0
- harness/scan/repo_map.py +101 -0
- harness/secrets.py +39 -0
- harness/server.py +199 -0
- harness/ship/__init__.py +1 -0
- harness/ship/bot_pr.py +221 -0
- harness/ship/git_ship.py +262 -0
- harness/ship/identity.py +62 -0
- harness/ship/ticket.py +251 -0
- harness/skillkit/__init__.py +6 -0
- harness/skillkit/catalog.py +241 -0
- harness/skillkit/refuse_change.py +640 -0
- harness/skillkit/refuse_finish.py +295 -0
- harness/skillkit/target.py +238 -0
- harness/task.py +717 -0
- py_harness_cli-0.3.0.dist-info/METADATA +177 -0
- py_harness_cli-0.3.0.dist-info/RECORD +92 -0
- py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
- py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
- py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
- py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Whether one proposed change may be written.
|
|
2
|
+
|
|
3
|
+
Each rule reads a draft and answers with a refusal or nothing. They
|
|
4
|
+
are run in order by `CHANGE_RULES` in `act/gate.py`, and each was
|
|
5
|
+
written from a run that went wrong: a module shadowing the standard
|
|
6
|
+
library, a function whose body was `...`, a test asserting nothing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
"""SoC / readable-name guards. Deterministic. No model."""
|
|
12
|
+
import ast
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from harness.scan.names import new_undefined, undefined_names
|
|
17
|
+
from harness.task import (
|
|
18
|
+
looks_like_add_feature,
|
|
19
|
+
looks_like_bugfix,
|
|
20
|
+
looks_like_design_loop,
|
|
21
|
+
looks_like_everyday_code,
|
|
22
|
+
looks_like_fix_smell,
|
|
23
|
+
question_symbol,
|
|
24
|
+
rename_pair,
|
|
25
|
+
smell_symbol,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_DEF = re.compile(r"^def\s+([A-Za-z_]\w*)\s*\(", re.MULTILINE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_CLASS = re.compile(r"^class\s+([A-Za-z_]\w*)", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_OK_PARAM = frozenset({"self", "cls", "i", "j", "k"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_OPAQUE = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"btn",
|
|
41
|
+
"calc",
|
|
42
|
+
"data",
|
|
43
|
+
"do",
|
|
44
|
+
"fn",
|
|
45
|
+
"foo",
|
|
46
|
+
"bar",
|
|
47
|
+
"baz",
|
|
48
|
+
"func",
|
|
49
|
+
"helper",
|
|
50
|
+
"mgr",
|
|
51
|
+
"misc",
|
|
52
|
+
"obj",
|
|
53
|
+
"proc",
|
|
54
|
+
"stuff",
|
|
55
|
+
"temp",
|
|
56
|
+
"thing",
|
|
57
|
+
"tmp",
|
|
58
|
+
"util",
|
|
59
|
+
"val",
|
|
60
|
+
"var",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _opaque_param(draft: str, asked: frozenset[str] = frozenset()) -> str:
|
|
66
|
+
for match in re.finditer(r"^def\s+\w+\s*\((.*?)\)", draft, re.MULTILINE | re.DOTALL):
|
|
67
|
+
for part in match.group(1).split(","):
|
|
68
|
+
token = part.strip()
|
|
69
|
+
if not token or token.startswith("*"):
|
|
70
|
+
continue
|
|
71
|
+
name = token.split(":")[0].split("=")[0].strip()
|
|
72
|
+
if name in _OK_PARAM or name in asked:
|
|
73
|
+
continue
|
|
74
|
+
if len(name) == 1 or name in _OPAQUE:
|
|
75
|
+
return (
|
|
76
|
+
f"opaque parameter {name}. Use a readable noun "
|
|
77
|
+
f"(quantity, unit_price), not x or tmp."
|
|
78
|
+
)
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_SHELL_FETCH = re.compile(
|
|
83
|
+
r"(?m)^\s*(os\.system|subprocess\.|Popen)|"
|
|
84
|
+
r"\b(curl|wget)\s+\S"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_TASK_SIGNATURE = re.compile(r"\b\w+\s*\(\s*([^)]*?)\s*\)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_ABOUT_FILES = re.compile(
|
|
92
|
+
r"\b(file|files|path|paths|read|reads|load|loads|parse|parses|"
|
|
93
|
+
r"open|opens|contents|lines of|\.env|config|json|csv|yaml|toml)\b",
|
|
94
|
+
re.I,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def task_names_arguments(task: str) -> str:
|
|
99
|
+
"""The argument list the task itself gives, or ""."""
|
|
100
|
+
symbol = question_symbol(task)
|
|
101
|
+
if not symbol:
|
|
102
|
+
return ""
|
|
103
|
+
found = re.search(rf"\b{re.escape(symbol)}\s*\(\s*([^)]*?)\s*\)", task)
|
|
104
|
+
return found.group(1).strip() if found else ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def refuse_add_opens_file(task: str, rel: str, draft: str) -> str:
|
|
108
|
+
"""Counting the prices of an order does not mean opening a file.
|
|
109
|
+
|
|
110
|
+
A live 8B read `add a function total_lines` in an orders module as a
|
|
111
|
+
file-line counter and appended `open(file_path)`.
|
|
112
|
+
|
|
113
|
+
The first version of this refused `open(` in any added function and
|
|
114
|
+
told the model to write `return len(prices)` instead. That is only
|
|
115
|
+
right for the one task it was written for. `read_env_file(path)` has
|
|
116
|
+
to open a file — refusing it left a function returning an int where
|
|
117
|
+
the caller wanted a dict, and the run spent its whole budget being
|
|
118
|
+
told to write something else. So this now declines to judge whenever
|
|
119
|
+
the task itself mentions files, or names its own arguments.
|
|
120
|
+
"""
|
|
121
|
+
if not looks_like_add_feature(task):
|
|
122
|
+
return ""
|
|
123
|
+
if "test" in (rel or "").replace("\\", "/").lower():
|
|
124
|
+
return ""
|
|
125
|
+
if not draft or not re.search(r"\bopen\s*\(", draft):
|
|
126
|
+
return ""
|
|
127
|
+
if _ABOUT_FILES.search(task):
|
|
128
|
+
return ""
|
|
129
|
+
if task_names_arguments(task):
|
|
130
|
+
return ""
|
|
131
|
+
symbol = question_symbol(task) or "the_new_function"
|
|
132
|
+
return (
|
|
133
|
+
f"Reading a file is not what {symbol} was asked for. "
|
|
134
|
+
f"Action: patch Path: {rel} Append: def {symbol}(...) using the "
|
|
135
|
+
"values the module already has."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def refuse_stub_body(task: str, rel: str, draft: str) -> str:
|
|
140
|
+
"""Refuse a requested function whose body is `...` or `pass`.
|
|
141
|
+
|
|
142
|
+
Asked to create `slugify`, a live 8B wrote
|
|
143
|
+
|
|
144
|
+
def slugify(text: str) -> str: ...
|
|
145
|
+
|
|
146
|
+
twice. It parses, it passes a name check, and it does nothing. A
|
|
147
|
+
stub is a reasonable thing to write when someone asked for an
|
|
148
|
+
interface; it is not what "create a function that lowercases and
|
|
149
|
+
joins words" asked for.
|
|
150
|
+
|
|
151
|
+
Every function in the draft is judged, not only the one the task
|
|
152
|
+
names. The task wording does not reliably yield that name — for
|
|
153
|
+
"create a function slugify(text)" it comes back as "create" — and on
|
|
154
|
+
an add-feature task an empty body is wrong whatever it is called.
|
|
155
|
+
"""
|
|
156
|
+
if "test" in (rel or "").replace("\\", "/").lower():
|
|
157
|
+
return ""
|
|
158
|
+
if not (looks_like_add_feature(task) or looks_like_everyday_code(task)):
|
|
159
|
+
return ""
|
|
160
|
+
try:
|
|
161
|
+
tree = ast.parse(draft or "")
|
|
162
|
+
except (SyntaxError, ValueError):
|
|
163
|
+
return ""
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
166
|
+
continue
|
|
167
|
+
body = [
|
|
168
|
+
item
|
|
169
|
+
for item in node.body
|
|
170
|
+
if not (
|
|
171
|
+
isinstance(item, ast.Expr)
|
|
172
|
+
and isinstance(item.value, ast.Constant)
|
|
173
|
+
and isinstance(item.value.value, str)
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
empty = all(
|
|
177
|
+
isinstance(item, ast.Pass)
|
|
178
|
+
or (
|
|
179
|
+
isinstance(item, ast.Expr)
|
|
180
|
+
and isinstance(item.value, ast.Constant)
|
|
181
|
+
and item.value.value is Ellipsis
|
|
182
|
+
)
|
|
183
|
+
for item in body
|
|
184
|
+
)
|
|
185
|
+
if body and empty:
|
|
186
|
+
return (
|
|
187
|
+
f"def {node.name} has no body. Write what it does, not a "
|
|
188
|
+
f"placeholder. Action: patch Path: {rel} Append: def "
|
|
189
|
+
f"{node.name}(...) with a return statement."
|
|
190
|
+
)
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def refuse_shell_fetch(rel: str, draft: str) -> str:
|
|
195
|
+
"""HTTP helpers use urllib. curl|sh is already PV003; this catches curl alone.
|
|
196
|
+
|
|
197
|
+
Test files may quote the blocked pattern, so they are not judged.
|
|
198
|
+
"""
|
|
199
|
+
if "test" in rel.replace("\\", "/").lower():
|
|
200
|
+
return ""
|
|
201
|
+
if not draft or not _SHELL_FETCH.search(draft):
|
|
202
|
+
return ""
|
|
203
|
+
return "urllib.request only. Do not emit curl, wget, or os.system."
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_PIPE_SH = re.compile(r"curl\s+\S+.*\|\s*(sh|bash)|wget\s+\S+.*\|\s*(sh|bash)", re.I)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_BIND_ALL = re.compile(r"0\.0\.0\.0")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_INLINE_SECRET = re.compile(
|
|
213
|
+
r"""(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*['"][^'"]{8,}['"]"""
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def refuse_ops_draft(rel: str, draft: str) -> str:
|
|
218
|
+
"""Workflow YAML stays a test runner. No installer pipe, no public bind."""
|
|
219
|
+
suffix = Path(rel or "").suffix.lower()
|
|
220
|
+
if suffix not in {".yml", ".yaml"} and "workflow" not in (rel or "").lower():
|
|
221
|
+
return ""
|
|
222
|
+
if not draft:
|
|
223
|
+
return ""
|
|
224
|
+
if _PIPE_SH.search(draft):
|
|
225
|
+
return "no curl|sh in a workflow. Run unittest only."
|
|
226
|
+
if _BIND_ALL.search(draft):
|
|
227
|
+
return "do not bind 0.0.0.0. Keep the sidecar on 127.0.0.1."
|
|
228
|
+
if _INLINE_SECRET.search(draft):
|
|
229
|
+
return "no inline secrets. Use the runner's secret store, or omit."
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_OS_PATH = re.compile(
|
|
234
|
+
r"\bos\.path\.(join|exists|isfile|isdir|dirname|abspath|basename|"
|
|
235
|
+
r"expanduser|normpath)\b"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_HARD_HOME = re.compile(
|
|
240
|
+
r"""['"](?:/Users/|/home/|[A-Za-z]:\\Users\\|[A-Za-z]:\\\\Users\\\\)"""
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_HARD_TMP = re.compile(r"""['"]/tmp/""")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_POSIX_ONLY_VENV = re.compile(r"""['"]bin/python['"]""")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_OPEN_CALL = re.compile(r"\bopen\(([^)]*)\)")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def refuse_platform_draft(rel: str, draft: str) -> str:
|
|
254
|
+
"""Path helpers stay pathlib and work on Windows, macOS, and Linux.
|
|
255
|
+
|
|
256
|
+
Test files may quote a blocked pattern, so they are not judged.
|
|
257
|
+
"""
|
|
258
|
+
if "test" in (rel or "").replace("\\", "/").lower():
|
|
259
|
+
return ""
|
|
260
|
+
if not draft:
|
|
261
|
+
return ""
|
|
262
|
+
if _OS_PATH.search(draft):
|
|
263
|
+
return (
|
|
264
|
+
"use pathlib. Path / 'src' / 'app.py', not os.path.join. "
|
|
265
|
+
"exists is Path.exists()."
|
|
266
|
+
)
|
|
267
|
+
if _HARD_HOME.search(draft):
|
|
268
|
+
return "no hardcoded home. Use Path.home()."
|
|
269
|
+
if _HARD_TMP.search(draft):
|
|
270
|
+
return "no hardcoded /tmp. Use tempfile.TemporaryDirectory."
|
|
271
|
+
if _POSIX_ONLY_VENV.search(draft) and "Scripts" not in draft:
|
|
272
|
+
return (
|
|
273
|
+
"venv interpreter is Scripts/python.exe on Windows, "
|
|
274
|
+
"bin/python on POSIX. Branch on os.name."
|
|
275
|
+
)
|
|
276
|
+
for match in _OPEN_CALL.finditer(draft):
|
|
277
|
+
args = match.group(1)
|
|
278
|
+
if "encoding" in args:
|
|
279
|
+
continue
|
|
280
|
+
if re.search(r"['\"][^'\"]*b", args):
|
|
281
|
+
continue
|
|
282
|
+
return 'open(..., encoding="utf-8"). Text files need an encoding.'
|
|
283
|
+
if ".chmod(" in draft and "nt" not in draft:
|
|
284
|
+
return 'chmod is POSIX-only. Guard with os.name != "nt".'
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# A task that spells a signature has chosen the names in it. `add a
|
|
289
|
+
# function double(n) that returns n times two` asks for `n`, and a rule
|
|
290
|
+
# that refuses `n` there is arguing with the person who typed it.
|
|
291
|
+
_SIGNATURE = re.compile(r"\b([a-zA-Z_]\w*)\s*\(([^)]*)\)")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def names_the_task_asked_for(task: str) -> frozenset[str]:
|
|
295
|
+
"""Every function and parameter name the task spells out itself."""
|
|
296
|
+
asked: set[str] = set()
|
|
297
|
+
for match in _SIGNATURE.finditer(task or ""):
|
|
298
|
+
asked.add(match.group(1))
|
|
299
|
+
for part in match.group(2).split(","):
|
|
300
|
+
name = part.strip().split(":")[0].split("=")[0].strip()
|
|
301
|
+
if name and name.isidentifier():
|
|
302
|
+
asked.add(name)
|
|
303
|
+
return frozenset(asked)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def refuse_opaque_names(draft: str, task: str = "") -> str:
|
|
307
|
+
"""Refuse a name nobody can read, unless the task asked for it.
|
|
308
|
+
|
|
309
|
+
This rule was written and tested, and nothing ever called it. It sat
|
|
310
|
+
from the test that checks every draft rule is in the table, behind a
|
|
311
|
+
comment saying it was "called from the draft rules themselves",
|
|
312
|
+
which was not true.
|
|
313
|
+
|
|
314
|
+
Wiring it as it stood would have refused the benchmark's own tier-1
|
|
315
|
+
case: the task is `add a function double(n) that returns n times
|
|
316
|
+
two`, and `n` is the parameter the task names. Refusing a draft for
|
|
317
|
+
doing what it was told is worse than not checking at all, and is
|
|
318
|
+
the likeliest reason this was left dead rather than fixed.
|
|
319
|
+
"""
|
|
320
|
+
if not draft.strip():
|
|
321
|
+
return ""
|
|
322
|
+
asked = names_the_task_asked_for(task)
|
|
323
|
+
for match in _DEF.finditer(draft):
|
|
324
|
+
name = match.group(1)
|
|
325
|
+
if name.startswith("test_") or (name.startswith("__") and name.endswith("__")):
|
|
326
|
+
continue
|
|
327
|
+
if name in asked:
|
|
328
|
+
continue
|
|
329
|
+
if len(name) == 1 or name in _OPAQUE:
|
|
330
|
+
return (
|
|
331
|
+
f"opaque name {name}. Use a readable snake_case verb_noun "
|
|
332
|
+
f"(total_price, not calc or tmp)."
|
|
333
|
+
)
|
|
334
|
+
if name != name.lower() or any(ch.isupper() for ch in name):
|
|
335
|
+
return f"functions are snake_case: {name}"
|
|
336
|
+
param = _opaque_param(draft, asked)
|
|
337
|
+
if param:
|
|
338
|
+
return param
|
|
339
|
+
for match in _CLASS.finditer(draft):
|
|
340
|
+
name = match.group(1)
|
|
341
|
+
if name[0].islower() or "_" in name:
|
|
342
|
+
return f"classes are PascalCase: {name}"
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def refuse_smell_wrong_file(
|
|
347
|
+
task: str,
|
|
348
|
+
action: str,
|
|
349
|
+
path: str,
|
|
350
|
+
located_path: str,
|
|
351
|
+
located_body: str = "",
|
|
352
|
+
) -> str:
|
|
353
|
+
if not looks_like_fix_smell(task) or action != "patch" or not located_path:
|
|
354
|
+
return ""
|
|
355
|
+
rel = path.replace("\\", "/").lower()
|
|
356
|
+
located = located_path.replace("\\", "/").lower()
|
|
357
|
+
if "test" not in rel or "test" in located:
|
|
358
|
+
return ""
|
|
359
|
+
old = smell_symbol(task)
|
|
360
|
+
if old and located_body and not re.search(rf"\bdef\s+{re.escape(old)}\b", located_body):
|
|
361
|
+
return ""
|
|
362
|
+
return (
|
|
363
|
+
f"rename the implementation first. "
|
|
364
|
+
f"Action: patch Path: {located_path} Find: the old def line."
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_TEST_DEF = re.compile(r"^[ \t]*def\s+(test_\w+)\s*\(", re.MULTILINE)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_TEST_CLASS = re.compile(r"^[ \t]*class\s+(\w+)\s*\(", re.MULTILINE)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_ONE_SHOT_ASSERT = re.compile(
|
|
375
|
+
r"self\.assert\w+\s*\(\s*[A-Za-z_]\w+\s*\(",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
_OPAQUE_TEST_PART = frozenset(
|
|
380
|
+
{"bar", "foo", "fn", "func", "it", "ok", "tmp", "works"}
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_ACT_NAMES = ("got", "actual", "result", "outcome")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def refuse_weak_test(rel: str, draft: str) -> str:
|
|
388
|
+
"""Refuse a newly written test that is unclear about what it checks.
|
|
389
|
+
|
|
390
|
+
Calibrated against this project's own tests: none of them is refused.
|
|
391
|
+
A rule that rejects the code it is shipped with is not a style rule, it
|
|
392
|
+
is an obstacle, so `tests/test_refusals.py` checks that directly.
|
|
393
|
+
|
|
394
|
+
Only a draft holding a single test is judged on arrangement. A whole
|
|
395
|
+
file holds many tests written over time, and judging it as one unit
|
|
396
|
+
turns one inline assertion anywhere into a refusal for everything.
|
|
397
|
+
"""
|
|
398
|
+
posix = rel.replace("\\", "/").lower()
|
|
399
|
+
if "test" not in posix and "def test_" not in (draft or ""):
|
|
400
|
+
return ""
|
|
401
|
+
if "def test_" not in (draft or ""):
|
|
402
|
+
return ""
|
|
403
|
+
one_test = len(_TEST_DEF.findall(draft)) == 1
|
|
404
|
+
for match in _TEST_CLASS.finditer(draft):
|
|
405
|
+
name = match.group(1)
|
|
406
|
+
if name.startswith("test_") or name in {"Test", "Tests"} or "_" in name:
|
|
407
|
+
return (
|
|
408
|
+
f"class name {name}. Use Test<Unit> PascalCase "
|
|
409
|
+
"(TestMultiply), not Tests or test_mathy."
|
|
410
|
+
)
|
|
411
|
+
for match in _TEST_DEF.finditer(draft):
|
|
412
|
+
name = match.group(1)
|
|
413
|
+
parts = name.split("_")
|
|
414
|
+
# A short name is only a problem when it says nothing: test_total
|
|
415
|
+
# and test_health name their subject, test_ok and test_works do not.
|
|
416
|
+
# An opaque word only makes a name opaque when it carries the
|
|
417
|
+
# meaning. test_it_works says nothing;
|
|
418
|
+
# test_the_suite_passes_before_the_agent_touches_it says plenty and
|
|
419
|
+
# happens to end in "it".
|
|
420
|
+
if len(parts) <= 3 and any(part in _OPAQUE_TEST_PART for part in parts[1:]):
|
|
421
|
+
return (
|
|
422
|
+
f"opaque test name {name}. "
|
|
423
|
+
"Name the behavior, not it/fn/ok/works."
|
|
424
|
+
)
|
|
425
|
+
# Anchored to the start of a line so the phrase inside a string literal,
|
|
426
|
+
# which is how several of this project's own tests carry sample code,
|
|
427
|
+
# is not mistaken for the statement.
|
|
428
|
+
if re.search(r"^[ \t]*assert\s+True\b", draft, re.MULTILINE):
|
|
429
|
+
return "do not assert True. Assert the value you computed."
|
|
430
|
+
named_act = any(re.search(rf"\b{name}\s*=", draft) for name in _ACT_NAMES)
|
|
431
|
+
if one_test and _ONE_SHOT_ASSERT.search(draft) and not named_act:
|
|
432
|
+
return (
|
|
433
|
+
"use AAA. Arrange inputs, Act: got = multiply(left, right), "
|
|
434
|
+
"Assert: self.assertEqual(got, expected)."
|
|
435
|
+
)
|
|
436
|
+
return ""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def refuse_test_in_impl(rel: str, draft: str) -> str:
|
|
440
|
+
"""Tests belong in tests/. Live 8B wrote def test_ into src/orders.py."""
|
|
441
|
+
posix = (rel or "").replace("\\", "/").lower()
|
|
442
|
+
if "test" in posix or posix.endswith("conftest.py"):
|
|
443
|
+
return ""
|
|
444
|
+
if re.search(r"^[ \t]*def test_", draft or "", re.MULTILINE):
|
|
445
|
+
return (
|
|
446
|
+
"tests go in tests/test_<unit>.py. "
|
|
447
|
+
"Do not write def test_ in the implementation file."
|
|
448
|
+
)
|
|
449
|
+
return ""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _undefined_message(rel: str, name: str) -> str:
|
|
453
|
+
"""Say how to bind the name, not just that it is unbound.
|
|
454
|
+
|
|
455
|
+
`Path` used without `from pathlib import Path` was answered with
|
|
456
|
+
"Find: Path Replace: the name you assigned", which asks for a rename
|
|
457
|
+
when the fix is an import line.
|
|
458
|
+
"""
|
|
459
|
+
from harness.scan.names import import_for
|
|
460
|
+
|
|
461
|
+
line = import_for(name)
|
|
462
|
+
if line:
|
|
463
|
+
return (
|
|
464
|
+
f"{name} is used but never imported. "
|
|
465
|
+
f"Action: patch Path: {rel} Find: {line.split()[-1]} "
|
|
466
|
+
f"Replace: {line.split()[-1]} # then add at the top: {line}"
|
|
467
|
+
)
|
|
468
|
+
return (
|
|
469
|
+
f"undefined name {name}. "
|
|
470
|
+
f"Action: patch Path: {rel} Find: {name} "
|
|
471
|
+
"Replace: the name you assigned."
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def refuse_undefined_draft(task: str, rel: str, original: str, draft: str) -> str:
|
|
476
|
+
"""Refuse a write that adds an unbound name, or a bugfix that leaves one."""
|
|
477
|
+
if not draft or not (rel or "").endswith(".py"):
|
|
478
|
+
return ""
|
|
479
|
+
if looks_like_bugfix(task):
|
|
480
|
+
leftover = undefined_names(draft)
|
|
481
|
+
if leftover:
|
|
482
|
+
return _undefined_message(rel, leftover[0])
|
|
483
|
+
return ""
|
|
484
|
+
added = new_undefined(original, draft)
|
|
485
|
+
if added:
|
|
486
|
+
return _undefined_message(rel, added[0]) or (
|
|
487
|
+
f"undefined name {added[0]}. "
|
|
488
|
+
f"Action: patch Path: {rel} Find: {added[0]} "
|
|
489
|
+
"Replace: the name you assigned."
|
|
490
|
+
)
|
|
491
|
+
return ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def refuse_rename_incomplete(task: str, rel: str, draft: str) -> str:
|
|
495
|
+
"""A rename is not done if the old def is still there."""
|
|
496
|
+
if not looks_like_fix_smell(task) or "test" in (rel or "").replace("\\", "/").lower():
|
|
497
|
+
return ""
|
|
498
|
+
old, new = rename_pair(task)
|
|
499
|
+
if not old or not new:
|
|
500
|
+
return ""
|
|
501
|
+
if re.search(rf"^def {re.escape(old)}\b", draft or "", re.MULTILINE):
|
|
502
|
+
return (
|
|
503
|
+
f"still defines {old}. "
|
|
504
|
+
f"Action: patch Find: def {old} Replace: def {new}"
|
|
505
|
+
)
|
|
506
|
+
if not re.search(rf"^def {re.escape(new)}\b", draft or "", re.MULTILINE):
|
|
507
|
+
return (
|
|
508
|
+
f"missing def {new}. "
|
|
509
|
+
f"Action: patch Find: def {old} Replace: def {new}"
|
|
510
|
+
)
|
|
511
|
+
return ""
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def refuse_god_target(task: str, project: Path, action: str, path: str) -> str:
|
|
515
|
+
"""Design-loop writes go to a new one-function file, not the god module."""
|
|
516
|
+
if not looks_like_design_loop(task) or action not in {"edit", "patch"}:
|
|
517
|
+
return ""
|
|
518
|
+
rel = (path or "").replace("\\", "/").lstrip("./")
|
|
519
|
+
if not rel:
|
|
520
|
+
return ""
|
|
521
|
+
target = Path(project) / rel
|
|
522
|
+
if not target.is_file():
|
|
523
|
+
return ""
|
|
524
|
+
try:
|
|
525
|
+
text = target.read_text(encoding="utf-8")
|
|
526
|
+
except OSError:
|
|
527
|
+
return ""
|
|
528
|
+
count = len(re.findall(r"^def ", text, re.MULTILINE))
|
|
529
|
+
if count < 4:
|
|
530
|
+
return ""
|
|
531
|
+
return (
|
|
532
|
+
f"SoC: {rel} already has {count} functions. "
|
|
533
|
+
"Action: edit Path: pkg/<new_concern>.py with only the new function."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
_STDLIB = frozenset(sys.stdlib_module_names)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
_SHADOW_ALLOWED = frozenset({"types", "typing", "test", "tests", "config"})
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# Names that say where a thing was put, not what it is. A reader
|
|
544
|
+
# grepping for the concern finds nothing, because the file name never
|
|
545
|
+
# had one.
|
|
546
|
+
DRAWER_STEMS = frozenset(
|
|
547
|
+
{"common", "helper", "helpers", "misc", "shared", "tmp", "util", "utils"}
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def refuse_opaque_module(rel: str, original: str) -> str:
|
|
552
|
+
"""Reject a *new* file whose name names nothing.
|
|
553
|
+
|
|
554
|
+
`refuse_opaque_names` reads defs, classes and parameters and never
|
|
555
|
+
looked at the file name, so `pkg/helpers.py` was always allowed —
|
|
556
|
+
and an 8B reaches for it constantly.
|
|
557
|
+
|
|
558
|
+
Only a new file. An existing `util.py` in somebody's tree is theirs,
|
|
559
|
+
and refusing to touch it would make the rule about their history
|
|
560
|
+
rather than about this change. Test files are left alone too: a
|
|
561
|
+
`tests/test_util.py` beside a `util.py` is named after the thing it
|
|
562
|
+
tests, which is correct.
|
|
563
|
+
"""
|
|
564
|
+
if original.strip():
|
|
565
|
+
return ""
|
|
566
|
+
path = Path(rel)
|
|
567
|
+
if path.name.startswith("test_") or "tests" in path.parts:
|
|
568
|
+
return ""
|
|
569
|
+
stem = path.stem.lower()
|
|
570
|
+
if stem not in DRAWER_STEMS:
|
|
571
|
+
return ""
|
|
572
|
+
return (
|
|
573
|
+
f"opaque module {path.name}. Name the concern it holds "
|
|
574
|
+
"(pricing.py, not helpers.py), so the next reader can find it."
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def refuse_stdlib_shadow(rel: str, original: str) -> str:
|
|
579
|
+
"""Refuse a new module whose name hides one from the standard library.
|
|
580
|
+
|
|
581
|
+
Asked for a clamp helper, the model created `pkg/math.py`. Every later
|
|
582
|
+
`import math` in that project then finds the new file, and the failure
|
|
583
|
+
appears far from the change that caused it. Only new files are checked:
|
|
584
|
+
a project that already has such a module is its own business.
|
|
585
|
+
"""
|
|
586
|
+
if original.strip():
|
|
587
|
+
return ""
|
|
588
|
+
stem = Path(rel).stem
|
|
589
|
+
if stem not in _STDLIB or stem in _SHADOW_ALLOWED:
|
|
590
|
+
return ""
|
|
591
|
+
return (
|
|
592
|
+
f"{rel} would hide the standard library module {stem}. "
|
|
593
|
+
f"Choose another name, such as {stem}_helpers.py."
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def refuse_layout(rel: str, original: str, draft: str) -> str:
|
|
598
|
+
posix = rel.replace("\\", "/").lstrip("./")
|
|
599
|
+
has_impl = bool(re.search(r"^(async\s+)?(def |class )", draft, re.MULTILINE))
|
|
600
|
+
if posix.endswith("__init__.py") and has_impl:
|
|
601
|
+
return (
|
|
602
|
+
"SoC: __init__.py is exports only. "
|
|
603
|
+
"Action: edit Path: pkg/<noun>.py with the function."
|
|
604
|
+
)
|
|
605
|
+
if (posix.startswith("scripts/") or "/scripts/" in posix) and has_impl:
|
|
606
|
+
if "def main" not in draft:
|
|
607
|
+
return "SoC: library code is not in scripts/. Use pkg/<noun>.py"
|
|
608
|
+
if has_impl and original:
|
|
609
|
+
count = len(re.findall(r"^def ", original, re.MULTILINE))
|
|
610
|
+
if count >= 4:
|
|
611
|
+
return (
|
|
612
|
+
f"SoC: {posix} already has {count} functions. "
|
|
613
|
+
"Action: edit Path: pkg/<new_concern>.py with only the new function."
|
|
614
|
+
)
|
|
615
|
+
return ""
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def wrap_bare_unittest(source: str, symbol: str) -> str:
|
|
619
|
+
"""8B writes def test_*(self) with no TestCase. Wrap it."""
|
|
620
|
+
if "TestCase" in source:
|
|
621
|
+
return source
|
|
622
|
+
if not re.search(r"^def\s+test_\w+\s*\(\s*self", source, re.MULTILINE):
|
|
623
|
+
return source
|
|
624
|
+
class_name = "Test" + "".join(
|
|
625
|
+
part[:1].upper() + part[1:] for part in symbol.split("_") if part
|
|
626
|
+
)
|
|
627
|
+
if class_name == "Test":
|
|
628
|
+
class_name = "TestModule"
|
|
629
|
+
import_line = f"from pkg.{symbol} import {symbol}\n\n" if symbol else ""
|
|
630
|
+
body = "\n".join(
|
|
631
|
+
(" " + line if line.strip() else "") for line in source.strip().splitlines()
|
|
632
|
+
)
|
|
633
|
+
return (
|
|
634
|
+
"import unittest\n\n"
|
|
635
|
+
f"{import_line}"
|
|
636
|
+
f"class {class_name}(unittest.TestCase):\n"
|
|
637
|
+
f"{body}\n\n"
|
|
638
|
+
'if __name__ == "__main__":\n'
|
|
639
|
+
" unittest.main()\n"
|
|
640
|
+
)
|