codepulse-engine 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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
codepulse/cli.py
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
14
|
+
from rich.syntax import Syntax
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.tree import Tree
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import text
|
|
19
|
+
|
|
20
|
+
from codepulse.embeddings.embedder import CodeEmbedder
|
|
21
|
+
from codepulse.indexer.engine import IndexingEngine
|
|
22
|
+
from codepulse.parser.factory import get_parser_for_file
|
|
23
|
+
from codepulse.parser.graph import CallGraphBuilder
|
|
24
|
+
from codepulse.storage.db import StorageManager
|
|
25
|
+
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
try:
|
|
28
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
29
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
console = Console(safe_box=True)
|
|
34
|
+
_stderr_console = Console(safe_box=True, stderr=True)
|
|
35
|
+
|
|
36
|
+
# Subcommands whose positional `path` is a repository directory that must exist.
|
|
37
|
+
_REPOSITORY_PATH_COMMANDS = frozenset(
|
|
38
|
+
{"index", "update", "search", "impact", "symbol", "refs", "callers", "callees", "similar", "status", "modules"}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _display_db_path(db_url: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Resolves a `sqlite:///<path>` URL's <path> component to an absolute
|
|
45
|
+
path (against the CURRENT working directory, matching how
|
|
46
|
+
`create_engine` itself would resolve it) purely for display -- so
|
|
47
|
+
"which exact file is this command about to touch" is unambiguous,
|
|
48
|
+
regardless of whether `db_url` came from a CLI flag, an env var, a
|
|
49
|
+
relative `codepulse.toml` entry, or the plain relative default.
|
|
50
|
+
Non-sqlite URLs (e.g. a future Postgres backend) are shown as-is.
|
|
51
|
+
"""
|
|
52
|
+
prefix = "sqlite:///"
|
|
53
|
+
if not db_url.startswith(prefix):
|
|
54
|
+
return db_url
|
|
55
|
+
raw_path = db_url[len(prefix):]
|
|
56
|
+
if os.path.isabs(raw_path):
|
|
57
|
+
return db_url
|
|
58
|
+
return prefix + os.path.abspath(raw_path).replace(os.sep, "/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_parse(args: argparse.Namespace) -> None:
|
|
62
|
+
"""Parses a single file and displays extracted AST symbols and complexity."""
|
|
63
|
+
file_path = args.file_path
|
|
64
|
+
if not os.path.exists(file_path):
|
|
65
|
+
console.print(f"[bold red]Error:[/bold red] File not found: {file_path}")
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
parser = get_parser_for_file(file_path)
|
|
69
|
+
if not parser:
|
|
70
|
+
console.print(f"[bold red]Error:[/bold red] No parser available for file: {file_path}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
74
|
+
content = f.read()
|
|
75
|
+
|
|
76
|
+
result = parser.parse_file(file_path, content)
|
|
77
|
+
|
|
78
|
+
console.print(
|
|
79
|
+
Panel.fit(
|
|
80
|
+
f"[bold cyan]File:[/bold cyan] {file_path}\n"
|
|
81
|
+
f"[bold cyan]Language:[/bold cyan] {result.language.upper()}\n"
|
|
82
|
+
f"[bold cyan]Lines:[/bold cyan] {result.line_count} | [bold cyan]Size:[/bold cyan] {result.file_size_bytes} bytes",
|
|
83
|
+
title="🔍 AST Parse Result",
|
|
84
|
+
border_style="cyan",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 1. Symbols Table
|
|
89
|
+
sym_table = Table(title="✨ Extracted Symbols", border_style="blue", show_header=True)
|
|
90
|
+
sym_table.add_column("Type", style="magenta", width=12)
|
|
91
|
+
sym_table.add_column("Name", style="bold green", width=25)
|
|
92
|
+
sym_table.add_column("Lines", style="yellow", width=10)
|
|
93
|
+
sym_table.add_column("Complexity", style="cyan", width=12)
|
|
94
|
+
sym_table.add_column("Signature / Docstring", style="white")
|
|
95
|
+
|
|
96
|
+
for sym in result.symbols:
|
|
97
|
+
doc_preview = f"\n[italic dim]{sym.docstring[:80]}...[/italic dim]" if sym.docstring else ""
|
|
98
|
+
sym_table.add_row(
|
|
99
|
+
sym.type.value if hasattr(sym.type, "value") else str(sym.type),
|
|
100
|
+
sym.name,
|
|
101
|
+
f"L{sym.start_line}-{sym.end_line}",
|
|
102
|
+
str(sym.complexity),
|
|
103
|
+
f"[bold]{sym.signature}[/bold]{doc_preview}",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
console.print(sym_table)
|
|
107
|
+
|
|
108
|
+
# 2. Imports Table
|
|
109
|
+
if result.imports:
|
|
110
|
+
imp_table = Table(title="📦 Imports", border_style="magenta", show_header=True)
|
|
111
|
+
imp_table.add_column("Module", style="bold yellow")
|
|
112
|
+
imp_table.add_column("Items / Aliases", style="white")
|
|
113
|
+
imp_table.add_column("Line", style="cyan")
|
|
114
|
+
|
|
115
|
+
for imp in result.imports:
|
|
116
|
+
items_str = ", ".join(f"{it.name}" + (f" as {it.alias}" if it.alias else "") for it in imp.items)
|
|
117
|
+
imp_table.add_row(imp.module or "(relative)", items_str or "*", str(imp.line_number))
|
|
118
|
+
|
|
119
|
+
console.print(imp_table)
|
|
120
|
+
|
|
121
|
+
# 3. Call Invocations
|
|
122
|
+
if result.calls:
|
|
123
|
+
call_table = Table(title="📞 Function Calls", border_style="green", show_header=True)
|
|
124
|
+
call_table.add_column("Caller", style="bold cyan")
|
|
125
|
+
call_table.add_column("Callee", style="bold green")
|
|
126
|
+
call_table.add_column("Line", style="yellow")
|
|
127
|
+
|
|
128
|
+
for c in result.calls:
|
|
129
|
+
call_table.add_row(c.caller_symbol, c.callee_name, str(c.line_number))
|
|
130
|
+
|
|
131
|
+
console.print(call_table)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_index_and_search(args: argparse.Namespace) -> None:
|
|
135
|
+
"""Indexes a repository and opens an interactive search prompt or executes a search."""
|
|
136
|
+
path = args.path
|
|
137
|
+
if not os.path.exists(path):
|
|
138
|
+
console.print(f"[bold red]Error:[/bold red] Path not found: {path}")
|
|
139
|
+
sys.exit(1)
|
|
140
|
+
|
|
141
|
+
console.print(f"[bold cyan]⚡ Indexing repository:[/bold cyan] {path}...")
|
|
142
|
+
start_time = time.time()
|
|
143
|
+
|
|
144
|
+
engine = IndexingEngine()
|
|
145
|
+
|
|
146
|
+
with Progress(
|
|
147
|
+
SpinnerColumn(),
|
|
148
|
+
TextColumn("[progress.description]{task.description}"),
|
|
149
|
+
BarColumn(),
|
|
150
|
+
TimeElapsedColumn(),
|
|
151
|
+
console=console,
|
|
152
|
+
) as progress:
|
|
153
|
+
task = progress.add_task("[cyan]Processing files...", total=100)
|
|
154
|
+
|
|
155
|
+
def cb(rel_path: str, curr: int, total: int):
|
|
156
|
+
progress.update(task, completed=int((curr / max(1, total)) * 100), description=f"[cyan]{rel_path[:30]}")
|
|
157
|
+
|
|
158
|
+
stats = engine.index_directory(path, repo_name=os.path.basename(path), progress_callback=cb)
|
|
159
|
+
|
|
160
|
+
elapsed = time.time() - start_time
|
|
161
|
+
console.print(
|
|
162
|
+
Panel(
|
|
163
|
+
f"[bold green]✓ Indexing Complete in {elapsed:.2f}s[/bold green]\n"
|
|
164
|
+
f"Files: [bold]{stats['total_files']}[/bold] | Symbols: [bold]{stats['total_symbols']}[/bold] | "
|
|
165
|
+
f"Chunks: [bold]{stats['total_chunks']}[/bold] | Call Graph Edges: [bold]{stats['call_graph_edges']}[/bold]",
|
|
166
|
+
title="📊 Repository Stats",
|
|
167
|
+
border_style="green",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# If a query is provided directly
|
|
172
|
+
if args.query:
|
|
173
|
+
_perform_search(engine, args.query, args.type, args.limit, json_output=getattr(args, "json", False))
|
|
174
|
+
else:
|
|
175
|
+
# Interactive REPL mode
|
|
176
|
+
console.print("[bold yellow]Interactive Search Mode. Type 'exit' to quit.[/bold yellow]\n")
|
|
177
|
+
while True:
|
|
178
|
+
try:
|
|
179
|
+
query = console.input("[bold cyan]CodePulse Search > [/bold cyan]").strip()
|
|
180
|
+
if query.lower() in ("exit", "quit", "q"):
|
|
181
|
+
break
|
|
182
|
+
if not query:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
_perform_search(engine, query, args.type, args.limit)
|
|
186
|
+
except KeyboardInterrupt:
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _perform_search(engine: IndexingEngine, query: str, search_type: str, limit: int, json_output: bool = False) -> None:
|
|
191
|
+
t0 = time.time()
|
|
192
|
+
results = engine.search(query, limit=limit, search_type=search_type)
|
|
193
|
+
search_ms = (time.time() - t0) * 1000
|
|
194
|
+
|
|
195
|
+
if json_output:
|
|
196
|
+
print(json.dumps({"query": query, "search_type": search_type, "results": [r.to_dict() for r in results]}, indent=2, default=str))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if not results:
|
|
200
|
+
console.print(f"[yellow]No matching code symbols found for '{query}'[/yellow]\n")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
table = Table(
|
|
204
|
+
title=f"🔎 Search Results for '{query}' ({search_type.upper()} - {search_ms:.1f}ms)",
|
|
205
|
+
border_style="cyan",
|
|
206
|
+
show_header=True,
|
|
207
|
+
)
|
|
208
|
+
table.add_column("Rank", style="dim", width=5)
|
|
209
|
+
table.add_column("Symbol", style="bold green", width=24)
|
|
210
|
+
table.add_column("File : Line", style="yellow", width=30)
|
|
211
|
+
table.add_column("Score", style="bold cyan", width=8)
|
|
212
|
+
table.add_column("Breakdown (KW / Vec / Graph)", style="magenta", width=25)
|
|
213
|
+
table.add_column("Signature / Preview", style="white")
|
|
214
|
+
|
|
215
|
+
for rank, res in enumerate(results, start=1):
|
|
216
|
+
score_breakdown = f"{res.keyword_score:.2f} / {res.vector_score:.2f} / {res.graph_score:.2f}"
|
|
217
|
+
table.add_row(
|
|
218
|
+
str(rank),
|
|
219
|
+
f"[{res.symbol_type}] {res.symbol_name}",
|
|
220
|
+
f"{res.file_path}:{res.start_line}",
|
|
221
|
+
f"{res.final_score:.2f}",
|
|
222
|
+
score_breakdown,
|
|
223
|
+
res.signature or res.snippet[:80],
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
console.print(table)
|
|
227
|
+
console.print()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_impact(args: argparse.Namespace) -> None:
|
|
231
|
+
"""Performs upstream and downstream blast-radius impact analysis on a symbol."""
|
|
232
|
+
path = args.path
|
|
233
|
+
symbol_name = args.symbol
|
|
234
|
+
engine = IndexingEngine()
|
|
235
|
+
result = engine.index_directory(path)
|
|
236
|
+
|
|
237
|
+
if not engine.call_graph:
|
|
238
|
+
if getattr(args, "json", False):
|
|
239
|
+
print(json.dumps({"error": "call_graph_not_available", "message": "Call graph not available."}))
|
|
240
|
+
else:
|
|
241
|
+
console.print("[red]Call graph not available.[/red]")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
# Phase 4.2 (roadmap §37 item 4.2): redirected to the shared
|
|
245
|
+
# `IndexingEngine.analyze_impact` intelligence-core method instead of
|
|
246
|
+
# calling `CallGraphBuilder.get_callers`/`get_callees` directly (this
|
|
247
|
+
# milestone's own audit found that duplicated the REST route's
|
|
248
|
+
# identical logic a second time here). `result["repository_id"]` is
|
|
249
|
+
# already returned by `index_directory` above -- no new CLI feature
|
|
250
|
+
# or flag needed to plumb it through. The CLI's own presentation
|
|
251
|
+
# (this Rich `Tree`) is unchanged.
|
|
252
|
+
repository_id = result["repository_id"]
|
|
253
|
+
impact = engine.analyze_impact(symbol_name, "", repository_id, max_depth=args.depth)
|
|
254
|
+
if getattr(args, "json", False):
|
|
255
|
+
print(json.dumps(impact, indent=2, default=str))
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
console.print(Panel(f"[bold magenta]Impact Analysis for symbol:[/bold magenta] [bold green]{symbol_name}[/bold green]"))
|
|
259
|
+
callers = impact["call_chain"]
|
|
260
|
+
callees = impact["dependencies"]
|
|
261
|
+
|
|
262
|
+
# Tree visualization
|
|
263
|
+
tree = Tree(f"[bold yellow]🎯 Symbol: {symbol_name}[/bold yellow]")
|
|
264
|
+
|
|
265
|
+
callers_branch = tree.add(f"[bold red]▲ Blast Radius (Upstream Callers - {len(callers)} found)[/bold red]")
|
|
266
|
+
for c in callers:
|
|
267
|
+
callers_branch.add(f"[cyan]{c['symbol']}[/cyan] ({c['file']}) [dim]depth={c['depth']}[/dim] [dim]confidence={c.get('confidence')}[/dim]")
|
|
268
|
+
|
|
269
|
+
callees_branch = tree.add(f"[bold blue]▼ Dependencies (Downstream Callees - {len(callees)} found)[/bold blue]")
|
|
270
|
+
for c in callees:
|
|
271
|
+
callees_branch.add(f"[cyan]{c['symbol']}[/cyan] ({c['file']}) [dim]depth={c['depth']}[/dim] [dim]confidence={c.get('confidence')}[/dim]")
|
|
272
|
+
|
|
273
|
+
console.print(tree)
|
|
274
|
+
console.print()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- Phase 6.1 (roadmap §37 item 6.1 "Full CLI command set"; §22 CLI
|
|
278
|
+
# Strategy; §45 backlog B21: "All §22 commands implemented with --json
|
|
279
|
+
# output"). Every command below follows the exact same established
|
|
280
|
+
# pattern `search`/`impact` already use: construct a fresh
|
|
281
|
+
# `IndexingEngine`, call `index_directory(path)` (cheap and idempotent
|
|
282
|
+
# for an already-indexed, unchanged repository, per Phase 3's own
|
|
283
|
+
# incremental-indexing machinery -- not a new "persistent session"
|
|
284
|
+
# mechanism), then delegate to the SAME shared `IndexingEngine.analyze_*`
|
|
285
|
+
# methods the REST/MCP adapters already call. No business logic is
|
|
286
|
+
# reimplemented here. --------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _print_json(data) -> None:
|
|
289
|
+
print(json.dumps(data, indent=2, default=str))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
293
|
+
"""Creates a starter codepulse.toml in the target directory -- no infra required (§25 Local Deployment Strategy)."""
|
|
294
|
+
target_dir = args.path
|
|
295
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
296
|
+
config_path = os.path.join(target_dir, "codepulse.toml")
|
|
297
|
+
if os.path.exists(config_path) and not args.force:
|
|
298
|
+
console.print(f"[yellow]{config_path} already exists -- use --force to overwrite.[/yellow]")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
from codepulse.config import CodePulseConfig
|
|
302
|
+
defaults = CodePulseConfig()
|
|
303
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
304
|
+
f.write(
|
|
305
|
+
"# CodePulse local configuration -- see docs/CodePulse_Strategy_and_Roadmap.md §25.\n"
|
|
306
|
+
"# Precedence: CLI flags > CODEPULSE_* env vars > this file > built-in defaults.\n\n"
|
|
307
|
+
"# Keep the database in THIS directory (project-local). Remove the line to use the default: one database\n"
|
|
308
|
+
"# per user, in your operating system's per-user data directory.\n"
|
|
309
|
+
'db_url = "sqlite:///codepulse.db"\n'
|
|
310
|
+
f'embedding_model = "{defaults.embedding_model}"\n'
|
|
311
|
+
)
|
|
312
|
+
console.print(f"[bold green]✓[/bold green] Created {config_path}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_index(args: argparse.Namespace) -> None:
|
|
316
|
+
"""Full index of a repository (standalone -- `search` already indexes as a side effect; this command exists for a plain `codepulse index <path>` workflow with no query)."""
|
|
317
|
+
engine = IndexingEngine()
|
|
318
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
319
|
+
if args.json:
|
|
320
|
+
_print_json(result)
|
|
321
|
+
return
|
|
322
|
+
console.print(
|
|
323
|
+
Panel(
|
|
324
|
+
f"[bold green]✓ Indexed[/bold green] {args.path}\n"
|
|
325
|
+
f"Repository id: [bold]{result['repository_id']}[/bold] | "
|
|
326
|
+
f"Files: [bold]{result.get('total_files', 0)}[/bold] | "
|
|
327
|
+
f"Incremental: [bold]{result.get('incremental', False)}[/bold]",
|
|
328
|
+
title="📊 Index Result",
|
|
329
|
+
border_style="green",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cmd_update(args: argparse.Namespace) -> None:
|
|
335
|
+
"""Incremental (git-diff based when available) re-index -- a thin, clearer-intent alias over the same index_directory() call, which already detects and prefers an incremental run automatically (Phase 3.1)."""
|
|
336
|
+
engine = IndexingEngine()
|
|
337
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
338
|
+
if args.json:
|
|
339
|
+
_print_json(result)
|
|
340
|
+
return
|
|
341
|
+
kind = "incremental" if result.get("incremental") else "full"
|
|
342
|
+
console.print(f"[bold green]✓[/bold green] Update complete ({kind}) -- {result.get('total_files', 0)} file(s) processed.")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _resolve_symbol(engine: IndexingEngine, repo_id: int, name: str):
|
|
346
|
+
"""Looks up a symbol by (repo_id, name) -- a repo can have more than
|
|
347
|
+
one symbol sharing a name (overloads, methods on different
|
|
348
|
+
classes), so this returns the full match list, never silently
|
|
349
|
+
picking one."""
|
|
350
|
+
from codepulse.storage.db import FileModel, SymbolModel
|
|
351
|
+
|
|
352
|
+
session = engine.storage.get_session()
|
|
353
|
+
try:
|
|
354
|
+
matches = (
|
|
355
|
+
session.query(SymbolModel)
|
|
356
|
+
.join(FileModel)
|
|
357
|
+
.filter(FileModel.repo_id == repo_id, SymbolModel.name == name)
|
|
358
|
+
.all()
|
|
359
|
+
)
|
|
360
|
+
return [
|
|
361
|
+
{
|
|
362
|
+
"id": s.id, "name": s.name, "type": s.type, "file_path": s.file.path,
|
|
363
|
+
"signature": s.signature, "docstring": s.docstring, "body": s.body,
|
|
364
|
+
"line_start": s.start_line, "line_end": s.end_line,
|
|
365
|
+
}
|
|
366
|
+
for s in matches
|
|
367
|
+
]
|
|
368
|
+
finally:
|
|
369
|
+
session.close()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def cmd_symbol(args: argparse.Namespace) -> None:
|
|
373
|
+
"""Looks up and displays one symbol's definition."""
|
|
374
|
+
engine = IndexingEngine()
|
|
375
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
376
|
+
matches = _resolve_symbol(engine, result["repository_id"], args.symbol)
|
|
377
|
+
|
|
378
|
+
if not matches:
|
|
379
|
+
if args.json:
|
|
380
|
+
_print_json({"error": "symbol_not_found", "message": f"No symbol named {args.symbol!r}."})
|
|
381
|
+
else:
|
|
382
|
+
console.print(f"[red]No symbol named '{args.symbol}' found.[/red]")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
if args.json:
|
|
386
|
+
_print_json({"matches": matches})
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
for m in matches:
|
|
390
|
+
console.print(
|
|
391
|
+
Panel(
|
|
392
|
+
f"[bold cyan]{m['signature']}[/bold cyan]\n\n"
|
|
393
|
+
f"{m['docstring'] or '[dim](no docstring)[/dim]'}\n\n"
|
|
394
|
+
f"[dim]{m['file_path']}:{m['line_start']}-{m['line_end']}[/dim]",
|
|
395
|
+
title=f"[{m['type']}] {m['name']}",
|
|
396
|
+
border_style="cyan",
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cmd_refs(args: argparse.Namespace) -> None:
|
|
402
|
+
"""Direct references (callers + direct subclasses) -- Phase 4.4's IndexingEngine.analyze_references, reused unmodified."""
|
|
403
|
+
engine = IndexingEngine()
|
|
404
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
405
|
+
refs = engine.analyze_references(args.symbol, "", result["repository_id"])
|
|
406
|
+
if args.json:
|
|
407
|
+
_print_json(refs)
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
tree = Tree(f"[bold yellow]References: {args.symbol}[/bold yellow]")
|
|
411
|
+
callers_branch = tree.add(f"[bold cyan]Direct callers ({len(refs['callers'])})[/bold cyan]")
|
|
412
|
+
for c in refs["callers"]:
|
|
413
|
+
callers_branch.add(f"{c['symbol']} ({c['file']}) [dim]confidence={c.get('confidence')}[/dim]")
|
|
414
|
+
sub_branch = tree.add(f"[bold magenta]Direct subclasses ({len(refs['subclasses'])})[/bold magenta]")
|
|
415
|
+
for s in refs["subclasses"]:
|
|
416
|
+
sub_branch.add(f"{s['symbol']} ({s['file']}) [dim]confidence={s.get('confidence')}[/dim]")
|
|
417
|
+
console.print(tree)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cmd_callers(args: argparse.Namespace) -> None:
|
|
421
|
+
"""Callers of a symbol, direct + transitive (a projection of IndexingEngine.analyze_impact)."""
|
|
422
|
+
engine = IndexingEngine()
|
|
423
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
424
|
+
impact = engine.analyze_impact(args.symbol, "", result["repository_id"], max_depth=args.depth)
|
|
425
|
+
if args.json:
|
|
426
|
+
_print_json({"symbol": args.symbol, "callers": impact["call_chain"]})
|
|
427
|
+
return
|
|
428
|
+
tree = Tree(f"[bold red]▲ Callers of {args.symbol} ({len(impact['call_chain'])} found)[/bold red]")
|
|
429
|
+
for c in impact["call_chain"]:
|
|
430
|
+
tree.add(f"{c['symbol']} ({c['file']}) [dim]depth={c['depth']} confidence={c.get('confidence')}[/dim]")
|
|
431
|
+
console.print(tree)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_callees(args: argparse.Namespace) -> None:
|
|
435
|
+
"""Symbols called BY a symbol, direct + transitive (a projection of IndexingEngine.analyze_impact)."""
|
|
436
|
+
engine = IndexingEngine()
|
|
437
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
438
|
+
impact = engine.analyze_impact(args.symbol, "", result["repository_id"], max_depth=args.depth)
|
|
439
|
+
if args.json:
|
|
440
|
+
_print_json({"symbol": args.symbol, "callees": impact["dependencies"]})
|
|
441
|
+
return
|
|
442
|
+
tree = Tree(f"[bold blue]▼ Callees of {args.symbol} ({len(impact['dependencies'])} found)[/bold blue]")
|
|
443
|
+
for c in impact["dependencies"]:
|
|
444
|
+
tree.add(f"{c['symbol']} ({c['file']}) [dim]depth={c['depth']} confidence={c.get('confidence')}[/dim]")
|
|
445
|
+
console.print(tree)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def cmd_similar(args: argparse.Namespace) -> None:
|
|
449
|
+
"""Semantically similar symbols (IndexingEngine.find_similar_symbols, Phase 5.1)."""
|
|
450
|
+
engine = IndexingEngine()
|
|
451
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
452
|
+
matches = _resolve_symbol(engine, result["repository_id"], args.symbol)
|
|
453
|
+
if not matches:
|
|
454
|
+
if args.json:
|
|
455
|
+
_print_json({"error": "symbol_not_found", "message": f"No symbol named {args.symbol!r}."})
|
|
456
|
+
else:
|
|
457
|
+
console.print(f"[red]No symbol named '{args.symbol}' found.[/red]")
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
m = matches[0]
|
|
461
|
+
query_text = f"{m['signature']}\n{m['docstring'] or ''}\n{m['body'][:500]}"
|
|
462
|
+
similar = engine.find_similar_symbols(
|
|
463
|
+
query_text, m["name"], result["repository_id"], threshold=args.threshold, limit=args.limit
|
|
464
|
+
)
|
|
465
|
+
if args.json:
|
|
466
|
+
_print_json({"query_symbol": {"id": m["id"], "name": m["name"]}, "similar": similar})
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
table = Table(title=f"Symbols similar to {args.symbol}", border_style="cyan")
|
|
470
|
+
table.add_column("Symbol", style="bold green")
|
|
471
|
+
table.add_column("File", style="yellow")
|
|
472
|
+
table.add_column("Relevance", style="cyan")
|
|
473
|
+
for s in similar:
|
|
474
|
+
table.add_row(s["symbol_name"], s["file_path"], f"{s['relevance_score']:.2f}")
|
|
475
|
+
console.print(table)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
479
|
+
"""Index freshness -- last snapshot commit/time, file/symbol counts. Honest, not fabricated: no job history is reported (local CLI indexing runs synchronously and never creates IndexJob rows -- those are REST's async ingestion flow, Phase 1.3)."""
|
|
480
|
+
from codepulse.storage.db import RepositoryModel, RepositorySnapshotModel
|
|
481
|
+
|
|
482
|
+
engine = IndexingEngine()
|
|
483
|
+
session = engine.storage.get_session()
|
|
484
|
+
try:
|
|
485
|
+
from codepulse.repository_identity import canonical_repository_url, find_repository
|
|
486
|
+
|
|
487
|
+
repo_url = canonical_repository_url(args.path)
|
|
488
|
+
repo = find_repository(session, args.path)
|
|
489
|
+
if not repo:
|
|
490
|
+
payload = {"indexed": False, "path": repo_url}
|
|
491
|
+
if args.json:
|
|
492
|
+
_print_json(payload)
|
|
493
|
+
else:
|
|
494
|
+
console.print(f"[yellow]{repo_url} has not been indexed yet -- run `codepulse index {args.path}`.[/yellow]")
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
snapshot = (
|
|
498
|
+
session.query(RepositorySnapshotModel)
|
|
499
|
+
.filter(RepositorySnapshotModel.repo_id == repo.id)
|
|
500
|
+
.order_by(RepositorySnapshotModel.id.desc())
|
|
501
|
+
.first()
|
|
502
|
+
)
|
|
503
|
+
payload = {
|
|
504
|
+
"indexed": True,
|
|
505
|
+
"repository_id": repo.id,
|
|
506
|
+
"path": repo_url,
|
|
507
|
+
"file_count": repo.file_count,
|
|
508
|
+
"symbol_count": repo.symbol_count,
|
|
509
|
+
"last_snapshot_commit": snapshot.commit_sha if snapshot else None,
|
|
510
|
+
"last_indexed_at": snapshot.indexed_at if snapshot else None,
|
|
511
|
+
}
|
|
512
|
+
finally:
|
|
513
|
+
session.close()
|
|
514
|
+
|
|
515
|
+
if args.json:
|
|
516
|
+
_print_json(payload)
|
|
517
|
+
return
|
|
518
|
+
console.print(
|
|
519
|
+
Panel(
|
|
520
|
+
f"Repository id: [bold]{payload['repository_id']}[/bold]\n"
|
|
521
|
+
f"Files: [bold]{payload['file_count']}[/bold] | Symbols: [bold]{payload['symbol_count']}[/bold]\n"
|
|
522
|
+
f"Last snapshot commit: [bold]{payload['last_snapshot_commit'] or '(none / non-git)'}[/bold]\n"
|
|
523
|
+
f"Last indexed at: [bold]{payload['last_indexed_at']}[/bold]",
|
|
524
|
+
title=f"📈 Status: {args.path}",
|
|
525
|
+
border_style="cyan",
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def cmd_modules(args: argparse.Namespace) -> None:
|
|
531
|
+
"""
|
|
532
|
+
Module clusters + raw inbound/outbound degree rankings, via
|
|
533
|
+
IndexingEngine.analyze_module_structure (Phase 4.3's ModuleGraphBuilder,
|
|
534
|
+
reused unmodified). Backend-hardening addition (not a roadmap sprint
|
|
535
|
+
item) -- this engine capability existed since Phase 4.3 with no CLI
|
|
536
|
+
caller until now.
|
|
537
|
+
"""
|
|
538
|
+
engine = IndexingEngine()
|
|
539
|
+
result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
|
|
540
|
+
module_graph = engine.analyze_module_structure(result["repository_id"])
|
|
541
|
+
payload = {
|
|
542
|
+
"repository_id": result["repository_id"],
|
|
543
|
+
"clusters": module_graph.clusters,
|
|
544
|
+
"ranked_by_inbound": module_graph.rank_by_inbound(),
|
|
545
|
+
"ranked_by_outbound": module_graph.rank_by_outbound(),
|
|
546
|
+
}
|
|
547
|
+
if args.json:
|
|
548
|
+
_print_json(payload)
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
tree = Tree(f"[bold magenta]Module clusters ({len(payload['clusters'])})[/bold magenta]")
|
|
552
|
+
for cluster in payload["clusters"]:
|
|
553
|
+
branch = tree.add(f"[bold]Cluster {cluster['id']}[/bold] ({len(cluster['files'])} files)")
|
|
554
|
+
for f in cluster["files"]:
|
|
555
|
+
branch.add(f)
|
|
556
|
+
console.print(tree)
|
|
557
|
+
|
|
558
|
+
table = Table(title="Top files by inbound edges (possible API/entry points, per §11's literal heuristic)", border_style="cyan")
|
|
559
|
+
table.add_column("File", style="yellow")
|
|
560
|
+
table.add_column("In", justify="right")
|
|
561
|
+
table.add_column("Out", justify="right")
|
|
562
|
+
for row in payload["ranked_by_inbound"][:10]:
|
|
563
|
+
table.add_row(row["file"], str(row["in_degree"]), str(row["out_degree"]))
|
|
564
|
+
console.print(table)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _schema_warning(status) -> str | None:
|
|
568
|
+
"""One line telling the user their database needs `codepulse migrate`, or None when nothing is wrong."""
|
|
569
|
+
if status.state == "behind":
|
|
570
|
+
return f"Database schema is {len(status.pending)} revision(s) behind ({status.current} -> {status.head}): run `codepulse migrate`."
|
|
571
|
+
if status.state == "unstamped":
|
|
572
|
+
extra = f" and create missing table(s) {', '.join(status.missing_tables)}" if status.missing_tables else ""
|
|
573
|
+
return f"Database is not tracked by Alembic: run `codepulse migrate` (its schema matches, so it will just be stamped{extra})."
|
|
574
|
+
if status.state == "diverged":
|
|
575
|
+
return "Database schema differs from this CodePulse and cannot be stamped automatically: run `codepulse migrate` for details."
|
|
576
|
+
if status.state == "unknown":
|
|
577
|
+
return f"Database is at a revision this CodePulse does not know ({status.current}); it may belong to a newer version."
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def cmd_migrate(args: argparse.Namespace) -> None:
|
|
582
|
+
"""
|
|
583
|
+
Brings the database to the current schema, safely (item 17): creates a new one, upgrades one that is
|
|
584
|
+
behind (after a consistent backup of the SQLite file), stamps one that CodePulse created itself before
|
|
585
|
+
migrations were tracked (only if its schema matches the models), and REFUSES -- changing nothing -- one
|
|
586
|
+
that differs or comes from a newer CodePulse. `--check` only reports. Exit code: 0 up to date or done,
|
|
587
|
+
1 needs an upgrade/stamp (with --check) or was refused, 2 refused as unsafe in --check mode.
|
|
588
|
+
"""
|
|
589
|
+
from codepulse.config import get_settings
|
|
590
|
+
from codepulse.storage.migrations import MigrationRefused, get_schema_status, migrate_database
|
|
591
|
+
|
|
592
|
+
settings = get_settings()
|
|
593
|
+
try:
|
|
594
|
+
result = migrate_database(settings.db_url, backup=not args.no_backup, check=args.check)
|
|
595
|
+
except MigrationRefused as e:
|
|
596
|
+
status = get_schema_status(settings.db_url)
|
|
597
|
+
if args.json:
|
|
598
|
+
_print_json({"action": "refused", "state": status.state, "from": status.current, "to": status.head,
|
|
599
|
+
"differences": status.differences, "message": str(e)})
|
|
600
|
+
else:
|
|
601
|
+
_stderr_console.print(f"[bold red]Refused:[/bold red] {e}")
|
|
602
|
+
for d in status.differences:
|
|
603
|
+
_stderr_console.print(f" - {d}")
|
|
604
|
+
sys.exit(1)
|
|
605
|
+
|
|
606
|
+
payload = {
|
|
607
|
+
"action": result.action, "state": result.state, "from": result.from_revision, "to": result.to_revision,
|
|
608
|
+
"applied": result.applied, "backup": result.backup_path, "detail": result.detail,
|
|
609
|
+
}
|
|
610
|
+
if args.json:
|
|
611
|
+
_print_json(payload)
|
|
612
|
+
else:
|
|
613
|
+
colour = {"created": "green", "upgraded": "green", "stamped": "green", "up-to-date": "green"}.get(result.action, "yellow")
|
|
614
|
+
console.print(f"[{colour}]{result.action}[/{colour}]: {result.detail or result.state}")
|
|
615
|
+
if result.backup_path:
|
|
616
|
+
console.print(f"[dim]backup of the previous state: {result.backup_path}[/dim]")
|
|
617
|
+
if result.action in ("needs-upgrade", "needs-stamp"):
|
|
618
|
+
console.print("Run `codepulse migrate` to apply.")
|
|
619
|
+
if args.check:
|
|
620
|
+
sys.exit({"up-to-date": 0, "needs-upgrade": 1, "needs-stamp": 1, "blocked": 2}.get(result.action, 0))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def cmd_key_create(args: argparse.Namespace) -> None:
|
|
624
|
+
"""
|
|
625
|
+
Creates an API key scoped to the given folders: how a new user gets their first key (a fresh install has none, and
|
|
626
|
+
`POST /v1/admin/keys` needs an admin key to exist already). A folder not in the database yet is registered as an
|
|
627
|
+
empty repository, so the editor can then index it with this key. The raw key is printed once, on stdout, and only
|
|
628
|
+
its hash is stored. A key with no repository grant can do nothing, so at least one `--repo` is required.
|
|
629
|
+
"""
|
|
630
|
+
from codepulse.auth import create_api_key, register_repository
|
|
631
|
+
from codepulse.config import get_settings
|
|
632
|
+
from codepulse.repository_identity import canonical_repository_url
|
|
633
|
+
from codepulse.storage.db import StorageManager
|
|
634
|
+
|
|
635
|
+
storage = StorageManager(db_url=get_settings().db_url)
|
|
636
|
+
repositories: list[dict[str, Any]] = []
|
|
637
|
+
seen: set[str] = set()
|
|
638
|
+
for path in args.repo:
|
|
639
|
+
canonical = canonical_repository_url(os.path.abspath(path))
|
|
640
|
+
if canonical in seen:
|
|
641
|
+
continue
|
|
642
|
+
seen.add(canonical)
|
|
643
|
+
name = os.path.basename(canonical.rstrip("/\\")) or canonical
|
|
644
|
+
repo_id, created = register_repository(storage, name, canonical)
|
|
645
|
+
repositories.append({"id": repo_id, "name": name, "path": canonical, "registered": created})
|
|
646
|
+
|
|
647
|
+
created_key = create_api_key(storage, args.name, [r["id"] for r in repositories], role=args.role)
|
|
648
|
+
|
|
649
|
+
if args.json:
|
|
650
|
+
_print_json({
|
|
651
|
+
"id": created_key.id, "name": created_key.name, "role": args.role, "key": created_key.raw_key,
|
|
652
|
+
"repositories": repositories,
|
|
653
|
+
})
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
console.print(f"[bold green]✓ Created API key[/bold green] '{created_key.name}' (id {created_key.id}, role {args.role}) for:")
|
|
657
|
+
for r in repositories:
|
|
658
|
+
note = "registered, not indexed yet" if r["registered"] else "already known"
|
|
659
|
+
console.print(f" {r['id']} {r['name']} [dim]{r['path']} ({note})[/dim]")
|
|
660
|
+
console.print("[bold]Your key (shown once and never stored, so copy it now):[/bold]")
|
|
661
|
+
print(created_key.raw_key) # plain print: exactly the key, on a line of its own
|
|
662
|
+
console.print("Next, in VS Code (Command Palette):")
|
|
663
|
+
console.print(" 1. [bold]CodePulse: Set API Key[/bold] and paste the key")
|
|
664
|
+
console.print(" 2. [bold]CodePulse: Index Workspace[/bold]")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
668
|
+
"""Environment/config diagnostics -- embedding model loads? DB reachable? ignore rules active?"""
|
|
669
|
+
from codepulse.config import get_settings
|
|
670
|
+
from codepulse.indexer.ignore import RepositoryIgnoreRules
|
|
671
|
+
|
|
672
|
+
settings = get_settings()
|
|
673
|
+
checks: list[dict] = []
|
|
674
|
+
|
|
675
|
+
checks.append({"check": "config", "ok": True, "detail": f"db_url={settings.db_url}, embedding_model={settings.embedding_model}"})
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
storage = StorageManager(db_url=settings.db_url)
|
|
679
|
+
session = storage.get_session()
|
|
680
|
+
session.execute(text("SELECT 1"))
|
|
681
|
+
session.close()
|
|
682
|
+
checks.append({"check": "database_reachable", "ok": True, "detail": settings.db_url})
|
|
683
|
+
except Exception as e:
|
|
684
|
+
checks.append({"check": "database_reachable", "ok": False, "detail": str(e)})
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
from codepulse.storage.migrations import get_schema_status
|
|
688
|
+
|
|
689
|
+
schema = get_schema_status(settings.db_url)
|
|
690
|
+
warning = _schema_warning(schema)
|
|
691
|
+
checks.append({"check": "database_schema", "ok": warning is None, "detail": warning or schema.detail})
|
|
692
|
+
except Exception as e:
|
|
693
|
+
checks.append({"check": "database_schema", "ok": False, "detail": str(e)})
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
embedder = CodeEmbedder()
|
|
697
|
+
_ = embedder.encode("def f(): pass")
|
|
698
|
+
checks.append({"check": "embedding_model_loads", "ok": True, "detail": settings.embedding_model})
|
|
699
|
+
except Exception as e:
|
|
700
|
+
checks.append({"check": "embedding_model_loads", "ok": False, "detail": str(e)})
|
|
701
|
+
|
|
702
|
+
target = args.path or "."
|
|
703
|
+
try:
|
|
704
|
+
rules = RepositoryIgnoreRules(target)
|
|
705
|
+
checks.append({"check": "ignore_rules_active", "ok": True, "detail": f"{target}"})
|
|
706
|
+
except Exception as e:
|
|
707
|
+
checks.append({"check": "ignore_rules_active", "ok": False, "detail": str(e)})
|
|
708
|
+
|
|
709
|
+
if args.json:
|
|
710
|
+
_print_json({"checks": checks})
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
table = Table(title="🩺 CodePulse Doctor", border_style="green")
|
|
714
|
+
table.add_column("Check", style="bold")
|
|
715
|
+
table.add_column("Status")
|
|
716
|
+
table.add_column("Detail", style="dim")
|
|
717
|
+
for c in checks:
|
|
718
|
+
status = "[bold green]OK[/bold green]" if c["ok"] else "[bold red]FAIL[/bold red]"
|
|
719
|
+
table.add_row(c["check"], status, c["detail"])
|
|
720
|
+
console.print(table)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def resolve_mcp_api_key(cli_value: str | None, env: Mapping[str, str] | None = None) -> str | None:
|
|
724
|
+
"""
|
|
725
|
+
Resolves the raw API key `--mcp` scopes its connection to, per §13.
|
|
726
|
+
|
|
727
|
+
`--api-key` takes priority when given. Otherwise falls back to the
|
|
728
|
+
`CODEPULSE_MCP_API_KEY` environment variable -- added specifically so
|
|
729
|
+
a process SPAWNING `codepulse serve --mcp` (the VS Code extension's
|
|
730
|
+
Phase 3 MCP integration; any other MCP client launcher) never has to
|
|
731
|
+
put a secret directly into that child process's command-line
|
|
732
|
+
arguments, which are visible to other processes on the same machine
|
|
733
|
+
(`ps`/Task Manager, `/proc/[pid]/cmdline`) -- a real, avoidable
|
|
734
|
+
exposure `--api-key`-only would otherwise force on every caller.
|
|
735
|
+
"""
|
|
736
|
+
if cli_value:
|
|
737
|
+
return cli_value
|
|
738
|
+
return (os.environ if env is None else env).get("CODEPULSE_MCP_API_KEY") or None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
742
|
+
"""Starts the local REST API (default), or the MCP server over stdio with --mcp (§22: "start local API/MCP server")."""
|
|
743
|
+
if args.mcp:
|
|
744
|
+
api_key = resolve_mcp_api_key(args.api_key)
|
|
745
|
+
if not api_key:
|
|
746
|
+
_stderr_console.print(
|
|
747
|
+
"[bold red]Error:[/bold red] --api-key (or the CODEPULSE_MCP_API_KEY environment "
|
|
748
|
+
"variable) is required for --mcp (MCP connections are scoped to repositories at "
|
|
749
|
+
"connection-setup time, §13)."
|
|
750
|
+
)
|
|
751
|
+
sys.exit(1)
|
|
752
|
+
import asyncio
|
|
753
|
+
from codepulse.config import get_settings
|
|
754
|
+
from codepulse.embeddings.embedder import CodeEmbedder as _Embedder
|
|
755
|
+
from codepulse.mcp_server import MCPAuthenticationError, build_mcp_server
|
|
756
|
+
|
|
757
|
+
settings = get_settings()
|
|
758
|
+
storage = StorageManager(db_url=settings.db_url)
|
|
759
|
+
engine = IndexingEngine(storage=storage, embedder=_Embedder())
|
|
760
|
+
try:
|
|
761
|
+
mcp = build_mcp_server(storage, engine, api_key)
|
|
762
|
+
except MCPAuthenticationError as e:
|
|
763
|
+
_stderr_console.print(f"[bold red]Error:[/bold red] {e}")
|
|
764
|
+
sys.exit(1)
|
|
765
|
+
# Load the embedding model on THIS (main) thread before serving.
|
|
766
|
+
# Left lazy, the first semantic/hybrid `search_code` call imports
|
|
767
|
+
# sentence-transformers from the stdio server's tool-call worker
|
|
768
|
+
# thread, which hangs on Windows (seen live: >240s vs 19s here).
|
|
769
|
+
# If it cannot load, still serve -- keyword search works and the
|
|
770
|
+
# semantic path reports EmbeddingUnavailableError explicitly (no
|
|
771
|
+
# silent pseudo-embedding fallback).
|
|
772
|
+
from codepulse.embeddings.embedder import EmbeddingUnavailableError
|
|
773
|
+
try:
|
|
774
|
+
engine.embedder.preload()
|
|
775
|
+
except EmbeddingUnavailableError as e:
|
|
776
|
+
_stderr_console.print(
|
|
777
|
+
f"[bold yellow]Warning:[/bold yellow] embedding model unavailable ({e}); "
|
|
778
|
+
"semantic and hybrid search will return errors, keyword search still works."
|
|
779
|
+
)
|
|
780
|
+
# stdout is the MCP protocol channel: status goes to stderr only.
|
|
781
|
+
_stderr_console.print("[bold green]✓[/bold green] MCP server starting (stdio transport)...")
|
|
782
|
+
asyncio.run(mcp.run_stdio_async())
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
import uvicorn
|
|
786
|
+
console.print(f"[bold green]✓[/bold green] Starting CodePulse API on http://{args.host}:{args.port}")
|
|
787
|
+
uvicorn.run("codepulse.api.app:app", host=args.host, port=args.port)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def main() -> None:
|
|
791
|
+
parser = argparse.ArgumentParser(
|
|
792
|
+
prog="codepulse",
|
|
793
|
+
description="CodePulse: Semantic Codebase Intelligence Engine",
|
|
794
|
+
)
|
|
795
|
+
parser.add_argument("--config", "-c", help="Path to codepulse.toml config file", default=None)
|
|
796
|
+
parser.add_argument("--db-url", help="Override database URL", default=None)
|
|
797
|
+
parser.add_argument("--embedding-model", help="Override embedding model name", default=None)
|
|
798
|
+
|
|
799
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
800
|
+
|
|
801
|
+
# 1. Parse command
|
|
802
|
+
parse_p = subparsers.add_parser("parse", help="Parse a file and view AST symbols, complexity, and calls")
|
|
803
|
+
parse_p.add_argument("file_path", help="Path to source code file")
|
|
804
|
+
|
|
805
|
+
# 2. Search / Index command
|
|
806
|
+
search_p = subparsers.add_parser("search", help="Index and search a repository")
|
|
807
|
+
search_p.add_argument("path", help="Repository path to index")
|
|
808
|
+
search_p.add_argument("--query", "-q", help="Search query (natural language or code identifier)", default=None)
|
|
809
|
+
search_p.add_argument("--type", "-t", choices=["hybrid", "keyword", "semantic"], default="hybrid")
|
|
810
|
+
search_p.add_argument("--limit", "-l", type=int, default=10)
|
|
811
|
+
search_p.add_argument("--json", action="store_true", help="Emit raw JSON instead of a formatted table (requires --query)")
|
|
812
|
+
|
|
813
|
+
# 3. Impact analysis command
|
|
814
|
+
impact_p = subparsers.add_parser("impact", help="Analyze call graph impact and blast radius")
|
|
815
|
+
impact_p.add_argument("path", help="Repository path")
|
|
816
|
+
impact_p.add_argument("symbol", help="Symbol name to analyze")
|
|
817
|
+
impact_p.add_argument("--depth", "-d", type=int, default=3)
|
|
818
|
+
impact_p.add_argument("--json", action="store_true", help="Emit raw JSON instead of a formatted tree")
|
|
819
|
+
|
|
820
|
+
# 4. init
|
|
821
|
+
init_p = subparsers.add_parser("init", help="Create a local codepulse.toml config -- no infra required")
|
|
822
|
+
init_p.add_argument("path", nargs="?", default=".", help="Directory to create codepulse.toml in (default: cwd)")
|
|
823
|
+
init_p.add_argument("--force", action="store_true", help="Overwrite an existing codepulse.toml")
|
|
824
|
+
|
|
825
|
+
# 5. index
|
|
826
|
+
index_p = subparsers.add_parser("index", help="Full index of a repository")
|
|
827
|
+
index_p.add_argument("path", help="Repository path")
|
|
828
|
+
index_p.add_argument("--json", action="store_true")
|
|
829
|
+
|
|
830
|
+
# 6. update
|
|
831
|
+
update_p = subparsers.add_parser("update", help="Incremental (git-diff based when available) re-index")
|
|
832
|
+
update_p.add_argument("path", help="Repository path")
|
|
833
|
+
update_p.add_argument("--json", action="store_true")
|
|
834
|
+
|
|
835
|
+
# 7. symbol
|
|
836
|
+
symbol_p = subparsers.add_parser("symbol", help="Look up a symbol's definition")
|
|
837
|
+
symbol_p.add_argument("path", help="Repository path")
|
|
838
|
+
symbol_p.add_argument("symbol", help="Symbol name")
|
|
839
|
+
symbol_p.add_argument("--json", action="store_true")
|
|
840
|
+
|
|
841
|
+
# 8. refs
|
|
842
|
+
refs_p = subparsers.add_parser("refs", help="Direct references (callers + direct subclasses) of a symbol")
|
|
843
|
+
refs_p.add_argument("path", help="Repository path")
|
|
844
|
+
refs_p.add_argument("symbol", help="Symbol name")
|
|
845
|
+
refs_p.add_argument("--json", action="store_true")
|
|
846
|
+
|
|
847
|
+
# 9. callers
|
|
848
|
+
callers_p = subparsers.add_parser("callers", help="Callers of a symbol, direct and transitive")
|
|
849
|
+
callers_p.add_argument("path", help="Repository path")
|
|
850
|
+
callers_p.add_argument("symbol", help="Symbol name")
|
|
851
|
+
callers_p.add_argument("--depth", "-d", type=int, default=3)
|
|
852
|
+
callers_p.add_argument("--json", action="store_true")
|
|
853
|
+
|
|
854
|
+
# 10. callees
|
|
855
|
+
callees_p = subparsers.add_parser("callees", help="Symbols called by a symbol, direct and transitive")
|
|
856
|
+
callees_p.add_argument("path", help="Repository path")
|
|
857
|
+
callees_p.add_argument("symbol", help="Symbol name")
|
|
858
|
+
callees_p.add_argument("--depth", "-d", type=int, default=3)
|
|
859
|
+
callees_p.add_argument("--json", action="store_true")
|
|
860
|
+
|
|
861
|
+
# 11. similar
|
|
862
|
+
similar_p = subparsers.add_parser("similar", help="Semantically similar symbols")
|
|
863
|
+
similar_p.add_argument("path", help="Repository path")
|
|
864
|
+
similar_p.add_argument("symbol", help="Symbol name")
|
|
865
|
+
similar_p.add_argument("--threshold", type=float, default=0.75)
|
|
866
|
+
similar_p.add_argument("--limit", "-l", type=int, default=10)
|
|
867
|
+
similar_p.add_argument("--json", action="store_true")
|
|
868
|
+
|
|
869
|
+
# 12. status
|
|
870
|
+
status_p = subparsers.add_parser("status", help="Index freshness for a repository")
|
|
871
|
+
status_p.add_argument("path", help="Repository path")
|
|
872
|
+
status_p.add_argument("--json", action="store_true")
|
|
873
|
+
|
|
874
|
+
# 12b. modules
|
|
875
|
+
modules_p = subparsers.add_parser("modules", help="Module clusters + inbound/outbound degree rankings")
|
|
876
|
+
modules_p.add_argument("path", help="Repository path")
|
|
877
|
+
modules_p.add_argument("--json", action="store_true")
|
|
878
|
+
|
|
879
|
+
# 13. doctor
|
|
880
|
+
doctor_p = subparsers.add_parser("doctor", help="Environment/config diagnostics")
|
|
881
|
+
doctor_p.add_argument("path", nargs="?", default=None, help="Directory to check ignore rules against (default: cwd)")
|
|
882
|
+
doctor_p.add_argument("--json", action="store_true")
|
|
883
|
+
|
|
884
|
+
# 14. serve
|
|
885
|
+
migrate_p = subparsers.add_parser(
|
|
886
|
+
"migrate",
|
|
887
|
+
help="Bring the database up to the current schema (backs it up first; refuses anything it cannot do safely)",
|
|
888
|
+
)
|
|
889
|
+
migrate_p.add_argument("--check", action="store_true", help="Only report; change nothing. Exit 0 if up to date, 1 if it needs migrating, 2 if it cannot be migrated safely")
|
|
890
|
+
migrate_p.add_argument("--no-backup", action="store_true", help="Do not copy a SQLite database before changing it")
|
|
891
|
+
migrate_p.add_argument("--json", action="store_true")
|
|
892
|
+
|
|
893
|
+
from codepulse.auth import VALID_ROLES
|
|
894
|
+
|
|
895
|
+
key_p = subparsers.add_parser("key", help="Manage API keys (this is where you create your first one)")
|
|
896
|
+
key_sub = key_p.add_subparsers(dest="key_command", required=True)
|
|
897
|
+
key_create_p = key_sub.add_parser(
|
|
898
|
+
"create",
|
|
899
|
+
help="Create an API key scoped to one or more folders (registers a folder that is not known yet)",
|
|
900
|
+
)
|
|
901
|
+
key_create_p.add_argument(
|
|
902
|
+
"--repo", action="append", required=True, metavar="PATH",
|
|
903
|
+
help="A project folder the key may access (repeatable; at least one: a key with no folder can do nothing)",
|
|
904
|
+
)
|
|
905
|
+
key_create_p.add_argument("--name", default="codepulse", help="A label for the key (default: codepulse)")
|
|
906
|
+
key_create_p.add_argument(
|
|
907
|
+
"--role", choices=list(VALID_ROLES), default="indexer",
|
|
908
|
+
help="viewer = search and read; indexer = viewer + index; admin = everything incl. managing keys (default: indexer)",
|
|
909
|
+
)
|
|
910
|
+
key_create_p.add_argument("--json", action="store_true", help="Print the key and what it covers as JSON")
|
|
911
|
+
|
|
912
|
+
serve_p = subparsers.add_parser("serve", help="Start the local API server (or the MCP server with --mcp)")
|
|
913
|
+
serve_p.add_argument("--host", default="127.0.0.1")
|
|
914
|
+
serve_p.add_argument("--port", type=int, default=8000)
|
|
915
|
+
serve_p.add_argument("--mcp", action="store_true", help="Start the MCP server (stdio transport) instead of the REST API")
|
|
916
|
+
serve_p.add_argument(
|
|
917
|
+
"--api-key",
|
|
918
|
+
default=None,
|
|
919
|
+
help=(
|
|
920
|
+
"Raw API key to scope the MCP connection to (required with --mcp, unless "
|
|
921
|
+
"CODEPULSE_MCP_API_KEY is set in the environment instead -- preferred for "
|
|
922
|
+
"spawned/managed processes, since CLI args are visible to other processes "
|
|
923
|
+
"on the same machine)"
|
|
924
|
+
),
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
args = parser.parse_args()
|
|
928
|
+
|
|
929
|
+
# Initialize configuration with CLI overrides
|
|
930
|
+
cli_overrides = {}
|
|
931
|
+
if args.db_url:
|
|
932
|
+
cli_overrides["db_url"] = args.db_url
|
|
933
|
+
if args.embedding_model:
|
|
934
|
+
cli_overrides["embedding_model"] = args.embedding_model
|
|
935
|
+
|
|
936
|
+
from codepulse.config import get_settings
|
|
937
|
+
settings = get_settings(config_file=args.config, cli_overrides=cli_overrides, reload=True)
|
|
938
|
+
|
|
939
|
+
# Backend-hardening addition (not a roadmap sprint item), found by the
|
|
940
|
+
# 2026-10-02 audit: every command below that takes a repository path
|
|
941
|
+
# used to accept a nonexistent one, exit 0 with "0 found", and leave
|
|
942
|
+
# an empty phantom repository (and possibly a brand-new database file)
|
|
943
|
+
# behind. Validated here, BEFORE the database diagnostic and before
|
|
944
|
+
# anything can create a database. `parse` (a file), `init` (creates its
|
|
945
|
+
# directory), `doctor` (optional) and `serve` (no path) are exempt.
|
|
946
|
+
missing_path = None
|
|
947
|
+
if args.command in _REPOSITORY_PATH_COMMANDS and not os.path.isdir(args.path):
|
|
948
|
+
missing_path = args.path
|
|
949
|
+
elif args.command == "key" and args.key_command == "create":
|
|
950
|
+
missing_path = next((p for p in args.repo if not os.path.isdir(p)), None)
|
|
951
|
+
if missing_path is not None:
|
|
952
|
+
message = f"Path '{missing_path}' is not an existing directory"
|
|
953
|
+
if getattr(args, "json", False):
|
|
954
|
+
_print_json({"error": "path_not_found", "message": message})
|
|
955
|
+
else:
|
|
956
|
+
_stderr_console.print(f"[bold red]Error:[/bold red] {message}")
|
|
957
|
+
sys.exit(1)
|
|
958
|
+
|
|
959
|
+
# Backend-hardening addition (not a roadmap sprint item): db_url
|
|
960
|
+
# defaults to "sqlite:///codepulse.db", resolved relative to the
|
|
961
|
+
# CURRENT WORKING DIRECTORY -- with zero prior feedback about which
|
|
962
|
+
# actual file that is. Running the same command from two different
|
|
963
|
+
# directories has always silently used two different databases (no
|
|
964
|
+
# error, no warning), a real footgun this discloses up front rather
|
|
965
|
+
# than leaving a user to discover it only once results look wrong.
|
|
966
|
+
# Skipped for `parse` and `init`, which never touch a database at all.
|
|
967
|
+
if args.command not in ("parse", "init"):
|
|
968
|
+
resolved_db = _display_db_path(settings.db_url)
|
|
969
|
+
db_file = resolved_db[len("sqlite:///"):] if resolved_db.startswith("sqlite:///") else None
|
|
970
|
+
# A resolved path is genuinely more alarming when the file
|
|
971
|
+
# doesn't exist yet -- that means THIS command is about to
|
|
972
|
+
# silently create a brand-new, empty database (the exact
|
|
973
|
+
# "wrong directory" mistake this whole diagnostic exists to
|
|
974
|
+
# catch), not just reuse one that already has prior work in
|
|
975
|
+
# it. A quiet path-only line doesn't mean much to a user who
|
|
976
|
+
# has no reason yet to suspect anything is wrong; "a new,
|
|
977
|
+
# empty database is being created" does.
|
|
978
|
+
if db_file is not None and not os.path.exists(db_file):
|
|
979
|
+
_stderr_console.print(
|
|
980
|
+
f"[yellow]⚠ No existing database found -- creating a NEW, empty one at:[/yellow]\n"
|
|
981
|
+
f"[yellow] {resolved_db}[/yellow]\n"
|
|
982
|
+
f"[dim]CodePulse keeps one database per user by default. If you expected to reuse an existing "
|
|
983
|
+
f"one, set --db-url / CODEPULSE_DB_URL, or run this from the directory that holds your "
|
|
984
|
+
f"codepulse.db.[/dim]"
|
|
985
|
+
)
|
|
986
|
+
else:
|
|
987
|
+
_stderr_console.print(f"[dim]Using database: {resolved_db}[/dim]")
|
|
988
|
+
if args.command != "migrate":
|
|
989
|
+
try:
|
|
990
|
+
from codepulse.storage.migrations import get_schema_status
|
|
991
|
+
|
|
992
|
+
warning = _schema_warning(get_schema_status(settings.db_url))
|
|
993
|
+
except Exception:
|
|
994
|
+
warning = None # never let a status lookup get in the way of the command itself
|
|
995
|
+
if warning:
|
|
996
|
+
_stderr_console.print(f"[yellow]⚠ {warning}[/yellow]")
|
|
997
|
+
|
|
998
|
+
try:
|
|
999
|
+
_dispatch(args)
|
|
1000
|
+
except Exception as exc:
|
|
1001
|
+
from codepulse.indexer.engine import IndexingPersistenceError
|
|
1002
|
+
|
|
1003
|
+
if not isinstance(exc, IndexingPersistenceError):
|
|
1004
|
+
raise
|
|
1005
|
+
message = f"{exc}. Nothing from this run was saved; the database is unchanged."
|
|
1006
|
+
if getattr(args, "json", False):
|
|
1007
|
+
_print_json({"error": "indexing_failed", "message": message})
|
|
1008
|
+
else:
|
|
1009
|
+
_stderr_console.print(f"[bold red]Error:[/bold red] {message}")
|
|
1010
|
+
sys.exit(1)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _dispatch(args: argparse.Namespace) -> None:
|
|
1014
|
+
if args.command == "parse":
|
|
1015
|
+
cmd_parse(args)
|
|
1016
|
+
elif args.command == "search":
|
|
1017
|
+
cmd_index_and_search(args)
|
|
1018
|
+
elif args.command == "impact":
|
|
1019
|
+
cmd_impact(args)
|
|
1020
|
+
elif args.command == "init":
|
|
1021
|
+
cmd_init(args)
|
|
1022
|
+
elif args.command == "index":
|
|
1023
|
+
cmd_index(args)
|
|
1024
|
+
elif args.command == "update":
|
|
1025
|
+
cmd_update(args)
|
|
1026
|
+
elif args.command == "symbol":
|
|
1027
|
+
cmd_symbol(args)
|
|
1028
|
+
elif args.command == "refs":
|
|
1029
|
+
cmd_refs(args)
|
|
1030
|
+
elif args.command == "callers":
|
|
1031
|
+
cmd_callers(args)
|
|
1032
|
+
elif args.command == "callees":
|
|
1033
|
+
cmd_callees(args)
|
|
1034
|
+
elif args.command == "similar":
|
|
1035
|
+
cmd_similar(args)
|
|
1036
|
+
elif args.command == "status":
|
|
1037
|
+
cmd_status(args)
|
|
1038
|
+
elif args.command == "modules":
|
|
1039
|
+
cmd_modules(args)
|
|
1040
|
+
elif args.command == "doctor":
|
|
1041
|
+
cmd_doctor(args)
|
|
1042
|
+
elif args.command == "migrate":
|
|
1043
|
+
cmd_migrate(args)
|
|
1044
|
+
elif args.command == "key":
|
|
1045
|
+
if args.key_command == "create":
|
|
1046
|
+
cmd_key_create(args)
|
|
1047
|
+
elif args.command == "serve":
|
|
1048
|
+
cmd_serve(args)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
if __name__ == "__main__":
|
|
1052
|
+
main()
|