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,646 @@
1
+ """
2
+ Extract an ArchitectureModel directly from source code analysis.
3
+
4
+ This is the "backward pass" — code → model — bypassing the stage2 markdown
5
+ artifact requirement. Derives entities and relationships from AST analysis,
6
+ import graphs, and project configuration files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from architecture_model.config.loader import get_config
18
+ from architecture_model.config.schema import ProjectConfig
19
+ from architecture_model.core.types import (
20
+ Actor,
21
+ ActorType,
22
+ ArchitectureModel,
23
+ Behavior,
24
+ Capability,
25
+ Component,
26
+ Constraint,
27
+ ConstraintType,
28
+ Entities,
29
+ Interface,
30
+ InterfaceType,
31
+ Layer,
32
+ ModelMeta,
33
+ Priority,
34
+ Relationship,
35
+ RelationType,
36
+ Status,
37
+ Strength,
38
+ )
39
+ from .constraint_detector import detect_constraints
40
+ from .route_detector import RouteInfo, detect_routes
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Public API
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def extract_from_code(
49
+ project_root: str | Path,
50
+ config: ProjectConfig | None = None,
51
+ manifest: dict | None = None,
52
+ ) -> ArchitectureModel:
53
+ """Extract an architecture model directly from source code analysis.
54
+
55
+ This bypasses the stage2 markdown artifact requirement by deriving
56
+ entities and relationships from AST analysis, import graphs, and
57
+ project configuration files.
58
+
59
+ Args:
60
+ project_root: Root directory of the project to analyze.
61
+ config: Optional pre-loaded ProjectConfig. If None, auto-discovered.
62
+ manifest: Optional pre-generated manifest dict. If None, generated fresh.
63
+
64
+ Returns:
65
+ Complete ArchitectureModel derived from code analysis.
66
+ """
67
+ root = Path(project_root).resolve()
68
+
69
+ if config is None:
70
+ config = get_config(root)
71
+
72
+ if manifest is None:
73
+ from architecture_model.manifest import generate_manifest
74
+
75
+ manifest = generate_manifest(root, config)
76
+
77
+ # Derive all entities
78
+ capabilities = _derive_capabilities(config)
79
+ routes = detect_routes(root, _get_web_layer_dirs(config))
80
+ actors = _derive_actors(routes, manifest)
81
+ route_behaviors = _derive_route_behaviors(routes, config)
82
+ service_behaviors = _detect_service_behaviors(root, config)
83
+ behaviors = route_behaviors + service_behaviors
84
+ components = _derive_components(manifest, config)
85
+ interfaces = _derive_interfaces(manifest, config)
86
+ layers = _derive_layers(config)
87
+ constraints = detect_constraints(root)
88
+
89
+ entities = Entities(
90
+ actors=actors,
91
+ capabilities=capabilities,
92
+ behaviors=behaviors,
93
+ interfaces=interfaces,
94
+ constraints=constraints,
95
+ layers=layers,
96
+ components=components,
97
+ )
98
+
99
+ # Derive relationships
100
+ relationships = _derive_relationships(
101
+ capabilities=capabilities,
102
+ behaviors=behaviors,
103
+ components=components,
104
+ interfaces=interfaces,
105
+ constraints=constraints,
106
+ layers=layers,
107
+ config=config,
108
+ )
109
+
110
+ meta = ModelMeta(
111
+ schema_version="1.0.0",
112
+ project=config.name or root.name,
113
+ system=config.system or config.name or root.name,
114
+ generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
115
+ source_artifacts=["code-analysis"],
116
+ )
117
+
118
+ return ArchitectureModel(meta=meta, entities=entities, relationships=relationships)
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Helper functions
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ def _slugify(text: str) -> str:
127
+ """Convert text to a valid entity ID slug.
128
+
129
+ Replaces path separators, dots, and special characters with hyphens,
130
+ strips leading/trailing hyphens, and collapses runs of hyphens.
131
+ """
132
+ slug = re.sub(r"[/\\._\s{}]+", "-", text)
133
+ slug = re.sub(r"[^a-zA-Z0-9-]", "", slug)
134
+ slug = re.sub(r"-+", "-", slug)
135
+ return slug.strip("-").lower()
136
+
137
+
138
+ def _file_to_fblock(file_path: str, config: ProjectConfig) -> str | None:
139
+ """Determine which F-block a file belongs to based on directory membership."""
140
+ for block in config.functional_blocks:
141
+ for d in block.dirs:
142
+ if file_path.startswith(d + "/") or file_path == d:
143
+ return block.id
144
+ for f in block.files:
145
+ if file_path == f:
146
+ return block.id
147
+ return None
148
+
149
+
150
+ def _file_to_layer(file_path: str, config: ProjectConfig) -> str | None:
151
+ """Determine which layer a file belongs to based on directory membership."""
152
+ for layer in config.layers:
153
+ for d in layer.dirs:
154
+ if file_path.startswith(d + "/") or file_path == d:
155
+ return layer.id
156
+ return None
157
+
158
+
159
+ def _get_web_layer_dirs(config: ProjectConfig) -> list[str] | None:
160
+ """Extract web-layer directories from config for route detection."""
161
+ for layer in config.layers:
162
+ if "web" in layer.id.lower() or "api" in layer.id.lower():
163
+ return layer.dirs
164
+ return None
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Entity derivation
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _derive_capabilities(config: ProjectConfig) -> list[Capability]:
173
+ """Derive one capability per F-block in config."""
174
+ capabilities: list[Capability] = []
175
+ for block in config.functional_blocks:
176
+ capabilities.append(
177
+ Capability(
178
+ id=f"CAP-{block.id}",
179
+ name=block.name,
180
+ status=Status.ACTIVE,
181
+ description=block.description_source or f"Capability for {block.name}",
182
+ f_block=block.id,
183
+ )
184
+ )
185
+ return capabilities
186
+
187
+
188
+ def _derive_actors(routes: list[RouteInfo], manifest: dict) -> list[Actor]:
189
+ """Infer actors from entry point types and external dependencies."""
190
+ actors: list[Actor] = []
191
+ seen_ids: set[str] = set()
192
+
193
+ has_authenticated = any(r.is_authenticated for r in routes)
194
+ has_anonymous = any(not r.is_authenticated for r in routes)
195
+
196
+ if has_authenticated:
197
+ actors.append(
198
+ Actor(
199
+ id="ACT-USER",
200
+ name="Authenticated User",
201
+ status=Status.ACTIVE,
202
+ description="User who has authenticated with the system",
203
+ type=ActorType.HUMAN,
204
+ )
205
+ )
206
+ seen_ids.add("ACT-USER")
207
+
208
+ if has_anonymous:
209
+ actors.append(
210
+ Actor(
211
+ id="ACT-ANON",
212
+ name="Anonymous User",
213
+ status=Status.ACTIVE,
214
+ description="Unauthenticated user or public endpoint consumer",
215
+ type=ActorType.HUMAN,
216
+ )
217
+ )
218
+ seen_ids.add("ACT-ANON")
219
+
220
+ # Check for database dependencies in manifest modules
221
+ db_indicators = {"asyncpg", "psycopg2", "pymongo", "sqlalchemy", "databases"}
222
+ all_imports: set[str] = set()
223
+ for mod in manifest.get("modules", []):
224
+ for imp in mod.get("imports", []):
225
+ all_imports.add(imp.split(".")[0])
226
+
227
+ if all_imports & db_indicators:
228
+ actors.append(
229
+ Actor(
230
+ id="ACT-DB",
231
+ name="Database",
232
+ status=Status.ACTIVE,
233
+ description="External database service",
234
+ type=ActorType.EXTERNAL_SERVICE,
235
+ )
236
+ )
237
+ seen_ids.add("ACT-DB")
238
+
239
+ return actors
240
+
241
+
242
+ def _derive_route_behaviors(
243
+ routes: list[RouteInfo], config: ProjectConfig
244
+ ) -> list[Behavior]:
245
+ """Derive behaviors from route handlers."""
246
+ behaviors: list[Behavior] = []
247
+ seen_ids: set[str] = set()
248
+
249
+ for route in routes:
250
+ # Prefer function name for semantic IDs
251
+ name_slug = _slugify(route.function_name) if route.function_name else ""
252
+ if not name_slug:
253
+ name_slug = _slugify(route.path) if route.path else "unknown"
254
+
255
+ behavior_id = f"BEH-{route.method}-{name_slug}"
256
+
257
+ # Deduplicate
258
+ if behavior_id in seen_ids:
259
+ continue
260
+ seen_ids.add(behavior_id)
261
+
262
+ # Determine priority based on HTTP method
263
+ if route.method in ("POST", "PUT", "DELETE"):
264
+ priority = Priority.HIGH
265
+ else:
266
+ priority = Priority.MEDIUM
267
+
268
+ # Determine actor
269
+ actor = "ACT-USER" if route.is_authenticated else "ACT-ANON"
270
+
271
+ # Determine f_block from file location
272
+ f_block = _file_to_fblock(route.file, config) or ""
273
+
274
+ behaviors.append(
275
+ Behavior(
276
+ id=behavior_id,
277
+ name=route.docstring or f"{route.method} {route.path}",
278
+ status=Status.ACTIVE,
279
+ description=route.docstring or f"Route handler: {route.method} {route.path}",
280
+ trigger=f"HTTP {route.method} {route.path}",
281
+ actor=actor,
282
+ priority=priority,
283
+ source_file=route.file,
284
+ tags=[route.framework, f_block] if f_block else [route.framework],
285
+ )
286
+ )
287
+
288
+ return behaviors
289
+
290
+
291
+ def _detect_service_behaviors(
292
+ project_root: Path, config: ProjectConfig
293
+ ) -> list[Behavior]:
294
+ """Scan service-layer files for public functions using AST."""
295
+ behaviors: list[Behavior] = []
296
+ seen_ids: set[str] = set()
297
+
298
+ # Find service-layer directories
299
+ service_dirs: list[str] = []
300
+ for layer in config.layers:
301
+ if "service" in layer.id.lower():
302
+ service_dirs.extend(layer.dirs)
303
+
304
+ if not service_dirs:
305
+ return behaviors
306
+
307
+ for dir_path in service_dirs:
308
+ target = project_root / dir_path
309
+ if not target.is_dir():
310
+ continue
311
+
312
+ for py_file in sorted(target.rglob("*.py")):
313
+ if py_file.name == "__init__.py":
314
+ continue
315
+ if "__pycache__" in str(py_file):
316
+ continue
317
+
318
+ rel_path = str(py_file.relative_to(project_root))
319
+ module_name = py_file.stem
320
+ f_block = _file_to_fblock(rel_path, config) or ""
321
+
322
+ # Parse AST and extract public functions
323
+ try:
324
+ source = py_file.read_text(encoding="utf-8")
325
+ tree = ast.parse(source, filename=str(py_file))
326
+ except (SyntaxError, UnicodeDecodeError, OSError):
327
+ continue
328
+
329
+ for node in ast.iter_child_nodes(tree):
330
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
331
+ continue
332
+ if node.name.startswith("_"):
333
+ continue
334
+
335
+ behavior_id = f"BEH-SVC-{module_name}-{node.name}"
336
+ if behavior_id in seen_ids:
337
+ continue
338
+ seen_ids.add(behavior_id)
339
+
340
+ docstring = ast.get_docstring(node) or ""
341
+ first_line = docstring.split("\n")[0].strip() if docstring else ""
342
+
343
+ behaviors.append(
344
+ Behavior(
345
+ id=behavior_id,
346
+ name=first_line or f"{module_name}.{node.name}",
347
+ status=Status.ACTIVE,
348
+ description=first_line or f"Service function: {module_name}.{node.name}",
349
+ source_file=rel_path,
350
+ source_line=node.lineno,
351
+ tags=["internal", f_block] if f_block else ["internal"],
352
+ priority=Priority.MEDIUM,
353
+ )
354
+ )
355
+
356
+ return behaviors
357
+
358
+
359
+ def _derive_components(manifest: dict, config: ProjectConfig) -> list[Component]:
360
+ """Derive components from manifest modules, filtered to F-block directories."""
361
+ components: list[Component] = []
362
+ seen_ids: set[str] = set()
363
+
364
+ for mod in manifest.get("modules", []):
365
+ file_path = mod.get("file", "")
366
+ if not file_path:
367
+ continue
368
+
369
+ f_block = _file_to_fblock(file_path, config)
370
+ if f_block is None:
371
+ # Skip modules outside F-block directories (tests, scripts, etc.)
372
+ continue
373
+
374
+ layer = _file_to_layer(file_path, config) or ""
375
+
376
+ # Build component ID from file path
377
+ comp_id = _slugify(file_path.removesuffix(".py"))
378
+ if not comp_id:
379
+ continue
380
+
381
+ if comp_id in seen_ids:
382
+ continue
383
+ seen_ids.add(comp_id)
384
+
385
+ # Use module docstring as description if available
386
+ description = mod.get("docstring", "") or ""
387
+ name = Path(file_path).stem.replace("_", " ").title()
388
+
389
+ components.append(
390
+ Component(
391
+ id=comp_id,
392
+ name=name,
393
+ status=Status.ACTIVE,
394
+ description=description,
395
+ layer=layer,
396
+ f_block=f_block,
397
+ files=[file_path],
398
+ source_file=file_path,
399
+ )
400
+ )
401
+
402
+ return components
403
+
404
+
405
+ def _derive_interfaces(manifest: dict, config: ProjectConfig) -> list[Interface]:
406
+ """Derive interfaces from cross-F-block imports in manifest."""
407
+ interfaces: list[Interface] = []
408
+ seen_pairs: set[tuple[str, str]] = set()
409
+
410
+ for iface in manifest.get("interfaces", []):
411
+ source_file = iface.get("source", "")
412
+ target_file = iface.get("target", "")
413
+
414
+ source_block = _file_to_fblock(source_file, config)
415
+ target_block = _file_to_fblock(target_file, config)
416
+
417
+ if source_block and target_block and source_block != target_block:
418
+ # source_block = importer (consumer), target_block = importee (provider)
419
+ pair = (target_block, source_block)
420
+ if pair in seen_pairs:
421
+ continue
422
+ seen_pairs.add(pair)
423
+
424
+ interfaces.append(
425
+ Interface(
426
+ id=f"IFC-{target_block}-{source_block}",
427
+ name=f"{target_block} → {source_block}",
428
+ status=Status.ACTIVE,
429
+ description=f"Interface: {target_block} provides to {source_block}",
430
+ type=InterfaceType.INTERNAL,
431
+ provider=f"CAP-{target_block}",
432
+ consumer=f"CAP-{source_block}",
433
+ )
434
+ )
435
+
436
+ # External dependencies (imports to things not in any F-block)
437
+ external_targets: set[str] = set()
438
+ for iface in manifest.get("interfaces", []):
439
+ target_file = iface.get("target", "")
440
+ target_block = _file_to_fblock(target_file, config)
441
+ if target_block is None and target_file:
442
+ # This is an import to something outside known F-blocks
443
+ import_path = iface.get("import_path", "")
444
+ top_module = import_path.split(".")[0] if import_path else ""
445
+ if top_module and top_module not in external_targets:
446
+ external_targets.add(top_module)
447
+
448
+ for ext in sorted(external_targets):
449
+ ifc_id = f"IFC-EXT-{_slugify(ext)}"
450
+ interfaces.append(
451
+ Interface(
452
+ id=ifc_id,
453
+ name=f"External: {ext}",
454
+ status=Status.ACTIVE,
455
+ description=f"External dependency on {ext}",
456
+ type=InterfaceType.EXTERNAL,
457
+ )
458
+ )
459
+
460
+ return interfaces
461
+
462
+
463
+ def _derive_layers(config: ProjectConfig) -> list[Layer]:
464
+ """Derive layers from config."""
465
+ layers: list[Layer] = []
466
+ for idx, layer_config in enumerate(config.layers):
467
+ # Title-case from ID
468
+ name = layer_config.id.replace("-", " ").title()
469
+ layers.append(
470
+ Layer(
471
+ id=layer_config.id,
472
+ name=name,
473
+ status=Status.ACTIVE,
474
+ description=layer_config.description or f"Architecture layer: {name}",
475
+ order=idx,
476
+ directories=layer_config.dirs,
477
+ )
478
+ )
479
+ return layers
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Helpers for relationship derivation
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ def _cap_to_layer(cap_id: str, config: ProjectConfig) -> str | None:
488
+ """Map a capability ID (CAP-F1) back to its layer ID."""
489
+ block_id = cap_id.replace("CAP-", "")
490
+ for block in config.functional_blocks:
491
+ if block.id == block_id:
492
+ for bdir in block.dirs:
493
+ for layer in config.layers:
494
+ if bdir in layer.dirs or any(
495
+ bdir.startswith(ld + "/") or ld.startswith(bdir + "/")
496
+ for ld in layer.dirs
497
+ ):
498
+ return layer.id
499
+ return None
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Relationship derivation
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ def _derive_relationships(
508
+ capabilities: list[Capability],
509
+ behaviors: list[Behavior],
510
+ components: list[Component],
511
+ interfaces: list[Interface],
512
+ constraints: list[Constraint],
513
+ layers: list[Layer],
514
+ config: ProjectConfig,
515
+ ) -> list[Relationship]:
516
+ """Derive all relationships between entities."""
517
+ relationships: list[Relationship] = []
518
+
519
+ # Build quick lookup sets
520
+ cap_ids = {c.id for c in capabilities}
521
+ layer_ids = {l.id for l in layers}
522
+
523
+ # realizes: behavior → capability of its F-block
524
+ for beh in behaviors:
525
+ f_block = ""
526
+ # Extract f_block from tags
527
+ for tag in beh.tags:
528
+ for block in config.functional_blocks:
529
+ if tag == block.id:
530
+ f_block = tag
531
+ break
532
+ if f_block:
533
+ break
534
+
535
+ if f_block:
536
+ cap_id = f"CAP-{f_block}"
537
+ if cap_id in cap_ids:
538
+ relationships.append(
539
+ Relationship(
540
+ type=RelationType.REALIZES,
541
+ from_id=beh.id,
542
+ to_id=cap_id,
543
+ description=f"{beh.name} realizes {cap_id}",
544
+ )
545
+ )
546
+
547
+ # realizes: component → capability of its F-block
548
+ for comp in components:
549
+ if comp.f_block:
550
+ cap_id = f"CAP-{comp.f_block}"
551
+ if cap_id in cap_ids:
552
+ relationships.append(
553
+ Relationship(
554
+ type=RelationType.REALIZES,
555
+ from_id=comp.id,
556
+ to_id=cap_id,
557
+ description=f"{comp.name} realizes {cap_id}",
558
+ )
559
+ )
560
+
561
+ # depends-on: from interface pairs (cross-F-block dependencies)
562
+ for iface in interfaces:
563
+ if iface.type == InterfaceType.INTERNAL and iface.provider and iface.consumer:
564
+ relationships.append(
565
+ Relationship(
566
+ type=RelationType.DEPENDS_ON,
567
+ from_id=iface.consumer,
568
+ to_id=iface.provider,
569
+ description=f"{iface.consumer} depends on {iface.provider}",
570
+ strength=Strength.MODERATE,
571
+ )
572
+ )
573
+
574
+ # depends-on: layer-to-layer from cross-layer interfaces (import-derived)
575
+ layer_dep_pairs: set[tuple[str, str]] = set()
576
+ for iface in interfaces:
577
+ if iface.type == InterfaceType.INTERNAL and iface.provider and iface.consumer:
578
+ consumer_layer = _cap_to_layer(iface.consumer, config)
579
+ provider_layer = _cap_to_layer(iface.provider, config)
580
+ if consumer_layer and provider_layer and consumer_layer != provider_layer:
581
+ layer_dep_pairs.add((consumer_layer, provider_layer))
582
+
583
+ for from_layer, to_layer in sorted(layer_dep_pairs):
584
+ relationships.append(
585
+ Relationship(
586
+ type=RelationType.DEPENDS_ON,
587
+ from_id=from_layer,
588
+ to_id=to_layer,
589
+ description=f"{from_layer} depends on {to_layer}",
590
+ strength=Strength.STRONG,
591
+ )
592
+ )
593
+
594
+ # exposes: capability → interface (where capability is provider)
595
+ for iface in interfaces:
596
+ if iface.provider and iface.provider in cap_ids:
597
+ relationships.append(
598
+ Relationship(
599
+ type=RelationType.EXPOSES,
600
+ from_id=iface.provider,
601
+ to_id=iface.id,
602
+ description=f"{iface.provider} exposes {iface.name}",
603
+ )
604
+ )
605
+
606
+ # consumes: capability → interface (where capability is consumer)
607
+ for iface in interfaces:
608
+ if iface.consumer and iface.consumer in cap_ids:
609
+ relationships.append(
610
+ Relationship(
611
+ type=RelationType.CONSUMES,
612
+ from_id=iface.consumer,
613
+ to_id=iface.id,
614
+ description=f"{iface.consumer} consumes {iface.name}",
615
+ )
616
+ )
617
+
618
+ # allocated-to: component → layer
619
+ for comp in components:
620
+ if comp.layer and comp.layer in layer_ids:
621
+ relationships.append(
622
+ Relationship(
623
+ type=RelationType.ALLOCATED_TO,
624
+ from_id=comp.id,
625
+ to_id=comp.layer,
626
+ description=f"{comp.name} allocated to {comp.layer}",
627
+ )
628
+ )
629
+
630
+ # constrained-by: all capabilities → technology constraints
631
+ tech_constraints = [
632
+ c for c in constraints if c.type == ConstraintType.TECHNOLOGY
633
+ ]
634
+ for cap in capabilities:
635
+ for con in tech_constraints:
636
+ relationships.append(
637
+ Relationship(
638
+ type=RelationType.CONSTRAINED_BY,
639
+ from_id=cap.id,
640
+ to_id=con.id,
641
+ description=f"{cap.name} constrained by {con.name}",
642
+ strength=Strength.WEAK,
643
+ )
644
+ )
645
+
646
+ return relationships