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,281 @@
1
+ """Documentation drift detection and auto-fix.
2
+
3
+ Checks for discrepancies between code reality and documentation,
4
+ then either flags issues or auto-fixes simple cases.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class DriftFlag:
17
+ """A detected documentation drift issue."""
18
+ file: str # which doc is out of date
19
+ issue: str # what's wrong
20
+ severity: str # "blocker", "high", "medium", "low"
21
+ auto_fixable: bool = False # can we fix it programmatically?
22
+ suggested_fix: str = "" # what to change
23
+ fixed: bool = False # was it auto-fixed?
24
+
25
+
26
+ def _check_test_count(project_root: Path) -> DriftFlag | None:
27
+ """Check if README test count matches actual."""
28
+ readme = project_root / "README.md"
29
+ if not readme.exists():
30
+ return None
31
+
32
+ content = readme.read_text(encoding="utf-8")
33
+
34
+ # Find test count claims in README
35
+ match = re.search(r"(\d+)\s+tests?\s+pass", content, re.IGNORECASE)
36
+ if not match:
37
+ return None
38
+
39
+ claimed_count = int(match.group(1))
40
+
41
+ # Run actual test count (collect only, don't execute)
42
+ try:
43
+ result = subprocess.run(
44
+ ["python", "-m", "pytest", "--collect-only", "-q",
45
+ "--ignore=tests/test_config_loader.py"],
46
+ capture_output=True, text=True, cwd=str(project_root), timeout=30,
47
+ )
48
+ # Parse "X tests collected" or "X items"
49
+ count_match = re.search(r"(\d+) tests? (?:collected|selected)", result.stdout)
50
+ if not count_match:
51
+ # Try alternate format
52
+ count_match = re.search(r"(\d+) items?", result.stdout)
53
+ if not count_match:
54
+ return None
55
+
56
+ actual_count = int(count_match.group(1))
57
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
58
+ return None
59
+
60
+ if abs(actual_count - claimed_count) > 5: # Allow small variance
61
+ return DriftFlag(
62
+ file=str(readme.relative_to(project_root)),
63
+ issue=f"README claims {claimed_count} tests but actual is {actual_count}",
64
+ severity="medium",
65
+ auto_fixable=True,
66
+ suggested_fix=f"Replace '{claimed_count}' with '{actual_count}' in test count",
67
+ )
68
+ return None
69
+
70
+
71
+ def _check_version_sync(project_root: Path) -> DriftFlag | None:
72
+ """Check if version in pyproject.toml matches __init__.py."""
73
+ pyproject = project_root / "pyproject.toml"
74
+ if not pyproject.exists():
75
+ return None
76
+
77
+ pyproject_content = pyproject.read_text(encoding="utf-8")
78
+ version_match = re.search(r'version\s*=\s*"([^"]+)"', pyproject_content)
79
+ if not version_match:
80
+ return None
81
+ pyproject_version = version_match.group(1)
82
+
83
+ # Find __init__.py
84
+ for init_path in project_root.rglob("__init__.py"):
85
+ rel = str(init_path.relative_to(project_root))
86
+ if "test" in rel or ".venv" in rel:
87
+ continue
88
+ try:
89
+ init_content = init_path.read_text(encoding="utf-8")
90
+ init_match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', init_content)
91
+ if init_match:
92
+ init_version = init_match.group(1)
93
+ if init_version != pyproject_version:
94
+ rel_path = init_path.relative_to(project_root)
95
+ return DriftFlag(
96
+ file=str(rel_path),
97
+ issue=f"__version__='{init_version}' != pyproject.toml version='{pyproject_version}'",
98
+ severity="high",
99
+ auto_fixable=True,
100
+ suggested_fix=f"Update __version__ to '{pyproject_version}'",
101
+ )
102
+ break # Found and matches
103
+ except (UnicodeDecodeError, OSError):
104
+ continue
105
+ return None
106
+
107
+
108
+ def _check_schema_version(project_root: Path) -> DriftFlag | None:
109
+ """Check if schema.json version matches CONTEXT.md claims."""
110
+ schema_path = project_root / "src" / "architecture_model" / "spec" / "schema.json"
111
+ context_path = project_root / "CONTEXT.md"
112
+
113
+ if not schema_path.exists() or not context_path.exists():
114
+ return None
115
+
116
+ try:
117
+ import json
118
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
119
+ schema_id = schema.get("$id", "")
120
+ # Extract version from $id URL
121
+ schema_version_match = re.search(r"v?([\d.]+)", schema_id)
122
+ if not schema_version_match:
123
+ return None
124
+ schema_version = schema_version_match.group(1)
125
+
126
+ context_content = context_path.read_text(encoding="utf-8")
127
+ context_match = re.search(r"[Ss]chema version:?\s*([\d.]+)", context_content)
128
+ if not context_match:
129
+ return None
130
+ context_version = context_match.group(1)
131
+
132
+ if schema_version != context_version:
133
+ return DriftFlag(
134
+ file="src/architecture_model/spec/schema.json",
135
+ issue=f"Schema $id version ({schema_version}) != CONTEXT.md ({context_version})",
136
+ severity="medium",
137
+ auto_fixable=False,
138
+ suggested_fix=f"Align schema $id version to {context_version}",
139
+ )
140
+ except (json.JSONDecodeError, OSError):
141
+ pass
142
+ return None
143
+
144
+
145
+ def _check_python_version(project_root: Path) -> DriftFlag | None:
146
+ """Check if README Python version matches pyproject.toml requires-python."""
147
+ readme = project_root / "README.md"
148
+ pyproject = project_root / "pyproject.toml"
149
+
150
+ if not readme.exists() or not pyproject.exists():
151
+ return None
152
+
153
+ pyproject_content = pyproject.read_text(encoding="utf-8")
154
+ req_match = re.search(r'requires-python\s*=\s*"([^"]+)"', pyproject_content)
155
+ if not req_match:
156
+ return None
157
+
158
+ # Extract minimum version from requires-python (e.g., ">=3.11" -> "3.11")
159
+ min_version_match = re.search(r"(\d+\.\d+)", req_match.group(1))
160
+ if not min_version_match:
161
+ return None
162
+ min_version = min_version_match.group(1)
163
+
164
+ readme_content = readme.read_text(encoding="utf-8")
165
+ readme_match = re.search(r"Python\s+(\d+\.\d+)\+?", readme_content)
166
+ if not readme_match:
167
+ return None
168
+
169
+ readme_version = readme_match.group(1)
170
+ if readme_version != min_version:
171
+ return DriftFlag(
172
+ file="README.md",
173
+ issue=f"README says Python {readme_version}+ but pyproject requires >={min_version}",
174
+ severity="medium",
175
+ auto_fixable=True,
176
+ suggested_fix=f"Replace 'Python {readme_version}+' with 'Python {min_version}+'",
177
+ )
178
+ return None
179
+
180
+
181
+ def detect_drift(project_root: Path) -> list[DriftFlag]:
182
+ """Run all drift detection checks on a project.
183
+
184
+ Args:
185
+ project_root: Path to the project to check.
186
+
187
+ Returns:
188
+ List of detected drift issues (may be empty if all is well).
189
+ """
190
+ checks = [
191
+ _check_test_count,
192
+ _check_version_sync,
193
+ _check_schema_version,
194
+ _check_python_version,
195
+ ]
196
+
197
+ flags: list[DriftFlag] = []
198
+ for check in checks:
199
+ try:
200
+ flag = check(project_root)
201
+ if flag:
202
+ flags.append(flag)
203
+ except Exception:
204
+ pass # Drift detection is best-effort
205
+
206
+ return flags
207
+
208
+
209
+ def auto_fix_drift(flags: list[DriftFlag], project_root: Path) -> list[DriftFlag]:
210
+ """Auto-fix drift flags that are marked as auto_fixable.
211
+
212
+ Args:
213
+ flags: List of detected drift flags.
214
+ project_root: Path to the project root.
215
+
216
+ Returns:
217
+ List of flags that were successfully fixed.
218
+ """
219
+ fixed: list[DriftFlag] = []
220
+
221
+ for flag in flags:
222
+ if not flag.auto_fixable:
223
+ continue
224
+
225
+ file_path = project_root / flag.file
226
+ if not file_path.exists():
227
+ continue
228
+
229
+ try:
230
+ content = file_path.read_text(encoding="utf-8")
231
+ new_content = content
232
+
233
+ if "test count" in flag.issue.lower() or "tests" in flag.issue.lower():
234
+ # Fix test count
235
+ match = re.search(r"(\d+)\s+tests?\s+pass", flag.issue)
236
+ new_match = re.search(r"actual is (\d+)", flag.issue)
237
+ if match and new_match:
238
+ old_count = match.group(1)
239
+ new_count = new_match.group(1)
240
+ new_content = re.sub(
241
+ rf"{old_count}(\s+tests?\s+pass)",
242
+ f"{new_count}\\1",
243
+ content,
244
+ )
245
+
246
+ elif "__version__" in flag.issue:
247
+ # Fix version mismatch
248
+ match = re.search(r"to '([^']+)'", flag.suggested_fix)
249
+ if match:
250
+ target_version = match.group(1)
251
+ new_content = re.sub(
252
+ r'__version__\s*=\s*["\'][^"\']+["\']',
253
+ f'__version__ = "{target_version}"',
254
+ content,
255
+ )
256
+
257
+ elif "Python" in flag.issue:
258
+ # Fix Python version
259
+ match = re.search(r"Python ([\d.]+)\+.*Python ([\d.]+)\+", flag.suggested_fix)
260
+ if match:
261
+ old_ver = match.group(1)
262
+ new_ver = match.group(2)
263
+ new_content = content.replace(f"Python {old_ver}+", f"Python {new_ver}+")
264
+ else:
265
+ # Try alternate parsing from issue text
266
+ old_match = re.search(r"says Python ([\d.]+)", flag.issue)
267
+ new_match_ver = re.search(r"requires >=([\d.]+)", flag.issue)
268
+ if old_match and new_match_ver:
269
+ old_ver = old_match.group(1)
270
+ new_ver = new_match_ver.group(1)
271
+ new_content = content.replace(f"Python {old_ver}+", f"Python {new_ver}+")
272
+
273
+ if new_content != content:
274
+ file_path.write_text(new_content, encoding="utf-8")
275
+ flag.fixed = True
276
+ fixed.append(flag)
277
+
278
+ except (OSError, UnicodeDecodeError):
279
+ pass
280
+
281
+ return fixed
@@ -0,0 +1,51 @@
1
+ """Failure pattern definitions and taxonomy."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+
7
+
8
+ class PatternType(str, Enum):
9
+ """Known failure pattern types."""
10
+ CROSS_DEP = "cross_dep" # ImportError/NameError from another module
11
+ MISSING_IMPL = "missing_impl" # AttributeError on generated code
12
+ WRONG_CONSTANT = "wrong_constant" # AssertionError with literal mismatch
13
+ API_MISMATCH = "api_mismatch" # TypeError wrong arg count/types
14
+ COMPLEX_BEHAVIOR = "complex_behavior" # Multiple sequential failures
15
+ TEST_INFRA = "test_infra" # ModuleNotFoundError for test helpers
16
+ UNKNOWN = "unknown" # Unclassified
17
+
18
+
19
+ class Strategy(str, Enum):
20
+ """Adaptation strategies to apply for each pattern."""
21
+ EXPAND_DEP_CONTEXT = "expand_dep_context" # Include full dep API + body_hints
22
+ INCLUDE_SOURCE_EXCERPT = "include_source_excerpt" # Escape hatch: include source
23
+ PRIORITIZE_CONSTANTS = "prioritize_constants" # Move constants to top, increase budget
24
+ FIX_SIGNATURES = "fix_signatures" # Include exact param lists
25
+ INCREASE_CONTRACT_CAP = "increase_contract_cap" # Raise from 50 to 100+
26
+ COPY_TEST_INFRA = "copy_test_infra" # Additional test helper copying
27
+ NO_ACTION = "no_action" # Pattern recognized but no fix available
28
+
29
+
30
+ @dataclass
31
+ class FailureClassification:
32
+ """A classified test failure with dual-level signals."""
33
+ pattern: PatternType
34
+ confidence: float # 0.0-1.0
35
+ raw_signal: str # regex match from pytest output
36
+ structured_signal: dict = field(default_factory=dict) # parsed details
37
+ suggested_strategy: Strategy = Strategy.NO_ACTION
38
+ affected_symbols: list[str] = field(default_factory=list) # symbols/modules involved
39
+ source_module: str = "" # which module the failure relates to
40
+
41
+
42
+ # Pattern → Strategy mapping (default strategies)
43
+ PATTERN_STRATEGIES: dict[PatternType, Strategy] = {
44
+ PatternType.CROSS_DEP: Strategy.EXPAND_DEP_CONTEXT,
45
+ PatternType.MISSING_IMPL: Strategy.INCLUDE_SOURCE_EXCERPT,
46
+ PatternType.WRONG_CONSTANT: Strategy.PRIORITIZE_CONSTANTS,
47
+ PatternType.API_MISMATCH: Strategy.FIX_SIGNATURES,
48
+ PatternType.COMPLEX_BEHAVIOR: Strategy.INCREASE_CONTRACT_CAP,
49
+ PatternType.TEST_INFRA: Strategy.COPY_TEST_INFRA,
50
+ PatternType.UNKNOWN: Strategy.NO_ACTION,
51
+ }
@@ -0,0 +1 @@
1
+ """MCP server for architecture tools."""
@@ -0,0 +1,8 @@
1
+ """Allow running as: python -m opencode_arch.mcp"""
2
+ from opencode_arch.mcp.server import mcp
3
+
4
+ if mcp is not None:
5
+ mcp.run()
6
+ else:
7
+ print("Error: mcp package not installed. Install with: pip install 'opencode-arch[mcp]'")
8
+ raise SystemExit(1)
@@ -0,0 +1,183 @@
1
+ # src/opencode_arch/mcp/server.py
2
+ """FastMCP server entry point for opencode-arch.
3
+
4
+ Run with: python -m opencode_arch.mcp.server
5
+ """
6
+ from __future__ import annotations
7
+
8
+ try:
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+ mcp = FastMCP(
12
+ "opencode-arch",
13
+ instructions="Architecture context compression, validation, and code quality tools",
14
+ )
15
+
16
+ from opencode_arch.mcp.tools.scan import scan_repository
17
+ from opencode_arch.mcp.tools.slice import slice_context
18
+ from opencode_arch.mcp.tools.validate import validate_architecture
19
+ from opencode_arch.mcp.tools.extract import store_extraction
20
+ from opencode_arch.mcp.tools.generate import run_tests_on_generated_code
21
+ from opencode_arch.mcp.tools.group import group_repository
22
+ from opencode_arch.mcp.tools.check import check_representativeness
23
+ from opencode_arch.mcp.tools.require import capture_requirement
24
+ from opencode_arch.mcp.tools.feedback import record_feedback
25
+ from opencode_arch.mcp.tools.ingest import ingest_source_graph
26
+
27
+ @mcp.tool()
28
+ async def architect_scan(repo_path: str) -> dict:
29
+ """Scan a repository to generate its reality manifest (AST analysis).
30
+
31
+ Returns a manifest with: modules, functions, classes, imports, metrics.
32
+ Use this as raw material before slicing context.
33
+ """
34
+ return await scan_repository(repo_path=repo_path)
35
+
36
+ @mcp.tool()
37
+ async def architect_slice(repo_path: str, focus: str = "all", budget: int = 4000, detail: str = "standard") -> str:
38
+ """Generate an optimized context slice from a repository.
39
+
40
+ Compresses the full repository into a dense context string within the
41
+ token budget. This is the core token-arbitrage function.
42
+
43
+ Args:
44
+ repo_path: Absolute path to the repository.
45
+ focus: "all", an F-block ID ("F1"), layer name, or artifact name ("icd").
46
+ budget: Maximum token budget (default 4000).
47
+ detail: "minimal", "standard", or "full".
48
+ """
49
+ return await slice_context(repo_path=repo_path, focus=focus, budget=budget, detail=detail)
50
+
51
+ @mcp.tool()
52
+ async def architect_validate(model_yaml: str) -> dict:
53
+ """Validate an architecture model for structural correctness.
54
+
55
+ Checks: ID uniqueness, referential integrity, orphan detection,
56
+ capability realization, meta completeness. Returns score 0-100.
57
+ """
58
+ return await validate_architecture(model_yaml=model_yaml)
59
+
60
+ @mcp.tool()
61
+ async def architect_extract(repo_path: str, model_yaml: str, context_tokens: int = 0) -> dict:
62
+ """Store a validated architecture extraction.
63
+
64
+ Call AFTER the agent produces a YAML model. Validates, writes to
65
+ .architecture-model.yaml, and records telemetry.
66
+ """
67
+ return await store_extraction(repo_path=repo_path, model_yaml=model_yaml, context_tokens=context_tokens)
68
+
69
+ @mcp.tool()
70
+ async def architect_generate(repo_path: str, test_command: str = "") -> dict:
71
+ """Run tests on generated code to verify quality.
72
+
73
+ Executes the repository's test suite against generated code.
74
+ Returns pass rate, failures, and total test count.
75
+ """
76
+ return await run_tests_on_generated_code(repo_path=repo_path, test_command=test_command or None)
77
+
78
+ @mcp.tool()
79
+ async def architect_group(repo_path: str, target_groups: int = 0) -> dict:
80
+ """Group repository modules into logical architecture components.
81
+
82
+ Uses multi-signal affinity (subdirectory, name-prefix, imports) to
83
+ suggest component boundaries. Call after scan, before extraction.
84
+
85
+ Args:
86
+ repo_path: Absolute path to the repository.
87
+ target_groups: Desired number of groups (0 = auto-calculate).
88
+ """
89
+ return await group_repository(repo_path=repo_path, target_groups=target_groups)
90
+
91
+ @mcp.tool()
92
+ async def architect_check(repo_path: str, model_yaml: str) -> dict:
93
+ """Verify model representativeness against code reality.
94
+
95
+ Computes three sub-scores comparing model against AST-derived ground truth.
96
+ Target: 100% on all three. Returns file_coverage, relationship_accuracy,
97
+ boundary_coherence, and overall (0-100).
98
+ """
99
+ return await check_representativeness(repo_path=repo_path, model_yaml=model_yaml)
100
+
101
+ @mcp.tool()
102
+ async def architect_require(
103
+ repo_path: str,
104
+ requirement: str,
105
+ component_id: str = "",
106
+ priority: str = "must",
107
+ context: str = "",
108
+ ) -> dict:
109
+ """Capture a functional requirement linked to an architecture component.
110
+
111
+ Stores requirements in .architecture/requirements.yaml with MoSCoW priority.
112
+
113
+ Args:
114
+ repo_path: Absolute path to the repository.
115
+ requirement: The requirement text.
116
+ component_id: Component ID (e.g., "COMP-3"). Empty string = unlinked.
117
+ priority: must | should | could (MoSCoW).
118
+ context: Additional context from conversation.
119
+ """
120
+ return await capture_requirement(
121
+ repo_path=repo_path,
122
+ requirement=requirement,
123
+ component_id=component_id or None,
124
+ priority=priority,
125
+ context=context,
126
+ )
127
+
128
+ @mcp.tool()
129
+ async def architect_feedback(
130
+ repo_path: str,
131
+ feedback_type: str,
132
+ content: str,
133
+ context: dict | None = None,
134
+ rating: int | None = None,
135
+ correction: dict | None = None,
136
+ ) -> dict:
137
+ """Record user feedback for model improvement and training.
138
+
139
+ Appends feedback to .architecture/feedback.jsonl for future training use.
140
+
141
+ Args:
142
+ repo_path: Absolute path to the repository.
143
+ feedback_type: "correction" | "rating" | "tool_feedback" | "training".
144
+ content: The feedback content (human-readable).
145
+ context: Optional context dict.
146
+ rating: Optional 1-5 quality rating.
147
+ correction: Optional structured correction {entity_id, field, old, new}.
148
+ """
149
+ return await record_feedback(
150
+ repo_path=repo_path,
151
+ feedback_type=feedback_type,
152
+ content=content,
153
+ context=context,
154
+ rating=rating,
155
+ correction=correction,
156
+ )
157
+
158
+ @mcp.tool()
159
+ async def architect_ingest(repo_path: str, source_graph_json: str) -> dict:
160
+ """Ingest a SourceGraph JSON for any language repository.
161
+
162
+ Accepts dependency and export data (from external tools or agent analysis),
163
+ groups modules into components, extracts interface contracts, and stores
164
+ the architecture model.
165
+
166
+ Args:
167
+ repo_path: Absolute path to the repository.
168
+ source_graph_json: JSON string with SourceGraph data. Format:
169
+ {"language": "typescript", "units": [{"file": "...", "exports": [...]}], "edges": [...]}
170
+ """
171
+ return await ingest_source_graph(repo_path=repo_path, source_graph_json=source_graph_json)
172
+
173
+ except ImportError:
174
+ # mcp package not available - tools still work as standalone async functions
175
+ mcp = None
176
+
177
+
178
+ if __name__ == "__main__":
179
+ if mcp is not None:
180
+ mcp.run()
181
+ else:
182
+ print("Error: mcp package not installed. Install with: pip install 'opencode-arch[mcp]'")
183
+ raise SystemExit(1)
@@ -0,0 +1 @@
1
+ """MCP tool implementations."""
@@ -0,0 +1,159 @@
1
+ """architect_check MCP tool — verify model representativeness against code reality."""
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ async def check_representativeness(repo_path: str, model_yaml: str) -> dict[str, Any]:
12
+ """Check how well an architecture model represents the actual codebase.
13
+
14
+ Supports three modes:
15
+ - Hierarchical (config): uses pre-existing fblock_dict from config
16
+ - Hierarchical (auto): generates F-blocks from module grouping when no config exists
17
+ - Flat: fallback when neither hierarchical path is available
18
+
19
+ Args:
20
+ repo_path: Absolute path to the repository root.
21
+ model_yaml: The architecture model YAML to evaluate.
22
+
23
+ Returns:
24
+ Dict with scores, details, and suggested improvements.
25
+ """
26
+ path = Path(repo_path)
27
+ if not path.exists():
28
+ return {"error": f"Repository path does not exist: {repo_path}"}
29
+
30
+ try:
31
+ from architecture_model.manifest.generator import generate_manifest
32
+ from architecture_model.core.parser import load_model
33
+ from architecture_model.core.representativeness import compute_representativeness as _compute
34
+
35
+ # Parse the model via temp file
36
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
37
+ f.write(model_yaml)
38
+ tmp_path = f.name
39
+
40
+ try:
41
+ model = load_model(tmp_path)
42
+ finally:
43
+ Path(tmp_path).unlink(missing_ok=True)
44
+
45
+ # Try hierarchical mode (config-based)
46
+ output = _try_hierarchical(path, model)
47
+ if output is None:
48
+ # Try auto-F-block hierarchical mode
49
+ output = _try_auto_hierarchical(path, model)
50
+ if output is None:
51
+ # Fall back to flat mode
52
+ manifest = generate_manifest(path)
53
+ result = _compute(model, manifest.modules, manifest.interfaces)
54
+ output = {
55
+ "mode": "flat",
56
+ "file_coverage": round(result.file_coverage, 1),
57
+ "relationship_accuracy": round(result.relationship_accuracy, 1),
58
+ "boundary_coherence": round(result.boundary_coherence, 1),
59
+ "overall": round(result.overall, 1),
60
+ "uncovered_files": result.uncovered_files,
61
+ "unverified_relationships": result.unverified_relationships,
62
+ "low_coherence_components": result.low_coherence_components,
63
+ }
64
+
65
+ try:
66
+ from opencode_arch.telemetry.collector import drain_and_store
67
+ drain_and_store(tool="architect_check", repo=path.name)
68
+ except Exception:
69
+ pass
70
+
71
+ return output
72
+
73
+ except Exception as e:
74
+ return {"error": f"Check failed: {e}"}
75
+
76
+
77
+ def _try_hierarchical(path: Path, root_model) -> dict[str, Any] | None:
78
+ """Attempt hierarchical check if recursive manifests are available via config."""
79
+ try:
80
+ from architecture_model.manifest.recursive import generate_recursive_manifests
81
+ from architecture_model.core.representativeness import compute_hierarchical_representativeness
82
+ from architecture_model.config.loader import get_config
83
+
84
+ config = get_config(path)
85
+ if not config.fblock_dict or len(config.fblock_dict) < 2:
86
+ return None
87
+
88
+ recursive_manifests = generate_recursive_manifests(path)
89
+ if not recursive_manifests:
90
+ return None
91
+
92
+ result = compute_hierarchical_representativeness(
93
+ root_model, {}, recursive_manifests
94
+ )
95
+
96
+ return _format_hierarchical_output(result)
97
+
98
+ except Exception:
99
+ return None
100
+
101
+
102
+ def _try_auto_hierarchical(path: Path, root_model) -> dict[str, Any] | None:
103
+ """Attempt hierarchical check using auto-generated F-blocks from module grouping."""
104
+ try:
105
+ from architecture_model.manifest.generator import generate_manifest
106
+ from architecture_model.manifest.grouping import group_modules, auto_fblocks
107
+ from architecture_model.manifest.recursive import generate_recursive_manifests
108
+ from architecture_model.core.representativeness import compute_hierarchical_representativeness
109
+
110
+ manifest = generate_manifest(path)
111
+ groups = group_modules(manifest.modules, manifest.interfaces)
112
+ fblock_config = auto_fblocks(groups)
113
+
114
+ if not fblock_config or len(fblock_config) < 2:
115
+ return None
116
+
117
+ recursive_manifests = generate_recursive_manifests(path, fblock_override=fblock_config)
118
+ if not recursive_manifests:
119
+ return None
120
+
121
+ result = compute_hierarchical_representativeness(
122
+ root_model, {}, recursive_manifests
123
+ )
124
+
125
+ output = _format_hierarchical_output(result)
126
+ output["mode"] = "hierarchical_auto"
127
+ output["fblock_count"] = len(fblock_config)
128
+ return output
129
+
130
+ except Exception:
131
+ return None
132
+
133
+
134
+ def _format_hierarchical_output(result) -> dict[str, Any]:
135
+ """Format a hierarchical representativeness result into output dict."""
136
+ output: dict[str, Any] = {
137
+ "mode": "hierarchical",
138
+ "root": {
139
+ "file_coverage": round(result.root.file_coverage, 1),
140
+ "relationship_accuracy": round(result.root.relationship_accuracy, 1),
141
+ "boundary_coherence": round(result.root.boundary_coherence, 1),
142
+ "overall": round(result.root.overall, 1),
143
+ },
144
+ "blocks": {},
145
+ "overall": round(result.overall, 1),
146
+ "uncovered_files": result.root.uncovered_files,
147
+ "unverified_relationships": result.root.unverified_relationships,
148
+ "low_coherence_components": result.root.low_coherence_components,
149
+ }
150
+
151
+ for block_id, block_result in result.blocks.items():
152
+ output["blocks"][block_id] = {
153
+ "file_coverage": round(block_result.file_coverage, 1),
154
+ "relationship_accuracy": round(block_result.relationship_accuracy, 1),
155
+ "boundary_coherence": round(block_result.boundary_coherence, 1),
156
+ "overall": round(block_result.overall, 1),
157
+ }
158
+
159
+ return output