repolens-cli 0.1.0__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.
- repolens/__init__.py +1 -0
- repolens/ai_client.py +230 -0
- repolens/analyzer.py +242 -0
- repolens/cli.py +117 -0
- repolens/fetcher.py +198 -0
- repolens/graph.py +126 -0
- repolens/models.py +52 -0
- repolens/scanner.py +69 -0
- repolens/tui/__init__.py +0 -0
- repolens/tui/app.py +951 -0
- repolens_cli-0.1.0.dist-info/METADATA +88 -0
- repolens_cli-0.1.0.dist-info/RECORD +15 -0
- repolens_cli-0.1.0.dist-info/WHEEL +4 -0
- repolens_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repolens_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
repolens/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RepoLens — AI-native codebase intelligence."""
|
repolens/ai_client.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from openai import OpenAI
|
|
7
|
+
|
|
8
|
+
from .models import RepoAnalysis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_PROVIDER_DEFAULTS: dict[str, dict] = {
|
|
12
|
+
"openai": {
|
|
13
|
+
"base_url": "https://api.openai.com/v1",
|
|
14
|
+
"model": "gpt-4o",
|
|
15
|
+
"key_env": "OPENAI_API_KEY",
|
|
16
|
+
},
|
|
17
|
+
"gemini": {
|
|
18
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
19
|
+
"model": "gemini-2.5-flash",
|
|
20
|
+
"key_env": "GEMINI_API_KEY",
|
|
21
|
+
},
|
|
22
|
+
"groq": {
|
|
23
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
24
|
+
"model": "llama-3.3-70b-versatile",
|
|
25
|
+
"key_env": "GROQ_API_KEY",
|
|
26
|
+
},
|
|
27
|
+
"ollama": {
|
|
28
|
+
"base_url": "http://localhost:11434/v1",
|
|
29
|
+
"model": "llama3.2",
|
|
30
|
+
"key_env": None, # no key needed
|
|
31
|
+
},
|
|
32
|
+
"anthropic": {
|
|
33
|
+
"base_url": "https://api.anthropic.com/v1",
|
|
34
|
+
"model": "claude-sonnet-4-6",
|
|
35
|
+
"key_env": "ANTHROPIC_API_KEY",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_client: Optional[OpenAI] = None
|
|
40
|
+
_model: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_configured() -> bool:
|
|
44
|
+
for p, cfg in _PROVIDER_DEFAULTS.items():
|
|
45
|
+
key_env = cfg.get("key_env")
|
|
46
|
+
if key_env and os.environ.get(key_env):
|
|
47
|
+
return True
|
|
48
|
+
if os.environ.get("REPOLENS_AI_PROVIDER") == "ollama":
|
|
49
|
+
return True
|
|
50
|
+
if os.environ.get("REPOLENS_AI_BASE_URL"):
|
|
51
|
+
return True
|
|
52
|
+
if os.environ.get("REPOLENS_AI_API_KEY"):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_client() -> tuple[OpenAI, str]:
|
|
58
|
+
global _client, _model
|
|
59
|
+
if _client is not None:
|
|
60
|
+
return _client, _model
|
|
61
|
+
|
|
62
|
+
provider = os.environ.get("REPOLENS_AI_PROVIDER", "").lower()
|
|
63
|
+
if not provider:
|
|
64
|
+
# auto-detect from available keys
|
|
65
|
+
for p, cfg in _PROVIDER_DEFAULTS.items():
|
|
66
|
+
key_env = cfg.get("key_env")
|
|
67
|
+
if key_env and os.environ.get(key_env):
|
|
68
|
+
provider = p
|
|
69
|
+
break
|
|
70
|
+
if not provider:
|
|
71
|
+
if os.environ.get("REPOLENS_AI_API_KEY") and os.environ.get("REPOLENS_AI_BASE_URL"):
|
|
72
|
+
provider = "custom"
|
|
73
|
+
elif os.environ.get("REPOLENS_AI_BASE_URL"):
|
|
74
|
+
provider = "ollama" # no-auth local provider
|
|
75
|
+
else:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"No AI provider configured.\n"
|
|
78
|
+
"Set REPOLENS_AI_PROVIDER and a matching API key, e.g.:\n"
|
|
79
|
+
" REPOLENS_AI_PROVIDER=gemini GEMINI_API_KEY=...\n"
|
|
80
|
+
" REPOLENS_AI_PROVIDER=groq GROQ_API_KEY=...\n"
|
|
81
|
+
" REPOLENS_AI_PROVIDER=ollama (no key needed)\n"
|
|
82
|
+
" REPOLENS_AI_PROVIDER=openai OPENAI_API_KEY=...\n"
|
|
83
|
+
"See .env.example for full reference."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
cfg = _PROVIDER_DEFAULTS.get(provider, {})
|
|
87
|
+
base_url = os.environ.get("REPOLENS_AI_BASE_URL") or cfg.get("base_url", "")
|
|
88
|
+
model = os.environ.get("REPOLENS_AI_MODEL") or cfg.get("model", "gpt-4o")
|
|
89
|
+
|
|
90
|
+
# Resolve API key
|
|
91
|
+
api_key = os.environ.get("REPOLENS_AI_API_KEY")
|
|
92
|
+
if not api_key:
|
|
93
|
+
key_env = cfg.get("key_env")
|
|
94
|
+
if key_env:
|
|
95
|
+
api_key = os.environ.get(key_env)
|
|
96
|
+
if not api_key:
|
|
97
|
+
api_key = "ollama" # openai SDK requires a non-empty string; local providers ignore it
|
|
98
|
+
|
|
99
|
+
_client = OpenAI(api_key=api_key, base_url=base_url or None)
|
|
100
|
+
_model = model
|
|
101
|
+
return _client, _model
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_repo_context(analysis: RepoAnalysis) -> str:
|
|
105
|
+
lines: list[str] = []
|
|
106
|
+
lines.append(f"# Repository: {analysis.root}")
|
|
107
|
+
lines.append(f"Files analysed: {len(analysis.file_analyses)}")
|
|
108
|
+
lines.append("")
|
|
109
|
+
|
|
110
|
+
lines.append("## File Tree")
|
|
111
|
+
for f in analysis.files[:100]:
|
|
112
|
+
in_deg = analysis.stats.in_degree.get(f.path, 0)
|
|
113
|
+
badge = f" [{in_deg}←]" if in_deg > 0 else ""
|
|
114
|
+
lines.append(f" {f.path} ({f.language}){badge}")
|
|
115
|
+
if len(analysis.files) > 100:
|
|
116
|
+
lines.append(f" … and {len(analysis.files) - 100} more")
|
|
117
|
+
lines.append("")
|
|
118
|
+
|
|
119
|
+
lines.append("## Import Graph (file → local deps)")
|
|
120
|
+
for path, deps in list(analysis.stats.import_edges.items())[:50]:
|
|
121
|
+
if deps:
|
|
122
|
+
lines.append(f" {path} → {', '.join(deps)}")
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
if analysis.stats.circular_deps:
|
|
126
|
+
lines.append("## ⚠ Circular Dependencies")
|
|
127
|
+
for cycle in analysis.stats.circular_deps:
|
|
128
|
+
lines.append(" " + " → ".join(cycle) + " → " + cycle[0])
|
|
129
|
+
lines.append("")
|
|
130
|
+
|
|
131
|
+
lines.append("## Entry Points")
|
|
132
|
+
for ep in analysis.stats.entry_points[:20]:
|
|
133
|
+
lines.append(f" {ep}")
|
|
134
|
+
lines.append("")
|
|
135
|
+
|
|
136
|
+
lines.append("## Most-Imported Files")
|
|
137
|
+
for path, count in analysis.stats.hub_files[:10]:
|
|
138
|
+
if count > 0:
|
|
139
|
+
lines.append(f" {path} ({count} importers)")
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
lines.append("## Functions per File (sample)")
|
|
143
|
+
items = sorted(
|
|
144
|
+
analysis.file_analyses.items(),
|
|
145
|
+
key=lambda x: len(x[1].functions),
|
|
146
|
+
reverse=True,
|
|
147
|
+
)[:20]
|
|
148
|
+
for path, fa in items:
|
|
149
|
+
if fa.functions:
|
|
150
|
+
names = ", ".join(f.name for f in fa.functions[:10])
|
|
151
|
+
lines.append(f" {path}: {names}")
|
|
152
|
+
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_SYSTEM_PROMPT = (
|
|
157
|
+
"You are RepoLens, an AI assistant that helps developers understand codebases. "
|
|
158
|
+
"You have a structured summary of a code repository: file tree, import dependency "
|
|
159
|
+
"graph, circular dependency alerts, entry points, and function listings. "
|
|
160
|
+
"Answer concisely, reference actual file names, and trace call chains step by step "
|
|
161
|
+
"when asked. If unsure, say so."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ask(
|
|
166
|
+
analysis: RepoAnalysis,
|
|
167
|
+
question: str,
|
|
168
|
+
history: list[dict] | None = None,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Send *question* to the model, including prior *history* for multi-turn chat.
|
|
171
|
+
|
|
172
|
+
history: list of {"role": "user"|"assistant", "content": str} pairs
|
|
173
|
+
from previous turns (oldest first, excluding repo context).
|
|
174
|
+
"""
|
|
175
|
+
client, model = _get_client()
|
|
176
|
+
context = _build_repo_context(analysis)
|
|
177
|
+
|
|
178
|
+
# First user turn carries the repo context; subsequent turns are plain text.
|
|
179
|
+
if history:
|
|
180
|
+
first_user = history[0]["content"]
|
|
181
|
+
if not first_user.startswith("<repo_context>"):
|
|
182
|
+
history[0] = {
|
|
183
|
+
"role": "user",
|
|
184
|
+
"content": f"<repo_context>\n{context}\n</repo_context>\n\n{first_user}",
|
|
185
|
+
}
|
|
186
|
+
messages = [{"role": "system", "content": _SYSTEM_PROMPT}] + history + [
|
|
187
|
+
{"role": "user", "content": question}
|
|
188
|
+
]
|
|
189
|
+
else:
|
|
190
|
+
messages = [
|
|
191
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
192
|
+
{
|
|
193
|
+
"role": "user",
|
|
194
|
+
"content": f"<repo_context>\n{context}\n</repo_context>\n\nQuestion: {question}",
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
response = client.chat.completions.create(
|
|
199
|
+
model=model,
|
|
200
|
+
max_tokens=1024,
|
|
201
|
+
messages=messages,
|
|
202
|
+
)
|
|
203
|
+
return response.choices[0].message.content or ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def generate_onboarding(analysis: RepoAnalysis) -> str:
|
|
207
|
+
client, model = _get_client()
|
|
208
|
+
context = _build_repo_context(analysis)
|
|
209
|
+
response = client.chat.completions.create(
|
|
210
|
+
model=model,
|
|
211
|
+
max_tokens=2048,
|
|
212
|
+
messages=[
|
|
213
|
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
214
|
+
{
|
|
215
|
+
"role": "user",
|
|
216
|
+
"content": (
|
|
217
|
+
f"<repo_context>\n{context}\n</repo_context>\n\n"
|
|
218
|
+
"Generate a new developer onboarding guide. Include:\n"
|
|
219
|
+
"1. What this codebase does (1-2 sentences)\n"
|
|
220
|
+
"2. Key abstractions to understand first\n"
|
|
221
|
+
"3. Entry points — where to start reading\n"
|
|
222
|
+
"4. Most important files and what each does\n"
|
|
223
|
+
"5. Architectural patterns worth knowing\n"
|
|
224
|
+
"6. Circular dependencies or tech debt to be aware of\n\n"
|
|
225
|
+
"Be specific; reference actual file names."
|
|
226
|
+
),
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
return response.choices[0].message.content or ""
|
repolens/analyzer.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import FileAnalysis, FileNode, FunctionNode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Python ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def _py_resolve(module: str, level: int, current_file: str, all_paths: set[str]) -> Optional[str]:
|
|
14
|
+
"""Resolve a Python import to a repo-relative file path."""
|
|
15
|
+
parts = module.split(".") if module else []
|
|
16
|
+
|
|
17
|
+
if level > 0:
|
|
18
|
+
base = Path(current_file).parent
|
|
19
|
+
for _ in range(level - 1):
|
|
20
|
+
base = base.parent
|
|
21
|
+
candidate_parts = list(base.parts) + parts
|
|
22
|
+
else:
|
|
23
|
+
candidate_parts = parts
|
|
24
|
+
|
|
25
|
+
as_path = "/".join(candidate_parts)
|
|
26
|
+
for candidate in (f"{as_path}.py", f"{as_path}/__init__.py"):
|
|
27
|
+
if candidate in all_paths:
|
|
28
|
+
return candidate
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _call_name(node: ast.expr) -> Optional[str]:
|
|
33
|
+
if isinstance(node, ast.Name):
|
|
34
|
+
return node.id
|
|
35
|
+
if isinstance(node, ast.Attribute):
|
|
36
|
+
obj = _call_name(node.value)
|
|
37
|
+
return f"{obj}.{node.attr}" if obj else node.attr
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _analyze_python(file_node: FileNode, all_paths: set[str]) -> FileAnalysis:
|
|
42
|
+
fa = FileAnalysis(path=file_node.path, language="python")
|
|
43
|
+
if not file_node.content:
|
|
44
|
+
return fa
|
|
45
|
+
try:
|
|
46
|
+
tree = ast.parse(file_node.content, filename=file_node.path)
|
|
47
|
+
except SyntaxError:
|
|
48
|
+
return fa
|
|
49
|
+
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if isinstance(node, ast.Import):
|
|
52
|
+
for alias in node.names:
|
|
53
|
+
fa.raw_imports.append(alias.name)
|
|
54
|
+
r = _py_resolve(alias.name, 0, file_node.path, all_paths)
|
|
55
|
+
if r:
|
|
56
|
+
fa.resolved_imports.append(r)
|
|
57
|
+
|
|
58
|
+
elif isinstance(node, ast.ImportFrom):
|
|
59
|
+
module = node.module or ""
|
|
60
|
+
level = node.level
|
|
61
|
+
fa.raw_imports.append(("." * level) + module)
|
|
62
|
+
r = _py_resolve(module, level, file_node.path, all_paths)
|
|
63
|
+
if r:
|
|
64
|
+
fa.resolved_imports.append(r)
|
|
65
|
+
|
|
66
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
67
|
+
calls: list[str] = []
|
|
68
|
+
for child in ast.walk(node):
|
|
69
|
+
if child is node:
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(child, ast.Call):
|
|
72
|
+
name = _call_name(child.func)
|
|
73
|
+
if name:
|
|
74
|
+
calls.append(name)
|
|
75
|
+
raw_doc = ast.get_docstring(node, clean=True)
|
|
76
|
+
# Trim to first paragraph so long docstrings don't flood the TUI
|
|
77
|
+
docstring = raw_doc.split("\n\n")[0].strip() if raw_doc else None
|
|
78
|
+
fa.functions.append(
|
|
79
|
+
FunctionNode(
|
|
80
|
+
name=node.name,
|
|
81
|
+
file_path=file_node.path,
|
|
82
|
+
line_start=node.lineno,
|
|
83
|
+
line_end=node.end_lineno or node.lineno,
|
|
84
|
+
calls=calls,
|
|
85
|
+
docstring=docstring,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
elif isinstance(node, ast.ClassDef):
|
|
90
|
+
fa.classes.append(node.name)
|
|
91
|
+
|
|
92
|
+
return fa
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── JavaScript / TypeScript ───────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
_JS_IMPORT_RE = re.compile(
|
|
98
|
+
r"""(?:
|
|
99
|
+
import\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]
|
|
100
|
+
| (?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)
|
|
101
|
+
| export\s+[^'"]*?\s+from\s+['"]([^'"]+)['"]
|
|
102
|
+
)""",
|
|
103
|
+
re.VERBOSE | re.MULTILINE,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_JS_FUNC_RE = re.compile(
|
|
107
|
+
r"""(?:
|
|
108
|
+
(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+(\w+)\s*\(
|
|
109
|
+
| (?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?\(
|
|
110
|
+
| (?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?function
|
|
111
|
+
)""",
|
|
112
|
+
re.VERBOSE | re.MULTILINE,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Matches /** ... */ JSDoc block immediately before a function
|
|
116
|
+
_JSDOC_RE = re.compile(r'/\*\*(.*?)\*/', re.DOTALL)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _js_resolve(import_path: str, current_file: str, all_paths: set[str]) -> Optional[str]:
|
|
120
|
+
if not import_path.startswith("."):
|
|
121
|
+
return None
|
|
122
|
+
base = Path(current_file).parent
|
|
123
|
+
candidate = (base / import_path).as_posix()
|
|
124
|
+
for ext in ("", ".js", ".jsx", ".ts", ".tsx", "/index.js", "/index.ts", "/index.tsx"):
|
|
125
|
+
p = candidate + ext
|
|
126
|
+
if p in all_paths:
|
|
127
|
+
return p
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _analyze_js(file_node: FileNode, all_paths: set[str]) -> FileAnalysis:
|
|
132
|
+
fa = FileAnalysis(path=file_node.path, language=file_node.language)
|
|
133
|
+
if not file_node.content:
|
|
134
|
+
return fa
|
|
135
|
+
content = file_node.content
|
|
136
|
+
|
|
137
|
+
for m in _JS_IMPORT_RE.finditer(content):
|
|
138
|
+
raw = m.group(1) or m.group(2) or m.group(3)
|
|
139
|
+
if not raw:
|
|
140
|
+
continue
|
|
141
|
+
fa.raw_imports.append(raw)
|
|
142
|
+
r = _js_resolve(raw, file_node.path, all_paths)
|
|
143
|
+
if r:
|
|
144
|
+
fa.resolved_imports.append(r)
|
|
145
|
+
|
|
146
|
+
for m in _JS_FUNC_RE.finditer(content):
|
|
147
|
+
name = m.group(1) or m.group(2) or m.group(3)
|
|
148
|
+
if not name:
|
|
149
|
+
continue
|
|
150
|
+
line = content[: m.start()].count("\n") + 1
|
|
151
|
+
# Look for a JSDoc comment ending just before this function
|
|
152
|
+
preceding = content[: m.start()].rstrip()
|
|
153
|
+
jsdoc_match = _JSDOC_RE.search(preceding)
|
|
154
|
+
docstring: Optional[str] = None
|
|
155
|
+
if jsdoc_match and preceding.endswith("*/"):
|
|
156
|
+
raw = jsdoc_match.group(1)
|
|
157
|
+
# Strip leading " * " from each line and @param/@returns tags
|
|
158
|
+
lines = [re.sub(r'^\s*\*\s?', '', l) for l in raw.splitlines()]
|
|
159
|
+
desc_lines = [l for l in lines if l.strip() and not l.strip().startswith("@")]
|
|
160
|
+
if desc_lines:
|
|
161
|
+
docstring = " ".join(desc_lines).strip()
|
|
162
|
+
fa.functions.append(
|
|
163
|
+
FunctionNode(
|
|
164
|
+
name=name,
|
|
165
|
+
file_path=file_node.path,
|
|
166
|
+
line_start=line,
|
|
167
|
+
line_end=line,
|
|
168
|
+
docstring=docstring,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return fa
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Go ────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
_GO_IMPORT_BLOCK_RE = re.compile(r'import\s*\(([^)]+)\)', re.DOTALL)
|
|
177
|
+
_GO_IMPORT_SINGLE_RE = re.compile(r'^import\s+"([^"]+)"', re.MULTILINE)
|
|
178
|
+
_GO_FUNC_RE = re.compile(r'^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(', re.MULTILINE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _analyze_go(file_node: FileNode, _all_paths: set[str]) -> FileAnalysis:
|
|
182
|
+
fa = FileAnalysis(path=file_node.path, language="go")
|
|
183
|
+
if not file_node.content:
|
|
184
|
+
return fa
|
|
185
|
+
content = file_node.content
|
|
186
|
+
for block in _GO_IMPORT_BLOCK_RE.findall(content):
|
|
187
|
+
for imp in re.findall(r'"([^"]+)"', block):
|
|
188
|
+
fa.raw_imports.append(imp)
|
|
189
|
+
for m in _GO_IMPORT_SINGLE_RE.finditer(content):
|
|
190
|
+
fa.raw_imports.append(m.group(1))
|
|
191
|
+
for m in _GO_FUNC_RE.finditer(content):
|
|
192
|
+
line = content[: m.start()].count("\n") + 1
|
|
193
|
+
fa.functions.append(
|
|
194
|
+
FunctionNode(name=m.group(1), file_path=file_node.path, line_start=line, line_end=line)
|
|
195
|
+
)
|
|
196
|
+
return fa
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Rust ──────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
_RUST_USE_RE = re.compile(r'^use\s+([\w::{},\s*]+);', re.MULTILINE)
|
|
202
|
+
_RUST_FN_RE = re.compile(r'^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[\(<]', re.MULTILINE)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _analyze_rust(file_node: FileNode, _all_paths: set[str]) -> FileAnalysis:
|
|
206
|
+
fa = FileAnalysis(path=file_node.path, language="rust")
|
|
207
|
+
if not file_node.content:
|
|
208
|
+
return fa
|
|
209
|
+
content = file_node.content
|
|
210
|
+
for m in _RUST_USE_RE.finditer(content):
|
|
211
|
+
fa.raw_imports.append(m.group(1).strip())
|
|
212
|
+
for m in _RUST_FN_RE.finditer(content):
|
|
213
|
+
line = content[: m.start()].count("\n") + 1
|
|
214
|
+
fa.functions.append(
|
|
215
|
+
FunctionNode(name=m.group(1), file_path=file_node.path, line_start=line, line_end=line)
|
|
216
|
+
)
|
|
217
|
+
return fa
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def analyze_file(file_node: FileNode, all_paths: set[str]) -> FileAnalysis:
|
|
223
|
+
dispatch = {
|
|
224
|
+
"python": _analyze_python,
|
|
225
|
+
"javascript": _analyze_js,
|
|
226
|
+
"typescript": _analyze_js,
|
|
227
|
+
"go": _analyze_go,
|
|
228
|
+
"rust": _analyze_rust,
|
|
229
|
+
}
|
|
230
|
+
fn = dispatch.get(file_node.language)
|
|
231
|
+
if fn:
|
|
232
|
+
return fn(file_node, all_paths)
|
|
233
|
+
return FileAnalysis(path=file_node.path, language=file_node.language)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def analyze_all(files: list[FileNode]) -> dict[str, FileAnalysis]:
|
|
237
|
+
all_paths = {f.path for f in files}
|
|
238
|
+
return {
|
|
239
|
+
f.path: analyze_file(f, all_paths)
|
|
240
|
+
for f in files
|
|
241
|
+
if f.content is not None
|
|
242
|
+
}
|
repolens/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="repolens",
|
|
11
|
+
description="RepoLens — AI-native codebase intelligence",
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
"path",
|
|
15
|
+
nargs="?",
|
|
16
|
+
default=".",
|
|
17
|
+
help="Directory to analyse (default: current directory)",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--no-ai",
|
|
21
|
+
action="store_true",
|
|
22
|
+
help="Skip AI features",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--max-files",
|
|
26
|
+
type=int,
|
|
27
|
+
default=2000,
|
|
28
|
+
help="Max source files to scan (default: 2000)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--json",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Output analysis as JSON instead of launching TUI",
|
|
34
|
+
)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
root = Path(args.path).resolve()
|
|
38
|
+
if not root.is_dir():
|
|
39
|
+
print(f"Error: {root} is not a directory.", file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
print(f"RepoLens scanning {root} …")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from dotenv import load_dotenv
|
|
46
|
+
load_dotenv()
|
|
47
|
+
except ImportError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
from repolens.scanner import scan
|
|
51
|
+
from repolens.analyzer import analyze_all
|
|
52
|
+
from repolens.graph import build_graph
|
|
53
|
+
from repolens.models import RepoAnalysis
|
|
54
|
+
|
|
55
|
+
print(" Walking directory tree…")
|
|
56
|
+
files = scan(str(root), max_files=args.max_files)
|
|
57
|
+
print(f" Found {len(files)} source files.")
|
|
58
|
+
|
|
59
|
+
print(" Analysing imports and functions…")
|
|
60
|
+
file_analyses = analyze_all(files)
|
|
61
|
+
print(f" Analysed {len(file_analyses)} files.")
|
|
62
|
+
|
|
63
|
+
print(" Building dependency and call graphs…")
|
|
64
|
+
stats = build_graph(file_analyses)
|
|
65
|
+
|
|
66
|
+
analysis = RepoAnalysis(
|
|
67
|
+
root=str(root),
|
|
68
|
+
files=files,
|
|
69
|
+
file_analyses=file_analyses,
|
|
70
|
+
stats=stats,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
n_cycles = len(stats.circular_deps)
|
|
74
|
+
print(f" Done. {len(stats.functions)} functions · {n_cycles} circular dep(s)")
|
|
75
|
+
|
|
76
|
+
if args.json:
|
|
77
|
+
_print_json(analysis)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
print(" Launching TUI…\n")
|
|
81
|
+
from repolens.tui.app import RepoLensApp
|
|
82
|
+
app = RepoLensApp(analysis)
|
|
83
|
+
app.run()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_json(analysis: "RepoAnalysis") -> None:
|
|
87
|
+
import json
|
|
88
|
+
|
|
89
|
+
stats = analysis.stats
|
|
90
|
+
output = {
|
|
91
|
+
"root": analysis.root,
|
|
92
|
+
"total_files": len(analysis.files),
|
|
93
|
+
"files": [
|
|
94
|
+
{"path": f.path, "language": f.language, "size": f.size}
|
|
95
|
+
for f in analysis.files
|
|
96
|
+
],
|
|
97
|
+
"import_graph": {k: v for k, v in stats.import_edges.items() if v},
|
|
98
|
+
"circular_deps": stats.circular_deps,
|
|
99
|
+
"hub_files": [{"path": p, "in_degree": d} for p, d in stats.hub_files],
|
|
100
|
+
"entry_points": stats.entry_points,
|
|
101
|
+
"functions": [
|
|
102
|
+
{
|
|
103
|
+
"id": fid,
|
|
104
|
+
"name": fn.name,
|
|
105
|
+
"file": fn.file_path,
|
|
106
|
+
"line": fn.line_start,
|
|
107
|
+
"calls": fn.calls,
|
|
108
|
+
"callers": fn.callers,
|
|
109
|
+
}
|
|
110
|
+
for fid, fn in stats.functions.items()
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
print(json.dumps(output, indent=2))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|