node-walk 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.
- node_walk/__init__.py +3 -0
- node_walk/analysis/__init__.py +12 -0
- node_walk/analysis/base.py +205 -0
- node_walk/analysis/python/__init__.py +10 -0
- node_walk/analysis/python/analyzer.py +44 -0
- node_walk/analysis/python/scope.py +44 -0
- node_walk/analysis/python/visitor.py +559 -0
- node_walk/analysis/python_analyzer.py +14 -0
- node_walk/cli/__init__.py +1 -0
- node_walk/cli/main.py +560 -0
- node_walk/indexer.py +188 -0
- node_walk/ir/__init__.py +39 -0
- node_walk/ir/enums.py +61 -0
- node_walk/ir/models.py +100 -0
- node_walk/query/__init__.py +1 -0
- node_walk/query/engine.py +368 -0
- node_walk/storage/__init__.py +13 -0
- node_walk/storage/base.py +79 -0
- node_walk/storage/repository.py +14 -0
- node_walk/storage/schema.py +130 -0
- node_walk/storage/sqlite_store.py +285 -0
- node_walk-0.1.0.dist-info/METADATA +107 -0
- node_walk-0.1.0.dist-info/RECORD +26 -0
- node_walk-0.1.0.dist-info/WHEEL +4 -0
- node_walk-0.1.0.dist-info/entry_points.txt +4 -0
- node_walk-0.1.0.dist-info/licenses/LICENSE +21 -0
node_walk/cli/main.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeGraph CLI — Typer-based command-line interface.
|
|
3
|
+
|
|
4
|
+
All commands discover the graph database from the nearest .node_walk/
|
|
5
|
+
directory (walking up from cwd). The `index` command creates it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich import box
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from rich.syntax import Syntax
|
|
21
|
+
|
|
22
|
+
from node_walk.indexer import Indexer
|
|
23
|
+
from node_walk.ir.enums import RelationshipType, SymbolKind
|
|
24
|
+
from node_walk.query.engine import QueryEngine, SymbolMatch, WalkResult
|
|
25
|
+
from node_walk.storage.sqlite_store import SQLiteGraphStore
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# App and console setup
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="node_walk",
|
|
33
|
+
help="Semantic code intelligence — navigate your codebase like a graph.",
|
|
34
|
+
add_completion=False,
|
|
35
|
+
rich_markup_mode="rich",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
console = Console()
|
|
39
|
+
err_console = Console(stderr=True)
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Helpers
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_DB_FILENAME = "graph.db"
|
|
46
|
+
_CG_DIR = ".node_walk"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_db(start: Path | None = None) -> Path | None:
|
|
50
|
+
"""Walk up from *start* (or cwd) looking for a .node_walk/graph.db file."""
|
|
51
|
+
current = (start or Path.cwd()).resolve()
|
|
52
|
+
while True:
|
|
53
|
+
candidate = current / _CG_DIR / _DB_FILENAME
|
|
54
|
+
if candidate.exists():
|
|
55
|
+
return candidate
|
|
56
|
+
parent = current.parent
|
|
57
|
+
if parent == current:
|
|
58
|
+
return None
|
|
59
|
+
current = parent
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_store(repo_path: Path | None = None) -> SQLiteGraphStore:
|
|
63
|
+
"""Open the graph store, or exit with a helpful error."""
|
|
64
|
+
db = _find_db(repo_path)
|
|
65
|
+
if db is None:
|
|
66
|
+
err_console.print(
|
|
67
|
+
"[red]No .node_walk/graph.db found.[/red] "
|
|
68
|
+
"Run [bold]node_walk index <path>[/bold] first."
|
|
69
|
+
)
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
return SQLiteGraphStore(db)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_engine(repo_path: Path | None = None) -> QueryEngine:
|
|
75
|
+
return QueryEngine(_get_store(repo_path))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_symbols_table(
|
|
79
|
+
matches: list[SymbolMatch],
|
|
80
|
+
title: str = "Results",
|
|
81
|
+
store: "SQLiteGraphStore | None" = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
if not matches:
|
|
84
|
+
console.print("[dim]No symbols found.[/dim]")
|
|
85
|
+
return
|
|
86
|
+
table = Table(title=title, box=box.ROUNDED, show_header=True, header_style="bold cyan")
|
|
87
|
+
table.add_column("Kind", style="yellow", width=12)
|
|
88
|
+
table.add_column("Name", style="bold white")
|
|
89
|
+
table.add_column("File", style="dim")
|
|
90
|
+
table.add_column("Lines", style="dim", width=10)
|
|
91
|
+
_store = store or _get_store()
|
|
92
|
+
for m in matches:
|
|
93
|
+
sym = m.symbol
|
|
94
|
+
file_info = _store.get_file(sym.file_id)
|
|
95
|
+
file_label = Path(file_info.path).name if file_info else "?"
|
|
96
|
+
table.add_row(
|
|
97
|
+
sym.kind.value,
|
|
98
|
+
sym.qualified_name,
|
|
99
|
+
file_label,
|
|
100
|
+
f"{sym.start_line}-{sym.end_line}",
|
|
101
|
+
)
|
|
102
|
+
console.print(table)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _print_walk_results(results: list[WalkResult], title: str) -> None:
|
|
106
|
+
if not results:
|
|
107
|
+
console.print("[dim]No results.[/dim]")
|
|
108
|
+
return
|
|
109
|
+
table = Table(title=title, box=box.ROUNDED, show_header=True, header_style="bold cyan")
|
|
110
|
+
table.add_column("Depth", style="dim", width=7)
|
|
111
|
+
table.add_column("Via", style="yellow", width=14)
|
|
112
|
+
table.add_column("Kind", style="yellow", width=12)
|
|
113
|
+
table.add_column("Symbol", style="bold white")
|
|
114
|
+
for r in results:
|
|
115
|
+
table.add_row(
|
|
116
|
+
str(r.depth),
|
|
117
|
+
r.via_relationship.value if r.via_relationship else "—",
|
|
118
|
+
r.symbol.kind.value,
|
|
119
|
+
r.symbol.qualified_name,
|
|
120
|
+
)
|
|
121
|
+
console.print(table)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_symbol(engine: QueryEngine, query: str) -> str | None:
|
|
125
|
+
"""
|
|
126
|
+
Resolve a user-provided symbol name/qname to a symbol_id.
|
|
127
|
+
If multiple matches, prints a picker and prompts the user.
|
|
128
|
+
Returns the chosen symbol_id, or None on failure.
|
|
129
|
+
"""
|
|
130
|
+
matches = engine.find_symbol(query)
|
|
131
|
+
if not matches:
|
|
132
|
+
console.print(f"[red]No symbol found matching:[/red] {query!r}")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if len(matches) == 1:
|
|
136
|
+
return matches[0].symbol.id
|
|
137
|
+
|
|
138
|
+
# Multiple matches — let the user pick
|
|
139
|
+
console.print(f"\n[bold]Multiple matches for[/bold] [yellow]{query!r}[/yellow]:\n")
|
|
140
|
+
for i, m in enumerate(matches[:10], 1):
|
|
141
|
+
sym = m.symbol
|
|
142
|
+
console.print(f" [cyan]{i}[/cyan] {sym.kind.value:12} {sym.qualified_name}")
|
|
143
|
+
|
|
144
|
+
choice = typer.prompt("\nPick a number", default="1")
|
|
145
|
+
try:
|
|
146
|
+
idx = int(choice) - 1
|
|
147
|
+
return matches[idx].symbol.id
|
|
148
|
+
except (ValueError, IndexError):
|
|
149
|
+
console.print("[red]Invalid choice.[/red]")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Commands
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def index(
|
|
160
|
+
path: Annotated[Path, typer.Argument(help="Repository root to index.")] = Path("."),
|
|
161
|
+
clear: Annotated[bool, typer.Option("--clear/--no-clear", help="Wipe existing graph before indexing.")] = True,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Index a repository and build the semantic graph."""
|
|
164
|
+
root = path.resolve()
|
|
165
|
+
db_path = root / _CG_DIR / _DB_FILENAME
|
|
166
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
store = SQLiteGraphStore(db_path)
|
|
169
|
+
|
|
170
|
+
files_done = 0
|
|
171
|
+
|
|
172
|
+
def progress(file_path: str, current: int, total: int) -> None:
|
|
173
|
+
rel = Path(file_path).relative_to(root) if root in Path(file_path).parents else Path(file_path).name
|
|
174
|
+
console.print(f" [dim][{current}/{total}][/dim] {rel}", end="\r")
|
|
175
|
+
|
|
176
|
+
console.print(f"\n[bold cyan]CodeGraph[/bold cyan] — indexing [bold]{root}[/bold]\n")
|
|
177
|
+
|
|
178
|
+
indexer = Indexer(store, progress_callback=progress)
|
|
179
|
+
stats = indexer.index(root, clear=clear)
|
|
180
|
+
|
|
181
|
+
console.print() # newline after \r progress
|
|
182
|
+
console.print(
|
|
183
|
+
Panel(
|
|
184
|
+
f"[green]OK[/green] Files analyzed: [bold]{stats.files_analyzed}[/bold] / {stats.files_discovered}\n"
|
|
185
|
+
f"[green]OK[/green] Symbols extracted: [bold]{stats.symbols_extracted}[/bold]\n"
|
|
186
|
+
f"[green]OK[/green] Relationships: [bold]{stats.relationships_extracted}[/bold]\n"
|
|
187
|
+
f"[green]OK[/green] Resolved cross-file: [bold]{stats.relationships_resolved}[/bold]\n"
|
|
188
|
+
+ (f"[yellow]WARN[/yellow] Errors: {len(stats.errors)}" if stats.errors else ""),
|
|
189
|
+
title="Index complete",
|
|
190
|
+
border_style="green",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if stats.errors:
|
|
195
|
+
console.print("\n[yellow]Errors:[/yellow]")
|
|
196
|
+
for e in stats.errors[:10]:
|
|
197
|
+
console.print(f" [dim]{e}[/dim]")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command()
|
|
201
|
+
def find(
|
|
202
|
+
query: Annotated[str, typer.Argument(help="Symbol name or qualified name to search for.")],
|
|
203
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results.")] = 20,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Search for symbols by name or qualified name."""
|
|
206
|
+
store = _get_store()
|
|
207
|
+
engine = QueryEngine(store)
|
|
208
|
+
matches = engine.find_symbol(query, limit=limit)
|
|
209
|
+
_print_symbols_table(matches, title=f"Results for {query!r}", store=store)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def definition(
|
|
214
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name or qualified name.")],
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Show the definition (file, lines, signature) of a symbol."""
|
|
217
|
+
engine = _get_engine()
|
|
218
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
219
|
+
if not sym_id:
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
|
|
222
|
+
sym = engine.get_definition(sym_id)
|
|
223
|
+
if not sym:
|
|
224
|
+
console.print("[red]Symbol not found.[/red]")
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
|
|
227
|
+
store = _get_store()
|
|
228
|
+
file_info = store.get_file(sym.file_id)
|
|
229
|
+
file_path = file_info.path if file_info else "?"
|
|
230
|
+
|
|
231
|
+
console.print(
|
|
232
|
+
Panel(
|
|
233
|
+
f"[bold]{sym.qualified_name}[/bold]\n\n"
|
|
234
|
+
f"Kind: [yellow]{sym.kind.value}[/yellow]\n"
|
|
235
|
+
f"Language: {sym.language.value}\n"
|
|
236
|
+
f"File: [dim]{file_path}[/dim]\n"
|
|
237
|
+
f"Lines: {sym.start_line}–{sym.end_line}\n"
|
|
238
|
+
+ (f"Signature: [dim]{sym.signature}[/dim]\n" if sym.signature else "")
|
|
239
|
+
+ (f"Async: yes\n" if sym.is_async else "")
|
|
240
|
+
+ (f"\n[italic]{sym.docstring[:200]}[/italic]" if sym.docstring else ""),
|
|
241
|
+
title="Definition",
|
|
242
|
+
border_style="cyan",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def callers(
|
|
249
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name or qualified name.")],
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Find all symbols that call the given symbol."""
|
|
252
|
+
engine = _get_engine()
|
|
253
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
254
|
+
if not sym_id:
|
|
255
|
+
raise typer.Exit(1)
|
|
256
|
+
|
|
257
|
+
pairs = engine.get_callers(sym_id)
|
|
258
|
+
if not pairs:
|
|
259
|
+
console.print("[dim]No callers found.[/dim]")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
table = Table(title=f"Callers of {symbol!r}", box=box.ROUNDED, header_style="bold cyan")
|
|
263
|
+
table.add_column("Kind", style="yellow", width=12)
|
|
264
|
+
table.add_column("Caller", style="bold white")
|
|
265
|
+
table.add_column("Line", style="dim", width=6)
|
|
266
|
+
table.add_column("Confidence", width=12)
|
|
267
|
+
|
|
268
|
+
for sym, rel in pairs:
|
|
269
|
+
loc = rel.source_location
|
|
270
|
+
table.add_row(
|
|
271
|
+
sym.kind.value,
|
|
272
|
+
sym.qualified_name,
|
|
273
|
+
str(loc.line) if loc else "—",
|
|
274
|
+
f"[green]{rel.resolution.value}[/green]" if rel.resolution.value == "resolved"
|
|
275
|
+
else f"[yellow]{rel.resolution.value}[/yellow]",
|
|
276
|
+
)
|
|
277
|
+
console.print(table)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.command()
|
|
281
|
+
def callees(
|
|
282
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name or qualified name.")],
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Find all symbols called by the given symbol."""
|
|
285
|
+
engine = _get_engine()
|
|
286
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
287
|
+
if not sym_id:
|
|
288
|
+
raise typer.Exit(1)
|
|
289
|
+
|
|
290
|
+
pairs = engine.get_callees(sym_id)
|
|
291
|
+
if not pairs:
|
|
292
|
+
console.print("[dim]No callees found.[/dim]")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
table = Table(title=f"Callees of {symbol!r}", box=box.ROUNDED, header_style="bold cyan")
|
|
296
|
+
table.add_column("Kind", style="yellow", width=12)
|
|
297
|
+
table.add_column("Callee", style="bold white")
|
|
298
|
+
table.add_column("Line", style="dim", width=6)
|
|
299
|
+
table.add_column("Confidence", width=12)
|
|
300
|
+
|
|
301
|
+
for sym, rel in pairs:
|
|
302
|
+
loc = rel.source_location
|
|
303
|
+
table.add_row(
|
|
304
|
+
sym.kind.value,
|
|
305
|
+
sym.qualified_name,
|
|
306
|
+
str(loc.line) if loc else "—",
|
|
307
|
+
f"[green]{rel.resolution.value}[/green]" if rel.resolution.value == "resolved"
|
|
308
|
+
else f"[yellow]{rel.resolution.value}[/yellow]",
|
|
309
|
+
)
|
|
310
|
+
console.print(table)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.command()
|
|
314
|
+
def refs(
|
|
315
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name or qualified name.")],
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Find all references to the given symbol."""
|
|
318
|
+
store = _get_store()
|
|
319
|
+
engine = QueryEngine(store)
|
|
320
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
321
|
+
if not sym_id:
|
|
322
|
+
raise typer.Exit(1)
|
|
323
|
+
pairs = engine.get_references(sym_id)
|
|
324
|
+
if not pairs:
|
|
325
|
+
console.print("[dim]No references found.[/dim]")
|
|
326
|
+
return
|
|
327
|
+
matches = [SymbolMatch(symbol=s, score=1.0) for s, _ in pairs]
|
|
328
|
+
_print_symbols_table(matches, title=f"References to {symbol!r}", store=store)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@app.command()
|
|
332
|
+
def implementations(
|
|
333
|
+
symbol: Annotated[str, typer.Argument(help="Class/interface name.")],
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Find implementations or subclasses of the given class/interface."""
|
|
336
|
+
store = _get_store()
|
|
337
|
+
engine = QueryEngine(store)
|
|
338
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
339
|
+
if not sym_id:
|
|
340
|
+
raise typer.Exit(1)
|
|
341
|
+
syms = engine.get_implementations(sym_id)
|
|
342
|
+
if not syms:
|
|
343
|
+
console.print("[dim]No implementations found.[/dim]")
|
|
344
|
+
return
|
|
345
|
+
matches = [SymbolMatch(symbol=s) for s in syms]
|
|
346
|
+
_print_symbols_table(matches, title=f"Implementations of {symbol!r}", store=store)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@app.command()
|
|
350
|
+
def imports(
|
|
351
|
+
symbol: Annotated[str, typer.Argument(help="Symbol or file name.")],
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Show what a symbol/module imports."""
|
|
354
|
+
engine = _get_engine()
|
|
355
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
356
|
+
if not sym_id:
|
|
357
|
+
raise typer.Exit(1)
|
|
358
|
+
rels = engine.get_imports(sym_id)
|
|
359
|
+
if not rels:
|
|
360
|
+
console.print("[dim]No imports found.[/dim]")
|
|
361
|
+
return
|
|
362
|
+
table = Table(title=f"Imports of {symbol!r}", box=box.ROUNDED, header_style="bold cyan")
|
|
363
|
+
table.add_column("Target", style="bold white")
|
|
364
|
+
table.add_column("Status", width=12)
|
|
365
|
+
for rel in rels:
|
|
366
|
+
target = rel.metadata.get("target_name", rel.target_id or "?")
|
|
367
|
+
status = (
|
|
368
|
+
f"[green]{rel.resolution.value}[/green]"
|
|
369
|
+
if rel.resolution.value == "resolved"
|
|
370
|
+
else f"[yellow]{rel.resolution.value}[/yellow]"
|
|
371
|
+
)
|
|
372
|
+
table.add_row(target, status)
|
|
373
|
+
console.print(table)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.command()
|
|
377
|
+
def trace(
|
|
378
|
+
symbol: Annotated[str, typer.Argument(help="Starting symbol name.")],
|
|
379
|
+
depth: Annotated[int, typer.Option("--depth", "-d", help="Max traversal depth.")] = 5,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Trace outgoing CALLS and IMPORTS from a symbol (shows what it depends on)."""
|
|
382
|
+
engine = _get_engine()
|
|
383
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
384
|
+
if not sym_id:
|
|
385
|
+
raise typer.Exit(1)
|
|
386
|
+
results = engine.trace(sym_id, depth=depth)
|
|
387
|
+
_print_walk_results(results, title=f"Trace from {symbol!r} (depth={depth})")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@app.command(name="blast-radius")
|
|
391
|
+
def blast_radius(
|
|
392
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name to assess impact for.")],
|
|
393
|
+
depth: Annotated[int, typer.Option("--depth", "-d", help="Max traversal depth.")] = 3,
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Find everything that could be affected if this symbol changes."""
|
|
396
|
+
engine = _get_engine()
|
|
397
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
398
|
+
if not sym_id:
|
|
399
|
+
raise typer.Exit(1)
|
|
400
|
+
results = engine.blast_radius(sym_id, depth=depth)
|
|
401
|
+
_print_walk_results(results, title=f"Blast radius of {symbol!r} (depth={depth})")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.command()
|
|
405
|
+
def source(
|
|
406
|
+
symbol: Annotated[str, typer.Argument(help="Symbol name or qualified name.")],
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Display the exact source lines for a symbol."""
|
|
409
|
+
engine = _get_engine()
|
|
410
|
+
sym_id = _resolve_symbol(engine, symbol)
|
|
411
|
+
if not sym_id:
|
|
412
|
+
raise typer.Exit(1)
|
|
413
|
+
src = engine.get_source(sym_id)
|
|
414
|
+
if not src:
|
|
415
|
+
console.print("[red]Could not retrieve source.[/red]")
|
|
416
|
+
raise typer.Exit(1)
|
|
417
|
+
|
|
418
|
+
code = "\n".join(src.lines)
|
|
419
|
+
syntax = Syntax(
|
|
420
|
+
code,
|
|
421
|
+
"python",
|
|
422
|
+
line_numbers=True,
|
|
423
|
+
start_line=src.start_line,
|
|
424
|
+
theme="monokai",
|
|
425
|
+
)
|
|
426
|
+
console.print(
|
|
427
|
+
Panel(
|
|
428
|
+
syntax,
|
|
429
|
+
title=f"{Path(src.file_path).name} :{src.start_line}-{src.end_line}",
|
|
430
|
+
border_style="cyan",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@app.command()
|
|
436
|
+
def stats() -> None:
|
|
437
|
+
"""Show graph statistics: file count, symbol counts by kind, relationship counts."""
|
|
438
|
+
engine = _get_engine()
|
|
439
|
+
data = engine.stats()
|
|
440
|
+
|
|
441
|
+
table = Table(title="Graph statistics", box=box.ROUNDED, header_style="bold cyan")
|
|
442
|
+
table.add_column("Metric", style="bold white")
|
|
443
|
+
table.add_column("Count", style="cyan", justify="right")
|
|
444
|
+
|
|
445
|
+
priority = ["files", "symbols", "relationships", "unresolved_relationships"]
|
|
446
|
+
for key in priority:
|
|
447
|
+
if key in data:
|
|
448
|
+
label = key.replace("_", " ").title()
|
|
449
|
+
val = data[key]
|
|
450
|
+
style = "red" if "unresolved" in key and val > 0 else "cyan"
|
|
451
|
+
table.add_row(label, f"[{style}]{val}[/{style}]")
|
|
452
|
+
|
|
453
|
+
# Symbol kinds
|
|
454
|
+
kind_keys = sorted(k for k in data if k.startswith("symbols_") and k != "symbols")
|
|
455
|
+
if kind_keys:
|
|
456
|
+
table.add_section()
|
|
457
|
+
for key in kind_keys:
|
|
458
|
+
table.add_row(f" {key.replace('symbols_', '').capitalize()}", str(data[key]))
|
|
459
|
+
|
|
460
|
+
# Relationship types
|
|
461
|
+
rel_keys = sorted(k for k in data if k.startswith("rel_"))
|
|
462
|
+
if rel_keys:
|
|
463
|
+
table.add_section()
|
|
464
|
+
for key in rel_keys:
|
|
465
|
+
table.add_row(f" {key.replace('rel_', '').capitalize()}", str(data[key]))
|
|
466
|
+
|
|
467
|
+
console.print(table)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@app.command(name="export")
|
|
471
|
+
def export_graph(
|
|
472
|
+
output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Output file (default: stdout).")] = None,
|
|
473
|
+
fmt: Annotated[str, typer.Option("--format", "-f", help="Output format: json")] = "json",
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Export the entire graph as JSON."""
|
|
476
|
+
store = _get_store()
|
|
477
|
+
files = [f.model_dump() for f in store.get_all_files()]
|
|
478
|
+
symbols = [s.model_dump() for s in store.get_all_symbols()]
|
|
479
|
+
|
|
480
|
+
# Get all relationships
|
|
481
|
+
all_rels = []
|
|
482
|
+
for sym in store.get_all_symbols():
|
|
483
|
+
rels = store.get_relationships_from(sym.id)
|
|
484
|
+
for r in rels:
|
|
485
|
+
all_rels.append(r.model_dump())
|
|
486
|
+
|
|
487
|
+
# Deduplicate by id
|
|
488
|
+
seen = set()
|
|
489
|
+
unique_rels = []
|
|
490
|
+
for r in all_rels:
|
|
491
|
+
if r["id"] not in seen:
|
|
492
|
+
seen.add(r["id"])
|
|
493
|
+
unique_rels.append(r)
|
|
494
|
+
|
|
495
|
+
data = {"files": files, "symbols": symbols, "relationships": unique_rels}
|
|
496
|
+
text = json.dumps(data, indent=2, default=str)
|
|
497
|
+
|
|
498
|
+
if output:
|
|
499
|
+
output.write_text(text, encoding="utf-8")
|
|
500
|
+
console.print(f"[green]Exported to[/green] {output}")
|
|
501
|
+
else:
|
|
502
|
+
print(text)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.command(name="help")
|
|
506
|
+
def show_help() -> None:
|
|
507
|
+
"""Show this help message and exit."""
|
|
508
|
+
console.print(
|
|
509
|
+
Panel(
|
|
510
|
+
"[bold white]CodeGraph[/bold white] - Local Semantic Code Intelligence for Python\n"
|
|
511
|
+
"[dim]Lightweight, local-first, SQLite-backed indexer and query engine.[/dim]",
|
|
512
|
+
border_style="cyan",
|
|
513
|
+
expand=False,
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
|
|
518
|
+
table.add_column("Command", style="bold yellow", width=18)
|
|
519
|
+
table.add_column("Description", style="white")
|
|
520
|
+
|
|
521
|
+
table.add_section()
|
|
522
|
+
table.add_row("[bold white]Project Setup[/bold white]", "")
|
|
523
|
+
table.add_row("index <path>", "Analyze and index a Python codebase.")
|
|
524
|
+
|
|
525
|
+
table.add_section()
|
|
526
|
+
table.add_row("[bold white]Code Navigation[/bold white]", "")
|
|
527
|
+
table.add_row("find <query>", "Search for symbols by name or qualified name.")
|
|
528
|
+
table.add_row("definition <name>", "Show definition metadata for a symbol.")
|
|
529
|
+
table.add_row("source <name>", "Display the exact source code block of a symbol.")
|
|
530
|
+
|
|
531
|
+
table.add_section()
|
|
532
|
+
table.add_row("[bold white]Graph Relationships[/bold white]", "")
|
|
533
|
+
table.add_row("callers <name>", "Find all functions/methods calling this symbol.")
|
|
534
|
+
table.add_row("callees <name>", "Find all functions/methods called by this symbol.")
|
|
535
|
+
table.add_row("refs <name>", "Find all references/usages of this symbol.")
|
|
536
|
+
table.add_row("implementations <name>", "Find implementations or subclasses of a class/ABC.")
|
|
537
|
+
table.add_row("imports <name>", "Find all imports of a module/symbol.")
|
|
538
|
+
|
|
539
|
+
table.add_section()
|
|
540
|
+
table.add_row("[bold white]Advanced Traversals[/bold white]", "")
|
|
541
|
+
table.add_row("trace <name>", "Trace outgoing dependencies (call/import graph paths).")
|
|
542
|
+
table.add_row("blast-radius <name>", "Show transitive incoming impact paths.")
|
|
543
|
+
|
|
544
|
+
table.add_section()
|
|
545
|
+
table.add_row("[bold white]Utilities[/bold white]", "")
|
|
546
|
+
table.add_row("stats", "Show database statistics (file, symbol, edge counts).")
|
|
547
|
+
table.add_row("export", "Export the entire semantic graph as JSON.")
|
|
548
|
+
|
|
549
|
+
console.print(table)
|
|
550
|
+
console.print("\n[dim]To see options for any command, run: [bold]node_walk <command> --help[/bold][/dim]")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
# Entry point
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if __name__ == "__main__":
|
|
560
|
+
app()
|