codegraph-brain 0.6.0__py3-none-any.whl

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 (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
cgis/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Top-level package for Cgis app."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ __app_name__ = "cgis"
6
+
7
+ try:
8
+ __version__ = version("codegraph-brain")
9
+ except PackageNotFoundError: # source checkout without an install
10
+ __version__ = "0.0.0+dev"
cgis/__main__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Cgis app entry point script."""
2
+
3
+ import logging
4
+
5
+ from cgis import __app_name__, cli
6
+
7
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
8
+
9
+
10
+ def main() -> None:
11
+ """Launch the CGIS CLI application."""
12
+ cli.app(prog_name=__app_name__)
13
+
14
+
15
+ if __name__ == "__main__": # pragma: no cover
16
+ main()
cgis/api/.gitkeep ADDED
File without changes
cgis/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CGIS MCP API package — exposes the code graph as agentic tools via the MCP server."""
cgis/api/mcp_server.py ADDED
@@ -0,0 +1,550 @@
1
+ """MCP Server — exposes cgis graph operations as agentic tools.
2
+
3
+ STDIO transport: stdout is strictly reserved for JSON-RPC.
4
+ All logging goes to stderr via structlog.
5
+ """
6
+
7
+ import dataclasses
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import structlog
13
+ from mcp.server.mcpserver import MCPServer
14
+
15
+ from cgis.core.models import Edge, Node, NodeType
16
+ from cgis.extractors.python_extractor import PythonExtractor
17
+ from cgis.extractors.typescript_extractor import TypeScriptExtractor
18
+ from cgis.pipeline import IngestionPipeline
19
+ from cgis.query.analysis.suggest_service import report_to_dict, suggest_packages
20
+ from cgis.query.context.audit import audit_reachability
21
+ from cgis.query.context.context_service import build_context
22
+ from cgis.query.drift.drift_service import analyze_drift
23
+ from cgis.query.drift.fractal import analyze_fractal_db
24
+ from cgis.query.drift.ontology_init import propose_ontology
25
+ from cgis.query.engine import QueryEngine
26
+ from cgis.query.fqn import resolve_fqn
27
+ from cgis.query.render.graph_json import graph_to_json
28
+ from cgis.query.render.mermaid import MermaidCompiler
29
+ from cgis.query.render.metrics import DuckDBAnalyzer
30
+ from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore
31
+
32
+ print("CGIS MCP Server starting…", file=sys.stderr)
33
+
34
+ logger = structlog.getLogger(__name__)
35
+
36
+ mcp: MCPServer = MCPServer("cgis-code-graph")
37
+
38
+ _EXTRACTORS = {
39
+ ".py": PythonExtractor(),
40
+ ".ts": TypeScriptExtractor(),
41
+ ".tsx": TypeScriptExtractor(tsx=True),
42
+ }
43
+ _DEFAULT_DB = "graph.db"
44
+
45
+
46
+ def _resolution_error(fqn: str, candidates: list[str], truncated: bool = False) -> str:
47
+ """Render a not-found / ambiguous FQN error for tool output."""
48
+ if candidates:
49
+ listing = "\n".join(f"- {c}" for c in candidates)
50
+ msg = f"❌ Ambiguous FQN '{fqn}'. Candidates:\n{listing}"
51
+ if truncated:
52
+ msg += "\n… (more matches exist; refine the name)"
53
+ return msg
54
+ return f"❌ FQN not found in graph: {fqn}"
55
+
56
+
57
+ def _blank_fqn_error(fqn: str) -> str | None:
58
+ """Reject empty/whitespace FQN before touching the store (mirrors the #173 search guard)."""
59
+ if not fqn.strip():
60
+ return "❌ FQN cannot be empty or whitespace-only."
61
+ return None
62
+
63
+
64
+ def _render_subgraph(
65
+ output_format: str,
66
+ root: str,
67
+ note: str,
68
+ title: str,
69
+ nodes: list[Node],
70
+ edges: list[Edge],
71
+ ) -> str:
72
+ """Render a traversal result as a Mermaid diagram or joinable JSON (#171).
73
+
74
+ ``json`` returns the raw ``{root, nodes, edges}`` payload with real FQNs —
75
+ no markdown wrapper — so an agent can parse and combine it across calls.
76
+ ``mermaid`` (default) returns the human-readable diagram. Any other value
77
+ is an explicit error rather than a silent fallback.
78
+ """
79
+ fmt = output_format.strip().lower()
80
+ if fmt == "json":
81
+ return json.dumps(graph_to_json(root, nodes, edges), indent=2)
82
+ if fmt == "mermaid":
83
+ diagram = MermaidCompiler().compile(nodes, edges)
84
+ return f"{note}### {title} `{root}`:\n\n```mermaid\n{diagram}\n```"
85
+ return f"❌ Unknown format '{output_format}'. Use 'mermaid' or 'json'."
86
+
87
+
88
+ @mcp.tool()
89
+ def cgis_ingest(project_path: str, db_path: str = _DEFAULT_DB, full_rebuild: bool = False) -> str:
90
+ """Scan a local directory, extract all symbols, resolve links, and build the graph DB.
91
+
92
+ Use this to initialise or refresh the code knowledge graph for a project.
93
+ Paths are normalised relative to the workspace root so the database is
94
+ portable across machines.
95
+
96
+ By default the ingest is **incremental**: only changed/new files are
97
+ re-scanned, and the summary reports both what changed this run and the
98
+ whole-graph total. Set ``full_rebuild=True`` to re-scan every file and
99
+ overwrite the database from scratch — use this to drop nodes for files that
100
+ were deleted or renamed, which an incremental run leaves behind.
101
+ """
102
+ pipeline = IngestionPipeline(_EXTRACTORS)
103
+ try:
104
+ with SQLiteStore(db_path) as store:
105
+ if full_rebuild:
106
+ # Clear first, then run incrementally over an empty DB: this drops
107
+ # deleted-file nodes, repopulates files_state correctly, and runs
108
+ # uplift inside the pipeline (store provided) — all in one path.
109
+ store.clear()
110
+ _nodes, _raw, resolved = pipeline.run(project_path, store=store)
111
+ total_nodes = store.get_node_count()
112
+ total_edges = store.get_edge_count()
113
+ except Exception as exc:
114
+ return f"❌ {exc}"
115
+
116
+ mode = "full rebuild" if full_rebuild else "incremental"
117
+ logger.info(
118
+ "MCP ingest complete",
119
+ mode=mode,
120
+ total_nodes=total_nodes,
121
+ total_edges=total_edges,
122
+ db=db_path,
123
+ )
124
+ lines = [f"✅ Ingested: {project_path} (mode: {mode})"]
125
+ if not full_rebuild and not resolved:
126
+ # Incremental no-op: an empty resolved set means no files changed. Say so
127
+ # explicitly so the stable total below doesn't read as a shrunken graph (#192).
128
+ lines.append("No files changed since the last ingest.")
129
+ lines.append(f"Graph total: {total_nodes} nodes / {total_edges} edges")
130
+ lines.append(f"Graph stored in: {db_path}")
131
+ return "\n".join(lines)
132
+
133
+
134
+ @mcp.tool()
135
+ def cgis_trace_flow(
136
+ fqn: str, db_path: str = _DEFAULT_DB, depth: int = 3, output_format: str = "mermaid"
137
+ ) -> str:
138
+ """Trace the execution call-graph starting from a specific FQN downwards.
139
+
140
+ ``output_format="mermaid"`` (default) returns a human-readable diagram;
141
+ ``"json"`` returns a joinable ``{root, nodes, edges}`` payload with real
142
+ FQNs (not display hashes) for agent/CI use. Use ``cgis_ingest`` first if
143
+ the database does not exist yet.
144
+ """
145
+ if blank := _blank_fqn_error(fqn):
146
+ return blank
147
+ if not Path(db_path).exists():
148
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
149
+ try:
150
+ with SQLiteStore(db_path) as store:
151
+ res = resolve_fqn(store, fqn)
152
+ if res.resolved is None:
153
+ return _resolution_error(fqn, res.candidates, res.truncated)
154
+ nodes, edges = QueryEngine(store).get_flow_graph(res.resolved, max_depth=depth)
155
+ except Exception as exc:
156
+ return f"❌ {exc}"
157
+
158
+ note = f"> Resolved '{fqn}' → '{res.resolved}'\n\n" if res.via_suffix else ""
159
+ return _render_subgraph(output_format, res.resolved, note, "Execution flow for", nodes, edges)
160
+
161
+
162
+ @mcp.tool()
163
+ def cgis_analyze_impact(
164
+ fqn: str, db_path: str = _DEFAULT_DB, depth: int = 3, output_format: str = "mermaid"
165
+ ) -> str:
166
+ """Analyse transitive upstream callers of a specific FQN.
167
+
168
+ Answers "what breaks if I change X?". ``output_format="mermaid"`` (default)
169
+ returns a diagram; ``"json"`` returns a joinable ``{root, nodes, edges}``
170
+ payload with real FQNs — letting an agent compute set differences (e.g.
171
+ "which route handlers never reach ``verify_ownership``?") directly.
172
+ """
173
+ if blank := _blank_fqn_error(fqn):
174
+ return blank
175
+ if not Path(db_path).exists():
176
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
177
+ try:
178
+ with SQLiteStore(db_path) as store:
179
+ res = resolve_fqn(store, fqn)
180
+ if res.resolved is None:
181
+ return _resolution_error(fqn, res.candidates, res.truncated)
182
+ nodes, edges = QueryEngine(store).get_impact_graph(res.resolved, max_depth=depth)
183
+ except Exception as exc:
184
+ return f"❌ {exc}"
185
+
186
+ note = f"> Resolved '{fqn}' → '{res.resolved}'\n\n" if res.via_suffix else ""
187
+ return _render_subgraph(output_format, res.resolved, note, "Impact analysis for", nodes, edges)
188
+
189
+
190
+ @mcp.tool()
191
+ def cgis_get_structure(
192
+ fqn: str, db_path: str = _DEFAULT_DB, depth: int = 2, output_format: str = "mermaid"
193
+ ) -> str:
194
+ """Show the structural layout (CONTAINS/DECLARES) of a module or class.
195
+
196
+ Traverses only containment edges — no call-graph noise — matching the CLI
197
+ ``structure`` command. ``output_format="mermaid"`` (default) returns a
198
+ diagram of the hierarchy rooted at the given FQN; ``"json"`` returns the
199
+ joinable ``{root, nodes, edges}`` payload with real FQNs.
200
+ """
201
+ if blank := _blank_fqn_error(fqn):
202
+ return blank
203
+ if not Path(db_path).exists():
204
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
205
+ try:
206
+ with SQLiteStore(db_path) as store:
207
+ res = resolve_fqn(store, fqn)
208
+ if res.resolved is None:
209
+ return _resolution_error(fqn, res.candidates, res.truncated)
210
+ nodes, edges = QueryEngine(store).get_structural_graph(res.resolved, max_depth=depth)
211
+ except Exception as exc:
212
+ return f"❌ {exc}"
213
+
214
+ note = f"> Resolved '{fqn}' → '{res.resolved}'\n\n" if res.via_suffix else ""
215
+ return _render_subgraph(output_format, res.resolved, note, "Structure of", nodes, edges)
216
+
217
+
218
+ @mcp.tool()
219
+ def cgis_drift(
220
+ db_path: str = _DEFAULT_DB,
221
+ patterns_path: str = "docs/ontology/patterns.yaml",
222
+ max_drift: float = 0.50,
223
+ profile: str | None = None,
224
+ max_residual: float = 0.45,
225
+ ) -> str:
226
+ """Report per-domain architectural drift against declared ideal patterns.
227
+
228
+ Returns JSON: ``any_critical`` verdict, per-domain reports (each carrying a
229
+ ``fit`` block — nearest alphabet template + residual + good/weak/none band),
230
+ the observe-only quotient layer, and ``coverage`` (graph prefixes bound by no
231
+ domain). Call after ``cgis_ingest`` to learn whether your edits pushed a
232
+ domain past its drift tolerance.
233
+
234
+ ``max_drift`` is now the default tolerance only for domains that omit
235
+ ``drift_tolerance`` — it no longer caps domains that declare their own
236
+ (see #170).
237
+
238
+ ``profile``: when set, score only domains with this profile (plus
239
+ profile-less ones). Use when your patterns.yaml mixes languages but the
240
+ graph holds one language — avoids false EMPTY reports for other-language
241
+ domains that would otherwise fail the gate.
242
+
243
+ ``max_residual``: a domain whose nearest template is farther than this gets
244
+ ``fit.band = "none"`` ("no template fits") — a grab-bag module or an
245
+ alphabet gap, independent of drift tolerance (#177).
246
+ """
247
+ if not Path(db_path).exists():
248
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
249
+ if not Path(patterns_path).exists():
250
+ return f"❌ Patterns file not found: {patterns_path}"
251
+ try:
252
+ analysis = analyze_drift(
253
+ db_path,
254
+ patterns_path,
255
+ max_drift=max_drift,
256
+ profile=profile,
257
+ max_residual=max_residual,
258
+ )
259
+ payload = {
260
+ "any_critical": analysis.any_critical,
261
+ "max_drift": max_drift,
262
+ "domains": [
263
+ {**dataclasses.asdict(r), "tangle_ratio": round(r.actual.tangle_ratio, 4)}
264
+ for r in analysis.reports
265
+ ],
266
+ "quotient": [
267
+ {
268
+ **dataclasses.asdict(r),
269
+ "enforce": b.enforce,
270
+ "tangle_ratio": round(r.actual.tangle_ratio, 4),
271
+ }
272
+ for b, r in analysis.quotient
273
+ ],
274
+ "coverage": analysis.coverage,
275
+ }
276
+ return json.dumps(payload, indent=2)
277
+ except Exception as exc:
278
+ return f"❌ {exc}"
279
+
280
+
281
+ @mcp.tool()
282
+ def cgis_suggest_packages(
283
+ db_path: str = _DEFAULT_DB,
284
+ prefix: str | None = None,
285
+ with_calls: bool = False,
286
+ min_q: float = 0.35,
287
+ ) -> str:
288
+ """Suggest sub-package boundaries for a package from its dependency communities.
289
+
290
+ Returns JSON: modularity_q, divergence, direction (under/over/matched),
291
+ verdict (split/consolidate/aligned/leave/borderline/no_signal), the detected
292
+ communities (id + member files), the cross-community bridge edges (cost of
293
+ splitting), and the thresholds used. Default layer is IMPORTS; set
294
+ ``with_calls`` for the combined import+call graph. Run ``cgis_ingest`` first.
295
+
296
+ A mis-rooted graph (import targets resolve to no internal file) returns
297
+ ``no_signal`` with a diagnostic note rather than a silent clean verdict.
298
+ """
299
+ if not Path(db_path).exists():
300
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
301
+ try:
302
+ report = suggest_packages(db_path, prefix, with_calls=with_calls, min_q=min_q)
303
+ except Exception as exc:
304
+ return f"❌ Error during suggest-packages: {exc}"
305
+ return json.dumps(report_to_dict(report), indent=2)
306
+
307
+
308
+ @mcp.tool()
309
+ def cgis_validate(db_path: str = _DEFAULT_DB, threshold: float = 0.30) -> str:
310
+ """Report graph integrity as JSON: edge resolution stats + health verdict.
311
+
312
+ Check this before trusting ``cgis_analyze_impact`` output — a high
313
+ unresolved ratio means callers are missing from the graph.
314
+ """
315
+ if not Path(db_path).exists():
316
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
317
+ try:
318
+ with SQLiteStore(db_path) as store:
319
+ stats = store.get_edge_stats()
320
+ payload = {
321
+ "total": stats.total,
322
+ "resolved": stats.resolved,
323
+ "stdlib": stats.stdlib,
324
+ "external": stats.external,
325
+ "unresolved": stats.unresolved,
326
+ "unresolved_ratio": stats.unresolved_ratio,
327
+ "top_unresolved": [
328
+ [t.removeprefix(RAW_CALL_PREFIX), c] for t, c in stats.top_unresolved
329
+ ],
330
+ "threshold": threshold,
331
+ "healthy": stats.unresolved_ratio <= threshold,
332
+ }
333
+ return json.dumps(payload, indent=2)
334
+ except Exception as exc:
335
+ return f"❌ {exc}"
336
+
337
+
338
+ @mcp.tool()
339
+ def cgis_find_symbol(
340
+ query: str,
341
+ db_path: str = _DEFAULT_DB,
342
+ kind: str | None = None,
343
+ fqn_prefix: str | None = None,
344
+ limit: int = 20,
345
+ ) -> str:
346
+ """Resolve a partial symbol name to candidate FQNs (substring match, ranked).
347
+
348
+ Call this BEFORE ``cgis_trace_flow`` / ``cgis_analyze_impact`` /
349
+ ``cgis_get_structure`` when you know a short name (e.g.
350
+ ``get_reservation_prices``) but not its full FQN — it removes the
351
+ read-the-file-first guesswork. Returns JSON ``[{fqn, name, type, file,
352
+ line}]`` ranked exact > prefix > substring. ``kind`` filters by node type
353
+ (FUNCTION / METHOD / CLASS / …); ``fqn_prefix`` scopes the search.
354
+ """
355
+ if not Path(db_path).exists():
356
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
357
+ try:
358
+ # Blank/whitespace kind means "no type filter", not "match nothing".
359
+ kinds = (kind.strip().upper(),) if kind and kind.strip() else ()
360
+ with SQLiteStore(db_path) as store:
361
+ matches = store.search_nodes(query, kinds=kinds, fqn_prefix=fqn_prefix, limit=limit)
362
+ except Exception as exc:
363
+ return f"❌ {exc}"
364
+ payload = [
365
+ {
366
+ "fqn": n.id,
367
+ "name": n.name,
368
+ "type": n.type.value,
369
+ "file": n.file_path,
370
+ "line": n.start_line,
371
+ }
372
+ for n in matches
373
+ ]
374
+ return json.dumps(payload, indent=2)
375
+
376
+
377
+ @mcp.tool()
378
+ def cgis_init_ontology(
379
+ db_path: str = _DEFAULT_DB,
380
+ margin: float = 0.03,
381
+ min_nodes: int = 10,
382
+ depth: int | None = None,
383
+ ) -> str:
384
+ """Propose a starter patterns.yaml from the measured graph (read-only).
385
+
386
+ Returns the YAML text — save it yourself (e.g. to patterns.yaml), review
387
+ the proposed labels, then run ``cgis_drift`` with it. Tolerances are the
388
+ measured scores plus ``margin``: a baseline to ratchet down, not a verdict.
389
+
390
+ No files are written; the caller decides where to persist the output.
391
+ """
392
+ if not Path(db_path).exists():
393
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
394
+ try:
395
+ return propose_ontology(db_path, margin=margin, min_nodes=min_nodes, depth=depth)
396
+ except Exception as e: # translate errors to the ❌-message medium
397
+ return f"❌ Error proposing ontology: {e}"
398
+
399
+
400
+ @mcp.tool()
401
+ def cgis_context(
402
+ fqn: str, db_path: str = _DEFAULT_DB, depth: int = 1, source_root: str = ""
403
+ ) -> str:
404
+ """Compile an agent-facing GraphRAG context package for a focal FQN (#19).
405
+
406
+ Returns an XML-tagged prompt — the focal node's source, its enclosing class,
407
+ its architectural domain boundary, direct callers (upstream ripple) and
408
+ callees (downstream dependencies) — meant to be injected into your context
409
+ window in place of raw file dumps. Far more token-efficient than reading
410
+ whole files, and structured so boundaries stay unambiguous.
411
+
412
+ Use ``cgis_ingest`` first if the database does not exist. ``source_root``
413
+ locates source files on disk when the graph was ingested from a
414
+ sub-directory (e.g. ``"src"`` after ``cgis ingest ./src``); it is safe to
415
+ pass even when the stored paths already start with that segment (#228).
416
+ When no candidate exists the ``<source>`` block degrades gracefully to
417
+ "unavailable".
418
+ """
419
+ if blank := _blank_fqn_error(fqn):
420
+ return blank
421
+ if not Path(db_path).exists():
422
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
423
+ try:
424
+ with SQLiteStore(db_path) as store:
425
+ res = resolve_fqn(store, fqn)
426
+ if res.resolved is None:
427
+ return _resolution_error(fqn, res.candidates, res.truncated)
428
+ payload = build_context(store, res.resolved, depth=depth, source_root=source_root)
429
+ except Exception as exc:
430
+ return f"❌ {exc}"
431
+
432
+ note = f"> Resolved '{fqn}' → '{res.resolved}'\n\n" if res.via_suffix else ""
433
+ return note + payload
434
+
435
+
436
+ @mcp.tool()
437
+ def cgis_metrics(
438
+ db_path: str = _DEFAULT_DB, limit: int = 10, exclude: list[str] | None = None
439
+ ) -> str:
440
+ """Whole-graph architectural metrics — coupling bottlenecks + God classes (#16).
441
+
442
+ Returns JSON ``{bottlenecks, god_classes, critical}`` computed with vectorized
443
+ DuckDB aggregations over the whole graph (fan-in/fan-out coupling,
444
+ declared-member counts, PageRank) — the global "what are the hotspots?" view
445
+ that complements the node-local trace/impact/context tools. Requires the
446
+ optional ``duckdb`` extra; an unavailable dependency is reported as a normal
447
+ ❌ message.
448
+
449
+ ``exclude`` drops any node whose FQN contains one of the given dot-segments
450
+ (e.g. ``["tests"]`` removes both ``tests.*`` and ``domains.*.tests.*``) so
451
+ test/vendor scaffolding stays out of the rankings.
452
+ """
453
+ if not Path(db_path).exists():
454
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
455
+ try:
456
+ with DuckDBAnalyzer(db_path) as analyzer:
457
+ report = analyzer.architecture_report(
458
+ bottleneck_limit=limit,
459
+ god_limit=limit,
460
+ critical_limit=limit,
461
+ exclude=exclude or [],
462
+ )
463
+ except Exception as exc:
464
+ return f"❌ {exc}"
465
+
466
+ return json.dumps(report.model_dump(), indent=2)
467
+
468
+
469
+ @mcp.tool()
470
+ def cgis_audit_reachability(
471
+ target: str,
472
+ db_path: str = _DEFAULT_DB,
473
+ from_type: str | None = None,
474
+ from_prefix: str | None = None,
475
+ depth: int = 5,
476
+ ) -> str:
477
+ """Reachability/authorization audit — which sources never reach a checkpoint (#172).
478
+
479
+ The headline use is **IDOR/authz coverage**: list every route handler that does
480
+ NOT transitively reach an ownership check. Reachability follows behavioral edges
481
+ (CALLS *and* FastAPI ``Depends()`` DEPENDS_ON), so a guard wired via DI counts.
482
+
483
+ Select sources with ``from_type`` (a NodeType like ``ROUTE_HANDLER`` /
484
+ ``API_ENDPOINT`` / ``FUNCTION``) and/or ``from_prefix`` (FQN prefix) — at least
485
+ one is required. Returns JSON ``{target, covered, gaps}`` where each gap carries
486
+ ``fqn``/``file``/``line``. Generalizes to validators, event tracking, or
487
+ service-layer-boundary rules by pointing ``target`` at the required node.
488
+ """
489
+ if blank := _blank_fqn_error(target):
490
+ return blank
491
+ if not Path(db_path).exists():
492
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
493
+ # Defensive against agents passing JSON null for omitted optional params.
494
+ node_type: NodeType | None = None
495
+ if from_type and from_type.strip():
496
+ try:
497
+ node_type = NodeType(from_type.strip().upper())
498
+ except ValueError:
499
+ valid = ", ".join(t.value for t in NodeType)
500
+ return f"❌ Unknown node type '{from_type}'. Valid: {valid}"
501
+ prefix = (from_prefix or "").strip() or None
502
+ if node_type is None and prefix is None:
503
+ return "❌ Provide from_type or from_prefix to select audited sources."
504
+ try:
505
+ with SQLiteStore(db_path) as store:
506
+ res = resolve_fqn(store, target)
507
+ if res.resolved is None:
508
+ return _resolution_error(target, res.candidates, res.truncated)
509
+ result = audit_reachability(
510
+ store,
511
+ target_fqn=res.resolved,
512
+ from_type=node_type,
513
+ from_prefix=prefix,
514
+ max_depth=depth,
515
+ )
516
+ except Exception as exc:
517
+ return f"❌ {exc}"
518
+
519
+ note = f"> Resolved '{target}' → '{res.resolved}'\n\n" if res.via_suffix else ""
520
+ return note + json.dumps(dataclasses.asdict(result), indent=2)
521
+
522
+
523
+ @mcp.tool()
524
+ def cgis_fractal(db_path: str = _DEFAULT_DB) -> str:
525
+ """Report the motif census across the repository's structural tiers.
526
+
527
+ Coarsens the graph along its own structure — symbol, class, module, then
528
+ directory levels trimmed from the leaf end — and measures the 13-triad
529
+ census at every rung. Returns JSON: one entry per layer (IMPORTS, CALLS)
530
+ with the full per-rung curve (groups, triads, entropy in bits, dominant
531
+ motif, tangle ratio) and the fit.
532
+
533
+ ``verdict`` is the sign of ``slope`` (entropy bits per halving of the group
534
+ count) outside a ``2 * std_error`` dead-band: ``hierarchical`` means
535
+ coarsening ADDS motif diversity, ``flat`` means it destroys it,
536
+ ``scale_invariant`` means the mix is the same at every scale, and
537
+ ``no_signal`` means fewer than three rungs carried enough triads to fit.
538
+
539
+ Read the curve, not just the verdict — the fit is a lossy summary of a
540
+ non-linear curve. Observe-only: this tool enforces nothing and no gate
541
+ reads it. Call after ``cgis_ingest``.
542
+ """
543
+ if not Path(db_path).exists():
544
+ return f"❌ Database not found at: {db_path}. Run cgis_ingest first."
545
+ try:
546
+ reports = analyze_fractal_db(db_path)
547
+ payload = {"layers": [dataclasses.asdict(r) for r in reports]}
548
+ return json.dumps(payload, indent=2)
549
+ except Exception as exc:
550
+ return f"❌ {exc}"