rpr-cli 0.1.1__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 (97) hide show
  1. rpr/__init__.py +1 -0
  2. rpr/agent/__init__.py +29 -0
  3. rpr/agent/approval.py +19 -0
  4. rpr/agent/bootstrap.py +60 -0
  5. rpr/agent/client.py +115 -0
  6. rpr/agent/control.py +16 -0
  7. rpr/agent/mock.py +158 -0
  8. rpr/agent/runtime.py +291 -0
  9. rpr/agent/session.py +91 -0
  10. rpr/agent/tools/__init__.py +10 -0
  11. rpr/agent/tools/base.py +62 -0
  12. rpr/agent/tools/mutating.py +47 -0
  13. rpr/agent/tools/readonly.py +147 -0
  14. rpr/agent/tools/registry.py +30 -0
  15. rpr/application/__init__.py +1 -0
  16. rpr/application/catalog.py +427 -0
  17. rpr/application/chat_service.py +413 -0
  18. rpr/application/checks.py +49 -0
  19. rpr/application/cli_adapter.py +36 -0
  20. rpr/application/completer.py +163 -0
  21. rpr/application/conversation_service.py +92 -0
  22. rpr/application/prompt_service.py +85 -0
  23. rpr/application/selector.py +240 -0
  24. rpr/application/shell.py +543 -0
  25. rpr/checks/__init__.py +0 -0
  26. rpr/checks/base.py +13 -0
  27. rpr/checks/instructions.py +89 -0
  28. rpr/checks/packages.py +619 -0
  29. rpr/checks/workspace.py +170 -0
  30. rpr/cli.py +78 -0
  31. rpr/commands/__init__.py +0 -0
  32. rpr/commands/add.py +166 -0
  33. rpr/commands/chat.py +48 -0
  34. rpr/commands/check.py +53 -0
  35. rpr/commands/generate/__init__.py +0 -0
  36. rpr/commands/generate/api.py +228 -0
  37. rpr/commands/generate/domain.py +383 -0
  38. rpr/commands/generate/engine.py +148 -0
  39. rpr/commands/generate/storybook.py +442 -0
  40. rpr/commands/generate/ui.py +414 -0
  41. rpr/commands/init.py +822 -0
  42. rpr/commands/map.py +113 -0
  43. rpr/commands/settings.py +102 -0
  44. rpr/commands/sync.py +97 -0
  45. rpr/context.py +203 -0
  46. rpr/generators/__init__.py +0 -0
  47. rpr/generators/base.py +110 -0
  48. rpr/generators/claude.py +33 -0
  49. rpr/generators/copilot.py +36 -0
  50. rpr/generators/cursor.py +38 -0
  51. rpr/generators/gemini.py +33 -0
  52. rpr/map/__init__.py +0 -0
  53. rpr/map/architecture.py +495 -0
  54. rpr/map/chains.py +317 -0
  55. rpr/map/classifier.py +170 -0
  56. rpr/map/coverage.py +200 -0
  57. rpr/map/dependencies.py +243 -0
  58. rpr/map/extractor.py +223 -0
  59. rpr/map/graph.py +318 -0
  60. rpr/map/output.py +1030 -0
  61. rpr/map/responsibility.py +345 -0
  62. rpr/map/topology.py +327 -0
  63. rpr/map/walker.py +151 -0
  64. rpr/scaffolds/domain/base_entity.md +30 -0
  65. rpr/scaffolds/domain/base_repo.md +48 -0
  66. rpr/scaffolds/domain/container.md +76 -0
  67. rpr/scaffolds/domain/settings.md +57 -0
  68. rpr/scaffolds/instructions/all.instructions.md +50 -0
  69. rpr/scaffolds/instructions/api.instructions.md +42 -0
  70. rpr/scaffolds/instructions/domain.instructions.md +93 -0
  71. rpr/scaffolds/instructions/frontend.instructions.md +97 -0
  72. rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
  73. rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
  74. rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
  75. rpr/scaffolds/instructions/tooling.instructions.md +42 -0
  76. rpr/scaffolds/js_special_files/fetch.service.md +222 -0
  77. rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
  78. rpr/scaffolds/special_files/domain_container.md +76 -0
  79. rpr/scaffolds/special_files/domain_settings.md +57 -0
  80. rpr/scaffolds/special_files/dto_util.md +62 -0
  81. rpr/scaffolds/special_files/encrypted_column.md +98 -0
  82. rpr/scaffolds/special_files/mapper_util.md +159 -0
  83. rpr/scaffolds/special_files/partial_update.md +61 -0
  84. rpr/templates/__init__.py +0 -0
  85. rpr/templates/registry.py +81 -0
  86. rpr/ui/__init__.py +0 -0
  87. rpr/ui/console.py +32 -0
  88. rpr/ui/markdown.py +59 -0
  89. rpr/ui/prompt_session.py +430 -0
  90. rpr/ui/renderers.py +167 -0
  91. rpr/ui/theme.py +286 -0
  92. rpr/workspace.py +131 -0
  93. rpr_cli-0.1.1.dist-info/METADATA +201 -0
  94. rpr_cli-0.1.1.dist-info/RECORD +97 -0
  95. rpr_cli-0.1.1.dist-info/WHEEL +4 -0
  96. rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
  97. rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
rpr/map/output.py ADDED
@@ -0,0 +1,1030 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from rpr.map.architecture import ArchitectureAssessment
9
+ from rpr.map.chains import RepresentativeChain
10
+ from rpr.map.classifier import ClassifiedNode
11
+ from rpr.map.coverage import CoverageRecord
12
+ from rpr.map.dependencies import ExternalDependencySummary
13
+ from rpr.map.graph import DependencyGraph
14
+ from rpr.map.responsibility import ResponsibilitySummary
15
+ from rpr.map.topology import DirectoryTopology, focus_edges
16
+ from rpr.ui.console import console
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Constants
20
+ # ---------------------------------------------------------------------------
21
+
22
+ _LAYER_ORDER: list[str] = [
23
+ "entry_point",
24
+ "application",
25
+ "entity",
26
+ "dto",
27
+ "repository",
28
+ "infrastructure",
29
+ "ui_component",
30
+ "ui_util",
31
+ "unknown",
32
+ "test",
33
+ ]
34
+
35
+ # (fill, stroke) per layer for Mermaid subgraph styles
36
+ _LAYER_COLORS: dict[str, tuple[str, str]] = {
37
+ "entry_point": ("#D6EAF8", "#5B9BD5"),
38
+ "application": ("#D5F5E3", "#52BE80"),
39
+ "entity": ("#FEF9E7", "#F1C40F"),
40
+ "dto": ("#FDEBD0", "#E67E22"),
41
+ "repository": ("#E8DAEF", "#8E44AD"),
42
+ "infrastructure": ("#FADBD8", "#E74C3C"),
43
+ "ui_component": ("#D1F2EB", "#1ABC9C"),
44
+ "ui_util": ("#EBF5FB", "#3498DB"),
45
+ "unknown": ("#F2F3F4", "#95A5A6"),
46
+ }
47
+
48
+ # (fill, stroke) per topology role for the topology-based Mermaid diagram
49
+ _TOPOLOGY_COLORS: dict[str, tuple[str, str]] = {
50
+ "entry": ("#D6EAF8", "#5B9BD5"),
51
+ "core": ("#D5F5E3", "#52BE80"),
52
+ "leaf": ("#F2F3F4", "#95A5A6"),
53
+ "other": ("#FEF9E7", "#F1C40F"),
54
+ }
55
+
56
+ # Test layer is excluded from the diagram to keep it focused on production code
57
+ _DIAGRAM_SKIP_LAYERS: frozenset[str] = frozenset({"test"})
58
+ _DIAGRAM_TOP_N = 3 # max nodes shown per subgraph in default mode
59
+ _EDGE_LIMIT = 300 # max edge lines before truncation
60
+ _MODULE_EDGE_LIMIT = 50 # max module-level edges in the module dependency graph
61
+ _DRY_RUN_LINES = 30 # lines printed in --dry-run mode
62
+
63
+ # Limits for the three new enrichment sections
64
+ _EXT_DEPS_DIR_LIMIT = 20 # max directories shown in external-dependencies section
65
+ _RESP_DIR_LIMIT = 30 # max directories shown in responsibilities section
66
+ _COV_DIR_LIMIT = 30 # max directories shown in coverage section
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Internal helpers
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _sanitize_id(path_str: str) -> str:
75
+ """Convert a file path to a valid Mermaid node ID."""
76
+ return re.sub(r"[/.\-]", "_", path_str)
77
+
78
+
79
+ def _group_by_layer(nodes: list[ClassifiedNode]) -> dict[str, list[ClassifiedNode]]:
80
+ """Group nodes by layer, sorted by in_degree descending within each group."""
81
+ groups: dict[str, list[ClassifiedNode]] = defaultdict(list)
82
+ for node in nodes:
83
+ groups[node.layer].append(node)
84
+ return {
85
+ layer: sorted(members, key=lambda n: n.in_degree, reverse=True)
86
+ for layer, members in groups.items()
87
+ }
88
+
89
+
90
+ def _collect_languages(nodes: list[ClassifiedNode]) -> list[str]:
91
+ return sorted({node.file.language for node in nodes})
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Section renderers
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _render_header(
100
+ graph: DependencyGraph, nodes: list[ClassifiedNode], root: Path
101
+ ) -> str:
102
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
103
+ languages = ", ".join(_collect_languages(nodes)) or "none"
104
+ lines = [
105
+ "# Code Map",
106
+ "",
107
+ f"Generated: {ts}",
108
+ f"Root: {root}",
109
+ f"Files analyzed: {len(nodes)}",
110
+ f"Languages: {languages}",
111
+ f"Backend: {graph.backend}",
112
+ ]
113
+ return "\n".join(lines)
114
+
115
+
116
+ def _render_layer_table(groups: dict[str, list[ClassifiedNode]]) -> str:
117
+ rows = []
118
+ for layer in _LAYER_ORDER:
119
+ members = groups.get(layer)
120
+ if not members:
121
+ continue
122
+ top3 = ", ".join(m.file.rel_path.as_posix() for m in members[:3])
123
+ rows.append(f"| {layer} | {len(members)} | {top3} |")
124
+
125
+ if not rows:
126
+ return ""
127
+
128
+ lines = [
129
+ "## Layer Summary",
130
+ "",
131
+ "| Layer | File count | Entry files (top 3 by in-degree) |",
132
+ "|-------|-----------|----------------------------------|",
133
+ ] + rows
134
+ return "\n".join(lines)
135
+
136
+
137
+ def _render_entry_points(groups: dict[str, list[ClassifiedNode]]) -> str:
138
+ members = groups.get("entry_point")
139
+ if not members:
140
+ return ""
141
+ lines = ["## Entry Points", ""]
142
+ for node in members:
143
+ lines.append(
144
+ f"- {node.file.rel_path.as_posix()} (out-degree: {node.out_degree})"
145
+ )
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _build_mermaid(
150
+ groups: dict[str, list[ClassifiedNode]],
151
+ graph: DependencyGraph,
152
+ nodes: list[ClassifiedNode],
153
+ details: bool,
154
+ ) -> str:
155
+ # Only include layers that have files and are not skipped
156
+ active_layers = [
157
+ layer
158
+ for layer in _LAYER_ORDER
159
+ if layer not in _DIAGRAM_SKIP_LAYERS and groups.get(layer)
160
+ ]
161
+ if not active_layers:
162
+ return ""
163
+
164
+ # Build a lookup: posix rel_path → layer
165
+ node_layer: dict[str, str] = {n.file.rel_path.as_posix(): n.layer for n in nodes}
166
+
167
+ lines: list[str] = []
168
+
169
+ # --- Subgraph blocks ---
170
+ for layer in active_layers:
171
+ members = groups[layer]
172
+ shown = members if details else members[:_DIAGRAM_TOP_N]
173
+ sg_id = f"{layer}_sg"
174
+ lines.append(f' subgraph {sg_id}["{layer}"]')
175
+ for node in shown:
176
+ node_id = _sanitize_id(node.file.rel_path.as_posix())
177
+ label = node.file.rel_path.name
178
+ lines.append(f' {node_id}["{label}"]')
179
+ lines.append(" end")
180
+
181
+ lines.append("")
182
+
183
+ # --- Edges ---
184
+ if details:
185
+ # Individual node-to-node edges (skip test-layer nodes)
186
+ test_keys = {n.file.rel_path.as_posix() for n in nodes if n.layer == "test"}
187
+ seen_edges: set[tuple[str, str]] = set()
188
+ for src, deps in sorted(graph.edges.items()):
189
+ if src in test_keys or node_layer.get(src) in _DIAGRAM_SKIP_LAYERS:
190
+ continue
191
+ for dep in deps:
192
+ if dep in test_keys or node_layer.get(dep) in _DIAGRAM_SKIP_LAYERS:
193
+ continue
194
+ pair = (src, dep)
195
+ if pair not in seen_edges:
196
+ seen_edges.add(pair)
197
+ src_id = _sanitize_id(src)
198
+ dep_id = _sanitize_id(dep)
199
+ lines.append(f" {src_id} --> {dep_id}")
200
+ else:
201
+ # Subgraph-to-subgraph edges (deduplicated, no self-loops)
202
+ seen_sg_edges: set[tuple[str, str]] = set()
203
+ for src, deps in graph.edges.items():
204
+ src_layer = node_layer.get(src)
205
+ if not src_layer or src_layer in _DIAGRAM_SKIP_LAYERS:
206
+ continue
207
+ for dep in deps:
208
+ dep_layer = node_layer.get(dep)
209
+ if not dep_layer or dep_layer in _DIAGRAM_SKIP_LAYERS:
210
+ continue
211
+ if src_layer == dep_layer:
212
+ continue
213
+ pair = (src_layer, dep_layer)
214
+ if pair not in seen_sg_edges:
215
+ seen_sg_edges.add(pair)
216
+ lines.append(f" {src_layer}_sg --> {dep_layer}_sg")
217
+
218
+ lines.append("")
219
+
220
+ # --- Subgraph styles ---
221
+ for layer in active_layers:
222
+ if layer in _LAYER_COLORS:
223
+ fill, stroke = _LAYER_COLORS[layer]
224
+ lines.append(f" style {layer}_sg fill:{fill},stroke:{stroke},color:#000")
225
+
226
+ inner = "\n".join(lines)
227
+
228
+ diagram = (
229
+ "## Architecture Diagram\n\n"
230
+ "```mermaid\n"
231
+ "---\n"
232
+ "config:\n"
233
+ " layout: elk\n"
234
+ " theme: base\n"
235
+ "---\n"
236
+ "flowchart TB\n"
237
+ f"{inner}\n"
238
+ "```"
239
+ )
240
+ return diagram
241
+
242
+
243
+ def _render_layers_breakdown(groups: dict[str, list[ClassifiedNode]]) -> str:
244
+ sections: list[str] = ["## Layers Breakdown"]
245
+ for layer in _LAYER_ORDER:
246
+ members = groups.get(layer)
247
+ if not members:
248
+ continue
249
+ # Capitalise the layer label for the heading
250
+ heading = layer.replace("_", " ").title()
251
+ sections.append(f"\n### {heading}")
252
+ for node in members:
253
+ leaf_suffix = " (leaf)" if node.is_leaf else ""
254
+ path = node.file.rel_path.as_posix()
255
+ sections.append(
256
+ f"- {path} — {node.in_degree} dependents, {node.out_degree} dependencies{leaf_suffix}"
257
+ )
258
+ return "\n".join(sections)
259
+
260
+
261
+ def _render_edges(graph: DependencyGraph, nodes: list[ClassifiedNode]) -> str:
262
+ test_keys = {n.file.rel_path.as_posix() for n in nodes if n.layer == "test"}
263
+ leaf_keys = {n.file.rel_path.as_posix() for n in nodes if n.is_leaf}
264
+ skip_keys = test_keys | leaf_keys
265
+
266
+ edge_lines: list[str] = []
267
+ for src in sorted(graph.edges):
268
+ if src in skip_keys:
269
+ continue
270
+ deps = sorted(d for d in graph.edges[src] if d not in skip_keys)
271
+ if not deps:
272
+ continue
273
+ edge_lines.append(f"`{src}` \u2192 {', '.join(f'`{d}`' for d in deps)}")
274
+
275
+ if not edge_lines:
276
+ return ""
277
+
278
+ total = len(edge_lines)
279
+ truncation_note = ""
280
+ if total > _EDGE_LIMIT:
281
+ omitted = total - _EDGE_LIMIT
282
+ edge_lines = edge_lines[:_EDGE_LIMIT]
283
+ truncation_note = f"\n_(truncated \u2014 {omitted} edges omitted)_"
284
+
285
+ lines = ["## Dependency Edges", ""] + edge_lines
286
+ if truncation_note:
287
+ lines.append(truncation_note)
288
+ return "\n".join(lines)
289
+
290
+
291
+ def _render_cycles(graph: DependencyGraph) -> str:
292
+ if not graph.cycles:
293
+ return ""
294
+ lines = ["## \u26a0 Cycles Detected", ""]
295
+ for cycle in graph.cycles:
296
+ lines.append("- " + " \u2192 ".join(cycle))
297
+ return "\n".join(lines)
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Multi-resolution section renderers (17d)
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ def _render_architecture_summary(assessment: ArchitectureAssessment | None) -> str:
306
+ if assessment is None:
307
+ return ""
308
+
309
+ lines = ["## Architecture Summary", "", f"Verdict: **{assessment.verdict}**"]
310
+
311
+ if assessment.verdict_reasoning:
312
+ lines.append("")
313
+ lines.append("**Reasoning:**")
314
+ for r in assessment.verdict_reasoning:
315
+ lines.append(f"- {r}")
316
+
317
+ if assessment.entry_directories:
318
+ lines.append("\n### Entry Areas")
319
+ for sig in assessment.entry_directories:
320
+ lines.append(
321
+ f"- {sig.directory} ({sig.file_count} files, {sig.out_degree} outgoing, {sig.in_degree} incoming top-level deps)"
322
+ )
323
+
324
+ if assessment.core_directories:
325
+ lines.append("\n### Core Areas")
326
+ for sig in assessment.core_directories:
327
+ lines.append(
328
+ f"- {sig.directory} ({sig.file_count} files, {sig.out_degree} outgoing, {sig.in_degree} incoming top-level deps)"
329
+ )
330
+
331
+ if assessment.leaf_directories:
332
+ lines.append("\n### Leaf Areas")
333
+ for d in assessment.leaf_directories:
334
+ lines.append(f"- {d}")
335
+
336
+ if assessment.warnings:
337
+ lines.append("\n### Warnings")
338
+ for w in assessment.warnings:
339
+ lines.append(f"- {w}")
340
+
341
+ return "\n".join(lines)
342
+
343
+
344
+ def _render_top_level_graph(topology: DirectoryTopology | None) -> str:
345
+ if topology is None:
346
+ return ""
347
+ edges = topology.top_level_edges()
348
+ if not edges:
349
+ return ""
350
+ lines = ["## Top-Level Dependencies", ""]
351
+ for edge in edges:
352
+ src = edge.source or "(root)"
353
+ tgt = edge.target or "(root)"
354
+ lines.append(f"`{src}` -> `{tgt}` ({edge.support} file edges)")
355
+ return "\n".join(lines)
356
+
357
+
358
+ def _render_directory_tree(topology: DirectoryTopology | None) -> str:
359
+ if topology is None:
360
+ return ""
361
+
362
+ tree_lines: list[str] = []
363
+
364
+ def _walk(path: str, depth: int) -> None:
365
+ node = topology.nodes.get(path)
366
+ if node is None:
367
+ return
368
+ for child in node.children:
369
+ child_name = child[len(path) + 1 :] if path else child
370
+ tree_lines.append(" " * depth + child_name + "/")
371
+ _walk(child, depth + 1)
372
+
373
+ _walk("", 0)
374
+
375
+ if not tree_lines:
376
+ return ""
377
+
378
+ lines = ["## Directory Tree", "", "```"] + tree_lines + ["```"]
379
+ return "\n".join(lines)
380
+
381
+
382
+ def _render_module_graph(topology: DirectoryTopology | None) -> str:
383
+ if topology is None:
384
+ return ""
385
+ edges = topology.edges_at_depth(2)
386
+ if not edges:
387
+ return ""
388
+
389
+ lines = ["## Module Dependencies", ""]
390
+ total = len(edges)
391
+ truncation_note = ""
392
+ if total > _MODULE_EDGE_LIMIT:
393
+ omitted = total - _MODULE_EDGE_LIMIT
394
+ edges = edges[:_MODULE_EDGE_LIMIT]
395
+ truncation_note = f"\n_(truncated \u2014 {omitted} edges omitted)_"
396
+
397
+ for edge in edges:
398
+ src = edge.source or "(root)"
399
+ tgt = edge.target or "(root)"
400
+ lines.append(f"`{src}` -> `{tgt}` ({edge.support} file edges)")
401
+
402
+ if truncation_note:
403
+ lines.append(truncation_note)
404
+ return "\n".join(lines)
405
+
406
+
407
+ def _render_chains(chains: list[RepresentativeChain] | None) -> str:
408
+ if not chains:
409
+ return ""
410
+
411
+ lines = ["## Representative Coupling Paths", ""]
412
+ for i, chain in enumerate(chains, 1):
413
+ score_note = (
414
+ f" (coupling score: {chain.coupling_score})" if chain.coupling_score else ""
415
+ )
416
+ lines.append(f"### Path {i}: {chain.reason}{score_note}")
417
+ file_seq = " -> ".join(f"`{f}`" for f in chain.files)
418
+ lines.append(f"Files: {file_seq}")
419
+ if chain.directories_crossed:
420
+ dirs = " -> ".join(f"`{d}`" for d in chain.directories_crossed)
421
+ lines.append(f"Directories: {dirs}")
422
+ if chain.truncated and chain.truncation_note:
423
+ lines.append(f"Note: {chain.truncation_note}")
424
+ if chain.edge_examples:
425
+ lines.append("")
426
+ lines.append("Edge examples:")
427
+ for ex in chain.edge_examples:
428
+ lines.append(
429
+ f"- `{ex.source_directory}` -> `{ex.target_directory}`: `{ex.source_file}` -> `{ex.target_file}`"
430
+ )
431
+ lines.append("")
432
+
433
+ return "\n".join(lines).rstrip()
434
+
435
+
436
+ def _render_diagnostic_chains(chains: list[RepresentativeChain]) -> str:
437
+ """Render shortest-path chains for diagnostic validation in --details appendix."""
438
+ if not chains:
439
+ return ""
440
+ lines = [
441
+ "## Shortest-Path Chains (Diagnostic)",
442
+ "",
443
+ "_BFS traversal order — useful for validating graph traversal correctness._",
444
+ "",
445
+ ]
446
+ for i, chain in enumerate(chains, 1):
447
+ lines.append(f"### Chain {i}: {chain.reason}")
448
+ file_seq = " -> ".join(f"`{f}`" for f in chain.files)
449
+ lines.append(f"Files: {file_seq}")
450
+ if chain.truncated and chain.truncation_note:
451
+ lines.append(f"Note: {chain.truncation_note}")
452
+ lines.append("")
453
+ return "\n".join(lines).rstrip()
454
+
455
+
456
+ def _render_cycles_and_hotspots(
457
+ assessment: ArchitectureAssessment | None,
458
+ graph: DependencyGraph,
459
+ ) -> str:
460
+ has_cycles = bool(graph.cycles)
461
+ has_dir_cycles = assessment is not None and bool(assessment.directory_cycles)
462
+ has_hotspots = assessment is not None and bool(assessment.hotspots)
463
+
464
+ if not has_cycles and not has_dir_cycles and not has_hotspots:
465
+ return ""
466
+
467
+ lines = ["## Cycles and Hotspots"]
468
+
469
+ if has_dir_cycles and assessment is not None:
470
+ lines.append("\n### Directory Cycles")
471
+ for dc in assessment.directory_cycles:
472
+ lines.append("- " + " -> ".join(dc))
473
+
474
+ if has_cycles:
475
+ lines.append("\n### File Cycles")
476
+ if assessment and assessment.cycle_summaries:
477
+ for cs in assessment.cycle_summaries:
478
+ cycle_str = " -> ".join(cs.file_cycle)
479
+ lines.append(f"- {cycle_str}")
480
+ if cs.directory_cycle and len(set(cs.directory_cycle)) > 1:
481
+ dir_str = " -> ".join(cs.directory_cycle)
482
+ lines.append(f" Directories: {dir_str}")
483
+ else:
484
+ for cycle in graph.cycles:
485
+ lines.append("- " + " -> ".join(cycle))
486
+
487
+ if has_hotspots and assessment is not None:
488
+ lines.append("\n### Hotspots")
489
+ for h in assessment.hotspots:
490
+ lines.append(
491
+ f"- **{h.directory}**: {h.reason} (files={h.file_count}, in={h.in_degree}, out={h.out_degree})"
492
+ )
493
+ if h.top_fan_in_files:
494
+ top_fi = ", ".join(f"`{f}` ({n})" for f, n in h.top_fan_in_files[:3])
495
+ lines.append(f" - Highest fan-in: {top_fi}")
496
+ if h.top_fan_out_files:
497
+ top_fo = ", ".join(f"`{f}` ({n})" for f, n in h.top_fan_out_files[:3])
498
+ lines.append(f" - Highest fan-out: {top_fo}")
499
+
500
+ return "\n".join(lines)
501
+
502
+
503
+ def _render_topology_mermaid(
504
+ topology: DirectoryTopology,
505
+ assessment: ArchitectureAssessment,
506
+ ) -> str:
507
+ """Render the Architecture Diagram from directory topology.
508
+
509
+ 1. Single-child empty directories (like "rpr-parser" -> "src") are collapsed.
510
+ 2. Deep directories are rendered as nested subgraphs showing the real structure.
511
+ 3. Edges connect the subgraphs to limit lines.
512
+ """
513
+ # ── Step 1: dynamic forest roots from the root ("") ──
514
+ collapsed_map: dict[str, str] = {}
515
+ forest_roots: set[str] = set()
516
+
517
+ root_node = topology.nodes.get("")
518
+ if not root_node:
519
+ return ""
520
+
521
+ for child in root_node.children:
522
+ curr = child
523
+ while True:
524
+ node = topology.nodes.get(curr)
525
+ if not node:
526
+ break
527
+ if len(node.files) == 0 and len(node.children) == 1:
528
+ curr = node.children[0]
529
+ else:
530
+ break
531
+ forest_roots.add(curr)
532
+ collapsed_map[curr] = curr
533
+
534
+ rendered_dirs: set[str] = set()
535
+
536
+ def _collect_dirs(d: str) -> None:
537
+ rendered_dirs.add(d)
538
+ node = topology.nodes.get(d)
539
+ if node:
540
+ for c in node.children:
541
+ _collect_dirs(c)
542
+
543
+ for fr in forest_roots:
544
+ _collect_dirs(fr)
545
+
546
+ # ── Step 2: derive edges between rendered directories ──
547
+ active_edges: set[tuple[str, str]] = set()
548
+ for edge in topology.edges.values():
549
+ if (
550
+ edge.source in rendered_dirs
551
+ and edge.target in rendered_dirs
552
+ and edge.source != edge.target
553
+ ):
554
+ active_edges.add((edge.source, edge.target))
555
+
556
+ def _get_fr(d: str) -> str | None:
557
+ for fr in forest_roots:
558
+ if d == fr or d.startswith(fr + "/"):
559
+ return fr
560
+ return None
561
+
562
+ active_forest_roots: set[str] = set()
563
+ for src, tgt in active_edges:
564
+ fr_src = _get_fr(src)
565
+ fr_tgt = _get_fr(tgt)
566
+ if fr_src:
567
+ active_forest_roots.add(fr_src)
568
+ if fr_tgt:
569
+ active_forest_roots.add(fr_tgt)
570
+
571
+ if not active_forest_roots:
572
+ return ""
573
+
574
+ # ── Step 3: assign roles from assessment ──────────────────────────────────
575
+ entry_dirs = {sig.directory for sig in assessment.entry_directories}
576
+ core_dirs = {sig.directory for sig in assessment.core_directories}
577
+ leaf_dirs = set(assessment.leaf_directories)
578
+
579
+ def _role(fr: str) -> str:
580
+ if fr in entry_dirs:
581
+ return "entry"
582
+ if fr in core_dirs:
583
+ return "core"
584
+ if fr in leaf_dirs:
585
+ return "leaf"
586
+ for ed in entry_dirs:
587
+ if fr.startswith(ed) or ed.startswith(fr):
588
+ return "entry"
589
+ for cd in core_dirs:
590
+ if fr.startswith(cd) or cd.startswith(fr):
591
+ return "core"
592
+ for ld in leaf_dirs:
593
+ if fr.startswith(ld) or ld.startswith(fr):
594
+ return "leaf"
595
+ return "other"
596
+
597
+ lines: list[str] = []
598
+
599
+ # ── Step 4: subgraph blocks (recursive) ───────────────────────────────────
600
+ def _render_dir(d: str, label: str, depth: int) -> None:
601
+ node = topology.nodes.get(d)
602
+ if not node:
603
+ return
604
+
605
+ indent = " " * depth
606
+ sg_id = _sanitize_id(d)
607
+ lines.append(f'{indent}subgraph {sg_id}["{label}"]')
608
+
609
+ for c in node.children:
610
+ _render_dir(c, Path(c).name, depth + 1)
611
+
612
+ lines.append(f"{indent}end")
613
+
614
+ for fr in sorted(active_forest_roots):
615
+ label = collapsed_map[fr]
616
+ _render_dir(fr, label, 1)
617
+
618
+ lines.append("")
619
+
620
+ # ── Step 5: edges ─────────────────────────────────────────────────────────
621
+ for src, tgt in sorted(active_edges):
622
+ # Skip ancestor↔descendant edges — Mermaid does not support edges
623
+ # between a parent subgraph and its own nested children.
624
+ if tgt.startswith(src + "/") or src.startswith(tgt + "/"):
625
+ continue
626
+ lines.append(f" {_sanitize_id(src)} --> {_sanitize_id(tgt)}")
627
+
628
+ lines.append("")
629
+
630
+ # ── Step 6: style directives ──────────────────────────────────────────────
631
+ for fr in sorted(active_forest_roots):
632
+ fill, stroke = _TOPOLOGY_COLORS[_role(fr)]
633
+ lines.append(
634
+ f" style {_sanitize_id(fr)} fill:{fill},stroke:{stroke},color:#000"
635
+ )
636
+
637
+ inner = "\n".join(lines)
638
+ return (
639
+ "## Architecture Diagram\n\n"
640
+ "```mermaid\n"
641
+ "---\n"
642
+ "config:\n"
643
+ " layout: elk\n"
644
+ " theme: base\n"
645
+ "---\n"
646
+ "flowchart TB\n"
647
+ f"{inner}\n"
648
+ "```"
649
+ )
650
+
651
+
652
+ def _render_details_appendix(
653
+ groups: dict[str, list[ClassifiedNode]],
654
+ graph: DependencyGraph,
655
+ nodes: list[ClassifiedNode],
656
+ diagnostic_chains: list[RepresentativeChain] | None = None,
657
+ ) -> str:
658
+ sub_sections = [
659
+ _render_layer_table(groups),
660
+ _render_entry_points(groups),
661
+ _build_mermaid(groups, graph, nodes, details=True),
662
+ _render_layers_breakdown(groups),
663
+ _render_edges(graph, nodes),
664
+ ]
665
+ if diagnostic_chains:
666
+ sub_sections.append(_render_diagnostic_chains(diagnostic_chains))
667
+
668
+ parts: list[str] = []
669
+ for section in sub_sections:
670
+ if section.strip():
671
+ # Demote ## headings to ### inside the appendix
672
+ demoted = re.sub(r"^## ", "### ", section, flags=re.MULTILINE)
673
+ parts.append(demoted)
674
+
675
+ if not parts:
676
+ return ""
677
+
678
+ return "## Diagnostic Appendix\n\n" + "\n\n".join(parts)
679
+
680
+
681
+ _FOCUS_FILE_LIMIT = 50
682
+ _FOCUS_EDGE_LIMIT = 100
683
+
684
+
685
+ def _render_focus_drilldown(
686
+ focus: str,
687
+ topology: DirectoryTopology,
688
+ graph: DependencyGraph,
689
+ assessment: ArchitectureAssessment,
690
+ ) -> str:
691
+ """Render a drill-down section for a specific directory.
692
+
693
+ Shows a file-level Mermaid diagram for the focused directory with inbound
694
+ and outbound external directory nodes for context.
695
+ """
696
+ internal_edges, inbound_edges, outbound_edges = focus_edges(
697
+ topology, focus, graph.edges
698
+ )
699
+
700
+ focus_files = topology.files_in(focus)
701
+ if not focus_files:
702
+ return ""
703
+
704
+ entry_dirs = {sig.directory for sig in assessment.entry_directories}
705
+ core_dirs = {sig.directory for sig in assessment.core_directories}
706
+
707
+ def _ext_dir(file_path: str) -> str:
708
+ """Return the top-level directory for an external file."""
709
+ parts = file_path.split("/")
710
+ return parts[0] if len(parts) > 1 else ""
711
+
712
+ def _role_color(d: str) -> tuple[str, str]:
713
+ if d in entry_dirs:
714
+ return _TOPOLOGY_COLORS["entry"]
715
+ if d in core_dirs:
716
+ return _TOPOLOGY_COLORS["core"]
717
+ return _TOPOLOGY_COLORS["other"]
718
+
719
+ # Collect external directory names
720
+ ext_dirs_in: set[str] = {_ext_dir(src) for src, _ in inbound_edges if _ext_dir(src)}
721
+ ext_dirs_out: set[str] = {
722
+ _ext_dir(tgt) for _, tgt in outbound_edges if _ext_dir(tgt)
723
+ }
724
+
725
+ # Determine which focus files to show (cap at limit)
726
+ shown_files = focus_files[:_FOCUS_FILE_LIMIT]
727
+ truncated = len(focus_files) > _FOCUS_FILE_LIMIT
728
+ shown_set = set(shown_files)
729
+
730
+ lines: list[str] = []
731
+
732
+ # Group shown files by their immediate subdirectory under focus
733
+ sub_groups: dict[str, list[str]] = {}
734
+ for f in shown_files:
735
+ remainder = f[len(focus) + 1 :] if focus else f
736
+ parts = remainder.split("/")
737
+ sub = parts[0] if len(parts) > 1 else ""
738
+ sub_groups.setdefault(sub, []).append(f)
739
+
740
+ # Render the focused directory subgraph
741
+ focus_sg = _sanitize_id(focus) if focus else "root"
742
+ focus_label = focus.split("/")[-1] if focus else "root"
743
+ lines.append(f' subgraph {focus_sg}["{focus_label}"]')
744
+ for sub, files in sorted(sub_groups.items()):
745
+ if sub:
746
+ sub_path = f"{focus}/{sub}" if focus else sub
747
+ sub_sg = _sanitize_id(sub_path)
748
+ lines.append(f' subgraph {sub_sg}["{sub}"]')
749
+ for f in files:
750
+ fid = _sanitize_id(f)
751
+ lines.append(f' {fid}["{Path(f).name}"]')
752
+ lines.append(" end")
753
+ else:
754
+ for f in files:
755
+ fid = _sanitize_id(f)
756
+ lines.append(f' {fid}["{Path(f).name}"]')
757
+ lines.append(" end")
758
+ lines.append("")
759
+
760
+ # Render external directory nodes
761
+ for d in sorted(ext_dirs_in | ext_dirs_out):
762
+ d_id = _sanitize_id(d) + "_ext"
763
+ lines.append(f' {d_id}["{d}"]')
764
+ if ext_dirs_in or ext_dirs_out:
765
+ lines.append("")
766
+
767
+ # Render internal file-to-file edges (capped)
768
+ edge_count = 0
769
+ for src, tgt in internal_edges:
770
+ if src in shown_set and tgt in shown_set and edge_count < _FOCUS_EDGE_LIMIT:
771
+ lines.append(f" {_sanitize_id(src)} --> {_sanitize_id(tgt)}")
772
+ edge_count += 1
773
+
774
+ # Render inbound edges from external dirs to internal files
775
+ seen_inbound: set[tuple[str, str]] = set()
776
+ for ext_file, int_file in inbound_edges:
777
+ if int_file in shown_set:
778
+ ext_d = _ext_dir(ext_file)
779
+ key = (ext_d, int_file)
780
+ if key not in seen_inbound and edge_count < _FOCUS_EDGE_LIMIT:
781
+ lines.append(
782
+ f" {_sanitize_id(ext_d)}_ext --> {_sanitize_id(int_file)}"
783
+ )
784
+ seen_inbound.add(key)
785
+ edge_count += 1
786
+
787
+ # Render outbound edges from internal files to external dirs
788
+ seen_outbound: set[tuple[str, str]] = set()
789
+ for int_file, ext_file in outbound_edges:
790
+ if int_file in shown_set:
791
+ ext_d = _ext_dir(ext_file)
792
+ key = (int_file, ext_d)
793
+ if key not in seen_outbound and edge_count < _FOCUS_EDGE_LIMIT:
794
+ lines.append(
795
+ f" {_sanitize_id(int_file)} --> {_sanitize_id(ext_d)}_ext"
796
+ )
797
+ seen_outbound.add(key)
798
+ edge_count += 1
799
+
800
+ if lines:
801
+ lines.append("")
802
+
803
+ # Style directives
804
+ fill, stroke = _TOPOLOGY_COLORS["other"]
805
+ lines.append(f" style {focus_sg} fill:{fill},stroke:{stroke},color:#000")
806
+ for d in sorted(ext_dirs_in | ext_dirs_out):
807
+ f_color, s_color = _role_color(d)
808
+ lines.append(
809
+ f" style {_sanitize_id(d)}_ext fill:{f_color},stroke:{s_color},color:#000"
810
+ )
811
+
812
+ inner = "\n".join(lines)
813
+ mermaid = (
814
+ "```mermaid\n"
815
+ "---\n"
816
+ "config:\n"
817
+ " layout: elk\n"
818
+ " theme: base\n"
819
+ "---\n"
820
+ "flowchart TB\n"
821
+ f"{inner}\n"
822
+ "```"
823
+ )
824
+
825
+ label = focus if focus else "root"
826
+ header = f"## Focus: {label}"
827
+ if truncated:
828
+ header += f"\n\n> Showing {len(shown_files)} of {len(focus_files)} files."
829
+
830
+ result_parts = [header, mermaid]
831
+
832
+ if inbound_edges:
833
+ ext_in_dirs = sorted(
834
+ {_ext_dir(src) for src, _ in inbound_edges if _ext_dir(src)}
835
+ )
836
+ result_parts.append(
837
+ "**Inbound dependencies:** " + ", ".join(f"`{d}`" for d in ext_in_dirs)
838
+ )
839
+ if outbound_edges:
840
+ ext_out_dirs = sorted(
841
+ {_ext_dir(tgt) for _, tgt in outbound_edges if _ext_dir(tgt)}
842
+ )
843
+ result_parts.append(
844
+ "**Outbound dependencies:** " + ", ".join(f"`{d}`" for d in ext_out_dirs)
845
+ )
846
+
847
+ return "\n\n".join(result_parts)
848
+
849
+
850
+ # ---------------------------------------------------------------------------
851
+ # Enrichment section renderers
852
+ # ---------------------------------------------------------------------------
853
+
854
+
855
+ def _render_external_dependencies(
856
+ ext_deps: list[ExternalDependencySummary] | None,
857
+ ) -> str:
858
+ """Render the External Dependencies section, or return ``""`` when empty."""
859
+ if not ext_deps:
860
+ return ""
861
+ lines: list[str] = ["## External Dependencies\n"]
862
+ shown = ext_deps[:_EXT_DEPS_DIR_LIMIT]
863
+ for summary in shown:
864
+ label = f"`{summary.directory}`" if summary.directory else "*(root)*"
865
+ pkgs = ", ".join(f"`{p}`" for p in summary.packages)
866
+ lines.append(f"**{label}** — {pkgs}")
867
+ if len(ext_deps) > _EXT_DEPS_DIR_LIMIT:
868
+ lines.append(
869
+ f"*… and {len(ext_deps) - _EXT_DEPS_DIR_LIMIT} more directories not shown.*"
870
+ )
871
+ return "\n".join(lines)
872
+
873
+
874
+ def _render_responsibilities(
875
+ responsibilities: list[ResponsibilitySummary] | None,
876
+ ) -> str:
877
+ """Render the Module Responsibilities section, or return ``""`` when empty."""
878
+ if not responsibilities:
879
+ return ""
880
+ lines: list[str] = ["## Module Responsibilities\n"]
881
+ shown = responsibilities[:_RESP_DIR_LIMIT]
882
+ for summary in shown:
883
+ label = f"`{summary.directory}`" if summary.directory else "*(root)*"
884
+ lines.append(f"**{label}** — {summary.summary}")
885
+ if len(responsibilities) > _RESP_DIR_LIMIT:
886
+ lines.append(
887
+ f"*… and {len(responsibilities) - _RESP_DIR_LIMIT} more directories not shown.*"
888
+ )
889
+ return "\n".join(lines)
890
+
891
+
892
+ def _render_structural_coverage(
893
+ coverage: list[CoverageRecord] | None,
894
+ ) -> str:
895
+ """Render the Structural Test Coverage section, or return ``""`` when empty."""
896
+ if not coverage:
897
+ return ""
898
+
899
+ strong = [r for r in coverage if r.evidence == "strong"]
900
+ fallback = [r for r in coverage if r.evidence == "fallback"]
901
+ no_evidence = [r for r in coverage if r.evidence == "none"]
902
+
903
+ lines: list[str] = ["## Structural Test Coverage\n"]
904
+ lines.append(
905
+ f"**Summary:** {len(strong)} director{'y' if len(strong) == 1 else 'ies'} with "
906
+ f"strong import evidence, {len(fallback)} with name-based fallback evidence, "
907
+ f"{len(no_evidence)} with no test evidence detected.\n"
908
+ )
909
+
910
+ if strong:
911
+ lines.append("### Strong Coverage (import evidence)")
912
+ shown_strong = strong[:_COV_DIR_LIMIT]
913
+ for rec in shown_strong:
914
+ label = f"`{rec.module}`" if rec.module else "*(root)*"
915
+ test_labels = ", ".join(f"`{t}`" for t in rec.test_files[:3])
916
+ suffix = f" — tested by {test_labels}" if test_labels else ""
917
+ lines.append(f"- {label}{suffix}")
918
+ if len(strong) > _COV_DIR_LIMIT:
919
+ lines.append(f" *… and {len(strong) - _COV_DIR_LIMIT} more.*")
920
+
921
+ if fallback:
922
+ lines.append("\n### Fallback Coverage (name-based)")
923
+ shown_fallback = fallback[:_COV_DIR_LIMIT]
924
+ for rec in shown_fallback:
925
+ label = f"`{rec.module}`" if rec.module else "*(root)*"
926
+ test_labels = ", ".join(f"`{t}`" for t in rec.test_files[:3])
927
+ suffix = f" — inferred from {test_labels} *(name-based)*" if test_labels else " *(name-based)*"
928
+ lines.append(f"- {label}{suffix}")
929
+ if len(fallback) > _COV_DIR_LIMIT:
930
+ lines.append(f" *… and {len(fallback) - _COV_DIR_LIMIT} more.*")
931
+
932
+ if no_evidence:
933
+ lines.append("\n### No Test Evidence Detected")
934
+ shown_none = no_evidence[:_COV_DIR_LIMIT]
935
+ for rec in shown_none:
936
+ label = f"`{rec.module}`" if rec.module else "*(root)*"
937
+ lines.append(f"- {label}")
938
+ if len(no_evidence) > _COV_DIR_LIMIT:
939
+ lines.append(f" *… and {len(no_evidence) - _COV_DIR_LIMIT} more.*")
940
+
941
+ return "\n".join(lines)
942
+
943
+
944
+ # ---------------------------------------------------------------------------
945
+ # Public API
946
+ # ---------------------------------------------------------------------------
947
+
948
+
949
+ def write_map(
950
+ graph: DependencyGraph,
951
+ nodes: list[ClassifiedNode],
952
+ output: Path,
953
+ root: Path,
954
+ dry_run: bool = False,
955
+ details: bool = False,
956
+ topology: DirectoryTopology | None = None,
957
+ assessment: ArchitectureAssessment | None = None,
958
+ chains: list[RepresentativeChain] | None = None,
959
+ focus: str | None = None,
960
+ diagnostic_chains: list[RepresentativeChain] | None = None,
961
+ ext_deps: list[ExternalDependencySummary] | None = None,
962
+ responsibilities: list[ResponsibilitySummary] | None = None,
963
+ coverage: list[CoverageRecord] | None = None,
964
+ ) -> None:
965
+ """Render the dependency graph and classified nodes into a code-map Markdown file.
966
+
967
+ Args:
968
+ graph: Fully-resolved dependency graph.
969
+ nodes: Classified nodes produced by ``classify(graph)``.
970
+ output: Destination path for the Markdown file.
971
+ root: Project root (written into the header block).
972
+ dry_run: When True, print the first 30 lines to stdout instead of writing.
973
+ details: When True, append a diagnostic section with raw layer/file details.
974
+ topology: Directory topology from ``build_directory_topology``.
975
+ assessment: Architecture assessment from ``assess_architecture``.
976
+ chains: Coupling-ranked chains from ``build_representative_chains``.
977
+ focus: Repo-relative directory path to drill down into.
978
+ diagnostic_chains: BFS-ordered chains from ``build_diagnostic_chains`` for the
979
+ ``--details`` appendix.
980
+ ext_deps: Per-directory external dependency summaries.
981
+ responsibilities: Per-directory responsibility summaries.
982
+ coverage: Per-directory structural test-coverage records.
983
+ """
984
+ has_new_artifacts = topology is not None and assessment is not None
985
+
986
+ if has_new_artifacts:
987
+ groups = _group_by_layer(nodes)
988
+ sections: list[str] = [
989
+ _render_header(graph, nodes, root),
990
+ _render_architecture_summary(assessment),
991
+ _render_topology_mermaid(topology, assessment), # type: ignore[arg-type]
992
+ _render_top_level_graph(topology),
993
+ _render_directory_tree(topology),
994
+ _render_module_graph(topology),
995
+ _render_chains(chains),
996
+ ]
997
+ if focus is not None and topology is not None and assessment is not None:
998
+ focus_section = _render_focus_drilldown(focus, topology, graph, assessment)
999
+ if focus_section.strip():
1000
+ sections.append(focus_section)
1001
+ sections.append(_render_external_dependencies(ext_deps))
1002
+ sections.append(_render_responsibilities(responsibilities))
1003
+ sections.append(_render_structural_coverage(coverage))
1004
+ sections.append(_render_cycles_and_hotspots(assessment, graph))
1005
+ if details:
1006
+ appendix = _render_details_appendix(groups, graph, nodes, diagnostic_chains)
1007
+ if appendix.strip():
1008
+ sections.append(appendix)
1009
+ else:
1010
+ # Legacy layout — preserves backward compatibility for callers that omit new params
1011
+ groups = _group_by_layer(nodes)
1012
+ sections = [
1013
+ _render_header(graph, nodes, root),
1014
+ _render_layer_table(groups),
1015
+ _render_entry_points(groups),
1016
+ _build_mermaid(groups, graph, nodes, details),
1017
+ _render_layers_breakdown(groups),
1018
+ _render_edges(graph, nodes),
1019
+ _render_cycles(graph),
1020
+ ]
1021
+
1022
+ content = "\n\n".join(s for s in sections if s.strip())
1023
+
1024
+ if dry_run:
1025
+ preview = "\n".join(content.splitlines()[:_DRY_RUN_LINES])
1026
+ console.print(preview)
1027
+ return
1028
+
1029
+ output.parent.mkdir(parents=True, exist_ok=True)
1030
+ output.write_text(content, encoding="utf-8")