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,243 @@
1
+ """External dependency summarisation for the code map.
2
+
3
+ For each analysed directory, this module identifies which third-party (or
4
+ system) package identifiers appeared in raw import strings but were *not*
5
+ resolved to in-repo edges by the dependency-graph builder. The result is a
6
+ per-directory inventory of external package names that developers can review
7
+ without consulting raw source files.
8
+
9
+ The ``DependencyGraph`` contract is intentionally left unchanged — this module
10
+ is a sibling analysis artifact, not an extension of the resolved graph.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from rpr.map.graph import DependencyGraph
18
+ from rpr.map.topology import DirectoryTopology
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Constants
22
+ # ---------------------------------------------------------------------------
23
+
24
+ # Standard Rust crate roots that are not third-party packages.
25
+ _RUST_STDLIB_ROOTS: frozenset[str] = frozenset(
26
+ {
27
+ "std",
28
+ "core",
29
+ "alloc",
30
+ "proc_macro",
31
+ "test",
32
+ "self",
33
+ "super",
34
+ "crate",
35
+ }
36
+ )
37
+
38
+ # Max number of packages listed per directory in the report.
39
+ _MAX_PACKAGES_PER_DIR = 15
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Domain value object
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ExternalDependencySummary:
49
+ """Aggregated external packages pulled in by files in a single directory.
50
+
51
+ Attributes:
52
+ directory: Repo-relative POSIX path (root is ``""``).
53
+ packages: Sorted, deduplicated external package identifiers.
54
+ """
55
+
56
+ directory: str
57
+ packages: tuple[str, ...]
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Language-specific import normalisation
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _normalise_python(raw: str) -> str | None:
66
+ """Return the top-level Python package name, or None to discard.
67
+
68
+ Relative imports (starting with ``.``) came from the same repository and
69
+ were already considered by the graph builder; they are discarded here.
70
+ """
71
+ if raw.startswith("."):
72
+ return None
73
+ segment = raw.split(".")[0]
74
+ return segment if segment else None
75
+
76
+
77
+ def _normalise_ts_js(raw: str) -> str | None:
78
+ """Return the npm package specifier, or None to discard.
79
+
80
+ Relative imports starting with ``.`` or ``/`` are in-repository references
81
+ that were already processed by the graph builder.
82
+ """
83
+ if raw.startswith(".") or raw.startswith("/"):
84
+ return None
85
+ # Scoped packages: "@scope/name/sub" → "@scope/name"
86
+ if raw.startswith("@"):
87
+ parts = raw.split("/")
88
+ if len(parts) >= 2:
89
+ return "/".join(parts[:2])
90
+ return raw
91
+ # Bare specifier: "react/jsx-runtime" → "react"
92
+ return raw.split("/")[0]
93
+
94
+
95
+ def _normalise_rust(raw: str) -> str | None:
96
+ """Return the Rust crate root, or None to discard stdlib/self/crate paths."""
97
+ # Strip leading "::" that sometimes appears on absolute paths
98
+ clean = raw.lstrip(":")
99
+ root = clean.split("::")[0].split("/")[0]
100
+ if root in _RUST_STDLIB_ROOTS:
101
+ return None
102
+ return root if root else None
103
+
104
+
105
+ def _normalise_cpp(raw: str) -> str | None:
106
+ """Return the first path segment of a C++ include, or None to discard.
107
+
108
+ Quoted includes (``"…"``) are typically project-relative and were already
109
+ resolved by the graph builder; only angle-bracket system/library includes
110
+ are treated as external here.
111
+ """
112
+ # Quoted includes are relative → discard
113
+ if raw.startswith('"'):
114
+ return None
115
+ # Angle-bracket includes: strip surrounding < >
116
+ bare = raw.strip("<>")
117
+ segment = bare.split("/")[0]
118
+ return segment if segment else None
119
+
120
+
121
+ _NORMALISE_BY_LANGUAGE = {
122
+ "python": _normalise_python,
123
+ "typescript": _normalise_ts_js,
124
+ "javascript": _normalise_ts_js,
125
+ "rust": _normalise_rust,
126
+ "cpp": _normalise_cpp,
127
+ }
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Core builder
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def build_external_dependencies(
136
+ graph: DependencyGraph,
137
+ raw_imports: dict[str, list[str]],
138
+ topology: DirectoryTopology,
139
+ ) -> list[ExternalDependencySummary]:
140
+ """Build per-directory external dependency summaries.
141
+
142
+ For every file in *graph*, any raw import string that was *not* resolved to
143
+ an in-repo graph edge is normalised into an external package name and
144
+ attributed to the file's parent directory. Results are deduplicated and
145
+ sorted both within each directory and across the returned list.
146
+
147
+ Args:
148
+ graph: The resolved dependency graph (read-only; provides resolved
149
+ edges and per-node language information).
150
+ raw_imports: Mapping ``{file_rel_path: [raw_import, ...]}``. Typically
151
+ produced by ``build_raw_import_inventory(graph)``.
152
+ topology: Directory topology (used to obtain the canonical directory
153
+ label for each file).
154
+
155
+ Returns:
156
+ A list of :class:`ExternalDependencySummary` objects — one per directory
157
+ that has at least one external import — sorted by ``directory``.
158
+ Directories with no external packages are omitted.
159
+ """
160
+ # Build a fast lookup set of resolved in-repo edges per file.
161
+ resolved_per_file: dict[str, set[str]] = {
162
+ rel: set(deps) for rel, deps in graph.edges.items()
163
+ }
164
+
165
+ # Build a fast lookup: file rel_path → language
166
+ lang_by_file: dict[str, str] = {
167
+ str(sf.rel_path).replace("\\", "/"): sf.language
168
+ for sf in graph.nodes
169
+ }
170
+
171
+ # Build file → directory mapping from topology
172
+ file_to_dir: dict[str, str] = {}
173
+ for dir_node in topology.nodes.values():
174
+ for file_rel in dir_node.files:
175
+ file_to_dir[file_rel] = dir_node.path
176
+
177
+ # Accumulate external packages per directory
178
+ accumulator: dict[str, set[str]] = {}
179
+
180
+ for file_rel, raw_list in raw_imports.items():
181
+ lang = lang_by_file.get(file_rel)
182
+ if lang is None:
183
+ continue # file not in graph nodes; skip
184
+ normalise = _NORMALISE_BY_LANGUAGE.get(lang)
185
+ if normalise is None:
186
+ continue
187
+
188
+ resolved = resolved_per_file.get(file_rel, set())
189
+ directory = file_to_dir.get(file_rel, "")
190
+ bucket = accumulator.setdefault(directory, set())
191
+
192
+ for raw in raw_list:
193
+ # Check whether this raw import was resolved into an in-repo edge.
194
+ # The graph stores edge targets as rel_paths, not raw import strings,
195
+ # so we identify external imports as those that produce no in-repo edge
196
+ # pointing to a path that starts with the normalised identifier.
197
+ # Simpler heuristic: if the raw import starts with "." or the directory
198
+ # contains a file whose rel_path ends with the import path, it's
199
+ # already captured in resolved edges. Here we rely on the normaliser
200
+ # to filter out relative imports and then check whether *any* resolved
201
+ # edge target is "plausibly" this import. The key insight is that the
202
+ # graph builder already resolves everything it can; what remains in
203
+ # raw_list but is absent from resolved edges is external.
204
+ package = normalise(raw)
205
+ if package is None:
206
+ continue
207
+
208
+ # If every resolved edge target represents an in-repo file, and none
209
+ # of them looks like it came from this raw import, treat it as external.
210
+ # We do this conservatively: we check whether any resolved edge target
211
+ # for this file has the package as a prefix segment.
212
+ is_in_repo = _looks_like_in_repo(package, resolved, lang)
213
+ if not is_in_repo:
214
+ bucket.add(package)
215
+
216
+ # Build sorted result, omitting directories with no external packages
217
+ result: list[ExternalDependencySummary] = []
218
+ for directory in sorted(accumulator):
219
+ packages = tuple(sorted(accumulator[directory]))
220
+ if packages:
221
+ capped = packages[:_MAX_PACKAGES_PER_DIR]
222
+ result.append(ExternalDependencySummary(directory=directory, packages=capped))
223
+ return result
224
+
225
+
226
+ def _looks_like_in_repo(package: str, resolved_edges: set[str], lang: str) -> bool:
227
+ """Return True if *package* appears to be satisfied by an in-repo resolved edge.
228
+
229
+ For Python and TS/JS the package name appears as a leading path segment or
230
+ module path component in the resolved edge targets. For Rust and C++ the
231
+ same heuristic applies to crate or include names.
232
+ """
233
+ lower_pkg = package.lower()
234
+ for edge_target in resolved_edges:
235
+ # Normalise path separators and drop extension for comparison
236
+ target_norm = edge_target.replace("\\", "/").lower()
237
+ # e.g. "rpr/map/walker" starts with "rpr" → matches package "rpr"
238
+ parts = target_norm.split("/")
239
+ for part in parts:
240
+ stem = part.rsplit(".", 1)[0] # strip extension
241
+ if stem == lower_pkg or part == lower_pkg:
242
+ return True
243
+ return False
rpr/map/extractor.py ADDED
@@ -0,0 +1,223 @@
1
+ """Analysis bridge — Rust-first project dependency extraction.
2
+
3
+ Primary API
4
+ -----------
5
+ ``analyze_project(root)`` is the single public entry-point consumed by the
6
+ ``rpr map`` command. It returns a fully-resolved ``DependencyGraph`` domain
7
+ object, delegating the expensive crawl/parse/resolve work to the compiled Rust
8
+ extension when available and falling back to the pure-Python pipeline
9
+ otherwise.
10
+
11
+ Fallback extraction helpers
12
+ ----------------------------
13
+ ``extract_imports`` / ``extract_imports_batch`` parse raw import strings from
14
+ source files using the standard library (``ast`` for Python) and regular
15
+ expressions (TypeScript, JavaScript, Rust, C++). These helpers power the
16
+ Python compatibility path and remain available for callers that need them
17
+ directly.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ import re
24
+ import warnings
25
+ from pathlib import Path
26
+ from typing import Callable
27
+
28
+ from rpr.map.graph import DependencyGraph, build_graph
29
+ from rpr.map.walker import Language, SourceFile, walk
30
+
31
+ try:
32
+ import rpr_parser
33
+
34
+ _RUST_AVAILABLE = True
35
+ except ImportError:
36
+ _RUST_AVAILABLE = False
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Type alias for per-language extraction functions (LSP: uniform signature)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ _ExtractFn = Callable[[str], list[str]]
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Private per-language extractors (SRP: one concern each)
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def _extract_python(source: str) -> list[str]:
50
+ """Extract raw import strings from Python source using ``ast``."""
51
+ try:
52
+ tree = ast.parse(source)
53
+ except SyntaxError:
54
+ return []
55
+
56
+ results: list[str] = []
57
+ for node in ast.walk(tree):
58
+ if isinstance(node, ast.Import):
59
+ for alias in node.names:
60
+ results.append(alias.name)
61
+ elif isinstance(node, ast.ImportFrom):
62
+ dots = "." * (node.level or 0)
63
+ module = node.module or ""
64
+ results.append(dots + module)
65
+ return results
66
+
67
+
68
+ # Patterns compiled once at import time for efficiency.
69
+ _TS_JS_PATTERNS = (
70
+ # import ... from 'X' / "X"
71
+ re.compile(r"""import\s+.*?from\s+['"]([^'"]+)['"]""", re.DOTALL),
72
+ # require('X') / require("X")
73
+ re.compile(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)"""),
74
+ # import('X') — dynamic
75
+ re.compile(r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)"""),
76
+ )
77
+
78
+
79
+ def _extract_ts_js(source: str) -> list[str]:
80
+ """Extract raw import specifiers from TypeScript / JavaScript source."""
81
+ results: list[str] = []
82
+ for pattern in _TS_JS_PATTERNS:
83
+ results.extend(pattern.findall(source))
84
+ return results
85
+
86
+
87
+ _RUST_USE = re.compile(r"use\s+([\w:]+(?:\s+as\s+\w+)?)\s*;")
88
+ _RUST_MOD = re.compile(r"mod\s+(\w+)\s*;")
89
+ _RUST_EXTERN = re.compile(r"extern\s+crate\s+(\w+)\s*;")
90
+
91
+
92
+ def _extract_rust(source: str) -> list[str]:
93
+ """Extract raw import/module strings from Rust source."""
94
+ results: list[str] = []
95
+
96
+ for match in _RUST_USE.finditer(source):
97
+ # Drop the alias part: "std::io as io" → "std::io"
98
+ path = match.group(1).split(" as ")[0].strip()
99
+ results.append(path)
100
+
101
+ results.extend(_RUST_MOD.findall(source))
102
+ results.extend(_RUST_EXTERN.findall(source))
103
+
104
+ return results
105
+
106
+
107
+ _CPP_QUOTED = re.compile(r'#include\s+"([^"]+)"')
108
+ _CPP_ANGLE = re.compile(r"#include\s+<([^>]+)>")
109
+
110
+
111
+ def _extract_cpp(source: str) -> list[str]:
112
+ """Extract raw include strings from C++ source.
113
+
114
+ Angle-bracket includes (system headers) are emitted without the ``<>``
115
+ delimiters; the graph resolver will drop them as external.
116
+ """
117
+ results: list[str] = _CPP_QUOTED.findall(source)
118
+ results.extend(_CPP_ANGLE.findall(source))
119
+ return results
120
+
121
+
122
+ # Dispatch dict — adding a new language requires only a new entry here (OCP).
123
+ _EXTRACTORS: dict[Language, _ExtractFn] = {
124
+ "python": _extract_python,
125
+ "typescript": _extract_ts_js,
126
+ "javascript": _extract_ts_js,
127
+ "rust": _extract_rust,
128
+ "cpp": _extract_cpp,
129
+ }
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Public extraction API
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def extract_imports(file: SourceFile) -> list[str]:
137
+ """Extract raw import strings from a single ``SourceFile``.
138
+
139
+ Returns an empty list for unsupported languages. Returns an empty list
140
+ and emits a ``UserWarning`` when the file cannot be read.
141
+ """
142
+ extractor = _EXTRACTORS.get(file.language)
143
+ if extractor is None:
144
+ return []
145
+
146
+ try:
147
+ source = file.abs_path.read_text(encoding="utf-8", errors="replace")
148
+ except OSError:
149
+ warnings.warn(f"Cannot read {file.rel_path} — skipping", stacklevel=2)
150
+ return []
151
+
152
+ return extractor(source)
153
+
154
+
155
+ def extract_imports_batch(
156
+ files: list[SourceFile],
157
+ ) -> dict[str, list[str]]:
158
+ """Extract raw imports for every file; returns ``{rel_path: [import, ...]}``."""
159
+ return {str(f.rel_path).replace("\\", "/"): extract_imports(f) for f in files}
160
+
161
+
162
+ def build_raw_import_inventory(graph: DependencyGraph) -> dict[str, list[str]]:
163
+ """Return raw (unresolved) import strings for every file in *graph*.
164
+
165
+ This is a convenience wrapper around :func:`extract_imports_batch` that
166
+ accepts an already-constructed :class:`~rpr.map.graph.DependencyGraph` and
167
+ returns the same ``{rel_path: [import, ...]}`` mapping. The result includes
168
+ *all* imports — both those that were resolved to in-repo edges and those
169
+ that remain unresolved (i.e., external packages or unreadable paths).
170
+
171
+ Callers that need only external imports should subtract the resolved edges
172
+ stored in ``graph.edges`` from this inventory.
173
+ """
174
+ return extract_imports_batch(graph.nodes)
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Private path implementations
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ def _rust_path(root: Path) -> DependencyGraph:
183
+ """Delegate the full analysis to the compiled Rust extension."""
184
+ payload = rpr_parser.build_dependency_graph(str(root))
185
+ nodes = [
186
+ SourceFile(
187
+ abs_path=Path(n["abs_path"]),
188
+ rel_path=Path(n["rel_path"]),
189
+ language=n["language"],
190
+ )
191
+ for n in payload["nodes"]
192
+ ]
193
+ return DependencyGraph(
194
+ nodes=nodes,
195
+ edges=payload["edges"],
196
+ reverse_edges=payload["reverse_edges"],
197
+ cycles=payload["cycles"],
198
+ backend="rust",
199
+ )
200
+
201
+
202
+ def _python_path(root: Path) -> DependencyGraph:
203
+ """Pure-Python compatibility pipeline: walk → extract → build graph."""
204
+ files = walk(root)
205
+ imports_by_file = extract_imports_batch(files)
206
+ return build_graph(files, imports_by_file)
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Primary public API
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ def analyze_project(root: Path) -> DependencyGraph:
215
+ """Crawl *root* and return a fully-resolved ``DependencyGraph``.
216
+
217
+ Delegates to the Rust engine when ``rpr_parser`` is importable; falls back
218
+ to the Python-only pipeline otherwise. The returned graph's ``backend``
219
+ field indicates which path was taken.
220
+ """
221
+ if _RUST_AVAILABLE:
222
+ return _rust_path(root)
223
+ return _python_path(root)