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