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.
Files changed (44) hide show
  1. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/PKG-INFO +1 -1
  2. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/PKG-INFO +1 -1
  3. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/SOURCES.txt +1 -0
  4. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/code_graph_client.py +245 -27
  5. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/code_query_cli.py +75 -7
  6. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/code_parser.py +452 -40
  7. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/main.py +25 -26
  8. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/mcp_server.py +66 -5
  9. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/models/code_types.py +17 -0
  10. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/pyproject.toml +1 -1
  11. codecompass_mcp-2.7.0/tests/test_accuracy_fixes.py +383 -0
  12. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_mcp_server.py +13 -4
  13. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/LICENSE +0 -0
  14. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/README.md +0 -0
  15. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/dependency_links.txt +0 -0
  16. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/entry_points.txt +0 -0
  17. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/requires.txt +0 -0
  18. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/codecompass_mcp.egg-info/top_level.txt +0 -0
  19. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/config.py +0 -0
  20. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/__init__.py +0 -0
  21. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/cli.py +0 -0
  22. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/graph/setup.py +0 -0
  23. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/__init__.py +0 -0
  24. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/chunker.py +0 -0
  25. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/code_normalizer.py +0 -0
  26. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/description_enricher.py +0 -0
  27. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/entity_resolver.py +0 -0
  28. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/file_watcher.py +0 -0
  29. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/graph_writer.py +0 -0
  30. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/hierarchy_builder.py +0 -0
  31. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/reader_agent.py +0 -0
  32. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/ingestion/source_context.py +0 -0
  33. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/models/__init__.py +0 -0
  34. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/models/types.py +0 -0
  35. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/setup.cfg +0 -0
  36. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_batch_impact.py +0 -0
  37. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_blast_radius.py +0 -0
  38. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_code_parser.py +0 -0
  39. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_description_enricher.py +0 -0
  40. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_ingestion.py +0 -0
  41. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_lit_css_parser.py +0 -0
  42. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/tests/test_php_parser.py +0 -0
  43. {codecompass_mcp-2.6.2 → codecompass_mcp-2.7.0}/utils/__init__.py +0 -0
  44. {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.6.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.6.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
@@ -29,6 +29,7 @@ ingestion/source_context.py
29
29
  models/__init__.py
30
30
  models/code_types.py
31
31
  models/types.py
32
+ tests/test_accuracy_fixes.py
32
33
  tests/test_batch_impact.py
33
34
  tests/test_blast_radius.py
34
35
  tests/test_code_parser.py
@@ -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
- target_id = f"{project}:{entity_name.lower()}"
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
- is_relevant = any(
241
- edge.get("type") in {"CALLS", "USES_VAR", "REFERENCES"}
242
- for edge in edges.values()
243
- )
244
-
245
- if is_relevant and pred not in visited:
246
- node = self.graph.nodes[pred]
247
- if node.get("type") == "Entity":
248
- results.append({
249
- "caller_name": node.get("name"),
250
- "caller_type": node.get("entity_type"),
251
- "caller_file": node.get("file"),
252
- "depth": depth + 1
253
- })
254
- visited.add(pred)
255
- queue.append((pred, depth + 1))
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
- if _looks_like_entry_point(name, attr.get("entity_type", "")):
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, fmt: str = "drawio") -> dict:
196
- """Return flow trace data and rendered content for the requested format."""
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 == "mermaid":
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 = fetch_flow(start_name, repo_path, project, max_hops,
546
- include_external=include_external, fmt=fmt)
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.")