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.
Files changed (92) hide show
  1. finetune/__init__.py +1 -0
  2. finetune/agent_system.py +41 -0
  3. finetune/agent_traces.py +157 -0
  4. finetune/everyday.py +30 -0
  5. finetune/hf_ollama.py +158 -0
  6. finetune/huggingface_store.py +144 -0
  7. finetune/models.py +74 -0
  8. finetune/paths.py +11 -0
  9. finetune/python_vibe.py +788 -0
  10. finetune/splits.py +54 -0
  11. finetune/systems.py +9 -0
  12. harness/__init__.py +42 -0
  13. harness/__main__.py +8 -0
  14. harness/act/__init__.py +6 -0
  15. harness/act/autofix/__init__.py +110 -0
  16. harness/act/autofix/additions.py +217 -0
  17. harness/act/autofix/conflicts.py +124 -0
  18. harness/act/autofix/cover.py +419 -0
  19. harness/act/autofix/mechanical.py +151 -0
  20. harness/act/autofix/missing_imports.py +50 -0
  21. harness/act/autofix/moves.py +439 -0
  22. harness/act/autofix/names.py +339 -0
  23. harness/act/autofix/scaffold.py +224 -0
  24. harness/act/code.py +157 -0
  25. harness/act/gate.py +229 -0
  26. harness/act/parse.py +247 -0
  27. harness/act/patch_fix.py +138 -0
  28. harness/act/tools.py +244 -0
  29. harness/agent/__init__.py +11 -0
  30. harness/agent/dispatch.py +235 -0
  31. harness/agent/loop.py +699 -0
  32. harness/agent/options.py +144 -0
  33. harness/agent/policy.py +856 -0
  34. harness/agent/prompt.py +170 -0
  35. harness/cli.py +393 -0
  36. harness/editor_kit.py +265 -0
  37. harness/guard/__init__.py +6 -0
  38. harness/guard/fallbacks.py +6 -0
  39. harness/guard/loop_guard.py +57 -0
  40. harness/guard/python_vibe.py +68 -0
  41. harness/guard/run.py +41 -0
  42. harness/guard/types.py +19 -0
  43. harness/locate.py +767 -0
  44. harness/mcp_stdio.py +306 -0
  45. harness/memory/__init__.py +5 -0
  46. harness/memory/conversation.py +104 -0
  47. harness/model/__init__.py +6 -0
  48. harness/model/chat_backend.py +100 -0
  49. harness/model/engine.py +165 -0
  50. harness/model/ollama_generate.py +60 -0
  51. harness/model/openai_generate.py +156 -0
  52. harness/model/outbound.py +83 -0
  53. harness/model/route.py +90 -0
  54. harness/observe/__init__.py +6 -0
  55. harness/observe/eval_gate.py +80 -0
  56. harness/observe/eval_loop.py +185 -0
  57. harness/observe/eval_tasks.py +399 -0
  58. harness/observe/report_md.py +102 -0
  59. harness/observe/trace_record.py +79 -0
  60. harness/openai_api.py +81 -0
  61. harness/paths.py +88 -0
  62. harness/py.typed +0 -0
  63. harness/scan/__init__.py +6 -0
  64. harness/scan/app_spec.py +338 -0
  65. harness/scan/design.py +112 -0
  66. harness/scan/existing.py +131 -0
  67. harness/scan/layout.py +254 -0
  68. harness/scan/names.py +308 -0
  69. harness/scan/project_brief.py +287 -0
  70. harness/scan/project_docs.py +42 -0
  71. harness/scan/project_scan.py +49 -0
  72. harness/scan/repo_map.py +101 -0
  73. harness/secrets.py +39 -0
  74. harness/server.py +199 -0
  75. harness/ship/__init__.py +1 -0
  76. harness/ship/bot_pr.py +221 -0
  77. harness/ship/git_ship.py +262 -0
  78. harness/ship/identity.py +62 -0
  79. harness/ship/ticket.py +251 -0
  80. harness/skillkit/__init__.py +6 -0
  81. harness/skillkit/catalog.py +241 -0
  82. harness/skillkit/refuse_change.py +640 -0
  83. harness/skillkit/refuse_finish.py +295 -0
  84. harness/skillkit/target.py +238 -0
  85. harness/task.py +717 -0
  86. py_harness_cli-0.3.0.dist-info/METADATA +177 -0
  87. py_harness_cli-0.3.0.dist-info/RECORD +92 -0
  88. py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
  89. py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
  90. py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
  91. py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
  92. 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
@@ -0,0 +1,6 @@
1
+ """Read facts about a project without changing it.
2
+
3
+ Reports how large a project is, which files it contains, what those files
4
+ define, what instructions the project publishes, and which parts of its
5
+ structure are hard to read.
6
+ """
@@ -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 "")
@@ -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
+ )