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,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,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
|