codecompass-mcp 2.7.0__tar.gz → 2.8.0__tar.gz
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.7.0 → codecompass_mcp-2.8.0}/PKG-INFO +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/code_graph_client.py +205 -49
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/code_query_cli.py +29 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/code_parser.py +65 -4
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/main.py +44 -49
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/mcp_server.py +18 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/pyproject.toml +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_accuracy_fixes.py +33 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/LICENSE +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/README.md +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/SOURCES.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/requires.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/config.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/cli.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/setup.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/chunker.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/code_normalizer.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/description_enricher.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/entity_resolver.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/file_watcher.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/graph_writer.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/hierarchy_builder.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/reader_agent.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/source_context.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/models/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/models/code_types.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/models/types.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/setup.cfg +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_batch_impact.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_blast_radius.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_code_parser.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_description_enricher.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_ingestion.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_lit_css_parser.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_mcp_server.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_php_parser.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/utils/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/utils/formatting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import networkx as nx
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from typing import Optional, Any
|
|
@@ -36,6 +37,15 @@ def _now() -> str:
|
|
|
36
37
|
return datetime.now(timezone.utc).isoformat()
|
|
37
38
|
|
|
38
39
|
|
|
40
|
+
def _entity_id(project: str, name: str, file: str | None) -> str:
|
|
41
|
+
"""Node id for an entity — file-qualified so same-named entities in different
|
|
42
|
+
files stay distinct. Case is preserved so `Session` (class) and `session`
|
|
43
|
+
(function) don't collide. External/module targets with no file are name-only."""
|
|
44
|
+
if file:
|
|
45
|
+
return f"{project}:{file}:{name}"
|
|
46
|
+
return f"{project}:{name}"
|
|
47
|
+
|
|
48
|
+
|
|
39
49
|
_ENTRY_POINT_PREFIXES = ("run_", "main", "handle_", "cmd_", "test_", "setup_", "teardown_")
|
|
40
50
|
_ENTRY_POINT_NAMES = {"main", "__main__", "handler", "lambda_handler", "application", "app"}
|
|
41
51
|
|
|
@@ -186,9 +196,14 @@ class LocalGraphClient:
|
|
|
186
196
|
# Entity nodes and semantic edges (from code triples)
|
|
187
197
|
# ------------------------------------------------------------------
|
|
188
198
|
|
|
189
|
-
def write_code_triple(self, triple: CodeTriple, file_node_id: str, project: str
|
|
190
|
-
|
|
191
|
-
to_id
|
|
199
|
+
def write_code_triple(self, triple: CodeTriple, file_node_id: str, project: str,
|
|
200
|
+
from_id: str | None = None, to_id: str | None = None) -> None:
|
|
201
|
+
# from_id/to_id are the resolved (file-qualified) node ids from the batch
|
|
202
|
+
# writer; fall back to name-only for any direct/legacy caller.
|
|
203
|
+
if from_id is None:
|
|
204
|
+
from_id = f"{project}:{triple.from_entity}"
|
|
205
|
+
if to_id is None:
|
|
206
|
+
to_id = f"{project}:{triple.to_entity}"
|
|
192
207
|
rel_type = triple.relation_type if triple.relation_type in _ALLOWED_REL_TYPES else "RELATION"
|
|
193
208
|
|
|
194
209
|
ext = os.path.splitext(triple.source_file)[1].lower()
|
|
@@ -236,16 +251,52 @@ class LocalGraphClient:
|
|
|
236
251
|
|
|
237
252
|
self.graph.add_edge(file_node_id, from_id, type="CONTAINS")
|
|
238
253
|
|
|
254
|
+
def _resolve_to_id(self, triple: CodeTriple, project: str,
|
|
255
|
+
defs_by_name: dict, class_file: dict) -> str:
|
|
256
|
+
"""Resolve a triple's callee/target to a specific definition node when we
|
|
257
|
+
can, else a name-only bucket. Uses the captured receiver type to pick the
|
|
258
|
+
right same-named method (self.send() in a Session method -> the send
|
|
259
|
+
defined alongside class Session)."""
|
|
260
|
+
name = triple.to_entity
|
|
261
|
+
if triple.relation_type == "DEFINED_IN":
|
|
262
|
+
return f"{project}:{name}" # module container — name-only
|
|
263
|
+
cands = defs_by_name.get(name)
|
|
264
|
+
if cands:
|
|
265
|
+
rt = getattr(triple, "call_receiver_type", None)
|
|
266
|
+
if rt:
|
|
267
|
+
cf = class_file.get(rt)
|
|
268
|
+
if cf:
|
|
269
|
+
for nid, f in cands:
|
|
270
|
+
if f == cf:
|
|
271
|
+
return nid
|
|
272
|
+
if len(cands) == 1:
|
|
273
|
+
return cands[0][0]
|
|
274
|
+
# ambiguous or external → name-only bucket (edge not lost)
|
|
275
|
+
return f"{project}:{name}"
|
|
276
|
+
|
|
239
277
|
def write_code_triples_batch(
|
|
240
|
-
self,
|
|
241
|
-
triples: list[CodeTriple],
|
|
242
|
-
file_id_map: dict[str, str],
|
|
278
|
+
self,
|
|
279
|
+
triples: list[CodeTriple],
|
|
280
|
+
file_id_map: dict[str, str],
|
|
243
281
|
project: str
|
|
244
282
|
) -> int:
|
|
283
|
+
# Pass 1: index every definition so calls can resolve to a specific one.
|
|
284
|
+
defs_by_name: dict[str, list] = {}
|
|
285
|
+
class_file: dict[str, str] = {}
|
|
286
|
+
for t in triples:
|
|
287
|
+
if t.relation_type == "DEFINED_IN":
|
|
288
|
+
nid = _entity_id(project, t.from_entity, t.source_file)
|
|
289
|
+
defs_by_name.setdefault(t.from_entity, []).append((nid, t.source_file))
|
|
290
|
+
if t.from_type == "class":
|
|
291
|
+
class_file.setdefault(t.from_entity, t.source_file)
|
|
292
|
+
|
|
293
|
+
# Pass 2: write nodes/edges with resolved, file-qualified ids.
|
|
245
294
|
for triple in triples:
|
|
246
295
|
file_id = file_id_map.get(triple.source_file, "")
|
|
247
|
-
|
|
248
|
-
|
|
296
|
+
from_id = _entity_id(project, triple.from_entity, triple.source_file)
|
|
297
|
+
to_id = self._resolve_to_id(triple, project, defs_by_name, class_file)
|
|
298
|
+
self.write_code_triple(triple, file_id, project, from_id=from_id, to_id=to_id)
|
|
299
|
+
|
|
249
300
|
self.save()
|
|
250
301
|
return len(triples)
|
|
251
302
|
|
|
@@ -290,30 +341,59 @@ class LocalGraphClient:
|
|
|
290
341
|
# Traversal queries used by code_query_cli
|
|
291
342
|
# ------------------------------------------------------------------
|
|
292
343
|
|
|
344
|
+
def _resolve_query_nodes(self, entity_name: str, project: str) -> list[str]:
|
|
345
|
+
"""Resolve a bare or receiver-qualified name to the node id(s) it refers
|
|
346
|
+
to. `Session.send` → the `send` node in the file where class `Session` is
|
|
347
|
+
defined; `send` → every `send` node. Includes the name-only bucket id
|
|
348
|
+
when present (unresolved/ambiguous calls land there)."""
|
|
349
|
+
qualifier = None
|
|
350
|
+
name = entity_name
|
|
351
|
+
if "." in entity_name:
|
|
352
|
+
qualifier, name = entity_name.rsplit(".", 1)
|
|
353
|
+
name_l = name.lower()
|
|
354
|
+
|
|
355
|
+
matches = [
|
|
356
|
+
n for n, a in self.graph.nodes(data=True)
|
|
357
|
+
if a.get("type") == "Entity" and (a.get("name") or "").lower() == name_l
|
|
358
|
+
]
|
|
359
|
+
# name-only bucket (unresolved calls land here) is matched by its name attr,
|
|
360
|
+
# but sort it last so flow/trace pick a real definition as the start.
|
|
361
|
+
matches.sort(key=lambda n: self.graph.nodes[n].get("file") is None)
|
|
362
|
+
|
|
363
|
+
if qualifier and matches:
|
|
364
|
+
cls_files = {
|
|
365
|
+
a.get("file") for n, a in self.graph.nodes(data=True)
|
|
366
|
+
if a.get("type") == "Entity" and a.get("entity_type") == "class"
|
|
367
|
+
and (a.get("name") or "").lower() == qualifier.lower() and a.get("file")
|
|
368
|
+
}
|
|
369
|
+
pref = [n for n in matches if self.graph.nodes[n].get("file") in cls_files]
|
|
370
|
+
if pref:
|
|
371
|
+
return pref
|
|
372
|
+
return matches
|
|
373
|
+
|
|
293
374
|
def find_callers(self, entity_name: str, project: str, max_hops: int = 3) -> list[dict]:
|
|
294
375
|
"""Return everything that calls/uses/references entity_name (reverse traversal).
|
|
295
376
|
|
|
296
377
|
`entity_name` may be a bare name ("handle") or receiver-qualified
|
|
297
|
-
("app.handle" / "Router.handle").
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
the collision instead of hiding it.
|
|
378
|
+
("app.handle" / "Router.handle"). Node ids are file-qualified, so distinct
|
|
379
|
+
same-named methods are distinct nodes; a qualified query resolves to the
|
|
380
|
+
one defined alongside the named class. The receiver filter still applies
|
|
381
|
+
for calls that landed in a shared name-only bucket (genuinely ambiguous).
|
|
382
|
+
Every returned row carries the call's `receiver`/`receiver_type` and the
|
|
383
|
+
real call-site file+line.
|
|
304
384
|
"""
|
|
305
385
|
want_receiver = None
|
|
306
|
-
lookup_name = entity_name
|
|
307
386
|
if "." in entity_name:
|
|
308
|
-
want_receiver,
|
|
387
|
+
want_receiver, _ = entity_name.rsplit(".", 1)
|
|
309
388
|
|
|
310
|
-
|
|
311
|
-
if
|
|
389
|
+
targets = self._resolve_query_nodes(entity_name, project)
|
|
390
|
+
if not targets:
|
|
312
391
|
return []
|
|
392
|
+
target_set = set(targets)
|
|
313
393
|
|
|
314
394
|
results = []
|
|
315
|
-
visited =
|
|
316
|
-
queue = [(
|
|
395
|
+
visited = set(targets)
|
|
396
|
+
queue = [(t, 0) for t in targets]
|
|
317
397
|
|
|
318
398
|
while queue:
|
|
319
399
|
current_id, depth = queue.pop(0)
|
|
@@ -331,7 +411,7 @@ class LocalGraphClient:
|
|
|
331
411
|
|
|
332
412
|
# Only apply receiver filtering to the direct (depth-0) edge into
|
|
333
413
|
# the queried symbol; deeper hops are transitive callers.
|
|
334
|
-
if want_receiver is not None and current_id
|
|
414
|
+
if want_receiver is not None and current_id in target_set:
|
|
335
415
|
if not any(
|
|
336
416
|
_receiver_matches(want_receiver, edge.get("receiver"),
|
|
337
417
|
edge.get("receiver_type"))
|
|
@@ -349,18 +429,96 @@ class LocalGraphClient:
|
|
|
349
429
|
(e.get("receiver_type") for e in relevant_edges if e.get("receiver_type")),
|
|
350
430
|
None,
|
|
351
431
|
)
|
|
432
|
+
# The caller NODE is keyed by name only, so when two functions
|
|
433
|
+
# share a name its "file" is last-writer-wins (a merge). The
|
|
434
|
+
# CALLS edge, however, records where THIS call actually is —
|
|
435
|
+
# use it so each caller row points at the real call site with a
|
|
436
|
+
# line number, not the merged node's file.
|
|
437
|
+
call_edge = next(
|
|
438
|
+
(e for e in relevant_edges if e.get("source_file")), relevant_edges[0]
|
|
439
|
+
)
|
|
352
440
|
results.append({
|
|
353
441
|
"caller_name": node.get("name"),
|
|
354
442
|
"caller_type": node.get("entity_type"),
|
|
355
|
-
"caller_file": node.get("file"),
|
|
443
|
+
"caller_file": call_edge.get("source_file") or node.get("file"),
|
|
444
|
+
"line": call_edge.get("line"),
|
|
356
445
|
"receiver": receiver,
|
|
357
446
|
"receiver_type": receiver_type,
|
|
447
|
+
"resolved": True,
|
|
358
448
|
"depth": depth + 1
|
|
359
449
|
})
|
|
360
450
|
visited.add(pred)
|
|
361
451
|
queue.append((pred, depth + 1))
|
|
362
452
|
|
|
363
|
-
|
|
453
|
+
# Qualified query (Type.method): also surface the name-only bucket's
|
|
454
|
+
# direct callers — calls whose receiver couldn't be statically typed
|
|
455
|
+
# (e.g. `adapter = self.get_adapter(url); adapter.send()`). They MAY hit
|
|
456
|
+
# this method, so we don't drop them, but flag resolved=False rather than
|
|
457
|
+
# claim false precision. An unqualified query already includes them.
|
|
458
|
+
if want_receiver is not None:
|
|
459
|
+
method = entity_name.rsplit(".", 1)[1]
|
|
460
|
+
bucket = f"{project}:{method}"
|
|
461
|
+
if bucket in self.graph and bucket not in target_set:
|
|
462
|
+
for pred in self.graph.predecessors(bucket):
|
|
463
|
+
if pred in visited:
|
|
464
|
+
continue
|
|
465
|
+
rel = [e for e in self.graph.get_edge_data(pred, bucket).values()
|
|
466
|
+
if e.get("type") in {"CALLS", "USES_VAR", "REFERENCES"}]
|
|
467
|
+
node = self.graph.nodes[pred]
|
|
468
|
+
if not rel or node.get("type") != "Entity":
|
|
469
|
+
continue
|
|
470
|
+
ce = next((e for e in rel if e.get("source_file")), rel[0])
|
|
471
|
+
results.append({
|
|
472
|
+
"caller_name": node.get("name"),
|
|
473
|
+
"caller_type": node.get("entity_type"),
|
|
474
|
+
"caller_file": ce.get("source_file") or node.get("file"),
|
|
475
|
+
"line": ce.get("line"),
|
|
476
|
+
"receiver": next((e.get("receiver") for e in rel if e.get("receiver")), None),
|
|
477
|
+
"receiver_type": None,
|
|
478
|
+
"resolved": False,
|
|
479
|
+
"depth": 1,
|
|
480
|
+
})
|
|
481
|
+
visited.add(pred)
|
|
482
|
+
|
|
483
|
+
return sorted(results, key=lambda x: (not x.get("resolved", True), x["depth"]))
|
|
484
|
+
|
|
485
|
+
def grep_graph(self, pattern: str, project: str, field: str = "all",
|
|
486
|
+
ignore_case: bool = True, limit: int = 100) -> dict:
|
|
487
|
+
"""Regex-search the graph — 'grep' for entities instead of file lines.
|
|
488
|
+
|
|
489
|
+
Matches `pattern` against each entity's name / file / kind / description
|
|
490
|
+
(or a single `field`), returning the matching entities with the field
|
|
491
|
+
that hit and the matched text. This is the graph-native replacement for
|
|
492
|
+
grepping source: full regex power, but over the indexed symbols rather
|
|
493
|
+
than raw text.
|
|
494
|
+
"""
|
|
495
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
496
|
+
try:
|
|
497
|
+
rx = re.compile(pattern, flags)
|
|
498
|
+
except re.error as exc:
|
|
499
|
+
return {"pattern": pattern, "error": f"invalid regex: {exc}", "matches": [], "count": 0}
|
|
500
|
+
|
|
501
|
+
fields = ["name", "file", "kind", "description"] if field == "all" else [field]
|
|
502
|
+
matches = []
|
|
503
|
+
for _n, a in self.graph.nodes(data=True):
|
|
504
|
+
if a.get("type") != "Entity" or a.get("project") != project:
|
|
505
|
+
continue
|
|
506
|
+
for f in fields:
|
|
507
|
+
val = str(a.get(f) or "")
|
|
508
|
+
m = rx.search(val) if val else None
|
|
509
|
+
if m:
|
|
510
|
+
matches.append({
|
|
511
|
+
"name": a.get("name"),
|
|
512
|
+
"kind": a.get("kind"),
|
|
513
|
+
"file": a.get("file"),
|
|
514
|
+
"matched_field": f,
|
|
515
|
+
"match": m.group(0),
|
|
516
|
+
})
|
|
517
|
+
break
|
|
518
|
+
if len(matches) >= limit:
|
|
519
|
+
break
|
|
520
|
+
matches.sort(key=lambda r: (r["file"] or "", r["name"] or ""))
|
|
521
|
+
return {"pattern": pattern, "matches": matches, "count": len(matches)}
|
|
364
522
|
|
|
365
523
|
def search_entities(self, query: str, project: str, limit: int = 30,
|
|
366
524
|
kind: str | None = None) -> list[dict]:
|
|
@@ -482,22 +640,19 @@ class LocalGraphClient:
|
|
|
482
640
|
|
|
483
641
|
def find_styles(self, element_name: str, project: str) -> list[dict]:
|
|
484
642
|
"""Return all CSS selectors that style element_name."""
|
|
485
|
-
target_id = f"{project}:{element_name.lower()}"
|
|
486
|
-
if target_id not in self.graph:
|
|
487
|
-
return []
|
|
488
|
-
|
|
489
643
|
results = []
|
|
490
|
-
for
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
644
|
+
for target_id in self._resolve_query_nodes(element_name, project):
|
|
645
|
+
for pred in self.graph.predecessors(target_id):
|
|
646
|
+
edges = self.graph.get_edge_data(pred, target_id)
|
|
647
|
+
for key, edge in edges.items():
|
|
648
|
+
if edge.get("type") == "STYLES":
|
|
649
|
+
node = self.graph.nodes[pred]
|
|
650
|
+
results.append({
|
|
651
|
+
"selector": node.get("name"),
|
|
652
|
+
"source_file": node.get("file"),
|
|
653
|
+
"line": edge.get("line")
|
|
654
|
+
})
|
|
655
|
+
|
|
501
656
|
return sorted(results, key=lambda x: x["selector"])
|
|
502
657
|
|
|
503
658
|
def trace_flow(self, start_name: str, project: str, max_hops: int = 6,
|
|
@@ -506,9 +661,10 @@ class LocalGraphClient:
|
|
|
506
661
|
"""BFS from start_name along CALLS/IMPORTS edges. Returns nodes + edges for rendering."""
|
|
507
662
|
if edge_types is None:
|
|
508
663
|
edge_types = frozenset({"CALLS", "IMPORTS"})
|
|
509
|
-
|
|
510
|
-
if
|
|
664
|
+
starts = self._resolve_query_nodes(start_name, project)
|
|
665
|
+
if not starts:
|
|
511
666
|
return {"nodes": [], "edges": []}
|
|
667
|
+
start_id = starts[0]
|
|
512
668
|
|
|
513
669
|
flow_nodes: dict[str, dict] = {}
|
|
514
670
|
flow_edges: list[dict] = []
|
|
@@ -563,9 +719,10 @@ class LocalGraphClient:
|
|
|
563
719
|
|
|
564
720
|
def trace_calls(self, start_name: str, project: str, max_hops: int = 4) -> list[dict]:
|
|
565
721
|
"""Trace the call chain forward from start_name up to max_hops deep."""
|
|
566
|
-
|
|
567
|
-
if
|
|
722
|
+
starts = self._resolve_query_nodes(start_name, project)
|
|
723
|
+
if not starts:
|
|
568
724
|
return []
|
|
725
|
+
start_id = starts[0]
|
|
569
726
|
|
|
570
727
|
results = []
|
|
571
728
|
visited = {start_id}
|
|
@@ -686,14 +843,13 @@ class LocalGraphClient:
|
|
|
686
843
|
self, target: str, project: str, max_hops: int = 3
|
|
687
844
|
) -> tuple[list[dict], str | None]:
|
|
688
845
|
"""Return all files reachable from target via CALLS/IMPORTS/INHERITS."""
|
|
689
|
-
# Try as entity name first
|
|
690
|
-
|
|
846
|
+
# Try as entity name first (may resolve to several same-named nodes).
|
|
847
|
+
ent_nodes = self._resolve_query_nodes(target, project) if "/" not in target else []
|
|
691
848
|
target_file = None
|
|
692
849
|
|
|
693
|
-
if
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
start_ids = [entity_id]
|
|
850
|
+
if ent_nodes:
|
|
851
|
+
target_file = self.graph.nodes[ent_nodes[0]].get("file")
|
|
852
|
+
start_ids = ent_nodes
|
|
697
853
|
else:
|
|
698
854
|
# Try as file path
|
|
699
855
|
file_node = next((n for n, attr in self.graph.nodes(data=True)
|
|
@@ -51,6 +51,17 @@ def fetch_impact(entity_name: str, repo_path: str, project: str, max_hops: int =
|
|
|
51
51
|
return {"entity": entity_name, "callers": rows, "updated_at": updated_at}
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def fetch_grep(pattern: str, repo_path: str, project: str, field: str = "all",
|
|
55
|
+
ignore_case: bool = True, limit: int = 100) -> dict:
|
|
56
|
+
"""Regex-search the graph — 'grep' over indexed entities, not file lines."""
|
|
57
|
+
client = get_client(repo_path)
|
|
58
|
+
try:
|
|
59
|
+
return client.grep_graph(pattern, project, field=field,
|
|
60
|
+
ignore_case=ignore_case, limit=limit)
|
|
61
|
+
finally:
|
|
62
|
+
client.close()
|
|
63
|
+
|
|
64
|
+
|
|
54
65
|
def fetch_search(query: str, repo_path: str, project: str, limit: int = 30,
|
|
55
66
|
kind: str | None = None) -> dict:
|
|
56
67
|
"""Search the graph for entities matching a keyword — the discovery entry point."""
|
|
@@ -322,6 +333,16 @@ def main() -> None:
|
|
|
322
333
|
run_flow(args.flow, repo_path, project, max_hops=args.hops, rich=rich,
|
|
323
334
|
output=args.flow_output, include_external=args.include_external,
|
|
324
335
|
fmt=args.format)
|
|
336
|
+
elif args.flow_summary:
|
|
337
|
+
run_flow(args.flow_summary, repo_path, project, max_hops=args.hops, rich=rich,
|
|
338
|
+
output=args.flow_output, include_external=args.include_external,
|
|
339
|
+
fmt=args.format if args.format != "drawio" else "mermaid")
|
|
340
|
+
elif args.map:
|
|
341
|
+
print(json.dumps(fetch_map(repo_path, project), indent=2))
|
|
342
|
+
elif args.search:
|
|
343
|
+
print(json.dumps(fetch_search(args.search, repo_path, project), indent=2))
|
|
344
|
+
elif args.grep:
|
|
345
|
+
print(json.dumps(fetch_grep(args.grep, repo_path, project), indent=2))
|
|
325
346
|
elif args.dead_code:
|
|
326
347
|
run_dead_code(repo_path, project, rich=rich, show_entrypoints=args.include_entrypoints)
|
|
327
348
|
elif args.tree:
|
|
@@ -999,6 +1020,14 @@ def _parse_args() -> argparse.Namespace:
|
|
|
999
1020
|
parser.add_argument("--styles", metavar="ELEMENT")
|
|
1000
1021
|
parser.add_argument("--trace", metavar="ENTITY")
|
|
1001
1022
|
parser.add_argument("--flow", metavar="ENTITY")
|
|
1023
|
+
parser.add_argument("--flow-summary", metavar="ENTITY",
|
|
1024
|
+
help="Flow trace + rendered narration (mermaid by default) for a human")
|
|
1025
|
+
parser.add_argument("--map", action="store_true",
|
|
1026
|
+
help="Compact {file: [symbols]} index to reason over during discovery")
|
|
1027
|
+
parser.add_argument("--search", metavar="KEYWORDS",
|
|
1028
|
+
help="Keyword search over entity names/files/descriptions")
|
|
1029
|
+
parser.add_argument("--grep", metavar="REGEX",
|
|
1030
|
+
help="Regex search over graph entities (grep for the graph)")
|
|
1002
1031
|
parser.add_argument("--flow-output", metavar="PATH", default=None)
|
|
1003
1032
|
parser.add_argument("--include-external", action="store_true",
|
|
1004
1033
|
help="Include external/stdlib symbols in --flow output")
|
|
@@ -377,13 +377,38 @@ def _py_type_name(ann: Node | None) -> str | None:
|
|
|
377
377
|
return None
|
|
378
378
|
|
|
379
379
|
|
|
380
|
+
def _py_return_types(root: Node) -> dict[str, str]:
|
|
381
|
+
"""Map function name -> its declared return type (`def f(...) -> Foo:`).
|
|
382
|
+
|
|
383
|
+
Enables return-type inference: `x = self.get_adapter(url)` gets typed by
|
|
384
|
+
`get_adapter`'s `-> BaseAdapter` annotation. Same-named functions with
|
|
385
|
+
conflicting return types are dropped (can't disambiguate statically)."""
|
|
386
|
+
rets: dict[str, str] = {}
|
|
387
|
+
conflict: set[str] = set()
|
|
388
|
+
for node in _walk(root):
|
|
389
|
+
if node.type == "function_definition":
|
|
390
|
+
name = _get_node_name(node, ["identifier"])
|
|
391
|
+
rt = node.child_by_field_name("return_type")
|
|
392
|
+
t = _py_type_name(rt) if rt is not None else None
|
|
393
|
+
if name and t:
|
|
394
|
+
if name in rets and rets[name] != t:
|
|
395
|
+
conflict.add(name)
|
|
396
|
+
else:
|
|
397
|
+
rets[name] = t
|
|
398
|
+
for name in conflict:
|
|
399
|
+
rets.pop(name, None)
|
|
400
|
+
return rets
|
|
401
|
+
|
|
402
|
+
|
|
380
403
|
def _py_type_env(root: Node) -> dict[str, str]:
|
|
381
404
|
"""Best-effort variable → class map for a Python module.
|
|
382
405
|
|
|
383
|
-
Binds from unambiguous local signals: `x = Foo()` instantiations
|
|
384
|
-
annotations (`x: Foo`, `def f(x: Foo)`)
|
|
385
|
-
is
|
|
406
|
+
Binds from unambiguous local signals: `x = Foo()` instantiations, type
|
|
407
|
+
annotations (`x: Foo`, `def f(x: Foo)`), and return-type inference
|
|
408
|
+
(`x = get_thing()` where `get_thing` is declared `-> Foo`). A name bound to
|
|
409
|
+
two different types is dropped rather than mis-resolved.
|
|
386
410
|
"""
|
|
411
|
+
ret_types = _py_return_types(root)
|
|
387
412
|
types: dict[str, str] = {}
|
|
388
413
|
ambiguous: set[str] = set()
|
|
389
414
|
|
|
@@ -405,6 +430,11 @@ def _py_type_env(root: Node) -> dict[str, str]:
|
|
|
405
430
|
bind(name, _py_type_name(ann))
|
|
406
431
|
if ann is None:
|
|
407
432
|
bind(name, _py_call_class(right))
|
|
433
|
+
# return-type inference: x = fn(...) / x = obj.fn(...)
|
|
434
|
+
if right is not None and right.type == "call":
|
|
435
|
+
callee = _extract_python_call_parts(right)[0]
|
|
436
|
+
if callee:
|
|
437
|
+
bind(name, ret_types.get(callee))
|
|
408
438
|
elif node.type in ("typed_parameter", "typed_default_parameter"):
|
|
409
439
|
ident = _child_of_type(node, "identifier")
|
|
410
440
|
ann = node.child_by_field_name("type")
|
|
@@ -1126,14 +1156,17 @@ def _type_from_annotation(ann: Node | None) -> str | None:
|
|
|
1126
1156
|
def _js_type_env(root: Node) -> dict[str, str]:
|
|
1127
1157
|
"""Best-effort variable → type map for a file.
|
|
1128
1158
|
|
|
1129
|
-
Binds from
|
|
1159
|
+
Binds from unambiguous, local signals:
|
|
1130
1160
|
- `var x = new Foo()` -> x : Foo
|
|
1131
1161
|
- TS annotations `let x: Foo`,
|
|
1132
1162
|
`function f(x: Foo)` -> x : Foo
|
|
1163
|
+
- return-type inference `const x = getThing()` where TS declares
|
|
1164
|
+
`function getThing(): Foo` -> x : Foo
|
|
1133
1165
|
|
|
1134
1166
|
A name bound to two different types anywhere in the file is dropped as
|
|
1135
1167
|
ambiguous — better to fall back to name-based handling than to mis-resolve.
|
|
1136
1168
|
"""
|
|
1169
|
+
ret_types = _js_return_types(root)
|
|
1137
1170
|
types: dict[str, str] = {}
|
|
1138
1171
|
ambiguous: set[str] = set()
|
|
1139
1172
|
|
|
@@ -1152,6 +1185,11 @@ def _js_type_env(root: Node) -> dict[str, str]:
|
|
|
1152
1185
|
value = node.child_by_field_name("value")
|
|
1153
1186
|
bind(name, _new_expression_type(value))
|
|
1154
1187
|
bind(name, _type_from_annotation(_child_of_type(node, "type_annotation")))
|
|
1188
|
+
# return-type inference: const x = fn(...) / const x = obj.fn(...)
|
|
1189
|
+
if value is not None and value.type == "call_expression":
|
|
1190
|
+
callee = _extract_js_call(value)[0]
|
|
1191
|
+
if callee:
|
|
1192
|
+
bind(name, ret_types.get(callee))
|
|
1155
1193
|
elif node.type in ("required_parameter", "optional_parameter"):
|
|
1156
1194
|
pat = node.child_by_field_name("pattern") or _child_of_type(node, "identifier")
|
|
1157
1195
|
ann = _child_of_type(node, "type_annotation")
|
|
@@ -1162,6 +1200,29 @@ def _js_type_env(root: Node) -> dict[str, str]:
|
|
|
1162
1200
|
return types
|
|
1163
1201
|
|
|
1164
1202
|
|
|
1203
|
+
def _js_return_types(root: Node) -> dict[str, str]:
|
|
1204
|
+
"""Map function name -> its TS return type (`function f(): Foo` / `f(): Foo`).
|
|
1205
|
+
|
|
1206
|
+
Same-named functions with conflicting return types are dropped."""
|
|
1207
|
+
rets: dict[str, str] = {}
|
|
1208
|
+
conflict: set[str] = set()
|
|
1209
|
+
for node in _walk(root):
|
|
1210
|
+
if node.type in ("function_declaration", "function_expression", "method_definition"):
|
|
1211
|
+
name_node = (_child_of_type(node, "identifier")
|
|
1212
|
+
or _child_of_type(node, "property_identifier"))
|
|
1213
|
+
rt = node.child_by_field_name("return_type")
|
|
1214
|
+
t = _type_from_annotation(rt) if rt is not None else None
|
|
1215
|
+
if name_node is not None and t:
|
|
1216
|
+
name = _text(name_node)
|
|
1217
|
+
if name in rets and rets[name] != t:
|
|
1218
|
+
conflict.add(name)
|
|
1219
|
+
else:
|
|
1220
|
+
rets[name] = t
|
|
1221
|
+
for name in conflict:
|
|
1222
|
+
rets.pop(name, None)
|
|
1223
|
+
return rets
|
|
1224
|
+
|
|
1225
|
+
|
|
1165
1226
|
def _enclosing_class_name(node: Node) -> str | None:
|
|
1166
1227
|
"""Name of the nearest enclosing `class C { ... }`, for resolving `this`."""
|
|
1167
1228
|
current = node.parent
|
|
@@ -334,55 +334,50 @@ human-readable `description`. Use it as your primary navigation tool.
|
|
|
334
334
|
|
|
335
335
|
All commands default to the current directory — run them from the project root.
|
|
336
336
|
|
|
337
|
-
###
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
2. **
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
| `codecompass query --tree` | Full project structure with entity types |
|
|
382
|
-
| `codecompass query --dead-code` | Find functions/classes with no caller or importer (candidates to remove) |
|
|
383
|
-
| `codecompass query --flow <entry_symbol>` | Trace a call/import flow from an entry point (draw.io diagram by default) |
|
|
384
|
-
| `codecompass query --flow <entry_symbol> --format mermaid` | Same trace as a Markdown mermaid flowchart (renders on GitHub) |
|
|
385
|
-
| `codecompass query --flow <entry_symbol> --format json` | Same trace enriched with signatures, docstrings, and source snippets |
|
|
337
|
+
### The loop: discover → trace → read → edit
|
|
338
|
+
|
|
339
|
+
1. **Discover** — find the symbol(s) you care about (pick by what you have):
|
|
340
|
+
|
|
341
|
+
| You have… | Use | Example |
|
|
342
|
+
|---|---|---|
|
|
343
|
+
| a feature request that names a concept ("session timeout", "adapter retries") | `--grep <concept>` first | `--grep "^Session"` — scope straight to it (cheap); only fall back to `--map` if the concept isn't a symbol name |
|
|
344
|
+
| a regex / name pattern | `--grep <regex>` | `--grep "^get_"`, `--grep ".*Adapter$"` |
|
|
345
|
+
| keywords | `--search <words>` | `--search "session cookie"` |
|
|
346
|
+
| a truly nameless need ("where does caching go?" with no `cache` symbol) | `--map` | read the compact index and reason about where it belongs |
|
|
347
|
+
| the full layout | `--tree` | (large — prefer `--map` for reasoning) |
|
|
348
|
+
|
|
349
|
+
2. **Trace** — understand relationships around a known symbol/file:
|
|
350
|
+
|
|
351
|
+
| Question | Use |
|
|
352
|
+
|---|---|
|
|
353
|
+
| who calls / would break if I change this symbol? | `--impact <symbol>` |
|
|
354
|
+
| what files are affected if I edit this file? | `--blast-radius <file>` |
|
|
355
|
+
| what does this file depend on? | `--deps <file>` |
|
|
356
|
+
| what does this entry point call, step by step? | `--flow <symbol>` (lean structure) |
|
|
357
|
+
| explain a flow to a human (diagram + narration) | `--flow-summary <symbol>` |
|
|
358
|
+
| is anything unused? | `--dead-code` |
|
|
359
|
+
|
|
360
|
+
3. **Read** the specific slice the graph pointed you to (Read tool / `sed -n`),
|
|
361
|
+
not the whole file.
|
|
362
|
+
|
|
363
|
+
4. **Edit** — and before editing, `--impact`/`--blast-radius` the target so you
|
|
364
|
+
don't miss a caller or dependent. After adding/deleting files, re-ingest.
|
|
365
|
+
|
|
366
|
+
### Reading the results
|
|
367
|
+
|
|
368
|
+
- `--impact` rows carry `resolved`: `true` = the receiver was statically typed
|
|
369
|
+
(trust it); `false` = receiver type unknown, this call *might* target the
|
|
370
|
+
symbol (verify by reading the slice at `caller_file:line`).
|
|
371
|
+
- `--flow` is lean (structure only). Start at `hops=1` and only go deeper along
|
|
372
|
+
the one path you actually need — deep hops on a high-fan-out symbol are large.
|
|
373
|
+
- `--dead-code` is a candidate list — static analysis misses dynamic dispatch,
|
|
374
|
+
so read each before removing.
|
|
375
|
+
|
|
376
|
+
### Graph vs. `ls`/`find`
|
|
377
|
+
|
|
378
|
+
`ls`/`find` are for non-code paths the graph doesn't index (build/dist/log
|
|
379
|
+
output, fixtures, confirming a generated file exists). For anything about code
|
|
380
|
+
structure or relationships, use the graph.
|
|
386
381
|
|
|
387
382
|
### Explaining how something works
|
|
388
383
|
|
|
@@ -28,6 +28,7 @@ from graph.code_query_cli import (
|
|
|
28
28
|
fetch_deps,
|
|
29
29
|
fetch_flow,
|
|
30
30
|
fetch_flow_summary,
|
|
31
|
+
fetch_grep,
|
|
31
32
|
fetch_impact,
|
|
32
33
|
fetch_map,
|
|
33
34
|
fetch_search,
|
|
@@ -154,6 +155,23 @@ def styles(element: str) -> dict:
|
|
|
154
155
|
return fetch_styles(element, repo, os.path.basename(repo))
|
|
155
156
|
|
|
156
157
|
|
|
158
|
+
@mcp.tool()
|
|
159
|
+
def grep(pattern: str, field: str = "all", ignore_case: bool = True, limit: int = 100) -> dict:
|
|
160
|
+
"""Regex-search the graph — 'grep' over indexed entities instead of file text.
|
|
161
|
+
|
|
162
|
+
Matches `pattern` (a Python regex) against each entity's name / file / kind /
|
|
163
|
+
description (or one `field`), returning matching entities with the field that
|
|
164
|
+
hit. This is the graph-native replacement for grepping source: full regex
|
|
165
|
+
power over the symbols the graph knows about. Use it to find symbols by
|
|
166
|
+
pattern (`^test_`, `.*Adapter$`, `handle|dispatch`), then drill in with
|
|
167
|
+
impact/flow/deps.
|
|
168
|
+
"""
|
|
169
|
+
repo = _active_repo()
|
|
170
|
+
_ensure_initialized(repo)
|
|
171
|
+
return fetch_grep(pattern, repo, os.path.basename(repo), field=field,
|
|
172
|
+
ignore_case=ignore_case, limit=limit)
|
|
173
|
+
|
|
174
|
+
|
|
157
175
|
@mcp.tool()
|
|
158
176
|
def map(include_tests: bool = False) -> dict:
|
|
159
177
|
"""Compact `{file: [symbols]}` index of the codebase — the discovery entry
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codecompass-mcp"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.8.0"
|
|
4
4
|
description = "Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -339,6 +339,39 @@ def test_python_public_method_marked_exported():
|
|
|
339
339
|
assert by_name.get("_private") is False, "underscore method is not exported"
|
|
340
340
|
|
|
341
341
|
|
|
342
|
+
def test_graph_grep_regex():
|
|
343
|
+
triples = [
|
|
344
|
+
CodeTriple("HTTPAdapter", "class", "DEFINED_IN", "m", "module", "adapters.py", 1),
|
|
345
|
+
CodeTriple("get_adapter", "function", "DEFINED_IN", "m", "module", "sessions.py", 5),
|
|
346
|
+
CodeTriple("send", "function", "DEFINED_IN", "m", "module", "sessions.py", 9),
|
|
347
|
+
]
|
|
348
|
+
client = _client()
|
|
349
|
+
_write(client, triples)
|
|
350
|
+
# anchored regex on name
|
|
351
|
+
r = client.grep_graph("^get_", "proj", field="name")
|
|
352
|
+
assert {m["name"] for m in r["matches"]} == {"get_adapter"}
|
|
353
|
+
# alternation across name
|
|
354
|
+
r2 = client.grep_graph("Adapter|send", "proj", field="name")
|
|
355
|
+
assert {m["name"] for m in r2["matches"]} == {"HTTPAdapter", "get_adapter", "send"}
|
|
356
|
+
# field filter
|
|
357
|
+
r3 = client.grep_graph("sessions", "proj", field="file")
|
|
358
|
+
assert {m["name"] for m in r3["matches"]} == {"get_adapter", "send"}
|
|
359
|
+
# bad regex is reported, not raised
|
|
360
|
+
assert "error" in client.grep_graph("(", "proj")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_python_return_type_inference():
|
|
364
|
+
src = ("class Session:\n"
|
|
365
|
+
" def get_adapter(self, url) -> HTTPAdapter:\n"
|
|
366
|
+
" return self.a\n"
|
|
367
|
+
" def send(self, req):\n"
|
|
368
|
+
" adapter = self.get_adapter(req.url)\n"
|
|
369
|
+
" return adapter.send(req)\n")
|
|
370
|
+
triples = _parse_py(src)
|
|
371
|
+
call = next(t for t in triples if t.relation_type == "CALLS" and t.to_entity == "send")
|
|
372
|
+
assert call.call_receiver_type == "HTTPAdapter", "return type of get_adapter should type `adapter`"
|
|
373
|
+
|
|
374
|
+
|
|
342
375
|
def test_python_public_module_function_marked_exported():
|
|
343
376
|
src = "def has_app_context():\n return True\n\ndef _helper():\n pass\n"
|
|
344
377
|
triples = _parse_py(src)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|