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
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rpr.context import RunContext
6
+ from rpr.generators.base import Rule, interpolate
7
+
8
+ HEADER = "<!-- Generated by rpr sync -- do not edit manually -->\n\n"
9
+
10
+
11
+ def generate_cursor_rules(
12
+ rules: list[Rule],
13
+ project_dir: Path,
14
+ variables: dict[str, str],
15
+ ctx: RunContext,
16
+ ) -> None:
17
+ rules_dir = project_dir / ".cursor" / "rules"
18
+ ctx.ensure_dir(rules_dir)
19
+
20
+ for rule in rules:
21
+ content = interpolate(rule.content, variables)
22
+ globs = rule.apply_to
23
+
24
+ mdc = f"""---
25
+ description: {rule.description}
26
+ globs: {globs}
27
+ alwaysApply: {"true" if globs == "**" else "false"}
28
+ ---
29
+
30
+ {HEADER}{content}
31
+ """
32
+ ctx.write_file(
33
+ rules_dir / f"{rule.id}.mdc",
34
+ mdc,
35
+ preview_markdown=True,
36
+ markdown_title=f"Cursor Rule Preview: {rule.id}",
37
+ markdown_max_lines=80,
38
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from rpr.context import RunContext
6
+ from rpr.generators.base import Rule, interpolate
7
+
8
+ HEADER = "<!-- Generated by rpr sync -- do not edit manually -->\n\n"
9
+
10
+
11
+ def generate_gemini_rules(
12
+ rules: list[Rule],
13
+ project_dir: Path,
14
+ variables: dict[str, str],
15
+ ctx: RunContext,
16
+ ) -> None:
17
+ sections: list[str] = [HEADER.strip()]
18
+
19
+ for rule in rules:
20
+ content = interpolate(rule.content, variables)
21
+ section_header = (
22
+ f"## {rule.description}" if rule.description else f"## {rule.id}"
23
+ )
24
+ sections.append(f"{section_header}\n\n{content}")
25
+
26
+ full_content = "\n\n---\n\n".join(sections) + "\n"
27
+ ctx.write_file(
28
+ project_dir / "GEMINI.md",
29
+ full_content,
30
+ preview_markdown=True,
31
+ markdown_title="Gemini Instructions Preview",
32
+ markdown_max_lines=80,
33
+ )
rpr/map/__init__.py ADDED
File without changes
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from rpr.map.graph import DependencyGraph
7
+ from rpr.map.topology import DirectoryEdge, DirectoryTopology
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Tunable thresholds
11
+ # ---------------------------------------------------------------------------
12
+
13
+ _LARGE_DIR_FACTOR: float = 2.5
14
+ _HIGH_DEGREE_FACTOR: float = 2.5
15
+ _CYCLE_FILE_FRACTION: float = 0.15
16
+ _MIN_ONION_DIRS: int = 2
17
+ _WRAPPER_NAMES: frozenset[str] = frozenset({"src", "lib", "packages", "apps", "pkg"})
18
+ _TOP_HOTSPOT_FILES: int = 5 # max file-level bottlenecks reported per hotspot
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Value objects
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ArchitectureSignal:
28
+ """Evidence supporting the classification of a directory as entry or core."""
29
+
30
+ directory: str
31
+ in_degree: int
32
+ out_degree: int
33
+ file_count: int
34
+ note: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CycleSummary:
39
+ """A file-level import cycle expressed alongside its directory-level projection."""
40
+
41
+ file_cycle: tuple[str, ...]
42
+ directory_cycle: tuple[str, ...]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Hotspot:
47
+ """A directory flagged as potentially problematic."""
48
+
49
+ directory: str
50
+ reason: str # space-separated subset of: "large", "highly_connected", "mixed_role"
51
+ file_count: int
52
+ in_degree: int
53
+ out_degree: int
54
+ top_fan_in_files: tuple[tuple[str, int], ...] = () # (file_path, fan_in_count)
55
+ top_fan_out_files: tuple[tuple[str, int], ...] = () # (file_path, fan_out_count)
56
+
57
+
58
+ @dataclass
59
+ class ArchitectureAssessment:
60
+ """Agent-readable interpretation of a repository's architectural structure."""
61
+
62
+ entry_files: list[str]
63
+ entry_directories: list[ArchitectureSignal]
64
+ core_directories: list[ArchitectureSignal]
65
+ leaf_files: list[str]
66
+ leaf_directories: list[str]
67
+ cycle_summaries: list[CycleSummary]
68
+ directory_cycles: list[tuple[str, ...]]
69
+ hotspots: list[Hotspot]
70
+ warnings: list[str]
71
+ verdict: Literal["likely_onion", "layered_but_mixed", "unclear"]
72
+ verdict_reasoning: list[str] # conditions that led to this verdict
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Private helpers
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def _posix_parent(path: str) -> str:
81
+ idx = path.rfind("/")
82
+ return path[:idx] if idx != -1 else ""
83
+
84
+
85
+ def _cycle_to_dir_projection(file_cycle: list[str]) -> tuple[str, ...]:
86
+ """Project a file-level cycle onto its parent directories.
87
+
88
+ Adjacent duplicates are collapsed so that intra-directory hops do not
89
+ produce repeated directory entries in the summary.
90
+ """
91
+ dirs: list[str] = []
92
+ for f in file_cycle:
93
+ d = _posix_parent(f)
94
+ if not dirs or dirs[-1] != d:
95
+ dirs.append(d)
96
+ return tuple(dirs)
97
+
98
+
99
+ def _detect_directory_cycles(adj: dict[str, list[str]]) -> list[list[str]]:
100
+ """Iterative DFS cycle detection on a directory adjacency dict."""
101
+ WHITE, GRAY, BLACK = 0, 1, 2
102
+ all_nodes: set[str] = set(adj.keys())
103
+ for deps in adj.values():
104
+ all_nodes.update(deps)
105
+ color: dict[str, int] = {n: WHITE for n in all_nodes}
106
+ found: list[list[str]] = []
107
+ seen: set[tuple[str, ...]] = set()
108
+
109
+ for start in sorted(all_nodes):
110
+ if color[start] != WHITE:
111
+ continue
112
+ path: list[str] = [start]
113
+ stack: list[tuple[str, int, list[str]]] = [(start, 0, adj.get(start, []))]
114
+ color[start] = GRAY
115
+ while stack:
116
+ node, idx, nbrs = stack[-1]
117
+ if idx < len(nbrs):
118
+ stack[-1] = (node, idx + 1, nbrs)
119
+ nb = nbrs[idx]
120
+ if color.get(nb, WHITE) == GRAY:
121
+ ci = path.index(nb)
122
+ cycle = path[ci:] + [nb]
123
+ canonical = _canonical_dir_cycle(cycle)
124
+ if canonical not in seen:
125
+ seen.add(canonical)
126
+ found.append(cycle)
127
+ elif color.get(nb, WHITE) == WHITE:
128
+ color[nb] = GRAY
129
+ path.append(nb)
130
+ stack.append((nb, 0, adj.get(nb, [])))
131
+ else:
132
+ stack.pop()
133
+ path.pop()
134
+ color[node] = BLACK
135
+ return found
136
+
137
+
138
+ def _canonical_dir_cycle(cycle: list[str]) -> tuple[str, ...]:
139
+ body = cycle[:-1]
140
+ if not body:
141
+ return ()
142
+ min_idx = body.index(min(body))
143
+ return tuple(body[min_idx:] + body[:min_idx])
144
+
145
+
146
+ def _resolve_top_dirs(raw_top_dirs: set[str], topology: DirectoryTopology) -> set[str]:
147
+ """Unwrap wrapper directories one level deep.
148
+
149
+ A depth-1 directory whose name is in ``_WRAPPER_NAMES`` and has child
150
+ directories is replaced by its immediate children. All other depth-1
151
+ directories are kept as-is.
152
+ """
153
+ resolved: set[str] = set()
154
+ for d in raw_top_dirs:
155
+ node = topology.nodes.get(d)
156
+ if d in _WRAPPER_NAMES and node and node.children:
157
+ resolved.update(node.children)
158
+ else:
159
+ resolved.add(d)
160
+ return resolved
161
+
162
+
163
+ def _map_to_effective_dir(directory: str, top_dirs: set[str]) -> str | None:
164
+ """Map a full-depth directory path to its effective top-level directory.
165
+
166
+ Returns the element of *top_dirs* that *directory* equals or is a
167
+ descendant of, or ``None`` if no match is found.
168
+ """
169
+ if directory in top_dirs:
170
+ return directory
171
+ for td in top_dirs:
172
+ if directory.startswith(td + "/"):
173
+ return td
174
+ return None
175
+
176
+
177
+ def _compute_effective_edges(
178
+ topology: DirectoryTopology, top_dirs: set[str]
179
+ ) -> list[DirectoryEdge]:
180
+ """Compute edges between effective top-level directories.
181
+
182
+ Each full-depth edge is mapped through ``_map_to_effective_dir``.
183
+ Self-loops (both endpoints resolve to the same effective dir) are excluded.
184
+ Representatives and support are merged across all contributing edges.
185
+ """
186
+ accumulator: dict[tuple[str, str], list[tuple[str, str]]] = {}
187
+ for edge in topology.edges.values():
188
+ src = _map_to_effective_dir(edge.source, top_dirs)
189
+ tgt = _map_to_effective_dir(edge.target, top_dirs)
190
+ if src is None or tgt is None or src == tgt:
191
+ continue
192
+ bucket = accumulator.setdefault((src, tgt), [])
193
+ bucket.extend(edge.representatives)
194
+
195
+ result: list[DirectoryEdge] = []
196
+ for (src, tgt), pairs in sorted(accumulator.items()):
197
+ deduped = sorted(set(pairs))
198
+ result.append(
199
+ DirectoryEdge(
200
+ source=src,
201
+ target=tgt,
202
+ support=len(deduped),
203
+ representatives=tuple(deduped),
204
+ )
205
+ )
206
+ return result
207
+
208
+
209
+ def _top_files_in_dir(
210
+ directory: str,
211
+ all_files: set[str],
212
+ metric: dict[str, int],
213
+ top_n: int,
214
+ ) -> tuple[tuple[str, int], ...]:
215
+ """Return the top_n files within *directory* ranked by *metric* descending.
216
+
217
+ Only includes files with a non-zero metric value.
218
+ """
219
+ prefix = directory + "/"
220
+ dir_files = [f for f in all_files if f == directory or f.startswith(prefix)]
221
+ ranked = sorted(dir_files, key=lambda f: -metric.get(f, 0))
222
+ return tuple((f, metric.get(f, 0)) for f in ranked[:top_n] if metric.get(f, 0) > 0)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Public entry point
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def assess_architecture(
231
+ graph: DependencyGraph, topology: DirectoryTopology
232
+ ) -> ArchitectureAssessment:
233
+ """Produce an ArchitectureAssessment from a file-level graph and directory topology.
234
+
235
+ The assessment is conservative: a ``likely_onion`` verdict is only emitted
236
+ when dependency flow is predominantly inward and the derived top-level
237
+ directory graph is acyclic with no high file-level cycle density.
238
+ """
239
+ # ── 1. File-level degrees ────────────────────────────────────────────────
240
+ all_files = {str(n.rel_path).replace("\\", "/") for n in graph.nodes}
241
+ file_out: dict[str, int] = {f: len(graph.edges.get(f, [])) for f in all_files}
242
+ file_in: dict[str, int] = {
243
+ f: len(graph.reverse_edges.get(f, [])) for f in all_files
244
+ }
245
+
246
+ # ── 2. Entry files (no importer, has outgoing deps) ──────────────────────
247
+ entry_files = sorted(
248
+ f for f in all_files if file_in.get(f, 0) == 0 and file_out.get(f, 0) > 0
249
+ )
250
+
251
+ # ── 3. Leaf files (no outgoing deps to tracked files) ────────────────────
252
+ leaf_files = sorted(f for f in all_files if file_out.get(f, 0) == 0)
253
+
254
+ # ── 4. Top-level directory degree computation ─────────────────────────────
255
+ # Depth-1 directories are the initial candidates. Wrapper directories
256
+ # (e.g. "src", "packages") that contain sub-directories are unwrapped
257
+ # one level so the analysis reflects the real architectural components.
258
+ raw_top_dirs: set[str] = {d for d in topology.nodes if d and "/" not in d}
259
+ top_dirs = _resolve_top_dirs(raw_top_dirs, topology)
260
+ top_edges = _compute_effective_edges(topology, top_dirs)
261
+
262
+ dir_out_targets: dict[str, set[str]] = {d: set() for d in top_dirs}
263
+ dir_in_sources: dict[str, set[str]] = {d: set() for d in top_dirs}
264
+
265
+ for edge in top_edges:
266
+ if edge.source in top_dirs:
267
+ dir_out_targets[edge.source].add(edge.target)
268
+ if edge.target in top_dirs:
269
+ dir_in_sources[edge.target].add(edge.source)
270
+
271
+ dir_out_deg: dict[str, int] = {d: len(s) for d, s in dir_out_targets.items()}
272
+ dir_in_deg: dict[str, int] = {d: len(s) for d, s in dir_in_sources.items()}
273
+ # Use recursive file count so parent dirs reflect their full subtree.
274
+ dir_file_count: dict[str, int] = {d: len(topology.files_in(d)) for d in top_dirs}
275
+
276
+ # ── 5. Entry directories (in=0, out>0) ───────────────────────────────────
277
+ entry_directories: list[ArchitectureSignal] = [
278
+ ArchitectureSignal(
279
+ directory=d,
280
+ in_degree=dir_in_deg[d],
281
+ out_degree=dir_out_deg[d],
282
+ file_count=dir_file_count[d],
283
+ note=f"entry: {dir_out_deg[d]} outgoing, 0 incoming top-level deps",
284
+ )
285
+ for d in sorted(top_dirs)
286
+ if dir_out_deg[d] > 0 and dir_in_deg[d] == 0
287
+ ]
288
+
289
+ # ── 6. Core directories (out=0, in>0) ────────────────────────────────────
290
+ core_directories: list[ArchitectureSignal] = [
291
+ ArchitectureSignal(
292
+ directory=d,
293
+ in_degree=dir_in_deg[d],
294
+ out_degree=dir_out_deg[d],
295
+ file_count=dir_file_count[d],
296
+ note=f"core: {dir_in_deg[d]} incoming, 0 outgoing top-level deps",
297
+ )
298
+ for d in sorted(top_dirs)
299
+ if dir_in_deg[d] > 0 and dir_out_deg[d] == 0
300
+ ]
301
+
302
+ # ── 7. Leaf directories (no edges at all — fully isolated) ───────────────
303
+ leaf_directories = sorted(
304
+ d for d in top_dirs if dir_out_deg[d] == 0 and dir_in_deg[d] == 0
305
+ )
306
+
307
+ # ── 8. Cycle summaries from the file-level graph ──────────────────────────
308
+ cycle_summaries: list[CycleSummary] = [
309
+ CycleSummary(
310
+ file_cycle=tuple(fc),
311
+ directory_cycle=_cycle_to_dir_projection(fc),
312
+ )
313
+ for fc in graph.cycles
314
+ ]
315
+
316
+ # ── 9. Directory-level cycle detection ────────────────────────────────────
317
+ dir_adj: dict[str, list[str]] = {d: sorted(dir_out_targets[d]) for d in top_dirs}
318
+ dir_cycles = _detect_directory_cycles(dir_adj)
319
+
320
+ # ── 10. Hotspots ──────────────────────────────────────────────────────────
321
+ hotspots: list[Hotspot] = []
322
+ if top_dirs:
323
+ mean_files = sum(dir_file_count.values()) / len(top_dirs)
324
+ mean_deg = sum(dir_out_deg[d] + dir_in_deg[d] for d in top_dirs) / len(top_dirs)
325
+ for d in sorted(top_dirs):
326
+ fc = dir_file_count[d]
327
+ out_d = dir_out_deg[d]
328
+ in_d = dir_in_deg[d]
329
+ labels: list[str] = []
330
+ if mean_files > 0 and fc > _LARGE_DIR_FACTOR * mean_files:
331
+ labels.append("large")
332
+ if mean_deg > 0 and (out_d + in_d) > _HIGH_DEGREE_FACTOR * mean_deg:
333
+ labels.append("highly_connected")
334
+ if in_d >= 2 and out_d >= 2:
335
+ labels.append("mixed_role")
336
+ if labels:
337
+ top_fi = _top_files_in_dir(d, all_files, file_in, _TOP_HOTSPOT_FILES)
338
+ top_fo = _top_files_in_dir(d, all_files, file_out, _TOP_HOTSPOT_FILES)
339
+ hotspots.append(
340
+ Hotspot(
341
+ directory=d,
342
+ reason=" ".join(labels),
343
+ file_count=fc,
344
+ in_degree=in_d,
345
+ out_degree=out_d,
346
+ top_fan_in_files=top_fi,
347
+ top_fan_out_files=top_fo,
348
+ )
349
+ )
350
+
351
+ # ── 11. Warnings ──────────────────────────────────────────────────────────
352
+ warnings: list[str] = []
353
+
354
+ files_in_cycles: set[str] = set()
355
+ for fc in graph.cycles:
356
+ files_in_cycles.update(fc)
357
+ cycle_density = len(files_in_cycles) / max(len(all_files), 1)
358
+
359
+ if cycle_density > _CYCLE_FILE_FRACTION:
360
+ warnings.append(
361
+ f"High cycle density: {len(files_in_cycles)} of {len(all_files)} files "
362
+ f"({cycle_density:.0%}) are involved in import cycles."
363
+ )
364
+
365
+ if dir_cycles:
366
+ warnings.append(
367
+ f"{len(dir_cycles)} top-level directory cycle(s) detected; "
368
+ "dependency flow is not strictly layered."
369
+ )
370
+
371
+ bi_coupled = [
372
+ (a, b)
373
+ for a in sorted(top_dirs)
374
+ for b in sorted(dir_out_targets.get(a, set()))
375
+ if a < b and a in dir_out_targets.get(b, set())
376
+ ]
377
+ if bi_coupled:
378
+ pairs = ", ".join(f"{a}<->{b}" for a, b in bi_coupled[:3])
379
+ suffix = f" (+{len(bi_coupled) - 3} more)" if len(bi_coupled) > 3 else ""
380
+ warnings.append(
381
+ f"Bidirectional coupling between top-level directories: {pairs}{suffix}."
382
+ )
383
+
384
+ for h in hotspots:
385
+ warnings.append(
386
+ f"Hotspot '{h.directory}': {h.reason} "
387
+ f"(files={h.file_count}, in={h.in_degree}, out={h.out_degree})."
388
+ )
389
+
390
+ # ── 12. Verdict ────────────────────────────────────────────────────────────
391
+ has_entry = bool(entry_directories)
392
+ has_core = bool(core_directories)
393
+ has_dir_cycles = bool(dir_cycles)
394
+ high_cycle_density = cycle_density > _CYCLE_FILE_FRACTION
395
+
396
+ if (
397
+ len(top_dirs) >= _MIN_ONION_DIRS
398
+ and has_entry
399
+ and has_core
400
+ and not has_dir_cycles
401
+ and not high_cycle_density
402
+ ):
403
+ verdict: Literal["likely_onion", "layered_but_mixed", "unclear"] = (
404
+ "likely_onion"
405
+ )
406
+ elif not high_cycle_density and (has_entry or has_core) and len(top_dirs) >= 2:
407
+ verdict = "layered_but_mixed"
408
+ else:
409
+ verdict = "unclear"
410
+
411
+ if verdict == "unclear":
412
+ reasons: list[str] = []
413
+ if high_cycle_density:
414
+ reasons.append("high cycle density")
415
+ if not has_entry:
416
+ reasons.append("no clear entry directories identified")
417
+ if not has_core:
418
+ reasons.append("no clear core directories identified")
419
+ if len(top_dirs) < 2:
420
+ reasons.append("too few top-level directories to assess structure")
421
+ if reasons:
422
+ warnings.append(f"Verdict is unclear: {'; '.join(reasons)}.")
423
+
424
+ # ── 13. Verdict reasoning ──────────────────────────────────────────────────
425
+ verdict_reasoning: list[str] = []
426
+
427
+ if entry_directories:
428
+ _names = ", ".join(sig.directory for sig in entry_directories[:3])
429
+ _suffix = (
430
+ f" (+{len(entry_directories) - 3} more)"
431
+ if len(entry_directories) > 3
432
+ else ""
433
+ )
434
+ verdict_reasoning.append(f"Entry areas detected: {_names}{_suffix}")
435
+ else:
436
+ verdict_reasoning.append("No clear entry areas detected")
437
+
438
+ if core_directories:
439
+ _names = ", ".join(sig.directory for sig in core_directories[:3])
440
+ _suffix = (
441
+ f" (+{len(core_directories) - 3} more)" if len(core_directories) > 3 else ""
442
+ )
443
+ verdict_reasoning.append(f"Core areas detected: {_names}{_suffix}")
444
+ else:
445
+ verdict_reasoning.append("No clear core areas detected")
446
+
447
+ if dir_cycles:
448
+ verdict_reasoning.append(
449
+ f"Directory cycles present ({len(dir_cycles)}): dependency flow is not strictly layered"
450
+ )
451
+ else:
452
+ verdict_reasoning.append("No directory-level cycles detected")
453
+
454
+ if high_cycle_density:
455
+ verdict_reasoning.append(
456
+ f"High file-level cycle density: {cycle_density:.0%} of files in cycles "
457
+ f"(threshold {_CYCLE_FILE_FRACTION:.0%})"
458
+ )
459
+ else:
460
+ verdict_reasoning.append(
461
+ f"Cycle density within threshold: {cycle_density:.0%} of files in cycles"
462
+ )
463
+
464
+ if bi_coupled:
465
+ _pairs_str = ", ".join(f"{a}<->{b}" for a, b in bi_coupled[:2])
466
+ _suffix = f" (+{len(bi_coupled) - 2} more)" if len(bi_coupled) > 2 else ""
467
+ verdict_reasoning.append(
468
+ f"Bidirectional coupling between top-level directories: {_pairs_str}{_suffix}"
469
+ )
470
+ else:
471
+ verdict_reasoning.append(
472
+ "No bidirectional coupling between top-level directories"
473
+ )
474
+
475
+ mixed_role_hs = [h for h in hotspots if "mixed_role" in h.reason]
476
+ if mixed_role_hs:
477
+ _names = ", ".join(h.directory for h in mixed_role_hs[:3])
478
+ _suffix = f" (+{len(mixed_role_hs) - 3} more)" if len(mixed_role_hs) > 3 else ""
479
+ verdict_reasoning.append(f"Mixed-role hotspots detected: {_names}{_suffix}")
480
+ else:
481
+ verdict_reasoning.append("No mixed-role hotspots detected")
482
+
483
+ return ArchitectureAssessment(
484
+ entry_files=entry_files,
485
+ entry_directories=entry_directories,
486
+ core_directories=core_directories,
487
+ leaf_files=leaf_files,
488
+ leaf_directories=leaf_directories,
489
+ cycle_summaries=cycle_summaries,
490
+ directory_cycles=[tuple(dc) for dc in dir_cycles],
491
+ hotspots=hotspots,
492
+ warnings=warnings,
493
+ verdict=verdict,
494
+ verdict_reasoning=verdict_reasoning,
495
+ )