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.
- codecompass_mcp-2.0.0.dist-info/METADATA +368 -0
- codecompass_mcp-2.0.0.dist-info/RECORD +28 -0
- codecompass_mcp-2.0.0.dist-info/WHEEL +5 -0
- codecompass_mcp-2.0.0.dist-info/entry_points.txt +3 -0
- codecompass_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- codecompass_mcp-2.0.0.dist-info/top_level.txt +6 -0
- config.py +16 -0
- graph/__init__.py +0 -0
- graph/cli.py +13 -0
- graph/code_graph_client.py +485 -0
- graph/code_query_cli.py +504 -0
- graph/mcp_server.py +280 -0
- graph/setup.py +255 -0
- ingestion/__init__.py +0 -0
- ingestion/chunker.py +70 -0
- ingestion/code_normalizer.py +158 -0
- ingestion/code_parser.py +709 -0
- ingestion/entity_resolver.py +179 -0
- ingestion/file_watcher.py +165 -0
- ingestion/graph_writer.py +17 -0
- ingestion/hierarchy_builder.py +148 -0
- ingestion/reader_agent.py +135 -0
- main.py +306 -0
- models/__init__.py +0 -0
- models/code_types.py +35 -0
- models/types.py +45 -0
- utils/__init__.py +0 -0
- utils/formatting.py +24 -0
graph/code_query_cli.py
ADDED
|
@@ -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()
|