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,66 @@
|
|
|
1
|
+
"""CLI confidence visualization command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_confidence(repo_path: str) -> str:
|
|
7
|
+
"""Run confidence analysis and return formatted output."""
|
|
8
|
+
root = Path(repo_path)
|
|
9
|
+
model_file = root / ".architecture-model.yaml"
|
|
10
|
+
if not model_file.exists():
|
|
11
|
+
model_file = root / ".architecture-model-extracted.yaml"
|
|
12
|
+
if not model_file.exists():
|
|
13
|
+
return "Error: No model found. Run extraction first."
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from architecture_model.core.parser import load_model
|
|
17
|
+
from architecture_model.core.confidence import (
|
|
18
|
+
compute_model_confidence,
|
|
19
|
+
aggregate_block_confidence,
|
|
20
|
+
model_confidence_summary,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
model = load_model(model_file)
|
|
24
|
+
compute_model_confidence(model)
|
|
25
|
+
blocks = aggregate_block_confidence(model)
|
|
26
|
+
summary = model_confidence_summary(model)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return f"Error computing confidence: {e}"
|
|
29
|
+
|
|
30
|
+
lines = []
|
|
31
|
+
lines.append(f"# Confidence Report: {model.meta.project}")
|
|
32
|
+
lines.append(f"Overall: {summary['overall']:.0%} | "
|
|
33
|
+
f"High (>=80%): {summary['high_confidence']} | "
|
|
34
|
+
f"Low (<30%): {summary['low_confidence']} | "
|
|
35
|
+
f"Total: {summary['total_entities']}")
|
|
36
|
+
lines.append("")
|
|
37
|
+
|
|
38
|
+
lines.append(f"{'Block':<12} {'Avg':>6} {'Min':>6} {'Max':>6} {'Count':>6}")
|
|
39
|
+
lines.append("-" * 42)
|
|
40
|
+
for block_id in sorted(blocks.keys()):
|
|
41
|
+
b = blocks[block_id]
|
|
42
|
+
lines.append(f"{block_id:<12} {b['avg_confidence']:>5.0%} {b['min_confidence']:>5.0%} {b['max_confidence']:>5.0%} {b['entity_count']:>6}")
|
|
43
|
+
|
|
44
|
+
lines.append("")
|
|
45
|
+
|
|
46
|
+
if summary["gaps"]:
|
|
47
|
+
lines.append("## Top Gaps (lowest confidence)")
|
|
48
|
+
for gap in summary["gaps"]:
|
|
49
|
+
lines.append(f" {gap['id']:<12} {gap['name']:<25} {gap['confidence']:.0%}")
|
|
50
|
+
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append("## Entity Detail")
|
|
53
|
+
for comp in model.entities.components:
|
|
54
|
+
missing = []
|
|
55
|
+
if not comp.contract:
|
|
56
|
+
missing.append("contract")
|
|
57
|
+
if not comp.pattern:
|
|
58
|
+
missing.append("pattern")
|
|
59
|
+
if not comp.signatures:
|
|
60
|
+
missing.append("signatures")
|
|
61
|
+
if not comp.test_contracts:
|
|
62
|
+
missing.append("tests")
|
|
63
|
+
gaps_str = ", ".join(missing) if missing else "-"
|
|
64
|
+
lines.append(f" {comp.id:<12} {comp.name:<25} {comp.confidence:>5.0%} gaps: {gaps_str}")
|
|
65
|
+
|
|
66
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""SE Document generation orchestrator."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from architecture_model import load_model, generate_manifest
|
|
10
|
+
from architecture_model.core.types import ArchitectureModel
|
|
11
|
+
|
|
12
|
+
from opencode_arch.artifacts import select_artifacts, assemble_artifact_context, TEMPLATES, get_template, ArtifactSpec, format_capability_detail_context, API_DETAIL_TEMPLATE
|
|
13
|
+
from opencode_arch.runner.base import RunnerBackend, RunResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DocsResult:
|
|
18
|
+
"""Result of a docs generation run."""
|
|
19
|
+
generated: list[str] # artifact IDs that were generated
|
|
20
|
+
failed: list[str] # artifact IDs that failed
|
|
21
|
+
output_dir: str # path to output directory
|
|
22
|
+
time_seconds: float # total time taken
|
|
23
|
+
error: str | None = None # top-level error if whole run failed
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def run_docs_generate(
|
|
27
|
+
repo_path: Path,
|
|
28
|
+
runner: RunnerBackend,
|
|
29
|
+
output_dir: Path | None = None,
|
|
30
|
+
artifact_filter: list[str] | None = None,
|
|
31
|
+
model_path: Path | None = None,
|
|
32
|
+
) -> DocsResult:
|
|
33
|
+
"""Generate SE documentation for a project.
|
|
34
|
+
|
|
35
|
+
Flow:
|
|
36
|
+
1. Load model from project (or model_path override)
|
|
37
|
+
2. Generate manifest via generate_manifest()
|
|
38
|
+
3. Call select_artifacts(model, manifest) to determine what to generate
|
|
39
|
+
4. If artifact_filter provided, intersect with selected artifacts
|
|
40
|
+
5. For each artifact:
|
|
41
|
+
a. Get template from TEMPLATES[artifact_id]
|
|
42
|
+
b. Assemble context via assemble_artifact_context(template, model, manifest)
|
|
43
|
+
c. Build full prompt (context + generation instructions)
|
|
44
|
+
d. Call runner.run(prompt, str(repo_path))
|
|
45
|
+
e. Write result to output_dir/filename
|
|
46
|
+
6. Generate index.md linking all artifacts
|
|
47
|
+
7. Return DocsResult
|
|
48
|
+
"""
|
|
49
|
+
start_time = time.time()
|
|
50
|
+
repo_path = Path(repo_path).resolve()
|
|
51
|
+
|
|
52
|
+
# Determine output directory
|
|
53
|
+
if output_dir is None:
|
|
54
|
+
output_dir = repo_path / "docs" / "se"
|
|
55
|
+
else:
|
|
56
|
+
output_dir = Path(output_dir)
|
|
57
|
+
|
|
58
|
+
# Step 1: Load model
|
|
59
|
+
try:
|
|
60
|
+
if model_path:
|
|
61
|
+
model = load_model(Path(model_path))
|
|
62
|
+
else:
|
|
63
|
+
default_model_path = repo_path / ".architecture-model.yaml"
|
|
64
|
+
if default_model_path.exists():
|
|
65
|
+
model = load_model(default_model_path)
|
|
66
|
+
else:
|
|
67
|
+
elapsed = time.time() - start_time
|
|
68
|
+
return DocsResult(
|
|
69
|
+
generated=[],
|
|
70
|
+
failed=[],
|
|
71
|
+
output_dir=str(output_dir),
|
|
72
|
+
time_seconds=elapsed,
|
|
73
|
+
error="No architecture model found. Run extraction first.",
|
|
74
|
+
)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
elapsed = time.time() - start_time
|
|
77
|
+
return DocsResult(
|
|
78
|
+
generated=[],
|
|
79
|
+
failed=[],
|
|
80
|
+
output_dir=str(output_dir),
|
|
81
|
+
time_seconds=elapsed,
|
|
82
|
+
error=f"Failed to load model: {e}",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Step 2: Generate manifest (best-effort)
|
|
86
|
+
manifest = None
|
|
87
|
+
try:
|
|
88
|
+
manifest = generate_manifest(repo_path)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass # Manifest is optional — select_artifacts handles None
|
|
91
|
+
|
|
92
|
+
# Step 3: Select artifacts
|
|
93
|
+
try:
|
|
94
|
+
selected = select_artifacts(model, manifest)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
elapsed = time.time() - start_time
|
|
97
|
+
return DocsResult(
|
|
98
|
+
generated=[],
|
|
99
|
+
failed=[],
|
|
100
|
+
output_dir=str(output_dir),
|
|
101
|
+
time_seconds=elapsed,
|
|
102
|
+
error=f"Failed to select artifacts: {e}",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Step 4: Filter if requested
|
|
106
|
+
if artifact_filter:
|
|
107
|
+
selected = [s for s in selected if s.id in artifact_filter]
|
|
108
|
+
|
|
109
|
+
# Ensure output dir exists
|
|
110
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
# Step 5: Generate each artifact
|
|
113
|
+
generated: list[str] = []
|
|
114
|
+
failed: list[str] = []
|
|
115
|
+
generated_artifacts: list[tuple[str, str]] = [] # (artifact_id, filename)
|
|
116
|
+
|
|
117
|
+
for spec in selected:
|
|
118
|
+
# Per-capability API detail artifacts use specialized context
|
|
119
|
+
if spec.id.startswith("api-detail-"):
|
|
120
|
+
cap_id = spec.id.replace("api-detail-", "").upper()
|
|
121
|
+
template = API_DETAIL_TEMPLATE
|
|
122
|
+
filename = f"api-detail-{cap_id.lower()}.md"
|
|
123
|
+
|
|
124
|
+
# Assemble capability-specific context
|
|
125
|
+
try:
|
|
126
|
+
cap_context = format_capability_detail_context(cap_id, model, manifest)
|
|
127
|
+
except Exception:
|
|
128
|
+
failed.append(spec.id)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Build prompt with template structure + capability data
|
|
132
|
+
prompt = _build_capability_detail_prompt(template, cap_context, spec.name)
|
|
133
|
+
|
|
134
|
+
# Call runner
|
|
135
|
+
try:
|
|
136
|
+
result = await runner.run(prompt, str(repo_path))
|
|
137
|
+
except Exception:
|
|
138
|
+
failed.append(spec.id)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if not result.success:
|
|
142
|
+
failed.append(spec.id)
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
_write_artifact(output_dir, filename, spec.id, result.output)
|
|
146
|
+
generated.append(spec.id)
|
|
147
|
+
generated_artifacts.append((spec.id, filename))
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Standard artifacts
|
|
151
|
+
template = TEMPLATES.get(spec.id)
|
|
152
|
+
if template is None:
|
|
153
|
+
template = get_template(spec.id)
|
|
154
|
+
if template is None:
|
|
155
|
+
failed.append(spec.id)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# Assemble context
|
|
159
|
+
try:
|
|
160
|
+
context = assemble_artifact_context(template, model, manifest)
|
|
161
|
+
except Exception:
|
|
162
|
+
failed.append(spec.id)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Build prompt
|
|
166
|
+
prompt = _build_generation_prompt(context, spec.id)
|
|
167
|
+
|
|
168
|
+
# Call runner
|
|
169
|
+
try:
|
|
170
|
+
result = await runner.run(prompt, str(repo_path))
|
|
171
|
+
except Exception:
|
|
172
|
+
failed.append(spec.id)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
if not result.success:
|
|
176
|
+
failed.append(spec.id)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Write artifact
|
|
180
|
+
_write_artifact(output_dir, template.filename, spec.id, result.output)
|
|
181
|
+
generated.append(spec.id)
|
|
182
|
+
generated_artifacts.append((spec.id, template.filename))
|
|
183
|
+
|
|
184
|
+
# Step 6: Write index
|
|
185
|
+
if generated_artifacts:
|
|
186
|
+
_write_index(output_dir, generated_artifacts)
|
|
187
|
+
|
|
188
|
+
elapsed = time.time() - start_time
|
|
189
|
+
return DocsResult(
|
|
190
|
+
generated=generated,
|
|
191
|
+
failed=failed,
|
|
192
|
+
output_dir=str(output_dir),
|
|
193
|
+
time_seconds=elapsed,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def run_docs_list(
|
|
198
|
+
repo_path: Path,
|
|
199
|
+
model_path: Path | None = None,
|
|
200
|
+
) -> list[dict[str, str]]:
|
|
201
|
+
"""List which artifacts would be generated for a project.
|
|
202
|
+
|
|
203
|
+
Returns list of dicts: [{"id": ..., "name": ..., "category": ..., "priority": ...}]
|
|
204
|
+
No runner needed — just model analysis.
|
|
205
|
+
"""
|
|
206
|
+
repo_path = Path(repo_path).resolve()
|
|
207
|
+
|
|
208
|
+
# Load model
|
|
209
|
+
if model_path:
|
|
210
|
+
model = load_model(Path(model_path))
|
|
211
|
+
else:
|
|
212
|
+
default_model_path = repo_path / ".architecture-model.yaml"
|
|
213
|
+
model = load_model(default_model_path)
|
|
214
|
+
|
|
215
|
+
# Generate manifest (best-effort)
|
|
216
|
+
manifest = None
|
|
217
|
+
try:
|
|
218
|
+
manifest = generate_manifest(repo_path)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# Select artifacts
|
|
223
|
+
selected = select_artifacts(model, manifest)
|
|
224
|
+
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
"id": spec.id,
|
|
228
|
+
"name": spec.name,
|
|
229
|
+
"category": spec.category,
|
|
230
|
+
"priority": str(spec.priority),
|
|
231
|
+
}
|
|
232
|
+
for spec in selected
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _build_generation_prompt(context: str, template_artifact_id: str) -> str:
|
|
237
|
+
"""Build the full prompt sent to the agent.
|
|
238
|
+
|
|
239
|
+
Format includes context from assemble_artifact_context followed by
|
|
240
|
+
task instructions for the agent.
|
|
241
|
+
"""
|
|
242
|
+
return (
|
|
243
|
+
f"---\n"
|
|
244
|
+
f"{context}\n"
|
|
245
|
+
f"\n"
|
|
246
|
+
f"---\n"
|
|
247
|
+
f"TASK: Generate the '{template_artifact_id}' documentation artifact.\n"
|
|
248
|
+
f"\n"
|
|
249
|
+
f"Write a complete, well-structured markdown document. Use the DATA sections above\n"
|
|
250
|
+
f"as your source of truth. Do NOT invent information not present in the data.\n"
|
|
251
|
+
f"\n"
|
|
252
|
+
f"Output ONLY the markdown content, no code fences or explanations.\n"
|
|
253
|
+
f"---\n"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _build_capability_detail_prompt(template, cap_context: str, cap_name: str) -> str:
|
|
258
|
+
"""Build the prompt for a per-capability API detail artifact.
|
|
259
|
+
|
|
260
|
+
Uses the API_DETAIL_TEMPLATE sections as instructions, with the
|
|
261
|
+
capability-specific context as the grounding data.
|
|
262
|
+
"""
|
|
263
|
+
sections_instructions = "\n".join(
|
|
264
|
+
f" {s.heading}: {s.instructions}"
|
|
265
|
+
for s in template.sections
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
f"---\n"
|
|
270
|
+
f"SYSTEM: {template.system_prompt}\n"
|
|
271
|
+
f"\n"
|
|
272
|
+
f"## Architecture Model Data for: {cap_name}\n\n"
|
|
273
|
+
f"{cap_context}\n"
|
|
274
|
+
f"\n"
|
|
275
|
+
f"---\n"
|
|
276
|
+
f"TASK: Generate a detailed API documentation file for this capability.\n"
|
|
277
|
+
f"\n"
|
|
278
|
+
f"Required sections:\n"
|
|
279
|
+
f"{sections_instructions}\n"
|
|
280
|
+
f"\n"
|
|
281
|
+
f"Write a complete, well-structured markdown document. Use the DATA above\n"
|
|
282
|
+
f"as your source of truth. Include exact function signatures, parameters,\n"
|
|
283
|
+
f"return types, algorithm steps, and behavioral sequences.\n"
|
|
284
|
+
f"Do NOT invent information not present in the data.\n"
|
|
285
|
+
f"\n"
|
|
286
|
+
f"Output ONLY the markdown content, no code fences or explanations.\n"
|
|
287
|
+
f"---\n"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _write_artifact(output_dir: Path, filename: str, artifact_id: str, content: str):
|
|
292
|
+
"""Write generated artifact with frontmatter.
|
|
293
|
+
|
|
294
|
+
Prepends YAML frontmatter with artifact_id, timestamp, and generator.
|
|
295
|
+
"""
|
|
296
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
297
|
+
frontmatter = (
|
|
298
|
+
f"---\n"
|
|
299
|
+
f"artifact_id: {artifact_id}\n"
|
|
300
|
+
f"generated_at: {timestamp}\n"
|
|
301
|
+
f"generator: opencode-arch-docs\n"
|
|
302
|
+
f"---\n"
|
|
303
|
+
)
|
|
304
|
+
filepath = output_dir / filename
|
|
305
|
+
filepath.write_text(frontmatter + content)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _write_index(output_dir: Path, generated_artifacts: list[tuple[str, str]]):
|
|
309
|
+
"""Write index.md linking all generated artifacts.
|
|
310
|
+
|
|
311
|
+
generated_artifacts: list of (artifact_id, filename) tuples
|
|
312
|
+
"""
|
|
313
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
314
|
+
|
|
315
|
+
lines = [
|
|
316
|
+
"# SE Documentation Index",
|
|
317
|
+
"",
|
|
318
|
+
f"Generated: {timestamp}",
|
|
319
|
+
"",
|
|
320
|
+
"## Artifacts",
|
|
321
|
+
"",
|
|
322
|
+
"| Artifact | File | Category |",
|
|
323
|
+
"|----------|------|----------|",
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
for artifact_id, filename in generated_artifacts:
|
|
327
|
+
# Use artifact_id as the display name
|
|
328
|
+
name = artifact_id.replace("-", " ").title()
|
|
329
|
+
lines.append(f"| {name} | [{filename}](./{filename}) | {artifact_id} |")
|
|
330
|
+
|
|
331
|
+
lines.append("")
|
|
332
|
+
index_path = output_dir / "index.md"
|
|
333
|
+
index_path.write_text("\n".join(lines))
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Validate generated SE documentation against model and manifest."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from architecture_model.core.types import ArchitectureModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ValidationIssue:
|
|
15
|
+
"""A single validation issue found in a doc artifact."""
|
|
16
|
+
artifact_id: str # which artifact had the issue
|
|
17
|
+
line: int # line number in the doc (1-indexed, 0 if unknown)
|
|
18
|
+
issue_type: str # "invalid_path", "unknown_function", "unknown_component", "stale_reference"
|
|
19
|
+
message: str # human-readable description
|
|
20
|
+
severity: str # "error" or "warning"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DocsValidationResult:
|
|
25
|
+
"""Aggregate validation result for all docs."""
|
|
26
|
+
total_artifacts: int
|
|
27
|
+
passed: int
|
|
28
|
+
failed: int
|
|
29
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_valid(self) -> bool:
|
|
33
|
+
"""No errors (warnings are OK)."""
|
|
34
|
+
return not any(i.severity == "error" for i in self.issues)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# File extensions we recognize as code/config paths
|
|
38
|
+
_PATH_EXTENSIONS = (
|
|
39
|
+
".py", ".ts", ".js", ".jsx", ".tsx",
|
|
40
|
+
".yaml", ".yml", ".json", ".toml",
|
|
41
|
+
".cfg", ".ini", ".md", ".rst",
|
|
42
|
+
".sh", ".bash", ".sql",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Regex for backtick-wrapped file paths: must contain a slash or end with known extension
|
|
46
|
+
_FILE_REF_PATTERN = re.compile(
|
|
47
|
+
r"`([^`\s]+(?:" + "|".join(re.escape(ext) for ext in _PATH_EXTENSIONS) + r"))`"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Regex for backtick-wrapped function calls: word followed by ()
|
|
51
|
+
_FUNC_REF_PATTERN = re.compile(r"`(\w+)\(\)`")
|
|
52
|
+
|
|
53
|
+
# Regex for backtick-wrapped PascalCase class names: starts uppercase, has at least one lowercase
|
|
54
|
+
_CLASS_REF_PATTERN = re.compile(r"`([A-Z][a-zA-Z0-9]*)`")
|
|
55
|
+
|
|
56
|
+
# Regex for component-like IDs (e.g., COMP-1, SVC-1, IF-1, CAP-1)
|
|
57
|
+
_COMPONENT_ID_PATTERN = re.compile(r"\b([A-Z]{2,}-\d+)\b")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_docs(
|
|
61
|
+
docs_dir: Path,
|
|
62
|
+
model: "ArchitectureModel",
|
|
63
|
+
manifest: dict | None = None,
|
|
64
|
+
) -> DocsValidationResult:
|
|
65
|
+
"""Validate all markdown files in docs_dir against model and manifest.
|
|
66
|
+
|
|
67
|
+
Checks:
|
|
68
|
+
1. File paths mentioned in docs exist in manifest's file inventory
|
|
69
|
+
2. Function/class names referenced exist in manifest's AST data
|
|
70
|
+
3. Component IDs/names referenced match model's components
|
|
71
|
+
4. Interface names referenced match model's interfaces
|
|
72
|
+
|
|
73
|
+
An artifact PASSES if it has no "error" severity issues.
|
|
74
|
+
"""
|
|
75
|
+
docs_dir = Path(docs_dir)
|
|
76
|
+
md_files = sorted(docs_dir.glob("*.md"))
|
|
77
|
+
|
|
78
|
+
if not md_files:
|
|
79
|
+
return DocsValidationResult(total_artifacts=0, passed=0, failed=0)
|
|
80
|
+
|
|
81
|
+
# Build lookup sets
|
|
82
|
+
manifest_files = _get_manifest_files(manifest)
|
|
83
|
+
manifest_symbols = _get_manifest_symbols(manifest)
|
|
84
|
+
model_ids = _get_model_entity_ids(model)
|
|
85
|
+
|
|
86
|
+
all_issues: list[ValidationIssue] = []
|
|
87
|
+
passed = 0
|
|
88
|
+
failed = 0
|
|
89
|
+
|
|
90
|
+
for md_file in md_files:
|
|
91
|
+
content = md_file.read_text()
|
|
92
|
+
artifact_id = _get_artifact_id_from_file(md_file)
|
|
93
|
+
artifact_issues: list[ValidationIssue] = []
|
|
94
|
+
|
|
95
|
+
# Check 1: File path references (only if manifest available)
|
|
96
|
+
if manifest is not None:
|
|
97
|
+
file_refs = _extract_file_references(content)
|
|
98
|
+
for line_no, path_ref in file_refs:
|
|
99
|
+
if path_ref not in manifest_files:
|
|
100
|
+
artifact_issues.append(ValidationIssue(
|
|
101
|
+
artifact_id=artifact_id,
|
|
102
|
+
line=line_no,
|
|
103
|
+
issue_type="invalid_path",
|
|
104
|
+
message=f"File path '{path_ref}' not found in manifest",
|
|
105
|
+
severity="error",
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
# Check 2: Function/class references (only if manifest available)
|
|
109
|
+
if manifest is not None:
|
|
110
|
+
code_refs = _extract_code_references(content)
|
|
111
|
+
for line_no, name_ref in code_refs:
|
|
112
|
+
if name_ref not in manifest_symbols:
|
|
113
|
+
artifact_issues.append(ValidationIssue(
|
|
114
|
+
artifact_id=artifact_id,
|
|
115
|
+
line=line_no,
|
|
116
|
+
issue_type="unknown_function",
|
|
117
|
+
message=f"Symbol '{name_ref}' not found in manifest",
|
|
118
|
+
severity="warning",
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
# Check 3: Component/entity ID references (always check against model)
|
|
122
|
+
comp_refs = _extract_component_references(content, model)
|
|
123
|
+
for line_no, comp_ref in comp_refs:
|
|
124
|
+
if comp_ref not in model_ids:
|
|
125
|
+
artifact_issues.append(ValidationIssue(
|
|
126
|
+
artifact_id=artifact_id,
|
|
127
|
+
line=line_no,
|
|
128
|
+
issue_type="unknown_component",
|
|
129
|
+
message=f"Entity ID '{comp_ref}' not found in model",
|
|
130
|
+
severity="error",
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
all_issues.extend(artifact_issues)
|
|
134
|
+
|
|
135
|
+
# Artifact passes if no errors
|
|
136
|
+
has_errors = any(i.severity == "error" for i in artifact_issues)
|
|
137
|
+
if has_errors:
|
|
138
|
+
failed += 1
|
|
139
|
+
else:
|
|
140
|
+
passed += 1
|
|
141
|
+
|
|
142
|
+
return DocsValidationResult(
|
|
143
|
+
total_artifacts=len(md_files),
|
|
144
|
+
passed=passed,
|
|
145
|
+
failed=failed,
|
|
146
|
+
issues=all_issues,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_file_references(content: str) -> list[tuple[int, str]]:
|
|
151
|
+
"""Extract file path references from markdown content.
|
|
152
|
+
|
|
153
|
+
Looks for patterns like:
|
|
154
|
+
- `src/foo/bar.py` (backtick-wrapped paths ending in known extensions)
|
|
155
|
+
- References to .py, .ts, .js, .yaml, .json, .toml files
|
|
156
|
+
|
|
157
|
+
Returns: list of (line_number, path_string)
|
|
158
|
+
"""
|
|
159
|
+
results = []
|
|
160
|
+
for line_no, line in enumerate(content.splitlines(), start=1):
|
|
161
|
+
for match in _FILE_REF_PATTERN.finditer(line):
|
|
162
|
+
results.append((line_no, match.group(1)))
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_code_references(content: str) -> list[tuple[int, str]]:
|
|
167
|
+
"""Extract function/class name references from markdown.
|
|
168
|
+
|
|
169
|
+
Looks for patterns like:
|
|
170
|
+
- `function_name()` (backtick-wrapped with parens)
|
|
171
|
+
- `ClassName` (backtick-wrapped PascalCase)
|
|
172
|
+
|
|
173
|
+
Returns: list of (line_number, name_string)
|
|
174
|
+
"""
|
|
175
|
+
results = []
|
|
176
|
+
seen_on_line: dict[int, set[str]] = {}
|
|
177
|
+
|
|
178
|
+
for line_no, line in enumerate(content.splitlines(), start=1):
|
|
179
|
+
seen_on_line[line_no] = set()
|
|
180
|
+
|
|
181
|
+
# Extract function calls: `name()`
|
|
182
|
+
for match in _FUNC_REF_PATTERN.finditer(line):
|
|
183
|
+
name = match.group(1)
|
|
184
|
+
if name not in seen_on_line[line_no]:
|
|
185
|
+
results.append((line_no, name))
|
|
186
|
+
seen_on_line[line_no].add(name)
|
|
187
|
+
|
|
188
|
+
# Extract PascalCase class names: `ClassName`
|
|
189
|
+
for match in _CLASS_REF_PATTERN.finditer(line):
|
|
190
|
+
name = match.group(1)
|
|
191
|
+
# Skip if it looks like a file path (already caught by file refs)
|
|
192
|
+
if "." in name or "/" in name:
|
|
193
|
+
continue
|
|
194
|
+
# Must have at least one lowercase letter to be PascalCase
|
|
195
|
+
if not any(c.islower() for c in name):
|
|
196
|
+
# All-caps short names (2-3 chars like CLI) are still valid class names
|
|
197
|
+
if len(name) <= 4:
|
|
198
|
+
if name not in seen_on_line[line_no]:
|
|
199
|
+
results.append((line_no, name))
|
|
200
|
+
seen_on_line[line_no].add(name)
|
|
201
|
+
continue
|
|
202
|
+
if name not in seen_on_line[line_no]:
|
|
203
|
+
results.append((line_no, name))
|
|
204
|
+
seen_on_line[line_no].add(name)
|
|
205
|
+
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _extract_component_references(content: str, model: "ArchitectureModel") -> list[tuple[int, str]]:
|
|
210
|
+
"""Extract component ID/name references that should match the model.
|
|
211
|
+
|
|
212
|
+
Looks for known component ID patterns (e.g., COMP-1, SVC-1, IF-1) in text.
|
|
213
|
+
Returns: list of (line_number, component_ref)
|
|
214
|
+
"""
|
|
215
|
+
results = []
|
|
216
|
+
for line_no, line in enumerate(content.splitlines(), start=1):
|
|
217
|
+
for match in _COMPONENT_ID_PATTERN.finditer(line):
|
|
218
|
+
results.append((line_no, match.group(1)))
|
|
219
|
+
return results
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _get_manifest_files(manifest: dict | None) -> set[str]:
|
|
223
|
+
"""Get the set of all file paths from manifest.
|
|
224
|
+
|
|
225
|
+
Checks manifest["modules"] -> each module has "file" key
|
|
226
|
+
Also checks manifest.get("files", [])
|
|
227
|
+
Returns normalized relative paths.
|
|
228
|
+
"""
|
|
229
|
+
if manifest is None:
|
|
230
|
+
return set()
|
|
231
|
+
|
|
232
|
+
files: set[str] = set()
|
|
233
|
+
|
|
234
|
+
# From modules
|
|
235
|
+
for module in manifest.get("modules", []):
|
|
236
|
+
if "file" in module:
|
|
237
|
+
files.add(module["file"])
|
|
238
|
+
|
|
239
|
+
# From top-level files list
|
|
240
|
+
for f in manifest.get("files", []):
|
|
241
|
+
files.add(f)
|
|
242
|
+
|
|
243
|
+
return files
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_manifest_symbols(manifest: dict | None) -> set[str]:
|
|
247
|
+
"""Get all function/class names from manifest AST data.
|
|
248
|
+
|
|
249
|
+
Checks manifest["modules"] -> each module may have "functions", "classes" keys
|
|
250
|
+
Returns set of symbol names.
|
|
251
|
+
"""
|
|
252
|
+
if manifest is None:
|
|
253
|
+
return set()
|
|
254
|
+
|
|
255
|
+
symbols: set[str] = set()
|
|
256
|
+
|
|
257
|
+
for module in manifest.get("modules", []):
|
|
258
|
+
for func in module.get("functions", []):
|
|
259
|
+
symbols.add(func)
|
|
260
|
+
for cls in module.get("classes", []):
|
|
261
|
+
symbols.add(cls)
|
|
262
|
+
|
|
263
|
+
return symbols
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _get_model_entity_ids(model: "ArchitectureModel") -> set[str]:
|
|
267
|
+
"""Get all entity IDs from the architecture model."""
|
|
268
|
+
return model.all_entity_ids
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _get_artifact_id_from_file(filepath: Path) -> str:
|
|
272
|
+
"""Extract artifact_id from file frontmatter or filename.
|
|
273
|
+
|
|
274
|
+
Tries to read YAML frontmatter first:
|
|
275
|
+
---
|
|
276
|
+
artifact_id: system-overview
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
Falls back to: filename without extension (e.g., "system-overview.md" -> "system-overview")
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
content = filepath.read_text()
|
|
283
|
+
if content.startswith("---\n"):
|
|
284
|
+
# Try to parse frontmatter
|
|
285
|
+
parts = content.split("---\n", 2)
|
|
286
|
+
if len(parts) >= 3:
|
|
287
|
+
import yaml
|
|
288
|
+
frontmatter = yaml.safe_load(parts[1])
|
|
289
|
+
if isinstance(frontmatter, dict) and "artifact_id" in frontmatter:
|
|
290
|
+
return frontmatter["artifact_id"]
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
# Fallback to filename without extension
|
|
295
|
+
return filepath.stem
|