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,419 @@
|
|
|
1
|
+
"""Writing one test for a function, and choosing what to call it with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
"""Mechanical fixes the 8B fails to express. Deterministic. No model.
|
|
6
|
+
|
|
7
|
+
Live 8B (29 Aug 2026): left `subtotal` unbound after a NameError task, and
|
|
8
|
+
spent twelve `Find:` turns that never matched `def calc(x: int, ...)`.
|
|
9
|
+
Those are compiler jobs. The harness does them, then runs the suite,
|
|
10
|
+
before the first generate. A green suite ends the run without a model.
|
|
11
|
+
"""
|
|
12
|
+
import ast
|
|
13
|
+
import importlib.util
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from harness.act.code import apply_source
|
|
19
|
+
from harness.task import (
|
|
20
|
+
looks_like_file_operation,
|
|
21
|
+
covered_symbol,
|
|
22
|
+
looks_like_add_feature,
|
|
23
|
+
named_project_file,
|
|
24
|
+
question_symbol,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _imports(source: str, name: str) -> bool:
|
|
30
|
+
"""Whether `source` brings `name` in by an import."""
|
|
31
|
+
try:
|
|
32
|
+
tree = ast.parse(source)
|
|
33
|
+
except (SyntaxError, ValueError):
|
|
34
|
+
return False
|
|
35
|
+
for node in ast.walk(tree):
|
|
36
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
37
|
+
for alias in node.names:
|
|
38
|
+
if (alias.asname or alias.name.split(".")[-1]) == name:
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _test_file_for(
|
|
44
|
+
task: str, project: Path, name: str, dests: list[Path]
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Which test file a new test for `name` belongs in.
|
|
47
|
+
|
|
48
|
+
This used to be whichever file sorted first, so covering
|
|
49
|
+
`ticket_job` from `ship/ticket.py` appended the test to
|
|
50
|
+
`tests/test_agent_api.py`. It ran, it passed, and it was filed under
|
|
51
|
+
something it has nothing to do with.
|
|
52
|
+
|
|
53
|
+
In order: the file that already tests this symbol, the one named
|
|
54
|
+
after the module the task points at, and only then the first.
|
|
55
|
+
"""
|
|
56
|
+
for path in dests:
|
|
57
|
+
try:
|
|
58
|
+
body = path.read_text(encoding="utf-8")
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
# A test named after it, or a file that imports it. Merely
|
|
62
|
+
# mentioning the name is not enough — a docstring in an unrelated
|
|
63
|
+
# test file matched and sent the new test there — and a regex is
|
|
64
|
+
# not enough either, because most of these imports are written
|
|
65
|
+
# across several lines inside brackets.
|
|
66
|
+
if re.search(rf"\bdef test_\w*{re.escape(name)}", body) or _imports(
|
|
67
|
+
body, name
|
|
68
|
+
):
|
|
69
|
+
return path
|
|
70
|
+
named = named_project_file(task, project)
|
|
71
|
+
if named:
|
|
72
|
+
stem = Path(named).stem
|
|
73
|
+
for path in dests:
|
|
74
|
+
if path.stem in (f"test_{stem}", stem):
|
|
75
|
+
return path
|
|
76
|
+
return dests[0]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _already_exercised(body: str, name: str, safe: str) -> bool:
|
|
80
|
+
"""Does this test file actually put the name to work?
|
|
81
|
+
|
|
82
|
+
The word appearing somewhere is not the same as the name being
|
|
83
|
+
tested. `name in body` matched prose: asked to "add a check", the
|
|
84
|
+
harness took `check` for a symbol, found the English word in a
|
|
85
|
+
docstring, and finished the run saying a test already existed. The
|
|
86
|
+
word is in 17 of this project's test files; it is called in 5.
|
|
87
|
+
|
|
88
|
+
A call or a test named after it is the difference between a file
|
|
89
|
+
that mentions something and a file that exercises it.
|
|
90
|
+
"""
|
|
91
|
+
return (
|
|
92
|
+
f"{name}(" in body
|
|
93
|
+
or f"{safe}(" in body
|
|
94
|
+
or f"def test_{safe}_" in body
|
|
95
|
+
or f"def test_{safe}(" in body
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply_cover_test(project: Path, task: str, *, write: bool = True) -> str:
|
|
100
|
+
"""Add one AAA test for the named function.
|
|
101
|
+
|
|
102
|
+
Returns a note when a test already names the function, so the run can
|
|
103
|
+
finish without the model appending a dead copy after `if __name__`.
|
|
104
|
+
"""
|
|
105
|
+
if looks_like_file_operation(task):
|
|
106
|
+
# Nothing here is a symbol. Guessing one and finding it in some
|
|
107
|
+
# test file is how "already has a test for create" happened.
|
|
108
|
+
return ""
|
|
109
|
+
name = covered_symbol(task) or (
|
|
110
|
+
question_symbol(task) if looks_like_add_feature(task) else ""
|
|
111
|
+
)
|
|
112
|
+
if not name:
|
|
113
|
+
return ""
|
|
114
|
+
tests = Path(project) / "tests"
|
|
115
|
+
dests = sorted(tests.glob("test_*.py")) if tests.is_dir() else []
|
|
116
|
+
if not dests:
|
|
117
|
+
return ""
|
|
118
|
+
dest = _test_file_for(task, project, name, dests)
|
|
119
|
+
body = dest.read_text(encoding="utf-8")
|
|
120
|
+
safe = name.replace(".", "_")
|
|
121
|
+
if _already_exercised(body, name, safe):
|
|
122
|
+
return f"already has a test for {name}"
|
|
123
|
+
impl = named_project_file(task, project)
|
|
124
|
+
if not impl:
|
|
125
|
+
from harness.skillkit.target import pick_module
|
|
126
|
+
|
|
127
|
+
impl = pick_module(project, "", task)
|
|
128
|
+
impl_path = Path(project) / impl if impl else None
|
|
129
|
+
if impl_path is None or not impl_path.is_file():
|
|
130
|
+
return ""
|
|
131
|
+
if f"def {name}" not in impl_path.read_text(encoding="utf-8"):
|
|
132
|
+
return ""
|
|
133
|
+
sample = _sample_values(impl_path, name, project=Path(project))
|
|
134
|
+
if sample is None:
|
|
135
|
+
return ""
|
|
136
|
+
args, expected, class_name, func_name = sample
|
|
137
|
+
module = impl.replace("\\", "/").removesuffix(".py").replace("/", ".")
|
|
138
|
+
imported = class_name or func_name
|
|
139
|
+
names = ", ".join(key for key, _value in args)
|
|
140
|
+
values = ", ".join(repr(value) for _key, value in args)
|
|
141
|
+
assigns = f"{names} = {values}" if args else ""
|
|
142
|
+
if class_name:
|
|
143
|
+
holder = class_name[0].lower() + class_name[1:]
|
|
144
|
+
act = (
|
|
145
|
+
f" {holder} = {class_name}()\n"
|
|
146
|
+
+ (f" {assigns}\n" if assigns else "")
|
|
147
|
+
+ f" got = {holder}.{func_name}({names})\n"
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
act = (
|
|
151
|
+
(f" {assigns}\n" if assigns else "")
|
|
152
|
+
+ f" got = {func_name}({names})\n"
|
|
153
|
+
)
|
|
154
|
+
method = (
|
|
155
|
+
f" def test_{safe}_returns_the_expected_result(self) -> None:\n"
|
|
156
|
+
f"{act}"
|
|
157
|
+
f" self.assertEqual(got, {expected!r})\n"
|
|
158
|
+
)
|
|
159
|
+
merged = _add_import_symbol(body, module, imported)
|
|
160
|
+
merged = _append_class_method(merged, method)
|
|
161
|
+
try:
|
|
162
|
+
ast.parse(merged)
|
|
163
|
+
except SyntaxError:
|
|
164
|
+
return ""
|
|
165
|
+
if write:
|
|
166
|
+
apply_source(dest, merged, original=body)
|
|
167
|
+
try:
|
|
168
|
+
rel = dest.resolve().relative_to(Path(project).resolve()).as_posix()
|
|
169
|
+
except ValueError:
|
|
170
|
+
rel = dest.as_posix()
|
|
171
|
+
return f"AAA test for {name} in {rel}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _find_callable(
|
|
175
|
+
tree: ast.AST, name: str
|
|
176
|
+
) -> tuple[str, ast.FunctionDef] | None:
|
|
177
|
+
"""(class_name or "", function). Empty class_name is a module function."""
|
|
178
|
+
cls_name, meth = name, ""
|
|
179
|
+
if "." in name:
|
|
180
|
+
cls_name, meth = name.split(".", 1)
|
|
181
|
+
for node in tree.body:
|
|
182
|
+
if isinstance(node, ast.FunctionDef) and node.name == name and not meth:
|
|
183
|
+
return "", node
|
|
184
|
+
if not isinstance(node, ast.ClassDef):
|
|
185
|
+
continue
|
|
186
|
+
if meth and node.name != cls_name:
|
|
187
|
+
continue
|
|
188
|
+
if not meth and node.name != name:
|
|
189
|
+
if any(
|
|
190
|
+
isinstance(item, ast.FunctionDef) and item.name == name
|
|
191
|
+
for item in node.body
|
|
192
|
+
):
|
|
193
|
+
item = next(
|
|
194
|
+
item
|
|
195
|
+
for item in node.body
|
|
196
|
+
if isinstance(item, ast.FunctionDef) and item.name == name
|
|
197
|
+
)
|
|
198
|
+
return node.name, item
|
|
199
|
+
continue
|
|
200
|
+
for item in node.body:
|
|
201
|
+
if not isinstance(item, ast.FunctionDef):
|
|
202
|
+
continue
|
|
203
|
+
if meth and item.name == meth:
|
|
204
|
+
return node.name, item
|
|
205
|
+
if not meth and not item.name.startswith("_"):
|
|
206
|
+
return node.name, item
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
MIN_SHARE_REACHED = 0.5
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _lines_reached(call, path: Path, first: int, last: int) -> int:
|
|
214
|
+
"""How many lines between `first` and `last` the call actually runs.
|
|
215
|
+
|
|
216
|
+
A test built from an argument that returns on the first guard is a
|
|
217
|
+
test of the guard. Counting what ran is the only way to tell that
|
|
218
|
+
apart from a test that exercised the function.
|
|
219
|
+
"""
|
|
220
|
+
seen: set[int] = set()
|
|
221
|
+
# Compare real paths: a module loaded from /tmp reports /private/tmp
|
|
222
|
+
# on macOS, and the tracer then matched nothing at all.
|
|
223
|
+
target = os.path.realpath(path)
|
|
224
|
+
|
|
225
|
+
def trace(frame, event, _arg):
|
|
226
|
+
if os.path.realpath(frame.f_code.co_filename) != target:
|
|
227
|
+
return None
|
|
228
|
+
if event == "line" and first <= frame.f_lineno <= last:
|
|
229
|
+
seen.add(frame.f_lineno)
|
|
230
|
+
return trace
|
|
231
|
+
|
|
232
|
+
previous = sys.gettrace()
|
|
233
|
+
sys.settrace(trace)
|
|
234
|
+
try:
|
|
235
|
+
call()
|
|
236
|
+
finally:
|
|
237
|
+
sys.settrace(previous)
|
|
238
|
+
return len(seen)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _body_lines(func) -> int:
|
|
242
|
+
"""Executable lines in a function, not counting its docstring."""
|
|
243
|
+
body = list(func.body)
|
|
244
|
+
if (
|
|
245
|
+
body
|
|
246
|
+
and isinstance(body[0], ast.Expr)
|
|
247
|
+
and isinstance(body[0].value, ast.Constant)
|
|
248
|
+
and isinstance(body[0].value.value, str)
|
|
249
|
+
):
|
|
250
|
+
body = body[1:]
|
|
251
|
+
lines = set()
|
|
252
|
+
for statement in body:
|
|
253
|
+
for node in ast.walk(statement):
|
|
254
|
+
if hasattr(node, "lineno"):
|
|
255
|
+
lines.add(node.lineno)
|
|
256
|
+
return len(lines) or 1
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _candidates(hint: str, arg_name: str, source: str) -> list[object]:
|
|
260
|
+
"""Values worth trying for one argument, best guess first.
|
|
261
|
+
|
|
262
|
+
The string literals the module compares against are the ones that
|
|
263
|
+
reach a branch: a function that checks `text == "yes"` is only
|
|
264
|
+
exercised by "yes". A placeholder reaches the first return.
|
|
265
|
+
"""
|
|
266
|
+
if "list" in hint:
|
|
267
|
+
return [[10, 20], [], [1]]
|
|
268
|
+
if "dict" in hint:
|
|
269
|
+
return [{"prices": [10, 20], "percent": 10}, {}]
|
|
270
|
+
if "float" in hint:
|
|
271
|
+
return [1.5, 0.0]
|
|
272
|
+
if "bool" in hint:
|
|
273
|
+
return [True, False]
|
|
274
|
+
if "int" in hint:
|
|
275
|
+
return [2, 0, 100]
|
|
276
|
+
if "str" in hint or not hint:
|
|
277
|
+
literals = [
|
|
278
|
+
node.value
|
|
279
|
+
for node in ast.walk(ast.parse(source))
|
|
280
|
+
if isinstance(node, ast.Constant)
|
|
281
|
+
and isinstance(node.value, str)
|
|
282
|
+
and 0 < len(node.value) <= 40
|
|
283
|
+
and "\n" not in node.value
|
|
284
|
+
]
|
|
285
|
+
seen, ordered = set(), []
|
|
286
|
+
for value in literals:
|
|
287
|
+
if value not in seen:
|
|
288
|
+
seen.add(value)
|
|
289
|
+
ordered.append(value)
|
|
290
|
+
# Plain values first, so a literal only wins when it genuinely
|
|
291
|
+
# reaches further into the function. A test reading `shout("x")`
|
|
292
|
+
# is easier to follow than one reading `shout("!")`.
|
|
293
|
+
return ["x", "", *ordered[:12]]
|
|
294
|
+
return [2, 0]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _sample_values(
|
|
298
|
+
path: Path, name: str, *, project: Path | None = None
|
|
299
|
+
) -> tuple[list[tuple[str, object]], object, str, str] | None:
|
|
300
|
+
"""Call the function with simple args. None when that is not safe."""
|
|
301
|
+
try:
|
|
302
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
303
|
+
except (OSError, SyntaxError):
|
|
304
|
+
return None
|
|
305
|
+
found = _find_callable(tree, name)
|
|
306
|
+
if found is None:
|
|
307
|
+
return None
|
|
308
|
+
class_name, func = found
|
|
309
|
+
if func.args.kwonlyargs or func.args.vararg or func.args.kwarg:
|
|
310
|
+
return None
|
|
311
|
+
source = path.read_text(encoding="utf-8")
|
|
312
|
+
choices: list[tuple[str, list[object]]] = []
|
|
313
|
+
for arg in func.args.args:
|
|
314
|
+
if arg.arg in {"self", "cls"}:
|
|
315
|
+
continue
|
|
316
|
+
hint = ast.unparse(arg.annotation) if arg.annotation else ""
|
|
317
|
+
choices.append((arg.arg, _candidates(hint, arg.arg, source)))
|
|
318
|
+
args: list[tuple[str, object]] = [
|
|
319
|
+
(name, values[0]) for name, values in choices
|
|
320
|
+
]
|
|
321
|
+
token = name.replace(".", "_")
|
|
322
|
+
module_name = f"_vibe_cover_{token}"
|
|
323
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
324
|
+
if spec is None or spec.loader is None:
|
|
325
|
+
return None
|
|
326
|
+
module = importlib.util.module_from_spec(spec)
|
|
327
|
+
sys.modules[module_name] = module
|
|
328
|
+
inserted = ""
|
|
329
|
+
if project is not None:
|
|
330
|
+
inserted = str(Path(project).resolve())
|
|
331
|
+
sys.path.insert(0, inserted)
|
|
332
|
+
try:
|
|
333
|
+
spec.loader.exec_module(module)
|
|
334
|
+
if class_name:
|
|
335
|
+
cls = getattr(module, class_name, None)
|
|
336
|
+
if cls is None:
|
|
337
|
+
return None
|
|
338
|
+
target = getattr(cls(), func.name)
|
|
339
|
+
else:
|
|
340
|
+
target = getattr(module, func.name, None)
|
|
341
|
+
if target is None:
|
|
342
|
+
return None
|
|
343
|
+
first = func.lineno
|
|
344
|
+
last = func.end_lineno or func.lineno
|
|
345
|
+
best_reach, best_args, expected = -1, args, None
|
|
346
|
+
# Try one argument at a time against the first working set, and
|
|
347
|
+
# keep whichever call runs the most of the function.
|
|
348
|
+
for index, (arg_name, values) in enumerate(choices):
|
|
349
|
+
for value in values[:8]:
|
|
350
|
+
trial = list(args)
|
|
351
|
+
trial[index] = (arg_name, value)
|
|
352
|
+
shot = tuple(v for _k, v in trial)
|
|
353
|
+
try:
|
|
354
|
+
reach = _lines_reached(
|
|
355
|
+
lambda: target(*shot), path, first, last
|
|
356
|
+
)
|
|
357
|
+
outcome = target(*shot)
|
|
358
|
+
except Exception:
|
|
359
|
+
continue
|
|
360
|
+
if reach > best_reach:
|
|
361
|
+
best_reach, best_args, expected = reach, trial, outcome
|
|
362
|
+
if best_reach > 0:
|
|
363
|
+
args = list(best_args)
|
|
364
|
+
if best_reach < max(1, int(_body_lines(func) * MIN_SHARE_REACHED)):
|
|
365
|
+
# Every value tried returned on a guard, so a test built from
|
|
366
|
+
# this would asserts the guard rather than the function. Say
|
|
367
|
+
# nothing rather than write a test that proves nothing.
|
|
368
|
+
return None
|
|
369
|
+
except Exception:
|
|
370
|
+
return None
|
|
371
|
+
finally:
|
|
372
|
+
sys.modules.pop(module_name, None)
|
|
373
|
+
if inserted and sys.path and sys.path[0] == inserted:
|
|
374
|
+
sys.path.pop(0)
|
|
375
|
+
return list(best_args), expected, class_name, func.name
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _add_import_symbol(text: str, module: str, name: str) -> str:
|
|
379
|
+
if re.search(
|
|
380
|
+
rf"from\s+{re.escape(module)}\s+import\s+.*\b{re.escape(name)}\b", text
|
|
381
|
+
):
|
|
382
|
+
return text
|
|
383
|
+
pattern = re.compile(rf"^(from\s+{re.escape(module)}\s+import\s+)(.+)$", re.M)
|
|
384
|
+
match = pattern.search(text)
|
|
385
|
+
if match:
|
|
386
|
+
imported = match.group(2)
|
|
387
|
+
if re.search(rf"\b{re.escape(name)}\b", imported):
|
|
388
|
+
return text
|
|
389
|
+
return text.replace(
|
|
390
|
+
match.group(0), f"{match.group(1)}{imported.rstrip()}, {name}", 1
|
|
391
|
+
)
|
|
392
|
+
line = f"from {module} import {name}\n"
|
|
393
|
+
if "import " in text:
|
|
394
|
+
last = 0
|
|
395
|
+
for hit in re.finditer(r"^(?:from\s+\S+\s+)?import\s+.+$", text, re.M):
|
|
396
|
+
last = hit.end()
|
|
397
|
+
return text[:last] + "\n" + line + text[last:]
|
|
398
|
+
return line + text
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _append_class_method(text: str, method: str) -> str:
|
|
402
|
+
try:
|
|
403
|
+
tree = ast.parse(text)
|
|
404
|
+
except SyntaxError:
|
|
405
|
+
return text
|
|
406
|
+
classes = [node for node in tree.body if isinstance(node, ast.ClassDef)]
|
|
407
|
+
if not classes:
|
|
408
|
+
return text.rstrip() + (
|
|
409
|
+
"\n\nclass TestGenerated(unittest.TestCase):\n" + method + "\n"
|
|
410
|
+
)
|
|
411
|
+
last = classes[-1]
|
|
412
|
+
methods = [node for node in last.body if isinstance(node, ast.FunctionDef)]
|
|
413
|
+
end = methods[-1].end_lineno if methods else last.end_lineno
|
|
414
|
+
lines = text.splitlines(keepends=True)
|
|
415
|
+
insert = method if method.startswith("\n") else "\n" + method
|
|
416
|
+
if not insert.endswith("\n"):
|
|
417
|
+
insert += "\n"
|
|
418
|
+
lines.insert(end, insert)
|
|
419
|
+
return "".join(lines)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The repairs that run before any model turn, in order."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
"""Mechanical fixes the 8B fails to express. Deterministic. No model.
|
|
6
|
+
|
|
7
|
+
Live 8B (29 Aug 2026): left `subtotal` unbound after a NameError task, and
|
|
8
|
+
spent twelve `Find:` turns that never matched `def calc(x: int, ...)`.
|
|
9
|
+
Those are compiler jobs. The harness does them, then runs the suite,
|
|
10
|
+
before the first generate. A green suite ends the run without a model.
|
|
11
|
+
"""
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from harness.act.code import apply_source
|
|
14
|
+
from harness.task import (
|
|
15
|
+
looks_like_add_feature,
|
|
16
|
+
looks_like_app_overflow,
|
|
17
|
+
looks_like_bugfix,
|
|
18
|
+
looks_like_fix_smell,
|
|
19
|
+
looks_like_write_tests,
|
|
20
|
+
named_project_file,
|
|
21
|
+
rename_pair,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from harness.act.autofix.additions import (
|
|
25
|
+
_impl_py,
|
|
26
|
+
apply_add_function,
|
|
27
|
+
apply_function_rename,
|
|
28
|
+
)
|
|
29
|
+
from harness.act.autofix.moves import apply_file_move, apply_function_move
|
|
30
|
+
from harness.act.autofix.conflicts import _resolve_conflict, looks_like_conflict
|
|
31
|
+
from harness.act.autofix.cover import apply_cover_test
|
|
32
|
+
from harness.act.autofix.names import (
|
|
33
|
+
apply_typo_fixes,
|
|
34
|
+
apply_zero_return_sum,
|
|
35
|
+
typo_pairs,
|
|
36
|
+
)
|
|
37
|
+
from harness.act.autofix.scaffold import apply_home_config, apply_list_page_query
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _repair_in_place(
|
|
41
|
+
project: Path, task: str, rel: str, *, write: bool
|
|
42
|
+
) -> list[str]:
|
|
43
|
+
"""Rewrite the named file, or the one file that holds the typo.
|
|
44
|
+
|
|
45
|
+
Split out of `apply_mechanical` when it crossed eighty lines. It is
|
|
46
|
+
one job: change a file that is already there. Everything after it in
|
|
47
|
+
the caller adds something new instead.
|
|
48
|
+
"""
|
|
49
|
+
notes: list[str] = []
|
|
50
|
+
path = Path(project) / rel if rel else None
|
|
51
|
+
text = original = ""
|
|
52
|
+
if path is not None and path.is_file():
|
|
53
|
+
try:
|
|
54
|
+
original = path.read_text(encoding="utf-8")
|
|
55
|
+
except OSError:
|
|
56
|
+
original = ""
|
|
57
|
+
text = original
|
|
58
|
+
if looks_like_fix_smell(task):
|
|
59
|
+
old, new = rename_pair(task)
|
|
60
|
+
if old and new:
|
|
61
|
+
renamed = apply_function_rename(text, old, new)
|
|
62
|
+
if renamed != text:
|
|
63
|
+
text = renamed
|
|
64
|
+
notes.append(f"renamed def {old} → def {new} in {rel}")
|
|
65
|
+
if looks_like_bugfix(task):
|
|
66
|
+
fixed = apply_typo_fixes(text)
|
|
67
|
+
if fixed != text:
|
|
68
|
+
pairs = typo_pairs(original)
|
|
69
|
+
text = fixed
|
|
70
|
+
shown = ", ".join(f"{bad} → {good}" for bad, good in pairs)
|
|
71
|
+
notes.append(f"bound unique NameError typo ({shown}) in {rel}")
|
|
72
|
+
summed = apply_zero_return_sum(text, task)
|
|
73
|
+
if summed != text:
|
|
74
|
+
text = summed
|
|
75
|
+
notes.append(f"bound a zero return to a sum in {rel}")
|
|
76
|
+
if text != original and notes and write:
|
|
77
|
+
apply_source(path, text, original=original)
|
|
78
|
+
return notes
|
|
79
|
+
if looks_like_bugfix(task):
|
|
80
|
+
for dest, dest_rel in _impl_py(project):
|
|
81
|
+
try:
|
|
82
|
+
original = dest.read_text(encoding="utf-8")
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
fixed = apply_typo_fixes(original)
|
|
86
|
+
if fixed == original:
|
|
87
|
+
continue
|
|
88
|
+
pairs = typo_pairs(original)
|
|
89
|
+
if write:
|
|
90
|
+
apply_source(dest, fixed, original=original)
|
|
91
|
+
shown = ", ".join(f"{bad} → {good}" for bad, good in pairs)
|
|
92
|
+
notes.append(f"bound unique NameError typo ({shown}) in {dest_rel}")
|
|
93
|
+
return notes
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply_mechanical(
|
|
97
|
+
project: Path, task: str, rel: str, *, write: bool = True
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Write a rename, unique typo, or missing AAA test. Return a note, or empty."""
|
|
100
|
+
if not rel:
|
|
101
|
+
rel = named_project_file(task, project)
|
|
102
|
+
notes: list[str] = _repair_in_place(project, task, rel, write=write)
|
|
103
|
+
path = Path(project) / rel if rel else None
|
|
104
|
+
if looks_like_add_feature(task):
|
|
105
|
+
added = apply_add_function(project, task, write=write)
|
|
106
|
+
if added:
|
|
107
|
+
notes.append(added)
|
|
108
|
+
cover = apply_cover_test(project, task, write=write)
|
|
109
|
+
if cover:
|
|
110
|
+
notes.append(cover)
|
|
111
|
+
moved = apply_file_move(project, task, write=write)
|
|
112
|
+
if moved:
|
|
113
|
+
notes.append(moved)
|
|
114
|
+
moved_one = apply_function_move(project, task, write=write)
|
|
115
|
+
if moved_one:
|
|
116
|
+
notes.append(moved_one)
|
|
117
|
+
if path is not None and path.is_file() and looks_like_conflict(task):
|
|
118
|
+
# Read again rather than carry the text out of the repair above:
|
|
119
|
+
# that repair may have rewritten the file, and a conflict has to
|
|
120
|
+
# be resolved against what is on disk now.
|
|
121
|
+
try:
|
|
122
|
+
current = path.read_text(encoding="utf-8")
|
|
123
|
+
except OSError:
|
|
124
|
+
current = ""
|
|
125
|
+
note = _resolve_conflict(path, rel, current, write=write)
|
|
126
|
+
if note:
|
|
127
|
+
notes.append(note)
|
|
128
|
+
if looks_like_write_tests(task):
|
|
129
|
+
cover = apply_cover_test(project, task, write=write)
|
|
130
|
+
if cover:
|
|
131
|
+
notes.append(cover)
|
|
132
|
+
if looks_like_app_overflow(task):
|
|
133
|
+
paged = apply_list_page_query(project, task, write=write)
|
|
134
|
+
if paged:
|
|
135
|
+
notes.append(paged)
|
|
136
|
+
home = apply_home_config(project, task, write=write)
|
|
137
|
+
if home:
|
|
138
|
+
notes.append(home)
|
|
139
|
+
if not notes:
|
|
140
|
+
return ""
|
|
141
|
+
verb = "applied" if write else "would apply (read-only)"
|
|
142
|
+
return (
|
|
143
|
+
f"Harness {verb} a mechanical fix (no model):\n"
|
|
144
|
+
+ "\n".join(f"- {item}" for item in notes)
|
|
145
|
+
+ (
|
|
146
|
+
"\nNext Action must be run Argv: -m unittest discover -s tests -q. "
|
|
147
|
+
"Do not patch this file again."
|
|
148
|
+
if write
|
|
149
|
+
else "\nAction: done Summary: say what you would change and why."
|
|
150
|
+
)
|
|
151
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Adding the import line for a name used without one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
"""Mechanical fixes the 8B fails to express. Deterministic. No model.
|
|
6
|
+
|
|
7
|
+
Live 8B (29 Aug 2026): left `subtotal` unbound after a NameError task, and
|
|
8
|
+
spent twelve `Find:` turns that never matched `def calc(x: int, ...)`.
|
|
9
|
+
Those are compiler jobs. The harness does them, then runs the suite,
|
|
10
|
+
before the first generate. A green suite ends the run without a model.
|
|
11
|
+
"""
|
|
12
|
+
from harness.scan.names import undefined_names
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def apply_missing_imports(source: str) -> str:
|
|
18
|
+
"""Add the import line for a well-known name used without one.
|
|
19
|
+
|
|
20
|
+
A model writes `Path` and forgets `from pathlib import Path`. Refusing
|
|
21
|
+
that and asking for a rename is wrong twice over: the name is right,
|
|
22
|
+
and the repair is mechanical. Only names on a fixed list are handled,
|
|
23
|
+
so nothing is guessed.
|
|
24
|
+
"""
|
|
25
|
+
from harness.scan.names import import_for, undefined_names
|
|
26
|
+
|
|
27
|
+
wanted = [
|
|
28
|
+
line for line in (import_for(name) for name in undefined_names(source)) if line
|
|
29
|
+
]
|
|
30
|
+
if not wanted:
|
|
31
|
+
return source
|
|
32
|
+
lines = source.splitlines()
|
|
33
|
+
present = {line.strip() for line in lines}
|
|
34
|
+
missing = [line for line in dict.fromkeys(wanted) if line not in present]
|
|
35
|
+
if not missing:
|
|
36
|
+
return source
|
|
37
|
+
insert_at = 0
|
|
38
|
+
if lines and lines[0].lstrip()[:3] in {'"""', "'''"}:
|
|
39
|
+
quote = lines[0].lstrip()[:3]
|
|
40
|
+
rest = lines[0].lstrip()[3:]
|
|
41
|
+
if quote in rest:
|
|
42
|
+
insert_at = 1
|
|
43
|
+
else:
|
|
44
|
+
for index, line in enumerate(lines[1:], 1):
|
|
45
|
+
if quote in line:
|
|
46
|
+
insert_at = index + 1
|
|
47
|
+
break
|
|
48
|
+
while insert_at < len(lines) and not lines[insert_at].strip():
|
|
49
|
+
insert_at += 1
|
|
50
|
+
return "\n".join(lines[:insert_at] + missing + [""] + lines[insert_at:]).rstrip() + "\n"
|