codecompass-mcp 2.6.2__tar.gz → 2.7.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.6.2 → codecompass_mcp-2.7.0}/PKG-INFO +1 -1
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/SOURCES.txt +1 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/code_graph_client.py +245 -27
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/code_query_cli.py +75 -7
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/code_parser.py +452 -40
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/main.py +25 -26
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/mcp_server.py +66 -5
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/models/code_types.py +17 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/pyproject.toml +1 -1
- codecompass_mcp-2.7.0/tests/test_accuracy_fixes.py +383 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_mcp_server.py +13 -4
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/LICENSE +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/README.md +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/requires.txt +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/config.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/__init__.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/cli.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/setup.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/__init__.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/chunker.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/code_normalizer.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/description_enricher.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/entity_resolver.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/file_watcher.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/graph_writer.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/hierarchy_builder.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/reader_agent.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/source_context.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/models/__init__.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/models/types.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/setup.cfg +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_batch_impact.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_blast_radius.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_code_parser.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_description_enricher.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_ingestion.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_lit_css_parser.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_php_parser.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/utils/__init__.py +0 -0
- {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/utils/formatting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Structural code context for AI coding agents — a local code knowledge graph for blast radius, impact, deps, dead code, and flow tracing
|
|
5
5
|
Author: CodeCompass contributors
|
|
6
6
|
License: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codecompass-mcp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.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
|
|
@@ -40,6 +40,66 @@ _ENTRY_POINT_PREFIXES = ("run_", "main", "handle_", "cmd_", "test_", "setup_", "
|
|
|
40
40
|
_ENTRY_POINT_NAMES = {"main", "__main__", "handler", "lambda_handler", "application", "app"}
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _strip_code_ext(path: str) -> str:
|
|
44
|
+
for ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".php"):
|
|
45
|
+
if path.endswith(ext):
|
|
46
|
+
return path[: -len(ext)]
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _import_resolves_to(import_str: str, from_file: str, target_file: str) -> bool:
|
|
51
|
+
"""True if `import_str` written in `from_file` refers to `target_file`.
|
|
52
|
+
|
|
53
|
+
Resolves relative CommonJS/ESM specifiers (`./response`, `../lib/response`,
|
|
54
|
+
with an implicit `/index`) and dotted module names (`lib.response`) against
|
|
55
|
+
the target's extension-stripped path. Package imports (`router`, `express`)
|
|
56
|
+
never match a project file and return False.
|
|
57
|
+
"""
|
|
58
|
+
if not import_str or not target_file:
|
|
59
|
+
return False
|
|
60
|
+
target_base = _strip_code_ext(target_file).replace("\\", "/")
|
|
61
|
+
|
|
62
|
+
if import_str.startswith("."):
|
|
63
|
+
from_dir = os.path.dirname(from_file)
|
|
64
|
+
resolved = os.path.normpath(os.path.join(from_dir, import_str)).replace("\\", "/")
|
|
65
|
+
# Direct match, or the specifier points at a directory whose index file
|
|
66
|
+
# is the target (`./lib` → `lib/index.js`).
|
|
67
|
+
return resolved == target_base or target_base == f"{resolved}/index"
|
|
68
|
+
|
|
69
|
+
if "." in import_str and "/" not in import_str:
|
|
70
|
+
# Dotted module name, e.g. "lib.response" → "lib/response".
|
|
71
|
+
dotted = import_str.replace(".", "/")
|
|
72
|
+
return dotted == target_base
|
|
73
|
+
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _receiver_matches(want: str, actual: str | None, actual_type: str | None = None) -> bool:
|
|
78
|
+
"""True if a call's receiver satisfies a qualified query `want`.
|
|
79
|
+
|
|
80
|
+
Resolution order:
|
|
81
|
+
1. Inferred receiver type ("Router.handle" matches calls typed Router).
|
|
82
|
+
This is the strongest signal and is name-independent.
|
|
83
|
+
2. Self-references (`this`/`self`) — assumed to hit the queried object.
|
|
84
|
+
3. Receiver name — exact ("app") or trailing chain segment
|
|
85
|
+
("this.router" endswith ".router").
|
|
86
|
+
|
|
87
|
+
Once a receiver's *type* is known and differs from `want`, a coincidental
|
|
88
|
+
name match is rejected — a `new Router()` call never satisfies "app.handle".
|
|
89
|
+
"""
|
|
90
|
+
if actual_type is not None and actual_type == want:
|
|
91
|
+
return True
|
|
92
|
+
if actual in ("this", "self"):
|
|
93
|
+
return True
|
|
94
|
+
if actual_type is not None and actual_type != want:
|
|
95
|
+
return False
|
|
96
|
+
if actual is None:
|
|
97
|
+
return False
|
|
98
|
+
if actual == want:
|
|
99
|
+
return True
|
|
100
|
+
return actual.endswith("." + want)
|
|
101
|
+
|
|
102
|
+
|
|
43
103
|
def _looks_like_entry_point(name: str, entity_type: str) -> bool:
|
|
44
104
|
"""Heuristic: names invoked by a runtime/dispatcher, not by static calls.
|
|
45
105
|
|
|
@@ -48,6 +108,10 @@ def _looks_like_entry_point(name: str, entity_type: str) -> bool:
|
|
|
48
108
|
"""
|
|
49
109
|
if entity_type == "module":
|
|
50
110
|
return True
|
|
111
|
+
# Dunder methods (`__call__`, `__get__`, `__enter__`, …) are invoked by the
|
|
112
|
+
# language runtime / protocols, never by an explicit in-repo call site.
|
|
113
|
+
if name.startswith("__") and name.endswith("__") and len(name) > 4:
|
|
114
|
+
return True
|
|
51
115
|
lowered = name.lower()
|
|
52
116
|
if lowered in _ENTRY_POINT_NAMES:
|
|
53
117
|
return True
|
|
@@ -142,6 +206,10 @@ class LocalGraphClient:
|
|
|
142
206
|
"project": project,
|
|
143
207
|
"file": triple.source_file,
|
|
144
208
|
})
|
|
209
|
+
# A symbol is public if any of its definitions is exported. Never let a
|
|
210
|
+
# later non-exported definition clear a flag an earlier one set.
|
|
211
|
+
if getattr(triple, "is_exported", False):
|
|
212
|
+
self.graph.nodes[from_id]["is_exported"] = True
|
|
145
213
|
|
|
146
214
|
# Upsert to entity — only set language/kind/description if not already known
|
|
147
215
|
self.graph.add_node(to_id)
|
|
@@ -156,11 +224,13 @@ class LocalGraphClient:
|
|
|
156
224
|
existing["description"] = f"{language} {triple.to_type}"
|
|
157
225
|
|
|
158
226
|
self.graph.add_edge(
|
|
159
|
-
from_id,
|
|
160
|
-
to_id,
|
|
161
|
-
type=rel_type,
|
|
162
|
-
source_file=triple.source_file,
|
|
163
|
-
line=triple.line_number,
|
|
227
|
+
from_id,
|
|
228
|
+
to_id,
|
|
229
|
+
type=rel_type,
|
|
230
|
+
source_file=triple.source_file,
|
|
231
|
+
line=triple.line_number,
|
|
232
|
+
receiver=getattr(triple, "call_receiver", None),
|
|
233
|
+
receiver_type=getattr(triple, "call_receiver_type", None),
|
|
164
234
|
created_at=_now()
|
|
165
235
|
)
|
|
166
236
|
|
|
@@ -221,41 +291,161 @@ class LocalGraphClient:
|
|
|
221
291
|
# ------------------------------------------------------------------
|
|
222
292
|
|
|
223
293
|
def find_callers(self, entity_name: str, project: str, max_hops: int = 3) -> list[dict]:
|
|
224
|
-
"""Return everything that calls/uses/references entity_name (reverse traversal).
|
|
225
|
-
|
|
294
|
+
"""Return everything that calls/uses/references entity_name (reverse traversal).
|
|
295
|
+
|
|
296
|
+
`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.
|
|
304
|
+
"""
|
|
305
|
+
want_receiver = None
|
|
306
|
+
lookup_name = entity_name
|
|
307
|
+
if "." in entity_name:
|
|
308
|
+
want_receiver, lookup_name = entity_name.rsplit(".", 1)
|
|
309
|
+
|
|
310
|
+
target_id = f"{project}:{lookup_name.lower()}"
|
|
226
311
|
if target_id not in self.graph:
|
|
227
312
|
return []
|
|
228
313
|
|
|
229
314
|
results = []
|
|
230
315
|
visited = {target_id}
|
|
231
316
|
queue = [(target_id, 0)]
|
|
232
|
-
|
|
317
|
+
|
|
233
318
|
while queue:
|
|
234
319
|
current_id, depth = queue.pop(0)
|
|
235
320
|
if depth >= max_hops:
|
|
236
321
|
continue
|
|
237
|
-
|
|
322
|
+
|
|
238
323
|
for pred in self.graph.predecessors(current_id):
|
|
239
324
|
edges = self.graph.get_edge_data(pred, current_id)
|
|
240
|
-
|
|
241
|
-
edge
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
325
|
+
relevant_edges = [
|
|
326
|
+
edge for edge in edges.values()
|
|
327
|
+
if edge.get("type") in {"CALLS", "USES_VAR", "REFERENCES"}
|
|
328
|
+
]
|
|
329
|
+
if not relevant_edges or pred in visited:
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
# Only apply receiver filtering to the direct (depth-0) edge into
|
|
333
|
+
# the queried symbol; deeper hops are transitive callers.
|
|
334
|
+
if want_receiver is not None and current_id == target_id:
|
|
335
|
+
if not any(
|
|
336
|
+
_receiver_matches(want_receiver, edge.get("receiver"),
|
|
337
|
+
edge.get("receiver_type"))
|
|
338
|
+
for edge in relevant_edges
|
|
339
|
+
):
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
node = self.graph.nodes[pred]
|
|
343
|
+
if node.get("type") == "Entity":
|
|
344
|
+
receiver = next(
|
|
345
|
+
(e.get("receiver") for e in relevant_edges if e.get("receiver")),
|
|
346
|
+
None,
|
|
347
|
+
)
|
|
348
|
+
receiver_type = next(
|
|
349
|
+
(e.get("receiver_type") for e in relevant_edges if e.get("receiver_type")),
|
|
350
|
+
None,
|
|
351
|
+
)
|
|
352
|
+
results.append({
|
|
353
|
+
"caller_name": node.get("name"),
|
|
354
|
+
"caller_type": node.get("entity_type"),
|
|
355
|
+
"caller_file": node.get("file"),
|
|
356
|
+
"receiver": receiver,
|
|
357
|
+
"receiver_type": receiver_type,
|
|
358
|
+
"depth": depth + 1
|
|
359
|
+
})
|
|
360
|
+
visited.add(pred)
|
|
361
|
+
queue.append((pred, depth + 1))
|
|
362
|
+
|
|
257
363
|
return sorted(results, key=lambda x: x["depth"])
|
|
258
364
|
|
|
365
|
+
def search_entities(self, query: str, project: str, limit: int = 30,
|
|
366
|
+
kind: str | None = None) -> list[dict]:
|
|
367
|
+
"""Find graph entities matching a keyword — the way in when you don't yet
|
|
368
|
+
know a symbol name.
|
|
369
|
+
|
|
370
|
+
Matches the query (case-insensitive) against each entity's name, file
|
|
371
|
+
path, and description. Any term may match (OR), and entities matching
|
|
372
|
+
more terms — and matching in the name rather than file/description — rank
|
|
373
|
+
highest, so the most on-point symbols come first.
|
|
374
|
+
Optional `kind` filters by entity_type (e.g. "function", "class").
|
|
375
|
+
Returns a lean list (name/kind/file/entity_type) to then feed into
|
|
376
|
+
impact/flow/deps.
|
|
377
|
+
"""
|
|
378
|
+
terms = [t for t in query.lower().split() if t]
|
|
379
|
+
if not terms:
|
|
380
|
+
return []
|
|
381
|
+
|
|
382
|
+
scored = []
|
|
383
|
+
for _id, a in self.graph.nodes(data=True):
|
|
384
|
+
if a.get("type") != "Entity" or a.get("project") != project:
|
|
385
|
+
continue
|
|
386
|
+
if not a.get("file"):
|
|
387
|
+
continue
|
|
388
|
+
if kind and a.get("entity_type") != kind:
|
|
389
|
+
continue
|
|
390
|
+
name = (a.get("name") or "")
|
|
391
|
+
file = (a.get("file") or "")
|
|
392
|
+
desc = (a.get("description") or "")
|
|
393
|
+
hay_name = name.lower()
|
|
394
|
+
hay_rest = f"{file} {desc}".lower()
|
|
395
|
+
# any term may match (OR); score rewards name hits and more terms matched
|
|
396
|
+
score = 0
|
|
397
|
+
for t in terms:
|
|
398
|
+
if t == hay_name:
|
|
399
|
+
score += 100
|
|
400
|
+
elif t in hay_name:
|
|
401
|
+
score += 10
|
|
402
|
+
elif t in hay_rest:
|
|
403
|
+
score += 1
|
|
404
|
+
if score == 0:
|
|
405
|
+
continue
|
|
406
|
+
scored.append((score, {
|
|
407
|
+
"name": name,
|
|
408
|
+
"entity_type": a.get("entity_type", ""),
|
|
409
|
+
"kind": a.get("kind", ""),
|
|
410
|
+
"file": file,
|
|
411
|
+
}))
|
|
412
|
+
|
|
413
|
+
scored.sort(key=lambda s: (-s[0], s[1]["file"], s[1]["name"]))
|
|
414
|
+
return [row for _s, row in scored[:limit]]
|
|
415
|
+
|
|
416
|
+
def symbol_map(self, project: str, include_tests: bool = False) -> dict:
|
|
417
|
+
"""A compact index of the codebase for an agent to reason over.
|
|
418
|
+
|
|
419
|
+
Groups every source entity under its file as `{file: [names...]}` —
|
|
420
|
+
names only, no depth/kind/description bloat — so the whole map fits in a
|
|
421
|
+
few thousand tokens. The agent reads it once and uses its own judgment to
|
|
422
|
+
pick what's relevant to a vague task ("where would caching go?"), then
|
|
423
|
+
drills in with impact/flow/deps. This is the semantic-discovery entry
|
|
424
|
+
point that keyword `search` can't be: an LLM knows caching lives near the
|
|
425
|
+
request handler and response path even when nothing is named "cache".
|
|
426
|
+
|
|
427
|
+
`include_tests=False` (default) drops test/example files so the map shows
|
|
428
|
+
the actual implementation surface.
|
|
429
|
+
"""
|
|
430
|
+
by_file: dict[str, list[str]] = {}
|
|
431
|
+
for _id, a in self.graph.nodes(data=True):
|
|
432
|
+
if a.get("type") != "Entity" or a.get("project") != project:
|
|
433
|
+
continue
|
|
434
|
+
f = a.get("file")
|
|
435
|
+
if not f:
|
|
436
|
+
continue
|
|
437
|
+
if not include_tests and ("test/" in f or "test\\" in f
|
|
438
|
+
or f.startswith("test") or "example" in f):
|
|
439
|
+
continue
|
|
440
|
+
name = a.get("name")
|
|
441
|
+
if name:
|
|
442
|
+
by_file.setdefault(f, [])
|
|
443
|
+
if name not in by_file[f]:
|
|
444
|
+
by_file[f].append(name)
|
|
445
|
+
for f in by_file:
|
|
446
|
+
by_file[f].sort()
|
|
447
|
+
return dict(sorted(by_file.items()))
|
|
448
|
+
|
|
259
449
|
def find_dependencies(self, file_path: str, project: str, max_hops: int = 3) -> list[dict]:
|
|
260
450
|
"""Return all modules imported (directly or transitively) by file_path."""
|
|
261
451
|
file_node = next((n for n, attr in self.graph.nodes(data=True)
|
|
@@ -469,7 +659,9 @@ class LocalGraphClient:
|
|
|
469
659
|
"entity_type": attr.get("entity_type", ""),
|
|
470
660
|
"file": attr.get("file", ""),
|
|
471
661
|
}
|
|
472
|
-
|
|
662
|
+
# Exported symbols and name-heuristic entry points are public API:
|
|
663
|
+
# having no in-repo caller is expected, not evidence of dead code.
|
|
664
|
+
if attr.get("is_exported") or _looks_like_entry_point(name, attr.get("entity_type", "")):
|
|
473
665
|
maybe_entry.append(entry)
|
|
474
666
|
else:
|
|
475
667
|
dead.append(entry)
|
|
@@ -539,8 +731,34 @@ class LocalGraphClient:
|
|
|
539
731
|
visited.add(succ)
|
|
540
732
|
queue.append((succ, depth + 1))
|
|
541
733
|
|
|
734
|
+
# Files that import the target are affected when it changes, but that is
|
|
735
|
+
# a reverse edge the forward traversal above never follows. Add direct
|
|
736
|
+
# importers explicitly so `require('./target')` dependents are surfaced.
|
|
737
|
+
if target_file:
|
|
738
|
+
seen_files = {r["file"] for r in results}
|
|
739
|
+
for importer in self._direct_importers(target_file):
|
|
740
|
+
if importer != target_file and importer not in seen_files:
|
|
741
|
+
results.append({"file": importer, "edge_type": "IMPORTS", "hops": 1})
|
|
742
|
+
seen_files.add(importer)
|
|
743
|
+
|
|
542
744
|
return results, target_file
|
|
543
745
|
|
|
746
|
+
def _direct_importers(self, target_file: str) -> list[str]:
|
|
747
|
+
"""Source files with an IMPORTS edge whose module string resolves to
|
|
748
|
+
target_file. Handles both relative (`./response`) and dotted-module
|
|
749
|
+
(`lib.response`) import spellings."""
|
|
750
|
+
importers: list[str] = []
|
|
751
|
+
for _u, v, data in self.graph.edges(data=True):
|
|
752
|
+
if data.get("type") != "IMPORTS":
|
|
753
|
+
continue
|
|
754
|
+
from_file = data.get("source_file")
|
|
755
|
+
if not from_file:
|
|
756
|
+
continue
|
|
757
|
+
import_str = self.graph.nodes[v].get("name", "")
|
|
758
|
+
if _import_resolves_to(import_str, from_file, target_file):
|
|
759
|
+
importers.append(from_file)
|
|
760
|
+
return sorted(set(importers))
|
|
761
|
+
|
|
544
762
|
def get_client(project_path: str) -> LocalGraphClient:
|
|
545
763
|
"""Return a LocalGraphClient for the project at project_path."""
|
|
546
764
|
storage_path = os.path.join(project_path, ".codecompass", "graph.json")
|
|
@@ -51,6 +51,27 @@ 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_search(query: str, repo_path: str, project: str, limit: int = 30,
|
|
55
|
+
kind: str | None = None) -> dict:
|
|
56
|
+
"""Search the graph for entities matching a keyword — the discovery entry point."""
|
|
57
|
+
client = get_client(repo_path)
|
|
58
|
+
try:
|
|
59
|
+
rows = client.search_entities(query, project, limit=limit, kind=kind)
|
|
60
|
+
finally:
|
|
61
|
+
client.close()
|
|
62
|
+
return {"query": query, "matches": rows, "count": len(rows)}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch_map(repo_path: str, project: str, include_tests: bool = False) -> dict:
|
|
66
|
+
"""Compact `{file: [symbols]}` index for an agent to reason over during discovery."""
|
|
67
|
+
client = get_client(repo_path)
|
|
68
|
+
try:
|
|
69
|
+
m = client.symbol_map(project, include_tests=include_tests)
|
|
70
|
+
finally:
|
|
71
|
+
client.close()
|
|
72
|
+
return {"project": project, "files": m, "file_count": len(m)}
|
|
73
|
+
|
|
74
|
+
|
|
54
75
|
def fetch_deps(file_path: str, repo_path: str, project: str, max_hops: int = DEFAULT_HOPS) -> dict:
|
|
55
76
|
"""Return what file_path imports as structured data."""
|
|
56
77
|
client = get_client(repo_path)
|
|
@@ -192,8 +213,54 @@ def fetch_tree(repo_path: str, project: str) -> dict:
|
|
|
192
213
|
|
|
193
214
|
|
|
194
215
|
def fetch_flow(start_name: str, repo_path: str, project: str, max_hops: int = DEFAULT_HOPS,
|
|
195
|
-
include_external: bool = False
|
|
196
|
-
"""
|
|
216
|
+
include_external: bool = False) -> dict:
|
|
217
|
+
"""Lean flow trace for an agent: just the call structure it needs to navigate.
|
|
218
|
+
|
|
219
|
+
Returns each node's name/kind/file/depth and each edge's from/to/type/order/
|
|
220
|
+
line — NO embedded source, docstrings, narration, or rendered image. Use
|
|
221
|
+
fetch_flow_summary for a human-facing walkthrough (mermaid + narration).
|
|
222
|
+
"""
|
|
223
|
+
client = get_client(repo_path)
|
|
224
|
+
try:
|
|
225
|
+
data = client.trace_flow(start_name, project, max_hops, include_external=include_external)
|
|
226
|
+
finally:
|
|
227
|
+
client.close()
|
|
228
|
+
|
|
229
|
+
nodes = data["nodes"]
|
|
230
|
+
edges = data["edges"]
|
|
231
|
+
if not nodes:
|
|
232
|
+
return {"found": False, "entry_point": start_name, "nodes": [], "edges": []}
|
|
233
|
+
|
|
234
|
+
edges = _order_edges(edges, project, start_name)
|
|
235
|
+
lean_nodes = [
|
|
236
|
+
{"id": n["id"], "name": n["name"], "kind": n.get("kind", ""),
|
|
237
|
+
"file": n.get("file", ""), "depth": n["depth"]}
|
|
238
|
+
for n in sorted(nodes, key=lambda n: n["depth"])
|
|
239
|
+
]
|
|
240
|
+
lean_edges = [
|
|
241
|
+
{"from": e["from"], "to": e["to"], "type": e["type"],
|
|
242
|
+
"order": e.get("order"), "line": e.get("line")}
|
|
243
|
+
for e in edges
|
|
244
|
+
]
|
|
245
|
+
return {
|
|
246
|
+
"found": True,
|
|
247
|
+
"entry_point": start_name,
|
|
248
|
+
"node_count": len(nodes),
|
|
249
|
+
"edge_count": len(edges),
|
|
250
|
+
"nodes": lean_nodes,
|
|
251
|
+
"edges": lean_edges,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def fetch_flow_summary(start_name: str, repo_path: str, project: str, max_hops: int = DEFAULT_HOPS,
|
|
256
|
+
include_external: bool = False, fmt: str = "mermaid") -> dict:
|
|
257
|
+
"""Human-facing flow walkthrough: the trace plus a rendered narration.
|
|
258
|
+
|
|
259
|
+
Default format is "mermaid" (a Markdown flowchart + prose narration). "json"
|
|
260
|
+
additionally embeds each function's signature, docstring, and source snippet;
|
|
261
|
+
"drawio" renders a diagram. Heavier than fetch_flow — use when a person needs
|
|
262
|
+
to read the flow, not when an agent just needs the structure.
|
|
263
|
+
"""
|
|
197
264
|
client = get_client(repo_path)
|
|
198
265
|
try:
|
|
199
266
|
data = client.trace_flow(start_name, project, max_hops, include_external=include_external)
|
|
@@ -208,10 +275,11 @@ def fetch_flow(start_name: str, repo_path: str, project: str, max_hops: int = DE
|
|
|
208
275
|
edges = _order_edges(edges, project, start_name)
|
|
209
276
|
if fmt == "json":
|
|
210
277
|
content = json.dumps(_build_flow_json(nodes, edges, project, start_name, repo_path), indent=2)
|
|
211
|
-
elif fmt == "
|
|
212
|
-
content = _build_mermaid(nodes, edges, project, start_name)
|
|
213
|
-
else:
|
|
278
|
+
elif fmt == "drawio":
|
|
214
279
|
content = _build_drawio(nodes, edges, project, start_name)
|
|
280
|
+
else:
|
|
281
|
+
fmt = "mermaid"
|
|
282
|
+
content = _build_mermaid(nodes, edges, project, start_name)
|
|
215
283
|
|
|
216
284
|
return {
|
|
217
285
|
"found": True,
|
|
@@ -542,8 +610,8 @@ def run_flow(start_name: str, repo_path: str, project: str, max_hops: int = DEFA
|
|
|
542
610
|
json - structured trace enriched with signatures, docstrings, and
|
|
543
611
|
source snippets so an agent can narrate the data flow
|
|
544
612
|
"""
|
|
545
|
-
data =
|
|
546
|
-
|
|
613
|
+
data = fetch_flow_summary(start_name, repo_path, project, max_hops,
|
|
614
|
+
include_external=include_external, fmt=fmt)
|
|
547
615
|
|
|
548
616
|
if not data["found"]:
|
|
549
617
|
print(f"No flow found from '{start_name}' within {max_hops} hops.")
|