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
harness/paths.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Locations inside this repository.
|
|
2
|
+
|
|
3
|
+
A module that finds the repository root by counting parent directories
|
|
4
|
+
stops working when the module is moved to a different directory depth. The
|
|
5
|
+
root is resolved once here and imported everywhere else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
|
|
15
|
+
# Small platform trees are Python plus a few config files. Secrets stay out.
|
|
16
|
+
TEXT_SUFFIXES = frozenset(
|
|
17
|
+
{".py", ".pyi", ".md", ".toml", ".yml", ".yaml", ".cfg", ".ini", ".json"}
|
|
18
|
+
)
|
|
19
|
+
SECRET_NAMES = frozenset(
|
|
20
|
+
{".env", ".env.local", "credentials.json", ".pypirc", "secrets.json"}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_kit_skills() -> Path:
|
|
25
|
+
"""Locate the skills shipped with py-harness.
|
|
26
|
+
|
|
27
|
+
A source checkout keeps them at the repository root. An installed
|
|
28
|
+
package carries a copy inside `harness/`, because the repository root
|
|
29
|
+
is not present once the package is in site-packages.
|
|
30
|
+
"""
|
|
31
|
+
packaged = Path(__file__).resolve().parent / "kit_skills"
|
|
32
|
+
if packaged.is_dir():
|
|
33
|
+
return packaged
|
|
34
|
+
return REPO_ROOT / "skills"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
KIT_SKILLS_DIR = _find_kit_skills()
|
|
38
|
+
EVAL_DIR = REPO_ROOT / "eval"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def suffix_globs() -> tuple[str, ...]:
|
|
42
|
+
"""rglob patterns for every writable text suffix, sorted for stable tests."""
|
|
43
|
+
return tuple(f"*{suffix}" for suffix in sorted(TEXT_SUFFIXES))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_secret_name(name: str) -> bool:
|
|
47
|
+
return name.lower() in {item.lower() for item in SECRET_NAMES}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_windows(*, windows: bool | None = None) -> bool:
|
|
51
|
+
"""True on this OS, or the layout a caller asked to simulate."""
|
|
52
|
+
return os.name == "nt" if windows is None else windows
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def venv_python(venv: Path, *, windows: bool | None = None) -> Path:
|
|
56
|
+
"""The interpreter inside a virtual environment, on any platform.
|
|
57
|
+
|
|
58
|
+
POSIX puts it at `bin/python`; Windows puts it at `Scripts/python.exe`.
|
|
59
|
+
Pass `windows` to ask for one layout regardless of the platform running.
|
|
60
|
+
"""
|
|
61
|
+
on_windows = is_windows(windows=windows)
|
|
62
|
+
if on_windows:
|
|
63
|
+
return venv / "Scripts" / "python.exe"
|
|
64
|
+
return venv / "bin" / "python"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rel_posix(path: Path, root: Path) -> str:
|
|
68
|
+
"""Path relative to root, written with forward slashes on every platform.
|
|
69
|
+
|
|
70
|
+
Windows renders a relative path as `src\\app.py`. The model is shown
|
|
71
|
+
these paths and copies them back into `Path:`, and the skills, the
|
|
72
|
+
prompts and the tests are all written with forward slashes, so the two
|
|
73
|
+
styles must not mix. Forward slashes work as input on Windows too.
|
|
74
|
+
"""
|
|
75
|
+
return path.relative_to(root).as_posix()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def as_project_rel(rel: str) -> str:
|
|
79
|
+
"""Accept a path written with either separator, return one with slashes.
|
|
80
|
+
|
|
81
|
+
A model may answer with `src\\app.py` whatever platform it runs on. On
|
|
82
|
+
Linux and macOS that is a single filename containing a backslash, not a
|
|
83
|
+
path, so it is converted before use.
|
|
84
|
+
"""
|
|
85
|
+
cleaned = rel.replace("\\", "/").strip()
|
|
86
|
+
while cleaned.startswith("./"):
|
|
87
|
+
cleaned = cleaned[2:]
|
|
88
|
+
return cleaned
|
harness/py.typed
ADDED
|
File without changes
|
harness/scan/__init__.py
ADDED
harness/scan/app_spec.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Checklist for a greenfield GitHub PR-review CLI. Deterministic. No model.
|
|
2
|
+
|
|
3
|
+
A new-package loop used to stop after one function and a test. A typed
|
|
4
|
+
"design a CLI for reviewing GitHub PRs" job is not done then: list and
|
|
5
|
+
show still have to exist, HTTP has to be urllib with a token from the
|
|
6
|
+
environment, and the suite has to mock the network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from harness.scan.project_scan import SKIP_DIR
|
|
16
|
+
from harness.task import package_noun
|
|
17
|
+
|
|
18
|
+
CLEAN_PHRASE = "app checklist clean"
|
|
19
|
+
REQUIRED_KEYS = ("init", "http", "list", "show", "mocked_tests")
|
|
20
|
+
OVERFLOW_KEYS = ("comment", "pagination", "config")
|
|
21
|
+
# Live 8B named the GET list_prs / fetch_pulls, not only list_pulls / get_prs.
|
|
22
|
+
# After #214 the remasure left mocked_tests missing when those names were used.
|
|
23
|
+
_LIST_GETTER = re.compile(
|
|
24
|
+
r"^(?:list_pulls|get_prs|(?:list|get|fetch|load)_[a-z0-9_]*"
|
|
25
|
+
r"(?:pull_requests|prs|pulls))$"
|
|
26
|
+
)
|
|
27
|
+
_SHOW_FN = re.compile(
|
|
28
|
+
r"^(?:show_pull|show_pr|get_pr|get_pull|fetch_pr|fetch_pull)$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Gap:
|
|
34
|
+
"""One missing piece of the CLI, and the Action that closes it."""
|
|
35
|
+
|
|
36
|
+
key: str
|
|
37
|
+
next_action: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _skip(path: Path) -> bool:
|
|
41
|
+
return any(part in SKIP_DIR or part.startswith(".") for part in path.parts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_tree(project: Path) -> tuple[str, str]:
|
|
45
|
+
"""Concatenate impl and test sources. Missing files are empty strings."""
|
|
46
|
+
impl_parts: list[str] = []
|
|
47
|
+
test_parts: list[str] = []
|
|
48
|
+
root = Path(project)
|
|
49
|
+
if not root.is_dir():
|
|
50
|
+
return "", ""
|
|
51
|
+
for path in sorted(root.rglob("*.py")):
|
|
52
|
+
if _skip(path) or path.name.endswith(".bak"):
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
text = path.read_text(encoding="utf-8")
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
rel = path.relative_to(root).as_posix()
|
|
59
|
+
if "tests" in path.parts or path.name.startswith("test_"):
|
|
60
|
+
test_parts.append(text)
|
|
61
|
+
elif rel.endswith("__init__.py"):
|
|
62
|
+
continue
|
|
63
|
+
else:
|
|
64
|
+
impl_parts.append(text)
|
|
65
|
+
return "\n".join(impl_parts), "\n".join(test_parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _top_defs(source: str) -> list[str]:
|
|
69
|
+
return re.findall(r"^def\s+([a-z_][a-z0-9_]*)\s*\(", source, re.M)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_getter_name(impl: str) -> str:
|
|
73
|
+
"""The list/GET function the 8B wrote, or empty."""
|
|
74
|
+
names = _top_defs(impl)
|
|
75
|
+
for preferred in ("list_pulls", "get_prs"):
|
|
76
|
+
if preferred in names:
|
|
77
|
+
return preferred
|
|
78
|
+
for name in names:
|
|
79
|
+
if _LIST_GETTER.match(name):
|
|
80
|
+
return name
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _has_list_command(impl: str) -> bool:
|
|
85
|
+
return bool(
|
|
86
|
+
re.search(r'add_parser\(\s*["\']list["\']', impl) or list_getter_name(impl)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _has_show_command(impl: str) -> bool:
|
|
91
|
+
if re.search(r'add_parser\(\s*["\']show["\']', impl):
|
|
92
|
+
return True
|
|
93
|
+
return any(_SHOW_FN.match(name) for name in _top_defs(impl))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _has_comment_command(impl: str) -> bool:
|
|
97
|
+
return bool(
|
|
98
|
+
re.search(r'add_parser\(\s*["\']comment["\']', impl)
|
|
99
|
+
or re.search(r"\bdef comment_on\b", impl)
|
|
100
|
+
or re.search(r"\bdef comment\b", impl)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _uses_urllib(impl: str) -> bool:
|
|
105
|
+
return "urllib.request" in impl
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _token_from_env(impl: str) -> bool:
|
|
109
|
+
if not re.search(r"\b(os\.environ|os\.getenv)\b", impl):
|
|
110
|
+
return False
|
|
111
|
+
return bool(re.search(r"TOKEN|token", impl))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _mocks_http(tests: str) -> bool:
|
|
115
|
+
return bool(
|
|
116
|
+
re.search(r"\b(urlopen|urllib\.request)\b", tests)
|
|
117
|
+
and re.search(r"\b(patch|MagicMock|mock)\b", tests)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tests_call_list_or_show(tests: str) -> bool:
|
|
122
|
+
"""8B often names the GET get_prs / list_prs and drives list/show via main()."""
|
|
123
|
+
if re.search(r"\b(test_main_list|test_main_show)\b", tests):
|
|
124
|
+
return True
|
|
125
|
+
if re.search(r"\bmain\s*\(", tests) and re.search(r"\b(list|show)\b", tests):
|
|
126
|
+
return True
|
|
127
|
+
called = re.findall(r"\b([a-z_][a-z0-9_]*)\s*\(", tests)
|
|
128
|
+
return any(_LIST_GETTER.match(name) or _SHOW_FN.match(name) for name in called)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _has_pagination(impl: str) -> bool:
|
|
132
|
+
"""True when page= (or Link next) sits on an indented list URL.
|
|
133
|
+
|
|
134
|
+
A live 8B left ``url = f"...pulls?page="`` at module scope. That
|
|
135
|
+
NameErrors on import. Count only a line inside a function or the
|
|
136
|
+
list argparse branch.
|
|
137
|
+
"""
|
|
138
|
+
for line in impl.splitlines():
|
|
139
|
+
if not line[:1].isspace():
|
|
140
|
+
continue
|
|
141
|
+
if re.search(r"\bpage=", line):
|
|
142
|
+
return True
|
|
143
|
+
if re.search(r"rel=[\"']next|\bLink\b", line):
|
|
144
|
+
return True
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _has_home_config(impl: str) -> bool:
|
|
149
|
+
return "Path.home()" in impl
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def app_gaps(project: Path, task: str, *, include_overflow: bool = True) -> list[Gap]:
|
|
153
|
+
"""Missing pieces, in the order the 8B should write them."""
|
|
154
|
+
noun = package_noun(task)
|
|
155
|
+
module = f"pkg/{noun}.py"
|
|
156
|
+
test = f"tests/test_{noun}.py"
|
|
157
|
+
impl, tests = _read_tree(project)
|
|
158
|
+
init = Path(project) / "pkg" / "__init__.py"
|
|
159
|
+
gaps: list[Gap] = []
|
|
160
|
+
if not init.is_file():
|
|
161
|
+
gaps.append(
|
|
162
|
+
Gap(
|
|
163
|
+
"init",
|
|
164
|
+
"Next Action must be edit Path: pkg/__init__.py "
|
|
165
|
+
"(exports only). No logic.",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if not _uses_urllib(impl) or not _token_from_env(impl):
|
|
169
|
+
gaps.append(
|
|
170
|
+
Gap(
|
|
171
|
+
"http",
|
|
172
|
+
f"Next Action must be edit Path: {module} with urllib.request "
|
|
173
|
+
"and a token from os.environ. No curl. No inline secrets.",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
if not _has_list_command(impl):
|
|
177
|
+
gaps.append(
|
|
178
|
+
Gap(
|
|
179
|
+
"list",
|
|
180
|
+
f"Next Action must be edit Path: {module} with argparse "
|
|
181
|
+
"subcommand list and def list_pulls(...).",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
if not _has_show_command(impl):
|
|
185
|
+
gaps.append(
|
|
186
|
+
Gap(
|
|
187
|
+
"show",
|
|
188
|
+
f"Next Action must be edit Path: {module} with argparse "
|
|
189
|
+
"subcommand show and def show_pull(...).",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
if not _mocks_http(tests) or not _tests_call_list_or_show(tests):
|
|
193
|
+
gaps.append(
|
|
194
|
+
Gap(
|
|
195
|
+
"mocked_tests",
|
|
196
|
+
f"Next Action must be edit Path: {test} as a unittest.TestCase. "
|
|
197
|
+
"patch urllib.request.urlopen. Call list_pulls, get_prs, or "
|
|
198
|
+
"show_pull. Do not call the network.",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
if include_overflow:
|
|
202
|
+
if not _has_comment_command(impl):
|
|
203
|
+
gaps.append(
|
|
204
|
+
Gap(
|
|
205
|
+
"comment",
|
|
206
|
+
f"Next Action must be edit Path: {module} with argparse "
|
|
207
|
+
"subcommand comment and def comment_on(...).",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
if not _has_pagination(impl):
|
|
211
|
+
gaps.append(
|
|
212
|
+
Gap(
|
|
213
|
+
"pagination",
|
|
214
|
+
f"Next Action must be patch Path: {module} so the list_pulls "
|
|
215
|
+
"URL includes page=. Do not rename list_pulls. "
|
|
216
|
+
"Do not add weekday or another module.",
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
if not _has_home_config(impl):
|
|
220
|
+
gaps.append(
|
|
221
|
+
Gap(
|
|
222
|
+
"config",
|
|
223
|
+
f"Next Action must be edit Path: pkg/config.py with "
|
|
224
|
+
"Path.home() for the config file. No hardcoded home.",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return gaps
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def required_gaps(project: Path, task: str) -> list[Gap]:
|
|
231
|
+
"""Gaps that block done on the first run: list, show, mocked suite."""
|
|
232
|
+
return [gap for gap in app_gaps(project, task, include_overflow=False)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def overflow_gaps(project: Path, task: str) -> list[Gap]:
|
|
236
|
+
"""comment / pagination / config — a later typed run, not --steps."""
|
|
237
|
+
return [gap for gap in app_gaps(project, task) if gap.key in OVERFLOW_KEYS]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def requested_overflow(task: str) -> tuple[str, ...]:
|
|
241
|
+
"""Which overflow keys this typed run asked for. Empty means all of them."""
|
|
242
|
+
text = (task or "").lower()
|
|
243
|
+
keys: list[str] = []
|
|
244
|
+
if "comment" in text:
|
|
245
|
+
keys.append("comment")
|
|
246
|
+
if "pagination" in text or re.search(r"\bpage=", text):
|
|
247
|
+
keys.append("pagination")
|
|
248
|
+
if "config" in text or "path.home" in text:
|
|
249
|
+
keys.append("config")
|
|
250
|
+
return tuple(keys)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def next_overflow_action(project: Path, task: str) -> str:
|
|
254
|
+
"""The next overflow Action this run asked for, or empty when that piece exists."""
|
|
255
|
+
wanted = set(requested_overflow(task)) or set(OVERFLOW_KEYS)
|
|
256
|
+
for gap in overflow_gaps(project, task):
|
|
257
|
+
if gap.key in wanted:
|
|
258
|
+
return gap.next_action + "\n"
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def overflow_edit_line(task: str, project: Path | None = None) -> str:
|
|
263
|
+
"""Dictator Action for this overflow run. Comment-only copy burned pagination."""
|
|
264
|
+
if project is not None:
|
|
265
|
+
leftover = next_overflow_action(project, task).strip()
|
|
266
|
+
if leftover:
|
|
267
|
+
return leftover
|
|
268
|
+
wanted = requested_overflow(task)
|
|
269
|
+
key = wanted[0] if wanted else "comment"
|
|
270
|
+
noun = package_noun(task)
|
|
271
|
+
if key == "pagination":
|
|
272
|
+
return (
|
|
273
|
+
f"Next Action must be patch Path: pkg/{noun}.py so the list_pulls "
|
|
274
|
+
"URL includes page=. Do not rename list_pulls. "
|
|
275
|
+
"Do not add weekday or another module."
|
|
276
|
+
)
|
|
277
|
+
if key == "config":
|
|
278
|
+
return (
|
|
279
|
+
"Next Action must be edit Path: pkg/config.py with "
|
|
280
|
+
"Path.home() for the config file. No hardcoded home."
|
|
281
|
+
)
|
|
282
|
+
return (
|
|
283
|
+
f"Next Action must be edit Path: pkg/{noun}.py with argparse "
|
|
284
|
+
"subcommand comment and def comment_on(...)."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render_app_review(project: Path, task: str) -> str:
|
|
289
|
+
gaps = required_gaps(project, task)
|
|
290
|
+
extra = overflow_gaps(project, task)
|
|
291
|
+
if not gaps:
|
|
292
|
+
if extra:
|
|
293
|
+
leftover = ", ".join(gap.key for gap in extra)
|
|
294
|
+
return (
|
|
295
|
+
f"{CLEAN_PHRASE} for list and show. "
|
|
296
|
+
f"Later run can add {leftover}."
|
|
297
|
+
)
|
|
298
|
+
return f"{CLEAN_PHRASE} — list, show, comment, pagination, config, mocked tests"
|
|
299
|
+
lines = ["app checklist (deterministic, not a model opinion):"]
|
|
300
|
+
lines.extend(f"- {gap.key}: {gap.next_action}" for gap in gaps)
|
|
301
|
+
return "\n".join(lines)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def app_is_clean(report: str) -> bool:
|
|
305
|
+
"""True when the required list/show checklist is satisfied."""
|
|
306
|
+
return CLEAN_PHRASE in (report or "")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def next_app_action(project: Path, task: str, *, required_only: bool = True) -> str:
|
|
310
|
+
"""The single next Action line, or empty when that tier is clean."""
|
|
311
|
+
gaps = required_gaps(project, task) if required_only else app_gaps(project, task)
|
|
312
|
+
if not gaps:
|
|
313
|
+
return ""
|
|
314
|
+
return gaps[0].next_action + "\n"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def http_test_nudge(task: str) -> str:
|
|
318
|
+
"""AAA mock example the 8B can copy. Not the weekday write-tests skill."""
|
|
319
|
+
noun = package_noun(task)
|
|
320
|
+
return (
|
|
321
|
+
f"Next Action must be edit Path: tests/test_{noun}.py\n"
|
|
322
|
+
"```python\n"
|
|
323
|
+
"import json\n"
|
|
324
|
+
"import unittest\n"
|
|
325
|
+
"from unittest.mock import patch\n"
|
|
326
|
+
f"from pkg.{noun} import list_pulls\n\n\n"
|
|
327
|
+
f"class Test{noun.title().replace('_', '')}(unittest.TestCase):\n"
|
|
328
|
+
" def test_list_pulls_returns_titles(self) -> None:\n"
|
|
329
|
+
' payload = [{"title": "Fix login", "number": 1}]\n'
|
|
330
|
+
' with patch("urllib.request.urlopen") as fake:\n'
|
|
331
|
+
" fake.return_value.__enter__.return_value.read.return_value = (\n"
|
|
332
|
+
" json.dumps(payload).encode()\n"
|
|
333
|
+
" )\n"
|
|
334
|
+
' got = list_pulls("owner", "repo")\n'
|
|
335
|
+
" self.assertEqual(got, payload)\n"
|
|
336
|
+
"```\n"
|
|
337
|
+
"Do not call the network. Do not copy weekday or multiply.\n"
|
|
338
|
+
)
|
harness/scan/design.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Deterministic structure / SoC review. No model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from harness.scan.project_brief import iter_text_files
|
|
9
|
+
|
|
10
|
+
MAX_FINDINGS = 16
|
|
11
|
+
GOD_DEFS = 4
|
|
12
|
+
# Where a function stops being one thing. The architecture test in this
|
|
13
|
+
# repository refuses at 80, which is the point where a function cannot
|
|
14
|
+
# be read at all; this is the earlier point, where it should be split.
|
|
15
|
+
# Two numbers because they answer two questions, and 40 flags 7% of the
|
|
16
|
+
# functions here rather than most of them.
|
|
17
|
+
LONG_DEF = 40
|
|
18
|
+
CLEAN_PHRASE = "no structure findings"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def longest_def(source: str) -> tuple[str, int] | None:
|
|
22
|
+
"""The longest top-level function in the file, and its length.
|
|
23
|
+
|
|
24
|
+
One finding per file rather than one per function: a file with six
|
|
25
|
+
long functions has one problem, and sixteen findings of the same
|
|
26
|
+
shape push everything else off the report.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
tree = ast.parse(source)
|
|
30
|
+
except (SyntaxError, ValueError):
|
|
31
|
+
return None
|
|
32
|
+
found = [
|
|
33
|
+
(node.name, (node.end_lineno or node.lineno) - node.lineno)
|
|
34
|
+
for node in tree.body
|
|
35
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
36
|
+
]
|
|
37
|
+
return max(found, key=lambda item: item[1]) if found else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _defs(source: str) -> list[str]:
|
|
41
|
+
try:
|
|
42
|
+
tree = ast.parse(source)
|
|
43
|
+
except (SyntaxError, ValueError):
|
|
44
|
+
return []
|
|
45
|
+
names: list[str] = []
|
|
46
|
+
for node in tree.body:
|
|
47
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
48
|
+
names.append(node.name)
|
|
49
|
+
return names
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_design_review(project: Path, scope: str = "") -> str:
|
|
53
|
+
root = project.resolve()
|
|
54
|
+
python_files = [
|
|
55
|
+
path
|
|
56
|
+
for path, _size in iter_text_files(project, scope)
|
|
57
|
+
if path.suffix == ".py"
|
|
58
|
+
]
|
|
59
|
+
findings: list[str] = []
|
|
60
|
+
rels = [path.relative_to(root).as_posix() for path in python_files]
|
|
61
|
+
stems = {Path(rel).stem for rel in rels if "test" in rel}
|
|
62
|
+
has_tests = any(rel.startswith("tests/") or "/tests/" in rel for rel in rels)
|
|
63
|
+
has_lib = any(rel.startswith(("pkg/", "src/")) for rel in rels)
|
|
64
|
+
if not has_tests:
|
|
65
|
+
findings.append("missing tests/ — add tests/test_<module>.py beside each concern")
|
|
66
|
+
if not has_lib and any(rel.startswith("scripts/") for rel in rels):
|
|
67
|
+
findings.append("no pkg/ or src/ — library code should not live only in scripts/")
|
|
68
|
+
for path, rel in zip(python_files, rels, strict=True):
|
|
69
|
+
try:
|
|
70
|
+
source = path.read_text(encoding="utf-8")
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
names = _defs(source)
|
|
74
|
+
if rel.endswith("__init__.py") and names:
|
|
75
|
+
findings.append(
|
|
76
|
+
f"SoC: {rel} defines {', '.join(names)} — __init__.py is exports only"
|
|
77
|
+
)
|
|
78
|
+
if rel.startswith("scripts/") and any(name != "main" for name in names):
|
|
79
|
+
extra = [name for name in names if name != "main"]
|
|
80
|
+
findings.append(
|
|
81
|
+
f"SoC: {rel} has {', '.join(extra)} — move library code to pkg/<noun>.py"
|
|
82
|
+
)
|
|
83
|
+
if "test" not in rel and not rel.endswith("__init__.py") and len(names) >= GOD_DEFS:
|
|
84
|
+
findings.append(
|
|
85
|
+
f"god module: {rel} has {len(names)} top-level functions — "
|
|
86
|
+
"Action: edit Path: pkg/<new_concern>.py with one function"
|
|
87
|
+
)
|
|
88
|
+
longest = longest_def(source)
|
|
89
|
+
if longest and longest[1] > LONG_DEF and "test" not in rel:
|
|
90
|
+
name, length = longest
|
|
91
|
+
findings.append(
|
|
92
|
+
f"long function: {rel}:{name} is {length} lines — "
|
|
93
|
+
f"over {LONG_DEF}, split it into one function per thing it does"
|
|
94
|
+
)
|
|
95
|
+
if (
|
|
96
|
+
rel.startswith(("pkg/", "src/"))
|
|
97
|
+
and not rel.endswith("__init__.py")
|
|
98
|
+
and f"test_{Path(rel).stem}" not in stems
|
|
99
|
+
):
|
|
100
|
+
findings.append(f"missing tests: no tests/test_{Path(rel).stem}.py for {rel}")
|
|
101
|
+
if len(findings) >= MAX_FINDINGS:
|
|
102
|
+
break
|
|
103
|
+
if not findings:
|
|
104
|
+
findings.append(f"{CLEAN_PHRASE} in scope — pkg/ and tests/ look split")
|
|
105
|
+
lines = ["design review (deterministic, not a model opinion):"]
|
|
106
|
+
lines.extend(f"- {item}" for item in findings[:MAX_FINDINGS])
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def design_is_clean(report: str) -> bool:
|
|
111
|
+
"""True when the last scan reported no structure findings."""
|
|
112
|
+
return CLEAN_PHRASE in (report or "")
|
harness/scan/existing.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""What the project already has that the task is about.
|
|
2
|
+
|
|
3
|
+
A run asked to check a prompt for a leaked credential and wrote a
|
|
4
|
+
function that looked for a variable name rather than the shape of one,
|
|
5
|
+
and called it from nowhere. The shape was already in the tree, in
|
|
6
|
+
`secrets.py`, under the same words the task had used. The preamble the
|
|
7
|
+
model was given ran to twelve thousand characters and named neither the
|
|
8
|
+
file nor the function.
|
|
9
|
+
|
|
10
|
+
This module deliberately avoids quoting the words that case turned on.
|
|
11
|
+
A search that matches its own source is a search that reports itself.
|
|
12
|
+
|
|
13
|
+
Nothing was wrong with the model that a search would not have fixed. So
|
|
14
|
+
the search happens here, before the model starts: take the phrases out
|
|
15
|
+
of the task, find the ones that are rare in this project, and say where
|
|
16
|
+
they already appear. Rare is the whole trick — "add a function" matches
|
|
17
|
+
everything and means nothing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from harness.scan.project_scan import SKIP_DIR
|
|
26
|
+
|
|
27
|
+
# Words that carry no subject. A phrase built only from these is not
|
|
28
|
+
# worth searching for.
|
|
29
|
+
FILLER = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"add", "also", "and", "any", "are", "back", "call", "called", "can",
|
|
32
|
+
"change", "check", "code", "create", "does", "file", "files", "fix",
|
|
33
|
+
"for", "from", "function", "has", "have", "how", "into", "make",
|
|
34
|
+
"move", "must", "need", "new", "not", "one", "only", "out", "put",
|
|
35
|
+
"return", "returns", "run", "should", "test", "tests", "that",
|
|
36
|
+
"the", "then", "this", "to", "use", "used", "using", "when",
|
|
37
|
+
"where", "which", "with", "write", "you",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# A phrase in more files than this is common vocabulary, not a pointer.
|
|
42
|
+
MOST_FILES = 3
|
|
43
|
+
# And one in no file is not a pointer either.
|
|
44
|
+
FEWEST_FILES = 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def phrases(task: str) -> list[str]:
|
|
48
|
+
"""Two-word phrases from the task that might name something real."""
|
|
49
|
+
words = [w for w in re.findall(r"[A-Za-z][A-Za-z0-9_]+", task.lower())]
|
|
50
|
+
found = []
|
|
51
|
+
for first, second in zip(words, words[1:], strict=False):
|
|
52
|
+
if first in FILLER or second in FILLER:
|
|
53
|
+
continue
|
|
54
|
+
if len(first) < 3 or len(second) < 3:
|
|
55
|
+
continue
|
|
56
|
+
found.append(f"{first} {second}")
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _searchable(project: Path) -> list[Path]:
|
|
61
|
+
"""Source files only. A test names every subject in the project."""
|
|
62
|
+
return [
|
|
63
|
+
path
|
|
64
|
+
for path in sorted(Path(project).rglob("*.py"))
|
|
65
|
+
if not any(part in SKIP_DIR or part.startswith(".") for part in path.parts)
|
|
66
|
+
and not path.name.endswith(".bak")
|
|
67
|
+
and not path.name.startswith("test_")
|
|
68
|
+
and "tests" not in path.parts
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ranked_hits(
|
|
73
|
+
project: Path, task: str, *, skip: str = ""
|
|
74
|
+
) -> list[tuple[str, list[tuple[str, int]]]]:
|
|
75
|
+
"""Rare phrases from the task, each with the files they already appear in."""
|
|
76
|
+
wanted = phrases(task)
|
|
77
|
+
if not wanted:
|
|
78
|
+
return []
|
|
79
|
+
root = Path(project)
|
|
80
|
+
hits: dict[str, list[tuple[str, int]]] = {phrase: [] for phrase in wanted}
|
|
81
|
+
for path in _searchable(root):
|
|
82
|
+
rel = path.relative_to(root).as_posix()
|
|
83
|
+
if skip and rel == skip:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
87
|
+
except OSError:
|
|
88
|
+
continue
|
|
89
|
+
lowered = [line.lower() for line in lines]
|
|
90
|
+
for phrase in wanted:
|
|
91
|
+
for number, line in enumerate(lowered, 1):
|
|
92
|
+
if phrase in line:
|
|
93
|
+
hits[phrase].append((rel, number))
|
|
94
|
+
break
|
|
95
|
+
ranked = [
|
|
96
|
+
(phrase, found)
|
|
97
|
+
for phrase, found in hits.items()
|
|
98
|
+
if FEWEST_FILES <= len({rel for rel, _ in found}) <= MOST_FILES
|
|
99
|
+
]
|
|
100
|
+
ranked.sort(key=lambda item: (len({rel for rel, _ in item[1]}), item[0]))
|
|
101
|
+
return ranked[:2]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def existing_files(project: Path, task: str, *, skip: str = "") -> tuple[str, ...]:
|
|
105
|
+
"""Paths already_covers would name, without the prose."""
|
|
106
|
+
found: list[str] = []
|
|
107
|
+
for _phrase, hits in _ranked_hits(project, task, skip=skip):
|
|
108
|
+
for rel, _number in hits:
|
|
109
|
+
if rel not in found:
|
|
110
|
+
found.append(rel)
|
|
111
|
+
return tuple(found)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def already_covers(project: Path, task: str, *, skip: str = "") -> str:
|
|
115
|
+
"""One line naming where the task's subject already appears. "" if nowhere.
|
|
116
|
+
|
|
117
|
+
`skip` is the file the task already names, because finding the words
|
|
118
|
+
in the file being changed is not news.
|
|
119
|
+
"""
|
|
120
|
+
ranked = _ranked_hits(project, task, skip=skip)
|
|
121
|
+
if not ranked:
|
|
122
|
+
return ""
|
|
123
|
+
lines = []
|
|
124
|
+
for phrase, found in ranked:
|
|
125
|
+
where = ", ".join(f"{rel}:{number}" for rel, number in found[:2])
|
|
126
|
+
lines.append(f' "{phrase}" is already in {where}')
|
|
127
|
+
return (
|
|
128
|
+
"This project already has something for what the task names:\n"
|
|
129
|
+
+ "\n".join(lines)
|
|
130
|
+
+ "\nRead those before writing anything new for it."
|
|
131
|
+
)
|