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/generators/cursor.py
ADDED
|
@@ -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
|
+
)
|
rpr/generators/gemini.py
ADDED
|
@@ -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
|
rpr/map/architecture.py
ADDED
|
@@ -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
|
+
)
|