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,295 @@
|
|
|
1
|
+
"""Whether a run may say it is finished.
|
|
2
|
+
|
|
3
|
+
A different question from whether a change is acceptable. These look
|
|
4
|
+
at the project after the work, not at the draft before it: is the
|
|
5
|
+
symbol actually there, did a test run, does a test call what it was
|
|
6
|
+
asked to cover.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# The oracle reuses one draft rule: a half-finished rename is a
|
|
12
|
+
# reason to refuse a finish as well as a draft.
|
|
13
|
+
from harness.skillkit.refuse_change import refuse_rename_incomplete
|
|
14
|
+
|
|
15
|
+
"""SoC / readable-name guards. Deterministic. No model."""
|
|
16
|
+
import ast
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from harness.task import (
|
|
20
|
+
looks_like_add_feature,
|
|
21
|
+
looks_like_bugfix,
|
|
22
|
+
looks_like_design_loop,
|
|
23
|
+
covered_symbol,
|
|
24
|
+
looks_like_everyday_code,
|
|
25
|
+
looks_like_fix_smell,
|
|
26
|
+
looks_like_write_tests,
|
|
27
|
+
looks_like_app_loop,
|
|
28
|
+
looks_like_app_overflow,
|
|
29
|
+
looks_like_new_package,
|
|
30
|
+
looks_like_refactor,
|
|
31
|
+
question_symbol,
|
|
32
|
+
rename_pair,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _a_test_uses(body: str, symbol: str) -> bool:
|
|
37
|
+
"""True when some `def test_...` in `body` actually mentions `symbol`.
|
|
38
|
+
|
|
39
|
+
Asking only whether the name appears anywhere in the test files
|
|
40
|
+
accepted a file holding one import line and nothing else. On a real
|
|
41
|
+
module the run wrote exactly that, reported `done`, and `unittest
|
|
42
|
+
discover` found no tests at all. An import is not coverage.
|
|
43
|
+
"""
|
|
44
|
+
if not body.strip() or not symbol:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
tree = ast.parse(body)
|
|
48
|
+
except (SyntaxError, ValueError):
|
|
49
|
+
# Unparsable here means several files concatenated. Fall back to
|
|
50
|
+
# requiring the name somewhere after a test definition.
|
|
51
|
+
return bool(re.search(rf"def test_\w*[\s\S]*?\b{re.escape(symbol)}\b", body))
|
|
52
|
+
for node in ast.walk(tree):
|
|
53
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
54
|
+
continue
|
|
55
|
+
if not node.name.startswith("test"):
|
|
56
|
+
continue
|
|
57
|
+
for child in ast.walk(node):
|
|
58
|
+
if isinstance(child, ast.Name) and child.id == symbol:
|
|
59
|
+
return True
|
|
60
|
+
if isinstance(child, ast.Attribute) and child.attr == symbol:
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def tests_call(project: Path, symbol: str) -> bool:
|
|
66
|
+
"""True when some test in tests/ actually calls symbol."""
|
|
67
|
+
tests = Path(project) / "tests"
|
|
68
|
+
if not tests.is_dir() or not symbol:
|
|
69
|
+
return False
|
|
70
|
+
body = ""
|
|
71
|
+
for path in sorted(tests.glob("test_*.py")):
|
|
72
|
+
try:
|
|
73
|
+
body += "\n" + path.read_text(encoding="utf-8")
|
|
74
|
+
except OSError:
|
|
75
|
+
continue
|
|
76
|
+
return _a_test_uses(body, symbol)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def refuse_done_oracle(task: str, project: Path, last_path: str) -> str:
|
|
80
|
+
"""Refuse done when the named file or last write still has an unbound name."""
|
|
81
|
+
from harness.scan.names import undefined_in_file
|
|
82
|
+
from harness.task import named_project_file, rename_pair
|
|
83
|
+
|
|
84
|
+
if looks_like_app_overflow(task):
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
paths: list[str] = []
|
|
88
|
+
named = named_project_file(task, project)
|
|
89
|
+
if named:
|
|
90
|
+
paths.append(named)
|
|
91
|
+
if last_path and last_path not in paths:
|
|
92
|
+
paths.append(last_path)
|
|
93
|
+
if looks_like_bugfix(task):
|
|
94
|
+
for rel in paths:
|
|
95
|
+
leftover = undefined_in_file(Path(project) / rel)
|
|
96
|
+
if leftover:
|
|
97
|
+
return (
|
|
98
|
+
f"undefined name {leftover[0]} in {rel}. "
|
|
99
|
+
f"Action: patch Path: {rel} Find: {leftover[0]} "
|
|
100
|
+
"Replace: the name you assigned."
|
|
101
|
+
)
|
|
102
|
+
if looks_like_add_feature(task):
|
|
103
|
+
symbol = question_symbol(task)
|
|
104
|
+
if symbol:
|
|
105
|
+
found = False
|
|
106
|
+
root = Path(project)
|
|
107
|
+
from harness.scan.project_brief import iter_text_files
|
|
108
|
+
|
|
109
|
+
for path, _size in iter_text_files(root):
|
|
110
|
+
if path.suffix != ".py":
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
body = path.read_text(encoding="utf-8")
|
|
114
|
+
except OSError:
|
|
115
|
+
continue
|
|
116
|
+
if re.search(rf"^def {re.escape(symbol)}\b", body, re.MULTILINE):
|
|
117
|
+
found = True
|
|
118
|
+
break
|
|
119
|
+
if not found:
|
|
120
|
+
from harness.skillkit.target import pick_module
|
|
121
|
+
|
|
122
|
+
dest = pick_module(root, last_path, task)
|
|
123
|
+
return (
|
|
124
|
+
f"def {symbol} is not in the project. "
|
|
125
|
+
f"Action: patch Path: {dest} Append: def {symbol}(...)."
|
|
126
|
+
)
|
|
127
|
+
if looks_like_write_tests(task):
|
|
128
|
+
symbol = covered_symbol(task)
|
|
129
|
+
if symbol:
|
|
130
|
+
tests = Path(project) / "tests"
|
|
131
|
+
body = ""
|
|
132
|
+
if tests.is_dir():
|
|
133
|
+
for path in sorted(tests.glob("test_*.py")):
|
|
134
|
+
try:
|
|
135
|
+
body += path.read_text(encoding="utf-8")
|
|
136
|
+
except OSError:
|
|
137
|
+
continue
|
|
138
|
+
if not _a_test_uses(body, symbol):
|
|
139
|
+
test_rel = "tests/test_module.py"
|
|
140
|
+
if named:
|
|
141
|
+
test_rel = f"tests/test_{Path(named).stem}.py"
|
|
142
|
+
return (
|
|
143
|
+
f"no test calls {symbol}. "
|
|
144
|
+
f"Action: patch Path: {test_rel} "
|
|
145
|
+
f"Append: one AAA test that calls {symbol}."
|
|
146
|
+
)
|
|
147
|
+
if looks_like_fix_smell(task) and named:
|
|
148
|
+
old, new = rename_pair(task)
|
|
149
|
+
try:
|
|
150
|
+
body = (Path(project) / named).read_text(encoding="utf-8")
|
|
151
|
+
except OSError:
|
|
152
|
+
body = ""
|
|
153
|
+
if old and new and body:
|
|
154
|
+
missed = refuse_rename_incomplete(task, named, body)
|
|
155
|
+
if missed:
|
|
156
|
+
return missed
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def refuse_write_done(task: str, ran_tests: bool, *, wrote: bool = True) -> str:
|
|
161
|
+
"""Write tasks are not finished until a passing unittest has run.
|
|
162
|
+
|
|
163
|
+
A task that has not written yet is handled by the empty-done refuse.
|
|
164
|
+
New-package still needs a run even if the model claims it is done.
|
|
165
|
+
"""
|
|
166
|
+
if ran_tests:
|
|
167
|
+
return ""
|
|
168
|
+
if looks_like_new_package(task):
|
|
169
|
+
return "not done. Action: run Argv: -m unittest discover -s tests -q"
|
|
170
|
+
if not wrote:
|
|
171
|
+
return ""
|
|
172
|
+
needs_run = (
|
|
173
|
+
looks_like_add_feature(task)
|
|
174
|
+
or looks_like_everyday_code(task)
|
|
175
|
+
or looks_like_fix_smell(task)
|
|
176
|
+
or looks_like_bugfix(task)
|
|
177
|
+
or looks_like_refactor(task)
|
|
178
|
+
or looks_like_design_loop(task)
|
|
179
|
+
)
|
|
180
|
+
if not needs_run:
|
|
181
|
+
return ""
|
|
182
|
+
return "not done. Action: run Argv: -m unittest discover -s tests -q"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def refuse_package_done(task: str, ran_tests: bool, wrote: bool = True) -> str:
|
|
186
|
+
return refuse_write_done(task, ran_tests, wrote=wrote)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def refuse_app_done(task: str, project: Path) -> str:
|
|
190
|
+
"""Refuse done while list/show/http/mocked tests are still missing."""
|
|
191
|
+
from harness.scan.app_spec import (
|
|
192
|
+
next_app_action,
|
|
193
|
+
next_overflow_action,
|
|
194
|
+
render_app_review,
|
|
195
|
+
required_gaps,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if looks_like_app_overflow(task):
|
|
199
|
+
leftover = next_overflow_action(project, task)
|
|
200
|
+
return ("not done. " + leftover.strip()) if leftover else ""
|
|
201
|
+
if not looks_like_app_loop(task):
|
|
202
|
+
return ""
|
|
203
|
+
if required_gaps(project, task):
|
|
204
|
+
return (
|
|
205
|
+
"not done. "
|
|
206
|
+
+ next_app_action(project, task).strip()
|
|
207
|
+
+ "\n"
|
|
208
|
+
+ render_app_review(project, task)
|
|
209
|
+
)
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# Names a framework calls, so nobody in this project has to.
|
|
214
|
+
_CALLED_BY_SOMETHING_ELSE = ("test", "_", "main")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _added_functions(path: Path) -> list[str]:
|
|
218
|
+
"""Top-level functions this run put in the file that were not there.
|
|
219
|
+
|
|
220
|
+
The original is on disk: every write leaves a `.bak` beside the file
|
|
221
|
+
it changed. Without it there is no way to tell a function the run
|
|
222
|
+
added from one that was already there, and refusing on the second
|
|
223
|
+
would be refusing somebody else's code.
|
|
224
|
+
"""
|
|
225
|
+
backup = path.with_suffix(path.suffix + ".bak")
|
|
226
|
+
try:
|
|
227
|
+
now = ast.parse(path.read_text(encoding="utf-8"))
|
|
228
|
+
before = ast.parse(backup.read_text(encoding="utf-8"))
|
|
229
|
+
except (SyntaxError, ValueError, OSError):
|
|
230
|
+
return []
|
|
231
|
+
was = {
|
|
232
|
+
n.name for n in before.body
|
|
233
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
234
|
+
}
|
|
235
|
+
return [
|
|
236
|
+
n.name for n in now.body
|
|
237
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
238
|
+
and n.name not in was
|
|
239
|
+
and not n.name.startswith(_CALLED_BY_SOMETHING_ELSE)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _anything_calls(project: Path, name: str, skip: Path) -> bool:
|
|
244
|
+
"""Does any other file in the project call this by name?"""
|
|
245
|
+
called = re.compile(rf"\b{re.escape(name)}\s*\(")
|
|
246
|
+
for path in project.rglob("*.py"):
|
|
247
|
+
if path == skip or path.name.endswith(".bak") or ".git" in path.parts:
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
body = path.read_text(encoding="utf-8")
|
|
251
|
+
except OSError:
|
|
252
|
+
continue
|
|
253
|
+
if called.search(body) or _a_test_uses(body, name):
|
|
254
|
+
return True
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def refuse_unwired_addition(
|
|
259
|
+
project: Path, last_path: str, task: str = ""
|
|
260
|
+
) -> str:
|
|
261
|
+
"""A function added, and nobody left to call it.
|
|
262
|
+
|
|
263
|
+
Asked to add a check that refuses to send when a prompt carries a
|
|
264
|
+
token, a run wrote `reject_github_tokens_in_prompt` and left nothing
|
|
265
|
+
nothing. The file parsed and the suite stayed green, because a
|
|
266
|
+
function nobody calls cannot fail. It also cannot work.
|
|
267
|
+
|
|
268
|
+
Whether the body is any good needs a reader. Whether anything will
|
|
269
|
+
ever run it does not, so that much is checked here.
|
|
270
|
+
|
|
271
|
+
Overflow comment/pagination/config is a later typed run. The 8B
|
|
272
|
+
writes ``def comment_on`` and the unused-function guard then
|
|
273
|
+
burns the step budget. That cell is not this rule.
|
|
274
|
+
"""
|
|
275
|
+
if looks_like_app_overflow(task):
|
|
276
|
+
return ""
|
|
277
|
+
if not last_path:
|
|
278
|
+
return ""
|
|
279
|
+
path = Path(project) / last_path
|
|
280
|
+
for name in _added_functions(path):
|
|
281
|
+
try:
|
|
282
|
+
here = path.read_text(encoding="utf-8")
|
|
283
|
+
except OSError:
|
|
284
|
+
return ""
|
|
285
|
+
if re.search(rf"\b{re.escape(name)}\s*\(", here.replace(f"def {name}(", "")):
|
|
286
|
+
continue
|
|
287
|
+
if _anything_calls(Path(project), name, path):
|
|
288
|
+
continue
|
|
289
|
+
return (
|
|
290
|
+
f"`{name}` was added to {last_path} and nothing calls it. A "
|
|
291
|
+
"function nobody calls cannot fail, and cannot work either. "
|
|
292
|
+
"Action: patch the place that should use it, or write a test "
|
|
293
|
+
"that calls it."
|
|
294
|
+
)
|
|
295
|
+
return ""
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Replace the file paths inside a skill with paths from this project.
|
|
2
|
+
|
|
3
|
+
A skill is written as a block of text for the model to copy, so any file
|
|
4
|
+
path inside it will be copied as well. A path that refers to this
|
|
5
|
+
repository's test fixtures does not exist in the user's project, and
|
|
6
|
+
writing to it creates a file the user did not ask for.
|
|
7
|
+
|
|
8
|
+
Two replacements are made before the model sees a skill:
|
|
9
|
+
|
|
10
|
+
* The placeholders `{{module}}`, `{{test}}`, `{{scope}}` and `{{symbol}}`
|
|
11
|
+
are filled in from the project.
|
|
12
|
+
* Any `Path:` or `Scope:` that does not exist in the project is replaced
|
|
13
|
+
with one that does.
|
|
14
|
+
|
|
15
|
+
A path that does exist in the project is left unchanged, which is what
|
|
16
|
+
keeps the fixture-based tests working. A path ending in `__init__.py` is
|
|
17
|
+
also left unchanged, because creating a package legitimately names a file
|
|
18
|
+
that does not exist yet.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from harness.task import looks_like_new_package, looks_like_ops, package_noun, question_symbol
|
|
28
|
+
from harness.paths import rel_posix
|
|
29
|
+
from harness.scan.project_brief import iter_text_files
|
|
30
|
+
|
|
31
|
+
_DEF_NAME = re.compile(r"^def\s+([A-Za-z_]\w*)", re.MULTILINE)
|
|
32
|
+
# HTTP/UI adapters are larger than the domain file beside them. Size-first
|
|
33
|
+
# pick put `total_lines` in `orders_controller.py` on the live 8B demo.
|
|
34
|
+
_ROLE_SUFFIX = ("_controller", "_service", "_view", "_router", "_handler")
|
|
35
|
+
|
|
36
|
+
_PLACEHOLDER = re.compile(r"\{\{(module|test|scope|symbol)\}\}")
|
|
37
|
+
_PATH_LINE = re.compile(r"^(Path|File):\s*(\S+)\s*$", re.MULTILINE)
|
|
38
|
+
_SCOPE_LINE = re.compile(r"^Scope:\s*(\S+)\s*$", re.MULTILINE)
|
|
39
|
+
_SYMBOL_TOKEN = "the_symbol_from_the_task"
|
|
40
|
+
# Scaffolding a package legitimately names a file that does not exist yet.
|
|
41
|
+
_KEEP_NAMES = frozenset({"__init__.py"})
|
|
42
|
+
# Used only in documentation and error text, never handed to the model.
|
|
43
|
+
FALLBACK_MODULE = "path/to/module.py"
|
|
44
|
+
DEFAULT_NEW_MODULE = "src/main.py"
|
|
45
|
+
FALLBACK_TEST = "tests/test_module.py"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Target:
|
|
50
|
+
"""The files in this project that a skill should refer to.
|
|
51
|
+
|
|
52
|
+
Fields:
|
|
53
|
+
module: file where new code belongs.
|
|
54
|
+
test: file where new tests belong.
|
|
55
|
+
scope: directory to stay within.
|
|
56
|
+
symbol: name from the task, used to fill in a search query.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
module: str
|
|
60
|
+
test: str
|
|
61
|
+
scope: str
|
|
62
|
+
symbol: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_test(rel: str) -> bool:
|
|
66
|
+
parts = rel.replace("\\", "/").split("/")
|
|
67
|
+
return "tests" in parts or parts[-1].startswith("test_")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rels(project: Path, suffix: str = ".py") -> list[tuple[str, int]]:
|
|
71
|
+
root = project.resolve()
|
|
72
|
+
return [
|
|
73
|
+
(rel_posix(path, root), size)
|
|
74
|
+
for path, size in iter_text_files(project)
|
|
75
|
+
if path.suffix == suffix
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pick_module(project: Path, located_path: str = "", task: str = "") -> str:
|
|
80
|
+
"""The file a new function most likely belongs in.
|
|
81
|
+
|
|
82
|
+
A project with no Python file in it still needs a real path. The
|
|
83
|
+
placeholder used to be handed straight to the model, which then created
|
|
84
|
+
a file literally at `path/to/module.py` — the same fault as shipping a
|
|
85
|
+
fixture path, arriving by a different route.
|
|
86
|
+
"""
|
|
87
|
+
if looks_like_ops(task):
|
|
88
|
+
return pick_workflow(project)
|
|
89
|
+
if looks_like_new_package(task):
|
|
90
|
+
return f"pkg/{package_noun(task)}.py"
|
|
91
|
+
if located_path:
|
|
92
|
+
rel = located_path.replace("\\", "/").lstrip("./")
|
|
93
|
+
if rel.endswith(".py") and not _is_test(rel):
|
|
94
|
+
return rel
|
|
95
|
+
usable = [
|
|
96
|
+
(rel, size)
|
|
97
|
+
for rel, size in _rels(project)
|
|
98
|
+
if not _is_test(rel) and Path(rel).name != "__init__.py"
|
|
99
|
+
]
|
|
100
|
+
if not usable:
|
|
101
|
+
return _new_module_name(task)
|
|
102
|
+
wanted = _name_tokens(question_symbol(task))
|
|
103
|
+
usable.sort(key=lambda item: _module_score(project, item[0], item[1], wanted))
|
|
104
|
+
return usable[0][0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _name_tokens(symbol: str) -> frozenset[str]:
|
|
108
|
+
return frozenset(part for part in symbol.lower().split("_") if len(part) >= 3)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _module_score(
|
|
112
|
+
project: Path, rel: str, size: int, wanted: frozenset[str]
|
|
113
|
+
) -> tuple[int, int, int, str]:
|
|
114
|
+
"""Lower is better: more name overlap, not a role adapter, then smaller."""
|
|
115
|
+
stem = Path(rel).stem.lower()
|
|
116
|
+
role = int(any(stem.endswith(suffix) for suffix in _ROLE_SUFFIX))
|
|
117
|
+
overlap = 0
|
|
118
|
+
if wanted:
|
|
119
|
+
try:
|
|
120
|
+
body = (Path(project) / rel).read_text(encoding="utf-8")
|
|
121
|
+
except OSError:
|
|
122
|
+
body = ""
|
|
123
|
+
overlap = sum(
|
|
124
|
+
1 for name in _DEF_NAME.findall(body) if _name_tokens(name) & wanted
|
|
125
|
+
)
|
|
126
|
+
return (-overlap, role, size, rel)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pick_workflow(project: Path) -> str:
|
|
130
|
+
"""Existing workflow YAML, else the path the skill creates."""
|
|
131
|
+
root = Path(project)
|
|
132
|
+
folder = root / ".github" / "workflows"
|
|
133
|
+
if folder.is_dir():
|
|
134
|
+
found = sorted(
|
|
135
|
+
rel_posix(path, root)
|
|
136
|
+
for path in folder.iterdir()
|
|
137
|
+
if path.suffix in {".yml", ".yaml"} and path.is_file()
|
|
138
|
+
)
|
|
139
|
+
if found:
|
|
140
|
+
return found[0]
|
|
141
|
+
return ".github/workflows/tests.yml"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _new_module_name(task: str) -> str:
|
|
145
|
+
"""Where the first module of an empty project should go."""
|
|
146
|
+
symbol = question_symbol(task)
|
|
147
|
+
if symbol and symbol != _SYMBOL_TOKEN:
|
|
148
|
+
return f"src/{symbol}.py"
|
|
149
|
+
return DEFAULT_NEW_MODULE
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def pick_test(project: Path, module: str) -> str:
|
|
153
|
+
"""The matching test file, else any test file, else the name to create."""
|
|
154
|
+
stem = Path(module).stem
|
|
155
|
+
tests = sorted(rel for rel, _size in _rels(project) if _is_test(rel))
|
|
156
|
+
if not tests:
|
|
157
|
+
return f"tests/test_{stem}.py" if stem else FALLBACK_TEST
|
|
158
|
+
for rel in tests:
|
|
159
|
+
if Path(rel).stem == f"test_{stem}":
|
|
160
|
+
return rel
|
|
161
|
+
return tests[0]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def pick_scope(scope: str, module: str) -> str:
|
|
165
|
+
if scope:
|
|
166
|
+
return scope
|
|
167
|
+
parts = module.split("/")
|
|
168
|
+
return parts[0] if len(parts) > 1 else "."
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pick_target(
|
|
172
|
+
project: Path, task: str = "", scope: str = "", located_path: str = ""
|
|
173
|
+
) -> Target:
|
|
174
|
+
module = pick_module(project, located_path, task)
|
|
175
|
+
symbol = (
|
|
176
|
+
package_noun(task)
|
|
177
|
+
if looks_like_new_package(task)
|
|
178
|
+
else question_symbol(task) or _SYMBOL_TOKEN
|
|
179
|
+
)
|
|
180
|
+
return Target(
|
|
181
|
+
module=module,
|
|
182
|
+
test=pick_test(project, module),
|
|
183
|
+
scope=pick_scope(scope, module),
|
|
184
|
+
symbol=symbol,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _writes_a_whole_module(body: str) -> bool:
|
|
189
|
+
"""True when the skill hands over a file, not a change to one.
|
|
190
|
+
|
|
191
|
+
`Action: edit` with a module body creates a file. `Action: patch` with
|
|
192
|
+
Find or Append edits one that is already there, and only that case
|
|
193
|
+
should be pointed at an existing file.
|
|
194
|
+
"""
|
|
195
|
+
return bool(re.search(r"^Action:\s*edit\s*$", body, re.MULTILINE))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def retarget(body: str, target: Target, project: Path | None = None) -> str:
|
|
199
|
+
"""Fill placeholders, then repoint any path this project does not have."""
|
|
200
|
+
values = {
|
|
201
|
+
"module": target.module,
|
|
202
|
+
"test": target.test,
|
|
203
|
+
"scope": target.scope,
|
|
204
|
+
"symbol": target.symbol,
|
|
205
|
+
}
|
|
206
|
+
text = _PLACEHOLDER.sub(lambda m: values[m.group(1)], body)
|
|
207
|
+
if target.symbol != _SYMBOL_TOKEN:
|
|
208
|
+
text = text.replace(_SYMBOL_TOKEN, target.symbol)
|
|
209
|
+
if project is None:
|
|
210
|
+
return text
|
|
211
|
+
root = project.resolve()
|
|
212
|
+
|
|
213
|
+
creates_module = _writes_a_whole_module(text)
|
|
214
|
+
|
|
215
|
+
def _path(match: re.Match[str]) -> str:
|
|
216
|
+
key, rel = match.group(1), match.group(2)
|
|
217
|
+
if (root / rel).is_file() or Path(rel).name in _KEEP_NAMES:
|
|
218
|
+
return match.group(0)
|
|
219
|
+
if creates_module and not _is_test(rel):
|
|
220
|
+
# The skill writes a new module, not a change to an existing one.
|
|
221
|
+
# Repointing it onto a file that already exists made the model
|
|
222
|
+
# write the function twice: once where it was sent, once where
|
|
223
|
+
# the skill said. Keep the name, move it beside this project's
|
|
224
|
+
# own modules.
|
|
225
|
+
home = Path(target.module).parent
|
|
226
|
+
name = Path(rel).name
|
|
227
|
+
moved = (home / name).as_posix() if home != Path(".") else name
|
|
228
|
+
return f"{key}: {moved}"
|
|
229
|
+
return f"{key}: {target.test if _is_test(rel) else target.module}"
|
|
230
|
+
|
|
231
|
+
def _scope(match: re.Match[str]) -> str:
|
|
232
|
+
rel = match.group(1)
|
|
233
|
+
if (root / rel).is_dir():
|
|
234
|
+
return match.group(0)
|
|
235
|
+
return f"Scope: {target.scope}"
|
|
236
|
+
|
|
237
|
+
text = _PATH_LINE.sub(_path, text)
|
|
238
|
+
return _SCOPE_LINE.sub(_scope, text)
|