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,451 @@
1
+ """PlantUML diagram generation from architecture model data.
2
+
3
+ Generates deterministic PlantUML syntax strings from model entities and
4
+ relationships. These can be embedded in documentation or rendered by any
5
+ PlantUML-compatible tool.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from architecture_model.core.types import (
11
+ ArchitectureModel,
12
+ ActorType,
13
+ Behavior,
14
+ RelationType,
15
+ )
16
+
17
+
18
+ def _sanitize_id(id_str: str) -> str:
19
+ """Sanitize an entity ID for use as a PlantUML identifier.
20
+
21
+ Replace hyphens, dots, and spaces with underscores.
22
+ """
23
+ return id_str.replace("-", "_").replace(".", "_").replace(" ", "_")
24
+
25
+
26
+ def generate_component_diagram(model: ArchitectureModel) -> str:
27
+ """Generate a C4-style component diagram showing system structure.
28
+
29
+ Groups components by layer, renders actors, and shows
30
+ depends-on/exposes/consumes relationships.
31
+ """
32
+ lines: list[str] = []
33
+ lines.append("@startuml")
34
+ lines.append("!include <C4/C4_Component>")
35
+ lines.append("")
36
+ lines.append(f"title Component Diagram - {model.meta.project}")
37
+ lines.append("")
38
+
39
+ # Actors
40
+ for actor in model.entities.actors:
41
+ aid = _sanitize_id(actor.id)
42
+ if actor.type == ActorType.HUMAN:
43
+ lines.append(f'Person({aid}, "{actor.name}")')
44
+ else:
45
+ lines.append(f'System_Ext({aid}, "{actor.name}")')
46
+
47
+ if model.entities.actors:
48
+ lines.append("")
49
+
50
+ # Group components by layer
51
+ layer_ids = {layer.id for layer in model.entities.layers}
52
+ layer_map: dict[str, list] = {layer.id: [] for layer in model.entities.layers}
53
+ unlayered: list = []
54
+
55
+ for comp in model.entities.components:
56
+ if comp.layer and comp.layer in layer_ids:
57
+ layer_map[comp.layer].append(comp)
58
+ else:
59
+ unlayered.append(comp)
60
+
61
+ # Render layer boundaries with their components
62
+ for layer in model.entities.layers:
63
+ comps = layer_map.get(layer.id, [])
64
+ if not comps:
65
+ continue
66
+ lid = _sanitize_id(layer.id)
67
+ lines.append(f'Container_Boundary({lid}, "{layer.name}") {{')
68
+ for comp in comps:
69
+ cid = _sanitize_id(comp.id)
70
+ tech = f', "{comp.technology}"' if comp.technology else ', ""'
71
+ lines.append(f' Component({cid}, "{comp.name}"{tech}, "{comp.kind.value}")')
72
+ lines.append("}")
73
+ lines.append("")
74
+
75
+ # Components without layers
76
+ for comp in unlayered:
77
+ cid = _sanitize_id(comp.id)
78
+ tech = f', "{comp.technology}"' if comp.technology else ', ""'
79
+ lines.append(f'Component({cid}, "{comp.name}"{tech}, "{comp.kind.value}")')
80
+
81
+ if unlayered:
82
+ lines.append("")
83
+
84
+ # Relationships (only depends-on, exposes, consumes)
85
+ allowed_types = {RelationType.DEPENDS_ON, RelationType.EXPOSES, RelationType.CONSUMES}
86
+ rels_rendered = False
87
+ for rel in model.relationships:
88
+ if rel.type in allowed_types:
89
+ fid = _sanitize_id(rel.from_id)
90
+ tid = _sanitize_id(rel.to_id)
91
+ lines.append(f'Rel({fid}, {tid}, "{rel.type.value}")')
92
+ rels_rendered = True
93
+
94
+ if rels_rendered:
95
+ lines.append("")
96
+
97
+ lines.append("@enduml")
98
+ return "\n".join(lines)
99
+
100
+
101
+ def generate_dependency_diagram(model: ArchitectureModel) -> str:
102
+ """Generate a dependency graph showing component relationships.
103
+
104
+ Simpler than C4 — plain PlantUML with rectangles and arrows.
105
+ Only includes entities that participate in at least one relationship.
106
+ """
107
+ lines: list[str] = []
108
+ lines.append("@startuml")
109
+ lines.append(f"title Dependency Graph - {model.meta.project}")
110
+ lines.append("")
111
+
112
+ # Filter to relevant relationship types
113
+ allowed_types = {RelationType.DEPENDS_ON, RelationType.EXPOSES, RelationType.CONSUMES}
114
+ relevant_rels = [r for r in model.relationships if r.type in allowed_types]
115
+
116
+ # Collect IDs that participate in relationships
117
+ connected_ids: set[str] = set()
118
+ for rel in relevant_rels:
119
+ connected_ids.add(rel.from_id)
120
+ connected_ids.add(rel.to_id)
121
+
122
+ if not connected_ids:
123
+ lines.append("@enduml")
124
+ return "\n".join(lines)
125
+
126
+ # Build name lookup from all entity types
127
+ name_lookup: dict[str, str] = {}
128
+ for comp in model.entities.components:
129
+ name_lookup[comp.id] = comp.name
130
+ for iface in model.entities.interfaces:
131
+ name_lookup[iface.id] = iface.name
132
+
133
+ # Render rectangles for connected entities
134
+ for entity_id in sorted(connected_ids):
135
+ sid = _sanitize_id(entity_id)
136
+ name = name_lookup.get(entity_id, entity_id)
137
+ lines.append(f'rectangle "{name}" as {sid}')
138
+
139
+ lines.append("")
140
+
141
+ # Render arrows
142
+ for rel in relevant_rels:
143
+ fid = _sanitize_id(rel.from_id)
144
+ tid = _sanitize_id(rel.to_id)
145
+ if rel.type == RelationType.DEPENDS_ON:
146
+ lines.append(f"{fid} --> {tid} : depends-on")
147
+ elif rel.type == RelationType.EXPOSES:
148
+ lines.append(f"{fid} ..> {tid} : exposes")
149
+ elif rel.type == RelationType.CONSUMES:
150
+ lines.append(f"{fid} ..> {tid} : consumes")
151
+
152
+ lines.append("")
153
+ lines.append("@enduml")
154
+ return "\n".join(lines)
155
+
156
+
157
+ def generate_sequence_diagram(behavior: Behavior, model: ArchitectureModel) -> str:
158
+ """Generate a sequence diagram from a behavior's steps.
159
+
160
+ Returns empty string if behavior has no steps.
161
+ """
162
+ if not behavior.steps:
163
+ return ""
164
+
165
+ lines: list[str] = []
166
+ lines.append("@startuml")
167
+ lines.append(f"title {behavior.name}")
168
+ lines.append("")
169
+
170
+ # Resolve actor if present
171
+ actor_name: str | None = None
172
+ actor_alias: str | None = None
173
+ if behavior.actor:
174
+ # Look up actor entity by ID
175
+ for act in model.entities.actors:
176
+ if act.id == behavior.actor:
177
+ actor_name = act.name
178
+ actor_alias = _sanitize_id(act.id)
179
+ break
180
+ if not actor_name:
181
+ # Use raw actor ID as fallback
182
+ actor_name = behavior.actor
183
+ actor_alias = _sanitize_id(behavior.actor)
184
+
185
+ lines.append(f'actor "{actor_name}" as {actor_alias}')
186
+
187
+ # Add System participant for generic step mapping
188
+ lines.append(f'participant "System" as System')
189
+ lines.append("")
190
+
191
+ # Render steps as messages
192
+ source = actor_alias if actor_alias else "System"
193
+ for i, step in enumerate(behavior.steps):
194
+ if i == 0 and actor_alias:
195
+ lines.append(f"{source} -> System : {step}")
196
+ else:
197
+ lines.append(f"System -> System : {step}")
198
+
199
+ lines.append("")
200
+ lines.append("@enduml")
201
+ return "\n".join(lines)
202
+
203
+
204
+ def generate_nav_diagram(model: ArchitectureModel) -> str:
205
+ """Generate a full-model navigational traceability diagram.
206
+
207
+ Shows ALL entities (typed shapes) and ALL relationships (labeled edges)
208
+ on a single page. Components are nested inside their layer packages.
209
+ Every node displays its ID for precise referencing by systems engineers.
210
+ """
211
+ lines: list[str] = []
212
+ lines.append("@startuml")
213
+ lines.append(f"title Architecture Navigation - {model.meta.project}")
214
+ lines.append("left to right direction")
215
+ lines.append("")
216
+
217
+ # Track which entity IDs are rendered (for relationship filtering)
218
+ rendered_ids: set[str] = set()
219
+
220
+ # --- Actors (leftmost) ---
221
+ if model.entities.actors:
222
+ for act in model.entities.actors:
223
+ aid = _sanitize_id(act.id)
224
+ lines.append(f'actor "{act.name}\\n{act.id}" as {aid}')
225
+ rendered_ids.add(act.id)
226
+ lines.append("")
227
+
228
+ # --- Capabilities (functional view) ---
229
+ if model.entities.capabilities:
230
+ lines.append("package \"Functional View\" {")
231
+ for cap in model.entities.capabilities:
232
+ cid = _sanitize_id(cap.id)
233
+ lines.append(f' usecase "{cap.name}\\n{cap.id}" as {cid}')
234
+ rendered_ids.add(cap.id)
235
+ lines.append("}")
236
+ lines.append("")
237
+
238
+ # --- Behaviors (behavioral view) ---
239
+ if model.entities.behaviors:
240
+ lines.append("package \"Behavioral View\" {")
241
+ for beh in model.entities.behaviors:
242
+ bid = _sanitize_id(beh.id)
243
+ lines.append(f' rectangle "{beh.name}\\n{beh.id}" as {bid} <<behavior>>')
244
+ rendered_ids.add(beh.id)
245
+ lines.append("}")
246
+ lines.append("")
247
+
248
+ # --- Components in layers (logical/physical view) ---
249
+ layer_ids = {layer.id for layer in model.entities.layers}
250
+ layer_map: dict[str, list] = {layer.id: [] for layer in model.entities.layers}
251
+ unlayered: list = []
252
+
253
+ for comp in model.entities.components:
254
+ if comp.layer and comp.layer in layer_ids:
255
+ layer_map[comp.layer].append(comp)
256
+ else:
257
+ unlayered.append(comp)
258
+
259
+ for layer in model.entities.layers:
260
+ comps = layer_map.get(layer.id, [])
261
+ lid = _sanitize_id(layer.id)
262
+ lines.append(f'package "{layer.name}" as {lid} {{')
263
+ for comp in comps:
264
+ cid = _sanitize_id(comp.id)
265
+ lines.append(f' component "{comp.name}\\n{comp.id}" as {cid}')
266
+ rendered_ids.add(comp.id)
267
+ lines.append("}")
268
+ rendered_ids.add(layer.id)
269
+
270
+ for comp in unlayered:
271
+ cid = _sanitize_id(comp.id)
272
+ lines.append(f'component "{comp.name}\\n{comp.id}" as {cid}')
273
+ rendered_ids.add(comp.id)
274
+
275
+ if model.entities.components or model.entities.layers:
276
+ lines.append("")
277
+
278
+ # --- Interfaces ---
279
+ if model.entities.interfaces:
280
+ for iface in model.entities.interfaces:
281
+ iid = _sanitize_id(iface.id)
282
+ lines.append(f'() "{iface.name}\\n{iface.id}" as {iid}')
283
+ rendered_ids.add(iface.id)
284
+ lines.append("")
285
+
286
+ # --- Constraints ---
287
+ if model.entities.constraints:
288
+ for con in model.entities.constraints:
289
+ coid = _sanitize_id(con.id)
290
+ lines.append(f'card "{con.name}\\n{con.id}" as {coid}')
291
+ rendered_ids.add(con.id)
292
+ lines.append("")
293
+
294
+ # --- Relationships ---
295
+ for rel in model.relationships:
296
+ if rel.from_id in rendered_ids and rel.to_id in rendered_ids:
297
+ fid = _sanitize_id(rel.from_id)
298
+ tid = _sanitize_id(rel.to_id)
299
+ rtype = rel.type.value
300
+ if rel.type in (RelationType.EXPOSES, RelationType.CONSUMES):
301
+ lines.append(f"{fid} ..> {tid} : {rtype}")
302
+ else:
303
+ lines.append(f"{fid} --> {tid} : {rtype}")
304
+
305
+ lines.append("")
306
+ lines.append("@enduml")
307
+ return "\n".join(lines)
308
+
309
+
310
+ def generate_focused_diagram(
311
+ model: ArchitectureModel,
312
+ entity_id: str,
313
+ depth: int = 2,
314
+ ) -> str:
315
+ """Generate a focused subgraph centered on a specific entity.
316
+
317
+ Performs BFS from entity_id through relationships (both directions)
318
+ up to `depth` hops. Renders only the reachable subgraph with the
319
+ focus entity highlighted.
320
+
321
+ Returns empty string if entity_id is not found in the model.
322
+ """
323
+ # Build entity lookup (id -> (type, name))
324
+ entity_info: dict[str, tuple[str, str]] = {}
325
+ for act in model.entities.actors:
326
+ entity_info[act.id] = ("actor", act.name)
327
+ for cap in model.entities.capabilities:
328
+ entity_info[cap.id] = ("capability", cap.name)
329
+ for beh in model.entities.behaviors:
330
+ entity_info[beh.id] = ("behavior", beh.name)
331
+ for comp in model.entities.components:
332
+ entity_info[comp.id] = ("component", comp.name)
333
+ for iface in model.entities.interfaces:
334
+ entity_info[iface.id] = ("interface", iface.name)
335
+ for con in model.entities.constraints:
336
+ entity_info[con.id] = ("constraint", con.name)
337
+ for layer in model.entities.layers:
338
+ entity_info[layer.id] = ("layer", layer.name)
339
+
340
+ if entity_id not in entity_info:
341
+ return ""
342
+
343
+ # Build adjacency (both directions)
344
+ adjacency: dict[str, set[str]] = {}
345
+ for eid in entity_info:
346
+ adjacency[eid] = set()
347
+ for rel in model.relationships:
348
+ if rel.from_id in adjacency:
349
+ adjacency[rel.from_id].add(rel.to_id)
350
+ if rel.to_id in adjacency:
351
+ adjacency[rel.to_id].add(rel.from_id)
352
+
353
+ # BFS from entity_id
354
+ visited: dict[str, int] = {entity_id: 0}
355
+ queue: list[tuple[str, int]] = [(entity_id, 0)]
356
+ while queue:
357
+ current, d = queue.pop(0)
358
+ if d >= depth:
359
+ continue
360
+ for neighbor in adjacency.get(current, set()):
361
+ if neighbor not in visited and neighbor in entity_info:
362
+ visited[neighbor] = d + 1
363
+ queue.append((neighbor, d + 1))
364
+
365
+ if len(visited) <= 1 and not adjacency.get(entity_id):
366
+ # Isolated node — still render it
367
+ pass
368
+
369
+ # Render
370
+ focus_name = entity_info[entity_id][1]
371
+ lines: list[str] = []
372
+ lines.append("@startuml")
373
+ lines.append(f"title Focus: {entity_id} ({focus_name}) - depth {depth}")
374
+ lines.append("")
375
+
376
+ # Render nodes
377
+ for eid, d in sorted(visited.items(), key=lambda x: x[1]):
378
+ etype, ename = entity_info[eid]
379
+ sid = _sanitize_id(eid)
380
+ highlight = " #LightBlue" if eid == entity_id else ""
381
+ if etype == "actor":
382
+ lines.append(f'actor "{ename}\\n{eid}" as {sid}{highlight}')
383
+ elif etype == "capability":
384
+ lines.append(f'usecase "{ename}\\n{eid}" as {sid}{highlight}')
385
+ elif etype == "behavior":
386
+ lines.append(f'rectangle "{ename}\\n{eid}" as {sid} <<behavior>>{highlight}')
387
+ elif etype == "component":
388
+ lines.append(f'component "{ename}\\n{eid}" as {sid}{highlight}')
389
+ elif etype == "interface":
390
+ lines.append(f'() "{ename}\\n{eid}" as {sid}{highlight}')
391
+ elif etype == "constraint":
392
+ lines.append(f'card "{ename}\\n{eid}" as {sid}{highlight}')
393
+ elif etype == "layer":
394
+ lines.append(f'package "{ename}\\n{eid}" as {sid}{highlight}')
395
+
396
+ lines.append("")
397
+
398
+ # Render edges (only between visited nodes)
399
+ for rel in model.relationships:
400
+ if rel.from_id in visited and rel.to_id in visited:
401
+ fid = _sanitize_id(rel.from_id)
402
+ tid = _sanitize_id(rel.to_id)
403
+ rtype = rel.type.value
404
+ if rel.type in (RelationType.EXPOSES, RelationType.CONSUMES):
405
+ lines.append(f"{fid} ..> {tid} : {rtype}")
406
+ else:
407
+ lines.append(f"{fid} --> {tid} : {rtype}")
408
+
409
+ lines.append("")
410
+ lines.append("@enduml")
411
+ return "\n".join(lines)
412
+
413
+
414
+ def generate_all_diagrams(model: ArchitectureModel) -> dict[str, str]:
415
+ """Generate all applicable diagrams for the model.
416
+
417
+ Returns dict mapping diagram name to PlantUML string.
418
+ Only includes diagrams where there's enough data.
419
+ """
420
+ result: dict[str, str] = {}
421
+
422
+ # Component diagram: requires at least 2 components
423
+ if len(model.entities.components) >= 2:
424
+ result["component-diagram"] = generate_component_diagram(model)
425
+
426
+ # Dependency graph: requires at least 1 relevant relationship
427
+ allowed_types = {RelationType.DEPENDS_ON, RelationType.EXPOSES, RelationType.CONSUMES}
428
+ has_dep_rels = any(r.type in allowed_types for r in model.relationships)
429
+ if has_dep_rels:
430
+ result["dependency-graph"] = generate_dependency_diagram(model)
431
+
432
+ # Navigation diagram: requires at least 2 entities total
433
+ total_entities = (
434
+ len(model.entities.actors)
435
+ + len(model.entities.capabilities)
436
+ + len(model.entities.behaviors)
437
+ + len(model.entities.components)
438
+ + len(model.entities.interfaces)
439
+ + len(model.entities.constraints)
440
+ )
441
+ if total_entities >= 2:
442
+ result["nav-diagram"] = generate_nav_diagram(model)
443
+
444
+ # Sequence diagrams: one per behavior with steps
445
+ for behavior in model.entities.behaviors:
446
+ if behavior.steps:
447
+ diagram = generate_sequence_diagram(behavior, model)
448
+ if diagram:
449
+ result[f"sequence-{behavior.id}"] = diagram
450
+
451
+ return result