opencode-arch 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_arch/__init__.py +3 -0
- opencode_arch/artifacts/__init__.py +48 -0
- opencode_arch/artifacts/context.py +451 -0
- opencode_arch/artifacts/diagrams.py +451 -0
- opencode_arch/artifacts/selector.py +331 -0
- opencode_arch/artifacts/templates.py +444 -0
- opencode_arch/cli/__init__.py +1 -0
- opencode_arch/cli/bench.py +25 -0
- opencode_arch/cli/calibrate.py +208 -0
- opencode_arch/cli/confidence.py +66 -0
- opencode_arch/cli/docs.py +333 -0
- opencode_arch/cli/docs_validator.py +295 -0
- opencode_arch/cli/export_data.py +133 -0
- opencode_arch/cli/extract.py +93 -0
- opencode_arch/cli/gap_analyzer.py +107 -0
- opencode_arch/cli/generate.py +68 -0
- opencode_arch/cli/launch.py +264 -0
- opencode_arch/cli/main.py +360 -0
- opencode_arch/cli/metrics.py +186 -0
- opencode_arch/cli/prompts.py +20 -0
- opencode_arch/cli/regen_loop.py +1028 -0
- opencode_arch/context/__init__.py +29 -0
- opencode_arch/context/formatter.py +492 -0
- opencode_arch/context/pipeline_bridge.py +201 -0
- opencode_arch/extract/__init__.py +8 -0
- opencode_arch/extract/constraint_detector.py +398 -0
- opencode_arch/extract/from_artifacts.py +837 -0
- opencode_arch/extract/from_code.py +646 -0
- opencode_arch/extract/route_detector.py +400 -0
- opencode_arch/extract/table_parser.py +177 -0
- opencode_arch/learning/__init__.py +19 -0
- opencode_arch/learning/adapter.py +157 -0
- opencode_arch/learning/assessor.py +170 -0
- opencode_arch/learning/classifier.py +144 -0
- opencode_arch/learning/lessons.py +139 -0
- opencode_arch/learning/maintainer.py +281 -0
- opencode_arch/learning/patterns.py +51 -0
- opencode_arch/mcp/__init__.py +1 -0
- opencode_arch/mcp/__main__.py +8 -0
- opencode_arch/mcp/server.py +183 -0
- opencode_arch/mcp/tools/__init__.py +1 -0
- opencode_arch/mcp/tools/check.py +159 -0
- opencode_arch/mcp/tools/extract.py +107 -0
- opencode_arch/mcp/tools/feedback.py +65 -0
- opencode_arch/mcp/tools/generate.py +104 -0
- opencode_arch/mcp/tools/group.py +62 -0
- opencode_arch/mcp/tools/ingest.py +101 -0
- opencode_arch/mcp/tools/require.py +77 -0
- opencode_arch/mcp/tools/scan.py +53 -0
- opencode_arch/mcp/tools/slice.py +235 -0
- opencode_arch/mcp/tools/validate.py +59 -0
- opencode_arch/prompts/__init__.py +1 -0
- opencode_arch/prompts/regen.py +36 -0
- opencode_arch/runner/__init__.py +5 -0
- opencode_arch/runner/base.py +21 -0
- opencode_arch/runner/opencode.py +66 -0
- opencode_arch/telemetry/__init__.py +6 -0
- opencode_arch/telemetry/collector.py +40 -0
- opencode_arch/telemetry/recorder.py +12 -0
- opencode_arch/telemetry/store.py +537 -0
- opencode_arch-1.0.0.dist-info/METADATA +247 -0
- opencode_arch-1.0.0.dist-info/RECORD +65 -0
- opencode_arch-1.0.0.dist-info/WHEEL +4 -0
- opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|