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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Deterministic responsibility summaries for the code map.
|
|
2
|
+
|
|
3
|
+
For each directory in the analysed repository this module derives a concise
|
|
4
|
+
one-line description of what that directory is responsible for. All inference
|
|
5
|
+
is performed from repository contents only; no network calls or LLM inference
|
|
6
|
+
are used.
|
|
7
|
+
|
|
8
|
+
Heuristics are applied in priority order:
|
|
9
|
+
1. Module docstring — the first non-empty line of the docstring from a
|
|
10
|
+
canonical index file (``__init__.py``, ``mod.rs``, or ``index.ts/tsx``).
|
|
11
|
+
2. Vocabulary match — maps well-known directory name segments to canned
|
|
12
|
+
descriptions.
|
|
13
|
+
3. Dominant layer — the most frequent non-``unknown`` layer label from
|
|
14
|
+
classified nodes in the directory.
|
|
15
|
+
4. Neutral fallback — "N source files".
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from rpr.map.classifier import ClassifiedNode
|
|
26
|
+
from rpr.map.topology import DirectoryTopology
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Vocabulary map (last path segment → description)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
_VOCAB: dict[str, str] = {
|
|
33
|
+
# CLI and entry points
|
|
34
|
+
"commands": "CLI command handlers",
|
|
35
|
+
"cli": "Command-line interface entry points",
|
|
36
|
+
"main": "Application entry point",
|
|
37
|
+
"app": "Application entry point",
|
|
38
|
+
# Application layer
|
|
39
|
+
"application": "Application service layer",
|
|
40
|
+
"services": "Application services",
|
|
41
|
+
"use_cases": "Application use-case handlers",
|
|
42
|
+
"usecases": "Application use-case handlers",
|
|
43
|
+
"handlers": "Request/event handlers",
|
|
44
|
+
"workflows": "Application workflows",
|
|
45
|
+
# Domain
|
|
46
|
+
"domain": "Domain model",
|
|
47
|
+
"entities": "Domain entities",
|
|
48
|
+
"entity": "Domain entity",
|
|
49
|
+
"models": "Domain models",
|
|
50
|
+
# DTOs / schemas
|
|
51
|
+
"dtos": "Data transfer objects",
|
|
52
|
+
"dto": "Data transfer objects",
|
|
53
|
+
"schemas": "Data schemas",
|
|
54
|
+
"types": "Type definitions",
|
|
55
|
+
# Persistence
|
|
56
|
+
"repos": "Repository implementations",
|
|
57
|
+
"repositories": "Repository implementations",
|
|
58
|
+
"repository": "Repository implementation",
|
|
59
|
+
# Infrastructure
|
|
60
|
+
"infrastructure": "Infrastructure adapters",
|
|
61
|
+
"infra": "Infrastructure adapters",
|
|
62
|
+
"config": "Configuration",
|
|
63
|
+
"db": "Database layer",
|
|
64
|
+
"orm": "ORM models",
|
|
65
|
+
"migrations": "Database migrations",
|
|
66
|
+
# API
|
|
67
|
+
"api": "API layer",
|
|
68
|
+
"routes": "Route definitions",
|
|
69
|
+
"routers": "Route definitions",
|
|
70
|
+
"endpoints": "API endpoints",
|
|
71
|
+
"middleware": "Middleware",
|
|
72
|
+
# UI
|
|
73
|
+
"components": "UI components",
|
|
74
|
+
"pages": "Page components",
|
|
75
|
+
"screens": "Screen components",
|
|
76
|
+
"views": "View components",
|
|
77
|
+
"hooks": "React hooks",
|
|
78
|
+
"utils": "Utilities",
|
|
79
|
+
"util": "Utilities",
|
|
80
|
+
"helpers": "Helper functions",
|
|
81
|
+
# Map subsystem
|
|
82
|
+
"map": "Code-map analysis",
|
|
83
|
+
"extractor": "Import extraction",
|
|
84
|
+
"topology": "Directory topology",
|
|
85
|
+
"classifier": "Layer classification",
|
|
86
|
+
"output": "Report rendering",
|
|
87
|
+
"graph": "Dependency graph",
|
|
88
|
+
"walker": "File walker",
|
|
89
|
+
"architecture": "Architecture assessment",
|
|
90
|
+
"chains": "Representative dependency chains",
|
|
91
|
+
"checks": "Health-check validators",
|
|
92
|
+
"generators": "Code generators",
|
|
93
|
+
"scaffolds": "Project scaffolds",
|
|
94
|
+
"templates": "Code templates",
|
|
95
|
+
"agent": "AI agent tools",
|
|
96
|
+
"tui": "Terminal UI",
|
|
97
|
+
"ui": "User interface",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Layer labels → human-readable responsibility suffix
|
|
101
|
+
_LAYER_DESCRIPTIONS: dict[str, str] = {
|
|
102
|
+
"entry_point": "Entry points",
|
|
103
|
+
"application": "Application logic",
|
|
104
|
+
"entity": "Domain entities",
|
|
105
|
+
"dto": "Data transfer objects",
|
|
106
|
+
"repository": "Data access layer",
|
|
107
|
+
"infrastructure": "Infrastructure layer",
|
|
108
|
+
"ui_component": "UI components",
|
|
109
|
+
"ui_util": "UI utilities",
|
|
110
|
+
"test": "Tests",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Patterns for extracting a leading docstring from Rust source.
|
|
114
|
+
_RUST_INNER_DOC_RE = re.compile(r"//!(.+)")
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Domain value object
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class ResponsibilitySummary:
|
|
123
|
+
"""One-line responsibility description for a single directory.
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
directory: Repo-relative POSIX path (root is ``""``).
|
|
127
|
+
summary: Human-readable responsibility string.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
directory: str
|
|
131
|
+
summary: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Docstring extraction helpers
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _python_docstring(source: str) -> str | None:
|
|
140
|
+
"""Return the first line of the module docstring from Python source, or None."""
|
|
141
|
+
try:
|
|
142
|
+
tree = ast.parse(source)
|
|
143
|
+
except SyntaxError:
|
|
144
|
+
return None
|
|
145
|
+
if not tree.body:
|
|
146
|
+
return None
|
|
147
|
+
first = tree.body[0]
|
|
148
|
+
if not isinstance(first, ast.Expr):
|
|
149
|
+
return None
|
|
150
|
+
if not isinstance(first.value, ast.Constant) or not isinstance(first.value.value, str):
|
|
151
|
+
return None
|
|
152
|
+
lines = [ln.strip() for ln in first.value.value.strip().splitlines() if ln.strip()]
|
|
153
|
+
return lines[0] if lines else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _rust_docstring(source: str) -> str | None:
|
|
157
|
+
"""Return the first module-level inner doc comment (``//!``) line, or None."""
|
|
158
|
+
for line in source.splitlines():
|
|
159
|
+
stripped = line.strip()
|
|
160
|
+
m = _RUST_INNER_DOC_RE.match(stripped)
|
|
161
|
+
if m:
|
|
162
|
+
text = m.group(1).strip()
|
|
163
|
+
if text:
|
|
164
|
+
return text
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ts_docstring(source: str) -> str | None:
|
|
169
|
+
"""Return the first JSDoc/TSDoc ``/** … */`` description line, or None."""
|
|
170
|
+
# Match a leading block comment before the first import/export statement
|
|
171
|
+
m = re.match(r"\s*/\*\*(.+?)\*/", source, re.DOTALL)
|
|
172
|
+
if not m:
|
|
173
|
+
return None
|
|
174
|
+
inner = m.group(1)
|
|
175
|
+
for line in inner.splitlines():
|
|
176
|
+
clean = line.strip().lstrip("*").strip()
|
|
177
|
+
if clean and not clean.startswith("@"):
|
|
178
|
+
return clean
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _read_index_docstring(directory: str, topology: DirectoryTopology) -> str | None:
|
|
183
|
+
"""Attempt to read a docstring from the canonical index file of *directory*.
|
|
184
|
+
|
|
185
|
+
Looks for files directly in the directory (``files`` on the DirectoryNode)
|
|
186
|
+
with names ``__init__.py``, ``mod.rs``, or ``index.ts``/``index.tsx``.
|
|
187
|
+
Returns None when no index file exists or is not readable.
|
|
188
|
+
"""
|
|
189
|
+
dir_node = topology.nodes.get(directory)
|
|
190
|
+
if dir_node is None:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# index_files holds tuples of (relative_path, extractor_fn)
|
|
194
|
+
candidates: dict[str, object] = {
|
|
195
|
+
"__init__.py": _python_docstring,
|
|
196
|
+
"mod.rs": _rust_docstring,
|
|
197
|
+
"index.ts": _ts_docstring,
|
|
198
|
+
"index.tsx": _ts_docstring,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for file_rel in dir_node.files:
|
|
202
|
+
filename = file_rel.rsplit("/", 1)[-1]
|
|
203
|
+
extractor = candidates.get(filename)
|
|
204
|
+
if extractor is None:
|
|
205
|
+
continue
|
|
206
|
+
# Reconstruct abs_path using a best-effort approach; we don't have root
|
|
207
|
+
# available here. We delegate this to the caller via the files_in API
|
|
208
|
+
# and instead skip abs_path reading entirely — rely on caller providing
|
|
209
|
+
# an optional root for filesystem reads. See build_responsibility_summaries.
|
|
210
|
+
del extractor # accessed by name in public builder
|
|
211
|
+
return None # placeholder; actual reads happen in public builder
|
|
212
|
+
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Public builder
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def build_responsibility_summaries(
|
|
222
|
+
topology: DirectoryTopology,
|
|
223
|
+
classified: list[ClassifiedNode],
|
|
224
|
+
root: Path | None = None,
|
|
225
|
+
) -> list[ResponsibilitySummary]:
|
|
226
|
+
"""Derive a responsibility summary for every directory in the topology.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
topology: Directory topology from ``build_directory_topology``.
|
|
230
|
+
classified: Classified nodes from ``classify(graph)``.
|
|
231
|
+
root: Filesystem root; when provided, enables docstring extraction
|
|
232
|
+
from index files.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
A list of :class:`ResponsibilitySummary` objects — one per directory in
|
|
236
|
+
``topology.nodes`` — sorted by ``directory``.
|
|
237
|
+
"""
|
|
238
|
+
# Build file → classified node lookup
|
|
239
|
+
classified_by_file: dict[str, ClassifiedNode] = {
|
|
240
|
+
str(n.file.rel_path).replace("\\", "/"): n for n in classified
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Build directory → file list mapping (all files recursively)
|
|
244
|
+
def _files_in_dir(directory: str) -> list[str]:
|
|
245
|
+
return topology.files_in(directory)
|
|
246
|
+
|
|
247
|
+
results: list[ResponsibilitySummary] = []
|
|
248
|
+
|
|
249
|
+
for directory in sorted(topology.nodes):
|
|
250
|
+
summary = _derive_summary(
|
|
251
|
+
directory, topology, classified_by_file, root
|
|
252
|
+
)
|
|
253
|
+
results.append(ResponsibilitySummary(directory=directory, summary=summary))
|
|
254
|
+
|
|
255
|
+
return results
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _derive_summary(
|
|
259
|
+
directory: str,
|
|
260
|
+
topology: DirectoryTopology,
|
|
261
|
+
classified_by_file: dict[str, ClassifiedNode],
|
|
262
|
+
root: Path | None,
|
|
263
|
+
) -> str:
|
|
264
|
+
"""Return the best available one-line summary for a single directory."""
|
|
265
|
+
# ----------------------------------------------------------------
|
|
266
|
+
# Heuristic 1 — module docstring from index file (requires root)
|
|
267
|
+
# ----------------------------------------------------------------
|
|
268
|
+
if root is not None:
|
|
269
|
+
docstring = _try_index_docstring(directory, topology, root)
|
|
270
|
+
if docstring:
|
|
271
|
+
return _cap(docstring)
|
|
272
|
+
|
|
273
|
+
# ----------------------------------------------------------------
|
|
274
|
+
# Heuristic 2 — vocabulary match on last path segment
|
|
275
|
+
# ----------------------------------------------------------------
|
|
276
|
+
if directory:
|
|
277
|
+
last_segment = directory.rsplit("/", 1)[-1].lower()
|
|
278
|
+
if last_segment in _VOCAB:
|
|
279
|
+
return _VOCAB[last_segment]
|
|
280
|
+
else:
|
|
281
|
+
# Root directory — give a generic label based on project file count
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
# ----------------------------------------------------------------
|
|
285
|
+
# Heuristic 3 — dominant non-unknown layer among classified nodes
|
|
286
|
+
# ----------------------------------------------------------------
|
|
287
|
+
files_in = topology.files_in(directory)
|
|
288
|
+
layer_counts: dict[str, int] = {}
|
|
289
|
+
for file_rel in files_in:
|
|
290
|
+
node = classified_by_file.get(file_rel)
|
|
291
|
+
if node is None or node.layer == "unknown":
|
|
292
|
+
continue
|
|
293
|
+
layer_counts[node.layer] = layer_counts.get(node.layer, 0) + 1
|
|
294
|
+
|
|
295
|
+
if layer_counts:
|
|
296
|
+
dominant = max(layer_counts, key=lambda k: layer_counts[k])
|
|
297
|
+
description = _LAYER_DESCRIPTIONS.get(dominant)
|
|
298
|
+
if description:
|
|
299
|
+
return description
|
|
300
|
+
|
|
301
|
+
# ----------------------------------------------------------------
|
|
302
|
+
# Heuristic 4 — neutral fallback
|
|
303
|
+
# ----------------------------------------------------------------
|
|
304
|
+
file_count = len(files_in)
|
|
305
|
+
noun = "source file" if file_count == 1 else "source files"
|
|
306
|
+
return f"{file_count} {noun}"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _try_index_docstring(
|
|
310
|
+
directory: str,
|
|
311
|
+
topology: DirectoryTopology,
|
|
312
|
+
root: Path,
|
|
313
|
+
) -> str | None:
|
|
314
|
+
"""Read and parse the index file docstring for *directory* from disk."""
|
|
315
|
+
dir_node = topology.nodes.get(directory)
|
|
316
|
+
if dir_node is None:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
_INDEX_FILES: dict[str, object] = {
|
|
320
|
+
"__init__.py": _python_docstring,
|
|
321
|
+
"mod.rs": _rust_docstring,
|
|
322
|
+
"index.ts": _ts_docstring,
|
|
323
|
+
"index.tsx": _ts_docstring,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for file_rel in dir_node.files:
|
|
327
|
+
filename = file_rel.rsplit("/", 1)[-1]
|
|
328
|
+
extractor_fn = _INDEX_FILES.get(filename)
|
|
329
|
+
if extractor_fn is None:
|
|
330
|
+
continue
|
|
331
|
+
abs_path = root / Path(file_rel)
|
|
332
|
+
try:
|
|
333
|
+
source = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
334
|
+
except OSError:
|
|
335
|
+
continue
|
|
336
|
+
result = extractor_fn(source) # type: ignore[operator]
|
|
337
|
+
if result:
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _cap(text: str, limit: int = 120) -> str:
|
|
344
|
+
"""Truncate text to *limit* characters."""
|
|
345
|
+
return text if len(text) <= limit else text[:limit - 1] + "…"
|
rpr/map/topology.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rpr.map.graph import DependencyGraph
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Private path helpers
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _posix_parent(path: str) -> str:
|
|
14
|
+
"""Return the parent directory of a POSIX path string.
|
|
15
|
+
|
|
16
|
+
"api/routes/user.py" -> "api/routes"
|
|
17
|
+
"main.py" -> ""
|
|
18
|
+
"" -> ""
|
|
19
|
+
"""
|
|
20
|
+
idx = path.rfind("/")
|
|
21
|
+
return path[:idx] if idx != -1 else ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ancestors(directory: str) -> list[str]:
|
|
25
|
+
"""Return all ancestor directories including self and root "".
|
|
26
|
+
|
|
27
|
+
"api/routes" -> ["api/routes", "api", ""]
|
|
28
|
+
"api" -> ["api", ""]
|
|
29
|
+
"" -> [""]
|
|
30
|
+
"""
|
|
31
|
+
result: list[str] = []
|
|
32
|
+
current = directory
|
|
33
|
+
while True:
|
|
34
|
+
result.append(current)
|
|
35
|
+
if current == "":
|
|
36
|
+
break
|
|
37
|
+
current = _posix_parent(current)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _truncate_to_depth(directory: str, depth: int) -> str:
|
|
42
|
+
"""Truncate a directory path to the first *depth* segments.
|
|
43
|
+
|
|
44
|
+
_truncate_to_depth("api/routes/v2", 1) -> "api"
|
|
45
|
+
_truncate_to_depth("api/routes/v2", 2) -> "api/routes"
|
|
46
|
+
_truncate_to_depth("api", 1) -> "api"
|
|
47
|
+
_truncate_to_depth("", 1) -> ""
|
|
48
|
+
"""
|
|
49
|
+
if not directory:
|
|
50
|
+
return ""
|
|
51
|
+
parts = directory.split("/")
|
|
52
|
+
return "/".join(parts[:depth])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Domain value objects
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class DirectoryNode:
|
|
62
|
+
"""A directory in the containment tree."""
|
|
63
|
+
|
|
64
|
+
path: str # POSIX rel_path; root is ""
|
|
65
|
+
parent: str | None # None only for root ""
|
|
66
|
+
children: tuple[str, ...] # sorted immediate child directory paths
|
|
67
|
+
files: tuple[str, ...] # sorted file rel_paths directly in this dir
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class DirectoryEdge:
|
|
72
|
+
"""An aggregated dependency edge between two directories."""
|
|
73
|
+
|
|
74
|
+
source: str # source directory POSIX path
|
|
75
|
+
target: str # target directory POSIX path
|
|
76
|
+
support: int # number of file-level edges contributing
|
|
77
|
+
representatives: tuple[tuple[str, str], ...] # sorted (src_file, dest_file) pairs
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Directory topology aggregate
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class DirectoryTopology:
|
|
87
|
+
"""Directory-level projection of a file-level DependencyGraph.
|
|
88
|
+
|
|
89
|
+
``nodes`` preserves every discovered directory (including the root "").
|
|
90
|
+
``edges`` holds full-depth directory edges derived from file-level edges.
|
|
91
|
+
Query methods produce derived views without modifying the stored state.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
nodes: dict[str, DirectoryNode] # path -> DirectoryNode
|
|
95
|
+
edges: dict[tuple[str, str], DirectoryEdge] # (src_dir, tgt_dir) -> edge
|
|
96
|
+
root: str # always ""
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Derived views
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def edges_at_depth(self, depth: int) -> list[DirectoryEdge]:
|
|
103
|
+
"""Return directory edges with both endpoints truncated to *depth* segments.
|
|
104
|
+
|
|
105
|
+
Edges where source == target after truncation (intra-directory) are
|
|
106
|
+
excluded. Representatives and support counts are merged across all
|
|
107
|
+
full-depth edges that collapse to the same truncated pair. The result
|
|
108
|
+
is sorted by (source, target).
|
|
109
|
+
"""
|
|
110
|
+
accumulator: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
|
111
|
+
for edge in self.edges.values():
|
|
112
|
+
src = _truncate_to_depth(edge.source, depth)
|
|
113
|
+
tgt = _truncate_to_depth(edge.target, depth)
|
|
114
|
+
if src == tgt:
|
|
115
|
+
continue
|
|
116
|
+
key = (src, tgt)
|
|
117
|
+
bucket = accumulator.setdefault(key, [])
|
|
118
|
+
bucket.extend(edge.representatives)
|
|
119
|
+
|
|
120
|
+
result: list[DirectoryEdge] = []
|
|
121
|
+
for (src, tgt), pairs in sorted(accumulator.items()):
|
|
122
|
+
deduped = sorted(set(pairs))
|
|
123
|
+
result.append(
|
|
124
|
+
DirectoryEdge(
|
|
125
|
+
source=src,
|
|
126
|
+
target=tgt,
|
|
127
|
+
support=len(deduped),
|
|
128
|
+
representatives=tuple(deduped),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def top_level_edges(self) -> list[DirectoryEdge]:
|
|
134
|
+
"""Shorthand for ``edges_at_depth(1)``."""
|
|
135
|
+
return self.edges_at_depth(1)
|
|
136
|
+
|
|
137
|
+
def subgraph(self, directory: str) -> list[DirectoryEdge]:
|
|
138
|
+
"""Return edges where at least one endpoint is an immediate child of *directory*.
|
|
139
|
+
|
|
140
|
+
Internal paths (under *directory*) are expressed at child depth — one
|
|
141
|
+
segment deeper than *directory*. External paths are truncated to
|
|
142
|
+
top-level (depth 1) so outbound dependencies are readable. Edges
|
|
143
|
+
entirely outside the directory are excluded. The root directory ("")
|
|
144
|
+
returns the same result as ``top_level_edges()``.
|
|
145
|
+
"""
|
|
146
|
+
child_depth = directory.count("/") + 2 if directory else 1
|
|
147
|
+
prefix = directory + "/" if directory else ""
|
|
148
|
+
|
|
149
|
+
def _is_under(dir_path: str) -> bool:
|
|
150
|
+
if not directory:
|
|
151
|
+
return True
|
|
152
|
+
return dir_path == directory or dir_path.startswith(prefix)
|
|
153
|
+
|
|
154
|
+
def _truncate_for_side(dir_path: str) -> str:
|
|
155
|
+
if _is_under(dir_path):
|
|
156
|
+
return _truncate_to_depth(dir_path, child_depth)
|
|
157
|
+
return _truncate_to_depth(dir_path, 1)
|
|
158
|
+
|
|
159
|
+
accumulator: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
|
160
|
+
for edge in self.edges.values():
|
|
161
|
+
src_is_under = _is_under(edge.source)
|
|
162
|
+
tgt_is_under = _is_under(edge.target)
|
|
163
|
+
if not src_is_under and not tgt_is_under:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
src = _truncate_for_side(edge.source)
|
|
167
|
+
tgt = _truncate_for_side(edge.target)
|
|
168
|
+
if src == tgt:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
key = (src, tgt)
|
|
172
|
+
bucket = accumulator.setdefault(key, [])
|
|
173
|
+
bucket.extend(edge.representatives)
|
|
174
|
+
|
|
175
|
+
result: list[DirectoryEdge] = []
|
|
176
|
+
for (src, tgt), pairs in sorted(accumulator.items()):
|
|
177
|
+
deduped = sorted(set(pairs))
|
|
178
|
+
result.append(
|
|
179
|
+
DirectoryEdge(
|
|
180
|
+
source=src,
|
|
181
|
+
target=tgt,
|
|
182
|
+
support=len(deduped),
|
|
183
|
+
representatives=tuple(deduped),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def files_in(self, directory: str) -> list[str]:
|
|
189
|
+
"""Return all file rel_paths recursively contained under *directory*.
|
|
190
|
+
|
|
191
|
+
The result is sorted for determinism.
|
|
192
|
+
"""
|
|
193
|
+
collected: list[str] = []
|
|
194
|
+
prefix = directory + "/" if directory else ""
|
|
195
|
+
for node in self.nodes.values():
|
|
196
|
+
if node.path == directory or node.path.startswith(prefix):
|
|
197
|
+
collected.extend(node.files)
|
|
198
|
+
return sorted(collected)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Public builder
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_directory_topology(graph: DependencyGraph, root: Path) -> DirectoryTopology: # noqa: ARG001
|
|
207
|
+
"""Build a DirectoryTopology from a file-level DependencyGraph.
|
|
208
|
+
|
|
209
|
+
The *root* parameter is accepted for API symmetry with other map builders
|
|
210
|
+
but is not required for the computation (all paths are already relative
|
|
211
|
+
inside the graph).
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
graph: The file-level dependency graph (read-only input).
|
|
215
|
+
root: The filesystem root that was analysed (unused internally).
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
A fully populated DirectoryTopology.
|
|
219
|
+
"""
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Step 1: Collect all directory paths and direct-file containment.
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
dir_files: dict[str, list[str]] = {"": []} # root always present
|
|
224
|
+
|
|
225
|
+
for node in graph.nodes:
|
|
226
|
+
file_key = str(node.rel_path).replace("\\", "/")
|
|
227
|
+
parent_dir = _posix_parent(file_key)
|
|
228
|
+
# Record the file under its immediate parent only.
|
|
229
|
+
dir_files.setdefault(parent_dir, []).append(file_key)
|
|
230
|
+
# Ensure every ancestor directory exists (even if it contains no
|
|
231
|
+
# direct files).
|
|
232
|
+
for ancestor in _ancestors(parent_dir):
|
|
233
|
+
dir_files.setdefault(ancestor, [])
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
# Step 2: Build parent -> children index.
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
dir_children: dict[str, list[str]] = {d: [] for d in dir_files}
|
|
239
|
+
|
|
240
|
+
for directory in dir_files:
|
|
241
|
+
if directory == "":
|
|
242
|
+
continue
|
|
243
|
+
parent = _posix_parent(directory)
|
|
244
|
+
dir_children.setdefault(parent, []).append(directory)
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Step 3: Construct DirectoryNode objects.
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
nodes: dict[str, DirectoryNode] = {}
|
|
250
|
+
for directory in dir_files:
|
|
251
|
+
parent: str | None = _posix_parent(directory) if directory else None
|
|
252
|
+
nodes[directory] = DirectoryNode(
|
|
253
|
+
path=directory,
|
|
254
|
+
parent=parent,
|
|
255
|
+
children=tuple(sorted(dir_children.get(directory, []))),
|
|
256
|
+
files=tuple(sorted(dir_files[directory])),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# Step 4: Build full-depth directory edges from file-level edges.
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
edge_pairs: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
|
263
|
+
|
|
264
|
+
for src_file, deps in graph.edges.items():
|
|
265
|
+
src_dir = _posix_parent(src_file)
|
|
266
|
+
for tgt_file in deps:
|
|
267
|
+
tgt_dir = _posix_parent(tgt_file)
|
|
268
|
+
if src_dir == tgt_dir:
|
|
269
|
+
continue # intra-directory at full depth — skip
|
|
270
|
+
key = (src_dir, tgt_dir)
|
|
271
|
+
edge_pairs.setdefault(key, []).append((src_file, tgt_file))
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
# Step 5: Construct DirectoryEdge objects.
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
edges: dict[tuple[str, str], DirectoryEdge] = {}
|
|
277
|
+
for (src_dir, tgt_dir), pairs in edge_pairs.items():
|
|
278
|
+
deduped = sorted(set(pairs))
|
|
279
|
+
edges[(src_dir, tgt_dir)] = DirectoryEdge(
|
|
280
|
+
source=src_dir,
|
|
281
|
+
target=tgt_dir,
|
|
282
|
+
support=len(deduped),
|
|
283
|
+
representatives=tuple(deduped),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return DirectoryTopology(nodes=nodes, edges=edges, root="")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def focus_edges(
|
|
290
|
+
topology: DirectoryTopology,
|
|
291
|
+
directory: str,
|
|
292
|
+
graph_edges: dict[str, list[str]],
|
|
293
|
+
) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[tuple[str, str]]]:
|
|
294
|
+
"""Return file-level edges classified relative to a focused directory.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
topology: Directory topology used to enumerate files under *directory*.
|
|
298
|
+
directory: Repo-relative POSIX path of the directory to focus on.
|
|
299
|
+
graph_edges: File-level edges from ``DependencyGraph.edges``.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
A three-tuple ``(internal_edges, inbound_edges, outbound_edges)``:
|
|
303
|
+
|
|
304
|
+
- *internal_edges*: ``(src_file, tgt_file)`` where both files are
|
|
305
|
+
under *directory*.
|
|
306
|
+
- *inbound_edges*: ``(external_file, internal_file)`` where the
|
|
307
|
+
source is outside *directory* and the target is inside.
|
|
308
|
+
- *outbound_edges*: ``(internal_file, external_file)`` where the
|
|
309
|
+
source is inside *directory* and the target is outside.
|
|
310
|
+
"""
|
|
311
|
+
focus_files = set(topology.files_in(directory))
|
|
312
|
+
internal: list[tuple[str, str]] = []
|
|
313
|
+
inbound: list[tuple[str, str]] = []
|
|
314
|
+
outbound: list[tuple[str, str]] = []
|
|
315
|
+
|
|
316
|
+
for src, targets in graph_edges.items():
|
|
317
|
+
src_inside = src in focus_files
|
|
318
|
+
for tgt in targets:
|
|
319
|
+
tgt_inside = tgt in focus_files
|
|
320
|
+
if src_inside and tgt_inside:
|
|
321
|
+
internal.append((src, tgt))
|
|
322
|
+
elif src_inside and not tgt_inside:
|
|
323
|
+
outbound.append((src, tgt))
|
|
324
|
+
elif not src_inside and tgt_inside:
|
|
325
|
+
inbound.append((src, tgt))
|
|
326
|
+
|
|
327
|
+
return sorted(internal), sorted(inbound), sorted(outbound)
|