ai-forge-cli 2.0.1__tar.gz → 2.0.2__tar.gz

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 (59) hide show
  1. {ai_forge_cli-2.0.1/src/ai_forge_cli.egg-info → ai_forge_cli-2.0.2}/PKG-INFO +1 -1
  2. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/pyproject.toml +1 -1
  3. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2/src/ai_forge_cli.egg-info}/PKG-INFO +1 -1
  4. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/graph.py +271 -88
  5. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/LICENSE +0 -0
  6. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/README.md +0 -0
  7. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/setup.cfg +0 -0
  8. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/ai_forge_cli.egg-info/SOURCES.txt +0 -0
  9. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/ai_forge_cli.egg-info/dependency_links.txt +0 -0
  10. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/ai_forge_cli.egg-info/entry_points.txt +0 -0
  11. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/ai_forge_cli.egg-info/requires.txt +0 -0
  12. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/ai_forge_cli.egg-info/top_level.txt +0 -0
  13. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/__init__.py +0 -0
  14. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/__main__.py +0 -0
  15. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/bundle.py +0 -0
  16. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/__init__.py +0 -0
  17. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/base.py +0 -0
  18. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/context.py +0 -0
  19. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/find.py +0 -0
  20. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/init.py +0 -0
  21. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/inspect.py +0 -0
  22. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/list_cmd.py +0 -0
  23. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/update.py +0 -0
  24. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/commands/validate.py +0 -0
  25. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/common.py +0 -0
  26. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/forge.py +0 -0
  27. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/framework.yaml +0 -0
  28. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/index.py +0 -0
  29. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-build/SKILL.md +0 -0
  30. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-build/references/framework.md +0 -0
  31. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-build/references/framework.yaml +0 -0
  32. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-build/references/templates.yaml +0 -0
  33. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-cast/SKILL.md +0 -0
  34. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-cast/references/framework.md +0 -0
  35. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-cast/references/framework.yaml +0 -0
  36. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-cast/references/templates.yaml +0 -0
  37. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-design/SKILL.md +0 -0
  38. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-design/references/framework.md +0 -0
  39. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-design/references/framework.yaml +0 -0
  40. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-design/references/templates.yaml +0 -0
  41. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-review/SKILL.md +0 -0
  42. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-review/references/framework.md +0 -0
  43. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-review/references/framework.yaml +0 -0
  44. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-review/references/templates.yaml +0 -0
  45. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-spec/SKILL.md +0 -0
  46. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-spec/references/framework.md +0 -0
  47. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-spec/references/framework.yaml +0 -0
  48. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-spec/references/templates.yaml +0 -0
  49. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-validate/SKILL.md +0 -0
  50. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-validate/references/framework.md +0 -0
  51. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-validate/references/framework.yaml +0 -0
  52. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/skills/forge-validate/references/templates.yaml +0 -0
  53. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/src/cli/walker.py +0 -0
  54. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/tests/test_cli.py +0 -0
  55. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/tests/test_find.py +0 -0
  56. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/tests/test_index.py +0 -0
  57. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/tests/test_init.py +0 -0
  58. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/tests/test_update.py +0 -0
  59. {ai_forge_cli-2.0.1 → ai_forge_cli-2.0.2}/tests/test_walker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-forge-cli
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Context walker for the Forge spec system
5
5
  Requires-Python: >=3.11
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-forge-cli"
7
- version = "2.0.1"
7
+ version = "2.0.2"
8
8
  description = "Context walker for the Forge spec system"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = ["pyyaml>=6.0"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-forge-cli
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Context walker for the Forge spec system
5
5
  Requires-Python: >=3.11
6
6
  License-File: LICENSE
@@ -24,13 +24,16 @@ DESCRIPTION = (
24
24
 
25
25
  _MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
26
26
 
27
- # Minimum dot-path depth for node kinds we render as graph nodes.
28
- # Elements: conception.system.domain.module.element = 5 parts
29
- # Modules: conception.system.domain.module = 4 parts
30
- # Types, errors, etc. are NOT rendered as nodes.
27
+ # Dot-path depths for structural kinds.
28
+ # conception.system.domain.module.element = 5 parts
29
+ # conception.system.domain.module = 4 parts
31
30
  _ELEMENT_DEPTH = 5
32
31
  _MODULE_DEPTH = 4
33
32
 
33
+ # Kinds rendered as standalone nodes (not subgraph containers).
34
+ # Contracts are shown purely as labelled edges between modules, not as nodes.
35
+ _PERIPHERAL_KINDS = frozenset({"datastore"})
36
+
34
37
 
35
38
  # ===========================================================================
36
39
  # Argparse
@@ -51,6 +54,13 @@ def register(sub: argparse._SubParsersAction) -> None:
51
54
  "--scope", default=None, metavar="NODE_ID",
52
55
  help="Limit graph to the subgraph reachable from this node id.",
53
56
  )
57
+ p.add_argument(
58
+ "--show-deployments", action="store_true",
59
+ help=(
60
+ "Group modules inside their deployment environment instead of their "
61
+ "domain. Shows which environment each module is deployed to."
62
+ ),
63
+ )
54
64
  p.add_argument(
55
65
  "--no-open", action="store_true",
56
66
  help="Do not auto-open the HTML file after writing.",
@@ -76,14 +86,18 @@ def run(args: argparse.Namespace) -> int:
76
86
  return 1
77
87
  edges = _filter_to_scope(edges, args.scope)
78
88
 
79
- mermaid_text = _render_mermaid(idx, edges)
89
+ mermaid_text = _render_mermaid(idx, edges, show_deployments=args.show_deployments)
80
90
 
81
91
  if args.format == "mermaid":
82
92
  print(mermaid_text)
83
93
  return 0
84
94
 
85
- html = _render_html(mermaid_text, scope_label=args.scope,
86
- conception=idx.conception_name)
95
+ html = _render_html(
96
+ mermaid_text,
97
+ scope_label=args.scope,
98
+ conception=idx.conception_name,
99
+ show_deployments=args.show_deployments,
100
+ )
87
101
  out_path = Path(args.output).expanduser().resolve()
88
102
  try:
89
103
  out_path.write_text(html, encoding="utf-8")
@@ -132,7 +146,7 @@ def _collect_edges(idx: Any) -> list[Edge]:
132
146
  continue
133
147
  data = entry.data
134
148
 
135
- # --- Contracts: producer module → consumer modules ---
149
+ # Contracts: producer module → consumer modules
136
150
  if entry.kind == "contract":
137
151
  producer = data.get("producer")
138
152
  consumers = data.get("consumers") or []
@@ -142,19 +156,18 @@ def _collect_edges(idx: Any) -> list[Edge]:
142
156
  if isinstance(consumer, str) and consumer:
143
157
  edges.add(Edge(producer, consumer, label, "contract"))
144
158
 
145
- # --- Interactions: caller op → callee op (simplified to parent elements) ---
159
+ # Interactions: caller op → callee op (resolved to parent elements)
146
160
  elif entry.kind == "interaction":
147
161
  caller = data.get("caller")
148
162
  callee = data.get("callee")
149
163
  label = data.get("name") or entry_id.split(".")[-1]
150
164
  if caller and callee:
151
- # Simplify operation IDs to their parent element IDs
152
165
  src = _parent_element(str(caller), idx)
153
166
  dst = _parent_element(str(callee), idx)
154
167
  if src and dst and src != dst:
155
168
  edges.add(Edge(src, dst, label, "interaction"))
156
169
 
157
- # --- Element relationships ---
170
+ # Element relationships
158
171
  elif entry.kind == "element":
159
172
  for rel in (data.get("relationships") or []):
160
173
  if not isinstance(rel, dict):
@@ -164,7 +177,7 @@ def _collect_edges(idx: Any) -> list[Edge]:
164
177
  if isinstance(target, str) and target:
165
178
  edges.add(Edge(entry_id, target, label, "relationship"))
166
179
 
167
- # --- Datastores: consumer modules → datastore ---
180
+ # Datastores: consumer modules → datastore node
168
181
  elif entry.kind == "datastore":
169
182
  label = data.get("name") or entry_id.split(".")[-1]
170
183
  for consumer in (data.get("consumers") or []):
@@ -175,11 +188,7 @@ def _collect_edges(idx: Any) -> list[Edge]:
175
188
 
176
189
 
177
190
  def _parent_element(node_id: str, idx: Any) -> str | None:
178
- """Given an operation/property ID, return the parent element ID.
179
-
180
- Falls back gracefully: if the node itself is an element, return it.
181
- If the node isn't in the index at all, try truncating to element depth.
182
- """
191
+ """Given an operation/property ID, return the parent element ID."""
183
192
  entry = idx.get(node_id)
184
193
  if entry is None:
185
194
  parts = node_id.split(".")
@@ -191,7 +200,6 @@ def _parent_element(node_id: str, idx: Any) -> str | None:
191
200
  if entry.kind == "element":
192
201
  return node_id
193
202
  if entry.kind in ("operation", "property"):
194
- # Strip last segment to get the parent element
195
203
  parts = node_id.split(".")
196
204
  if len(parts) > _ELEMENT_DEPTH:
197
205
  return ".".join(parts[:_ELEMENT_DEPTH])
@@ -224,7 +232,7 @@ def _filter_to_scope(edges: list[Edge], scope_id: str) -> list[Edge]:
224
232
 
225
233
 
226
234
  # ===========================================================================
227
- # Mermaid rendering
235
+ # Mermaid rendering — helpers
228
236
  # ===========================================================================
229
237
 
230
238
  def _safe_id(node_id: str) -> str:
@@ -232,13 +240,11 @@ def _safe_id(node_id: str) -> str:
232
240
 
233
241
 
234
242
  def _node_shape(node_id: str, idx: Any) -> tuple[str, str]:
235
- """Return (open_bracket, close_bracket) for Mermaid node shape."""
236
243
  entry = idx.get(node_id)
237
244
  if entry is None:
238
245
  return ("[", "]")
239
246
  shapes = {
240
247
  "element": ("[", "]"),
241
- "module": ("([", "])"),
242
248
  "datastore": ("[(", ")]"),
243
249
  "contract": ("{", "}"),
244
250
  }
@@ -252,13 +258,17 @@ def _node_label(node_id: str, idx: Any) -> str:
252
258
  data = entry.data if isinstance(entry.data, dict) else {}
253
259
  name = data.get("name") or node_id.split(".")[-1]
254
260
  kind = entry.kind or ""
255
- return f"{name}<br/><small>{kind}</small>"
261
+ if kind == "datastore":
262
+ engine = data.get("engine") or data.get("kind") or ""
263
+ subtitle = f"{engine} · {kind}" if engine else kind
264
+ else:
265
+ subtitle = kind
266
+ return f"{name}<br/><small>{subtitle}</small>"
256
267
 
257
268
 
258
269
  def _domain_of(node_id: str, idx: Any) -> str | None:
259
- """Return the domain ID for grouping, or None."""
270
+ """Return the domain ID (3-part dot-path) for a module/element, or None."""
260
271
  parts = node_id.split(".")
261
- # conception.system.domain = 3 parts
262
272
  if len(parts) >= 3:
263
273
  candidate = ".".join(parts[:3])
264
274
  e = idx.get(candidate)
@@ -267,48 +277,173 @@ def _domain_of(node_id: str, idx: Any) -> str | None:
267
277
  return None
268
278
 
269
279
 
270
- def _render_mermaid(idx: Any, edges: list[Edge]) -> str:
271
- # Collect all node IDs referenced by edges
272
- all_nodes: set[str] = set()
273
- for e in edges:
274
- all_nodes.add(e.src)
275
- all_nodes.add(e.dst)
276
-
277
- # Group nodes by domain for subgraphs
278
- domain_nodes: dict[str, list[str]] = {}
279
- ungrouped: list[str] = []
280
- for node in sorted(all_nodes):
281
- domain = _domain_of(node, idx)
282
- if domain:
283
- domain_nodes.setdefault(domain, []).append(node)
280
+ def _build_module_elements(idx: Any) -> dict[str, list[str]]:
281
+ """Map module_id sorted list of element_ids that belong to it."""
282
+ result: dict[str, list[str]] = {}
283
+ for entry in idx.entries.values():
284
+ if entry.kind != "element":
285
+ continue
286
+ parts = entry.id.split(".")
287
+ if len(parts) == _ELEMENT_DEPTH:
288
+ module_id = ".".join(parts[:_MODULE_DEPTH])
289
+ result.setdefault(module_id, []).append(entry.id)
290
+ return {k: sorted(v) for k, v in result.items()}
291
+
292
+
293
+ def _build_env_modules(idx: Any) -> dict[str, list[str]]:
294
+ """Map environment_id → sorted list of module_ids deployed there."""
295
+ result: dict[str, list[str]] = {}
296
+ for entry in idx.entries.values():
297
+ if entry.kind != "deployment" or not isinstance(entry.data, dict):
298
+ continue
299
+ module = entry.data.get("module")
300
+ env = entry.data.get("environment")
301
+ if module and env:
302
+ result.setdefault(str(env), []).append(str(module))
303
+ return {k: sorted(set(v)) for k, v in result.items()}
304
+
305
+
306
+ def _emit_module_subgraph(
307
+ lines: list[str],
308
+ idx: Any,
309
+ module_id: str,
310
+ module_elements: dict[str, list[str]],
311
+ depth: int,
312
+ ) -> None:
313
+ """Emit a module as a Mermaid subgraph containing its element nodes."""
314
+ pad = " " * depth
315
+ entry = idx.get(module_id)
316
+ name = (
317
+ (entry.data.get("name") if entry and isinstance(entry.data, dict) else None)
318
+ or module_id.split(".")[-1]
319
+ )
320
+ lines.append(f'{pad}subgraph {_safe_id(module_id)}["{name}"]')
321
+ for elem_id in module_elements.get(module_id, []):
322
+ o, c = _node_shape(elem_id, idx)
323
+ label = _node_label(elem_id, idx)
324
+ lines.append(f'{pad} {_safe_id(elem_id)}{o}"{label}"{c}')
325
+ lines.append(f'{pad}end')
326
+
327
+
328
+ def _emit_peripheral_nodes(lines: list[str], idx: Any) -> None:
329
+ """Emit datastore and contract nodes (standalone, not inside any subgraph)."""
330
+ for entry in sorted(idx.entries.values(), key=lambda e: e.id):
331
+ if entry.kind not in _PERIPHERAL_KINDS:
332
+ continue
333
+ o, c = _node_shape(entry.id, idx)
334
+ label = _node_label(entry.id, idx)
335
+ lines.append(f' {_safe_id(entry.id)}{o}"{label}"{c}')
336
+
337
+
338
+ def _emit_domain_groups(
339
+ lines: list[str], idx: Any, module_elements: dict[str, list[str]]
340
+ ) -> None:
341
+ """Emit domain subgraphs → module subgraphs → element nodes."""
342
+ all_modules = sorted(e.id for e in idx.entries.values() if e.kind == "module")
343
+
344
+ domain_modules: dict[str, list[str]] = {}
345
+ ungrouped_modules: list[str] = []
346
+ for m in all_modules:
347
+ d = _domain_of(m, idx)
348
+ if d:
349
+ domain_modules.setdefault(d, []).append(m)
284
350
  else:
285
- ungrouped.append(node)
351
+ ungrouped_modules.append(m)
286
352
 
287
- lines: list[str] = ["graph LR"]
288
-
289
- # Emit domain subgraphs
290
- for domain_id in sorted(domain_nodes):
353
+ for domain_id in sorted(domain_modules):
291
354
  entry = idx.get(domain_id)
292
- domain_label = (
355
+ label = (
293
356
  (entry.data.get("name") if entry and isinstance(entry.data, dict) else None)
294
357
  or domain_id.split(".")[-1]
295
358
  )
296
- lines.append(f' subgraph {_safe_id(domain_id)}["{domain_label}"]')
297
- for node in sorted(set(domain_nodes[domain_id])):
298
- o, c = _node_shape(node, idx)
299
- label = _node_label(node, idx)
300
- lines.append(f' {_safe_id(node)}{o}"{label}"{c}')
359
+ lines.append(f' subgraph {_safe_id(domain_id)}["{label}"]')
360
+ for module_id in sorted(domain_modules[domain_id]):
361
+ _emit_module_subgraph(lines, idx, module_id, module_elements, depth=2)
301
362
  lines.append(" end")
302
363
 
303
- # Ungrouped nodes (datastores, contracts, etc. outside any domain)
304
- for node in sorted(set(ungrouped)):
305
- o, c = _node_shape(node, idx)
306
- label = _node_label(node, idx)
307
- lines.append(f' {_safe_id(node)}{o}"{label}"{c}')
364
+ for module_id in sorted(ungrouped_modules):
365
+ _emit_module_subgraph(lines, idx, module_id, module_elements, depth=1)
366
+
367
+ _emit_peripheral_nodes(lines, idx)
368
+
369
+
370
+ def _emit_env_groups(
371
+ lines: list[str], idx: Any, module_elements: dict[str, list[str]]
372
+ ) -> None:
373
+ """Emit environment subgraphs → module subgraphs → element nodes."""
374
+ env_modules = _build_env_modules(idx)
375
+ deployed: set[str] = {m for ms in env_modules.values() for m in ms}
376
+ all_modules = sorted(e.id for e in idx.entries.values() if e.kind == "module")
377
+
378
+ for env_id in sorted(env_modules):
379
+ entry = idx.get(env_id)
380
+ data = entry.data if entry and isinstance(entry.data, dict) else {}
381
+ name = data.get("name") or env_id.split(".")[-1]
382
+ kind_label = data.get("kind") or "environment"
383
+ lines.append(f' subgraph {_safe_id(env_id)}["{name} ({kind_label})"]')
384
+ for module_id in env_modules[env_id]:
385
+ _emit_module_subgraph(lines, idx, module_id, module_elements, depth=2)
386
+ lines.append(" end")
387
+
388
+ for module_id in sorted(m for m in all_modules if m not in deployed):
389
+ _emit_module_subgraph(lines, idx, module_id, module_elements, depth=1)
390
+
391
+ _emit_peripheral_nodes(lines, idx)
392
+
393
+
394
+ # ===========================================================================
395
+ # Mermaid rendering — styles
396
+ # ===========================================================================
397
+
398
+ # Colour palette (works over Mermaid dark theme).
399
+ _C_OUTER = "fill:#0d1829,stroke:#1e3a5e,color:#64748b" # domain / environment
400
+ _C_MODULE = "fill:#1a2d4a,stroke:#2563eb,color:#93c5fd" # module subgraph
401
+ _C_ELEMENT = "fill:#0f2235,stroke:#4a9ef8,color:#bfdbfe" # element node
402
+ _C_STORE = "fill:#0a1f14,stroke:#059669,color:#6ee7b7" # datastore node
403
+
404
+
405
+ def _emit_styles(lines: list[str], idx: Any, show_deployments: bool) -> None:
406
+ lines.append("")
407
+ lines.append(f" classDef elementNode {_C_ELEMENT}")
408
+ lines.append(f" classDef datastoreNode {_C_STORE}")
409
+
410
+ elem_ids = sorted(
411
+ _safe_id(e.id)
412
+ for e in idx.entries.values()
413
+ if e.kind == "element" and len(e.id.split(".")) == _ELEMENT_DEPTH
414
+ )
415
+ if elem_ids:
416
+ lines.append(f" class {','.join(elem_ids)} elementNode")
417
+
418
+ ds_ids = sorted(_safe_id(e.id) for e in idx.entries.values() if e.kind == "datastore")
419
+ if ds_ids:
420
+ lines.append(f" class {','.join(ds_ids)} datastoreNode")
421
+
422
+ outer_kind = "environment" if show_deployments else "domain"
423
+ for e in sorted(idx.entries.values(), key=lambda x: x.id):
424
+ if e.kind == outer_kind:
425
+ lines.append(f" style {_safe_id(e.id)} {_C_OUTER}")
426
+ for e in sorted(idx.entries.values(), key=lambda x: x.id):
427
+ if e.kind == "module":
428
+ lines.append(f" style {_safe_id(e.id)} {_C_MODULE}")
429
+
430
+
431
+ # ===========================================================================
432
+ # Mermaid rendering — main
433
+ # ===========================================================================
434
+
435
+ def _render_mermaid(idx: Any, edges: list[Edge], show_deployments: bool = False) -> str:
436
+ module_elements = _build_module_elements(idx)
437
+
438
+ lines: list[str] = ["graph LR"]
439
+
440
+ if show_deployments:
441
+ _emit_env_groups(lines, idx, module_elements)
442
+ else:
443
+ _emit_domain_groups(lines, idx, module_elements)
308
444
 
309
445
  lines.append("")
310
446
 
311
- # Emit edges — dashed for interactions, solid for everything else
312
447
  seen: set[tuple[str, str, str]] = set()
313
448
  for e in edges:
314
449
  src = _safe_id(e.src)
@@ -328,6 +463,8 @@ def _render_mermaid(idx: Any, edges: list[Edge]) -> str:
328
463
 
329
464
  lines.append(f" {src} {arrow} {dst}")
330
465
 
466
+ _emit_styles(lines, idx, show_deployments)
467
+
331
468
  return "\n".join(lines) + "\n"
332
469
 
333
470
 
@@ -335,25 +472,76 @@ def _render_mermaid(idx: Any, edges: list[Edge]) -> str:
335
472
  # HTML rendering
336
473
  # ===========================================================================
337
474
 
338
- def _render_html(mermaid_text: str, scope_label: str | None, conception: str) -> str:
475
+ def _render_html(
476
+ mermaid_text: str,
477
+ scope_label: str | None,
478
+ conception: str,
479
+ show_deployments: bool = False,
480
+ ) -> str:
339
481
  title = f"Forge Graph — {conception}"
340
482
  if scope_label:
341
483
  title += f" ({scope_label})"
342
484
 
343
485
  indented = textwrap.indent(mermaid_text, " ")
344
486
 
345
- # Key diagram: rendered by Mermaid so shapes/colours exactly match the main graph.
346
- key_diagram = textwrap.dedent("""\
347
- graph LR
348
- subgraph domain["Domain"]
349
- e["MyElement\\nelement"]
350
- m(["MyModule\\nmodule"])
351
- end
352
- d[("MyStore\\ndatastore")]
353
- e -->|"contract / relationship"| m
354
- m -->|"datastore name"| d
355
- e -. "interaction" .-> m
487
+ _key_styles = textwrap.dedent(f"""\
488
+ classDef elementNode {_C_ELEMENT}
489
+ classDef datastoreNode {_C_STORE}
490
+ class eA,eB elementNode
491
+ class ds datastoreNode
356
492
  """)
493
+
494
+ if show_deployments:
495
+ key_diagram = textwrap.dedent("""\
496
+ graph LR
497
+ subgraph envA["Production (production)"]
498
+ subgraph modA["ServiceA"]
499
+ eA["ElemA\\nelement"]
500
+ end
501
+ subgraph modB["ServiceB"]
502
+ eB["ElemB\\nelement"]
503
+ end
504
+ end
505
+ ds[("DataStore\\ndatastore")]
506
+ modA -->|"contract"| modB
507
+ eA -. "interaction" .-> eB
508
+ modB -->|"store name"| ds
509
+ """) + textwrap.indent(_key_styles, " ") + textwrap.dedent(f"""\
510
+ style envA {_C_OUTER}
511
+ style modA {_C_MODULE}
512
+ style modB {_C_MODULE}
513
+ """)
514
+ outer_label = "Environment (outer border)"
515
+ outer_desc = (
516
+ "A deployment target (production, staging, …). "
517
+ "The outer border groups the modules deployed there."
518
+ )
519
+ else:
520
+ key_diagram = textwrap.dedent("""\
521
+ graph LR
522
+ subgraph domain["Domain"]
523
+ subgraph modA["ServiceA"]
524
+ eA["ElemA\\nelement"]
525
+ end
526
+ subgraph modB["ServiceB"]
527
+ eB["ElemB\\nelement"]
528
+ end
529
+ end
530
+ ds[("DataStore\\ndatastore")]
531
+ modA -->|"contract"| modB
532
+ eA -. "interaction" .-> eB
533
+ modB -->|"store name"| ds
534
+ """) + textwrap.indent(_key_styles, " ") + textwrap.dedent(f"""\
535
+ style domain {_C_OUTER}
536
+ style modA {_C_MODULE}
537
+ style modB {_C_MODULE}
538
+ """)
539
+ outer_label = "Domain (outer border)"
540
+ outer_desc = (
541
+ "A bounded area of responsibility. "
542
+ "Groups related modules and their elements within a system."
543
+ )
544
+
357
545
  key_indented = textwrap.indent(key_diagram, " ")
358
546
 
359
547
  return f"""\
@@ -409,41 +597,38 @@ def _render_html(mermaid_text: str, scope_label: str | None, conception: str) ->
409
597
  background: #1a1f2e;
410
598
  border: 1px solid #2d3548;
411
599
  border-radius: 10px;
412
- padding: 1.25rem 1.75rem 1.5rem;
600
+ padding: 2rem 2.5rem 2.25rem;
413
601
  display: grid;
414
602
  grid-template-columns: auto 1fr;
415
- gap: 1.25rem 2.5rem;
603
+ gap: 1.75rem 3.5rem;
416
604
  align-items: start;
417
605
  }}
418
606
  .key h3 {{
419
607
  grid-column: 1 / -1;
420
- font-size: 0.7rem;
608
+ font-size: 0.75rem;
421
609
  font-weight: 700;
422
610
  text-transform: uppercase;
423
611
  letter-spacing: 0.1em;
424
612
  color: #64748b;
425
- margin-bottom: -0.25rem;
426
- }}
427
- .key-diagram {{
428
- /* let Mermaid render naturally; constrain width so it doesn't expand */
429
- max-width: 480px;
613
+ margin-bottom: -0.5rem;
430
614
  }}
615
+ .key-diagram {{ min-width: 640px; }}
431
616
  .key-descriptions {{
432
617
  display: flex;
433
618
  flex-direction: column;
434
- gap: 0.65rem;
435
- padding-top: 0.25rem;
619
+ gap: 1rem;
620
+ padding-top: 0.5rem;
436
621
  }}
437
622
  .key-row {{
438
623
  display: flex;
439
- gap: 0.5rem;
440
- font-size: 0.78rem;
441
- line-height: 1.4;
624
+ gap: 0.65rem;
625
+ font-size: 0.85rem;
626
+ line-height: 1.5;
442
627
  }}
443
628
  .key-row .bullet {{
444
629
  color: #475569;
445
630
  flex-shrink: 0;
446
- margin-top: 0.05rem;
631
+ margin-top: 0.1rem;
447
632
  }}
448
633
  .key-row strong {{ color: #cbd5e1; font-weight: 600; }}
449
634
  .key-row span {{ color: #64748b; }}
@@ -466,34 +651,32 @@ def _render_html(mermaid_text: str, scope_label: str | None, conception: str) ->
466
651
  <div class="key">
467
652
  <h3>Key</h3>
468
653
 
469
- <!-- left: a real Mermaid diagram so shapes match exactly -->
470
654
  <div class="key-diagram">
471
655
  <div class="mermaid">
472
656
  {key_indented}
473
657
  </div>
474
658
  </div>
475
659
 
476
- <!-- right: plain-English descriptions aligned to the diagram nodes/edges -->
477
660
  <div class="key-descriptions">
478
661
  <div class="key-row">
479
662
  <span class="bullet">▸</span>
480
- <div><strong>Element</strong> <span>— The core implementation unit (aggregate, entity, value object, service, or projection). What developers actually build and own.</span></div>
663
+ <div><strong>{outer_label}</strong> <span>— {outer_desc}</span></div>
481
664
  </div>
482
665
  <div class="key-row">
483
666
  <span class="bullet">▸</span>
484
- <div><strong>Module</strong> <span>— A deployable artifact (service, worker, function…) that packages one or more elements. Maps to a repo and a deployment unit.</span></div>
667
+ <div><strong>Module (inner border)</strong> <span>— A deployable artifact (service, worker, function…) that packages one or more elements. Maps to a repo and a deployment unit.</span></div>
485
668
  </div>
486
669
  <div class="key-row">
487
670
  <span class="bullet">▸</span>
488
- <div><strong>Datastore</strong> <span>— A database, cache, queue, or object store. Arrows show which modules consume it; the label is the datastore name.</span></div>
671
+ <div><strong>Element</strong> <span>— The core implementation unit (aggregate, entity, value object, service, or projection). What developers actually build and own.</span></div>
489
672
  </div>
490
673
  <div class="key-row">
491
674
  <span class="bullet">▸</span>
492
- <div><strong>Subgraph border</strong> <span>— A domain: a bounded area of responsibility that groups related modules and elements within a system.</span></div>
675
+ <div><strong>Datastore</strong> <span>— A database, cache, queue, or object store. The connecting arrow is labelled with the datastore name.</span></div>
493
676
  </div>
494
677
  <div class="key-row">
495
678
  <span class="bullet">▸</span>
496
- <div><strong>Solid arrow</strong> <span>— A contract dependency (producer → consumer, labelled with the contract name), a datastore access, or a structural element relationship.</span></div>
679
+ <div><strong>Solid arrow</strong> <span>— A contract reference (producer → consumer, labelled with the contract name) or a structural element relationship.</span></div>
497
680
  </div>
498
681
  <div class="key-row">
499
682
  <span class="bullet">▸</span>
File without changes
File without changes
File without changes