Navegador

navegador / navegador / cli / commands.py
Blame History Raw 2332 lines
1
"""
2
Navegador CLI — the single interface to your project's knowledge graph.
3
4
CODE: ingest, context, function, class, search, query
5
KNOWLEDGE: add (concept/rule/decision/person/domain), wiki, annotate, domain
6
UNIVERSAL: explain, search (spans both layers), stats
7
"""
8
9
import asyncio
10
import json
11
import logging
12
13
import click
14
from rich.console import Console
15
from rich.table import Table
16
17
console = Console()
18
19
DB_OPTION = click.option(
20
"--db", default=".navegador/graph.db", show_default=True, help="Graph DB path."
21
)
22
FMT_OPTION = click.option(
23
"--format",
24
"fmt",
25
type=click.Choice(["markdown", "json"]),
26
default="markdown",
27
show_default=True,
28
help="Output format. Use json for agent/pipe consumption.",
29
)
30
31
32
def _get_store(db: str):
33
from navegador.config import DEFAULT_DB_PATH, get_store
34
35
return get_store(db if db != DEFAULT_DB_PATH else None)
36
37
38
def _emit(text: str, fmt: str) -> None:
39
if fmt == "json":
40
click.echo(text)
41
else:
42
console.print(text)
43
44
45
# ── Root group ────────────────────────────────────────────────────────────────
46
47
48
@click.group()
49
@click.version_option(package_name="navegador")
50
def main():
51
"""Navegador — project knowledge graph for AI coding agents.
52
53
Combines code structure (AST, call graphs) with business knowledge
54
(concepts, rules, decisions, wiki) into a single queryable graph.
55
"""
56
logging.basicConfig(level=logging.WARNING)
57
58
59
# ── Init ──────────────────────────────────────────────────────────────────────
60
61
62
@main.command()
63
@click.argument("path", default=".", type=click.Path())
64
@click.option(
65
"--redis",
66
"redis_url",
67
default="",
68
help="Redis URL for centralized/production mode (e.g. redis://host:6379).",
69
)
70
@click.option(
71
"--llm-provider",
72
default="",
73
help="LLM provider (e.g. anthropic, openai, ollama).",
74
)
75
@click.option("--llm-model", default="", help="LLM model name.")
76
@click.option("--cluster", is_flag=True, help="Enable cluster/swarm mode.")
77
def init(path: str, redis_url: str, llm_provider: str, llm_model: str, cluster: bool):
78
"""Initialise navegador in a project directory.
79
80
Creates .navegador/ (gitignored), writes config.toml with storage,
81
LLM, and cluster settings.
82
83
\b
84
Local SQLite (default — zero infra):
85
navegador init
86
87
Centralized Redis (production / multi-agent):
88
navegador init --redis redis://host:6379
89
90
With LLM:
91
navegador init --llm-provider anthropic --llm-model claude-sonnet-4-6
92
"""
93
from navegador.config import init_project
94
95
storage = "redis" if redis_url else "sqlite"
96
nav_dir = init_project(
97
path,
98
storage=storage,
99
redis_url=redis_url,
100
llm_provider=llm_provider,
101
llm_model=llm_model,
102
cluster=cluster,
103
)
104
console.print(f"[green]Initialised navegador[/green] → {nav_dir}")
105
106
if redis_url:
107
console.print(
108
f"\n[bold]Redis mode:[/bold] set [cyan]NAVEGADOR_REDIS_URL={redis_url}[/cyan] "
109
"in your environment or CI secrets."
110
)
111
else:
112
console.print(
113
"\n[bold]Local SQLite mode[/bold] (default). "
114
"To use a shared Redis graph set [cyan]NAVEGADOR_REDIS_URL[/cyan]."
115
)
116
117
if llm_provider:
118
console.print(f"\n[bold]LLM:[/bold] {llm_provider} / {llm_model or '(default)'}")
119
120
if cluster:
121
console.print("\n[bold]Cluster mode:[/bold] enabled")
122
123
console.print("\nNext: [bold]navegador ingest .[/bold]")
124
125
126
# ── CODE: ingest ──────────────────────────────────────────────────────────────
127
128
129
@main.command()
130
@click.argument("repo_path", type=click.Path(exists=True))
131
@DB_OPTION
132
@click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
133
@click.option("--incremental", is_flag=True, help="Only re-parse changed files.")
134
@click.option("--watch", is_flag=True, help="Watch for changes and re-ingest incrementally.")
135
@click.option("--interval", default=2.0, show_default=True, help="Watch poll interval (seconds).")
136
@click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
137
@click.option(
138
"--redact",
139
is_flag=True,
140
help="Scan each file for sensitive content and redact before storing in graph nodes.",
141
)
142
@click.option(
143
"--monorepo",
144
is_flag=True,
145
help="Detect and ingest as a monorepo workspace (Turborepo, Nx, Yarn, pnpm, Cargo, Go).",
146
)
147
def ingest(
148
repo_path: str,
149
db: str,
150
clear: bool,
151
incremental: bool,
152
watch: bool,
153
interval: float,
154
as_json: bool,
155
redact: bool,
156
monorepo: bool,
157
):
158
"""Ingest a repository's code into the graph (AST + call graph)."""
159
if monorepo:
160
from navegador.monorepo import MonorepoIngester
161
162
store = _get_store(db)
163
mono_ingester = MonorepoIngester(store)
164
165
if as_json:
166
stats = mono_ingester.ingest(repo_path, clear=clear)
167
click.echo(json.dumps(stats, indent=2))
168
else:
169
with console.status(f"[bold]Ingesting monorepo[/bold] {repo_path}..."):
170
stats = mono_ingester.ingest(repo_path, clear=clear)
171
table = Table(title="Monorepo ingestion complete")
172
table.add_column("Metric", style="cyan")
173
table.add_column("Count", justify="right", style="green")
174
for k, v in stats.items():
175
table.add_row(str(k).capitalize(), str(v))
176
console.print(table)
177
return
178
179
from navegador.ingestion import RepoIngester
180
181
store = _get_store(db)
182
ingester = RepoIngester(store, redact=redact)
183
184
if watch:
185
console.print(f"[bold]Watching[/bold] {repo_path} (interval={interval}s, Ctrl-C to stop)")
186
187
def _on_cycle(stats):
188
changed = stats["files"]
189
skipped = stats["skipped"]
190
if changed:
191
console.print(f" [green]{changed} changed[/green], {skipped} unchanged")
192
return True # keep watching
193
194
try:
195
ingester.watch(repo_path, interval=interval, callback=_on_cycle)
196
except KeyboardInterrupt:
197
console.print("\n[yellow]Watch stopped.[/yellow]")
198
return
199
200
if as_json:
201
stats = ingester.ingest(repo_path, clear=clear, incremental=incremental)
202
click.echo(json.dumps(stats, indent=2))
203
else:
204
with console.status(f"[bold]Ingesting[/bold] {repo_path}..."):
205
stats = ingester.ingest(repo_path, clear=clear, incremental=incremental)
206
table = Table(title="Ingestion complete")
207
table.add_column("Metric", style="cyan")
208
table.add_column("Count", justify="right", style="green")
209
for k, v in stats.items():
210
table.add_row(k.capitalize(), str(v))
211
console.print(table)
212
213
214
# ── CODE: context / function / class ─────────────────────────────────────────
215
216
217
@main.command()
218
@click.argument("file_path")
219
@DB_OPTION
220
@FMT_OPTION
221
def context(file_path: str, db: str, fmt: str):
222
"""Load context for a file — all symbols and their relationships."""
223
from navegador.context import ContextLoader
224
225
bundle = ContextLoader(_get_store(db)).load_file(file_path)
226
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
227
228
229
@main.command()
230
@click.argument("name")
231
@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
232
@click.option("--depth", default=2, show_default=True)
233
@DB_OPTION
234
@FMT_OPTION
235
def function(name: str, file_path: str, db: str, depth: int, fmt: str):
236
"""Load context for a function — callers, callees, decorators."""
237
from navegador.context import ContextLoader
238
239
bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth)
240
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
241
242
243
@main.command("class")
244
@click.argument("name")
245
@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
246
@DB_OPTION
247
@FMT_OPTION
248
def class_(name: str, file_path: str, db: str, fmt: str):
249
"""Load context for a class — methods, inheritance, references."""
250
from navegador.context import ContextLoader
251
252
bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path)
253
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
254
255
256
# ── UNIVERSAL: explain ────────────────────────────────────────────────────────
257
258
259
@main.command()
260
@click.argument("name")
261
@click.option("--file", "file_path", default="")
262
@DB_OPTION
263
@FMT_OPTION
264
def explain(name: str, file_path: str, db: str, fmt: str):
265
"""Full picture: all relationships in and out, code and knowledge layers."""
266
from navegador.context import ContextLoader
267
268
bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path)
269
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
270
271
272
# ── UNIVERSAL: search ─────────────────────────────────────────────────────────
273
274
275
@main.command()
276
@click.argument("query")
277
@DB_OPTION
278
@click.option("--limit", default=20, show_default=True)
279
@click.option(
280
"--all", "search_all", is_flag=True, help="Include knowledge layer (concepts, rules, wiki)."
281
)
282
@click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.")
283
@FMT_OPTION
284
def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str):
285
"""Search symbols, concepts, rules, and wiki pages."""
286
from navegador.context import ContextLoader
287
288
loader = ContextLoader(_get_store(db))
289
290
if by_doc:
291
results = loader.search_by_docstring(query, limit=limit)
292
elif search_all:
293
results = loader.search_all(query, limit=limit)
294
else:
295
results = loader.search(query, limit=limit)
296
297
if fmt == "json":
298
click.echo(
299
json.dumps(
300
[
301
{
302
"type": r.type,
303
"name": r.name,
304
"file_path": r.file_path,
305
"line_start": r.line_start,
306
"docstring": r.docstring,
307
"description": r.description,
308
}
309
for r in results
310
],
311
indent=2,
312
)
313
)
314
return
315
316
if not results:
317
console.print("[yellow]No results.[/yellow]")
318
return
319
320
table = Table(title=f"Search: {query!r}")
321
table.add_column("Type", style="cyan")
322
table.add_column("Name", style="bold")
323
table.add_column("File / Domain")
324
table.add_column("Line", justify="right")
325
for r in results:
326
loc = r.file_path or r.domain or ""
327
table.add_row(r.type, r.name, loc, str(r.line_start or ""))
328
console.print(table)
329
330
331
# ── CODE: decorator / query ───────────────────────────────────────────────────
332
333
334
@main.command()
335
@click.argument("decorator_name")
336
@DB_OPTION
337
@FMT_OPTION
338
def decorated(decorator_name: str, db: str, fmt: str):
339
"""Find all functions/methods carrying a decorator."""
340
from navegador.context import ContextLoader
341
342
results = ContextLoader(_get_store(db)).decorated_by(decorator_name)
343
344
if fmt == "json":
345
click.echo(
346
json.dumps(
347
[
348
{"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start}
349
for r in results
350
],
351
indent=2,
352
)
353
)
354
return
355
356
if not results:
357
console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]")
358
return
359
360
table = Table(title=f"@{decorator_name}")
361
table.add_column("Type", style="cyan")
362
table.add_column("Name", style="bold")
363
table.add_column("File")
364
table.add_column("Line", justify="right")
365
for r in results:
366
table.add_row(r.type, r.name, r.file_path, str(r.line_start or ""))
367
console.print(table)
368
369
370
@main.command()
371
@click.argument("cypher")
372
@DB_OPTION
373
def query(cypher: str, db: str):
374
"""Run a raw Cypher query — output is always JSON."""
375
result = _get_store(db).query(cypher)
376
click.echo(json.dumps(result.result_set or [], default=str, indent=2))
377
378
379
# ── KNOWLEDGE: add group ──────────────────────────────────────────────────────
380
381
382
@main.group()
383
def add():
384
"""Add knowledge nodes — concepts, rules, decisions, people, domains."""
385
386
387
@add.command("concept")
388
@click.argument("name")
389
@click.option("--desc", default="", help="Description / definition.")
390
@click.option("--domain", default="")
391
@click.option("--status", default="", help="e.g. stable, proposed, deprecated")
392
@click.option("--rules", default="", help="Comma-separated rule names.")
393
@click.option("--wiki", default="", help="Wiki URL or reference.")
394
@DB_OPTION
395
def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str):
396
"""Add a business concept to the knowledge graph."""
397
from navegador.ingestion import KnowledgeIngester
398
399
k = KnowledgeIngester(_get_store(db))
400
k.add_concept(name, description=desc, domain=domain, status=status, rules=rules, wiki_refs=wiki)
401
console.print(f"[green]Concept added:[/green] {name}")
402
403
404
@add.command("rule")
405
@click.argument("name")
406
@click.option("--desc", default="")
407
@click.option("--domain", default="")
408
@click.option("--severity", default="info", type=click.Choice(["info", "warning", "critical"]))
409
@click.option("--rationale", default="")
410
@DB_OPTION
411
def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str):
412
"""Add a business rule or constraint."""
413
from navegador.ingestion import KnowledgeIngester
414
415
k = KnowledgeIngester(_get_store(db))
416
k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale)
417
console.print(f"[green]Rule added:[/green] {name}")
418
419
420
@add.command("decision")
421
@click.argument("name")
422
@click.option("--desc", default="")
423
@click.option("--domain", default="")
424
@click.option("--rationale", default="")
425
@click.option("--alternatives", default="")
426
@click.option("--date", default="")
427
@click.option(
428
"--status", default="accepted", type=click.Choice(["proposed", "accepted", "deprecated"])
429
)
430
@DB_OPTION
431
def add_decision(name, desc, domain, rationale, alternatives, date, status, db):
432
"""Add an architectural or product decision."""
433
from navegador.ingestion import KnowledgeIngester
434
435
k = KnowledgeIngester(_get_store(db))
436
k.add_decision(
437
name,
438
description=desc,
439
domain=domain,
440
status=status,
441
rationale=rationale,
442
alternatives=alternatives,
443
date=date,
444
)
445
console.print(f"[green]Decision added:[/green] {name}")
446
447
448
@add.command("person")
449
@click.argument("name")
450
@click.option("--email", default="")
451
@click.option("--role", default="")
452
@click.option("--team", default="")
453
@DB_OPTION
454
def add_person(name: str, email: str, role: str, team: str, db: str):
455
"""Add a person (contributor, owner, stakeholder)."""
456
from navegador.ingestion import KnowledgeIngester
457
458
k = KnowledgeIngester(_get_store(db))
459
k.add_person(name, email=email, role=role, team=team)
460
console.print(f"[green]Person added:[/green] {name}")
461
462
463
@add.command("domain")
464
@click.argument("name")
465
@click.option("--desc", default="")
466
@DB_OPTION
467
def add_domain(name: str, desc: str, db: str):
468
"""Add a business domain (auth, billing, notifications…)."""
469
from navegador.ingestion import KnowledgeIngester
470
471
k = KnowledgeIngester(_get_store(db))
472
k.add_domain(name, description=desc)
473
console.print(f"[green]Domain added:[/green] {name}")
474
475
476
# ── KNOWLEDGE: annotate ───────────────────────────────────────────────────────
477
478
479
@main.command()
480
@click.argument("code_name")
481
@click.option(
482
"--type",
483
"code_label",
484
default="Function",
485
type=click.Choice(["Function", "Class", "Method", "File", "Module"]),
486
)
487
@click.option("--concept", default="", help="Link to this concept.")
488
@click.option("--rule", default="", help="Link to this rule.")
489
@DB_OPTION
490
def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str):
491
"""Link a code node to a concept or rule in the knowledge graph."""
492
from navegador.ingestion import KnowledgeIngester
493
494
k = KnowledgeIngester(_get_store(db))
495
k.annotate_code(code_name, code_label, concept=concept or None, rule=rule or None)
496
console.print(f"[green]Annotated:[/green] {code_name}")
497
498
499
# ── KNOWLEDGE: domain view ────────────────────────────────────────────────────
500
501
502
@main.command()
503
@click.argument("name")
504
@DB_OPTION
505
@FMT_OPTION
506
def domain(name: str, db: str, fmt: str):
507
"""Show everything belonging to a domain — code and knowledge."""
508
from navegador.context import ContextLoader
509
510
bundle = ContextLoader(_get_store(db)).load_domain(name)
511
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
512
513
514
@main.command()
515
@click.argument("name")
516
@DB_OPTION
517
@FMT_OPTION
518
def concept(name: str, db: str, fmt: str):
519
"""Load a business concept — rules, related concepts, implementing code, wiki."""
520
from navegador.context import ContextLoader
521
522
bundle = ContextLoader(_get_store(db)).load_concept(name)
523
_emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt)
524
525
526
# ── KNOWLEDGE: wiki ───────────────────────────────────────────────────────────
527
528
529
@main.group()
530
def wiki():
531
"""Ingest and manage wiki pages in the knowledge graph."""
532
533
534
@wiki.command("ingest")
535
@click.option("--repo", default="", help="GitHub repo (owner/repo) — clones the wiki.")
536
@click.option("--dir", "wiki_dir", default="", help="Local directory of markdown files.")
537
@click.option("--token", default="", envvar="GITHUB_TOKEN", help="GitHub token.")
538
@click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.")
539
@DB_OPTION
540
def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str):
541
"""Pull wiki pages into the knowledge graph."""
542
from navegador.ingestion import WikiIngester
543
544
w = WikiIngester(_get_store(db))
545
546
if wiki_dir:
547
stats = w.ingest_local(wiki_dir)
548
elif repo:
549
if api:
550
stats = w.ingest_github_api(repo, token=token)
551
else:
552
stats = w.ingest_github(repo, token=token)
553
else:
554
raise click.UsageError("Provide --repo or --dir")
555
556
console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links")
557
558
559
# ── Stats ─────────────────────────────────────────────────────────────────────
560
561
562
@main.command()
563
@DB_OPTION
564
@click.option("--json", "as_json", is_flag=True)
565
def stats(db: str, as_json: bool):
566
"""Graph statistics broken down by node and edge type."""
567
from navegador.graph import queries as q
568
569
store = _get_store(db)
570
571
node_rows = store.query(q.NODE_TYPE_COUNTS).result_set or []
572
edge_rows = store.query(q.EDGE_TYPE_COUNTS).result_set or []
573
574
total_nodes = sum(r[1] for r in node_rows)
575
total_edges = sum(r[1] for r in edge_rows)
576
577
if as_json:
578
click.echo(
579
json.dumps(
580
{
581
"total_nodes": total_nodes,
582
"total_edges": total_edges,
583
"nodes": {r[0]: r[1] for r in node_rows},
584
"edges": {r[0]: r[1] for r in edge_rows},
585
},
586
indent=2,
587
)
588
)
589
return
590
591
node_table = Table(title=f"Nodes ({total_nodes:,})")
592
node_table.add_column("Type", style="cyan")
593
node_table.add_column("Count", justify="right", style="green")
594
for row in node_rows:
595
node_table.add_row(row[0], f"{row[1]:,}")
596
597
edge_table = Table(title=f"Edges ({total_edges:,})")
598
edge_table.add_column("Type", style="cyan")
599
edge_table.add_column("Count", justify="right", style="green")
600
for row in edge_rows:
601
edge_table.add_row(row[0], f"{row[1]:,}")
602
603
console.print(node_table)
604
console.print(edge_table)
605
606
607
# ── PLANOPTICON ingestion ──────────────────────────────────────────────────────
608
609
610
@main.group()
611
def planopticon():
612
"""Ingest planopticon output (meetings, videos, docs) into the knowledge graph."""
613
614
615
@planopticon.command("ingest")
616
@click.argument("path", type=click.Path(exists=True))
617
@click.option(
618
"--type",
619
"input_type",
620
type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]),
621
default="auto",
622
show_default=True,
623
help="Input format. auto detects from filename.",
624
)
625
@click.option("--source", default="", help="Source label for provenance (e.g. 'Q4 planning').")
626
@click.option("--json", "as_json", is_flag=True)
627
@DB_OPTION
628
def planopticon_ingest(path: str, input_type: str, source: str, as_json: bool, db: str):
629
"""Load a planopticon output directory or file into the knowledge graph.
630
631
PATH can be:
632
- A manifest.json file
633
- A knowledge_graph.json file
634
- An interchange.json file
635
- A batch manifest JSON
636
- A planopticon output directory (auto-detects manifest.json inside)
637
"""
638
from pathlib import Path as P
639
640
from navegador.ingestion import PlanopticonIngester
641
642
p = P(path)
643
# Resolve directory → manifest.json
644
if p.is_dir():
645
candidates = ["manifest.json", "results/knowledge_graph.json", "interchange.json"]
646
for c in candidates:
647
if (p / c).exists():
648
p = p / c
649
break
650
else:
651
raise click.UsageError(f"No recognised planopticon file found in {path}")
652
653
# Auto-detect type from filename
654
if input_type == "auto":
655
name = p.name.lower()
656
if "manifest" in name:
657
input_type = "manifest"
658
elif "interchange" in name:
659
input_type = "interchange"
660
elif "batch" in name:
661
input_type = "batch"
662
else:
663
input_type = "kg"
664
665
ing = PlanopticonIngester(_get_store(db), source_tag=source)
666
667
dispatch = {
668
"manifest": ing.ingest_manifest,
669
"kg": ing.ingest_kg,
670
"interchange": ing.ingest_interchange,
671
"batch": ing.ingest_batch,
672
}
673
stats = dispatch[input_type](p)
674
675
if as_json:
676
click.echo(json.dumps(stats, indent=2))
677
else:
678
table = Table(title=f"Planopticon import ({input_type})")
679
table.add_column("Metric", style="cyan")
680
table.add_column("Count", justify="right", style="green")
681
for k, v in stats.items():
682
table.add_row(k.capitalize(), str(v))
683
console.print(table)
684
685
686
# ── Export / Import ──────────────────────────────────────────────────────────
687
688
689
@main.command("export")
690
@click.argument("output", type=click.Path())
691
@DB_OPTION
692
@click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
693
def export_cmd(output: str, db: str, as_json: bool):
694
"""Export the graph to a text-based JSONL file (git-friendly)."""
695
from navegador.graph.export import export_graph
696
697
store = _get_store(db)
698
stats = export_graph(store, output)
699
700
if as_json:
701
click.echo(json.dumps(stats, indent=2))
702
else:
703
console.print(
704
f"[green]Exported[/green] {stats['nodes']} nodes, {stats['edges']} edges → {output}"
705
)
706
707
708
@main.command("import")
709
@click.argument("input_path", type=click.Path(exists=True))
710
@DB_OPTION
711
@click.option("--no-clear", is_flag=True, help="Don't wipe graph before importing.")
712
@click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.")
713
def import_cmd(input_path: str, db: str, no_clear: bool, as_json: bool):
714
"""Import a graph from a JSONL export file."""
715
from navegador.graph.export import import_graph
716
717
store = _get_store(db)
718
stats = import_graph(store, input_path, clear=not no_clear)
719
720
if as_json:
721
click.echo(json.dumps(stats, indent=2))
722
else:
723
console.print(
724
f"[green]Imported[/green] {stats['nodes']} nodes, {stats['edges']} edges ← {input_path}"
725
)
726
727
728
# ── Schema migrations ────────────────────────────────────────────────────────
729
730
731
@main.command()
732
@DB_OPTION
733
@click.option("--check", is_flag=True, help="Check if migration is needed without applying.")
734
def migrate(db: str, check: bool):
735
"""Apply pending schema migrations to the graph."""
736
from navegador.graph.migrations import (
737
CURRENT_SCHEMA_VERSION,
738
get_schema_version,
739
needs_migration,
740
)
741
from navegador.graph.migrations import (
742
migrate as do_migrate,
743
)
744
745
store = _get_store(db)
746
747
if check:
748
current = get_schema_version(store)
749
if needs_migration(store):
750
console.print(
751
f"[yellow]Migration needed:[/yellow] v{current} → v{CURRENT_SCHEMA_VERSION}"
752
)
753
else:
754
console.print(f"[green]Schema is up to date[/green] (v{current})")
755
return
756
757
current = get_schema_version(store)
758
applied = do_migrate(store)
759
if applied:
760
console.print(
761
f"[green]Migrated[/green] v{current} → v{CURRENT_SCHEMA_VERSION} "
762
f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
763
)
764
else:
765
console.print(f"[green]Schema is up to date[/green] (v{current})")
766
767
768
# ── Graph explorer ────────────────────────────────────────────────────────────
769
770
771
@main.command()
772
@DB_OPTION
773
@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
774
@click.option("--port", default=8080, show_default=True, help="TCP port.")
775
@click.option(
776
"--no-browser",
777
is_flag=True,
778
default=False,
779
help="Don't open a browser tab automatically.",
780
)
781
def explore(db: str, host: str, port: int, no_browser: bool):
782
"""Launch the browser-based graph explorer.
783
784
Starts an HTTP server and opens the interactive force-directed
785
visualisation in your default browser.
786
787
\b
788
Examples:
789
navegador explore
790
navegador explore --port 9000
791
navegador explore --no-browser
792
"""
793
import time
794
import webbrowser
795
796
from navegador.explorer import ExplorerServer
797
798
store = _get_store(db)
799
server = ExplorerServer(store, host=host, port=port)
800
server.start()
801
url = server.url
802
803
console.print(f"[green]Graph explorer running[/green] → {url}")
804
console.print("Press [bold]Ctrl-C[/bold] to stop.")
805
806
if not no_browser:
807
# Small delay so the server is accepting connections before the browser hits it
808
time.sleep(0.3)
809
webbrowser.open(url)
810
811
try:
812
while True:
813
time.sleep(1)
814
except KeyboardInterrupt:
815
console.print("\n[yellow]Stopping explorer…[/yellow]")
816
finally:
817
server.stop()
818
819
820
# ── Enrichment ───────────────────────────────────────────────────────────────
821
822
823
@main.command()
824
@DB_OPTION
825
@click.option(
826
"--framework",
827
"framework_name",
828
default="",
829
help="Framework to enrich (e.g. django, fastapi). Auto-detects if omitted.",
830
)
831
@click.option("--json", "as_json", is_flag=True, help="Output results as JSON.")
832
def enrich(db: str, framework_name: str, as_json: bool):
833
"""Run framework enrichment on the graph.
834
835
Promotes generic Function/Class nodes to semantic framework types
836
by detecting framework patterns and adding labels/properties.
837
838
\b
839
Auto-detect all frameworks:
840
navegador enrich
841
842
\b
843
Target a specific framework:
844
navegador enrich --framework django
845
"""
846
import importlib
847
import pkgutil
848
849
import navegador.enrichment as _enrichment_pkg
850
from navegador.enrichment.base import FrameworkEnricher
851
852
store = _get_store(db)
853
854
# Discover all FrameworkEnricher subclasses in the enrichment package.
855
def _load_enrichers() -> dict[str, type[FrameworkEnricher]]:
856
enrichers: dict[str, type[FrameworkEnricher]] = {}
857
pkg_path = _enrichment_pkg.__path__
858
pkg_name = _enrichment_pkg.__name__
859
for _finder, mod_name, _ispkg in pkgutil.iter_modules(pkg_path):
860
if mod_name == "base":
861
continue
862
mod = importlib.import_module(f"{pkg_name}.{mod_name}")
863
for attr in vars(mod).values():
864
if (
865
isinstance(attr, type)
866
and issubclass(attr, FrameworkEnricher)
867
and attr is not FrameworkEnricher
868
):
869
try:
870
instance = attr.__new__(attr)
871
instance.store = store
872
enrichers[attr(store).framework_name] = attr
873
except Exception: # noqa: BLE001
874
pass
875
return enrichers
876
877
available = _load_enrichers()
878
879
if framework_name:
880
if framework_name not in available:
881
raise click.BadParameter(
882
f"Unknown framework {framework_name!r}. "
883
f"Available: {', '.join(sorted(available)) or '(none registered)'}",
884
param_hint="--framework",
885
)
886
targets = {framework_name: available[framework_name]}
887
else:
888
# Auto-detect: only run enrichers whose detect() returns True.
889
targets = {name: cls for name, cls in available.items() if cls(store).detect()}
890
if not targets and not as_json:
891
console.print("[yellow]No frameworks detected in the graph.[/yellow]")
892
return
893
894
all_results: dict[str, dict] = {}
895
for name, cls in targets.items():
896
enricher = cls(store)
897
result = enricher.enrich()
898
all_results[name] = {
899
"promoted": result.promoted,
900
"edges_added": result.edges_added,
901
"patterns_found": result.patterns_found,
902
}
903
904
if as_json:
905
click.echo(json.dumps(all_results, indent=2))
906
return
907
908
for name, data in all_results.items():
909
table = Table(title=f"Enrichment: {name}")
910
table.add_column("Metric", style="cyan")
911
table.add_column("Value", justify="right", style="green")
912
table.add_row("Nodes promoted", str(data["promoted"]))
913
table.add_row("Edges added", str(data["edges_added"]))
914
for pattern, count in data["patterns_found"].items():
915
table.add_row(f" {pattern}", str(count))
916
console.print(table)
917
918
919
# ── Diff: map uncommitted changes to affected graph nodes ─────────────────────
920
921
922
@main.command("diff")
923
@DB_OPTION
924
@FMT_OPTION
925
@click.option(
926
"--repo",
927
"repo_path",
928
default=".",
929
show_default=True,
930
type=click.Path(exists=True),
931
help="Repository root to inspect (default: current directory).",
932
)
933
def diff_cmd(db: str, fmt: str, repo_path: str):
934
"""Show which graph nodes are affected by uncommitted changes.
935
936
Reads the current git diff, finds every function/class/method whose
937
line range overlaps a changed hunk, then follows knowledge edges to
938
surface impacted concepts, rules, and decisions.
939
940
\b
941
Examples:
942
navegador diff
943
navegador diff --format json
944
navegador diff --repo /path/to/project
945
"""
946
from pathlib import Path as P
947
948
from navegador.diff import DiffAnalyzer
949
950
analyzer = DiffAnalyzer(_get_store(db), P(repo_path))
951
952
if fmt == "json":
953
click.echo(analyzer.to_json())
954
return
955
956
# Rich markdown output
957
md = analyzer.to_markdown()
958
console.print(md)
959
960
961
# ── Editor integrations ───────────────────────────────────────────────────────
962
963
964
@main.group()
965
def editor():
966
"""Generate MCP config snippets for AI coding editors."""
967
968
969
@editor.command("setup")
970
@click.argument("editor_name", metavar="EDITOR")
971
@DB_OPTION
972
@click.option(
973
"--write",
974
"do_write",
975
is_flag=True,
976
help="Write the config file to the expected path in the current directory.",
977
)
978
def editor_setup(editor_name: str, db: str, do_write: bool):
979
"""Generate the MCP config snippet for an editor.
980
981
\b
982
EDITOR is one of: claude-code, cursor, codex, windsurf, all
983
984
\b
985
Examples:
986
navegador editor setup claude-code
987
navegador editor setup cursor --db .navegador/graph.db
988
navegador editor setup all --write
989
"""
990
from navegador.editor import SUPPORTED_EDITORS, EditorIntegration
991
992
if editor_name not in SUPPORTED_EDITORS and editor_name != "all":
993
raise click.BadParameter(
994
f"Unknown editor {editor_name!r}. "
995
f"Choose from: {', '.join(SUPPORTED_EDITORS + ['all'])}",
996
param_hint="EDITOR",
997
)
998
999
integration = EditorIntegration(db=db)
1000
targets = SUPPORTED_EDITORS if editor_name == "all" else [editor_name]
1001
1002
for target in targets:
1003
config_json = integration.config_json(target)
1004
config_path = integration.config_path(target)
1005
1006
if len(targets) > 1:
1007
console.print(f"\n[bold cyan]{target}[/bold cyan] ({config_path})")
1008
1009
click.echo(config_json)
1010
1011
if do_write:
1012
written = integration.write_config(target)
1013
console.print(f"[green]Written:[/green] {written}")
1014
1015
1016
# ── CI/CD ─────────────────────────────────────────────────────────────────────
1017
1018
1019
@main.group()
1020
def ci():
1021
"""CI/CD mode — machine-readable output and structured exit codes.
1022
1023
All subcommands emit JSON to stdout and exit with:
1024
0 success
1025
1 error
1026
2 warnings only
1027
"""
1028
1029
1030
@ci.command("ingest")
1031
@click.argument("repo_path", type=click.Path(exists=True))
1032
@DB_OPTION
1033
@click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
1034
@click.option("--incremental", is_flag=True, help="Only re-parse changed files.")
1035
def ci_ingest(repo_path: str, db: str, clear: bool, incremental: bool):
1036
"""Ingest a repository and exit non-zero on errors or empty results."""
1037
import sys
1038
1039
from navegador.cicd import CICDReporter
1040
from navegador.ingestion import RepoIngester
1041
1042
reporter = CICDReporter()
1043
data: dict = {}
1044
1045
try:
1046
store = _get_store(db)
1047
ingester = RepoIngester(store)
1048
stats = ingester.ingest(repo_path, clear=clear, incremental=incremental)
1049
data = stats
1050
if stats.get("files", 0) == 0:
1051
reporter.add_warning("No source files were ingested.")
1052
except Exception as exc: # noqa: BLE001
1053
reporter.add_error(str(exc))
1054
1055
reporter.emit(data=data or None)
1056
sys.exit(reporter.exit_code())
1057
1058
1059
@ci.command("stats")
1060
@DB_OPTION
1061
def ci_stats(db: str):
1062
"""Emit graph statistics as JSON (for CI consumption)."""
1063
import sys
1064
1065
from navegador.cicd import CICDReporter
1066
from navegador.graph import queries as q
1067
1068
reporter = CICDReporter()
1069
data: dict = {}
1070
1071
try:
1072
store = _get_store(db)
1073
node_rows = store.query(q.NODE_TYPE_COUNTS).result_set or []
1074
edge_rows = store.query(q.EDGE_TYPE_COUNTS).result_set or []
1075
data = {
1076
"total_nodes": sum(r[1] for r in node_rows),
1077
"total_edges": sum(r[1] for r in edge_rows),
1078
"nodes": {r[0]: r[1] for r in node_rows},
1079
"edges": {r[0]: r[1] for r in edge_rows},
1080
}
1081
except Exception as exc: # noqa: BLE001
1082
reporter.add_error(str(exc))
1083
1084
reporter.emit(data=data or None)
1085
sys.exit(reporter.exit_code())
1086
1087
1088
@ci.command("check")
1089
@DB_OPTION
1090
def ci_check(db: str):
1091
"""Check schema version — exits 2 if migration is needed, 1 on hard error."""
1092
import sys
1093
1094
from navegador.cicd import CICDReporter
1095
from navegador.graph.migrations import (
1096
CURRENT_SCHEMA_VERSION,
1097
get_schema_version,
1098
needs_migration,
1099
)
1100
1101
reporter = CICDReporter()
1102
data: dict = {}
1103
1104
try:
1105
store = _get_store(db)
1106
current = get_schema_version(store)
1107
data = {"schema_version": current, "current_schema_version": CURRENT_SCHEMA_VERSION}
1108
if needs_migration(store):
1109
reporter.add_warning(f"Schema migration needed: v{current} → v{CURRENT_SCHEMA_VERSION}")
1110
except Exception as exc: # noqa: BLE001
1111
reporter.add_error(str(exc))
1112
1113
reporter.emit(data=data or None)
1114
sys.exit(reporter.exit_code())
1115
1116
1117
# ── Shell completions ─────────────────────────────────────────────────────────
1118
1119
1120
@main.command()
1121
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
1122
@click.option(
1123
"--install",
1124
"do_install",
1125
is_flag=True,
1126
help="Append the completion line to the default shell rc file.",
1127
)
1128
@click.option(
1129
"--rc-path",
1130
default="",
1131
help="Override the rc file path used by --install.",
1132
)
1133
def completions(shell: str, do_install: bool, rc_path: str):
1134
"""Print (or install) tab-completion for bash, zsh, or fish.
1135
1136
\b
1137
Print the line to add manually:
1138
navegador completions bash
1139
navegador completions zsh
1140
navegador completions fish
1141
1142
\b
1143
Auto-append to your rc file:
1144
navegador completions bash --install
1145
navegador completions zsh --install
1146
navegador completions fish --install
1147
"""
1148
from navegador.completions import get_eval_line, get_rc_path, install_completion
1149
1150
if do_install:
1151
target = install_completion(shell, rc_path=rc_path or None)
1152
console.print(f"[green]Completion installed[/green] → {target}")
1153
console.print(f"Restart your shell or run: [bold]source {target}[/bold]")
1154
else:
1155
line = get_eval_line(shell)
1156
rc = rc_path or get_rc_path(shell)
1157
console.print(f"Add the following line to [bold]{rc}[/bold]:\n")
1158
click.echo(f" {line}")
1159
console.print(f"\nOr run: [bold]navegador completions {shell} --install[/bold]")
1160
1161
1162
# ── Churn / behavioural coupling ─────────────────────────────────────────────
1163
1164
1165
@main.command()
1166
@click.argument("repo_path", default=".", type=click.Path(exists=True))
1167
@DB_OPTION
1168
@click.option("--limit", default=500, show_default=True, help="Max commits to inspect.")
1169
@click.option(
1170
"--min-confidence",
1171
default=0.5,
1172
show_default=True,
1173
type=float,
1174
help="Minimum coupling confidence (0–1).",
1175
)
1176
@click.option(
1177
"--min-co-changes",
1178
default=3,
1179
show_default=True,
1180
type=int,
1181
help="Minimum co-change count for a coupling pair.",
1182
)
1183
@click.option("--store", "do_store", is_flag=True, help="Write results to the graph.")
1184
@click.option("--json", "as_json", is_flag=True, help="Output results as JSON.")
1185
def churn(
1186
repo_path: str,
1187
db: str,
1188
limit: int,
1189
min_confidence: float,
1190
min_co_changes: int,
1191
do_store: bool,
1192
as_json: bool,
1193
):
1194
"""Analyze git history for file churn and behavioural coupling.
1195
1196
Shows files that change most often and pairs of files that
1197
frequently change together (co-evolution / logical coupling).
1198
1199
\b
1200
Examples:
1201
navegador churn .
1202
navegador churn . --limit 200 --min-confidence 0.7
1203
navegador churn . --store # persist to graph
1204
navegador churn . --json # machine-readable output
1205
"""
1206
from pathlib import Path as P
1207
1208
from navegador.churn import ChurnAnalyzer
1209
1210
analyzer = ChurnAnalyzer(P(repo_path), limit=limit)
1211
1212
with console.status("[bold]Analysing git history…[/bold]"):
1213
churn_entries = analyzer.file_churn()
1214
pairs = analyzer.coupling_pairs(
1215
min_co_changes=min_co_changes, min_confidence=min_confidence
1216
)
1217
1218
if do_store:
1219
store = _get_store(db)
1220
stats = analyzer.store_churn(store)
1221
if as_json:
1222
click.echo(json.dumps(stats, indent=2))
1223
else:
1224
console.print(
1225
f"[green]Churn stored:[/green] "
1226
f"{stats['churn_updated']} files updated, "
1227
f"{stats['couplings_written']} coupling edges written"
1228
)
1229
return
1230
1231
if as_json:
1232
click.echo(
1233
json.dumps(
1234
{
1235
"churn": [
1236
{
1237
"file_path": e.file_path,
1238
"commit_count": e.commit_count,
1239
"lines_changed": e.lines_changed,
1240
}
1241
for e in churn_entries
1242
],
1243
"coupling_pairs": [
1244
{
1245
"file_a": p.file_a,
1246
"file_b": p.file_b,
1247
"co_change_count": p.co_change_count,
1248
"confidence": p.confidence,
1249
}
1250
for p in pairs
1251
],
1252
},
1253
indent=2,
1254
)
1255
)
1256
return
1257
1258
# ── Rich tables ───────────────────────────────────────────────────────────
1259
churn_table = Table(title=f"File churn (top {min(20, len(churn_entries))})")
1260
churn_table.add_column("File", style="cyan")
1261
churn_table.add_column("Commits", justify="right", style="green")
1262
churn_table.add_column("Lines changed", justify="right")
1263
for entry in churn_entries[:20]:
1264
churn_table.add_row(entry.file_path, str(entry.commit_count), str(entry.lines_changed))
1265
console.print(churn_table)
1266
1267
if pairs:
1268
pair_table = Table(title=f"Behavioural coupling ({len(pairs)} pairs)")
1269
pair_table.add_column("File A", style="cyan")
1270
pair_table.add_column("File B", style="cyan")
1271
pair_table.add_column("Co-changes", justify="right", style="green")
1272
pair_table.add_column("Confidence", justify="right")
1273
for pair in pairs[:20]:
1274
pair_table.add_row(
1275
pair.file_a,
1276
pair.file_b,
1277
str(pair.co_change_count),
1278
f"{pair.confidence:.2f}",
1279
)
1280
console.print(pair_table)
1281
else:
1282
console.print(
1283
f"[yellow]No coupling pairs found[/yellow] "
1284
f"(min_co_changes={min_co_changes}, min_confidence={min_confidence})"
1285
)
1286
1287
1288
# ── MCP ───────────────────────────────────────────────────────────────────────
1289
1290
1291
@main.command()
1292
@DB_OPTION
1293
@click.option(
1294
"--read-only",
1295
"read_only",
1296
is_flag=True,
1297
default=False,
1298
help=(
1299
"Start in read-only mode: disables ingest_repo and blocks write operations in query_graph."
1300
),
1301
)
1302
def mcp(db: str, read_only: bool):
1303
"""Start the MCP server for AI agent integration (stdio)."""
1304
from mcp.server.stdio import stdio_server # type: ignore[import]
1305
1306
from navegador.mcp import create_mcp_server
1307
1308
server = create_mcp_server(lambda: _get_store(db), read_only=read_only)
1309
mode = "read-only" if read_only else "read-write"
1310
console.print(f"[green]Navegador MCP server running[/green] (stdio, {mode})")
1311
1312
async def _run():
1313
async with stdio_server() as (read_stream, write_stream):
1314
await server.run(read_stream, write_stream, server.create_initialization_options())
1315
1316
asyncio.run(_run())
1317
1318
1319
# ── ANALYSIS: impact ──────────────────────────────────────────────────────────
1320
1321
1322
@main.command()
1323
@click.argument("name")
1324
@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
1325
@click.option("--depth", default=3, show_default=True, help="Traversal depth.")
1326
@DB_OPTION
1327
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
1328
def impact(name: str, file_path: str, depth: int, db: str, as_json: bool):
1329
"""Blast-radius analysis — what does changing NAME affect?
1330
1331
Traverses CALLS, REFERENCES, INHERITS, IMPLEMENTS, ANNOTATES edges
1332
outward to find all downstream symbols and files affected by a change.
1333
"""
1334
from navegador.analysis.impact import ImpactAnalyzer
1335
1336
result = ImpactAnalyzer(_get_store(db)).blast_radius(name, file_path=file_path, depth=depth)
1337
1338
if as_json:
1339
click.echo(json.dumps(result.to_dict(), indent=2))
1340
return
1341
1342
console.print(f"[bold]Blast radius:[/bold] [cyan]{name}[/cyan] (depth={depth})")
1343
if not result.affected_nodes:
1344
console.print("[yellow]No affected nodes found.[/yellow]")
1345
return
1346
1347
table = Table(title=f"Affected nodes ({len(result.affected_nodes)})")
1348
table.add_column("Type", style="cyan")
1349
table.add_column("Name", style="bold")
1350
table.add_column("File")
1351
table.add_column("Line", justify="right")
1352
for node in result.affected_nodes:
1353
table.add_row(node["type"], node["name"], node["file_path"], str(node["line_start"] or ""))
1354
console.print(table)
1355
1356
if result.affected_files:
1357
console.print(f"\n[bold]Affected files ({len(result.affected_files)}):[/bold]")
1358
for fp in result.affected_files:
1359
console.print(f" {fp}")
1360
1361
if result.affected_knowledge:
1362
console.print(f"\n[bold]Affected knowledge ({len(result.affected_knowledge)}):[/bold]")
1363
for kn in result.affected_knowledge:
1364
console.print(f" [{kn['type']}] {kn['name']}")
1365
1366
1367
# ── ANALYSIS: flow trace ──────────────────────────────────────────────────────
1368
1369
1370
@main.command()
1371
@click.argument("name")
1372
@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
1373
@click.option("--depth", default=10, show_default=True, help="Maximum call depth.")
1374
@DB_OPTION
1375
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
1376
def trace(name: str, file_path: str, depth: int, db: str, as_json: bool):
1377
"""Execution flow trace — follow call chains from an entry point.
1378
1379
Traverses CALLS edges forward from NAME, returning all execution paths
1380
up to the given depth.
1381
"""
1382
from navegador.analysis.flow import FlowTracer
1383
1384
chains = FlowTracer(_get_store(db)).trace(name, file_path=file_path, max_depth=depth)
1385
1386
if as_json:
1387
click.echo(json.dumps([c.to_list() for c in chains], indent=2))
1388
return
1389
1390
if not chains:
1391
console.print(f"[yellow]No call chains found from[/yellow] [cyan]{name}[/cyan].")
1392
return
1393
1394
console.print(f"[bold]Call chains from[/bold] [cyan]{name}[/cyan] — {len(chains)} path(s)")
1395
for i, chain in enumerate(chains, 1):
1396
steps = chain.to_list()
1397
path_str = (
1398
" → ".join([steps[0]["caller"]] + [s["callee"] for s in steps]) if steps else name
1399
)
1400
console.print(f" {i}. {path_str}")
1401
1402
1403
# ── ANALYSIS: dead code ───────────────────────────────────────────────────────
1404
1405
1406
@main.command()
1407
@DB_OPTION
1408
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
1409
def deadcode(db: str, as_json: bool):
1410
"""Detect dead code — unreachable functions, classes, and orphan files.
1411
1412
A function/class is dead if nothing calls, references, or imports it.
1413
An orphan file is one that no other file imports.
1414
"""
1415
from navegador.analysis.deadcode import DeadCodeDetector
1416
1417
report = DeadCodeDetector(_get_store(db)).detect()
1418
1419
if as_json:
1420
click.echo(json.dumps(report.to_dict(), indent=2))
1421
return
1422
1423
summary = report.to_dict()["summary"]
1424
console.print(
1425
f"[bold]Dead code report:[/bold] "
1426
f"{summary['unreachable_functions']} dead functions, "
1427
f"{summary['unreachable_classes']} dead classes, "
1428
f"{summary['orphan_files']} orphan files"
1429
)
1430
1431
if report.unreachable_functions:
1432
fn_table = Table(
1433
title=f"Unreachable functions/methods ({len(report.unreachable_functions)})"
1434
)
1435
fn_table.add_column("Type", style="cyan")
1436
fn_table.add_column("Name", style="bold")
1437
fn_table.add_column("File")
1438
fn_table.add_column("Line", justify="right")
1439
for fn in report.unreachable_functions:
1440
fn_table.add_row(fn["type"], fn["name"], fn["file_path"], str(fn["line_start"] or ""))
1441
console.print(fn_table)
1442
1443
if report.unreachable_classes:
1444
cls_table = Table(title=f"Unreachable classes ({len(report.unreachable_classes)})")
1445
cls_table.add_column("Name", style="bold")
1446
cls_table.add_column("File")
1447
cls_table.add_column("Line", justify="right")
1448
for cls in report.unreachable_classes:
1449
cls_table.add_row(cls["name"], cls["file_path"], str(cls["line_start"] or ""))
1450
console.print(cls_table)
1451
1452
if report.orphan_files:
1453
console.print(f"\n[bold]Orphan files ({len(report.orphan_files)}):[/bold]")
1454
for fp in report.orphan_files:
1455
console.print(f" {fp}")
1456
1457
if not any([report.unreachable_functions, report.unreachable_classes, report.orphan_files]):
1458
console.print("[green]No dead code found.[/green]")
1459
1460
1461
# ── ANALYSIS: test mapping ────────────────────────────────────────────────────
1462
1463
1464
@main.command()
1465
@DB_OPTION
1466
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
1467
def testmap(db: str, as_json: bool):
1468
"""Map test functions to production code via TESTS edges.
1469
1470
Finds functions starting with test_, resolves the production symbol
1471
via CALLS edges and name heuristics, then writes TESTS edges to the graph.
1472
"""
1473
from navegador.analysis.testmap import TestMapper
1474
1475
result = TestMapper(_get_store(db)).map_tests()
1476
1477
if as_json:
1478
click.echo(json.dumps(result.to_dict(), indent=2))
1479
return
1480
1481
console.print(
1482
f"[bold]Test map:[/bold] {len(result.links)} linked, "
1483
f"{len(result.unmatched_tests)} unmatched, "
1484
f"{result.edges_created} TESTS edges created"
1485
)
1486
1487
if result.links:
1488
table = Table(title=f"Test -> production links ({len(result.links)})")
1489
table.add_column("Test", style="cyan")
1490
table.add_column("Production symbol", style="bold")
1491
table.add_column("File")
1492
table.add_column("Source")
1493
for lnk in result.links:
1494
table.add_row(lnk.test_name, lnk.prod_name, lnk.prod_file, lnk.source)
1495
console.print(table)
1496
1497
if result.unmatched_tests:
1498
console.print(f"\n[yellow]Unmatched tests ({len(result.unmatched_tests)}):[/yellow]")
1499
for t in result.unmatched_tests:
1500
console.print(f" {t['name']} ({t['file_path']})")
1501
1502
1503
# ── ANALYSIS: cycles ──────────────────────────────────────────────────────────
1504
1505
1506
@main.command()
1507
@DB_OPTION
1508
@click.option(
1509
"--imports", "check_imports", is_flag=True, default=False, help="Check import cycles only."
1510
)
1511
@click.option("--calls", "check_calls", is_flag=True, default=False, help="Check call cycles only.")
1512
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
1513
def cycles(db: str, check_imports: bool, check_calls: bool, as_json: bool):
1514
"""Detect circular dependencies in import and call graphs.
1515
1516
By default checks both import cycles and call cycles.
1517
Use --imports or --calls to restrict to one graph.
1518
"""
1519
from navegador.analysis.cycles import CycleDetector
1520
1521
detector = CycleDetector(_get_store(db))
1522
run_imports = check_imports or (not check_imports and not check_calls)
1523
run_calls = check_calls or (not check_imports and not check_calls)
1524
1525
import_cycles = detector.detect_import_cycles() if run_imports else []
1526
call_cycles = detector.detect_call_cycles() if run_calls else []
1527
1528
if as_json:
1529
click.echo(
1530
json.dumps({"import_cycles": import_cycles, "call_cycles": call_cycles}, indent=2)
1531
)
1532
return
1533
1534
if not import_cycles and not call_cycles:
1535
console.print("[green]No circular dependencies found.[/green]")
1536
return
1537
1538
if import_cycles:
1539
table = Table(title=f"Import cycles ({len(import_cycles)})")
1540
table.add_column("#", justify="right")
1541
table.add_column("Cycle")
1542
for i, cycle in enumerate(import_cycles, 1):
1543
table.add_row(str(i), " -> ".join(cycle) + f" -> {cycle[0]}")
1544
console.print(table)
1545
1546
if call_cycles:
1547
table = Table(title=f"Call cycles ({len(call_cycles)})")
1548
table.add_column("#", justify="right")
1549
table.add_column("Cycle")
1550
for i, cycle in enumerate(call_cycles, 1):
1551
table.add_row(str(i), " -> ".join(cycle) + f" -> {cycle[0]}")
1552
console.print(table)
1553
1554
1555
# ── Multi-repo (#16) ─────────────────────────────────────────────────────────
1556
1557
1558
@main.group()
1559
def repo():
1560
"""Manage and query across multiple repositories."""
1561
1562
1563
@repo.command("add")
1564
@click.argument("name")
1565
@click.argument("path", type=click.Path())
1566
@DB_OPTION
1567
def repo_add(name: str, path: str, db: str):
1568
"""Register a repository by NAME and PATH."""
1569
from navegador.multirepo import MultiRepoManager
1570
1571
mgr = MultiRepoManager(_get_store(db))
1572
mgr.add_repo(name, path)
1573
console.print(f"[green]Repo registered:[/green] {name} → {path}")
1574
1575
1576
@repo.command("list")
1577
@DB_OPTION
1578
@click.option("--json", "as_json", is_flag=True)
1579
def repo_list(db: str, as_json: bool):
1580
"""List all registered repositories."""
1581
from navegador.multirepo import MultiRepoManager
1582
1583
repos = MultiRepoManager(_get_store(db)).list_repos()
1584
if as_json:
1585
click.echo(json.dumps(repos, indent=2))
1586
return
1587
if not repos:
1588
console.print("[yellow]No repositories registered.[/yellow]")
1589
return
1590
table = Table(title="Registered repositories")
1591
table.add_column("Name", style="cyan")
1592
table.add_column("Path")
1593
for r in repos:
1594
table.add_row(r["name"], r["path"])
1595
console.print(table)
1596
1597
1598
@repo.command("ingest-all")
1599
@DB_OPTION
1600
@click.option("--clear", is_flag=True, help="Clear graph before ingesting.")
1601
@click.option("--json", "as_json", is_flag=True)
1602
def repo_ingest_all(db: str, clear: bool, as_json: bool):
1603
"""Ingest all registered repositories."""
1604
from navegador.multirepo import MultiRepoManager
1605
1606
mgr = MultiRepoManager(_get_store(db))
1607
with console.status("[bold]Ingesting all repos…[/bold]"):
1608
summary = mgr.ingest_all(clear=clear)
1609
if as_json:
1610
click.echo(json.dumps(summary, indent=2))
1611
return
1612
for name, stats in summary.items():
1613
table = Table(title=f"Repo: {name}")
1614
table.add_column("Metric", style="cyan")
1615
table.add_column("Count", justify="right", style="green")
1616
for k, v in stats.items():
1617
table.add_row(str(k).capitalize(), str(v))
1618
console.print(table)
1619
1620
1621
@repo.command("search")
1622
@click.argument("query")
1623
@DB_OPTION
1624
@click.option("--limit", default=20, show_default=True)
1625
@click.option("--json", "as_json", is_flag=True)
1626
def repo_search(query: str, db: str, limit: int, as_json: bool):
1627
"""Search across all registered repositories."""
1628
from navegador.multirepo import MultiRepoManager
1629
1630
results = MultiRepoManager(_get_store(db)).cross_repo_search(query, limit=limit)
1631
if as_json:
1632
click.echo(json.dumps(results, indent=2))
1633
return
1634
if not results:
1635
console.print("[yellow]No results.[/yellow]")
1636
return
1637
table = Table(title=f"Cross-repo search: {query!r}")
1638
table.add_column("Label", style="cyan")
1639
table.add_column("Name", style="bold")
1640
table.add_column("File/Path")
1641
for r in results:
1642
table.add_row(r["label"], r["name"], r["file_path"])
1643
console.print(table)
1644
1645
1646
# ── Rename (#26) ──────────────────────────────────────────────────────────────
1647
1648
1649
@main.command()
1650
@click.argument("old_name")
1651
@click.argument("new_name")
1652
@DB_OPTION
1653
@click.option("--preview", is_flag=True, help="Show what would change without applying.")
1654
@click.option("--json", "as_json", is_flag=True)
1655
def rename(old_name: str, new_name: str, db: str, preview: bool, as_json: bool):
1656
"""Rename a symbol across the graph (coordinated rename).
1657
1658
\b
1659
Examples:
1660
navegador rename old_func new_func --preview
1661
navegador rename MyClass RenamedClass
1662
"""
1663
from navegador.refactor import SymbolRenamer
1664
1665
renamer = SymbolRenamer(_get_store(db))
1666
if preview:
1667
result = renamer.preview_rename(old_name, new_name)
1668
data = {
1669
"old_name": result.old_name,
1670
"new_name": result.new_name,
1671
"affected_files": result.affected_files,
1672
"affected_nodes": len(result.affected_nodes),
1673
"edges_updated": result.edges_updated,
1674
}
1675
else:
1676
result = renamer.apply_rename(old_name, new_name)
1677
data = {
1678
"old_name": result.old_name,
1679
"new_name": result.new_name,
1680
"affected_files": result.affected_files,
1681
"affected_nodes": len(result.affected_nodes),
1682
"edges_updated": result.edges_updated,
1683
}
1684
1685
if as_json:
1686
click.echo(json.dumps(data, indent=2))
1687
return
1688
1689
action = "Preview" if preview else "Renamed"
1690
console.print(f"[green]{action}:[/green] {old_name!r} → {new_name!r}")
1691
console.print(f" Nodes affected : {data['affected_nodes']}")
1692
console.print(f" Edges updated : {data['edges_updated']}")
1693
if data["affected_files"]:
1694
console.print(" Files:")
1695
for f in data["affected_files"]:
1696
console.print(f" {f}")
1697
1698
1699
# ── CODEOWNERS (#39) ──────────────────────────────────────────────────────────
1700
1701
1702
@main.command()
1703
@click.argument("repo_path", type=click.Path(exists=True))
1704
@DB_OPTION
1705
@click.option("--json", "as_json", is_flag=True)
1706
def codeowners(repo_path: str, db: str, as_json: bool):
1707
"""Parse CODEOWNERS and map ownership to Person nodes."""
1708
from navegador.codeowners import CodeownersIngester
1709
1710
stats = CodeownersIngester(_get_store(db)).ingest(repo_path)
1711
if as_json:
1712
click.echo(json.dumps(stats, indent=2))
1713
return
1714
console.print(
1715
f"[green]CODEOWNERS ingested:[/green] "
1716
f"{stats['owners']} owners, {stats['patterns']} patterns, {stats['edges']} edges"
1717
)
1718
1719
1720
# ── ADR (#40) ─────────────────────────────────────────────────────────────────
1721
1722
1723
@main.group()
1724
def adr():
1725
"""Ingest Architecture Decision Records (ADRs) into the knowledge graph."""
1726
1727
1728
@adr.command("ingest")
1729
@click.argument("adr_dir", type=click.Path(exists=True))
1730
@DB_OPTION
1731
@click.option("--json", "as_json", is_flag=True)
1732
def adr_ingest(adr_dir: str, db: str, as_json: bool):
1733
"""Parse ADR markdown files and create Decision nodes."""
1734
from navegador.adr import ADRIngester
1735
1736
stats = ADRIngester(_get_store(db)).ingest(adr_dir)
1737
if as_json:
1738
click.echo(json.dumps(stats, indent=2))
1739
return
1740
console.print(
1741
f"[green]ADRs ingested:[/green] {stats['decisions']} decisions, {stats['skipped']} skipped"
1742
)
1743
1744
1745
# ── API schema (#41) ─────────────────────────────────────────────────────────
1746
1747
1748
@main.group()
1749
def api():
1750
"""Ingest API schema files (OpenAPI, GraphQL) into the graph."""
1751
1752
1753
@api.command("ingest")
1754
@click.argument("path", type=click.Path(exists=True))
1755
@DB_OPTION
1756
@click.option(
1757
"--type",
1758
"schema_type",
1759
type=click.Choice(["openapi", "graphql", "auto"]),
1760
default="auto",
1761
show_default=True,
1762
help="Schema type. auto detects from file extension.",
1763
)
1764
@click.option("--json", "as_json", is_flag=True)
1765
def api_ingest(path: str, db: str, schema_type: str, as_json: bool):
1766
"""Parse an OpenAPI or GraphQL schema and create API endpoint nodes.
1767
1768
\b
1769
Examples:
1770
navegador api ingest openapi.yaml
1771
navegador api ingest schema.graphql --type graphql
1772
navegador api ingest swagger.json --type openapi
1773
"""
1774
from pathlib import Path as P
1775
1776
from navegador.api_schema import APISchemaIngester
1777
1778
ingester = APISchemaIngester(_get_store(db))
1779
p = P(path)
1780
1781
if schema_type == "auto":
1782
if p.suffix.lower() in (".graphql", ".gql"):
1783
schema_type = "graphql"
1784
else:
1785
schema_type = "openapi"
1786
1787
if schema_type == "graphql":
1788
stats = ingester.ingest_graphql(path)
1789
label = "GraphQL"
1790
else:
1791
stats = ingester.ingest_openapi(path)
1792
label = "OpenAPI"
1793
1794
if as_json:
1795
click.echo(json.dumps(stats, indent=2))
1796
return
1797
1798
table = Table(title=f"{label} schema ingested")
1799
table.add_column("Metric", style="cyan")
1800
table.add_column("Count", justify="right", style="green")
1801
for k, v in stats.items():
1802
table.add_row(k.replace("_", " ").capitalize(), str(v))
1803
console.print(table)
1804
1805
1806
# ── PM: project management ticket ingestion (#53) ─────────────────────────────
1807
1808
1809
@main.group()
1810
def pm():
1811
"""Ingest project management tickets (GitHub Issues, Linear, Jira)."""
1812
1813
1814
@pm.command("ingest")
1815
@click.option(
1816
"--github",
1817
"github_repo",
1818
default="",
1819
metavar="OWNER/REPO",
1820
help="GitHub repository in owner/repo format.",
1821
)
1822
@click.option("--token", default="", envvar="GITHUB_TOKEN", help="GitHub personal access token.")
1823
@click.option(
1824
"--state",
1825
default="open",
1826
type=click.Choice(["open", "closed", "all"]),
1827
show_default=True,
1828
help="GitHub issue state filter.",
1829
)
1830
@click.option("--limit", default=100, show_default=True, help="Maximum number of issues to fetch.")
1831
@DB_OPTION
1832
@click.option("--json", "as_json", is_flag=True)
1833
def pm_ingest(github_repo: str, token: str, state: str, limit: int, db: str, as_json: bool):
1834
"""Ingest tickets from a PM tool into the knowledge graph.
1835
1836
\b
1837
Examples:
1838
navegador pm ingest --github owner/repo
1839
navegador pm ingest --github owner/repo --token ghp_...
1840
navegador pm ingest --github owner/repo --state all --limit 200
1841
"""
1842
if not github_repo:
1843
raise click.UsageError(
1844
"Provide --github <owner/repo> (more backends coming in a future release)."
1845
)
1846
1847
from navegador.pm import TicketIngester
1848
1849
ing = TicketIngester(_get_store(db))
1850
stats = ing.ingest_github_issues(github_repo, token=token, state=state, limit=limit)
1851
1852
if as_json:
1853
click.echo(json.dumps(stats, indent=2))
1854
else:
1855
table = Table(title=f"PM import: {github_repo}")
1856
table.add_column("Metric", style="cyan")
1857
table.add_column("Count", justify="right", style="green")
1858
for k, v in stats.items():
1859
table.add_row(k.capitalize(), str(v))
1860
console.print(table)
1861
1862
1863
# ── Dependencies: external package ingestion (#58) ────────────────────────────
1864
1865
1866
@main.group()
1867
def deps():
1868
"""Ingest external package dependencies (npm, pip, cargo)."""
1869
1870
1871
@deps.command("ingest")
1872
@click.argument("path", type=click.Path(exists=True))
1873
@click.option(
1874
"--type",
1875
"dep_type",
1876
type=click.Choice(["auto", "npm", "pip", "cargo"]),
1877
default="auto",
1878
show_default=True,
1879
help="Manifest type. auto detects from filename.",
1880
)
1881
@DB_OPTION
1882
@click.option("--json", "as_json", is_flag=True)
1883
def deps_ingest(path: str, dep_type: str, db: str, as_json: bool):
1884
"""Ingest external dependencies from a package manifest.
1885
1886
\b
1887
PATH can be:
1888
package.json (npm)
1889
requirements.txt (pip)
1890
pyproject.toml (pip)
1891
Cargo.toml (cargo)
1892
1893
\b
1894
Examples:
1895
navegador deps ingest package.json
1896
navegador deps ingest requirements.txt
1897
navegador deps ingest Cargo.toml --type cargo
1898
"""
1899
from pathlib import Path as P
1900
1901
from navegador.dependencies import DependencyIngester
1902
1903
ing = DependencyIngester(_get_store(db))
1904
p = P(path)
1905
1906
if dep_type == "auto":
1907
name = p.name.lower()
1908
if name == "package.json":
1909
dep_type = "npm"
1910
elif name in ("requirements.txt", "pyproject.toml"):
1911
dep_type = "pip"
1912
elif name == "cargo.toml":
1913
dep_type = "cargo"
1914
else:
1915
raise click.UsageError(
1916
f"Cannot auto-detect type for {p.name!r}. Use --type npm|pip|cargo."
1917
)
1918
1919
dispatch = {
1920
"npm": ing.ingest_npm,
1921
"pip": ing.ingest_pip,
1922
"cargo": ing.ingest_cargo,
1923
}
1924
stats = dispatch[dep_type](path)
1925
1926
if as_json:
1927
click.echo(json.dumps(stats, indent=2))
1928
else:
1929
console.print(
1930
f"[green]Dependencies ingested[/green] ({dep_type}): {stats['packages']} packages"
1931
)
1932
1933
1934
# ── Submodules: ingest parent + submodules (#61) ──────────────────────────────
1935
1936
1937
@main.group()
1938
def submodules():
1939
"""Ingest a parent repository and all its git submodules."""
1940
1941
1942
@submodules.command("ingest")
1943
@click.argument("repo_path", type=click.Path(exists=True))
1944
@DB_OPTION
1945
@click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
1946
@click.option("--json", "as_json", is_flag=True)
1947
def submodules_ingest(repo_path: str, db: str, clear: bool, as_json: bool):
1948
"""Ingest a repository and all its git submodules as linked nodes.
1949
1950
\b
1951
Examples:
1952
navegador submodules ingest .
1953
navegador submodules ingest /path/to/repo --clear
1954
"""
1955
from navegador.submodules import SubmoduleIngester
1956
1957
ing = SubmoduleIngester(_get_store(db))
1958
stats = ing.ingest_with_submodules(repo_path, clear=clear)
1959
1960
if as_json:
1961
click.echo(json.dumps(stats, indent=2))
1962
else:
1963
sub_names = list(stats.get("submodules", {}).keys())
1964
console.print(
1965
f"[green]Submodule ingestion complete[/green]: "
1966
f"{stats.get('total_files', 0)} total files, "
1967
f"{len(sub_names)} submodule(s)"
1968
)
1969
if sub_names:
1970
console.print(" Submodules: " + ", ".join(sub_names))
1971
1972
1973
@submodules.command("list")
1974
@click.argument("repo_path", type=click.Path(exists=True), default=".")
1975
def submodules_list(repo_path: str):
1976
"""List git submodules found in REPO_PATH."""
1977
from navegador.submodules import SubmoduleIngester
1978
1979
subs = SubmoduleIngester.__new__(SubmoduleIngester)
1980
subs.store = None # type: ignore[assignment]
1981
items = subs.detect_submodules(repo_path)
1982
1983
if not items:
1984
console.print("[yellow]No submodules found (no .gitmodules).[/yellow]")
1985
return
1986
1987
table = Table(title=f"Submodules in {repo_path}")
1988
table.add_column("Name", style="cyan")
1989
table.add_column("Path")
1990
table.add_column("URL")
1991
for item in items:
1992
table.add_row(item["name"], item["path"], item.get("url", ""))
1993
console.print(table)
1994
1995
1996
# ── Workspace: multi-repo (#62) ────────────────────────────────────────────────
1997
1998
1999
@main.group()
2000
def workspace():
2001
"""Manage a multi-repo workspace (unified or federated graph)."""
2002
2003
2004
@workspace.command("ingest")
2005
@click.argument("repos", nargs=-1, metavar="NAME=PATH ...")
2006
@click.option(
2007
"--mode",
2008
type=click.Choice(["unified", "federated"]),
2009
default="unified",
2010
show_default=True,
2011
help="Graph mode: unified (shared graph) or federated (per-repo graphs).",
2012
)
2013
@DB_OPTION
2014
@click.option("--clear", is_flag=True)
2015
@click.option("--json", "as_json", is_flag=True)
2016
def workspace_ingest(repos: tuple, mode: str, db: str, clear: bool, as_json: bool):
2017
"""Ingest multiple repositories as a workspace.
2018
2019
\b
2020
REPOS is a list of NAME=PATH pairs, e.g.:
2021
navegador workspace ingest backend=/path/to/backend frontend=/path/to/frontend
2022
2023
\b
2024
Examples:
2025
navegador workspace ingest backend=. frontend=../frontend --mode unified
2026
navegador workspace ingest api=./api worker=./worker --mode federated
2027
"""
2028
from navegador.multirepo import WorkspaceManager, WorkspaceMode
2029
2030
if not repos:
2031
raise click.UsageError("Provide at least one NAME=PATH repo.")
2032
2033
wm = WorkspaceManager(_get_store(db), mode=WorkspaceMode(mode))
2034
for repo_spec in repos:
2035
if "=" not in repo_spec:
2036
raise click.UsageError(f"Invalid repo spec {repo_spec!r}. Expected NAME=PATH format.")
2037
name, path = repo_spec.split("=", 1)
2038
wm.add_repo(name.strip(), path.strip())
2039
2040
stats = wm.ingest_all(clear=clear)
2041
2042
if as_json:
2043
click.echo(json.dumps(stats, indent=2))
2044
else:
2045
for repo_name, repo_stats in stats.items():
2046
if "error" in repo_stats:
2047
console.print(f"[red]Error ingesting {repo_name}:[/red] {repo_stats['error']}")
2048
else:
2049
console.print(
2050
f"[green]{repo_name}[/green]: "
2051
f"{repo_stats.get('files', 0)} files, "
2052
f"{repo_stats.get('nodes', 0)} nodes"
2053
)
2054
2055
2056
# ── Intelligence: semantic search ─────────────────────────────────────────────
2057
2058
2059
@main.command("semantic-search")
2060
@click.argument("query")
2061
@DB_OPTION
2062
@click.option("--limit", default=10, show_default=True, help="Maximum results to return.")
2063
@click.option(
2064
"--index",
2065
"do_index",
2066
is_flag=True,
2067
help="(Re-)build the embedding index before searching.",
2068
)
2069
@click.option(
2070
"--provider",
2071
"llm_provider",
2072
default="",
2073
help="LLM provider to use (anthropic, openai, ollama). Auto-detected if omitted.",
2074
)
2075
@click.option("--model", "llm_model", default="", help="LLM model name.")
2076
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
2077
def semantic_search(
2078
query: str,
2079
db: str,
2080
limit: int,
2081
do_index: bool,
2082
llm_provider: str,
2083
llm_model: str,
2084
as_json: bool,
2085
):
2086
"""Semantic similarity search using embeddings.
2087
2088
Embeds QUERY and returns the most similar symbols from the graph.
2089
Use --index to (re-)build the embedding index before searching.
2090
2091
\b
2092
Examples:
2093
navegador semantic-search "validates JWT tokens"
2094
navegador semantic-search "database connection" --index --provider openai
2095
"""
2096
from navegador.intelligence.search import SemanticSearch
2097
from navegador.llm import auto_provider, get_provider
2098
2099
store = _get_store(db)
2100
provider = (
2101
get_provider(llm_provider, model=llm_model)
2102
if llm_provider
2103
else auto_provider(model=llm_model)
2104
)
2105
ss = SemanticSearch(store, provider)
2106
2107
if do_index:
2108
n = ss.index()
2109
if not as_json:
2110
console.print(f"[green]Indexed[/green] {n} nodes.")
2111
2112
results = ss.search(query, limit=limit)
2113
2114
if as_json:
2115
click.echo(json.dumps(results, indent=2))
2116
return
2117
2118
if not results:
2119
console.print("[yellow]No results found. Try --index to build the index first.[/yellow]")
2120
return
2121
2122
table = Table(title=f"Semantic search: {query!r}")
2123
table.add_column("Score", style="cyan", justify="right")
2124
table.add_column("Type", style="yellow")
2125
table.add_column("Name", style="bold")
2126
table.add_column("File", style="dim")
2127
for r in results:
2128
table.add_row(
2129
f"{r['score']:.3f}",
2130
r.get("type", ""),
2131
r.get("name", ""),
2132
r.get("file_path", ""),
2133
)
2134
console.print(table)
2135
2136
2137
# ── Intelligence: community detection ─────────────────────────────────────────
2138
2139
2140
@main.command("communities")
2141
@DB_OPTION
2142
@click.option("--min-size", default=2, show_default=True, help="Minimum community size.")
2143
@click.option("--store-labels", is_flag=True, help="Write community labels back onto nodes.")
2144
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
2145
def communities(db: str, min_size: int, store_labels: bool, as_json: bool):
2146
"""Detect communities in the graph via label propagation.
2147
2148
\b
2149
Examples:
2150
navegador communities
2151
navegador communities --min-size 3 --store-labels
2152
"""
2153
from navegador.intelligence.community import CommunityDetector
2154
2155
store = _get_store(db)
2156
detector = CommunityDetector(store)
2157
detected = detector.detect(min_size=min_size)
2158
2159
if store_labels:
2160
n = detector.store_communities()
2161
if not as_json:
2162
console.print(f"[green]Community labels written to[/green] {n} nodes.")
2163
2164
if as_json:
2165
click.echo(
2166
json.dumps(
2167
[
2168
{
2169
"name": c.name,
2170
"members": c.members,
2171
"size": c.size,
2172
"density": c.density,
2173
}
2174
for c in detected
2175
],
2176
indent=2,
2177
)
2178
)
2179
return
2180
2181
if not detected:
2182
console.print("[yellow]No communities found (graph may be empty).[/yellow]")
2183
return
2184
2185
table = Table(title=f"Communities (min_size={min_size})")
2186
table.add_column("Community", style="cyan")
2187
table.add_column("Size", justify="right", style="green")
2188
table.add_column("Density", justify="right", style="yellow")
2189
table.add_column("Members (preview)", style="dim")
2190
for c in detected:
2191
preview = ", ".join(c.members[:5])
2192
if c.size > 5:
2193
preview += f" …+{c.size - 5}"
2194
table.add_row(c.name, str(c.size), f"{c.density:.3f}", preview)
2195
console.print(table)
2196
2197
2198
# ── Intelligence: natural language query ──────────────────────────────────────
2199
2200
2201
@main.command("ask")
2202
@click.argument("question")
2203
@DB_OPTION
2204
@click.option(
2205
"--provider",
2206
"llm_provider",
2207
default="",
2208
help="LLM provider (anthropic, openai, ollama). Auto-detected if omitted.",
2209
)
2210
@click.option("--model", "llm_model", default="", help="LLM model name.")
2211
def ask(question: str, db: str, llm_provider: str, llm_model: str):
2212
"""Ask a natural-language question about the codebase.
2213
2214
Converts the question to Cypher, executes it, and returns a
2215
human-readable answer.
2216
2217
\b
2218
Examples:
2219
navegador ask "Which functions call authenticate_user?"
2220
navegador ask "What concepts are in the auth domain?"
2221
"""
2222
from navegador.intelligence.nlp import NLPEngine
2223
from navegador.llm import auto_provider, get_provider
2224
2225
store = _get_store(db)
2226
provider = (
2227
get_provider(llm_provider, model=llm_model)
2228
if llm_provider
2229
else auto_provider(model=llm_model)
2230
)
2231
engine = NLPEngine(store, provider)
2232
2233
with console.status("[bold]Thinking...[/bold]"):
2234
answer = engine.natural_query(question)
2235
2236
console.print(answer)
2237
2238
2239
# ── Intelligence: generate docs ───────────────────────────────────────────────
2240
2241
2242
@main.command("generate-docs")
2243
@click.argument("name")
2244
@DB_OPTION
2245
@click.option(
2246
"--provider",
2247
"llm_provider",
2248
default="",
2249
help="LLM provider (anthropic, openai, ollama). Auto-detected if omitted.",
2250
)
2251
@click.option("--model", "llm_model", default="", help="LLM model name.")
2252
@click.option("--file", "file_path", default="", help="Narrow to a specific file.")
2253
def generate_docs_cmd(name: str, db: str, llm_provider: str, llm_model: str, file_path: str):
2254
"""Generate LLM-powered documentation for a named symbol.
2255
2256
\b
2257
Examples:
2258
navegador generate-docs authenticate_user
2259
navegador generate-docs GraphStore --file navegador/graph/store.py
2260
"""
2261
from navegador.intelligence.nlp import NLPEngine
2262
from navegador.llm import auto_provider, get_provider
2263
2264
store = _get_store(db)
2265
provider = (
2266
get_provider(llm_provider, model=llm_model)
2267
if llm_provider
2268
else auto_provider(model=llm_model)
2269
)
2270
engine = NLPEngine(store, provider)
2271
2272
with console.status("[bold]Generating docs...[/bold]"):
2273
docs = engine.generate_docs(name, file_path=file_path)
2274
2275
console.print(docs)
2276
2277
2278
# ── Intelligence: docs (template + LLM) ──────────────────────────────────────
2279
2280
2281
@main.command("docs")
2282
@click.argument("target")
2283
@DB_OPTION
2284
@click.option("--project", is_flag=True, help="Generate full project documentation.")
2285
@click.option(
2286
"--provider",
2287
"llm_provider",
2288
default="",
2289
help="LLM provider (anthropic, openai, ollama). Template mode if omitted.",
2290
)
2291
@click.option("--model", "llm_model", default="", help="LLM model name.")
2292
@click.option("--json", "as_json", is_flag=True, help="Output as JSON (wraps markdown in a dict).")
2293
def docs(target: str, db: str, project: bool, llm_provider: str, llm_model: str, as_json: bool):
2294
"""Generate markdown documentation from the graph.
2295
2296
TARGET can be a file path or a module name (dotted or partial).
2297
Use --project to generate full project docs instead.
2298
2299
\b
2300
Examples:
2301
navegador docs navegador/graph/store.py
2302
navegador docs navegador.graph
2303
navegador docs . --project
2304
navegador docs . --project --provider openai
2305
"""
2306
from navegador.intelligence.docgen import DocGenerator
2307
2308
store = _get_store(db)
2309
2310
provider = None
2311
if llm_provider:
2312
from navegador.llm import get_provider
2313
2314
provider = get_provider(llm_provider, model=llm_model)
2315
2316
gen = DocGenerator(store, provider=provider)
2317
2318
if project:
2319
with console.status("[bold]Generating project docs...[/bold]"):
2320
output = gen.generate_project_docs()
2321
elif "/" in target or target.endswith(".py"):
2322
with console.status(f"[bold]Generating docs for file[/bold] {target}..."):
2323
output = gen.generate_file_docs(target)
2324
else:
2325
with console.status(f"[bold]Generating docs for module[/bold] {target}..."):
2326
output = gen.generate_module_docs(target)
2327
2328
if as_json:
2329
click.echo(json.dumps({"docs": output}, indent=2))
2330
else:
2331
console.print(output)
2332

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button