codecompass-mcp 2.7.0__tar.gz → 3.0.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-3.0.0}/PKG-INFO +1 -1
  2. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
  3. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/code_graph_client.py +225 -49
  4. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/code_query_cli.py +29 -0
  5. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/code_parser.py +202 -10
  6. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/hierarchy_builder.py +1 -1
  7. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/main.py +44 -49
  8. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/mcp_server.py +18 -0
  9. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/models/code_types.py +6 -0
  10. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/pyproject.toml +1 -1
  11. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_accuracy_fixes.py +57 -0
  12. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/LICENSE +0 -0
  13. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/README.md +0 -0
  14. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/SOURCES.txt +0 -0
  15. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  16. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  17. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/requires.txt +0 -0
  18. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  19. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/config.py +0 -0
  20. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/__init__.py +0 -0
  21. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/cli.py +0 -0
  22. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/setup.py +0 -0
  23. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/__init__.py +0 -0
  24. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/chunker.py +0 -0
  25. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/code_normalizer.py +0 -0
  26. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/description_enricher.py +0 -0
  27. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/entity_resolver.py +0 -0
  28. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/file_watcher.py +0 -0
  29. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/graph_writer.py +0 -0
  30. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/reader_agent.py +0 -0
  31. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/source_context.py +0 -0
  32. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/models/__init__.py +0 -0
  33. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/models/types.py +0 -0
  34. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/setup.cfg +0 -0
  35. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_batch_impact.py +0 -0
  36. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_blast_radius.py +0 -0
  37. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_code_parser.py +0 -0
  38. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_description_enricher.py +0 -0
  39. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_ingestion.py +0 -0
  40. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_lit_css_parser.py +0 -0
  41. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_mcp_server.py +0 -0
  42. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_php_parser.py +0 -0
  43. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/utils/__init__.py +0 -0
  44. {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.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: 3.0.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: 3.0.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,18 @@ 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, owner: str | None = None) -> str:
41
+ """Node id for an entity — file- and class-qualified so same-named entities
42
+ stay distinct across files AND across classes in the same file. Case is
43
+ preserved so `Session` (class) and `session` (function) don't collide.
44
+ External/module targets with no file are name-only.
45
+ """
46
+ if file:
47
+ local = f"{owner}.{name}" if owner else name
48
+ return f"{project}:{file}:{local}"
49
+ return f"{project}:{name}"
50
+
51
+
39
52
  _ENTRY_POINT_PREFIXES = ("run_", "main", "handle_", "cmd_", "test_", "setup_", "teardown_")
40
53
  _ENTRY_POINT_NAMES = {"main", "__main__", "handler", "lambda_handler", "application", "app"}
41
54
 
@@ -186,9 +199,14 @@ class LocalGraphClient:
186
199
  # Entity nodes and semantic edges (from code triples)
187
200
  # ------------------------------------------------------------------
188
201
 
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()}"
202
+ def write_code_triple(self, triple: CodeTriple, file_node_id: str, project: str,
203
+ from_id: str | None = None, to_id: str | None = None) -> None:
204
+ # from_id/to_id are the resolved (file-qualified) node ids from the batch
205
+ # writer; fall back to name-only for any direct/legacy caller.
206
+ if from_id is None:
207
+ from_id = f"{project}:{triple.from_entity}"
208
+ if to_id is None:
209
+ to_id = f"{project}:{triple.to_entity}"
192
210
  rel_type = triple.relation_type if triple.relation_type in _ALLOWED_REL_TYPES else "RELATION"
193
211
 
194
212
  ext = os.path.splitext(triple.source_file)[1].lower()
@@ -210,6 +228,8 @@ class LocalGraphClient:
210
228
  # later non-exported definition clear a flag an earlier one set.
211
229
  if getattr(triple, "is_exported", False):
212
230
  self.graph.nodes[from_id]["is_exported"] = True
231
+ if getattr(triple, "owner_class", None):
232
+ self.graph.nodes[from_id]["owner"] = triple.owner_class
213
233
 
214
234
  # Upsert to entity — only set language/kind/description if not already known
215
235
  self.graph.add_node(to_id)
@@ -236,16 +256,59 @@ class LocalGraphClient:
236
256
 
237
257
  self.graph.add_edge(file_node_id, from_id, type="CONTAINS")
238
258
 
259
+ def _resolve_to_id(self, triple: CodeTriple, project: str,
260
+ defs_by_name: dict, class_file: dict) -> str:
261
+ """Resolve a triple's callee/target to a specific definition node when we
262
+ can, else a name-only bucket. Uses the captured receiver type to pick the
263
+ right same-named method (self.send() in a Session method -> the send
264
+ defined alongside class Session)."""
265
+ name = triple.to_entity
266
+ if triple.relation_type == "DEFINED_IN":
267
+ return f"{project}:{name}" # module container — name-only
268
+ cands = defs_by_name.get(name) # list of (node_id, file, owner)
269
+ if cands:
270
+ rt = getattr(triple, "call_receiver_type", None)
271
+ if rt:
272
+ # strongest: a definition owned by exactly the receiver's class
273
+ for nid, f, owner in cands:
274
+ if owner == rt:
275
+ return nid
276
+ # next: any definition in the receiver class's file
277
+ cf = class_file.get(rt)
278
+ if cf:
279
+ for nid, f, owner in cands:
280
+ if f == cf:
281
+ return nid
282
+ if len(cands) == 1:
283
+ return cands[0][0]
284
+ # ambiguous or external → name-only bucket (edge not lost)
285
+ return f"{project}:{name}"
286
+
239
287
  def write_code_triples_batch(
240
- self,
241
- triples: list[CodeTriple],
242
- file_id_map: dict[str, str],
288
+ self,
289
+ triples: list[CodeTriple],
290
+ file_id_map: dict[str, str],
243
291
  project: str
244
292
  ) -> int:
293
+ # Pass 1: index every definition so calls can resolve to a specific one.
294
+ defs_by_name: dict[str, list] = {} # name -> [(node_id, file, owner)]
295
+ class_file: dict[str, str] = {}
296
+ for t in triples:
297
+ if t.relation_type == "DEFINED_IN":
298
+ owner = getattr(t, "owner_class", None)
299
+ nid = _entity_id(project, t.from_entity, t.source_file, owner)
300
+ defs_by_name.setdefault(t.from_entity, []).append((nid, t.source_file, owner))
301
+ if t.from_type == "class":
302
+ class_file.setdefault(t.from_entity, t.source_file)
303
+
304
+ # Pass 2: write nodes/edges with resolved, file+class-qualified ids.
245
305
  for triple in triples:
246
306
  file_id = file_id_map.get(triple.source_file, "")
247
- self.write_code_triple(triple, file_id, project)
248
-
307
+ from_id = _entity_id(project, triple.from_entity, triple.source_file,
308
+ getattr(triple, "owner_class", None))
309
+ to_id = self._resolve_to_id(triple, project, defs_by_name, class_file)
310
+ self.write_code_triple(triple, file_id, project, from_id=from_id, to_id=to_id)
311
+
249
312
  self.save()
250
313
  return len(triples)
251
314
 
@@ -290,30 +353,65 @@ class LocalGraphClient:
290
353
  # Traversal queries used by code_query_cli
291
354
  # ------------------------------------------------------------------
292
355
 
356
+ def _resolve_query_nodes(self, entity_name: str, project: str) -> list[str]:
357
+ """Resolve a bare or receiver-qualified name to the node id(s) it refers
358
+ to. `Session.send` → the `send` node in the file where class `Session` is
359
+ defined; `send` → every `send` node. Includes the name-only bucket id
360
+ when present (unresolved/ambiguous calls land there)."""
361
+ qualifier = None
362
+ name = entity_name
363
+ if "." in entity_name:
364
+ qualifier, name = entity_name.rsplit(".", 1)
365
+ name_l = name.lower()
366
+
367
+ matches = [
368
+ n for n, a in self.graph.nodes(data=True)
369
+ if a.get("type") == "Entity" and (a.get("name") or "").lower() == name_l
370
+ ]
371
+ # name-only bucket (unresolved calls land here) is matched by its name attr,
372
+ # but sort it last so flow/trace pick a real definition as the start.
373
+ matches.sort(key=lambda n: self.graph.nodes[n].get("file") is None)
374
+
375
+ if qualifier and matches:
376
+ # strongest: nodes whose owner class IS the qualifier (Command.invoke)
377
+ owned = [n for n in matches
378
+ if (self.graph.nodes[n].get("owner") or "").lower() == qualifier.lower()]
379
+ if owned:
380
+ return owned
381
+ # next: nodes in the file where class <qualifier> is defined
382
+ cls_files = {
383
+ a.get("file") for n, a in self.graph.nodes(data=True)
384
+ if a.get("type") == "Entity" and a.get("entity_type") == "class"
385
+ and (a.get("name") or "").lower() == qualifier.lower() and a.get("file")
386
+ }
387
+ pref = [n for n in matches if self.graph.nodes[n].get("file") in cls_files]
388
+ if pref:
389
+ return pref
390
+ return matches
391
+
293
392
  def find_callers(self, entity_name: str, project: str, max_hops: int = 3) -> list[dict]:
294
393
  """Return everything that calls/uses/references entity_name (reverse traversal).
295
394
 
296
395
  `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.
396
+ ("app.handle" / "Router.handle"). Node ids are file-qualified, so distinct
397
+ same-named methods are distinct nodes; a qualified query resolves to the
398
+ one defined alongside the named class. The receiver filter still applies
399
+ for calls that landed in a shared name-only bucket (genuinely ambiguous).
400
+ Every returned row carries the call's `receiver`/`receiver_type` and the
401
+ real call-site file+line.
304
402
  """
305
403
  want_receiver = None
306
- lookup_name = entity_name
307
404
  if "." in entity_name:
308
- want_receiver, lookup_name = entity_name.rsplit(".", 1)
405
+ want_receiver, _ = entity_name.rsplit(".", 1)
309
406
 
310
- target_id = f"{project}:{lookup_name.lower()}"
311
- if target_id not in self.graph:
407
+ targets = self._resolve_query_nodes(entity_name, project)
408
+ if not targets:
312
409
  return []
410
+ target_set = set(targets)
313
411
 
314
412
  results = []
315
- visited = {target_id}
316
- queue = [(target_id, 0)]
413
+ visited = set(targets)
414
+ queue = [(t, 0) for t in targets]
317
415
 
318
416
  while queue:
319
417
  current_id, depth = queue.pop(0)
@@ -331,7 +429,7 @@ class LocalGraphClient:
331
429
 
332
430
  # Only apply receiver filtering to the direct (depth-0) edge into
333
431
  # the queried symbol; deeper hops are transitive callers.
334
- if want_receiver is not None and current_id == target_id:
432
+ if want_receiver is not None and current_id in target_set:
335
433
  if not any(
336
434
  _receiver_matches(want_receiver, edge.get("receiver"),
337
435
  edge.get("receiver_type"))
@@ -349,18 +447,98 @@ class LocalGraphClient:
349
447
  (e.get("receiver_type") for e in relevant_edges if e.get("receiver_type")),
350
448
  None,
351
449
  )
450
+ # The caller NODE is keyed by name only, so when two functions
451
+ # share a name its "file" is last-writer-wins (a merge). The
452
+ # CALLS edge, however, records where THIS call actually is —
453
+ # use it so each caller row points at the real call site with a
454
+ # line number, not the merged node's file.
455
+ call_edge = next(
456
+ (e for e in relevant_edges if e.get("source_file")), relevant_edges[0]
457
+ )
352
458
  results.append({
353
459
  "caller_name": node.get("name"),
354
460
  "caller_type": node.get("entity_type"),
355
- "caller_file": node.get("file"),
461
+ "caller_file": call_edge.get("source_file") or node.get("file"),
462
+ "line": call_edge.get("line"),
356
463
  "receiver": receiver,
357
464
  "receiver_type": receiver_type,
465
+ "resolved": True,
358
466
  "depth": depth + 1
359
467
  })
360
468
  visited.add(pred)
361
469
  queue.append((pred, depth + 1))
362
470
 
363
- return sorted(results, key=lambda x: x["depth"])
471
+ # Qualified query (Type.method): if we found NO precise caller, fall back
472
+ # to the name-only bucket — calls whose receiver couldn't be statically
473
+ # typed (e.g. `adapter = self.get_adapter(url); adapter.send()`). They MAY
474
+ # hit this method, so we surface them flagged resolved=False rather than
475
+ # return nothing. We skip this when there's already a precise answer,
476
+ # because the bucket for a common name (`invoke`) is mostly calls to OTHER
477
+ # same-named methods and would flood a query that already resolved.
478
+ if want_receiver is not None and not results:
479
+ method = entity_name.rsplit(".", 1)[1]
480
+ bucket = f"{project}:{method}"
481
+ if bucket in self.graph and bucket not in target_set:
482
+ for pred in self.graph.predecessors(bucket):
483
+ if pred in visited:
484
+ continue
485
+ rel = [e for e in self.graph.get_edge_data(pred, bucket).values()
486
+ if e.get("type") in {"CALLS", "USES_VAR", "REFERENCES"}]
487
+ node = self.graph.nodes[pred]
488
+ if not rel or node.get("type") != "Entity":
489
+ continue
490
+ ce = next((e for e in rel if e.get("source_file")), rel[0])
491
+ results.append({
492
+ "caller_name": node.get("name"),
493
+ "caller_type": node.get("entity_type"),
494
+ "caller_file": ce.get("source_file") or node.get("file"),
495
+ "line": ce.get("line"),
496
+ "receiver": next((e.get("receiver") for e in rel if e.get("receiver")), None),
497
+ "receiver_type": None,
498
+ "resolved": False,
499
+ "depth": 1,
500
+ })
501
+ visited.add(pred)
502
+
503
+ return sorted(results, key=lambda x: (not x.get("resolved", True), x["depth"]))
504
+
505
+ def grep_graph(self, pattern: str, project: str, field: str = "all",
506
+ ignore_case: bool = True, limit: int = 100) -> dict:
507
+ """Regex-search the graph — 'grep' for entities instead of file lines.
508
+
509
+ Matches `pattern` against each entity's name / file / kind / description
510
+ (or a single `field`), returning the matching entities with the field
511
+ that hit and the matched text. This is the graph-native replacement for
512
+ grepping source: full regex power, but over the indexed symbols rather
513
+ than raw text.
514
+ """
515
+ flags = re.IGNORECASE if ignore_case else 0
516
+ try:
517
+ rx = re.compile(pattern, flags)
518
+ except re.error as exc:
519
+ return {"pattern": pattern, "error": f"invalid regex: {exc}", "matches": [], "count": 0}
520
+
521
+ fields = ["name", "file", "kind", "description"] if field == "all" else [field]
522
+ matches = []
523
+ for _n, a in self.graph.nodes(data=True):
524
+ if a.get("type") != "Entity" or a.get("project") != project:
525
+ continue
526
+ for f in fields:
527
+ val = str(a.get(f) or "")
528
+ m = rx.search(val) if val else None
529
+ if m:
530
+ matches.append({
531
+ "name": a.get("name"),
532
+ "kind": a.get("kind"),
533
+ "file": a.get("file"),
534
+ "matched_field": f,
535
+ "match": m.group(0),
536
+ })
537
+ break
538
+ if len(matches) >= limit:
539
+ break
540
+ matches.sort(key=lambda r: (r["file"] or "", r["name"] or ""))
541
+ return {"pattern": pattern, "matches": matches, "count": len(matches)}
364
542
 
365
543
  def search_entities(self, query: str, project: str, limit: int = 30,
366
544
  kind: str | None = None) -> list[dict]:
@@ -482,22 +660,19 @@ class LocalGraphClient:
482
660
 
483
661
  def find_styles(self, element_name: str, project: str) -> list[dict]:
484
662
  """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
663
  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
-
664
+ for target_id in self._resolve_query_nodes(element_name, project):
665
+ for pred in self.graph.predecessors(target_id):
666
+ edges = self.graph.get_edge_data(pred, target_id)
667
+ for key, edge in edges.items():
668
+ if edge.get("type") == "STYLES":
669
+ node = self.graph.nodes[pred]
670
+ results.append({
671
+ "selector": node.get("name"),
672
+ "source_file": node.get("file"),
673
+ "line": edge.get("line")
674
+ })
675
+
501
676
  return sorted(results, key=lambda x: x["selector"])
502
677
 
503
678
  def trace_flow(self, start_name: str, project: str, max_hops: int = 6,
@@ -506,9 +681,10 @@ class LocalGraphClient:
506
681
  """BFS from start_name along CALLS/IMPORTS edges. Returns nodes + edges for rendering."""
507
682
  if edge_types is None:
508
683
  edge_types = frozenset({"CALLS", "IMPORTS"})
509
- start_id = f"{project}:{start_name.lower()}"
510
- if start_id not in self.graph:
684
+ starts = self._resolve_query_nodes(start_name, project)
685
+ if not starts:
511
686
  return {"nodes": [], "edges": []}
687
+ start_id = starts[0]
512
688
 
513
689
  flow_nodes: dict[str, dict] = {}
514
690
  flow_edges: list[dict] = []
@@ -563,9 +739,10 @@ class LocalGraphClient:
563
739
 
564
740
  def trace_calls(self, start_name: str, project: str, max_hops: int = 4) -> list[dict]:
565
741
  """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:
742
+ starts = self._resolve_query_nodes(start_name, project)
743
+ if not starts:
568
744
  return []
745
+ start_id = starts[0]
569
746
 
570
747
  results = []
571
748
  visited = {start_id}
@@ -686,14 +863,13 @@ class LocalGraphClient:
686
863
  self, target: str, project: str, max_hops: int = 3
687
864
  ) -> tuple[list[dict], str | None]:
688
865
  """Return all files reachable from target via CALLS/IMPORTS/INHERITS."""
689
- # Try as entity name first
690
- entity_id = f"{project}:{target.lower()}"
866
+ # Try as entity name first (may resolve to several same-named nodes).
867
+ ent_nodes = self._resolve_query_nodes(target, project) if "/" not in target else []
691
868
  target_file = None
692
869
 
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]
870
+ if ent_nodes:
871
+ target_file = self.graph.nodes[ent_nodes[0]].get("file")
872
+ start_ids = ent_nodes
697
873
  else:
698
874
  # Try as file path
699
875
  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")
@@ -221,6 +221,7 @@ def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTripl
221
221
  source_file=file_path,
222
222
  line_number=_line(node),
223
223
  is_exported=name in exported,
224
+ owner_class=_enclosing_python_class(node),
224
225
  ))
225
226
 
226
227
  case "class_definition":
@@ -308,6 +309,7 @@ def _extract_python(root: Node, source: bytes, file_path: str) -> list[CodeTripl
308
309
  line_number=_line(node),
309
310
  call_receiver=receiver,
310
311
  call_receiver_type=receiver_type,
312
+ owner_class=_enclosing_python_class(node) if scope else None,
311
313
  ))
312
314
 
313
315
  return triples
@@ -377,13 +379,38 @@ def _py_type_name(ann: Node | None) -> str | None:
377
379
  return None
378
380
 
379
381
 
382
+ def _py_return_types(root: Node) -> dict[str, str]:
383
+ """Map function name -> its declared return type (`def f(...) -> Foo:`).
384
+
385
+ Enables return-type inference: `x = self.get_adapter(url)` gets typed by
386
+ `get_adapter`'s `-> BaseAdapter` annotation. Same-named functions with
387
+ conflicting return types are dropped (can't disambiguate statically)."""
388
+ rets: dict[str, str] = {}
389
+ conflict: set[str] = set()
390
+ for node in _walk(root):
391
+ if node.type == "function_definition":
392
+ name = _get_node_name(node, ["identifier"])
393
+ rt = node.child_by_field_name("return_type")
394
+ t = _py_type_name(rt) if rt is not None else None
395
+ if name and t:
396
+ if name in rets and rets[name] != t:
397
+ conflict.add(name)
398
+ else:
399
+ rets[name] = t
400
+ for name in conflict:
401
+ rets.pop(name, None)
402
+ return rets
403
+
404
+
380
405
  def _py_type_env(root: Node) -> dict[str, str]:
381
406
  """Best-effort variable → class map for a Python module.
382
407
 
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.
408
+ Binds from unambiguous local signals: `x = Foo()` instantiations, type
409
+ annotations (`x: Foo`, `def f(x: Foo)`), and return-type inference
410
+ (`x = get_thing()` where `get_thing` is declared `-> Foo`). A name bound to
411
+ two different types is dropped rather than mis-resolved.
386
412
  """
413
+ ret_types = _py_return_types(root)
387
414
  types: dict[str, str] = {}
388
415
  ambiguous: set[str] = set()
389
416
 
@@ -405,6 +432,11 @@ def _py_type_env(root: Node) -> dict[str, str]:
405
432
  bind(name, _py_type_name(ann))
406
433
  if ann is None:
407
434
  bind(name, _py_call_class(right))
435
+ # return-type inference: x = fn(...) / x = obj.fn(...)
436
+ if right is not None and right.type == "call":
437
+ callee = _extract_python_call_parts(right)[0]
438
+ if callee:
439
+ bind(name, ret_types.get(callee))
408
440
  elif node.type in ("typed_parameter", "typed_default_parameter"):
409
441
  ident = _child_of_type(node, "identifier")
410
442
  ann = node.child_by_field_name("type")
@@ -600,11 +632,127 @@ def _php_string_text(string_node: Node) -> str:
600
632
  return _text(frag) if frag else _text(string_node).strip("'\"")
601
633
 
602
634
 
635
+ def _php_type_basename(node: Node | None) -> str | None:
636
+ """Class name from a PHP type node (`named_type`/`type`/`qualified_name`),
637
+ stripping any namespace. Skips scalar/pseudo types (int, string, void, …)."""
638
+ if node is None:
639
+ return None
640
+ for desc in _walk(node):
641
+ if desc.type in ("name", "qualified_name"):
642
+ base = _php_basename(desc)
643
+ if base and base[:1].isupper(): # PSR class-name convention
644
+ return base
645
+ return None
646
+
647
+
648
+ def _php_new_type(node: Node | None) -> str | None:
649
+ """Class name of `new X(...)`, else None."""
650
+ if node is None or node.type != "object_creation_expression":
651
+ return None
652
+ for child in node.children:
653
+ if child.type in ("name", "qualified_name"):
654
+ base = _php_basename(child)
655
+ if base and base.lower() not in _PHP_SELF_REFERENTIAL:
656
+ return base
657
+ return None
658
+
659
+
660
+ def _php_is_public(method_node: Node) -> bool:
661
+ """PHP method visibility — public (or no modifier, which defaults public) is
662
+ API; private/protected is internal. Reads the `visibility_modifier` child."""
663
+ for child in method_node.children:
664
+ if child.type == "visibility_modifier":
665
+ return _text(child).lower() not in ("private", "protected")
666
+ return True # PHP methods default to public
667
+
668
+
669
+ def _php_return_types(root: Node) -> dict[str, str]:
670
+ """Map function/method name -> declared return type (`function f(): Foo`)."""
671
+ rets: dict[str, str] = {}
672
+ conflict: set[str] = set()
673
+ for node in _walk(root):
674
+ if node.type in ("function_definition", "method_declaration"):
675
+ name = _php_name(node)
676
+ rt = node.child_by_field_name("return_type")
677
+ t = _php_type_basename(rt) if rt is not None else None
678
+ if name and t:
679
+ if name in rets and rets[name] != t:
680
+ conflict.add(name)
681
+ else:
682
+ rets[name] = t
683
+ for name in conflict:
684
+ rets.pop(name, None)
685
+ return rets
686
+
687
+
688
+ def _php_type_env(root: Node) -> dict[str, str]:
689
+ """Best-effort PHP variable → class map (no `$` in keys).
690
+
691
+ Binds from `$x = new Foo()`, typed params (`function f(Foo $x)`), and
692
+ return-type inference (`$x = make()` where `make(): Foo`). Conflicts dropped.
693
+ """
694
+ ret_types = _php_return_types(root)
695
+ types: dict[str, str] = {}
696
+ ambiguous: set[str] = set()
697
+
698
+ def bind(name: str | None, t: str | None) -> None:
699
+ if not name or not t:
700
+ return
701
+ if name in types and types[name] != t:
702
+ ambiguous.add(name)
703
+ else:
704
+ types[name] = t
705
+
706
+ for node in _walk(root):
707
+ if node.type == "assignment_expression" and node.children:
708
+ lhs = node.children[0]
709
+ rhs = node.children[-1]
710
+ if lhs.type == "variable_name":
711
+ name = _php_var_name(lhs)
712
+ bind(name, _php_new_type(rhs))
713
+ if rhs.type in ("function_call_expression", "member_call_expression",
714
+ "nullsafe_member_call_expression", "scoped_call_expression"):
715
+ callee = _child_of_type(rhs, "name")
716
+ if callee is not None:
717
+ bind(name, ret_types.get(_text(callee)))
718
+ elif node.type == "simple_parameter":
719
+ var = _child_of_type(node, "variable_name")
720
+ ty = node.child_by_field_name("type")
721
+ if var is not None and ty is not None:
722
+ bind(_php_var_name(var), _php_type_basename(ty))
723
+
724
+ for name in ambiguous:
725
+ types.pop(name, None)
726
+ return types
727
+
728
+
729
+ def _php_call_receiver(node: Node, env: dict[str, str]) -> tuple[str | None, str | None]:
730
+ """Receiver text + inferred type for a member call `$obj->method()`."""
731
+ obj = node.child_by_field_name("object")
732
+ if obj is None:
733
+ obj = node.children[0] if node.children else None
734
+ if obj is None:
735
+ return None, None
736
+ # inline `(new Foo())->m()`
737
+ nt = _php_new_type(obj)
738
+ if nt:
739
+ return "new", nt
740
+ if obj.type == "variable_name":
741
+ var = _php_var_name(obj)
742
+ if var == "this":
743
+ container = _enclosing_php_container(node)
744
+ return "this", (container[0] if container else None)
745
+ return var, env.get(var)
746
+ return _text(obj), None
747
+
748
+
603
749
  def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
604
750
  module_name = _module_name_from_path(file_path)
605
751
  triples: list[CodeTriple] = []
752
+ type_env = _php_type_env(root)
606
753
 
607
- def defined_in(name: str, entity_type: str, line: int) -> None:
754
+ def defined_in(name: str, entity_type: str, line: int, is_exported: bool = True,
755
+ owner: str | None = None) -> None:
608
756
  triples.append(CodeTriple(
609
757
  from_entity=name,
610
758
  from_type=entity_type,
@@ -613,6 +761,8 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
613
761
  to_type=TYPE_MODULE,
614
762
  source_file=file_path,
615
763
  line_number=line,
764
+ is_exported=is_exported,
765
+ owner_class=owner,
616
766
  ))
617
767
 
618
768
  def inherits(from_name: str, from_type: str, to_node: Node, to_type: str) -> None:
@@ -628,11 +778,13 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
628
778
  line_number=_line(to_node),
629
779
  ))
630
780
 
631
- def calls(callee: str, callee_type: str, line: int, scope_node: Node) -> None:
781
+ def calls(callee: str, callee_type: str, line: int, scope_node: Node,
782
+ receiver: str | None = None, receiver_type: str | None = None) -> None:
632
783
  if not callee or not _is_meaningful_callee(callee):
633
784
  return
634
785
  scope = _enclosing_php_scope(scope_node)
635
786
  caller, caller_type = scope if scope else (module_name, TYPE_MODULE)
787
+ container = _enclosing_php_container(scope_node)
636
788
  triples.append(CodeTriple(
637
789
  from_entity=caller,
638
790
  from_type=caller_type,
@@ -641,6 +793,9 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
641
793
  to_type=callee_type,
642
794
  source_file=file_path,
643
795
  line_number=line,
796
+ call_receiver=receiver,
797
+ call_receiver_type=receiver_type,
798
+ owner_class=container[0] if (scope and container) else None,
644
799
  ))
645
800
 
646
801
  for node in _walk(root):
@@ -654,7 +809,9 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
654
809
  case "method_declaration":
655
810
  name = _php_name(node)
656
811
  if name:
657
- defined_in(name, TYPE_FUNCTION, _line(node))
812
+ container = _enclosing_php_container(node)
813
+ defined_in(name, TYPE_FUNCTION, _line(node), is_exported=_php_is_public(node),
814
+ owner=container[0] if container else None)
658
815
 
659
816
  case "anonymous_function" | "arrow_function":
660
817
  parent = node.parent
@@ -821,7 +978,9 @@ def _extract_php(root: Node, source: bytes, file_path: str) -> list[CodeTriple]:
821
978
  case "member_call_expression" | "nullsafe_member_call_expression":
822
979
  callee_node = _child_of_type(node, "name")
823
980
  if callee_node:
824
- calls(_text(callee_node), TYPE_FUNCTION, _line(node), node)
981
+ recv, recv_type = _php_call_receiver(node, type_env)
982
+ calls(_text(callee_node), TYPE_FUNCTION, _line(node), node,
983
+ receiver=recv, receiver_type=recv_type)
825
984
 
826
985
  case "scoped_call_expression":
827
986
  names = _children_of_type(node, "name")
@@ -856,7 +1015,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
856
1015
  # Best-effort variable → type map for receiver type inference on calls.
857
1016
  type_env = _js_type_env(root)
858
1017
 
859
- def defined(name: str, line: int) -> None:
1018
+ def defined(name: str, line: int, owner: str | None = None) -> None:
860
1019
  triples.append(CodeTriple(
861
1020
  from_entity=name,
862
1021
  from_type=TYPE_FUNCTION,
@@ -866,6 +1025,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
866
1025
  source_file=file_path,
867
1026
  line_number=line,
868
1027
  is_exported=name in exported,
1028
+ owner_class=owner,
869
1029
  ))
870
1030
 
871
1031
  for node in _walk(root):
@@ -878,7 +1038,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
878
1038
  case "method_definition":
879
1039
  name_node = _child_of_type(node, "property_identifier")
880
1040
  if name_node:
881
- defined(_text(name_node), _line(name_node))
1041
+ defined(_text(name_node), _line(name_node), owner=_enclosing_class_name(node))
882
1042
 
883
1043
  case "variable_declarator":
884
1044
  # Capture: const foo = () => ...
@@ -940,6 +1100,7 @@ def _extract_javascript(root: Node, source: bytes, file_path: str) -> list[CodeT
940
1100
  line_number=_line(node),
941
1101
  call_receiver=receiver,
942
1102
  call_receiver_type=receiver_type,
1103
+ owner_class=_enclosing_class_name(node) if scope else None,
943
1104
  ))
944
1105
 
945
1106
  return triples
@@ -1126,14 +1287,17 @@ def _type_from_annotation(ann: Node | None) -> str | None:
1126
1287
  def _js_type_env(root: Node) -> dict[str, str]:
1127
1288
  """Best-effort variable → type map for a file.
1128
1289
 
1129
- Binds from two unambiguous, local signals:
1290
+ Binds from unambiguous, local signals:
1130
1291
  - `var x = new Foo()` -> x : Foo
1131
1292
  - TS annotations `let x: Foo`,
1132
1293
  `function f(x: Foo)` -> x : Foo
1294
+ - return-type inference `const x = getThing()` where TS declares
1295
+ `function getThing(): Foo` -> x : Foo
1133
1296
 
1134
1297
  A name bound to two different types anywhere in the file is dropped as
1135
1298
  ambiguous — better to fall back to name-based handling than to mis-resolve.
1136
1299
  """
1300
+ ret_types = _js_return_types(root)
1137
1301
  types: dict[str, str] = {}
1138
1302
  ambiguous: set[str] = set()
1139
1303
 
@@ -1152,6 +1316,11 @@ def _js_type_env(root: Node) -> dict[str, str]:
1152
1316
  value = node.child_by_field_name("value")
1153
1317
  bind(name, _new_expression_type(value))
1154
1318
  bind(name, _type_from_annotation(_child_of_type(node, "type_annotation")))
1319
+ # return-type inference: const x = fn(...) / const x = obj.fn(...)
1320
+ if value is not None and value.type == "call_expression":
1321
+ callee = _extract_js_call(value)[0]
1322
+ if callee:
1323
+ bind(name, ret_types.get(callee))
1155
1324
  elif node.type in ("required_parameter", "optional_parameter"):
1156
1325
  pat = node.child_by_field_name("pattern") or _child_of_type(node, "identifier")
1157
1326
  ann = _child_of_type(node, "type_annotation")
@@ -1162,6 +1331,29 @@ def _js_type_env(root: Node) -> dict[str, str]:
1162
1331
  return types
1163
1332
 
1164
1333
 
1334
+ def _js_return_types(root: Node) -> dict[str, str]:
1335
+ """Map function name -> its TS return type (`function f(): Foo` / `f(): Foo`).
1336
+
1337
+ Same-named functions with conflicting return types are dropped."""
1338
+ rets: dict[str, str] = {}
1339
+ conflict: set[str] = set()
1340
+ for node in _walk(root):
1341
+ if node.type in ("function_declaration", "function_expression", "method_definition"):
1342
+ name_node = (_child_of_type(node, "identifier")
1343
+ or _child_of_type(node, "property_identifier"))
1344
+ rt = node.child_by_field_name("return_type")
1345
+ t = _type_from_annotation(rt) if rt is not None else None
1346
+ if name_node is not None and t:
1347
+ name = _text(name_node)
1348
+ if name in rets and rets[name] != t:
1349
+ conflict.add(name)
1350
+ else:
1351
+ rets[name] = t
1352
+ for name in conflict:
1353
+ rets.pop(name, None)
1354
+ return rets
1355
+
1356
+
1165
1357
  def _enclosing_class_name(node: Node) -> str | None:
1166
1358
  """Name of the nearest enclosing `class C { ... }`, for resolving `this`."""
1167
1359
  current = node.parent
@@ -20,7 +20,7 @@ _SKIP_DIRS = {
20
20
  }
21
21
 
22
22
  # Supported source file extensions (mirrors code_parser.SUPPORTED_EXTENSIONS)
23
- _SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".tsx", ".html", ".css", ".scss"}
23
+ _SOURCE_EXTENSIONS = {".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".css", ".scss", ".php"}
24
24
 
25
25
 
26
26
  def build_hierarchy(project_root: str, project_name: str, client) -> dict[str, str]:
@@ -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
@@ -33,6 +33,12 @@ class CodeTriple:
33
33
  # Lets dead-code analysis avoid flagging intentionally-exported symbols.
34
34
  is_exported: bool = False
35
35
 
36
+ # Class that `from_entity` belongs to — the method's class for a definition,
37
+ # or the caller's class for a call. None for module-level entities. Lets node
38
+ # ids be class-qualified so same-named methods of different classes in the
39
+ # SAME file stay distinct (Command.invoke vs Context.invoke in core.py).
40
+ owner_class: str | None = None
41
+
36
42
 
37
43
  @dataclass
38
44
  class FileNode:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codecompass-mcp"
3
- version = "2.7.0"
3
+ version = "3.0.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,63 @@ 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_php_receiver_type_and_exports():
343
+ src = (
344
+ "<?php\n"
345
+ "class Session {\n"
346
+ " public function send($r) { return 1; }\n"
347
+ " private function _helper() { return 2; }\n"
348
+ " public function getAdapter($u): HTTPAdapter { return new HTTPAdapter(); }\n"
349
+ " public function request($m) {\n"
350
+ " $adapter = $this->getAdapter($m);\n"
351
+ " $this->send($m);\n"
352
+ " return $adapter->send($m);\n"
353
+ " }\n"
354
+ "}\n"
355
+ )
356
+ triples = _parse(src, suffix=".php")
357
+ sends = [t for t in triples if t.relation_type == "CALLS" and t.to_entity == "send"]
358
+ types = {t.call_receiver_type for t in sends}
359
+ assert "Session" in types, "$this->send() typed by enclosing class"
360
+ assert "HTTPAdapter" in types, "$adapter->send() typed via getAdapter's return type"
361
+ by_name = {t.from_entity: t.is_exported for t in triples if t.relation_type == "DEFINED_IN"}
362
+ assert by_name.get("_helper") is False, "private method is not exported"
363
+ assert by_name.get("request") is True, "public method is exported"
364
+
365
+
366
+ def test_graph_grep_regex():
367
+ triples = [
368
+ CodeTriple("HTTPAdapter", "class", "DEFINED_IN", "m", "module", "adapters.py", 1),
369
+ CodeTriple("get_adapter", "function", "DEFINED_IN", "m", "module", "sessions.py", 5),
370
+ CodeTriple("send", "function", "DEFINED_IN", "m", "module", "sessions.py", 9),
371
+ ]
372
+ client = _client()
373
+ _write(client, triples)
374
+ # anchored regex on name
375
+ r = client.grep_graph("^get_", "proj", field="name")
376
+ assert {m["name"] for m in r["matches"]} == {"get_adapter"}
377
+ # alternation across name
378
+ r2 = client.grep_graph("Adapter|send", "proj", field="name")
379
+ assert {m["name"] for m in r2["matches"]} == {"HTTPAdapter", "get_adapter", "send"}
380
+ # field filter
381
+ r3 = client.grep_graph("sessions", "proj", field="file")
382
+ assert {m["name"] for m in r3["matches"]} == {"get_adapter", "send"}
383
+ # bad regex is reported, not raised
384
+ assert "error" in client.grep_graph("(", "proj")
385
+
386
+
387
+ def test_python_return_type_inference():
388
+ src = ("class Session:\n"
389
+ " def get_adapter(self, url) -> HTTPAdapter:\n"
390
+ " return self.a\n"
391
+ " def send(self, req):\n"
392
+ " adapter = self.get_adapter(req.url)\n"
393
+ " return adapter.send(req)\n")
394
+ triples = _parse_py(src)
395
+ call = next(t for t in triples if t.relation_type == "CALLS" and t.to_entity == "send")
396
+ assert call.call_receiver_type == "HTTPAdapter", "return type of get_adapter should type `adapter`"
397
+
398
+
342
399
  def test_python_public_module_function_marked_exported():
343
400
  src = "def has_app_context():\n return True\n\ndef _helper():\n pass\n"
344
401
  triples = _parse_py(src)
File without changes