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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- 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}"
|