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,339 @@
|
|
|
1
|
+
"""Repairing a name: a misspelling, or one only a person can settle."""
|
|
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 io
|
|
14
|
+
import keyword
|
|
15
|
+
import re
|
|
16
|
+
import tokenize
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import NamedTuple
|
|
19
|
+
from harness.act.code import apply_source
|
|
20
|
+
from harness.scan.names import undefined_in_file, undefined_names
|
|
21
|
+
from harness.task import looks_like_bugfix, named_project_file, question_symbol
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def levenshtein(left: str, right: str) -> int:
|
|
27
|
+
if left == right:
|
|
28
|
+
return 0
|
|
29
|
+
if not left:
|
|
30
|
+
return len(right)
|
|
31
|
+
if not right:
|
|
32
|
+
return len(left)
|
|
33
|
+
prev = list(range(len(right) + 1))
|
|
34
|
+
for i, char in enumerate(left, 1):
|
|
35
|
+
cur = [i]
|
|
36
|
+
for j, other in enumerate(right, 1):
|
|
37
|
+
cur.append(
|
|
38
|
+
min(prev[j] + 1, cur[-1] + 1, prev[j - 1] + (char != other))
|
|
39
|
+
)
|
|
40
|
+
prev = cur
|
|
41
|
+
return prev[-1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_typo(bad: str, good: str) -> bool:
|
|
45
|
+
if bad == good or good.startswith("__"):
|
|
46
|
+
return False
|
|
47
|
+
gap = abs(len(bad) - len(good))
|
|
48
|
+
if gap > 2:
|
|
49
|
+
return False
|
|
50
|
+
distance = levenshtein(bad, good)
|
|
51
|
+
if distance == 1:
|
|
52
|
+
return True
|
|
53
|
+
return distance == 2 and min(len(bad), len(good)) >= 6
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _class_body_ids(tree: ast.AST) -> set[int]:
|
|
57
|
+
"""Ids of method defs and class-body stores. Those names are not in scope
|
|
58
|
+
inside a method body — counting them bound `stauts` to `status`.
|
|
59
|
+
"""
|
|
60
|
+
found: set[int] = set()
|
|
61
|
+
for node in ast.walk(tree):
|
|
62
|
+
if not isinstance(node, ast.ClassDef):
|
|
63
|
+
continue
|
|
64
|
+
for item in node.body:
|
|
65
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
66
|
+
found.add(id(item))
|
|
67
|
+
elif isinstance(item, ast.Assign):
|
|
68
|
+
for target in item.targets:
|
|
69
|
+
for name in ast.walk(target):
|
|
70
|
+
if isinstance(name, ast.Name):
|
|
71
|
+
found.add(id(name))
|
|
72
|
+
elif isinstance(item, ast.AnnAssign):
|
|
73
|
+
found.add(id(item.target))
|
|
74
|
+
return found
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _bound_in_scope(source: str) -> set[str]:
|
|
78
|
+
"""Names a method body can actually load: parameters and real locals."""
|
|
79
|
+
try:
|
|
80
|
+
tree = ast.parse(source)
|
|
81
|
+
except (SyntaxError, ValueError):
|
|
82
|
+
return set()
|
|
83
|
+
in_class_body = _class_body_ids(tree)
|
|
84
|
+
bound: set[str] = set()
|
|
85
|
+
for node in ast.walk(tree):
|
|
86
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
87
|
+
if id(node) not in in_class_body:
|
|
88
|
+
bound.add(node.id)
|
|
89
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
90
|
+
if id(node) not in in_class_body:
|
|
91
|
+
bound.add(node.name)
|
|
92
|
+
for arg in (
|
|
93
|
+
*node.args.args,
|
|
94
|
+
*node.args.posonlyargs,
|
|
95
|
+
*node.args.kwonlyargs,
|
|
96
|
+
):
|
|
97
|
+
bound.add(arg.arg)
|
|
98
|
+
return bound
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _all_defined_names(source: str) -> set[str]:
|
|
102
|
+
"""Every name the file defines, including class-body methods."""
|
|
103
|
+
try:
|
|
104
|
+
tree = ast.parse(source)
|
|
105
|
+
except (SyntaxError, ValueError):
|
|
106
|
+
return set()
|
|
107
|
+
found: set[str] = set()
|
|
108
|
+
for node in ast.walk(tree):
|
|
109
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
110
|
+
found.add(node.id)
|
|
111
|
+
elif isinstance(
|
|
112
|
+
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
113
|
+
):
|
|
114
|
+
found.add(node.name)
|
|
115
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
116
|
+
for arg in (
|
|
117
|
+
*node.args.args,
|
|
118
|
+
*node.args.posonlyargs,
|
|
119
|
+
*node.args.kwonlyargs,
|
|
120
|
+
):
|
|
121
|
+
found.add(arg.arg)
|
|
122
|
+
return found
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def typo_pairs(source: str) -> list[tuple[str, str]]:
|
|
126
|
+
"""Unique undefined-name → nearby bound-name pairs."""
|
|
127
|
+
leftover = undefined_names(source)
|
|
128
|
+
if not leftover:
|
|
129
|
+
return []
|
|
130
|
+
bound = _bound_in_scope(source)
|
|
131
|
+
pairs: list[tuple[str, str]] = []
|
|
132
|
+
for bad in leftover:
|
|
133
|
+
hits = [good for good in bound if _is_typo(bad, good)]
|
|
134
|
+
if len(hits) == 1:
|
|
135
|
+
pairs.append((bad, hits[0]))
|
|
136
|
+
return pairs
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class UnboundTypo(NamedTuple):
|
|
140
|
+
"""A leftover misspelling the harness must not guess at."""
|
|
141
|
+
|
|
142
|
+
rel: str
|
|
143
|
+
bad: str
|
|
144
|
+
near: tuple[str, ...]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unbound_typo(task: str, project: Path) -> UnboundTypo | None:
|
|
148
|
+
"""Named-file leftover typo with no unique in-scope bind, or None."""
|
|
149
|
+
if not looks_like_bugfix(task):
|
|
150
|
+
return None
|
|
151
|
+
named = named_project_file(task, project)
|
|
152
|
+
if not named:
|
|
153
|
+
return None
|
|
154
|
+
path = Path(project) / named
|
|
155
|
+
leftover = undefined_in_file(path)
|
|
156
|
+
if not leftover:
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
source = path.read_text(encoding="utf-8")
|
|
160
|
+
except OSError:
|
|
161
|
+
return None
|
|
162
|
+
if typo_pairs(source):
|
|
163
|
+
return None
|
|
164
|
+
known = _all_defined_names(source)
|
|
165
|
+
for bad in leftover:
|
|
166
|
+
near = tuple(sorted(name for name in known if _is_typo(bad, name)))
|
|
167
|
+
if near:
|
|
168
|
+
return UnboundTypo(named, bad, near)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def replacement_from_answer(
|
|
173
|
+
answer: str, bound: set[str], forbidden: set[str]
|
|
174
|
+
) -> str | None:
|
|
175
|
+
"""Python text to put where the leftover name is. None if still unsafe.
|
|
176
|
+
|
|
177
|
+
A Constant (`"ok"`, `None`, `200`) is taken as written. A Name that
|
|
178
|
+
is in scope is bound. A Name that is only the method (`status`) is
|
|
179
|
+
refused. A bare word that is not in scope is the string they typed,
|
|
180
|
+
because the question was "what did you mean?", not "which name?".
|
|
181
|
+
"""
|
|
182
|
+
text = answer.strip()
|
|
183
|
+
if not text:
|
|
184
|
+
return None
|
|
185
|
+
if text.lower().startswith("return "):
|
|
186
|
+
text = text[7:].strip()
|
|
187
|
+
try:
|
|
188
|
+
tree = ast.parse(text, mode="eval")
|
|
189
|
+
except SyntaxError:
|
|
190
|
+
return None
|
|
191
|
+
node = tree.body
|
|
192
|
+
if isinstance(node, ast.Constant):
|
|
193
|
+
return ast.unparse(node)
|
|
194
|
+
if isinstance(node, ast.Name):
|
|
195
|
+
if node.id in forbidden:
|
|
196
|
+
return None
|
|
197
|
+
if node.id in bound:
|
|
198
|
+
return node.id
|
|
199
|
+
if node.id.isidentifier() and not keyword.iskeyword(node.id):
|
|
200
|
+
return ast.unparse(ast.Constant(node.id))
|
|
201
|
+
return None
|
|
202
|
+
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
|
|
203
|
+
if node.value.id in bound:
|
|
204
|
+
return ast.unparse(node)
|
|
205
|
+
return None
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def apply_person_bind(
|
|
210
|
+
project: Path, task: str, answer: str, *, write: bool = True
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Write the leftover typo as the person said. Empty if still unsafe."""
|
|
213
|
+
found = unbound_typo(task, project)
|
|
214
|
+
if found is None:
|
|
215
|
+
return ""
|
|
216
|
+
path = Path(project) / found.rel
|
|
217
|
+
try:
|
|
218
|
+
original = path.read_text(encoding="utf-8")
|
|
219
|
+
except OSError:
|
|
220
|
+
return ""
|
|
221
|
+
bound = _bound_in_scope(original)
|
|
222
|
+
forbidden = {name for name in found.near if name not in bound}
|
|
223
|
+
repl = replacement_from_answer(answer, bound, forbidden)
|
|
224
|
+
if repl is None:
|
|
225
|
+
return ""
|
|
226
|
+
text = _rename_name_tokens(original, found.bad, repl)
|
|
227
|
+
if text == original:
|
|
228
|
+
return ""
|
|
229
|
+
if write:
|
|
230
|
+
try:
|
|
231
|
+
apply_source(path, text, original=original)
|
|
232
|
+
except ValueError:
|
|
233
|
+
return ""
|
|
234
|
+
return f"bound `{found.bad}` → {repl} in {found.rel} (your answer)"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _rename_name_tokens(source: str, bad: str, good: str) -> str:
|
|
238
|
+
"""Replace `bad` where Python reads it as a name, and nowhere else.
|
|
239
|
+
|
|
240
|
+
A plain search also rewrites the word inside strings and comments. An
|
|
241
|
+
error message that mentions the misspelling is text the person wrote,
|
|
242
|
+
and the harness has no business changing it.
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
|
246
|
+
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
247
|
+
return source
|
|
248
|
+
lines = source.splitlines(keepends=True)
|
|
249
|
+
spots = [
|
|
250
|
+
token
|
|
251
|
+
for token in tokens
|
|
252
|
+
if token.type == tokenize.NAME and token.string == bad
|
|
253
|
+
]
|
|
254
|
+
for token in reversed(spots):
|
|
255
|
+
row = token.start[0] - 1
|
|
256
|
+
line = lines[row]
|
|
257
|
+
lines[row] = line[: token.start[1]] + good + line[token.end[1] :]
|
|
258
|
+
return "".join(lines)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def apply_typo_fixes(source: str) -> str:
|
|
262
|
+
text = source
|
|
263
|
+
for bad, good in typo_pairs(source):
|
|
264
|
+
text = _rename_name_tokens(text, bad, good)
|
|
265
|
+
return text
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _top_def_span(source: str, name: str) -> tuple[int, int] | None:
|
|
269
|
+
lines = source.splitlines(keepends=True)
|
|
270
|
+
start = next(
|
|
271
|
+
(
|
|
272
|
+
index
|
|
273
|
+
for index, line in enumerate(lines)
|
|
274
|
+
if re.match(rf"^def {re.escape(name)}\s*\(", line)
|
|
275
|
+
),
|
|
276
|
+
None,
|
|
277
|
+
)
|
|
278
|
+
if start is None:
|
|
279
|
+
return None
|
|
280
|
+
end = len(lines)
|
|
281
|
+
for index in range(start + 1, len(lines)):
|
|
282
|
+
line = lines[index]
|
|
283
|
+
if line and not line[:1].isspace() and line.startswith(
|
|
284
|
+
("def ", "class ", "if __name__")
|
|
285
|
+
):
|
|
286
|
+
end = index
|
|
287
|
+
break
|
|
288
|
+
return start, end
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def apply_zero_return_sum(source: str, task: str) -> str:
|
|
292
|
+
"""Replace `return 0` in the named sum function. Everyday-ready fixture.
|
|
293
|
+
|
|
294
|
+
Live 8B wrote nothing × 3 after #229 / #238 / #245. The task already
|
|
295
|
+
says sum the rows. Only a whole-line literal zero inside that def.
|
|
296
|
+
"""
|
|
297
|
+
if "sum" not in (task or "").lower():
|
|
298
|
+
return source
|
|
299
|
+
name = question_symbol(task)
|
|
300
|
+
if not name:
|
|
301
|
+
return source
|
|
302
|
+
span = _top_def_span(source, name)
|
|
303
|
+
if span is None:
|
|
304
|
+
return source
|
|
305
|
+
start, end = span
|
|
306
|
+
lines = source.splitlines(keepends=True)
|
|
307
|
+
body = "".join(lines[start:end])
|
|
308
|
+
series = "cleaned" if re.search(r"\bcleaned\s*=", body) else ""
|
|
309
|
+
if not series:
|
|
310
|
+
shown = re.match(rf"^def {re.escape(name)}\s*\(\s*([A-Za-z_]\w*)", lines[start])
|
|
311
|
+
series = shown.group(1) if shown else ""
|
|
312
|
+
if not series:
|
|
313
|
+
return source
|
|
314
|
+
last = None
|
|
315
|
+
for index in range(start, end):
|
|
316
|
+
if re.match(r"^\s+return 0(?:\.0)?\s*$", lines[index]):
|
|
317
|
+
last = index
|
|
318
|
+
if last is None:
|
|
319
|
+
return source
|
|
320
|
+
indent = re.match(r"^(\s*)", lines[last]).group(1)
|
|
321
|
+
lines[last] = f"{indent}return {_summed(lines[start], series)}\n"
|
|
322
|
+
return "".join(lines)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _summed(signature: str, series: str) -> str:
|
|
326
|
+
"""`sum(rows)` or `float(sum(rows))`, whichever the signature says.
|
|
327
|
+
|
|
328
|
+
The repair used to write a float every time. On the fixture it was
|
|
329
|
+
written for that is right — `-> float`, over a cleaned series. On a
|
|
330
|
+
function annotated `-> int` it contradicts the signature the person
|
|
331
|
+
wrote, and this project has one: `compute_total(rows: list[int]) ->
|
|
332
|
+
int`. A repair that argues with the declared type is a repair
|
|
333
|
+
somebody has to undo.
|
|
334
|
+
"""
|
|
335
|
+
returns = re.search(r"\)\s*->\s*([A-Za-z_][\w.\[\] |]*)\s*:", signature)
|
|
336
|
+
declared = (returns.group(1) if returns else "").strip()
|
|
337
|
+
if declared == "int":
|
|
338
|
+
return f"sum({series})"
|
|
339
|
+
return f"float(sum({series}))"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Write pkg/ + tests/ for a new-package task before the model starts.
|
|
2
|
+
|
|
3
|
+
A live 8B asked to design a GitHub PR CLI spent the step budget on
|
|
4
|
+
`locate Query: open-pr` and never created a file. The first Action has
|
|
5
|
+
to be a write to `pkg/<noun>.py`. The harness can create the empty
|
|
6
|
+
package itself — the same idea as binding a unique typo.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from harness.scan.app_spec import list_getter_name, required_gaps, requested_overflow
|
|
15
|
+
from harness.task import (
|
|
16
|
+
looks_like_app_loop,
|
|
17
|
+
looks_like_app_overflow,
|
|
18
|
+
looks_like_new_package,
|
|
19
|
+
package_noun,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_LIST_URL = re.compile(r"(/pulls)([\"'])")
|
|
23
|
+
|
|
24
|
+
INIT_BODY = '"""Public exports only. Implementation lives in sibling modules."""\n'
|
|
25
|
+
_MOCK_TEST = """\
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import unittest
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
from pkg.{mod} import {fn}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Test{cls}(unittest.TestCase):
|
|
35
|
+
def test_{fn}_returns_titles(self) -> None:
|
|
36
|
+
payload = [{{"title": "Fix login", "number": 1}}]
|
|
37
|
+
with patch.dict(os.environ, {{"GITHUB_TOKEN": "test-token"}}):
|
|
38
|
+
with patch("urllib.request.urlopen") as fake:
|
|
39
|
+
fake.return_value.__enter__.return_value.read.return_value = (
|
|
40
|
+
json.dumps(payload).encode()
|
|
41
|
+
)
|
|
42
|
+
got = {fn}("owner", "repo")
|
|
43
|
+
self.assertEqual(got, payload)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def apply_package_scaffold(
|
|
48
|
+
project: Path, task: str, *, write: bool = True
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Create pkg/__init__.py and tests/ when the task is a new package.
|
|
51
|
+
|
|
52
|
+
Returns a short note when something was (or would be) written, else "".
|
|
53
|
+
"""
|
|
54
|
+
if not looks_like_new_package(task):
|
|
55
|
+
return ""
|
|
56
|
+
root = Path(project)
|
|
57
|
+
pkg = root / "pkg"
|
|
58
|
+
init = pkg / "__init__.py"
|
|
59
|
+
tests = root / "tests"
|
|
60
|
+
tests_init = tests / "__init__.py"
|
|
61
|
+
notes: list[str] = []
|
|
62
|
+
if not init.is_file():
|
|
63
|
+
notes.append("pkg/__init__.py")
|
|
64
|
+
if write:
|
|
65
|
+
pkg.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
init.write_text(INIT_BODY, encoding="utf-8")
|
|
67
|
+
if not tests.is_dir() or not tests_init.is_file():
|
|
68
|
+
notes.append("tests/")
|
|
69
|
+
if write:
|
|
70
|
+
tests.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if not tests_init.is_file():
|
|
72
|
+
tests_init.write_text("", encoding="utf-8")
|
|
73
|
+
if not notes:
|
|
74
|
+
return ""
|
|
75
|
+
verb = "scaffolded" if write else "would scaffold"
|
|
76
|
+
return f"Harness {verb} {', '.join(notes)} (no model)."
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _list_getter(project: Path) -> tuple[str, str]:
|
|
80
|
+
"""Module stem and list/GET function the 8B actually wrote."""
|
|
81
|
+
pkg = Path(project) / "pkg"
|
|
82
|
+
if not pkg.is_dir():
|
|
83
|
+
return "", ""
|
|
84
|
+
for path in sorted(pkg.glob("*.py")):
|
|
85
|
+
if path.name.startswith("_") or path.name.endswith(".bak"):
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
text = path.read_text(encoding="utf-8")
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
name = list_getter_name(text)
|
|
92
|
+
if name:
|
|
93
|
+
return path.stem, name
|
|
94
|
+
return "", ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def apply_cli_mock_test(project: Path, task: str, *, write: bool = True) -> str:
|
|
98
|
+
"""Write a mocked urlopen test once list/show exist. No weekday copy."""
|
|
99
|
+
if not looks_like_app_loop(task):
|
|
100
|
+
return ""
|
|
101
|
+
leftover = {gap.key for gap in required_gaps(project, task)}
|
|
102
|
+
if leftover & {"http", "list", "show"}:
|
|
103
|
+
return ""
|
|
104
|
+
mod, fn = _list_getter(project)
|
|
105
|
+
if not fn:
|
|
106
|
+
return ""
|
|
107
|
+
dest = Path(project) / "tests" / f"test_{package_noun(task)}.py"
|
|
108
|
+
try:
|
|
109
|
+
existing = dest.read_text(encoding="utf-8") if dest.is_file() else ""
|
|
110
|
+
except OSError:
|
|
111
|
+
existing = ""
|
|
112
|
+
if "urlopen" in existing and "GITHUB_TOKEN" in existing and fn in existing:
|
|
113
|
+
return ""
|
|
114
|
+
cls = "".join(part.title() for part in fn.split("_"))
|
|
115
|
+
body = _MOCK_TEST.format(mod=mod, fn=fn, cls=cls)
|
|
116
|
+
rel = dest.relative_to(Path(project)).as_posix()
|
|
117
|
+
if write:
|
|
118
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
dest.write_text(body, encoding="utf-8")
|
|
120
|
+
return f"wrote mocked urlopen test in {rel}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _top_def_span(source: str, name: str) -> tuple[int, int] | None:
|
|
124
|
+
"""Line slice [start, end) for a top-level def, or None."""
|
|
125
|
+
lines = source.splitlines(keepends=True)
|
|
126
|
+
start = None
|
|
127
|
+
for index, line in enumerate(lines):
|
|
128
|
+
if re.match(rf"^def {re.escape(name)}\s*\(", line):
|
|
129
|
+
start = index
|
|
130
|
+
break
|
|
131
|
+
if start is None:
|
|
132
|
+
return None
|
|
133
|
+
end = len(lines)
|
|
134
|
+
for index in range(start + 1, len(lines)):
|
|
135
|
+
line = lines[index]
|
|
136
|
+
if not line or line[:1].isspace():
|
|
137
|
+
continue
|
|
138
|
+
if line.startswith(("def ", "class ", "if __name__")):
|
|
139
|
+
end = index
|
|
140
|
+
break
|
|
141
|
+
return start, end
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def apply_list_page_query(project: Path, task: str, *, write: bool = True) -> str:
|
|
145
|
+
"""Put page= on the list URL. Live 8B wrote nothing for 12 steps × 3.
|
|
146
|
+
|
|
147
|
+
Only the list/GET function. Show uses /pulls/{{number}} and is left
|
|
148
|
+
alone. A module-level pulls?page= still NameErrors; this writes an
|
|
149
|
+
indented query the oracle already counts.
|
|
150
|
+
"""
|
|
151
|
+
if not looks_like_app_overflow(task):
|
|
152
|
+
return ""
|
|
153
|
+
if "pagination" not in requested_overflow(task):
|
|
154
|
+
return ""
|
|
155
|
+
root = Path(project)
|
|
156
|
+
pkg = root / "pkg"
|
|
157
|
+
if not pkg.is_dir():
|
|
158
|
+
return ""
|
|
159
|
+
for path in sorted(pkg.glob("*.py")):
|
|
160
|
+
if path.name.startswith("_") or path.name.endswith(".bak"):
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
text = path.read_text(encoding="utf-8")
|
|
164
|
+
except OSError:
|
|
165
|
+
continue
|
|
166
|
+
getter = list_getter_name(text)
|
|
167
|
+
if not getter:
|
|
168
|
+
continue
|
|
169
|
+
span = _top_def_span(text, getter)
|
|
170
|
+
if span is None:
|
|
171
|
+
continue
|
|
172
|
+
start, end = span
|
|
173
|
+
lines = text.splitlines(keepends=True)
|
|
174
|
+
body = "".join(lines[start:end])
|
|
175
|
+
if re.search(r"\bpage=", body):
|
|
176
|
+
return ""
|
|
177
|
+
if not _LIST_URL.search(body):
|
|
178
|
+
continue
|
|
179
|
+
changed = []
|
|
180
|
+
for line in lines[start:end]:
|
|
181
|
+
if line[:1].isspace() and "page=" not in line:
|
|
182
|
+
line = _LIST_URL.sub(r"\1?page=1\2", line, count=1)
|
|
183
|
+
changed.append(line)
|
|
184
|
+
new_body = "".join(changed)
|
|
185
|
+
if new_body == body:
|
|
186
|
+
continue
|
|
187
|
+
rel = path.relative_to(root).as_posix()
|
|
188
|
+
if write:
|
|
189
|
+
path.write_text("".join(lines[:start]) + new_body + "".join(lines[end:]), encoding="utf-8")
|
|
190
|
+
return f"put page= on the list URL in {rel}"
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_CONFIG_BODY = """\
|
|
195
|
+
from pathlib import Path
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def config_file() -> Path:
|
|
199
|
+
return Path.home() / ".config" / "pr_review.toml"
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def apply_home_config(project: Path, task: str, *, write: bool = True) -> str:
|
|
204
|
+
"""Write pkg/config.py with Path.home(). Live 8B wrote nothing × 3.
|
|
205
|
+
|
|
206
|
+
The hint already names this file. A tree that looks finished burns
|
|
207
|
+
the step budget on done / explore, the pagination 0/3 shape.
|
|
208
|
+
"""
|
|
209
|
+
if not looks_like_app_overflow(task):
|
|
210
|
+
return ""
|
|
211
|
+
if "config" not in requested_overflow(task):
|
|
212
|
+
return ""
|
|
213
|
+
root = Path(project)
|
|
214
|
+
dest = root / "pkg" / "config.py"
|
|
215
|
+
if dest.is_file():
|
|
216
|
+
try:
|
|
217
|
+
if "Path.home()" in dest.read_text(encoding="utf-8"):
|
|
218
|
+
return ""
|
|
219
|
+
except OSError:
|
|
220
|
+
return ""
|
|
221
|
+
if write:
|
|
222
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
dest.write_text(_CONFIG_BODY, encoding="utf-8")
|
|
224
|
+
return "wrote Path.home() config in pkg/config.py"
|