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,837 @@
1
+ """
2
+ Extract Architecture Model from Tier 1 markdown artifacts.
3
+
4
+ Parses the 5 Tier 1 artifacts:
5
+ - functional-architecture.md → capabilities (F-blocks), actor hints
6
+ - use-cases.md → actors, behaviors (UCs), relationships
7
+ - logical-architecture.md → layers, components
8
+ - requirements-analysis.md → constraints
9
+ - icd.md → interfaces
10
+
11
+ Produces an ArchitectureModel instance (and optionally writes YAML).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from architecture_model.core.types import (
22
+ Actor,
23
+ ActorType,
24
+ ArchitectureModel,
25
+ Behavior,
26
+ Capability,
27
+ Component,
28
+ Constraint,
29
+ ConstraintType,
30
+ Entities,
31
+ Interface,
32
+ InterfaceType,
33
+ Layer,
34
+ ModelMeta,
35
+ Priority,
36
+ Relationship,
37
+ RelationType,
38
+ Status,
39
+ Strength,
40
+ )
41
+ from architecture_model.core.parser import save_model
42
+ from .table_parser import extract_sections, find_table_after_heading, parse_tables
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Public API
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def extract_from_artifacts(
51
+ artifact_dir: str | Path,
52
+ project: str = "",
53
+ system: str = "",
54
+ ) -> ArchitectureModel:
55
+ """
56
+ Extract a complete architecture model from Tier 1 artifact markdown files.
57
+
58
+ Args:
59
+ artifact_dir: Directory containing the stage2 artifact markdown files.
60
+ project: Project name (used in meta).
61
+ system: System identifier.
62
+
63
+ Returns:
64
+ Populated ArchitectureModel.
65
+ """
66
+ artifact_dir = Path(artifact_dir)
67
+
68
+ # Load artifact texts
69
+ texts: dict[str, str] = {}
70
+ for name in [
71
+ "functional-architecture",
72
+ "use-cases",
73
+ "logical-architecture",
74
+ "requirements-analysis",
75
+ "icd",
76
+ ]:
77
+ path = artifact_dir / f"{name}.md"
78
+ if path.exists():
79
+ texts[name] = path.read_text(encoding="utf-8")
80
+ else:
81
+ texts[name] = ""
82
+
83
+ # Extract entities from each artifact
84
+ actors = _extract_actors(texts["use-cases"])
85
+ capabilities = _extract_capabilities(texts["functional-architecture"])
86
+ behaviors = _extract_behaviors(texts["use-cases"])
87
+ interfaces = _extract_interfaces(texts["icd"])
88
+ constraints = _extract_constraints(texts["requirements-analysis"])
89
+ layers = _extract_layers(texts["logical-architecture"])
90
+ components = _extract_components(texts["logical-architecture"])
91
+
92
+ # Extract relationships
93
+ relationships = []
94
+ relationships.extend(_extract_uc_relationships(texts["use-cases"]))
95
+ relationships.extend(_extract_layer_relationships(texts["logical-architecture"]))
96
+ relationships.extend(_extract_capability_relationships(capabilities, behaviors))
97
+ relationships.extend(_extract_component_capability_relationships(components, capabilities))
98
+
99
+ iface_rels, discovered_actors = _extract_interface_relationships(interfaces)
100
+ relationships.extend(iface_rels)
101
+
102
+ # Merge auto-discovered external actors (avoid duplicates across ALL entity types)
103
+ all_entity_ids: set[str] = set()
104
+ all_entity_ids.update(a.id for a in actors)
105
+ all_entity_ids.update(c.id for c in capabilities)
106
+ all_entity_ids.update(b.id for b in behaviors)
107
+ all_entity_ids.update(i.id for i in interfaces)
108
+ all_entity_ids.update(c.id for c in constraints)
109
+ all_entity_ids.update(l.id for l in layers)
110
+ all_entity_ids.update(c.id for c in components)
111
+ for actor in discovered_actors:
112
+ if actor.id not in all_entity_ids:
113
+ actors.append(actor)
114
+ all_entity_ids.add(actor.id)
115
+
116
+ # Determine source artifacts used
117
+ source_artifacts = [name for name, text in texts.items() if text]
118
+
119
+ project_name = project or _guess_project(texts)
120
+ meta = ModelMeta(
121
+ schema_version="0.1.0",
122
+ project=project_name,
123
+ system=system or project_name,
124
+ generated_at=datetime.now(timezone.utc).isoformat(),
125
+ source_artifacts=source_artifacts,
126
+ )
127
+
128
+ return ArchitectureModel(
129
+ meta=meta,
130
+ entities=Entities(
131
+ actors=actors,
132
+ capabilities=capabilities,
133
+ behaviors=behaviors,
134
+ interfaces=interfaces,
135
+ constraints=constraints,
136
+ layers=layers,
137
+ components=components,
138
+ ),
139
+ relationships=relationships,
140
+ )
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Actors (from use-cases.md)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ _ACTOR_TYPE_MAP = {
148
+ "human": ActorType.HUMAN,
149
+ "human (primary)": ActorType.HUMAN,
150
+ "system": ActorType.SYSTEM,
151
+ "external source": ActorType.EXTERNAL_SERVICE,
152
+ "external": ActorType.EXTERNAL_SERVICE,
153
+ }
154
+
155
+
156
+ def _extract_actors(text: str) -> list[Actor]:
157
+ """Parse actors table from use-cases.md §1."""
158
+ if not text:
159
+ return []
160
+
161
+ rows = find_table_after_heading(text, r"Actors?\s*[&+]\s*Goals?")
162
+ actors: list[Actor] = []
163
+
164
+ for row in rows:
165
+ actor_id = row.get("actor_id", "").strip()
166
+ name = row.get("name", "").strip()
167
+ type_str = row.get("type", "human").strip().lower()
168
+ goals_str = row.get("goals", "")
169
+
170
+ if not actor_id or not name:
171
+ continue
172
+
173
+ actor_type = _ACTOR_TYPE_MAP.get(type_str, ActorType.HUMAN)
174
+ goals = [g.strip() for g in goals_str.split(",") if g.strip()]
175
+
176
+ actors.append(
177
+ Actor(
178
+ id=actor_id,
179
+ name=name,
180
+ status=Status.ACTIVE,
181
+ type=actor_type,
182
+ goals=goals,
183
+ )
184
+ )
185
+
186
+ return actors
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Capabilities (from functional-architecture.md)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ _FBLOCK_RE = re.compile(
194
+ r'class\s+"(F\d+):\s+(.+?)"\s+as\s+\w+\s+<<block>>',
195
+ re.MULTILINE,
196
+ )
197
+
198
+ _STATUS_RE = re.compile(r"\[(\w+)\]")
199
+
200
+
201
+ def _extract_capabilities(text: str) -> list[Capability]:
202
+ """Parse F-block definitions from functional-architecture.md PlantUML BDD."""
203
+ if not text:
204
+ return []
205
+
206
+ capabilities: list[Capability] = []
207
+
208
+ # Parse from PlantUML class diagrams
209
+ for match in _FBLOCK_RE.finditer(text):
210
+ fblock_id = match.group(1) # e.g., "F1"
211
+ fblock_name = match.group(2).strip() # e.g., "Ingest Source Data"
212
+
213
+ # Find status for this block (search region after match)
214
+ region = text[match.start() : match.start() + 500]
215
+ status_match = _STATUS_RE.search(region)
216
+ status = Status.ACTIVE
217
+ if status_match:
218
+ try:
219
+ status = Status(status_match.group(1).upper())
220
+ except ValueError:
221
+ pass
222
+
223
+ cap_id = f"CAP-{fblock_id}"
224
+ capabilities.append(
225
+ Capability(
226
+ id=cap_id,
227
+ name=fblock_name,
228
+ status=status,
229
+ f_block=fblock_id,
230
+ description=f"Functional block {fblock_id}: {fblock_name}",
231
+ priority=Priority.HIGH,
232
+ )
233
+ )
234
+
235
+ # Deduplicate by id (PlantUML may define same block multiple times)
236
+ seen: set[str] = set()
237
+ unique: list[Capability] = []
238
+ for cap in capabilities:
239
+ if cap.id not in seen:
240
+ seen.add(cap.id)
241
+ unique.append(cap)
242
+
243
+ return unique
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Behaviors (from use-cases.md)
248
+ # ---------------------------------------------------------------------------
249
+
250
+ _PRIORITY_MAP = {
251
+ "critical": Priority.CRITICAL,
252
+ "high": Priority.HIGH,
253
+ "medium": Priority.MEDIUM,
254
+ "low": Priority.LOW,
255
+ }
256
+
257
+
258
+ def _extract_behaviors(text: str) -> list[Behavior]:
259
+ """Parse UC catalog table from use-cases.md §2."""
260
+ if not text:
261
+ return []
262
+
263
+ rows = find_table_after_heading(text, r"Use Case Catalog|UC Catalog")
264
+ behaviors: list[Behavior] = []
265
+
266
+ for row in rows:
267
+ uc_id = row.get("uc_id", row.get("uc-id", "")).strip()
268
+ title = row.get("title", "").strip()
269
+ actor_str = row.get("actor_s", row.get("actor_s_", row.get("actors", ""))).strip()
270
+ status_str = row.get("status", "ACTIVE").strip()
271
+ priority_str = row.get("priority", "medium").strip().lower()
272
+ frequency = row.get("frequency", "").strip()
273
+ acceptance = row.get("acceptance_criteria", "").strip()
274
+ reqs = row.get(
275
+ "requirement_s", row.get("requirement_s_", row.get("requirements", ""))
276
+ ).strip()
277
+ f_block = row.get("f_block", row.get("f-block", "")).strip()
278
+
279
+ if not uc_id:
280
+ continue
281
+
282
+ # Parse status from [ACTIVE] format
283
+ status = _parse_status_bracket(status_str)
284
+ priority = _PRIORITY_MAP.get(priority_str, Priority.MEDIUM)
285
+
286
+ # Build postconditions from acceptance criteria
287
+ postconditions = [acceptance] if acceptance else []
288
+
289
+ # Build requirement traces
290
+ req_list = [r.strip() for r in reqs.split(",") if r.strip()] if reqs else []
291
+
292
+ behaviors.append(
293
+ Behavior(
294
+ id=uc_id,
295
+ name=title,
296
+ status=status,
297
+ description=f"{title} (F-block: {f_block})",
298
+ trigger=f"Actor: {actor_str}",
299
+ actor=actor_str,
300
+ frequency=frequency,
301
+ priority=priority,
302
+ postconditions=postconditions,
303
+ tags=[f_block] if f_block else [],
304
+ )
305
+ )
306
+
307
+ return behaviors
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Interfaces (from icd.md)
312
+ # ---------------------------------------------------------------------------
313
+
314
+ _IFACE_TYPE_MAP = {
315
+ "rest": InterfaceType.REST,
316
+ "external/rest": InterfaceType.REST,
317
+ "websocket": InterfaceType.WEBSOCKET,
318
+ "db": InterfaceType.DATABASE,
319
+ "external/db": InterfaceType.DATABASE,
320
+ "database": InterfaceType.DATABASE,
321
+ "file": InterfaceType.FILE,
322
+ "pipeline": InterfaceType.INTERNAL,
323
+ "internal": InterfaceType.INTERNAL,
324
+ "external": InterfaceType.EXTERNAL,
325
+ "external/ml": InterfaceType.EXTERNAL,
326
+ "message-queue": InterfaceType.MESSAGE_QUEUE,
327
+ }
328
+
329
+
330
+ def _extract_interfaces(text: str) -> list[Interface]:
331
+ """Parse interface inventory matrix from icd.md §1."""
332
+ if not text:
333
+ return []
334
+
335
+ rows = find_table_after_heading(text, r"Interface Inventory Matrix")
336
+ interfaces: list[Interface] = []
337
+
338
+ for row in rows:
339
+ ifc_id = row.get("interface_id", "").strip()
340
+ type_str = row.get("type", "internal").strip().lower()
341
+ provider = row.get("provider_f_block", row.get("provider", "")).strip()
342
+ consumer = row.get(
343
+ "consumer_f_block_s", row.get("consumer_f_block_s_", row.get("consumer", ""))
344
+ ).strip()
345
+ protocol = row.get("protocol", "").strip()
346
+ status_str = row.get("status", "ACTIVE").strip()
347
+
348
+ if not ifc_id:
349
+ continue
350
+
351
+ iface_type = _IFACE_TYPE_MAP.get(type_str, InterfaceType.INTERNAL)
352
+ status = _parse_status_bracket(status_str)
353
+
354
+ interfaces.append(
355
+ Interface(
356
+ id=ifc_id,
357
+ name=f"{provider} -> {consumer}",
358
+ status=status,
359
+ type=iface_type,
360
+ protocol=protocol,
361
+ provider=provider,
362
+ consumer=consumer,
363
+ description=f"{type_str} interface: {provider} -> {consumer} via {protocol}",
364
+ )
365
+ )
366
+
367
+ return interfaces
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Constraints (from requirements-analysis.md)
372
+ # ---------------------------------------------------------------------------
373
+
374
+ _CONSTRAINT_TYPE_MAP = {
375
+ "performance": ConstraintType.PERFORMANCE,
376
+ "security": ConstraintType.SECURITY,
377
+ "reliability": ConstraintType.RELIABILITY,
378
+ "scalability": ConstraintType.SCALABILITY,
379
+ "regulatory": ConstraintType.REGULATORY,
380
+ "technology": ConstraintType.TECHNOLOGY,
381
+ "operational": ConstraintType.OPERATIONAL,
382
+ "maintainability": ConstraintType.OPERATIONAL,
383
+ "usability": ConstraintType.OPERATIONAL,
384
+ }
385
+
386
+
387
+ def _extract_constraints(text: str) -> list[Constraint]:
388
+ """Parse constraints + NFRs from requirements-analysis.md."""
389
+ if not text:
390
+ return []
391
+
392
+ constraints: list[Constraint] = []
393
+
394
+ # Extract from "Constraints" section — Technical + Organizational tables
395
+ tc_rows = find_table_after_heading(text, r"Technical Constraints")
396
+ for row in tc_rows:
397
+ c_id = row.get("id", "").strip()
398
+ desc = row.get("constraint", "").strip()
399
+ rationale = row.get("rationale", "").strip()
400
+ impact = row.get("impact", "").strip()
401
+ if not c_id:
402
+ continue
403
+ constraints.append(
404
+ Constraint(
405
+ id=c_id,
406
+ name=desc[:60] if desc else c_id,
407
+ status=Status.ACTIVE,
408
+ type=ConstraintType.TECHNOLOGY,
409
+ rationale=rationale,
410
+ description=f"{desc}. Impact: {impact}" if impact else desc,
411
+ )
412
+ )
413
+
414
+ oc_rows = find_table_after_heading(text, r"Organizational Constraints")
415
+ for row in oc_rows:
416
+ c_id = row.get("id", "").strip()
417
+ desc = row.get("constraint", "").strip()
418
+ rationale = row.get("rationale", "").strip()
419
+ impact = row.get("impact", "").strip()
420
+ if not c_id:
421
+ continue
422
+ constraints.append(
423
+ Constraint(
424
+ id=c_id,
425
+ name=desc[:60] if desc else c_id,
426
+ status=Status.ACTIVE,
427
+ type=ConstraintType.OPERATIONAL,
428
+ rationale=rationale,
429
+ description=f"{desc}. Impact: {impact}" if impact else desc,
430
+ )
431
+ )
432
+
433
+ # Extract NFRs as constraints
434
+ nfr_rows = find_table_after_heading(text, r"Non-Functional Requirements")
435
+ for row in nfr_rows:
436
+ req_id = row.get("req_id", "").strip()
437
+ desc = row.get("description", "").strip()
438
+ category = row.get("category", "").strip().lower()
439
+ target = row.get("target", "").strip()
440
+ verification = row.get("verification", "").strip()
441
+ if not req_id:
442
+ continue
443
+
444
+ c_type = _CONSTRAINT_TYPE_MAP.get(category, ConstraintType.OPERATIONAL)
445
+ constraints.append(
446
+ Constraint(
447
+ id=req_id,
448
+ name=desc[:60] if desc else req_id,
449
+ status=Status.ACTIVE,
450
+ type=c_type,
451
+ metric=category,
452
+ threshold=target,
453
+ rationale=f"Verification: {verification}" if verification else "",
454
+ description=desc,
455
+ )
456
+ )
457
+
458
+ return constraints
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # Layers (from logical-architecture.md)
463
+ # ---------------------------------------------------------------------------
464
+
465
+
466
+ def _extract_layers(text: str) -> list[Layer]:
467
+ """Parse layer inventory table from logical-architecture.md."""
468
+ if not text:
469
+ return []
470
+
471
+ rows = find_table_after_heading(text, r"Layer Inventory")
472
+ layers: list[Layer] = []
473
+
474
+ for idx, row in enumerate(rows):
475
+ layer_name = row.get("layer", "").strip().strip("*")
476
+ responsibility = row.get("responsibility", "").strip()
477
+ realizes = row.get("realizes", "").strip()
478
+ file_count = row.get("file_count", "").strip()
479
+ status_str = row.get("status", "ACTIVE").strip()
480
+
481
+ if not layer_name:
482
+ continue
483
+
484
+ # Generate a clean ID
485
+ layer_id = _slugify(layer_name)
486
+ status = _parse_status_bracket(status_str)
487
+
488
+ layers.append(
489
+ Layer(
490
+ id=layer_id,
491
+ name=layer_name,
492
+ status=status,
493
+ order=idx,
494
+ description=f"{responsibility}. Realizes: {realizes}. Files: {file_count}",
495
+ )
496
+ )
497
+
498
+ return layers
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # Components (from logical-architecture.md)
503
+ # ---------------------------------------------------------------------------
504
+
505
+ _COMPONENT_RE = re.compile(
506
+ r"[-*]\s+\*\*(.+?)\*\*\s*(?:\(([^)]+)\))?\s*:?\s*(.*?)(?:\[(\w+)\])?$",
507
+ re.MULTILINE,
508
+ )
509
+
510
+
511
+ def _extract_components(text: str) -> list[Component]:
512
+ """Parse component listings from logical-architecture.md layer descriptions."""
513
+ if not text:
514
+ return []
515
+
516
+ components: list[Component] = []
517
+ seen_ids: set[str] = set()
518
+
519
+ # Parse the traceability table for key components
520
+ rows = find_table_after_heading(text, r"Component-to-Function Traceability")
521
+ for row in rows:
522
+ function = row.get("function", "").strip().strip("*")
523
+ layer_str = row.get("realizing_layer_s_", row.get("realizing_layers", "")).strip()
524
+ key_comp_str = row.get("key_components", "").strip()
525
+
526
+ if not function or not key_comp_str:
527
+ continue
528
+
529
+ # Extract F-block from function name
530
+ fblock_match = re.match(r"(F\d+)", function)
531
+ f_block = fblock_match.group(1) if fblock_match else ""
532
+
533
+ # Parse key components (backtick-delimited)
534
+ comp_files = re.findall(r"`([^`]+)`", key_comp_str)
535
+ for comp_file in comp_files:
536
+ comp_id = _slugify(comp_file.replace(".py", ""))
537
+ if comp_id in seen_ids:
538
+ continue
539
+ seen_ids.add(comp_id)
540
+
541
+ components.append(
542
+ Component(
543
+ id=comp_id,
544
+ name=comp_file,
545
+ status=Status.ACTIVE,
546
+ layer=layer_str.split(",")[0].strip() if layer_str else "",
547
+ f_block=f_block,
548
+ files=[comp_file],
549
+ )
550
+ )
551
+
552
+ return components
553
+
554
+
555
+ # ---------------------------------------------------------------------------
556
+ # Relationships
557
+ # ---------------------------------------------------------------------------
558
+
559
+
560
+ def _extract_uc_relationships(text: str) -> list[Relationship]:
561
+ """Extract UC relationships (<<includes>>, <<extends>>) from use-cases.md §3."""
562
+ if not text:
563
+ return []
564
+
565
+ relationships: list[Relationship] = []
566
+ sections = extract_sections(text, level=3)
567
+
568
+ # Look for includes/extends patterns
569
+ includes_re = re.compile(r"(UC-\d+)\s*.*?<<includes?>>\s*.*?(UC-\d+)", re.IGNORECASE)
570
+ extends_re = re.compile(r"(UC-\d+)\s*.*?<<extends?>>\s*.*?(UC-\d+)", re.IGNORECASE)
571
+
572
+ for section_name, section_text in sections.items():
573
+ for m in includes_re.finditer(section_text):
574
+ relationships.append(
575
+ Relationship(
576
+ type=RelationType.DEPENDS_ON,
577
+ from_id=m.group(1),
578
+ to_id=m.group(2),
579
+ description="<<includes>>",
580
+ )
581
+ )
582
+ for m in extends_re.finditer(section_text):
583
+ relationships.append(
584
+ Relationship(
585
+ type=RelationType.DEPENDS_ON,
586
+ from_id=m.group(1),
587
+ to_id=m.group(2),
588
+ description="<<extends>>",
589
+ strength=Strength.WEAK,
590
+ )
591
+ )
592
+
593
+ # Also parse list-based relationships
594
+ # Pattern: "- UC-01, UC-02 → UC-06 (shared enrichment trigger)"
595
+ arrow_re = re.compile(r"(UC-\d+(?:,\s*UC-\d+)*)\s*→\s*(UC-\d+)")
596
+ for section_name, section_text in sections.items():
597
+ for m in arrow_re.finditer(section_text):
598
+ sources = re.findall(r"UC-\d+", m.group(1))
599
+ target = m.group(2)
600
+ for src in sources:
601
+ if src != target:
602
+ relationships.append(
603
+ Relationship(
604
+ type=RelationType.DEPENDS_ON,
605
+ from_id=src,
606
+ to_id=target,
607
+ description="includes",
608
+ )
609
+ )
610
+
611
+ return relationships
612
+
613
+
614
+ def _extract_layer_relationships(text: str) -> list[Relationship]:
615
+ """Extract inter-layer dependency relationships from logical-architecture.md."""
616
+ if not text:
617
+ return []
618
+
619
+ relationships: list[Relationship] = []
620
+
621
+ # Parse communication mechanisms table
622
+ rows = find_table_after_heading(text, r"Communication Mechanisms")
623
+ for row in rows:
624
+ source = row.get("source_layer", "").strip()
625
+ target = row.get("target_layer", "").strip()
626
+ mechanism = row.get("mechanism", "").strip()
627
+
628
+ if not source or not target:
629
+ continue
630
+
631
+ # Extract layer names from "X → Y" format
632
+ parts = re.split(r"\s*→\s*", source)
633
+ if len(parts) == 2:
634
+ source_layer = _slugify(parts[0])
635
+ target_layer = _slugify(parts[1])
636
+ else:
637
+ source_layer = _slugify(source)
638
+ target_layer = _slugify(target)
639
+
640
+ relationships.append(
641
+ Relationship(
642
+ type=RelationType.DEPENDS_ON,
643
+ from_id=source_layer,
644
+ to_id=target_layer,
645
+ description=mechanism,
646
+ )
647
+ )
648
+
649
+ return relationships
650
+
651
+
652
+ def _extract_capability_relationships(
653
+ capabilities: list[Capability],
654
+ behaviors: list[Behavior],
655
+ ) -> list[Relationship]:
656
+ """Link behaviors (UCs) to capabilities (F-blocks) via realizes relationship."""
657
+ relationships: list[Relationship] = []
658
+
659
+ # Map F-block tag to capability ID
660
+ fblock_to_cap = {cap.f_block: cap.id for cap in capabilities}
661
+
662
+ for beh in behaviors:
663
+ # Behaviors have f_block in tags
664
+ for tag in beh.tags:
665
+ if tag in fblock_to_cap:
666
+ relationships.append(
667
+ Relationship(
668
+ type=RelationType.REALIZES,
669
+ from_id=beh.id,
670
+ to_id=fblock_to_cap[tag],
671
+ description=f"{beh.name} realizes {tag}",
672
+ )
673
+ )
674
+
675
+ return relationships
676
+
677
+
678
+ def _extract_component_capability_relationships(
679
+ components: list[Component],
680
+ capabilities: list[Capability],
681
+ ) -> list[Relationship]:
682
+ """Link components to capabilities (F-blocks) via realizes relationship."""
683
+ relationships: list[Relationship] = []
684
+ fblock_to_cap = {cap.f_block: cap.id for cap in capabilities}
685
+
686
+ for comp in components:
687
+ if comp.f_block and comp.f_block in fblock_to_cap:
688
+ relationships.append(
689
+ Relationship(
690
+ type=RelationType.REALIZES,
691
+ from_id=comp.id,
692
+ to_id=fblock_to_cap[comp.f_block],
693
+ description=f"{comp.name} realizes {comp.f_block}",
694
+ )
695
+ )
696
+
697
+ return relationships
698
+
699
+
700
+ def _extract_interface_relationships(
701
+ interfaces: list[Interface],
702
+ ) -> tuple[list[Relationship], list[Actor]]:
703
+ """Generate exposes/consumes relationships from interfaces.
704
+
705
+ Returns:
706
+ Tuple of (relationships, auto-discovered external actors).
707
+ """
708
+ relationships: list[Relationship] = []
709
+ discovered_actors: dict[str, Actor] = {}
710
+
711
+ for iface in interfaces:
712
+ if iface.provider:
713
+ provider_id = _resolve_fblock_ref(iface.provider)
714
+ relationships.append(
715
+ Relationship(
716
+ type=RelationType.EXPOSES,
717
+ from_id=provider_id,
718
+ to_id=iface.id,
719
+ description=f"{iface.provider} exposes {iface.id}",
720
+ )
721
+ )
722
+ if iface.consumer:
723
+ # Consumer may be comma-separated (multiple consumers)
724
+ first_consumer = iface.consumer.split(",")[0].strip()
725
+ consumer_id = _resolve_fblock_ref(first_consumer)
726
+ relationships.append(
727
+ Relationship(
728
+ type=RelationType.CONSUMES,
729
+ from_id=consumer_id,
730
+ to_id=iface.id,
731
+ description=f"{iface.consumer} consumes {iface.id}",
732
+ )
733
+ )
734
+ # Auto-register non-F-block consumers as external actors
735
+ if not re.match(r"CAP-F\d+", consumer_id) and consumer_id not in discovered_actors:
736
+ discovered_actors[consumer_id] = Actor(
737
+ id=consumer_id,
738
+ name=first_consumer,
739
+ type=ActorType.EXTERNAL_SERVICE,
740
+ status=Status.ACTIVE,
741
+ description=f"External system (auto-discovered from ICD consumer: {iface.id})",
742
+ )
743
+
744
+ return relationships, list(discovered_actors.values())
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # Utilities
749
+ # ---------------------------------------------------------------------------
750
+
751
+
752
+ def _parse_status_bracket(s: str) -> Status:
753
+ """Parse status from [ACTIVE] format."""
754
+ m = re.search(r"\[(\w+)\]", s)
755
+ if m:
756
+ try:
757
+ return Status(m.group(1).upper())
758
+ except ValueError:
759
+ pass
760
+ return Status.ACTIVE
761
+
762
+
763
+ def _slugify(text: str) -> str:
764
+ """Convert text to a slug ID."""
765
+ s = text.lower().strip()
766
+ s = re.sub(r"[^a-z0-9]+", "-", s)
767
+ s = s.strip("-")
768
+ return s
769
+
770
+
771
+ def _resolve_fblock_ref(text: str) -> str:
772
+ """
773
+ Resolve a text reference to an F-block into a CAP-Fx ID.
774
+
775
+ Handles: "F2 Enrichment", "F1 Ingestion", "F4", etc.
776
+ """
777
+ # Try to extract Fx pattern
778
+ m = re.match(r"(F\d+)", text.strip())
779
+ if m:
780
+ return f"CAP-{m.group(1)}"
781
+ return _slugify(text)
782
+
783
+
784
+ def _guess_project(texts: dict[str, str]) -> str:
785
+ """Try to extract project name from artifact headers."""
786
+ for text in texts.values():
787
+ m = re.search(r"\|\s*Project\s*\|\s*(.+?)\s*\|", text)
788
+ if m:
789
+ return m.group(1).strip()
790
+ return "unknown"
791
+
792
+
793
+ # ---------------------------------------------------------------------------
794
+ # CLI entry point
795
+ # ---------------------------------------------------------------------------
796
+
797
+
798
+ def main():
799
+ """CLI: extract architecture model from artifacts."""
800
+ import argparse
801
+
802
+ parser = argparse.ArgumentParser(description="Extract architecture model from Tier 1 artifacts")
803
+ parser.add_argument(
804
+ "artifact_dir",
805
+ help="Path to directory containing stage2 artifact markdown files",
806
+ )
807
+ parser.add_argument(
808
+ "-o",
809
+ "--output",
810
+ help="Output YAML path (default: <artifact_dir>/../architecture-model.yaml)",
811
+ )
812
+ parser.add_argument("--project", default="", help="Project name")
813
+ parser.add_argument("--system", default="", help="System identifier")
814
+ args = parser.parse_args()
815
+
816
+ artifact_dir = Path(args.artifact_dir)
817
+ output_path = args.output or (artifact_dir.parent / "architecture-model.yaml")
818
+
819
+ print(f"Extracting architecture model from: {artifact_dir}")
820
+ model = extract_from_artifacts(artifact_dir, project=args.project, system=args.system)
821
+
822
+ print(f" Entities: {model.entity_count}")
823
+ print(f" Actors: {len(model.entities.actors)}")
824
+ print(f" Capabilities: {len(model.entities.capabilities)}")
825
+ print(f" Behaviors: {len(model.entities.behaviors)}")
826
+ print(f" Interfaces: {len(model.entities.interfaces)}")
827
+ print(f" Constraints: {len(model.entities.constraints)}")
828
+ print(f" Layers: {len(model.entities.layers)}")
829
+ print(f" Components: {len(model.entities.components)}")
830
+ print(f" Relationships: {model.relationship_count}")
831
+
832
+ save_model(model, output_path)
833
+ print(f"\nModel saved to: {output_path}")
834
+
835
+
836
+ if __name__ == "__main__":
837
+ main()