codevira 1.6.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.
Files changed (58) hide show
  1. codevira-1.6.0.dist-info/LICENSE +21 -0
  2. codevira-1.6.0.dist-info/METADATA +477 -0
  3. codevira-1.6.0.dist-info/RECORD +58 -0
  4. codevira-1.6.0.dist-info/WHEEL +5 -0
  5. codevira-1.6.0.dist-info/entry_points.txt +2 -0
  6. codevira-1.6.0.dist-info/top_level.txt +2 -0
  7. indexer/__init__.py +1 -0
  8. indexer/chunker.py +428 -0
  9. indexer/global_db.py +197 -0
  10. indexer/graph_generator.py +380 -0
  11. indexer/index_codebase.py +588 -0
  12. indexer/outcome_tracker.py +172 -0
  13. indexer/rule_learner.py +186 -0
  14. indexer/sqlite_graph.py +640 -0
  15. indexer/treesitter_parser.py +423 -0
  16. mcp_server/__init__.py +1 -0
  17. mcp_server/__main__.py +20 -0
  18. mcp_server/auto_init.py +257 -0
  19. mcp_server/cli.py +622 -0
  20. mcp_server/crash_logger.py +236 -0
  21. mcp_server/data/__init__.py +1 -0
  22. mcp_server/data/agents/builder.md +84 -0
  23. mcp_server/data/agents/developer.md +111 -0
  24. mcp_server/data/agents/documenter.md +138 -0
  25. mcp_server/data/agents/orchestrator.md +96 -0
  26. mcp_server/data/agents/planner.md +106 -0
  27. mcp_server/data/agents/reviewer.md +82 -0
  28. mcp_server/data/agents/tester.md +83 -0
  29. mcp_server/data/config.example.yaml +33 -0
  30. mcp_server/data/rules/coding-standards.md +48 -0
  31. mcp_server/data/rules/engineering-excellence.md +28 -0
  32. mcp_server/data/rules/git-cicd-governance.md +32 -0
  33. mcp_server/data/rules/git_commits.md +130 -0
  34. mcp_server/data/rules/incremental-updates.md +5 -0
  35. mcp_server/data/rules/master_rule.md +187 -0
  36. mcp_server/data/rules/multi-language.md +19 -0
  37. mcp_server/data/rules/persistence.md +21 -0
  38. mcp_server/data/rules/resilience-observability.md +17 -0
  39. mcp_server/data/rules/smoke-testing.md +48 -0
  40. mcp_server/data/rules/testing-standards.md +23 -0
  41. mcp_server/detect.py +284 -0
  42. mcp_server/gitignore.py +284 -0
  43. mcp_server/global_sync.py +187 -0
  44. mcp_server/http_server.py +341 -0
  45. mcp_server/ide_inject.py +444 -0
  46. mcp_server/launchd.py +156 -0
  47. mcp_server/migrate.py +215 -0
  48. mcp_server/paths.py +256 -0
  49. mcp_server/prompts.py +136 -0
  50. mcp_server/server.py +1049 -0
  51. mcp_server/tools/__init__.py +0 -0
  52. mcp_server/tools/changesets.py +223 -0
  53. mcp_server/tools/code_reader.py +335 -0
  54. mcp_server/tools/graph.py +637 -0
  55. mcp_server/tools/learning.py +238 -0
  56. mcp_server/tools/playbook.py +89 -0
  57. mcp_server/tools/roadmap.py +599 -0
  58. mcp_server/tools/search.py +145 -0
@@ -0,0 +1,588 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ import hashlib
9
+ import threading
10
+ from pathlib import Path
11
+
12
+ from mcp_server.paths import get_data_dir, get_project_root
13
+ from indexer.sqlite_graph import SQLiteGraph
14
+
15
+ COLLECTION_NAME = "codebase_index"
16
+
17
+ # Global lock — prevents the background watcher and background full-index from
18
+ # writing to ChromaDB simultaneously. Both operations must acquire this lock
19
+ # before any ChromaDB write (add/delete/recreate collection).
20
+ _chroma_write_lock = threading.Lock()
21
+
22
+ # Atomic counters for background indexing progress
23
+ _bg_files_indexed: int = 0
24
+ _bg_total_files: int = 0
25
+ _bg_status: str = "idle" # idle | running | done | error
26
+ _bg_lock = threading.Lock()
27
+
28
+
29
+ def _project_root() -> Path:
30
+ return get_project_root()
31
+
32
+
33
+ def _index_dir() -> Path:
34
+ # get_data_dir() is cached in paths.py (_data_dir_cache), so this is fast.
35
+ return get_data_dir() / "codeindex"
36
+
37
+ def _load_config() -> dict:
38
+ """Load .codevira/config.yaml and return the 'project' sub-dict."""
39
+ config_path = get_data_dir() / "config.yaml"
40
+ if config_path.exists():
41
+ try:
42
+ import yaml
43
+ with open(config_path) as f:
44
+ raw = yaml.safe_load(f) or {}
45
+ # config.yaml nests settings under 'project' key
46
+ return raw.get("project", raw)
47
+ except Exception:
48
+ pass
49
+ return {}
50
+
51
+ def _check_search_deps() -> bool:
52
+ """Return True if chromadb + sentence-transformers are available."""
53
+ try:
54
+ import chromadb # noqa: F401
55
+ import sentence_transformers # noqa: F401
56
+ return True
57
+ except ImportError:
58
+ return False
59
+
60
+ def _get_chroma_client():
61
+ try:
62
+ import chromadb
63
+ except ImportError:
64
+ print("ERROR: semantic search requires chromadb.")
65
+ print(" Install it with: pip install 'codevira[search]'")
66
+ sys.exit(1)
67
+ db_dir = str(_index_dir())
68
+ return chromadb.PersistentClient(path=db_dir)
69
+
70
+ def _get_embedding_fn():
71
+ try:
72
+ from chromadb.utils import embedding_functions
73
+ return embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2")
74
+ except ImportError:
75
+ print("ERROR: semantic search requires sentence-transformers.")
76
+ print(" Install it with: pip install 'codevira[search]'")
77
+ sys.exit(1)
78
+
79
+ def _compute_hash(file_path: Path) -> str:
80
+ hasher = hashlib.sha256()
81
+ with open(file_path, "rb") as f:
82
+ for chunk in iter(lambda: f.read(4096), b""):
83
+ hasher.update(chunk)
84
+ return hasher.hexdigest()
85
+
86
+ def _get_changed_files(db: SQLiteGraph) -> list[tuple[str, str]]:
87
+ changed = []
88
+ seen_paths = set()
89
+ config = _load_config()
90
+ watched_dirs = config.get("watched_dirs", ["src"])
91
+ extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
92
+ skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
93
+
94
+ for watch_dir in watched_dirs:
95
+ watch_path = _project_root() / watch_dir
96
+ if not watch_path.exists():
97
+ continue
98
+
99
+ for p in watch_path.rglob("*"):
100
+ if not p.is_file():
101
+ continue
102
+ if p.suffix not in extensions:
103
+ continue
104
+ if any(skip in p.parts for skip in skip_dirs):
105
+ continue
106
+
107
+ try:
108
+ rel_path = str(p.relative_to(_project_root()))
109
+ if rel_path in seen_paths:
110
+ continue
111
+ seen_paths.add(rel_path)
112
+ current_hash = _compute_hash(p)
113
+ stored_hash = db.get_file_hash(rel_path)
114
+
115
+ if current_hash != stored_hash:
116
+ changed.append((rel_path, current_hash))
117
+ except Exception as e:
118
+ try:
119
+ from mcp_server.crash_logger import log_crash
120
+ log_crash(e, context="get_changed_files: hash check")
121
+ except Exception: pass
122
+
123
+ return changed
124
+
125
+
126
+ def _get_requested_files(file_paths: list[str]) -> list[tuple[str, str]]:
127
+ requested = []
128
+ seen_paths = set()
129
+ config = _load_config()
130
+ extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
131
+ skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
132
+
133
+ for raw_path in file_paths:
134
+ candidate = Path(raw_path)
135
+ abs_path = candidate if candidate.is_absolute() else _project_root() / candidate
136
+
137
+ try:
138
+ rel_path = str(abs_path.resolve().relative_to(_project_root()))
139
+ except ValueError:
140
+ continue
141
+
142
+ if rel_path in seen_paths:
143
+ continue
144
+ if not abs_path.exists() or not abs_path.is_file():
145
+ continue
146
+ if abs_path.suffix not in extensions:
147
+ continue
148
+ if any(skip in abs_path.parts for skip in skip_dirs):
149
+ continue
150
+
151
+ seen_paths.add(rel_path)
152
+ requested.append((rel_path, _compute_hash(abs_path)))
153
+
154
+ return requested
155
+
156
+ def _chunk_to_document(chunk) -> tuple[str, str, dict]:
157
+ doc_id = f"{chunk.file_path}::{chunk.chunk_type}::{chunk.name}::{chunk.start_line}"
158
+ document = f"{chunk.file_path} — {chunk.name}\n{chunk.docstring}\n\n{chunk.source_text}"
159
+ metadata = {
160
+ "file_path": chunk.file_path,
161
+ "name": chunk.name,
162
+ "chunk_type": chunk.chunk_type,
163
+ "start_line": chunk.start_line,
164
+ "end_line": chunk.end_line,
165
+ "layer": chunk.layer,
166
+ }
167
+ return doc_id, document, metadata
168
+
169
+ def cmd_full_rebuild():
170
+ from indexer.chunker import chunk_project
171
+ from indexer.graph_generator import generate_graph_sqlite
172
+ from rich.console import Console
173
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
174
+
175
+ console = Console()
176
+ _index_dir().mkdir(parents=True, exist_ok=True)
177
+ db = SQLiteGraph(get_data_dir() / "graph" / "graph.db")
178
+
179
+ if not _check_search_deps():
180
+ console.print("[yellow]⚠[/yellow] Semantic search skipped — install with: [bold]pip install 'codevira\\[search\\]'[/bold]")
181
+ # Still build the graph even without search deps
182
+ from indexer.graph_generator import generate_graph_sqlite
183
+ result = generate_graph_sqlite(str(_project_root()), str(get_data_dir() / "graph" / "graph.db"))
184
+ console.print(f"[green]✓[/green] Graph built: {result.get('nodes_added', 0)} nodes, {result.get('edges_added', 0)} edges.")
185
+ db.close()
186
+ return
187
+
188
+ client = _get_chroma_client()
189
+ try:
190
+ client.delete_collection(COLLECTION_NAME)
191
+ except Exception:
192
+ pass
193
+
194
+ embed_fn = _get_embedding_fn()
195
+ collection = client.create_collection(name=COLLECTION_NAME, embedding_function=embed_fn)
196
+
197
+ config = _load_config()
198
+ watched_dirs = config.get("watched_dirs", ["src"])
199
+ extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
200
+ skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
201
+
202
+ all_chunks = []
203
+ file_hashes = {}
204
+ seen_chunk_ids = set()
205
+
206
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), console=console) as progress:
207
+ task1 = progress.add_task("[cyan]Parsing and chunking source files...", total=None)
208
+
209
+ # Chunk the entire project once (not per watched_dir)
210
+ all_project_chunks = chunk_project(str(_project_root()))
211
+
212
+ for watch_dir in watched_dirs:
213
+ abs_dir = _project_root() / watch_dir
214
+ if not abs_dir.exists():
215
+ continue
216
+ wd_str = str(watch_dir)
217
+ for c in all_project_chunks:
218
+ if c.file_path.startswith(wd_str) or wd_str == ".":
219
+ chunk_id = f"{c.file_path}::{c.chunk_type}::{c.name}::{c.start_line}"
220
+ if chunk_id not in seen_chunk_ids:
221
+ seen_chunk_ids.add(chunk_id)
222
+ all_chunks.append(c)
223
+
224
+ for p in abs_dir.rglob("*"):
225
+ if p.is_file() and p.suffix in extensions and not any(s in p.parts for s in skip_dirs):
226
+ rel = str(p.relative_to(_project_root()))
227
+ file_hashes[rel] = _compute_hash(p)
228
+
229
+ progress.update(task1, completed=100)
230
+ task2 = progress.add_task(f"[cyan]Embedding {len(all_chunks)} chunks into ChromaDB...", total=len(all_chunks))
231
+
232
+ ids, docs, metadatas = [], [], []
233
+ for i, chunk in enumerate(all_chunks):
234
+ doc_id, doc, meta = _chunk_to_document(chunk)
235
+ ids.append(doc_id)
236
+ docs.append(doc)
237
+ metadatas.append(meta)
238
+
239
+ if len(ids) >= 100 or i == len(all_chunks) - 1:
240
+ if ids:
241
+ collection.add(ids=ids, documents=docs, metadatas=metadatas)
242
+ ids, docs, metadatas = [], [], []
243
+ progress.update(task2, advance=1)
244
+
245
+ console.print(f"[green]✓[/green] Full rebuild complete: {len(all_chunks)} chunks indexed.")
246
+
247
+ for path, f_hash in file_hashes.items():
248
+ db.update_file_hash(path, f_hash)
249
+
250
+ console.print(f"[cyan]Generating auto-graph stubs...[/cyan]")
251
+ generate_graph_sqlite(str(_project_root()), str(db.db_path))
252
+ db.close()
253
+
254
+ def cmd_incremental(quiet: bool = False, file_paths: list[str] | None = None):
255
+ from indexer.chunker import chunk_file
256
+ from indexer.graph_generator import generate_graph_sqlite
257
+ from rich.console import Console
258
+ console = Console(quiet=quiet)
259
+
260
+ db = SQLiteGraph(get_data_dir() / "graph" / "graph.db")
261
+ explicit_files = file_paths or []
262
+ changed_items = _get_requested_files(explicit_files) if explicit_files else _get_changed_files(db)
263
+
264
+ if not changed_items:
265
+ if explicit_files:
266
+ console.print("[green]✓[/green] No matching files found to re-index.")
267
+ else:
268
+ console.print("[green]✓[/green] No files changed. Index is up to date.")
269
+ db.close()
270
+ return 0
271
+
272
+ file_label = "requested file(s)" if explicit_files else "changed file(s)"
273
+ console.print(f"[bold cyan]Incremental update:[/bold cyan] {len(changed_items)} {file_label}")
274
+
275
+ client = _get_chroma_client()
276
+ embed_fn = _get_embedding_fn()
277
+ try:
278
+ collection = client.get_collection(COLLECTION_NAME, embedding_function=embed_fn)
279
+ except Exception:
280
+ console.print("[red]No existing index found.[/red] Run codevira index --full first.")
281
+ db.close()
282
+ sys.exit(1)
283
+
284
+ indexed_any = False
285
+ # Acquire ChromaDB write lock to prevent concurrent writes with the
286
+ # background watcher or background full-index thread.
287
+ with _chroma_write_lock:
288
+ for fpath, fhash in changed_items:
289
+ try:
290
+ collection.delete(where={"file_path": fpath})
291
+ except Exception as e:
292
+ try:
293
+ from mcp_server.crash_logger import log_crash
294
+ log_crash(e, context=f"incremental index: delete old chunks for {fpath}")
295
+ except Exception: pass
296
+
297
+ try:
298
+ chunks = chunk_file(str(_project_root() / fpath), str(_project_root()))
299
+ if chunks:
300
+ ids, docs, metas = [], [], []
301
+ for chunk in chunks:
302
+ doc_id, doc, meta = _chunk_to_document(chunk)
303
+ ids.append(doc_id)
304
+ docs.append(doc)
305
+ metas.append(meta)
306
+ collection.add(ids=ids, documents=docs, metadatas=metas)
307
+
308
+ db.update_file_hash(fpath, fhash)
309
+ indexed_any = True
310
+ console.print(f" [green]+[/green] Re-indexed {len(chunks)} chunks for {fpath}")
311
+
312
+ except Exception as e:
313
+ console.print(f"[red]Error indexing {fpath}: {e}[/red]")
314
+ try:
315
+ from mcp_server.crash_logger import log_crash
316
+ log_crash(e, context=f"incremental index: indexing {fpath}")
317
+ except Exception: pass
318
+ continue
319
+
320
+ if indexed_any:
321
+ generate_graph_sqlite(str(_project_root()), str(db.db_path))
322
+
323
+ db.close()
324
+ return 0
325
+
326
+ _watcher_logger = logging.getLogger("codevira.watcher")
327
+
328
+ # Debounce delay: how long to wait after the last file change before reindexing.
329
+ # This prevents rapid saves (auto-formatters, IDE auto-save) from triggering
330
+ # dozens of reindex cycles.
331
+ DEBOUNCE_SECONDS = 2.0
332
+
333
+
334
+ def start_background_watcher(quiet: bool = True):
335
+ """
336
+ Start a non-blocking file watcher that auto-reindexes on source changes.
337
+
338
+ Returns the watchdog Observer (already started) so the caller can stop it
339
+ later if needed. The watcher uses a debounce timer: after the last file
340
+ event, it waits DEBOUNCE_SECONDS before running cmd_incremental().
341
+
342
+ Called automatically by the MCP server on startup.
343
+ """
344
+ from watchdog.observers import Observer
345
+ from watchdog.events import FileSystemEventHandler
346
+
347
+ config = _load_config()
348
+ watched_dirs = config.get("watched_dirs", ["src"])
349
+ extensions = config.get("file_extensions", [".py", ".ts", ".tsx", ".go", ".rs"])
350
+ skip_dirs = config.get("skip_dirs", ["node_modules", ".venv", "__pycache__"])
351
+
352
+ class DebouncedHandler(FileSystemEventHandler):
353
+ def __init__(self):
354
+ super().__init__()
355
+ self._timer: threading.Timer | None = None
356
+ self._lock = threading.Lock()
357
+
358
+ def _schedule_reindex(self, src_path: str):
359
+ abs_path = Path(src_path)
360
+ if not any(abs_path.suffix == ext for ext in extensions):
361
+ return
362
+ if any(skip in abs_path.parts for skip in skip_dirs):
363
+ return
364
+
365
+ with self._lock:
366
+ # Cancel any pending timer and restart the debounce window
367
+ if self._timer is not None:
368
+ self._timer.cancel()
369
+ self._timer = threading.Timer(DEBOUNCE_SECONDS, self._do_reindex)
370
+ self._timer.daemon = True
371
+ self._timer.start()
372
+
373
+ def _do_reindex(self):
374
+ try:
375
+ _watcher_logger.debug("File change detected — running incremental reindex")
376
+ # Note: cmd_incremental acquires _chroma_write_lock internally,
377
+ # so we don't need to acquire it here.
378
+ cmd_incremental(quiet=quiet)
379
+ _watcher_logger.debug("Incremental reindex complete")
380
+ except Exception as e:
381
+ _watcher_logger.warning("Background reindex failed: %s", e)
382
+ try:
383
+ from mcp_server.crash_logger import log_crash
384
+ log_crash(e, context="background watcher: incremental reindex")
385
+ except Exception: pass
386
+
387
+ def on_modified(self, event):
388
+ if not event.is_directory:
389
+ self._schedule_reindex(event.src_path)
390
+
391
+ def on_created(self, event):
392
+ if not event.is_directory:
393
+ self._schedule_reindex(event.src_path)
394
+
395
+ def on_deleted(self, event):
396
+ if not event.is_directory:
397
+ self._schedule_reindex(event.src_path)
398
+
399
+ observer = Observer()
400
+ observer.daemon = True
401
+ handler = DebouncedHandler()
402
+
403
+ scheduled = 0
404
+ for wd in watched_dirs:
405
+ path = _project_root() / wd
406
+ if path.exists():
407
+ observer.schedule(handler, str(path), recursive=True)
408
+ scheduled += 1
409
+
410
+ if scheduled > 0:
411
+ observer.start()
412
+ _watcher_logger.info(
413
+ "Background watcher started — monitoring %d dir(s): %s",
414
+ scheduled, ", ".join(watched_dirs),
415
+ )
416
+ else:
417
+ _watcher_logger.warning("No valid watched_dirs found — watcher not started")
418
+
419
+ return observer
420
+
421
+
422
+ def cmd_watch():
423
+ """Blocking CLI mode: start watcher and keep the process alive."""
424
+ config = _load_config()
425
+ watched_dirs = config.get("watched_dirs", ["src"])
426
+ print(f"Watching for changes in: {', '.join(watched_dirs)}...")
427
+ print("Press Ctrl+C to stop.\n")
428
+
429
+ observer = start_background_watcher(quiet=False)
430
+ try:
431
+ while True:
432
+ time.sleep(1)
433
+ except KeyboardInterrupt:
434
+ observer.stop()
435
+ observer.join()
436
+
437
+ def get_indexing_status() -> dict:
438
+ """Return the current background indexing progress. Thread-safe."""
439
+ with _bg_lock:
440
+ return {
441
+ "status": _bg_status,
442
+ "files_indexed": _bg_files_indexed,
443
+ "total_files": _bg_total_files,
444
+ }
445
+
446
+
447
+ def start_background_full_index(callback=None) -> "threading.Thread":
448
+ """Start a full index rebuild in a background daemon thread.
449
+
450
+ This is used by auto_init.py to build the index without blocking tool calls.
451
+ The ChromaDB write lock (_chroma_write_lock) prevents concurrent writes
452
+ with the file watcher.
453
+
454
+ Args:
455
+ callback: Optional callable invoked when indexing completes.
456
+ Called with (status: str) where status is 'done' or 'error'.
457
+
458
+ Returns:
459
+ The started Thread object.
460
+ """
461
+ global _bg_status, _bg_files_indexed, _bg_total_files
462
+
463
+ def _run():
464
+ global _bg_status, _bg_files_indexed, _bg_total_files
465
+ with _bg_lock:
466
+ _bg_status = "running"
467
+ _bg_files_indexed = 0
468
+ _bg_total_files = 0
469
+
470
+ try:
471
+ with _chroma_write_lock:
472
+ cmd_full_rebuild()
473
+ with _bg_lock:
474
+ _bg_status = "done"
475
+ except Exception as e:
476
+ with _bg_lock:
477
+ _bg_status = "error"
478
+ _watcher_logger.error("Background full-index failed: %s", e)
479
+ try:
480
+ from mcp_server.crash_logger import log_crash
481
+ log_crash(e, context="background full-index")
482
+ except Exception:
483
+ pass
484
+ finally:
485
+ if callback is not None:
486
+ try:
487
+ callback(_bg_status)
488
+ except Exception:
489
+ pass
490
+
491
+ t = threading.Thread(target=_run, daemon=True, name="codevira-bg-index")
492
+ t.start()
493
+ return t
494
+
495
+
496
+ def cmd_status():
497
+ from rich.console import Console
498
+ from rich.table import Table
499
+ from rich.panel import Panel
500
+
501
+ console = Console()
502
+ db = SQLiteGraph(get_data_dir() / "graph" / "graph.db")
503
+
504
+ search_available = True
505
+ try:
506
+ client = _get_chroma_client()
507
+ embed_fn = _get_embedding_fn()
508
+ collection = client.get_collection(COLLECTION_NAME, embedding_function=embed_fn)
509
+ chunk_count = collection.count()
510
+ except ImportError:
511
+ chunk_count = 0
512
+ search_available = False
513
+ except Exception as e:
514
+ chunk_count = 0
515
+ console.print(f"[yellow]Warning: could not read search index: {e}[/yellow]")
516
+
517
+ stale_files = _get_changed_files(db)
518
+
519
+ table = Table(show_header=False, box=None)
520
+ table.add_row("[cyan]ChromaDB Chunks:[/cyan]", str(chunk_count))
521
+ table.add_row("[cyan]Outdated Files:[/cyan]", str(len(stale_files)))
522
+
523
+ panel = Panel(
524
+ table,
525
+ title="[bold green]Codevira Index Status[/bold green]",
526
+ expand=False,
527
+ border_style="green"
528
+ )
529
+ console.print(panel)
530
+
531
+ if stale_files:
532
+ console.print("\n[yellow]Files requiring re-indexing:[/yellow]")
533
+ for fp, _ in stale_files[:10]:
534
+ console.print(f" - {fp}")
535
+ if len(stale_files) > 10:
536
+ console.print(f" ... and {len(stale_files) - 10} more.")
537
+
538
+ db.close()
539
+
540
+ def cmd_generate_graph():
541
+ from indexer.graph_generator import generate_graph_sqlite
542
+ db_path = str(get_data_dir() / "graph" / "graph.db")
543
+ print(f"Generating context graph nodes from {_project_root()} into SQLite")
544
+ result = generate_graph_sqlite(str(_project_root()), db_path)
545
+
546
+ print(f" Files scanned: {result['files_processed']}")
547
+ print(f" Nodes added: {result['nodes_added']}")
548
+ print(f" Nodes skipped: {result['nodes_skipped']}")
549
+
550
+ def cmd_bootstrap_roadmap():
551
+ from indexer.graph_generator import generate_roadmap_stub
552
+ roadmap_file = get_data_dir() / "roadmap.yaml"
553
+ if roadmap_file.exists():
554
+ print(f"Roadmap already exists at {roadmap_file}")
555
+ return
556
+ generate_roadmap_stub(str(_project_root()), str(roadmap_file))
557
+
558
+ if __name__ == "__main__":
559
+ parser = argparse.ArgumentParser(description="Codevira Codebase Indexer (SQLite + ChromaDB + SHA256)")
560
+ parser.add_argument("--full", action="store_true", help="Perform a full rebuild of the index.")
561
+ parser.add_argument("--status", action="store_true", help="Show index status and outdated files.")
562
+ parser.add_argument("--watch", action="store_true", help="Watch for file changes and update incrementally.")
563
+ parser.add_argument("--generate-graph", action="store_true", help="Auto-generate SQLite graph stubs.")
564
+ parser.add_argument("--bootstrap-roadmap", action="store_true", help="Create initial roadmap.yaml stub.")
565
+ parser.add_argument("-q", "--quiet", action="store_true", help="Suppress non-error output.")
566
+ args = parser.parse_args()
567
+
568
+ if args.status:
569
+ cmd_status()
570
+ elif args.full:
571
+ cmd_full_rebuild()
572
+ if args.generate_graph:
573
+ print()
574
+ cmd_generate_graph()
575
+ if args.bootstrap_roadmap:
576
+ print()
577
+ cmd_bootstrap_roadmap()
578
+ elif args.watch:
579
+ cmd_watch()
580
+ elif args.generate_graph:
581
+ cmd_generate_graph()
582
+ if args.bootstrap_roadmap:
583
+ print()
584
+ cmd_bootstrap_roadmap()
585
+ elif args.bootstrap_roadmap:
586
+ cmd_bootstrap_roadmap()
587
+ else:
588
+ cmd_incremental(quiet=args.quiet)