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.
Files changed (44) hide show
  1. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/PKG-INFO +1 -1
  2. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
  3. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/code_graph_client.py +205 -49
  4. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/code_query_cli.py +29 -0
  5. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/code_parser.py +65 -4
  6. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/main.py +44 -49
  7. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/mcp_server.py +18 -0
  8. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/pyproject.toml +1 -1
  9. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_accuracy_fixes.py +33 -0
  10. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/LICENSE +0 -0
  11. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/README.md +0 -0
  12. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/SOURCES.txt +0 -0
  13. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  14. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  15. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/requires.txt +0 -0
  16. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  17. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/config.py +0 -0
  18. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/__init__.py +0 -0
  19. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/cli.py +0 -0
  20. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/graph/setup.py +0 -0
  21. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/__init__.py +0 -0
  22. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/chunker.py +0 -0
  23. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/code_normalizer.py +0 -0
  24. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/description_enricher.py +0 -0
  25. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/entity_resolver.py +0 -0
  26. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/file_watcher.py +0 -0
  27. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/graph_writer.py +0 -0
  28. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/hierarchy_builder.py +0 -0
  29. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/reader_agent.py +0 -0
  30. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/ingestion/source_context.py +0 -0
  31. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/models/__init__.py +0 -0
  32. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/models/code_types.py +0 -0
  33. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/models/types.py +0 -0
  34. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/setup.cfg +0 -0
  35. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_batch_impact.py +0 -0
  36. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_blast_radius.py +0 -0
  37. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_code_parser.py +0 -0
  38. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_description_enricher.py +0 -0
  39. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_ingestion.py +0 -0
  40. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_lit_css_parser.py +0 -0
  41. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_mcp_server.py +0 -0
  42. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/tests/test_php_parser.py +0 -0
  43. {codecompass_mcp-2.7.0 → codecompass_mcp-2.8.0}/utils/__init__.py +0 -0
  44. {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.7.0
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.7.0
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) -> None:
190
- from_id = f"{project}:{triple.from_entity.lower()}"
191
- to_id = f"{project}:{triple.to_entity.lower()}"
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
- self.write_code_triple(triple, file_id, project)
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"). When qualified, only call sites whose
298
- receiver matches are returned by inferred receiver *type* first
299
- (`Router.handle` -> calls on a `new Router()`), then by receiver name.
300
- This separates distinct same-named methods (app.handle vs router.handle)
301
- that share a single graph node. Every returned row carries the call's
302
- `receiver` and `receiver_type`, so an unqualified query still surfaces
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, lookup_name = entity_name.rsplit(".", 1)
387
+ want_receiver, _ = entity_name.rsplit(".", 1)
309
388
 
310
- target_id = f"{project}:{lookup_name.lower()}"
311
- if target_id not in self.graph:
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 = {target_id}
316
- queue = [(target_id, 0)]
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 == target_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
- return sorted(results, key=lambda x: x["depth"])
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 pred in self.graph.predecessors(target_id):
491
- edges = self.graph.get_edge_data(pred, target_id)
492
- for key, edge in edges.items():
493
- if edge.get("type") == "STYLES":
494
- node = self.graph.nodes[pred]
495
- results.append({
496
- "selector": node.get("name"),
497
- "source_file": node.get("file"),
498
- "line": edge.get("line")
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
- start_id = f"{project}:{start_name.lower()}"
510
- if start_id not in self.graph:
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
- start_id = f"{project}:{start_name.lower()}"
567
- if start_id not in self.graph:
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
- entity_id = f"{project}:{target.lower()}"
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 entity_id in self.graph:
694
- node = self.graph.nodes[entity_id]
695
- target_file = node.get("file")
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 and
384
- annotations (`x: Foo`, `def f(x: Foo)`). A name bound to two different types
385
- is dropped rather than mis-resolved.
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 two unambiguous, local signals:
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
- ### Rules
338
-
339
- 0. **Orient before you search.** When you don't yet know where something lives,
340
- start from the graph — `codecompass query --tree` to see what's where, then
341
- `--flow`/`--impact`/`--deps` to trace from an entry point — instead of blind
342
- grepping. Once you know the string/symbol you want, or need to confirm a
343
- result, grep/cat/read are fine. Pick the tool that fits; the graph is the
344
- default reflex for structure/relationship questions, not a hard requirement.
345
- 1. **Before editing any file**, run `--blast-radius` on it to see what depends on it:
346
- ```bash
347
- codecompass query --blast-radius <file_or_symbol>
348
- ```
349
- 2. **Before calling or importing a symbol you haven't read**, run `--impact` to
350
- understand its downstream effects:
351
- ```bash
352
- codecompass query --impact <symbol>
353
- ```
354
- 3. **After creating or deleting files**, re-ingest so the graph stays current:
355
- ```bash
356
- codecompass ingest-code
357
- ```
358
- 4. **Never skip step 1.** Reading a file without checking its blast radius first
359
- means you may miss callers, importers, or CSS/HTML dependents.
360
-
361
- ### Graph vs. `ls`/`find` — how to decide
362
-
363
- Use **codecompass** when the question is about code structure or relationships:
364
- "what calls this", "what depends on this file", "what does this module do",
365
- "how does this flow work", "is this dead code". The graph knows the real
366
- dependency edges; a directory listing does not.
367
-
368
- Use **`ls`/`find`** when the question has nothing to do with code
369
- relationships: confirming a generated/output file exists, listing a
370
- build/dist/log directory, checking test fixtures or assets, or any path the
371
- graph doesn't index. These are fine don't force codecompass onto questions
372
- it can't answer.
373
-
374
- ### Available queries
375
-
376
- | Command | Purpose |
377
- |---|---|
378
- | `codecompass query --blast-radius <file_or_symbol>` | All nodes affected if you change this |
379
- | `codecompass query --impact <symbol>` | Downstream callers / importers of a symbol |
380
- | `codecompass query --deps <file>` | What this file depends on |
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.7.0"
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