opencode-arch 1.0.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.
- opencode_arch/__init__.py +3 -0
- opencode_arch/artifacts/__init__.py +48 -0
- opencode_arch/artifacts/context.py +451 -0
- opencode_arch/artifacts/diagrams.py +451 -0
- opencode_arch/artifacts/selector.py +331 -0
- opencode_arch/artifacts/templates.py +444 -0
- opencode_arch/cli/__init__.py +1 -0
- opencode_arch/cli/bench.py +25 -0
- opencode_arch/cli/calibrate.py +208 -0
- opencode_arch/cli/confidence.py +66 -0
- opencode_arch/cli/docs.py +333 -0
- opencode_arch/cli/docs_validator.py +295 -0
- opencode_arch/cli/export_data.py +133 -0
- opencode_arch/cli/extract.py +93 -0
- opencode_arch/cli/gap_analyzer.py +107 -0
- opencode_arch/cli/generate.py +68 -0
- opencode_arch/cli/launch.py +264 -0
- opencode_arch/cli/main.py +360 -0
- opencode_arch/cli/metrics.py +186 -0
- opencode_arch/cli/prompts.py +20 -0
- opencode_arch/cli/regen_loop.py +1028 -0
- opencode_arch/context/__init__.py +29 -0
- opencode_arch/context/formatter.py +492 -0
- opencode_arch/context/pipeline_bridge.py +201 -0
- opencode_arch/extract/__init__.py +8 -0
- opencode_arch/extract/constraint_detector.py +398 -0
- opencode_arch/extract/from_artifacts.py +837 -0
- opencode_arch/extract/from_code.py +646 -0
- opencode_arch/extract/route_detector.py +400 -0
- opencode_arch/extract/table_parser.py +177 -0
- opencode_arch/learning/__init__.py +19 -0
- opencode_arch/learning/adapter.py +157 -0
- opencode_arch/learning/assessor.py +170 -0
- opencode_arch/learning/classifier.py +144 -0
- opencode_arch/learning/lessons.py +139 -0
- opencode_arch/learning/maintainer.py +281 -0
- opencode_arch/learning/patterns.py +51 -0
- opencode_arch/mcp/__init__.py +1 -0
- opencode_arch/mcp/__main__.py +8 -0
- opencode_arch/mcp/server.py +183 -0
- opencode_arch/mcp/tools/__init__.py +1 -0
- opencode_arch/mcp/tools/check.py +159 -0
- opencode_arch/mcp/tools/extract.py +107 -0
- opencode_arch/mcp/tools/feedback.py +65 -0
- opencode_arch/mcp/tools/generate.py +104 -0
- opencode_arch/mcp/tools/group.py +62 -0
- opencode_arch/mcp/tools/ingest.py +101 -0
- opencode_arch/mcp/tools/require.py +77 -0
- opencode_arch/mcp/tools/scan.py +53 -0
- opencode_arch/mcp/tools/slice.py +235 -0
- opencode_arch/mcp/tools/validate.py +59 -0
- opencode_arch/prompts/__init__.py +1 -0
- opencode_arch/prompts/regen.py +36 -0
- opencode_arch/runner/__init__.py +5 -0
- opencode_arch/runner/base.py +21 -0
- opencode_arch/runner/opencode.py +66 -0
- opencode_arch/telemetry/__init__.py +6 -0
- opencode_arch/telemetry/collector.py +40 -0
- opencode_arch/telemetry/recorder.py +12 -0
- opencode_arch/telemetry/store.py +537 -0
- opencode_arch-1.0.0.dist-info/METADATA +247 -0
- opencode_arch-1.0.0.dist-info/RECORD +65 -0
- opencode_arch-1.0.0.dist-info/WHEEL +4 -0
- opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
"""Regen-loop orchestrator — iterative subsystem-decomposed code regeneration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from architecture_model.core.decomposer import test_affinity_decompose
|
|
14
|
+
from architecture_model.manifest.test_analyzer import analyze_test_file
|
|
15
|
+
|
|
16
|
+
from opencode_arch.cli.gap_analyzer import analyze_gaps
|
|
17
|
+
from opencode_arch.prompts.regen import FEEDBACK_HEADER, REGEN_PROMPT
|
|
18
|
+
from opencode_arch.runner.base import RunnerBackend
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class PromptMetrics:
|
|
23
|
+
"""Token metrics for each prompt section."""
|
|
24
|
+
|
|
25
|
+
total_tokens: int
|
|
26
|
+
model_context_tokens: int
|
|
27
|
+
signatures_tokens: int
|
|
28
|
+
constants_tokens: int
|
|
29
|
+
contracts_tokens: int
|
|
30
|
+
dependency_tokens: int
|
|
31
|
+
feedback_tokens: int
|
|
32
|
+
source_equivalent_tokens: int # what agent would read without extension
|
|
33
|
+
compression_ratio: float # source_equivalent / total
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_subsystem_tests(test_files: list[Path], repo_path: Path) -> dict[str, Any]:
|
|
37
|
+
"""Run pytest on specific test files and return structured results.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
test_files: List of test file paths to run.
|
|
41
|
+
repo_path: Root path of the repository (used as cwd).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
{"passed": int, "failed": int, "total": int, "pass_rate": float, "output": str}
|
|
45
|
+
"""
|
|
46
|
+
if not test_files:
|
|
47
|
+
return {"passed": 0, "failed": 0, "total": 0, "pass_rate": 0.0, "output": "No test files."}
|
|
48
|
+
|
|
49
|
+
# Filter to existing test files only
|
|
50
|
+
existing = [str(f) for f in test_files if f.exists()]
|
|
51
|
+
if not existing:
|
|
52
|
+
return {"passed": 0, "failed": 0, "total": 0, "pass_rate": 0.0, "output": "No test files found."}
|
|
53
|
+
|
|
54
|
+
cmd = [sys.executable, "-m", "pytest"] + existing + [
|
|
55
|
+
"-v", "--tb=short", "-q",
|
|
56
|
+
"-W", "ignore::pytest.PytestConfigWarning",
|
|
57
|
+
"--override-ini=timeout=0",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
cmd,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
timeout=120,
|
|
66
|
+
cwd=str(repo_path),
|
|
67
|
+
)
|
|
68
|
+
output = result.stdout + result.stderr
|
|
69
|
+
except subprocess.TimeoutExpired:
|
|
70
|
+
return {"passed": 0, "failed": 0, "total": 0, "pass_rate": 0.0,
|
|
71
|
+
"output": "Test execution timed out (120s)."}
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return {"passed": 0, "failed": 0, "total": 0, "pass_rate": 0.0,
|
|
74
|
+
"output": f"Error running tests: {e}"}
|
|
75
|
+
|
|
76
|
+
passed, failed, total = _parse_pytest_summary(output)
|
|
77
|
+
pass_rate = passed / total if total > 0 else 0.0
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"passed": passed,
|
|
81
|
+
"failed": failed,
|
|
82
|
+
"total": total,
|
|
83
|
+
"pass_rate": pass_rate,
|
|
84
|
+
"output": output,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_pytest_summary(output: str) -> tuple[int, int, int]:
|
|
89
|
+
"""Parse pytest output to extract pass/fail counts.
|
|
90
|
+
|
|
91
|
+
Handles both:
|
|
92
|
+
- "5 passed, 2 failed" (standard summary)
|
|
93
|
+
- "X passed" or "X failed" standalone
|
|
94
|
+
"""
|
|
95
|
+
import re
|
|
96
|
+
|
|
97
|
+
passed = 0
|
|
98
|
+
failed = 0
|
|
99
|
+
|
|
100
|
+
# Match the short summary line: "= 5 passed, 2 failed in 0.3s ="
|
|
101
|
+
summary_pattern = re.compile(
|
|
102
|
+
r"(\d+)\s+passed"
|
|
103
|
+
r"(?:.*?(\d+)\s+failed)?"
|
|
104
|
+
)
|
|
105
|
+
# Also check for failed-only: "2 failed"
|
|
106
|
+
failed_only = re.compile(r"(\d+)\s+failed")
|
|
107
|
+
|
|
108
|
+
for line in reversed(output.splitlines()):
|
|
109
|
+
m = summary_pattern.search(line)
|
|
110
|
+
if m:
|
|
111
|
+
passed = int(m.group(1))
|
|
112
|
+
if m.group(2):
|
|
113
|
+
failed = int(m.group(2))
|
|
114
|
+
# Check if there's a failed count on the same line we missed
|
|
115
|
+
fm = failed_only.search(line)
|
|
116
|
+
if fm:
|
|
117
|
+
failed = int(fm.group(1))
|
|
118
|
+
break
|
|
119
|
+
# Check if line is failed-only (no passed)
|
|
120
|
+
fm = failed_only.search(line)
|
|
121
|
+
if fm and "passed" not in line:
|
|
122
|
+
failed = int(fm.group(1))
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
total = passed + failed
|
|
126
|
+
return passed, failed, total
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _setup_blind_workdir(repo_path: Path, test_files: list[Path]) -> Path:
|
|
130
|
+
"""Create a temporary working directory for blind mode.
|
|
131
|
+
|
|
132
|
+
Copies only test infrastructure (test files, __init__.py for package
|
|
133
|
+
structure, conftest.py, pyproject.toml/setup.py) into a temp dir.
|
|
134
|
+
The agent cannot see original source files — all behavioral info
|
|
135
|
+
must come from the prompt.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
repo_path: Original repository root.
|
|
139
|
+
test_files: Test files to copy into the blind workdir.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Path to the temporary working directory.
|
|
143
|
+
"""
|
|
144
|
+
work_dir = Path(tempfile.mkdtemp(prefix="blind-regen-"))
|
|
145
|
+
repo_path = repo_path.resolve()
|
|
146
|
+
|
|
147
|
+
# Copy test files, preserving relative path structure
|
|
148
|
+
for test_file in test_files:
|
|
149
|
+
if not test_file.exists():
|
|
150
|
+
continue
|
|
151
|
+
test_file = test_file.resolve()
|
|
152
|
+
rel = test_file.relative_to(repo_path)
|
|
153
|
+
dest = work_dir / rel
|
|
154
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
shutil.copy2(test_file, dest)
|
|
156
|
+
|
|
157
|
+
# Create __init__.py files for all parent packages to maintain import structure
|
|
158
|
+
for test_file in test_files:
|
|
159
|
+
if not test_file.exists():
|
|
160
|
+
continue
|
|
161
|
+
test_file = test_file.resolve()
|
|
162
|
+
rel = test_file.relative_to(repo_path)
|
|
163
|
+
# Walk up from test file's parent to repo root, creating __init__.py
|
|
164
|
+
current = rel.parent
|
|
165
|
+
while current != Path("."):
|
|
166
|
+
init_src = repo_path / current / "__init__.py"
|
|
167
|
+
init_dest = work_dir / current / "__init__.py"
|
|
168
|
+
if not init_dest.exists():
|
|
169
|
+
init_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
if init_src.exists():
|
|
171
|
+
shutil.copy2(init_src, init_dest)
|
|
172
|
+
else:
|
|
173
|
+
init_dest.write_text("")
|
|
174
|
+
current = current.parent
|
|
175
|
+
|
|
176
|
+
# Copy conftest.py files from directories containing test files
|
|
177
|
+
copied_conftest_dirs: set[Path] = set()
|
|
178
|
+
for test_file in test_files:
|
|
179
|
+
if not test_file.exists():
|
|
180
|
+
continue
|
|
181
|
+
test_file = test_file.resolve()
|
|
182
|
+
rel = test_file.relative_to(repo_path)
|
|
183
|
+
# Check test file's directory and all parents for conftest.py
|
|
184
|
+
current = rel.parent
|
|
185
|
+
while True:
|
|
186
|
+
if current not in copied_conftest_dirs:
|
|
187
|
+
conftest_src = repo_path / current / "conftest.py"
|
|
188
|
+
if conftest_src.exists():
|
|
189
|
+
conftest_dest = work_dir / current / "conftest.py"
|
|
190
|
+
conftest_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
shutil.copy2(conftest_src, conftest_dest)
|
|
192
|
+
copied_conftest_dirs.add(current)
|
|
193
|
+
if current == Path("."):
|
|
194
|
+
break
|
|
195
|
+
current = current.parent
|
|
196
|
+
|
|
197
|
+
# Also check root-level conftest.py
|
|
198
|
+
root_conftest = repo_path / "conftest.py"
|
|
199
|
+
if root_conftest.exists() and Path(".") not in copied_conftest_dirs:
|
|
200
|
+
shutil.copy2(root_conftest, work_dir / "conftest.py")
|
|
201
|
+
|
|
202
|
+
# Copy test helper modules (non-test .py files) from test directories.
|
|
203
|
+
# These are shared utilities that test files import (e.g., tests/helpers.py).
|
|
204
|
+
# A file is a "test file" if: test_*.py, tests_*.py, or *_test.py
|
|
205
|
+
def _is_test_file(name: str) -> bool:
|
|
206
|
+
return (
|
|
207
|
+
name.startswith("test_")
|
|
208
|
+
or name.startswith("tests_")
|
|
209
|
+
or name.endswith("_test.py")
|
|
210
|
+
) and name.endswith(".py")
|
|
211
|
+
|
|
212
|
+
def _find_test_root(test_file: Path) -> Path | None:
|
|
213
|
+
"""Find the closest ancestor directory named 'tests' or 'test'."""
|
|
214
|
+
rel = test_file.relative_to(repo_path)
|
|
215
|
+
# Walk up the path to find a directory named tests/test
|
|
216
|
+
for i, part in enumerate(rel.parts):
|
|
217
|
+
if part in ("tests", "test"):
|
|
218
|
+
return repo_path / Path(*rel.parts[: i + 1])
|
|
219
|
+
# Fallback: use the direct parent of the test file
|
|
220
|
+
return test_file.parent
|
|
221
|
+
|
|
222
|
+
# Find test root directories from the test file paths
|
|
223
|
+
resolved_test_files = {tf.resolve() for tf in test_files if tf.exists()}
|
|
224
|
+
test_roots: set[Path] = set()
|
|
225
|
+
for tf in resolved_test_files:
|
|
226
|
+
root = _find_test_root(tf)
|
|
227
|
+
if root and root.is_dir():
|
|
228
|
+
test_roots.add(root)
|
|
229
|
+
|
|
230
|
+
# Walk each test root and copy infrastructure files (helpers, __init__.py, conftest.py)
|
|
231
|
+
for test_root in test_roots:
|
|
232
|
+
for py_file in test_root.rglob("*.py"):
|
|
233
|
+
if not py_file.is_file():
|
|
234
|
+
continue
|
|
235
|
+
# Skip test files that aren't in our subsystem
|
|
236
|
+
if _is_test_file(py_file.name) and py_file.resolve() not in resolved_test_files:
|
|
237
|
+
continue
|
|
238
|
+
# Copy infrastructure files (conftest, __init__, helpers) if not already present
|
|
239
|
+
rel = py_file.relative_to(repo_path)
|
|
240
|
+
dest = work_dir / rel
|
|
241
|
+
if not dest.exists():
|
|
242
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
shutil.copy2(py_file, dest)
|
|
244
|
+
|
|
245
|
+
# Copy pyproject.toml / setup.py / pytest config for import resolution
|
|
246
|
+
for config_file in ("pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini"):
|
|
247
|
+
src = repo_path / config_file
|
|
248
|
+
if src.exists():
|
|
249
|
+
shutil.copy2(src, work_dir / config_file)
|
|
250
|
+
|
|
251
|
+
return work_dir
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _extract_signatures_for_subsystem(repo_path: Path, subsystem) -> list:
|
|
255
|
+
"""Extract FunctionSignature data from the architecture model for a subsystem.
|
|
256
|
+
|
|
257
|
+
Loads the .architecture-model.yaml and finds signatures associated with
|
|
258
|
+
the subsystem's components. Returns signature objects with body_hint included.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
repo_path: Path to the repository (where .architecture-model.yaml lives).
|
|
262
|
+
subsystem: Subsystem with source_files to match against components.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
List of signature-like objects with name, params, returns, body_hint.
|
|
266
|
+
"""
|
|
267
|
+
model_file = repo_path / ".architecture-model.yaml"
|
|
268
|
+
if not model_file.exists():
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
from architecture_model.core.parser import load_model
|
|
273
|
+
|
|
274
|
+
model = load_model(model_file)
|
|
275
|
+
signatures = []
|
|
276
|
+
|
|
277
|
+
# Get source file stems for matching
|
|
278
|
+
source_stems = {f.stem for f in subsystem.source_files}
|
|
279
|
+
|
|
280
|
+
for comp in model.entities.components:
|
|
281
|
+
# Match component to subsystem by checking if its source files overlap
|
|
282
|
+
comp_files = getattr(comp, "files", [])
|
|
283
|
+
comp_stems = {Path(f).stem for f in comp_files} if comp_files else set()
|
|
284
|
+
|
|
285
|
+
# Also match by component name (stem of source file)
|
|
286
|
+
if comp_stems:
|
|
287
|
+
if not source_stems.intersection(comp_stems):
|
|
288
|
+
continue
|
|
289
|
+
elif comp.name not in source_stems:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
# Extract signatures from component
|
|
293
|
+
for sig in getattr(comp, "signatures", []):
|
|
294
|
+
signatures.append(sig)
|
|
295
|
+
|
|
296
|
+
return signatures
|
|
297
|
+
except Exception:
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _extract_constants_for_subsystem(repo_path: Path, subsystem) -> list:
|
|
302
|
+
"""Extract Constant objects from the architecture model for a subsystem.
|
|
303
|
+
|
|
304
|
+
Returns constants from matched components (module constants, class attributes,
|
|
305
|
+
module-level instances) that may not appear in test-derived constants.
|
|
306
|
+
"""
|
|
307
|
+
model_file = repo_path / ".architecture-model.yaml"
|
|
308
|
+
if not model_file.exists():
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
from architecture_model.core.parser import load_model
|
|
313
|
+
|
|
314
|
+
model = load_model(model_file)
|
|
315
|
+
constants = []
|
|
316
|
+
|
|
317
|
+
source_stems = {f.stem for f in subsystem.source_files}
|
|
318
|
+
|
|
319
|
+
for comp in model.entities.components:
|
|
320
|
+
comp_files = getattr(comp, "files", [])
|
|
321
|
+
comp_stems = {Path(f).stem for f in comp_files} if comp_files else set()
|
|
322
|
+
|
|
323
|
+
if comp_stems:
|
|
324
|
+
if not source_stems.intersection(comp_stems):
|
|
325
|
+
continue
|
|
326
|
+
elif comp.name not in source_stems:
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
for const in getattr(comp, "constants", []):
|
|
330
|
+
constants.append(const)
|
|
331
|
+
|
|
332
|
+
return constants
|
|
333
|
+
except Exception:
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _build_prompt(
|
|
338
|
+
subsystem_name: str,
|
|
339
|
+
source_files: list[Path],
|
|
340
|
+
model_context: str,
|
|
341
|
+
constants: list,
|
|
342
|
+
signatures: list,
|
|
343
|
+
contracts: list,
|
|
344
|
+
dependency_apis: str,
|
|
345
|
+
iteration: int,
|
|
346
|
+
max_iterations: int,
|
|
347
|
+
previous_feedback: str,
|
|
348
|
+
source_equivalent_tokens: int = 0,
|
|
349
|
+
contract_cap: int = 50,
|
|
350
|
+
) -> tuple[str, PromptMetrics]:
|
|
351
|
+
"""Build the regen prompt for a subsystem iteration.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Tuple of (prompt_string, PromptMetrics with per-section token counts).
|
|
355
|
+
"""
|
|
356
|
+
# Format source files
|
|
357
|
+
files_str = "\n".join(f"- {f}" for f in source_files) if source_files else "- (none specified)"
|
|
358
|
+
|
|
359
|
+
# Format constants
|
|
360
|
+
if constants:
|
|
361
|
+
consts_str = "\n".join(f"- {c.name} = {c.value!r} ({c.context})" for c in constants)
|
|
362
|
+
else:
|
|
363
|
+
consts_str = "(none extracted)"
|
|
364
|
+
|
|
365
|
+
# Format signatures (include body_hint for blind regen)
|
|
366
|
+
if signatures:
|
|
367
|
+
sigs_parts = []
|
|
368
|
+
for sig in signatures:
|
|
369
|
+
params = ", ".join(sig.params) if sig.params else ""
|
|
370
|
+
ret = f" -> {sig.returns}" if sig.returns else ""
|
|
371
|
+
hint = getattr(sig, "body_hint", "")
|
|
372
|
+
if hint:
|
|
373
|
+
sigs_parts.append(f"- {sig.name}({params}){ret} [body: {hint}]")
|
|
374
|
+
else:
|
|
375
|
+
sigs_parts.append(f"- {sig.name}({params}){ret}")
|
|
376
|
+
sigs_str = "\n".join(sigs_parts)
|
|
377
|
+
else:
|
|
378
|
+
sigs_str = "(none extracted)"
|
|
379
|
+
|
|
380
|
+
# Format contracts
|
|
381
|
+
if contracts:
|
|
382
|
+
contract_parts = []
|
|
383
|
+
for c in contracts:
|
|
384
|
+
contract_parts.append(f"- [{c.contract_type}] {c.assertion} (from {c.test_method})")
|
|
385
|
+
contracts_str = "\n".join(contract_parts[:contract_cap])
|
|
386
|
+
if len(contracts) > contract_cap:
|
|
387
|
+
contracts_str += f"\n ... and {len(contracts) - contract_cap} more"
|
|
388
|
+
else:
|
|
389
|
+
contracts_str = "(none extracted)"
|
|
390
|
+
|
|
391
|
+
# Format feedback section
|
|
392
|
+
if previous_feedback:
|
|
393
|
+
feedback_str = FEEDBACK_HEADER.format(
|
|
394
|
+
prev_iteration=iteration - 1,
|
|
395
|
+
failure_analysis=previous_feedback,
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
feedback_str = ""
|
|
399
|
+
|
|
400
|
+
# Compute per-section token counts (chars / 4 approximation)
|
|
401
|
+
model_context_str = model_context or "(no architecture model available)"
|
|
402
|
+
dependency_str = dependency_apis or "(no dependency context)"
|
|
403
|
+
|
|
404
|
+
model_context_tokens = len(model_context_str) // 4
|
|
405
|
+
signatures_tokens = len(sigs_str) // 4
|
|
406
|
+
constants_tokens = len(consts_str) // 4
|
|
407
|
+
contracts_tokens = len(contracts_str) // 4
|
|
408
|
+
dependency_tokens = len(dependency_str) // 4
|
|
409
|
+
feedback_tokens = len(feedback_str) // 4
|
|
410
|
+
|
|
411
|
+
prompt_str = REGEN_PROMPT.format(
|
|
412
|
+
subsystem_name=subsystem_name,
|
|
413
|
+
iteration=iteration,
|
|
414
|
+
max_iterations=max_iterations,
|
|
415
|
+
source_files=files_str,
|
|
416
|
+
model_context=model_context_str,
|
|
417
|
+
constants=consts_str,
|
|
418
|
+
signatures=sigs_str,
|
|
419
|
+
test_contracts=contracts_str,
|
|
420
|
+
dependency_apis=dependency_str,
|
|
421
|
+
previous_feedback=feedback_str,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
total_tokens = len(prompt_str) // 4
|
|
425
|
+
compression_ratio = (
|
|
426
|
+
source_equivalent_tokens / total_tokens if total_tokens > 0 else 0.0
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
metrics = PromptMetrics(
|
|
430
|
+
total_tokens=total_tokens,
|
|
431
|
+
model_context_tokens=model_context_tokens,
|
|
432
|
+
signatures_tokens=signatures_tokens,
|
|
433
|
+
constants_tokens=constants_tokens,
|
|
434
|
+
contracts_tokens=contracts_tokens,
|
|
435
|
+
dependency_tokens=dependency_tokens,
|
|
436
|
+
feedback_tokens=feedback_tokens,
|
|
437
|
+
source_equivalent_tokens=source_equivalent_tokens,
|
|
438
|
+
compression_ratio=compression_ratio,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return prompt_str, metrics
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _compute_source_equivalent(subsystem, repo_path: Path, all_subsystems: list) -> int:
|
|
445
|
+
"""Compute tokens needed to read source + deps (the 'without extension' baseline).
|
|
446
|
+
|
|
447
|
+
This represents what the agent would need to read WITHOUT the architecture
|
|
448
|
+
model extension — the raw source files of the subsystem plus all its
|
|
449
|
+
dependencies.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
subsystem: Subsystem object with .source_files and .dependencies.
|
|
453
|
+
repo_path: Root path of the repository.
|
|
454
|
+
all_subsystems: All subsystems (to resolve dependency source files).
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Estimated token count (chars / 4).
|
|
458
|
+
"""
|
|
459
|
+
total_chars = 0
|
|
460
|
+
# Own source files
|
|
461
|
+
for f in subsystem.source_files:
|
|
462
|
+
path = repo_path / f if not Path(f).is_absolute() else Path(f)
|
|
463
|
+
try:
|
|
464
|
+
total_chars += len(path.read_text(encoding="utf-8"))
|
|
465
|
+
except (OSError, UnicodeDecodeError):
|
|
466
|
+
pass
|
|
467
|
+
# Dependency source files
|
|
468
|
+
for dep_name in subsystem.dependencies:
|
|
469
|
+
for s in all_subsystems:
|
|
470
|
+
if s.name == dep_name:
|
|
471
|
+
for sf in s.source_files:
|
|
472
|
+
path = repo_path / sf if not Path(sf).is_absolute() else Path(sf)
|
|
473
|
+
try:
|
|
474
|
+
total_chars += len(path.read_text(encoding="utf-8"))
|
|
475
|
+
except (OSError, UnicodeDecodeError):
|
|
476
|
+
pass
|
|
477
|
+
return total_chars // 4
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def run_regen_loop(
|
|
481
|
+
repo_path: Path,
|
|
482
|
+
runner: RunnerBackend,
|
|
483
|
+
max_iterations: int = 5,
|
|
484
|
+
target_pass_rate: float = 0.5,
|
|
485
|
+
subsystem_name: str | None = None,
|
|
486
|
+
blind: bool = False,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
"""Run the test-as-oracle decomposed regen loop.
|
|
489
|
+
|
|
490
|
+
Decomposes the repo into subsystems by test affinity, then iteratively
|
|
491
|
+
regenerates code per subsystem using the LLM, running tests after each
|
|
492
|
+
attempt to measure convergence.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
repo_path: Path to the target repository.
|
|
496
|
+
runner: RunnerBackend for LLM invocation.
|
|
497
|
+
max_iterations: Max iterations per subsystem (default 5).
|
|
498
|
+
target_pass_rate: Stop when this pass rate is achieved (default 0.5).
|
|
499
|
+
subsystem_name: If set, only process this subsystem.
|
|
500
|
+
blind: If True, agent works in temp dir without source file access.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Summary dict with per-subsystem results and overall metrics.
|
|
504
|
+
"""
|
|
505
|
+
start_time = time.time()
|
|
506
|
+
repo_path = Path(repo_path).resolve()
|
|
507
|
+
|
|
508
|
+
if not repo_path.exists():
|
|
509
|
+
return {"error": f"Path does not exist: {repo_path}", "success": False}
|
|
510
|
+
|
|
511
|
+
# Step 1: Decompose into subsystems
|
|
512
|
+
subsystems = test_affinity_decompose(repo_path)
|
|
513
|
+
if not subsystems:
|
|
514
|
+
return {"error": "No subsystems found (no test files?)", "success": False}
|
|
515
|
+
|
|
516
|
+
# Step 2: Filter if specific subsystem requested
|
|
517
|
+
if subsystem_name:
|
|
518
|
+
subsystems = [s for s in subsystems if s.name == subsystem_name]
|
|
519
|
+
if not subsystems:
|
|
520
|
+
return {"error": f"Subsystem '{subsystem_name}' not found", "success": False}
|
|
521
|
+
|
|
522
|
+
# Step 3: Try to load architecture model context
|
|
523
|
+
model_context = _load_model_context(repo_path)
|
|
524
|
+
|
|
525
|
+
# Step 4: Process each subsystem
|
|
526
|
+
results: dict[str, dict[str, Any]] = {}
|
|
527
|
+
|
|
528
|
+
for subsystem in subsystems:
|
|
529
|
+
sub_result = await _process_subsystem(
|
|
530
|
+
subsystem=subsystem,
|
|
531
|
+
repo_path=repo_path,
|
|
532
|
+
runner=runner,
|
|
533
|
+
model_context=model_context,
|
|
534
|
+
max_iterations=max_iterations,
|
|
535
|
+
target_pass_rate=target_pass_rate,
|
|
536
|
+
blind=blind,
|
|
537
|
+
all_subsystems=subsystems,
|
|
538
|
+
)
|
|
539
|
+
results[subsystem.name] = sub_result
|
|
540
|
+
|
|
541
|
+
# Record in telemetry
|
|
542
|
+
_record_outcome(
|
|
543
|
+
repo_path=repo_path,
|
|
544
|
+
subsystem=subsystem,
|
|
545
|
+
result=sub_result,
|
|
546
|
+
mode="blind" if blind else "normal",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Step 5: Run full test suite as integration check
|
|
550
|
+
all_test_files = []
|
|
551
|
+
for s in subsystems:
|
|
552
|
+
all_test_files.extend(s.test_files)
|
|
553
|
+
full_result = run_subsystem_tests(all_test_files, repo_path) if all_test_files else {}
|
|
554
|
+
|
|
555
|
+
elapsed = time.time() - start_time
|
|
556
|
+
|
|
557
|
+
# Summary
|
|
558
|
+
converged = sum(1 for r in results.values() if r.get("converged", False))
|
|
559
|
+
total_subs = len(results)
|
|
560
|
+
|
|
561
|
+
# Record learning curve entry
|
|
562
|
+
try:
|
|
563
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
564
|
+
store = TelemetryStore()
|
|
565
|
+
|
|
566
|
+
all_metrics = [r.get("token_metrics", {}) for r in results.values()]
|
|
567
|
+
avg_prompt = sum(m.get("prompt_tokens", 0) for m in all_metrics) / max(len(all_metrics), 1)
|
|
568
|
+
avg_source = sum(m.get("source_equivalent_tokens", 0) for m in all_metrics) / max(len(all_metrics), 1)
|
|
569
|
+
avg_compression = sum(m.get("compression_ratio", 0) for m in all_metrics) / max(len(all_metrics), 1)
|
|
570
|
+
avg_iters = sum(r.get("iterations", 0) for r in results.values()) / max(len(results), 1)
|
|
571
|
+
avg_pass = sum(r.get("pass_rate", 0) for r in results.values()) / max(len(results), 1)
|
|
572
|
+
|
|
573
|
+
store.record_learning_curve(
|
|
574
|
+
repo=repo_path.name,
|
|
575
|
+
mode="blind" if blind else "normal",
|
|
576
|
+
total_subsystems=total_subs,
|
|
577
|
+
converged_subsystems=converged,
|
|
578
|
+
avg_pass_rate=avg_pass,
|
|
579
|
+
avg_iterations=avg_iters,
|
|
580
|
+
avg_prompt_tokens=avg_prompt,
|
|
581
|
+
avg_source_equivalent=avg_source,
|
|
582
|
+
avg_compression_ratio=avg_compression,
|
|
583
|
+
total_time_seconds=elapsed,
|
|
584
|
+
)
|
|
585
|
+
except Exception:
|
|
586
|
+
pass # Telemetry is best-effort
|
|
587
|
+
|
|
588
|
+
# --- Learning loop: generate report card ---
|
|
589
|
+
report_card = None
|
|
590
|
+
try:
|
|
591
|
+
from opencode_arch.learning.assessor import generate_report_card
|
|
592
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
593
|
+
import json
|
|
594
|
+
|
|
595
|
+
store = TelemetryStore()
|
|
596
|
+
|
|
597
|
+
# Get previous fidelity/compression for trend detection
|
|
598
|
+
prev_cards = store.get_report_cards(limit=1)
|
|
599
|
+
prev_fidelity = prev_cards[0]["fidelity"] if prev_cards else None
|
|
600
|
+
prev_compression = prev_cards[0]["compression_ratio"] if prev_cards else None
|
|
601
|
+
|
|
602
|
+
report_card = generate_report_card(
|
|
603
|
+
repo=repo_path.name,
|
|
604
|
+
mode="blind" if blind else "normal",
|
|
605
|
+
subsystem_results=results,
|
|
606
|
+
previous_fidelity=prev_fidelity,
|
|
607
|
+
previous_compression=prev_compression,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Store report card
|
|
611
|
+
store.record_report_card(
|
|
612
|
+
repo=report_card.repo,
|
|
613
|
+
mode=report_card.mode,
|
|
614
|
+
grade=report_card.grade,
|
|
615
|
+
fidelity=report_card.fidelity,
|
|
616
|
+
compression_ratio=report_card.compression_ratio,
|
|
617
|
+
failure_patterns=json.dumps(report_card.failure_patterns),
|
|
618
|
+
novel_patterns=report_card.novel_patterns,
|
|
619
|
+
improvement_actions=json.dumps(report_card.improvement_actions),
|
|
620
|
+
)
|
|
621
|
+
except Exception:
|
|
622
|
+
pass # Report card generation is best-effort
|
|
623
|
+
|
|
624
|
+
# --- Learning loop: extract lessons ---
|
|
625
|
+
try:
|
|
626
|
+
from opencode_arch.learning.lessons import extract_lessons
|
|
627
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
628
|
+
import json
|
|
629
|
+
|
|
630
|
+
store = TelemetryStore()
|
|
631
|
+
lessons = extract_lessons(
|
|
632
|
+
repo=repo_path.name,
|
|
633
|
+
mode="blind" if blind else "normal",
|
|
634
|
+
subsystem_results=results,
|
|
635
|
+
)
|
|
636
|
+
for lesson in lessons:
|
|
637
|
+
store.record_lesson(
|
|
638
|
+
lesson_id=lesson.lesson_id,
|
|
639
|
+
discovered_repo=lesson.discovered_repo,
|
|
640
|
+
category=lesson.category,
|
|
641
|
+
description=lesson.description,
|
|
642
|
+
evidence=json.dumps(lesson.evidence),
|
|
643
|
+
)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass # Lesson extraction is best-effort
|
|
646
|
+
|
|
647
|
+
# --- Learning loop: detect and fix documentation drift ---
|
|
648
|
+
try:
|
|
649
|
+
from opencode_arch.learning.maintainer import detect_drift, auto_fix_drift
|
|
650
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
651
|
+
|
|
652
|
+
store = TelemetryStore()
|
|
653
|
+
drift_flags = detect_drift(repo_path)
|
|
654
|
+
if drift_flags:
|
|
655
|
+
# Record flags
|
|
656
|
+
for flag in drift_flags:
|
|
657
|
+
store.record_drift_flag(
|
|
658
|
+
file=flag.file,
|
|
659
|
+
issue=flag.issue,
|
|
660
|
+
severity=flag.severity,
|
|
661
|
+
auto_fixable=flag.auto_fixable,
|
|
662
|
+
suggested_fix=flag.suggested_fix,
|
|
663
|
+
)
|
|
664
|
+
# Attempt auto-fix
|
|
665
|
+
auto_fix_drift(drift_flags, repo_path)
|
|
666
|
+
except Exception:
|
|
667
|
+
pass # Drift detection is best-effort
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
"success": True,
|
|
671
|
+
"subsystem_results": results,
|
|
672
|
+
"total_subsystems": total_subs,
|
|
673
|
+
"converged_subsystems": converged,
|
|
674
|
+
"full_test_result": full_result,
|
|
675
|
+
"time_seconds": elapsed,
|
|
676
|
+
"report_card": {
|
|
677
|
+
"grade": report_card.grade,
|
|
678
|
+
"fidelity": report_card.fidelity,
|
|
679
|
+
"compression_ratio": report_card.compression_ratio,
|
|
680
|
+
"improvement_actions": report_card.improvement_actions,
|
|
681
|
+
} if report_card else None,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
async def _process_subsystem(
|
|
686
|
+
subsystem,
|
|
687
|
+
repo_path: Path,
|
|
688
|
+
runner: RunnerBackend,
|
|
689
|
+
model_context: str,
|
|
690
|
+
max_iterations: int,
|
|
691
|
+
target_pass_rate: float,
|
|
692
|
+
blind: bool = False,
|
|
693
|
+
all_subsystems: list | None = None,
|
|
694
|
+
) -> dict[str, Any]:
|
|
695
|
+
"""Process a single subsystem through the regen loop."""
|
|
696
|
+
start_time = time.time()
|
|
697
|
+
|
|
698
|
+
# In blind mode, set up isolated working directory
|
|
699
|
+
work_dir: Path | None = None
|
|
700
|
+
if blind:
|
|
701
|
+
work_dir = _setup_blind_workdir(repo_path, subsystem.test_files)
|
|
702
|
+
|
|
703
|
+
# Determine effective paths for runner and tests
|
|
704
|
+
effective_path = work_dir if blind else repo_path
|
|
705
|
+
|
|
706
|
+
# In blind mode, remap test file paths to the work_dir
|
|
707
|
+
if blind and work_dir:
|
|
708
|
+
effective_test_files = []
|
|
709
|
+
for tf in subsystem.test_files:
|
|
710
|
+
if tf.exists():
|
|
711
|
+
rel = tf.resolve().relative_to(repo_path)
|
|
712
|
+
effective_test_files.append(work_dir / rel)
|
|
713
|
+
else:
|
|
714
|
+
effective_test_files.append(tf)
|
|
715
|
+
else:
|
|
716
|
+
effective_test_files = subsystem.test_files
|
|
717
|
+
|
|
718
|
+
# In blind mode, extract signatures from architecture model
|
|
719
|
+
signatures = []
|
|
720
|
+
model_constants = []
|
|
721
|
+
if blind:
|
|
722
|
+
signatures = _extract_signatures_for_subsystem(repo_path, subsystem)
|
|
723
|
+
model_constants = _extract_constants_for_subsystem(repo_path, subsystem)
|
|
724
|
+
|
|
725
|
+
# Analyze test files for contracts and constants
|
|
726
|
+
all_contracts = []
|
|
727
|
+
all_constants = []
|
|
728
|
+
all_imports = []
|
|
729
|
+
|
|
730
|
+
for test_file in subsystem.test_files:
|
|
731
|
+
if test_file.exists():
|
|
732
|
+
try:
|
|
733
|
+
analysis = analyze_test_file(test_file)
|
|
734
|
+
all_contracts.extend(analysis.contracts)
|
|
735
|
+
all_constants.extend(analysis.constants)
|
|
736
|
+
all_imports.extend(analysis.required_imports)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass # Gracefully skip unparseable test files
|
|
739
|
+
|
|
740
|
+
# In blind mode, merge model constants (module-level, class attrs, instances)
|
|
741
|
+
# with test-derived constants, deduplicating by name
|
|
742
|
+
if blind and model_constants:
|
|
743
|
+
existing_names = {c.name for c in all_constants}
|
|
744
|
+
for mc in model_constants:
|
|
745
|
+
if mc.name not in existing_names:
|
|
746
|
+
all_constants.append(mc)
|
|
747
|
+
existing_names.add(mc.name)
|
|
748
|
+
|
|
749
|
+
# Build dependency context (APIs from subsystems we depend on)
|
|
750
|
+
dependency_apis = _build_dependency_context(subsystem, repo_path)
|
|
751
|
+
|
|
752
|
+
# Compute source-equivalent token baseline
|
|
753
|
+
source_equivalent_tokens = _compute_source_equivalent(
|
|
754
|
+
subsystem, repo_path, all_subsystems or []
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
# --- Learning loop: proactive adaptations ---
|
|
758
|
+
contract_cap = 50
|
|
759
|
+
try:
|
|
760
|
+
from opencode_arch.learning.adapter import get_adaptations, apply_adaptations
|
|
761
|
+
|
|
762
|
+
# Compute body_hint coverage for this subsystem
|
|
763
|
+
sigs_with_hints = sum(1 for s in signatures if getattr(s, "body_hint", ""))
|
|
764
|
+
body_hint_coverage = sigs_with_hints / len(signatures) if signatures else 0.0
|
|
765
|
+
|
|
766
|
+
adaptations = get_adaptations(
|
|
767
|
+
subsystem_name=subsystem.name,
|
|
768
|
+
dependency_count=len(subsystem.dependencies),
|
|
769
|
+
signature_count=len(signatures),
|
|
770
|
+
contract_count=len(all_contracts),
|
|
771
|
+
body_hint_coverage=body_hint_coverage,
|
|
772
|
+
)
|
|
773
|
+
if adaptations:
|
|
774
|
+
adapted = apply_adaptations(adaptations, contract_cap=50)
|
|
775
|
+
contract_cap = adapted.get("contract_cap", 50)
|
|
776
|
+
except Exception:
|
|
777
|
+
pass # Learning adaptations are best-effort
|
|
778
|
+
|
|
779
|
+
# Iterative loop
|
|
780
|
+
best_pass_rate = 0.0
|
|
781
|
+
feedback = ""
|
|
782
|
+
iterations_used = 0
|
|
783
|
+
last_metrics: PromptMetrics | None = None
|
|
784
|
+
all_failure_patterns: dict[str, int] = {}
|
|
785
|
+
|
|
786
|
+
for iteration in range(1, max_iterations + 1):
|
|
787
|
+
iterations_used = iteration
|
|
788
|
+
|
|
789
|
+
# Build prompt — in blind mode, show relative paths for file creation
|
|
790
|
+
if blind:
|
|
791
|
+
display_files = [f.resolve().relative_to(repo_path) for f in subsystem.source_files if f.exists()]
|
|
792
|
+
else:
|
|
793
|
+
display_files = subsystem.source_files
|
|
794
|
+
|
|
795
|
+
prompt, metrics = _build_prompt(
|
|
796
|
+
subsystem_name=subsystem.name,
|
|
797
|
+
source_files=display_files,
|
|
798
|
+
model_context=model_context,
|
|
799
|
+
constants=all_constants,
|
|
800
|
+
signatures=signatures,
|
|
801
|
+
contracts=all_contracts,
|
|
802
|
+
dependency_apis=dependency_apis,
|
|
803
|
+
iteration=iteration,
|
|
804
|
+
max_iterations=max_iterations,
|
|
805
|
+
previous_feedback=feedback,
|
|
806
|
+
source_equivalent_tokens=source_equivalent_tokens,
|
|
807
|
+
contract_cap=contract_cap,
|
|
808
|
+
)
|
|
809
|
+
last_metrics = metrics
|
|
810
|
+
|
|
811
|
+
# Call LLM via runner
|
|
812
|
+
run_result = await runner.run(prompt=prompt, repo_path=str(effective_path))
|
|
813
|
+
|
|
814
|
+
# Run subsystem tests
|
|
815
|
+
test_result = run_subsystem_tests(effective_test_files, effective_path)
|
|
816
|
+
pass_rate = test_result["pass_rate"]
|
|
817
|
+
|
|
818
|
+
if pass_rate > best_pass_rate:
|
|
819
|
+
best_pass_rate = pass_rate
|
|
820
|
+
|
|
821
|
+
# Check convergence
|
|
822
|
+
if pass_rate >= target_pass_rate:
|
|
823
|
+
token_metrics = _format_token_metrics(metrics)
|
|
824
|
+
return {
|
|
825
|
+
"converged": True,
|
|
826
|
+
"pass_rate": pass_rate,
|
|
827
|
+
"iterations": iteration,
|
|
828
|
+
"tests_passed": test_result["passed"],
|
|
829
|
+
"tests_total": test_result["total"],
|
|
830
|
+
"features": {
|
|
831
|
+
"constant_count": len(all_constants),
|
|
832
|
+
"signature_count": len(signatures),
|
|
833
|
+
"contract_count": len(all_contracts),
|
|
834
|
+
},
|
|
835
|
+
"token_metrics": token_metrics,
|
|
836
|
+
"failure_patterns": all_failure_patterns,
|
|
837
|
+
"time_seconds": time.time() - start_time,
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
# --- Learning loop: classify failures ---
|
|
841
|
+
try:
|
|
842
|
+
from opencode_arch.learning.classifier import classify_failures
|
|
843
|
+
classifications = classify_failures(
|
|
844
|
+
test_output=test_result["output"],
|
|
845
|
+
pass_rate=pass_rate,
|
|
846
|
+
total_tests=test_result["total"],
|
|
847
|
+
failed_tests=test_result["failed"],
|
|
848
|
+
)
|
|
849
|
+
for c in classifications:
|
|
850
|
+
pattern_name = c.pattern.value
|
|
851
|
+
all_failure_patterns[pattern_name] = all_failure_patterns.get(pattern_name, 0) + 1
|
|
852
|
+
except Exception:
|
|
853
|
+
pass # Classification is best-effort
|
|
854
|
+
|
|
855
|
+
# Analyze gaps for next iteration
|
|
856
|
+
feedback = analyze_gaps(test_result["output"], model_context)
|
|
857
|
+
|
|
858
|
+
# Did not converge
|
|
859
|
+
token_metrics = _format_token_metrics(last_metrics) if last_metrics else {}
|
|
860
|
+
return {
|
|
861
|
+
"converged": False,
|
|
862
|
+
"pass_rate": best_pass_rate,
|
|
863
|
+
"iterations": iterations_used,
|
|
864
|
+
"tests_passed": 0,
|
|
865
|
+
"tests_total": 0,
|
|
866
|
+
"last_feedback": feedback,
|
|
867
|
+
"features": {
|
|
868
|
+
"constant_count": len(all_constants),
|
|
869
|
+
"signature_count": len(signatures),
|
|
870
|
+
"contract_count": len(all_contracts),
|
|
871
|
+
},
|
|
872
|
+
"token_metrics": token_metrics,
|
|
873
|
+
"failure_patterns": all_failure_patterns,
|
|
874
|
+
"time_seconds": time.time() - start_time,
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _load_model_context(repo_path: Path) -> str:
|
|
879
|
+
"""Try to load architecture model context for the repo."""
|
|
880
|
+
model_file = repo_path / ".architecture-model.yaml"
|
|
881
|
+
if not model_file.exists():
|
|
882
|
+
return ""
|
|
883
|
+
|
|
884
|
+
try:
|
|
885
|
+
from architecture_model.core.parser import load_model
|
|
886
|
+
from opencode_arch.context import format_model_context
|
|
887
|
+
|
|
888
|
+
model = load_model(model_file)
|
|
889
|
+
return format_model_context(model, max_tokens=2000, detail_level="standard")
|
|
890
|
+
except Exception:
|
|
891
|
+
return ""
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _build_dependency_context(subsystem, repo_path: Path) -> str:
|
|
895
|
+
"""Build rich context string for dependency APIs.
|
|
896
|
+
|
|
897
|
+
Loads the architecture model and extracts the public API surface
|
|
898
|
+
(constants, classes, function signatures) of each dependency subsystem's
|
|
899
|
+
components. This gives the blind regen agent enough information to produce
|
|
900
|
+
correct imports and usage without access to source files.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
subsystem: Subsystem object with .dependencies list of dep names.
|
|
904
|
+
repo_path: Path to repo root (where .architecture-model.yaml lives).
|
|
905
|
+
|
|
906
|
+
Returns:
|
|
907
|
+
Formatted string with API surface per dependency, or "" if no deps.
|
|
908
|
+
"""
|
|
909
|
+
if not subsystem.dependencies:
|
|
910
|
+
return ""
|
|
911
|
+
|
|
912
|
+
model_file = repo_path / ".architecture-model.yaml"
|
|
913
|
+
if not model_file.exists():
|
|
914
|
+
# Fallback: just list dependency names
|
|
915
|
+
parts = []
|
|
916
|
+
for dep_name in subsystem.dependencies:
|
|
917
|
+
parts.append(f"- Depends on subsystem '{dep_name}'")
|
|
918
|
+
return "\n".join(parts)
|
|
919
|
+
|
|
920
|
+
try:
|
|
921
|
+
from architecture_model.core.parser import load_model
|
|
922
|
+
|
|
923
|
+
model = load_model(model_file)
|
|
924
|
+
except Exception:
|
|
925
|
+
# Model failed to load — fallback to names only
|
|
926
|
+
parts = []
|
|
927
|
+
for dep_name in subsystem.dependencies:
|
|
928
|
+
parts.append(f"- Depends on subsystem '{dep_name}'")
|
|
929
|
+
return "\n".join(parts)
|
|
930
|
+
|
|
931
|
+
# Build a mapping from dependency name -> matched components
|
|
932
|
+
sections = []
|
|
933
|
+
|
|
934
|
+
for dep_name in subsystem.dependencies:
|
|
935
|
+
# Find components that match this dependency (by component name or file stem)
|
|
936
|
+
matched_components = []
|
|
937
|
+
for comp in model.entities.components:
|
|
938
|
+
comp_files = getattr(comp, "files", [])
|
|
939
|
+
comp_stems = {Path(f).stem for f in comp_files} if comp_files else set()
|
|
940
|
+
|
|
941
|
+
if dep_name in comp_stems or comp.name == dep_name:
|
|
942
|
+
matched_components.append(comp)
|
|
943
|
+
|
|
944
|
+
if not matched_components:
|
|
945
|
+
sections.append(f"#### Module: {dep_name}\n (no model data available)")
|
|
946
|
+
continue
|
|
947
|
+
|
|
948
|
+
for comp in matched_components:
|
|
949
|
+
lines = [f"#### Module: {dep_name}"]
|
|
950
|
+
|
|
951
|
+
# Constants
|
|
952
|
+
for const in getattr(comp, "constants", []) or []:
|
|
953
|
+
const_type = getattr(const, "type", None) or ""
|
|
954
|
+
const_value = getattr(const, "value", None) or ""
|
|
955
|
+
if const_type and const_value:
|
|
956
|
+
lines.append(f" {const.name}: {const_type} = {const_value}")
|
|
957
|
+
elif const_value:
|
|
958
|
+
lines.append(f" {const.name} = {const_value}")
|
|
959
|
+
else:
|
|
960
|
+
lines.append(f" {const.name}")
|
|
961
|
+
|
|
962
|
+
# Class symbols
|
|
963
|
+
for sym in getattr(comp, "symbols", []) or []:
|
|
964
|
+
kind = getattr(sym, "kind", "")
|
|
965
|
+
if kind == "class":
|
|
966
|
+
supers = getattr(sym, "supers", []) or []
|
|
967
|
+
supers_str = ", ".join(supers) if supers else ""
|
|
968
|
+
if supers_str:
|
|
969
|
+
lines.append(f" class {sym.name}({supers_str}):")
|
|
970
|
+
else:
|
|
971
|
+
lines.append(f" class {sym.name}:")
|
|
972
|
+
members = getattr(sym, "members", []) or []
|
|
973
|
+
for member in members:
|
|
974
|
+
lines.append(f" .{member}")
|
|
975
|
+
|
|
976
|
+
# Function signatures (NO body_hint for deps — that's only for current subsystem)
|
|
977
|
+
for sig in getattr(comp, "signatures", []) or []:
|
|
978
|
+
params = ", ".join(sig.params) if sig.params else ""
|
|
979
|
+
ret = f" -> {sig.returns}" if getattr(sig, "returns", None) else ""
|
|
980
|
+
lines.append(f" def {sig.name}({params}){ret}")
|
|
981
|
+
|
|
982
|
+
sections.append("\n".join(lines))
|
|
983
|
+
|
|
984
|
+
return "\n\n".join(sections)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _format_token_metrics(metrics: PromptMetrics) -> dict[str, Any]:
|
|
988
|
+
"""Format PromptMetrics into a serializable dict for result tracking."""
|
|
989
|
+
return {
|
|
990
|
+
"prompt_tokens": metrics.total_tokens,
|
|
991
|
+
"source_equivalent_tokens": metrics.source_equivalent_tokens,
|
|
992
|
+
"compression_ratio": metrics.compression_ratio,
|
|
993
|
+
"sections": {
|
|
994
|
+
"model_context": metrics.model_context_tokens,
|
|
995
|
+
"signatures": metrics.signatures_tokens,
|
|
996
|
+
"constants": metrics.constants_tokens,
|
|
997
|
+
"contracts": metrics.contracts_tokens,
|
|
998
|
+
"dependency_apis": metrics.dependency_tokens,
|
|
999
|
+
"feedback": metrics.feedback_tokens,
|
|
1000
|
+
},
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _record_outcome(repo_path: Path, subsystem, result: dict[str, Any], mode: str = "normal"):
|
|
1005
|
+
"""Record regen outcome to telemetry store."""
|
|
1006
|
+
try:
|
|
1007
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
1008
|
+
store = TelemetryStore()
|
|
1009
|
+
features = result.get("features", {})
|
|
1010
|
+
token_metrics = result.get("token_metrics", {})
|
|
1011
|
+
store.log_regen_outcome(
|
|
1012
|
+
repo=repo_path.name,
|
|
1013
|
+
subsystem=subsystem.name,
|
|
1014
|
+
iteration=result.get("iterations", 0),
|
|
1015
|
+
features={
|
|
1016
|
+
"constant_count": features.get("constant_count", 0),
|
|
1017
|
+
"signature_count": features.get("signature_count", 0),
|
|
1018
|
+
"contract_count": features.get("contract_count", 0),
|
|
1019
|
+
},
|
|
1020
|
+
pass_rate=result.get("pass_rate", 0.0),
|
|
1021
|
+
time_seconds=result.get("time_seconds", 0.0),
|
|
1022
|
+
prompt_tokens=token_metrics.get("prompt_tokens", 0),
|
|
1023
|
+
source_equivalent_tokens=token_metrics.get("source_equivalent_tokens", 0),
|
|
1024
|
+
compression_ratio=token_metrics.get("compression_ratio", 0.0),
|
|
1025
|
+
mode=mode,
|
|
1026
|
+
)
|
|
1027
|
+
except Exception:
|
|
1028
|
+
pass # Telemetry is best-effort
|