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.
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/PKG-INFO +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/code_graph_client.py +225 -49
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/code_query_cli.py +29 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/code_parser.py +202 -10
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/hierarchy_builder.py +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/main.py +44 -49
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/mcp_server.py +18 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/models/code_types.py +6 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/pyproject.toml +1 -1
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_accuracy_fixes.py +57 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/LICENSE +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/README.md +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/SOURCES.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/requires.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/config.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/cli.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/graph/setup.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/chunker.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/code_normalizer.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/description_enricher.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/entity_resolver.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/file_watcher.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/graph_writer.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/reader_agent.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/ingestion/source_context.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/models/__init__.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/models/types.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/setup.cfg +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_batch_impact.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_blast_radius.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_code_parser.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_description_enricher.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_ingestion.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_lit_css_parser.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_mcp_server.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/tests/test_php_parser.py +0 -0
- {codecompass_mcp-2.7.0 → codecompass_mcp-3.0.0}/utils/__init__.py +0 -0
- {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:
|
|
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:
|
|
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
|
|
190
|
-
|
|
191
|
-
to_id
|
|
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
|
-
|
|
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").
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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,
|
|
405
|
+
want_receiver, _ = entity_name.rsplit(".", 1)
|
|
309
406
|
|
|
310
|
-
|
|
311
|
-
if
|
|
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 =
|
|
316
|
-
queue = [(
|
|
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
|
|
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
|
-
|
|
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
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
510
|
-
if
|
|
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
|
-
|
|
567
|
-
if
|
|
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
|
-
|
|
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
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
384
|
-
annotations (`x: Foo`, `def f(x: Foo)`)
|
|
385
|
-
is
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
|
@@ -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 = "
|
|
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
|
|
File without changes
|
|
File without changes
|
{codecompass_mcp-2.7.0 → codecompass_mcp-3.0.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
|