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