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.
- rpr/__init__.py +1 -0
- rpr/agent/__init__.py +29 -0
- rpr/agent/approval.py +19 -0
- rpr/agent/bootstrap.py +60 -0
- rpr/agent/client.py +115 -0
- rpr/agent/control.py +16 -0
- rpr/agent/mock.py +158 -0
- rpr/agent/runtime.py +291 -0
- rpr/agent/session.py +91 -0
- rpr/agent/tools/__init__.py +10 -0
- rpr/agent/tools/base.py +62 -0
- rpr/agent/tools/mutating.py +47 -0
- rpr/agent/tools/readonly.py +147 -0
- rpr/agent/tools/registry.py +30 -0
- rpr/application/__init__.py +1 -0
- rpr/application/catalog.py +427 -0
- rpr/application/chat_service.py +413 -0
- rpr/application/checks.py +49 -0
- rpr/application/cli_adapter.py +36 -0
- rpr/application/completer.py +163 -0
- rpr/application/conversation_service.py +92 -0
- rpr/application/prompt_service.py +85 -0
- rpr/application/selector.py +240 -0
- rpr/application/shell.py +543 -0
- rpr/checks/__init__.py +0 -0
- rpr/checks/base.py +13 -0
- rpr/checks/instructions.py +89 -0
- rpr/checks/packages.py +619 -0
- rpr/checks/workspace.py +170 -0
- rpr/cli.py +78 -0
- rpr/commands/__init__.py +0 -0
- rpr/commands/add.py +166 -0
- rpr/commands/chat.py +48 -0
- rpr/commands/check.py +53 -0
- rpr/commands/generate/__init__.py +0 -0
- rpr/commands/generate/api.py +228 -0
- rpr/commands/generate/domain.py +383 -0
- rpr/commands/generate/engine.py +148 -0
- rpr/commands/generate/storybook.py +442 -0
- rpr/commands/generate/ui.py +414 -0
- rpr/commands/init.py +822 -0
- rpr/commands/map.py +113 -0
- rpr/commands/settings.py +102 -0
- rpr/commands/sync.py +97 -0
- rpr/context.py +203 -0
- rpr/generators/__init__.py +0 -0
- rpr/generators/base.py +110 -0
- rpr/generators/claude.py +33 -0
- rpr/generators/copilot.py +36 -0
- rpr/generators/cursor.py +38 -0
- rpr/generators/gemini.py +33 -0
- rpr/map/__init__.py +0 -0
- rpr/map/architecture.py +495 -0
- rpr/map/chains.py +317 -0
- rpr/map/classifier.py +170 -0
- rpr/map/coverage.py +200 -0
- rpr/map/dependencies.py +243 -0
- rpr/map/extractor.py +223 -0
- rpr/map/graph.py +318 -0
- rpr/map/output.py +1030 -0
- rpr/map/responsibility.py +345 -0
- rpr/map/topology.py +327 -0
- rpr/map/walker.py +151 -0
- rpr/scaffolds/domain/base_entity.md +30 -0
- rpr/scaffolds/domain/base_repo.md +48 -0
- rpr/scaffolds/domain/container.md +76 -0
- rpr/scaffolds/domain/settings.md +57 -0
- rpr/scaffolds/instructions/all.instructions.md +50 -0
- rpr/scaffolds/instructions/api.instructions.md +42 -0
- rpr/scaffolds/instructions/domain.instructions.md +93 -0
- rpr/scaffolds/instructions/frontend.instructions.md +97 -0
- rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
- rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
- rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
- rpr/scaffolds/instructions/tooling.instructions.md +42 -0
- rpr/scaffolds/js_special_files/fetch.service.md +222 -0
- rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
- rpr/scaffolds/special_files/domain_container.md +76 -0
- rpr/scaffolds/special_files/domain_settings.md +57 -0
- rpr/scaffolds/special_files/dto_util.md +62 -0
- rpr/scaffolds/special_files/encrypted_column.md +98 -0
- rpr/scaffolds/special_files/mapper_util.md +159 -0
- rpr/scaffolds/special_files/partial_update.md +61 -0
- rpr/templates/__init__.py +0 -0
- rpr/templates/registry.py +81 -0
- rpr/ui/__init__.py +0 -0
- rpr/ui/console.py +32 -0
- rpr/ui/markdown.py +59 -0
- rpr/ui/prompt_session.py +430 -0
- rpr/ui/renderers.py +167 -0
- rpr/ui/theme.py +286 -0
- rpr/workspace.py +131 -0
- rpr_cli-0.1.1.dist-info/METADATA +201 -0
- rpr_cli-0.1.1.dist-info/RECORD +97 -0
- rpr_cli-0.1.1.dist-info/WHEEL +4 -0
- rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
- 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")
|