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,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)