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,107 @@
|
|
|
1
|
+
"""architect_extract MCP tool — validate and store an architecture extraction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def store_extraction(
|
|
11
|
+
repo_path: str,
|
|
12
|
+
model_yaml: str,
|
|
13
|
+
context_tokens: int = 0,
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
"""Validate and store an architecture model extraction.
|
|
16
|
+
|
|
17
|
+
Called AFTER the agent has produced a YAML architecture model.
|
|
18
|
+
Validates the model, writes it to .architecture-model.yaml,
|
|
19
|
+
and records telemetry.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
repo_path: Path to the repository root (where to save the model).
|
|
23
|
+
model_yaml: The YAML architecture model produced by the agent.
|
|
24
|
+
context_tokens: How many tokens of context the agent used (for telemetry).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Dict with: stored (bool), score (int), issues (list), telemetry_recorded (bool).
|
|
28
|
+
"""
|
|
29
|
+
path = Path(repo_path)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
# Parse the YAML
|
|
33
|
+
raw = yaml.safe_load(model_yaml)
|
|
34
|
+
if not isinstance(raw, dict):
|
|
35
|
+
return {"stored": False, "error": "YAML did not parse to a dict", "score": 0}
|
|
36
|
+
|
|
37
|
+
# Validate using architecture_model
|
|
38
|
+
from architecture_model.core.parser import _parse_raw
|
|
39
|
+
from architecture_model.core.validator import validate_model
|
|
40
|
+
|
|
41
|
+
model = _parse_raw(raw)
|
|
42
|
+
validation = validate_model(model)
|
|
43
|
+
|
|
44
|
+
score = validation.score
|
|
45
|
+
issues = [str(issue) for issue in validation.issues]
|
|
46
|
+
|
|
47
|
+
# Write to repo
|
|
48
|
+
output_path = path / ".architecture-model.yaml"
|
|
49
|
+
output_path.write_text(model_yaml)
|
|
50
|
+
|
|
51
|
+
# Record telemetry
|
|
52
|
+
telemetry_recorded = False
|
|
53
|
+
try:
|
|
54
|
+
from opencode_arch.telemetry.store import TelemetryStore
|
|
55
|
+
store = TelemetryStore()
|
|
56
|
+
store.record(
|
|
57
|
+
tool="architect_extract",
|
|
58
|
+
repo=str(path.name),
|
|
59
|
+
context_tokens=context_tokens,
|
|
60
|
+
output_quality=score,
|
|
61
|
+
iterations=1,
|
|
62
|
+
)
|
|
63
|
+
telemetry_recorded = True
|
|
64
|
+
except Exception:
|
|
65
|
+
pass # Telemetry failure shouldn't block the tool
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
from opencode_arch.telemetry.collector import drain_and_store
|
|
69
|
+
drain_and_store(tool="architect_extract", repo=path.name)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# After the model is stored successfully, persist full project snapshot
|
|
74
|
+
try:
|
|
75
|
+
from architecture_model.manifest.generator import generate_manifest
|
|
76
|
+
from architecture_model.persistence.store import save_project
|
|
77
|
+
|
|
78
|
+
manifest = generate_manifest(Path(repo_path))
|
|
79
|
+
|
|
80
|
+
# Try to compute representativeness
|
|
81
|
+
rep = None
|
|
82
|
+
try:
|
|
83
|
+
from architecture_model.core.representativeness import compute_representativeness
|
|
84
|
+
rep = compute_representativeness(model, manifest)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
save_project(
|
|
89
|
+
Path(repo_path), model, manifest,
|
|
90
|
+
representativeness=rep,
|
|
91
|
+
telemetry={"context_tokens": context_tokens} if context_tokens else None,
|
|
92
|
+
)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass # Persistence failures should never block tool operation
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"stored": True,
|
|
98
|
+
"score": score,
|
|
99
|
+
"issues": issues,
|
|
100
|
+
"path": str(output_path),
|
|
101
|
+
"telemetry_recorded": telemetry_recorded,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
except yaml.YAMLError as e:
|
|
105
|
+
return {"stored": False, "error": f"Invalid YAML: {e}", "score": 0}
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return {"stored": False, "error": f"Validation failed: {e}", "score": 0}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Record user feedback for model improvement and training."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def record_feedback(
|
|
11
|
+
repo_path: str,
|
|
12
|
+
feedback_type: str,
|
|
13
|
+
content: str,
|
|
14
|
+
context: dict | None = None,
|
|
15
|
+
rating: int | None = None,
|
|
16
|
+
correction: dict | None = None,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""Record user feedback to .architecture/feedback.jsonl.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
repo_path: Repository root path
|
|
22
|
+
feedback_type: "correction" | "rating" | "tool_feedback" | "training"
|
|
23
|
+
content: The feedback content (human-readable)
|
|
24
|
+
context: Optional context dict (tool name, prompt, response, etc.)
|
|
25
|
+
rating: Optional 1-5 quality rating
|
|
26
|
+
correction: Optional structured correction {entity_id, field, old, new}
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
{recorded, feedback_id, total_feedback}
|
|
30
|
+
"""
|
|
31
|
+
repo = Path(repo_path)
|
|
32
|
+
arch_dir = repo / ".architecture"
|
|
33
|
+
arch_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
feedback_path = arch_dir / "feedback.jsonl"
|
|
35
|
+
|
|
36
|
+
# Count existing entries to generate ID
|
|
37
|
+
existing_count = 0
|
|
38
|
+
if feedback_path.exists():
|
|
39
|
+
existing_count = sum(1 for line in feedback_path.read_text().splitlines() if line.strip())
|
|
40
|
+
|
|
41
|
+
feedback_id = f"FB-{existing_count + 1}"
|
|
42
|
+
|
|
43
|
+
# Build entry
|
|
44
|
+
entry: dict[str, Any] = {
|
|
45
|
+
"id": feedback_id,
|
|
46
|
+
"type": feedback_type,
|
|
47
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
48
|
+
"content": content,
|
|
49
|
+
}
|
|
50
|
+
if context:
|
|
51
|
+
entry["context"] = context
|
|
52
|
+
if rating is not None:
|
|
53
|
+
entry["rating"] = rating
|
|
54
|
+
if correction:
|
|
55
|
+
entry["correction"] = correction
|
|
56
|
+
|
|
57
|
+
# Append to JSONL
|
|
58
|
+
with feedback_path.open("a") as f:
|
|
59
|
+
f.write(json.dumps(entry, default=str) + "\n")
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
"recorded": True,
|
|
63
|
+
"feedback_id": feedback_id,
|
|
64
|
+
"total_feedback": existing_count + 1,
|
|
65
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# src/opencode_arch/mcp/tools/generate.py
|
|
2
|
+
"""architect_generate MCP tool — run tests on generated code."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def run_tests_on_generated_code(
|
|
13
|
+
repo_path: str,
|
|
14
|
+
test_command: str | None = None,
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""Run the repository's test suite against generated code.
|
|
17
|
+
|
|
18
|
+
This is the quality gate for code generation. The agent generates code,
|
|
19
|
+
then calls this tool to verify it passes the original tests.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
repo_path: Path to the repository with generated code + tests.
|
|
23
|
+
test_command: Custom test command. Defaults to pytest.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Dict with: passed (bool), pass_rate (float), total_tests (int),
|
|
27
|
+
passed_tests (int), failures (list of failure descriptions).
|
|
28
|
+
"""
|
|
29
|
+
path = Path(repo_path)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return {"error": f"Path does not exist: {repo_path}"}
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
if test_command:
|
|
35
|
+
cmd = test_command.split()
|
|
36
|
+
else:
|
|
37
|
+
cmd = [sys.executable, "-m", "pytest", str(path), "-v", "--tb=short", "-q"]
|
|
38
|
+
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
cmd,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=120,
|
|
44
|
+
cwd=str(path),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Parse pytest output
|
|
48
|
+
output = result.stdout + result.stderr
|
|
49
|
+
parsed = _parse_pytest_output(output, result.returncode)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from opencode_arch.telemetry.collector import drain_and_store
|
|
53
|
+
drain_and_store(tool="architect_generate", repo=path.name)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
return parsed
|
|
58
|
+
|
|
59
|
+
except subprocess.TimeoutExpired:
|
|
60
|
+
return {"error": "Test execution timed out (120s)", "passed": False, "pass_rate": 0.0, "total_tests": 0}
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return {"error": f"Test execution failed: {e}", "passed": False, "pass_rate": 0.0, "total_tests": 0}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_pytest_output(output: str, returncode: int) -> dict[str, Any]:
|
|
66
|
+
"""Parse pytest output to extract pass/fail counts."""
|
|
67
|
+
total = 0
|
|
68
|
+
passed_count = 0
|
|
69
|
+
failures: list[str] = []
|
|
70
|
+
|
|
71
|
+
for line in output.split("\n"):
|
|
72
|
+
# Look for summary line: "5 passed, 2 failed in 0.5s"
|
|
73
|
+
if "passed" in line or "failed" in line or "error" in line:
|
|
74
|
+
parts = line.strip().split()
|
|
75
|
+
for i, part in enumerate(parts):
|
|
76
|
+
if part == "passed" and i > 0:
|
|
77
|
+
try:
|
|
78
|
+
passed_count = int(parts[i - 1])
|
|
79
|
+
except ValueError:
|
|
80
|
+
pass
|
|
81
|
+
elif part == "failed" and i > 0:
|
|
82
|
+
try:
|
|
83
|
+
total += int(parts[i - 1])
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Capture FAILED test names
|
|
88
|
+
if line.startswith("FAILED"):
|
|
89
|
+
failures.append(line.strip())
|
|
90
|
+
|
|
91
|
+
total += passed_count
|
|
92
|
+
|
|
93
|
+
if total == 0 and "no tests ran" in output.lower():
|
|
94
|
+
return {"passed": True, "pass_rate": 0.0, "total_tests": 0, "passed_tests": 0, "failures": []}
|
|
95
|
+
|
|
96
|
+
pass_rate = passed_count / total if total > 0 else 0.0
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"passed": returncode == 0,
|
|
100
|
+
"pass_rate": pass_rate,
|
|
101
|
+
"total_tests": total,
|
|
102
|
+
"passed_tests": passed_count,
|
|
103
|
+
"failures": failures,
|
|
104
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""architect_group MCP tool — group repository modules into logical components."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def group_repository(repo_path: str, target_groups: int = 0) -> dict[str, Any]:
|
|
9
|
+
"""Group repository modules into logical architecture components.
|
|
10
|
+
|
|
11
|
+
Uses multi-signal affinity (subdirectory, name-prefix, imports) to
|
|
12
|
+
cluster source files into coherent component groups. This provides
|
|
13
|
+
suggested component boundaries for architecture extraction.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
repo_path: Absolute path to the repository root.
|
|
17
|
+
target_groups: Desired number of groups. 0 = auto-calculate.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Dict with keys: groups, total_modules, total_groups, filtered_trivial.
|
|
21
|
+
Each group has: name, files, file_count, locked.
|
|
22
|
+
Returns {"error": "..."} on failure.
|
|
23
|
+
"""
|
|
24
|
+
path = Path(repo_path)
|
|
25
|
+
if not path.exists():
|
|
26
|
+
return {"error": f"Repository path does not exist: {repo_path}"}
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from architecture_model.manifest.generator import generate_manifest
|
|
30
|
+
from architecture_model.manifest.grouping import group_modules
|
|
31
|
+
|
|
32
|
+
manifest = generate_manifest(path)
|
|
33
|
+
target = target_groups if target_groups > 0 else None
|
|
34
|
+
groups = group_modules(
|
|
35
|
+
manifest.modules,
|
|
36
|
+
manifest.interfaces,
|
|
37
|
+
target_groups=target,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
result_groups = []
|
|
41
|
+
for g in groups:
|
|
42
|
+
result_groups.append({
|
|
43
|
+
"name": g.name,
|
|
44
|
+
"files": g.modules,
|
|
45
|
+
"file_count": len(g.modules),
|
|
46
|
+
"primary_file": g.primary_file,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from opencode_arch.telemetry.collector import drain_and_store
|
|
51
|
+
drain_and_store(tool="architect_group", repo=path.name)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"groups": result_groups,
|
|
57
|
+
"total_modules": len(manifest.modules),
|
|
58
|
+
"total_groups": len(groups),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return {"error": f"Grouping failed: {e}"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""architect_ingest tool — accept a SourceGraph JSON for any language.
|
|
2
|
+
|
|
3
|
+
Allows agents or external tools to submit dependency/export data for
|
|
4
|
+
non-Python repos. Stores the graph and runs grouping + interface extraction.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def ingest_source_graph(
|
|
13
|
+
repo_path: str,
|
|
14
|
+
source_graph_json: str,
|
|
15
|
+
) -> dict:
|
|
16
|
+
"""Ingest a SourceGraph JSON and generate architecture components.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
repo_path: Absolute path to the repository.
|
|
20
|
+
source_graph_json: JSON string with SourceGraph data.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dict with components, interfaces, and group info.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
from architecture_model.manifest.protocol import SourceGraph
|
|
27
|
+
from architecture_model.manifest.grouping import group_source_graph, auto_fblocks
|
|
28
|
+
from architecture_model.core.types import (
|
|
29
|
+
ArchitectureModel, Component, Entities, ModelMeta,
|
|
30
|
+
)
|
|
31
|
+
from architecture_model.orchestration.auto_enrich import extract_component_interfaces
|
|
32
|
+
from architecture_model.core.parser import save_model
|
|
33
|
+
|
|
34
|
+
project_root = Path(repo_path)
|
|
35
|
+
|
|
36
|
+
# Parse the source graph
|
|
37
|
+
data = json.loads(source_graph_json)
|
|
38
|
+
graph = SourceGraph.from_json(data)
|
|
39
|
+
graph.root = repo_path
|
|
40
|
+
|
|
41
|
+
# Group into components
|
|
42
|
+
groups = group_source_graph(graph)
|
|
43
|
+
if not groups:
|
|
44
|
+
return {"error": "No non-trivial source units found", "units": len(graph.units)}
|
|
45
|
+
|
|
46
|
+
# Create components from groups
|
|
47
|
+
components: list[Component] = []
|
|
48
|
+
for idx, group in enumerate(groups, 1):
|
|
49
|
+
components.append(Component(
|
|
50
|
+
id=f"COMP-{idx}",
|
|
51
|
+
name=group.name,
|
|
52
|
+
status="ACTIVE",
|
|
53
|
+
files=group.modules,
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
# Build model
|
|
57
|
+
model = ArchitectureModel(
|
|
58
|
+
meta=ModelMeta(project=project_root.name, schema_version="1.3"),
|
|
59
|
+
entities=Entities(components=components),
|
|
60
|
+
relationships=[],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Extract interface contracts
|
|
64
|
+
n_ifaces = extract_component_interfaces(model, graph)
|
|
65
|
+
|
|
66
|
+
# Enrich components from SourceGraph (signatures, symbols, contracts, patterns)
|
|
67
|
+
from architecture_model.orchestration.auto_enrich import enrich_from_source_graph
|
|
68
|
+
enrich_from_source_graph(model, graph)
|
|
69
|
+
|
|
70
|
+
# Generate F-block config
|
|
71
|
+
fblock_config = auto_fblocks(groups, threshold=3)
|
|
72
|
+
|
|
73
|
+
# Save the model
|
|
74
|
+
model_path = project_root / ".architecture-model-extracted.yaml"
|
|
75
|
+
save_model(model, model_path)
|
|
76
|
+
|
|
77
|
+
# Also save the source graph for later use
|
|
78
|
+
graph_path = project_root / ".architecture-models" / "source-graph.json"
|
|
79
|
+
graph_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
graph_path.write_text(json.dumps(graph.to_json(), indent=2))
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"stored": True,
|
|
84
|
+
"model_path": str(model_path),
|
|
85
|
+
"graph_path": str(graph_path),
|
|
86
|
+
"components": len(components),
|
|
87
|
+
"interfaces": n_ifaces,
|
|
88
|
+
"fblocks": len(fblock_config),
|
|
89
|
+
"units": len(graph.units),
|
|
90
|
+
"edges": len(graph.edges),
|
|
91
|
+
"language": graph.language,
|
|
92
|
+
"groups": [
|
|
93
|
+
{"name": g.name, "files": g.modules, "file_count": len(g.modules)}
|
|
94
|
+
for g in groups
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
except json.JSONDecodeError as e:
|
|
99
|
+
return {"error": f"Invalid JSON: {e}"}
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return {"error": str(e)}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Capture functional requirements against architecture components."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def capture_requirement(
|
|
11
|
+
repo_path: str,
|
|
12
|
+
requirement: str,
|
|
13
|
+
component_id: str | None = None,
|
|
14
|
+
priority: str = "must",
|
|
15
|
+
context: str = "",
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Store a functional requirement linked to a component.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
repo_path: Repository root path
|
|
21
|
+
requirement: The requirement text
|
|
22
|
+
component_id: Component ID (e.g., "COMP-3"). If None, stored as unlinked.
|
|
23
|
+
priority: must | should | could (MoSCoW)
|
|
24
|
+
context: Additional context from conversation
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
{stored, requirement_id, component, total_requirements}
|
|
28
|
+
"""
|
|
29
|
+
repo = Path(repo_path)
|
|
30
|
+
arch_dir = repo / ".architecture"
|
|
31
|
+
arch_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
req_path = arch_dir / "requirements.yaml"
|
|
33
|
+
|
|
34
|
+
# Load existing
|
|
35
|
+
if req_path.exists():
|
|
36
|
+
data = yaml.safe_load(req_path.read_text()) or {}
|
|
37
|
+
else:
|
|
38
|
+
data = {}
|
|
39
|
+
|
|
40
|
+
reqs = data.get("requirements", [])
|
|
41
|
+
|
|
42
|
+
# Generate next ID
|
|
43
|
+
existing_ids = [r.get("id", "") for r in reqs]
|
|
44
|
+
max_num = 0
|
|
45
|
+
for rid in existing_ids:
|
|
46
|
+
if rid.startswith("REQ-"):
|
|
47
|
+
try:
|
|
48
|
+
max_num = max(max_num, int(rid[4:]))
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
next_id = f"REQ-{max_num + 1}"
|
|
52
|
+
|
|
53
|
+
# Build requirement entry
|
|
54
|
+
entry = {
|
|
55
|
+
"id": next_id,
|
|
56
|
+
"text": requirement,
|
|
57
|
+
"priority": priority,
|
|
58
|
+
"status": "proposed",
|
|
59
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
60
|
+
}
|
|
61
|
+
if component_id:
|
|
62
|
+
entry["component"] = component_id
|
|
63
|
+
if context:
|
|
64
|
+
entry["context"] = context
|
|
65
|
+
|
|
66
|
+
reqs.append(entry)
|
|
67
|
+
data["requirements"] = reqs
|
|
68
|
+
|
|
69
|
+
# Write back
|
|
70
|
+
req_path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"stored": True,
|
|
74
|
+
"requirement_id": next_id,
|
|
75
|
+
"component": component_id or "unlinked",
|
|
76
|
+
"total_requirements": len(reqs),
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""architect_scan MCP tool — generate reality manifest via AST scanning."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def scan_repository(repo_path: str) -> dict[str, Any]:
|
|
9
|
+
"""Scan a repository and generate its reality manifest.
|
|
10
|
+
|
|
11
|
+
Performs AST analysis on all source files to produce a ground-truth
|
|
12
|
+
inventory of modules, functions, classes, imports, and metrics.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
repo_path: Absolute path to the repository root.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Manifest dict with keys: generated_at, project_root, metrics,
|
|
19
|
+
functional_blocks, modules, interfaces.
|
|
20
|
+
Returns {"error": "..."} on failure.
|
|
21
|
+
"""
|
|
22
|
+
path = Path(repo_path)
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return {"error": f"Repository path does not exist: {repo_path}"}
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from architecture_model.manifest.generator import generate_manifest
|
|
28
|
+
manifest = generate_manifest(path)
|
|
29
|
+
|
|
30
|
+
# Include suggested component groupings
|
|
31
|
+
suggested = None
|
|
32
|
+
try:
|
|
33
|
+
from architecture_model.manifest.grouping import group_modules
|
|
34
|
+
groups = group_modules(manifest.modules, manifest.interfaces)
|
|
35
|
+
suggested = [
|
|
36
|
+
{"name": g.name, "files": g.modules, "file_count": len(g.modules)}
|
|
37
|
+
for g in groups
|
|
38
|
+
]
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from opencode_arch.telemetry.collector import drain_and_store
|
|
44
|
+
drain_and_store(tool="architect_scan", repo=path.name)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
result = manifest
|
|
49
|
+
if suggested and isinstance(result, dict):
|
|
50
|
+
result["suggested_components"] = suggested
|
|
51
|
+
return result
|
|
52
|
+
except Exception as e:
|
|
53
|
+
return {"error": f"Scan failed: {e}"}
|