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/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()