codecompass-mcp 2.0.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.
@@ -0,0 +1,504 @@
1
+ """Code-aware traversal CLI for the code knowledge graph.
2
+
3
+ python -m graph.code_query_cli --impact "login()"
4
+ python -m graph.code_query_cli --deps src/auth/login.py
5
+ python -m graph.code_query_cli --styles LoginForm
6
+ python -m graph.code_query_cli --trace "main()" --hops 4
7
+ python -m graph.code_query_cli --tree frontend
8
+
9
+ Output is plain text by default (agent-friendly). Pass --rich for formatted tables.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ _project_root = Path(__file__).resolve().parent.parent
20
+ if str(_project_root) not in sys.path:
21
+ sys.path.insert(0, str(_project_root))
22
+
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+ from rich.tree import Tree
26
+
27
+ from graph.code_graph_client import get_client
28
+ from ingestion.file_watcher import pid_file_path
29
+
30
+ console = Console()
31
+
32
+ DEFAULT_HOPS = 3
33
+ STALE_WARN_HOURS = 24
34
+
35
+
36
+ def main() -> None:
37
+ args = _parse_args()
38
+ rich = args.rich
39
+
40
+ _check_neo4j(args.project)
41
+ if not args.list_projects:
42
+ _check_watcher(args.project)
43
+
44
+ if args.list_projects:
45
+ run_list_projects(rich=rich)
46
+ elif args.batch_impact:
47
+ run_batch_impact(args.batch_impact, args.project, max_hops=args.hops, rich=rich)
48
+ elif args.blast_radius:
49
+ run_blast_radius(args.blast_radius, args.project, max_hops=args.hops, rich=rich)
50
+ elif args.impact:
51
+ run_impact(args.impact, args.project, max_hops=args.hops, rich=rich)
52
+ elif args.deps:
53
+ run_deps(args.deps, args.project, max_hops=args.hops, rich=rich)
54
+ elif args.styles:
55
+ run_styles(args.styles, args.project, rich=rich)
56
+ elif args.trace:
57
+ run_trace(args.trace, args.project, max_hops=args.hops, rich=rich)
58
+ elif args.tree:
59
+ run_tree(args.tree, rich=rich)
60
+ else:
61
+ print("No query mode specified. Use --help.")
62
+ sys.exit(1)
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Query modes
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def run_list_projects(rich: bool = False) -> None:
70
+ """List all projects currently ingested in the code graph."""
71
+ client = get_client("default")
72
+ try:
73
+ projects = client.get_all_projects()
74
+ finally:
75
+ client.close()
76
+
77
+ if not projects:
78
+ print("No projects ingested yet.")
79
+ print(" Run: codecompass ingest-code <repo_path> --project <name>")
80
+ return
81
+
82
+ if rich:
83
+ console.print("\n[bold blue]Ingested projects:[/]")
84
+ for p in projects:
85
+ console.print(f" [cyan]{p}[/]")
86
+ else:
87
+ print("Ingested projects:")
88
+ for p in projects:
89
+ print(f" {p}")
90
+
91
+
92
+ def run_impact(entity_name: str, project: str, max_hops: int = DEFAULT_HOPS, rich: bool = False) -> None:
93
+ """Show what would break if entity_name is changed (reverse CALLS traversal)."""
94
+ client = get_client(project)
95
+ try:
96
+ rows = client.find_callers(entity_name, project, max_hops)
97
+ updated_at = _file_updated_at_for_entity(client, entity_name, project)
98
+ finally:
99
+ client.close()
100
+
101
+ if not rows:
102
+ print(f"Nothing calls '{entity_name}' within {max_hops} hops.")
103
+ return
104
+
105
+ stamp = _staleness_line(updated_at, rich_mode=rich)
106
+ if rich:
107
+ console.print(f"\n[bold blue]Impact analysis:[/] {entity_name}")
108
+ if stamp:
109
+ console.print(stamp)
110
+ table = _make_table(title=f"Callers of '{entity_name}'", columns=["Caller", "Type", "File", "Depth"])
111
+ for row in rows:
112
+ table.add_row(row.get("caller_name",""), row.get("caller_type",""), row.get("caller_file",""), str(row.get("depth","")))
113
+ console.print(table)
114
+ else:
115
+ if stamp:
116
+ print(stamp)
117
+ print(f"Callers of '{entity_name}':")
118
+ for row in rows:
119
+ print(f" {row.get('caller_name','')} ({row.get('caller_type','')}) in {row.get('caller_file','')} [depth {row.get('depth','')}]")
120
+
121
+
122
+ def run_deps(file_path: str, project: str, max_hops: int = DEFAULT_HOPS, rich: bool = False) -> None:
123
+ """Show what a file imports, directly and transitively."""
124
+ client = get_client(project)
125
+ try:
126
+ rows = client.find_dependencies(file_path, project, max_hops)
127
+ updated_at = client.get_file_updated_at(file_path, project)
128
+ finally:
129
+ client.close()
130
+
131
+ if not rows:
132
+ print(f"No imports found for '{file_path}'.")
133
+ return
134
+
135
+ stamp = _staleness_line(updated_at, rich_mode=rich)
136
+ if rich:
137
+ console.print(f"\n[bold blue]Dependencies of:[/] {file_path}")
138
+ if stamp:
139
+ console.print(stamp)
140
+ table = _make_table(title=f"Dependencies of '{file_path}'", columns=["Module", "Type", "Depth"])
141
+ for row in rows:
142
+ table.add_row(row.get("dependency",""), row.get("dep_type",""), str(row.get("depth","")))
143
+ console.print(table)
144
+ else:
145
+ if stamp:
146
+ print(stamp)
147
+ print(f"Dependencies of '{file_path}':")
148
+ for row in rows:
149
+ print(f" {row.get('dependency','')} ({row.get('dep_type','')}) [depth {row.get('depth','')}]")
150
+
151
+
152
+ def run_styles(element_name: str, project: str, rich: bool = False) -> None:
153
+ """Show every CSS selector that styles element_name."""
154
+ client = get_client(project)
155
+ try:
156
+ rows = client.find_styles(element_name, project)
157
+ updated_at = _file_updated_at_for_entity(client, element_name, project)
158
+ finally:
159
+ client.close()
160
+
161
+ if not rows:
162
+ print(f"No CSS selectors found for '{element_name}'.")
163
+ return
164
+
165
+ stamp = _staleness_line(updated_at, rich_mode=rich)
166
+ if rich:
167
+ console.print(f"\n[bold blue]CSS rules targeting:[/] {element_name}\n")
168
+ if stamp:
169
+ console.print(stamp)
170
+ table = _make_table(title=f"Selectors styling '{element_name}'", columns=["Selector", "Source File", "Line"])
171
+ for row in rows:
172
+ table.add_row(row.get("selector",""), row.get("source_file",""), str(row.get("line","")))
173
+ console.print(table)
174
+ else:
175
+ if stamp:
176
+ print(stamp)
177
+ print(f"CSS selectors for '{element_name}':")
178
+ for row in rows:
179
+ print(f" {row.get('selector','')} in {row.get('source_file','')} line {row.get('line','')}")
180
+
181
+
182
+ def run_trace(start_name: str, project: str, max_hops: int = DEFAULT_HOPS, rich: bool = False) -> None:
183
+ """Trace the call chain forward from start_name."""
184
+ client = get_client(project)
185
+ try:
186
+ rows = client.trace_calls(start_name, project, max_hops)
187
+ updated_at = _file_updated_at_for_entity(client, start_name, project)
188
+ finally:
189
+ client.close()
190
+
191
+ if not rows:
192
+ print(f"'{start_name}' makes no tracked calls within {max_hops} hops.")
193
+ return
194
+
195
+ stamp = _staleness_line(updated_at, rich_mode=rich)
196
+ if rich:
197
+ console.print(f"\n[bold blue]Call trace from:[/] {start_name}\n")
198
+ if stamp:
199
+ console.print(stamp)
200
+ table = _make_table(title=f"Call chain from '{start_name}'", columns=["Callee", "Type", "File", "Depth"])
201
+ for row in rows:
202
+ table.add_row(row.get("callee_name",""), row.get("callee_type",""), row.get("callee_file",""), str(row.get("depth","")))
203
+ console.print(table)
204
+ else:
205
+ if stamp:
206
+ print(stamp)
207
+ print(f"Call chain from '{start_name}':")
208
+ for row in rows:
209
+ print(f" {row.get('callee_name','')} ({row.get('callee_type','')}) in {row.get('callee_file','')} [depth {row.get('depth','')}]")
210
+
211
+
212
+ def run_blast_radius(target: str, project: str, max_hops: int = DEFAULT_HOPS, rich: bool = False) -> None:
213
+ """Show every file reachable from target via CALLS/IMPORTS/INHERITS (forward traversal)."""
214
+ client = get_client(project)
215
+ try:
216
+ rows, target_file = client.get_blast_radius(target, project, max_hops)
217
+ updated_at = client.get_file_updated_at(target_file, project) if target_file else None
218
+ finally:
219
+ client.close()
220
+
221
+ if target_file is None:
222
+ print(f"ERROR: '{target}' not found in project '{project}'")
223
+ sys.exit(1)
224
+
225
+ # Deduplicate by file, keeping the minimum-hop row for each.
226
+ seen: dict[str, dict] = {}
227
+ for row in rows:
228
+ f = row["file"]
229
+ if f not in seen or row["hops"] < seen[f]["hops"]:
230
+ seen[f] = row
231
+
232
+ # Prepend the target file itself at hop 0.
233
+ if target_file not in seen:
234
+ seen[target_file] = {"file": target_file, "edge_type": "self", "hops": 0}
235
+
236
+ deduped = sorted(seen.values(), key=lambda r: (r["hops"], r["file"]))
237
+ max_hop_seen = max(r["hops"] for r in deduped)
238
+ summary = f"# blast radius: {len(deduped)} files across {max_hop_seen} hops"
239
+
240
+ stamp = _staleness_line(updated_at, rich_mode=rich)
241
+ if rich:
242
+ if stamp:
243
+ console.print(stamp)
244
+ table = _make_table(
245
+ title=f"Blast radius of '{target}'",
246
+ columns=["File", "Relationship", "Hops"],
247
+ )
248
+ for row in deduped:
249
+ table.add_row(row["file"], row.get("edge_type", ""), str(row["hops"]))
250
+ console.print(table)
251
+ console.print(f"[dim]{summary}[/]")
252
+ else:
253
+ if stamp:
254
+ print(stamp)
255
+ for row in deduped:
256
+ print(row["file"])
257
+ print(summary)
258
+
259
+
260
+ def run_batch_impact(targets: list[str], project: str, max_hops: int = DEFAULT_HOPS, rich: bool = False) -> None:
261
+ """Union of blast radii across multiple targets, annotated with which target caused each file."""
262
+ # Also split comma-separated targets (supports both --batch-impact a b c and --batch-impact "a, b, c")
263
+ flat_targets: list[str] = []
264
+ for t in targets:
265
+ flat_targets.extend(s.strip() for s in t.split(",") if s.strip())
266
+
267
+ input_set = set(flat_targets)
268
+ client = get_client(project)
269
+ try:
270
+ # merged: file -> {hops, via: set[str]}
271
+ merged: dict[str, dict] = {}
272
+ missing: list[str] = []
273
+ resolved: list[str] = []
274
+ staleness_ts: str | None = None
275
+
276
+ for target in flat_targets:
277
+ rows, target_file = client.get_blast_radius(target, project, max_hops)
278
+ if target_file is None:
279
+ print(f"WARNING: '{target}' not found in project '{project}'")
280
+ missing.append(target)
281
+ continue
282
+ resolved.append(target)
283
+ if staleness_ts is None:
284
+ staleness_ts = client.get_file_updated_at(target_file, project)
285
+
286
+ # Include the target file itself at hop 0
287
+ all_rows = list(rows)
288
+ if not any(r["file"] == target_file for r in all_rows):
289
+ all_rows.append({"file": target_file, "edge_type": "self", "hops": 0})
290
+
291
+ for row in all_rows:
292
+ f = row["file"]
293
+ h = row["hops"]
294
+ if f not in merged:
295
+ merged[f] = {"hops": h, "via": {target}}
296
+ else:
297
+ if h < merged[f]["hops"]:
298
+ merged[f]["hops"] = h
299
+ merged[f]["via"].add(target)
300
+ finally:
301
+ client.close()
302
+
303
+ if not resolved:
304
+ sys.exit(1)
305
+
306
+ deduped = sorted(merged.items(), key=lambda kv: (kv[1]["hops"], kv[0]))
307
+ max_hop_seen = max(v["hops"] for _, v in deduped) if deduped else 0
308
+ summary = f"# batch impact: {len(deduped)} files, {len(flat_targets)} input targets, {max_hop_seen} hops"
309
+
310
+ stamp = _staleness_line(staleness_ts, rich_mode=rich)
311
+ if rich:
312
+ if stamp:
313
+ console.print(stamp)
314
+ table = _make_table(
315
+ title=f"Batch impact ({len(targets)} targets)",
316
+ columns=["File", "Via", "Hops"],
317
+ )
318
+ for f, meta in deduped:
319
+ via_str = ", ".join(sorted(meta["via"]))
320
+ flags = " [also in input]" if f in input_set else ""
321
+ table.add_row(f"{f}{flags}", via_str, str(meta["hops"]))
322
+ console.print(table)
323
+ console.print(f"[dim]{summary}[/]")
324
+ else:
325
+ if stamp:
326
+ print(stamp)
327
+ for f, meta in deduped:
328
+ via_str = ", ".join(sorted(meta["via"]))
329
+ flags = " [also in input]" if f in input_set else ""
330
+ print(f"{f} [via: {via_str}]{flags}")
331
+ print(summary)
332
+
333
+
334
+ def run_tree(project: str, rich: bool = False) -> None:
335
+ """Print the full project hierarchy as a tree."""
336
+ client = get_client(project)
337
+ try:
338
+ rows = client.get_project_tree(project)
339
+ last_ingested = client.get_project_last_ingested(project)
340
+ finally:
341
+ client.close()
342
+
343
+ if not rows:
344
+ print(f"No hierarchy found for project '{project}'. Has it been ingested?")
345
+ return
346
+
347
+ stamp = _staleness_line(last_ingested, rich_mode=rich)
348
+ if rich:
349
+ console.print(f"\n[bold blue]Hierarchy for project:[/] {project}\n")
350
+ if stamp:
351
+ console.print(stamp)
352
+ rich_tree = Tree(f"[bold cyan]{project}[/]")
353
+ path_to_node: dict[str, Tree] = {}
354
+ for row in rows:
355
+ path = row.get("path") or row.get("name", "")
356
+ name = row.get("name", path)
357
+ node_type = row.get("node_type", "")
358
+ label = _node_label(node_type, name)
359
+ parts = [p for p in path.replace("\\", "/").split("/") if p]
360
+ parent_path = "/".join(parts[:-1])
361
+ parent_node = path_to_node.get(parent_path, rich_tree)
362
+ child_node = parent_node.add(label)
363
+ path_to_node[path] = child_node
364
+ console.print(rich_tree)
365
+ else:
366
+ if stamp:
367
+ print(stamp)
368
+ print(f"Project tree: {project}")
369
+ for row in rows:
370
+ path = row.get("path") or row.get("name", "")
371
+ depth = len([p for p in path.replace("\\", "/").split("/") if p]) - 1
372
+ print(f"{' ' * depth}{row.get('node_type','')}: {row.get('name','')}")
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Pre-flight checks
377
+ # ---------------------------------------------------------------------------
378
+
379
+ def _check_neo4j(project: str) -> None:
380
+ """Fail fast with a helpful message if Neo4j is unreachable or auth fails."""
381
+ client = get_client(project)
382
+ try:
383
+ client._run_read("RETURN 1")
384
+ client.close()
385
+ except Exception as e:
386
+ client.close()
387
+ msg = str(e)
388
+ ename = type(e).__name__
389
+ if any(k in ename or k in msg for k in ("ServiceUnavailable", "ConnectionRefused", "refused", "timed out")):
390
+ print("ERROR: Cannot connect to Neo4j at bolt://localhost:7687")
391
+ print(" Start it: docker compose up -d (from codecompass/)")
392
+ print(" Then wait ~5s and retry.")
393
+ elif any(k in ename or k in msg for k in ("AuthError", "Unauthorized", "authentication")):
394
+ print("ERROR: Neo4j authentication failed.")
395
+ print(" Check NEO4J_USER and NEO4J_PASSWORD in your .env file.")
396
+ else:
397
+ print(f"ERROR: Neo4j connection failed — {ename}: {msg}")
398
+ sys.exit(1)
399
+
400
+
401
+ def _check_watcher(project: str) -> None:
402
+ """Warn (but don't exit) if no watcher process is running for this project."""
403
+ import os
404
+ pid_file = pid_file_path(project)
405
+ if not os.path.exists(pid_file):
406
+ print(f"WARNING: Watcher is not running for project '{project}'.")
407
+ print(f" Files edited outside this session won't be re-indexed automatically.")
408
+ print(f" Start it: codecompass watch <repo_path> --project {project}")
409
+ return
410
+ try:
411
+ with open(pid_file) as f:
412
+ pid = int(f.read().strip())
413
+ os.kill(pid, 0) # signal 0 checks if the process is alive without killing it
414
+ except (ProcessLookupError, ValueError, OSError):
415
+ try:
416
+ os.unlink(pid_file)
417
+ except OSError:
418
+ pass
419
+ print(f"WARNING: Watcher for project '{project}' is no longer running (stale PID file removed).")
420
+ print(f" Start it: codecompass watch <repo_path> --project {project}")
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # Helpers
425
+ # ---------------------------------------------------------------------------
426
+
427
+ def _staleness_line(timestamp: str | None, rich_mode: bool = False) -> str | None:
428
+ """Return a staleness header line, with a warning if the index is older than STALE_WARN_HOURS."""
429
+ if not timestamp:
430
+ return None
431
+ try:
432
+ ts = datetime.fromisoformat(timestamp)
433
+ age_h = (datetime.now(timezone.utc) - ts).total_seconds() / 3600
434
+ if age_h > STALE_WARN_HOURS:
435
+ warn = f" WARNING: {age_h:.0f}h old — re-run: codecompass ingest-code . --project <name>"
436
+ if rich_mode:
437
+ return f"[yellow]# index updated: {timestamp}{warn}[/]"
438
+ return f"# index updated: {timestamp}{warn}"
439
+ if rich_mode:
440
+ return f"[dim]# index updated: {timestamp}[/]"
441
+ return f"# index updated: {timestamp}"
442
+ except (ValueError, TypeError):
443
+ return f"# index updated: {timestamp}"
444
+
445
+
446
+ def _file_updated_at_for_entity(client, entity_name: str, project: str) -> str | None:
447
+ """Look up the updated_at timestamp of the file containing entity_name."""
448
+ rows = client._run_read("""
449
+ MATCH (e:Entity {project: $project})
450
+ WHERE e.name = $name
451
+ MATCH (f:File {path: e.file, project: $project})
452
+ RETURN f.updated_at AS updated_at
453
+ LIMIT 1
454
+ """, project=project, name=entity_name)
455
+ return rows[0]["updated_at"] if rows else None
456
+
457
+
458
+ def _make_table(title: str, columns: list[str]) -> Table:
459
+ table = Table(title=title, show_lines=True, border_style="dim")
460
+ for col in columns:
461
+ table.add_column(col, style="cyan" if col in ("Caller", "Module", "Selector", "Callee") else "")
462
+ return table
463
+
464
+
465
+ def _node_label(node_type: str, name: str) -> str:
466
+ icons = {"Project": "📦", "Folder": "📁", "File": "📄", "Entity": "⚙"}
467
+ icon = icons.get(node_type, "•")
468
+ colour = {"File": "green", "Folder": "blue", "Entity": "yellow"}.get(node_type, "white")
469
+ return f"[{colour}]{icon} {name}[/]"
470
+
471
+
472
+ def _parse_args() -> argparse.Namespace:
473
+ parser = argparse.ArgumentParser(
474
+ description="Code-aware graph traversal CLI",
475
+ formatter_class=argparse.RawDescriptionHelpFormatter,
476
+ epilog=__doc__,
477
+ )
478
+ parser.add_argument("--project", default="default",
479
+ help="Project name to query (default: 'default')")
480
+ parser.add_argument("--hops", type=int, default=DEFAULT_HOPS,
481
+ help=f"Max traversal depth (default: {DEFAULT_HOPS})")
482
+ parser.add_argument("--batch-impact", metavar="TARGET", nargs="+",
483
+ help="Union of blast radii across multiple targets (file paths or symbol names)")
484
+ parser.add_argument("--blast-radius", metavar="TARGET",
485
+ help="All files reachable from TARGET via CALLS/IMPORTS/INHERITS (symbol or file path)")
486
+ parser.add_argument("--impact", metavar="ENTITY",
487
+ help="What calls ENTITY? (reverse CALLS traversal)")
488
+ parser.add_argument("--deps", metavar="FILE",
489
+ help="What does FILE import? (forward IMPORTS traversal)")
490
+ parser.add_argument("--styles", metavar="ELEMENT",
491
+ help="What CSS selectors style ELEMENT?")
492
+ parser.add_argument("--trace", metavar="ENTITY",
493
+ help="Trace forward call chain from ENTITY")
494
+ parser.add_argument("--tree", metavar="PROJECT",
495
+ help="Print full folder/file hierarchy for PROJECT")
496
+ parser.add_argument("--rich", action="store_true",
497
+ help="Rich formatted output with tables and colour (default: plain text)")
498
+ parser.add_argument("--list-projects", action="store_true",
499
+ help="List all ingested projects")
500
+ return parser.parse_args()
501
+
502
+
503
+ if __name__ == "__main__":
504
+ main()