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.
Files changed (65) hide show
  1. opencode_arch/__init__.py +3 -0
  2. opencode_arch/artifacts/__init__.py +48 -0
  3. opencode_arch/artifacts/context.py +451 -0
  4. opencode_arch/artifacts/diagrams.py +451 -0
  5. opencode_arch/artifacts/selector.py +331 -0
  6. opencode_arch/artifacts/templates.py +444 -0
  7. opencode_arch/cli/__init__.py +1 -0
  8. opencode_arch/cli/bench.py +25 -0
  9. opencode_arch/cli/calibrate.py +208 -0
  10. opencode_arch/cli/confidence.py +66 -0
  11. opencode_arch/cli/docs.py +333 -0
  12. opencode_arch/cli/docs_validator.py +295 -0
  13. opencode_arch/cli/export_data.py +133 -0
  14. opencode_arch/cli/extract.py +93 -0
  15. opencode_arch/cli/gap_analyzer.py +107 -0
  16. opencode_arch/cli/generate.py +68 -0
  17. opencode_arch/cli/launch.py +264 -0
  18. opencode_arch/cli/main.py +360 -0
  19. opencode_arch/cli/metrics.py +186 -0
  20. opencode_arch/cli/prompts.py +20 -0
  21. opencode_arch/cli/regen_loop.py +1028 -0
  22. opencode_arch/context/__init__.py +29 -0
  23. opencode_arch/context/formatter.py +492 -0
  24. opencode_arch/context/pipeline_bridge.py +201 -0
  25. opencode_arch/extract/__init__.py +8 -0
  26. opencode_arch/extract/constraint_detector.py +398 -0
  27. opencode_arch/extract/from_artifacts.py +837 -0
  28. opencode_arch/extract/from_code.py +646 -0
  29. opencode_arch/extract/route_detector.py +400 -0
  30. opencode_arch/extract/table_parser.py +177 -0
  31. opencode_arch/learning/__init__.py +19 -0
  32. opencode_arch/learning/adapter.py +157 -0
  33. opencode_arch/learning/assessor.py +170 -0
  34. opencode_arch/learning/classifier.py +144 -0
  35. opencode_arch/learning/lessons.py +139 -0
  36. opencode_arch/learning/maintainer.py +281 -0
  37. opencode_arch/learning/patterns.py +51 -0
  38. opencode_arch/mcp/__init__.py +1 -0
  39. opencode_arch/mcp/__main__.py +8 -0
  40. opencode_arch/mcp/server.py +183 -0
  41. opencode_arch/mcp/tools/__init__.py +1 -0
  42. opencode_arch/mcp/tools/check.py +159 -0
  43. opencode_arch/mcp/tools/extract.py +107 -0
  44. opencode_arch/mcp/tools/feedback.py +65 -0
  45. opencode_arch/mcp/tools/generate.py +104 -0
  46. opencode_arch/mcp/tools/group.py +62 -0
  47. opencode_arch/mcp/tools/ingest.py +101 -0
  48. opencode_arch/mcp/tools/require.py +77 -0
  49. opencode_arch/mcp/tools/scan.py +53 -0
  50. opencode_arch/mcp/tools/slice.py +235 -0
  51. opencode_arch/mcp/tools/validate.py +59 -0
  52. opencode_arch/prompts/__init__.py +1 -0
  53. opencode_arch/prompts/regen.py +36 -0
  54. opencode_arch/runner/__init__.py +5 -0
  55. opencode_arch/runner/base.py +21 -0
  56. opencode_arch/runner/opencode.py +66 -0
  57. opencode_arch/telemetry/__init__.py +6 -0
  58. opencode_arch/telemetry/collector.py +40 -0
  59. opencode_arch/telemetry/recorder.py +12 -0
  60. opencode_arch/telemetry/store.py +537 -0
  61. opencode_arch-1.0.0.dist-info/METADATA +247 -0
  62. opencode_arch-1.0.0.dist-info/RECORD +65 -0
  63. opencode_arch-1.0.0.dist-info/WHEEL +4 -0
  64. opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
  65. 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