adl-toolkit 0.0.0-stage → 0.1.1
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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,2849 @@
|
|
|
1
|
+
"""MCP request handlers.
|
|
2
|
+
|
|
3
|
+
Implements the business logic for each MCP tool/endpoint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import functools
|
|
14
|
+
from collections import Counter, deque
|
|
15
|
+
from typing import List, Dict, Any, NamedTuple, Optional, Tuple
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from adl_drift.server.protocol import (
|
|
19
|
+
MCPRequest, MCPResponse, MCPContextItem,
|
|
20
|
+
MCPInitializeResponse, MCPToolDefinition
|
|
21
|
+
)
|
|
22
|
+
from adl_drift.detectors.base import DetectorRegistry
|
|
23
|
+
from adl_drift.lang.registry import backend_for_file, get_backend
|
|
24
|
+
from adl_drift.graph.builder import CallGraphBuilder, GRAPH_SCHEMA_VERSION
|
|
25
|
+
from adl_drift.graph.node import GraphNode
|
|
26
|
+
from adl_drift.graph.edges import EdgeType
|
|
27
|
+
from adl_drift.graph.cluster import detect_communities
|
|
28
|
+
from adl_drift.cortex.memory import CortexMemory, GRAPH_CACHE_RACY_WINDOW_SECONDS
|
|
29
|
+
from adl_drift.cortex.patterns import PatternManager
|
|
30
|
+
from adl_drift.cortex.telemetry import TelemetryRecorder
|
|
31
|
+
from adl_drift.utils.types import SCAN_FILE_ERRORS, Pattern, PatternCategory
|
|
32
|
+
from adl_drift.scanner.incremental import IncrementalScanner, java_incremental_scope
|
|
33
|
+
from adl_drift.scanner.parallel import parallel_analyze, should_parallelize
|
|
34
|
+
from adl_drift.scanner.discovery import (
|
|
35
|
+
CachedDiscovery, discover_file_groups, discover_files, select_backend,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _GraphCacheEntry(NamedTuple):
|
|
40
|
+
"""In-memory graph of the last ``_build_full_graph`` call.
|
|
41
|
+
|
|
42
|
+
``fingerprint`` is ``CortexMemory._source_state()``'s stat-based digest
|
|
43
|
+
(relative path, size, mtime_ns of every discovered source) — the same
|
|
44
|
+
value ``load_graph`` compares to decide whether the persisted graph is
|
|
45
|
+
still valid. ``taken_at`` is the wall-clock time just before that stat
|
|
46
|
+
walk started.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
fingerprint: str
|
|
50
|
+
taken_at: float
|
|
51
|
+
builder: CallGraphBuilder
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MCPHandlers:
|
|
55
|
+
"""Handles all MCP tool invocations.
|
|
56
|
+
|
|
57
|
+
Read tools:
|
|
58
|
+
- drift_scan: Scan a file or project (updates the persisted graph)
|
|
59
|
+
- drift_query: Query patterns by category
|
|
60
|
+
- drift_search: Symbol search (ranked: exact id > exact name > substring)
|
|
61
|
+
- drift_node: Node detail + source snippet + edge counts
|
|
62
|
+
- drift_graph: Get call graph for a symbol
|
|
63
|
+
- drift_callers / drift_callees: Depth-bounded dependency walks
|
|
64
|
+
- drift_impact: Blast-radius analysis
|
|
65
|
+
- drift_path: Shortest path between two symbols
|
|
66
|
+
- drift_status: Index freshness + graph health
|
|
67
|
+
- drift_communities: Louvain module clusters
|
|
68
|
+
- drift_why: Ask "why" about a pattern (tribal knowledge)
|
|
69
|
+
- drift_context: Graph-ranked context for an AI query
|
|
70
|
+
|
|
71
|
+
Write tools (append/flag-only DB writes — no filesystem):
|
|
72
|
+
- drift_learn: Record tribal knowledge
|
|
73
|
+
- drift_approve: Approve a detected pattern
|
|
74
|
+
|
|
75
|
+
List-returning tools accept ``limit`` and ``max_tokens`` (≈4 chars/token
|
|
76
|
+
on the JSON payload); trimmed responses carry a ``truncated`` count.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# P2.11 Part 3: write-tool input caps (content is stored verbatim in
|
|
80
|
+
# SQLite; these only bound abuse, not expression).
|
|
81
|
+
MAX_TOPIC_LEN = 200
|
|
82
|
+
MAX_CONTENT_LEN = 10_000
|
|
83
|
+
MAX_TAGS = 20
|
|
84
|
+
|
|
85
|
+
def __init__(self, project_path: Path, db_path: Optional[Path] = None):
|
|
86
|
+
self.project_path = project_path
|
|
87
|
+
self.project_root = CallGraphBuilder.find_project_root(project_path) or project_path
|
|
88
|
+
self.memory = (
|
|
89
|
+
CortexMemory(db_path)
|
|
90
|
+
if db_path is not None
|
|
91
|
+
else CortexMemory.for_project(self.project_root)
|
|
92
|
+
)
|
|
93
|
+
self.pattern_manager = PatternManager(self.memory)
|
|
94
|
+
# P2.5: per-file errors from the most recent _build_full_graph().
|
|
95
|
+
self.last_graph_errors: List[Dict[str, str]] = []
|
|
96
|
+
# MCP_GRAPH_CACHE_PLAN.md Lớp 1: graph returned by the previous
|
|
97
|
+
# _build_full_graph() call, reused while the source fingerprint is
|
|
98
|
+
# unchanged. Dropped whenever this process persists a graph through
|
|
99
|
+
# another path (_scan_directory) so that graph is not shadowed.
|
|
100
|
+
self._graph_cache: Optional[_GraphCacheEntry] = None
|
|
101
|
+
# MCP_INCREMENTAL_REBUILD_PLAN.md Lớp 1: how the last _build_full_graph()
|
|
102
|
+
# produced its graph — same shape as DriftCommands.last_graph_update
|
|
103
|
+
# ({"mode", "invalidated_files", "reason"}); mode is one of "cached",
|
|
104
|
+
# "dependency_scoped" (Java body-only edit) or "full_rebuild".
|
|
105
|
+
self.last_graph_update: Dict[str, Any] = {}
|
|
106
|
+
self._discovery_caches: Dict[Path, CachedDiscovery] = {}
|
|
107
|
+
|
|
108
|
+
self.backend = None
|
|
109
|
+
self._configure_backend(project_path)
|
|
110
|
+
|
|
111
|
+
# Initialize detector registry
|
|
112
|
+
# P1.11: route detectors reused by _build_full_graph (which skips full
|
|
113
|
+
# detection) so route handlers are still marked as entry points.
|
|
114
|
+
self._route_detectors = self.registry.get_for_category(PatternCategory.ROUTES)
|
|
115
|
+
|
|
116
|
+
# MONITOR: wrap every tool handler so each call records telemetry
|
|
117
|
+
# (tokens returned vs. read+grep baseline, confidence profile) into
|
|
118
|
+
# cortex.db for the project monitor UI. Best-effort, never fatal.
|
|
119
|
+
# Accounting lives in the shared TelemetryRecorder so the CLI path
|
|
120
|
+
# (cli/commands.py) reports the identical metric (PM.1 / PM.2).
|
|
121
|
+
self._telemetry = TelemetryRecorder(self.memory, self.project_root)
|
|
122
|
+
self._install_telemetry()
|
|
123
|
+
|
|
124
|
+
def _configure_backend(self, path: Path, backend_name: Optional[str] = None) -> None:
|
|
125
|
+
backend = get_backend(backend_name) if backend_name else select_backend(path)
|
|
126
|
+
if self.backend is not None and self.backend.name == backend.name:
|
|
127
|
+
return
|
|
128
|
+
self.backend = backend
|
|
129
|
+
self.config = backend.project_config(path)
|
|
130
|
+
self.parser = backend.parser()
|
|
131
|
+
self.extractor = backend.extractor()
|
|
132
|
+
self.registry = DetectorRegistry(backend.detectors())
|
|
133
|
+
self._route_detectors = self.registry.get_for_category(PatternCategory.ROUTES)
|
|
134
|
+
|
|
135
|
+
def _resolver_config(self):
|
|
136
|
+
return self.backend.resolver_config()
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _backend_name(file_path: Path) -> str:
|
|
140
|
+
backend = backend_for_file(file_path)
|
|
141
|
+
return backend.name if backend else "python"
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _group_paths(cls, paths: List[Path]) -> Dict[str, List[Path]]:
|
|
145
|
+
groups: Dict[str, List[Path]] = {}
|
|
146
|
+
for path in paths:
|
|
147
|
+
groups.setdefault(cls._backend_name(path), []).append(path)
|
|
148
|
+
return groups
|
|
149
|
+
|
|
150
|
+
def _file_groups(self, path: Path) -> Dict[str, List[Path]]:
|
|
151
|
+
# A no-change query otherwise re-walks the tree three times and
|
|
152
|
+
# re-parses every build file before the Lớp 1 fingerprint can hit.
|
|
153
|
+
cache = self._discovery_caches.get(path)
|
|
154
|
+
if cache is None:
|
|
155
|
+
cache = self._discovery_caches[path] = CachedDiscovery(
|
|
156
|
+
path, racy_window=GRAPH_CACHE_RACY_WINDOW_SECONDS,
|
|
157
|
+
)
|
|
158
|
+
return cache.groups()
|
|
159
|
+
|
|
160
|
+
def _parse_file(self, file_path: Path):
|
|
161
|
+
self._configure_backend(file_path, self._backend_name(file_path))
|
|
162
|
+
return self.parser.parse_file(file_path)
|
|
163
|
+
|
|
164
|
+
def _resolve_graph(
|
|
165
|
+
self,
|
|
166
|
+
builder: CallGraphBuilder,
|
|
167
|
+
parses: Dict[Path, Any],
|
|
168
|
+
results: Dict[Path, Any],
|
|
169
|
+
) -> None:
|
|
170
|
+
if not parses:
|
|
171
|
+
return
|
|
172
|
+
builder.resolve_relationships(results)
|
|
173
|
+
for backend_name in sorted({self._backend_name(path) for path in parses}):
|
|
174
|
+
self._configure_backend(self.project_path, backend_name)
|
|
175
|
+
builder.resolver_config = self._resolver_config()
|
|
176
|
+
builder._resolver = None
|
|
177
|
+
group = {
|
|
178
|
+
path: parse for path, parse in parses.items()
|
|
179
|
+
if self._backend_name(path) == backend_name
|
|
180
|
+
}
|
|
181
|
+
builder.resolve_decorator_types(group, results)
|
|
182
|
+
builder.resolve_calls(group, results)
|
|
183
|
+
builder.harvest_literal_candidates(group, results)
|
|
184
|
+
|
|
185
|
+
# ---- MONITOR telemetry --------------------------------------------------
|
|
186
|
+
|
|
187
|
+
_TELEMETRY_MAP = {
|
|
188
|
+
"drift_scan": "handle_scan",
|
|
189
|
+
"drift_query": "handle_query",
|
|
190
|
+
"drift_search": "handle_search",
|
|
191
|
+
"drift_node": "handle_node",
|
|
192
|
+
"drift_graph": "handle_graph",
|
|
193
|
+
"drift_callers": "handle_callers",
|
|
194
|
+
"drift_callees": "handle_callees",
|
|
195
|
+
"drift_impact": "handle_impact",
|
|
196
|
+
"drift_explore": "handle_explore",
|
|
197
|
+
"drift_path": "handle_path",
|
|
198
|
+
"drift_status": "handle_status",
|
|
199
|
+
"drift_communities": "handle_communities",
|
|
200
|
+
"drift_why": "handle_why",
|
|
201
|
+
"drift_context": "handle_context",
|
|
202
|
+
"drift_plan_change": "handle_plan_change",
|
|
203
|
+
"drift_verify_change": "handle_verify_change",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def _install_telemetry(self) -> None:
|
|
207
|
+
for tool, method_name in self._TELEMETRY_MAP.items():
|
|
208
|
+
original = getattr(self, method_name)
|
|
209
|
+
|
|
210
|
+
def make_wrapper(tool: str, original):
|
|
211
|
+
@functools.wraps(original)
|
|
212
|
+
def wrapper(params=None):
|
|
213
|
+
params = params or {}
|
|
214
|
+
t0 = time.perf_counter()
|
|
215
|
+
response = original(params)
|
|
216
|
+
try:
|
|
217
|
+
self._record_invocation(
|
|
218
|
+
tool, params, response,
|
|
219
|
+
(time.perf_counter() - t0) * 1000.0,
|
|
220
|
+
)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
return response
|
|
224
|
+
return wrapper
|
|
225
|
+
|
|
226
|
+
setattr(self, method_name, make_wrapper(tool, original))
|
|
227
|
+
|
|
228
|
+
def _record_invocation(self, tool: str, params: Dict[str, Any],
|
|
229
|
+
response: Any, duration_ms: float) -> None:
|
|
230
|
+
"""Estimate token savings + confidence profile for one MCP tool call."""
|
|
231
|
+
payload = response.model_dump() if hasattr(response, "model_dump") else {}
|
|
232
|
+
is_error = payload.get("error") is not None
|
|
233
|
+
result = payload.get("result")
|
|
234
|
+
body = result if result is not None else payload.get("error")
|
|
235
|
+
self._telemetry.record(
|
|
236
|
+
tool, params, body,
|
|
237
|
+
is_error=is_error, duration_ms=duration_ms, source="mcp",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _route_patterns(self, parse_result: Any, file_path: Path) -> List[Pattern]:
|
|
241
|
+
"""P1.11: ROUTES-only detection for the graph-build path."""
|
|
242
|
+
patterns: List[Pattern] = []
|
|
243
|
+
for det in self._route_detectors:
|
|
244
|
+
try:
|
|
245
|
+
patterns.extend(det.detect(parse_result.tree, parse_result.source_bytes, file_path))
|
|
246
|
+
except Exception:
|
|
247
|
+
continue
|
|
248
|
+
return patterns
|
|
249
|
+
|
|
250
|
+
def initialize(self) -> MCPInitializeResponse:
|
|
251
|
+
"""Return server capabilities."""
|
|
252
|
+
return MCPInitializeResponse(
|
|
253
|
+
server_info={
|
|
254
|
+
"name": "adl-drift",
|
|
255
|
+
"version": "0.1.0",
|
|
256
|
+
},
|
|
257
|
+
tools=[
|
|
258
|
+
MCPToolDefinition(
|
|
259
|
+
name="drift_scan",
|
|
260
|
+
description="Scan Python and Java files for patterns",
|
|
261
|
+
parameters={
|
|
262
|
+
"type": "object",
|
|
263
|
+
"properties": {
|
|
264
|
+
"path": {"type": "string", "description": "File or directory to scan"},
|
|
265
|
+
"category": {"type": "string", "description": "Pattern category filter"},
|
|
266
|
+
"limit": {"type": "integer", "description": "Max results (default: all)"},
|
|
267
|
+
"max_tokens": {"type": "integer", "description": "Token budget for results"},
|
|
268
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor"},
|
|
269
|
+
},
|
|
270
|
+
"required": ["path"],
|
|
271
|
+
}
|
|
272
|
+
),
|
|
273
|
+
MCPToolDefinition(
|
|
274
|
+
name="drift_query",
|
|
275
|
+
description="Query detected patterns from memory",
|
|
276
|
+
parameters={
|
|
277
|
+
"type": "object",
|
|
278
|
+
"properties": {
|
|
279
|
+
"category": {"type": "string"},
|
|
280
|
+
"approved_only": {"type": "boolean"},
|
|
281
|
+
"limit": {"type": "integer", "description": "Max patterns (default 50)"},
|
|
282
|
+
"max_tokens": {"type": "integer", "description": "Token budget for patterns"},
|
|
283
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor"},
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
),
|
|
287
|
+
MCPToolDefinition(
|
|
288
|
+
name="drift_graph",
|
|
289
|
+
description="Get call graph for a symbol",
|
|
290
|
+
parameters={
|
|
291
|
+
"type": "object",
|
|
292
|
+
"properties": {
|
|
293
|
+
"symbol": {"type": "string", "description": "Fully qualified symbol name"},
|
|
294
|
+
"symbols": {"type": "array", "items": {"type": "string"}, "description": "Batch alternative to symbol: fetch call graphs for several symbols in one call"},
|
|
295
|
+
"limit": {"type": "integer", "description": "Max items per list"},
|
|
296
|
+
"max_tokens": {"type": "integer", "description": "Token budget per list (default 2000; 0 = unbounded)"},
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
),
|
|
300
|
+
MCPToolDefinition(
|
|
301
|
+
name="drift_why",
|
|
302
|
+
description="Get tribal knowledge about a topic",
|
|
303
|
+
parameters={
|
|
304
|
+
"type": "object",
|
|
305
|
+
"properties": {
|
|
306
|
+
"topic": {"type": "string"},
|
|
307
|
+
},
|
|
308
|
+
"required": ["topic"],
|
|
309
|
+
}
|
|
310
|
+
),
|
|
311
|
+
MCPToolDefinition(
|
|
312
|
+
name="drift_impact",
|
|
313
|
+
description="Blast-radius analysis: direct + transitive callers of a symbol. "
|
|
314
|
+
"If the name doesn't match exactly, a token-overlap guess is used "
|
|
315
|
+
"instead (fuzzy_fallback=true) -- no need to drift_search first.",
|
|
316
|
+
parameters={
|
|
317
|
+
"type": "object",
|
|
318
|
+
"properties": {
|
|
319
|
+
"symbol": {"type": "string", "description": "Symbol name (simple or qualified); a loose/behavior-phrased name falls back to a fuzzy guess"},
|
|
320
|
+
"max_tokens": {"type": "integer", "description": "Token budget (default 6000; 0 = unbounded, capped at 11000). Counts stay exact, lists are cut, `truncated` says what was dropped"},
|
|
321
|
+
"limit": {"type": "integer", "description": "Max items per list"},
|
|
322
|
+
"max_depth": {"type": "integer", "description": "Transitive caller depth horizon (default: unbounded)"},
|
|
323
|
+
"max_nodes": {"type": "integer", "description": "Cap on node ids across all lists"},
|
|
324
|
+
"include_initializers": {"type": "boolean", "description": "Count synthetic Java <init>/<clinit> contexts as callers (default false; they are otherwise returned separately)"},
|
|
325
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor"},
|
|
326
|
+
},
|
|
327
|
+
"required": ["symbol"],
|
|
328
|
+
}
|
|
329
|
+
),
|
|
330
|
+
MCPToolDefinition(
|
|
331
|
+
name="drift_explore",
|
|
332
|
+
description="Everything about one symbol in a single call: resolved detail "
|
|
333
|
+
"(signature/docstring/snippet), blast radius (direct + transitive "
|
|
334
|
+
"callers), and dependencies (what it calls) -- instead of "
|
|
335
|
+
"drift_node + drift_impact + drift_graph as 3 round trips. Falls "
|
|
336
|
+
"back to a fuzzy guess like drift_impact. Use drift_impact/"
|
|
337
|
+
"drift_graph/drift_callers directly instead for a single aspect, "
|
|
338
|
+
"or to page a huge list with `cursor` (not supported here).",
|
|
339
|
+
parameters={
|
|
340
|
+
"type": "object",
|
|
341
|
+
"properties": {
|
|
342
|
+
"symbol": {"type": "string", "description": "Symbol name (simple or qualified); a loose/behavior-phrased name falls back to a fuzzy guess"},
|
|
343
|
+
"max_tokens": {"type": "integer", "description": "Total token budget, split across detail/impact/dependencies (default 6000; 0 = unbounded, capped at 11000)"},
|
|
344
|
+
"limit": {"type": "integer", "description": "Max items per list (impact's caller lists and dependencies)"},
|
|
345
|
+
"max_depth": {"type": "integer", "description": "Transitive caller depth horizon for impact (default: unbounded)"},
|
|
346
|
+
"max_nodes": {"type": "integer", "description": "Cap on node ids across impact's lists"},
|
|
347
|
+
"callees_depth": {"type": "integer", "description": "Depth of the dependencies (callees) walk (default 1)"},
|
|
348
|
+
"include_initializers": {"type": "boolean", "description": "Count synthetic Java <init>/<clinit> contexts as callers (default false)"},
|
|
349
|
+
"snippet_lines": {"type": "integer", "description": "Source lines for detail.snippet; omit to size to the symbol's real body length (capped at 200)"},
|
|
350
|
+
"max_source_lines": {"type": "integer", "description": "Hard cap on detail.snippet length"},
|
|
351
|
+
},
|
|
352
|
+
"required": ["symbol"],
|
|
353
|
+
}
|
|
354
|
+
),
|
|
355
|
+
MCPToolDefinition(
|
|
356
|
+
name="drift_context",
|
|
357
|
+
description="Get graph-ranked project context for AI assistance",
|
|
358
|
+
parameters={
|
|
359
|
+
"type": "object",
|
|
360
|
+
"properties": {
|
|
361
|
+
"query": {"type": "string", "description": "What the AI is trying to do"},
|
|
362
|
+
"file_path": {"type": "string"},
|
|
363
|
+
"max_tokens": {"type": "integer", "description": "Token budget (default 2000; 0 = unbounded)"},
|
|
364
|
+
},
|
|
365
|
+
"required": ["query"],
|
|
366
|
+
}
|
|
367
|
+
),
|
|
368
|
+
MCPToolDefinition(
|
|
369
|
+
name="drift_search",
|
|
370
|
+
description="Search symbols by name (ranked: exact id > exact name > substring; degree-boosted)",
|
|
371
|
+
parameters={
|
|
372
|
+
"type": "object",
|
|
373
|
+
"properties": {
|
|
374
|
+
"query": {"type": "string", "description": "Symbol name or substring"},
|
|
375
|
+
"queries": {"type": "array", "items": {"type": "string"}, "description": "Batch alternative to query: run several searches in one call"},
|
|
376
|
+
"kind": {"type": "string", "description": "Filter: function | class | module | variable"},
|
|
377
|
+
"limit": {"type": "integer", "description": "Max matches (default 20)"},
|
|
378
|
+
"max_tokens": {"type": "integer", "description": "Token budget (default 2000; 0 = unbounded)"},
|
|
379
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor (single-query only)"},
|
|
380
|
+
"include_source": {"type": "boolean", "description": "Embed a source snippet on each match, like drift_node (default false)"},
|
|
381
|
+
"snippet_lines": {"type": "integer", "description": "Snippet length when include_source is set; omit to size to the symbol's real body length (capped at 200)"},
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
),
|
|
385
|
+
MCPToolDefinition(
|
|
386
|
+
name="drift_node",
|
|
387
|
+
description="Node detail: file/line/signature/docstring, source snippet, edge counts by type",
|
|
388
|
+
parameters={
|
|
389
|
+
"type": "object",
|
|
390
|
+
"properties": {
|
|
391
|
+
"node_id": {"type": "string", "description": "Node id (qualified or simple name)"},
|
|
392
|
+
"node_ids": {"type": "array", "items": {"type": "string"}, "description": "Batch alternative to node_id: fetch several nodes in one call"},
|
|
393
|
+
"snippet_lines": {"type": "integer", "description": "Source lines to include; omit to size to the symbol's real body length (capped at 200)"},
|
|
394
|
+
"max_tokens": {"type": "integer", "description": "Token budget; trims the snippet from the bottom"},
|
|
395
|
+
"max_source_lines": {"type": "integer", "description": "Hard cap on snippet lines"},
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
),
|
|
399
|
+
MCPToolDefinition(
|
|
400
|
+
name="drift_callers",
|
|
401
|
+
description="Who depends on this symbol (depth-bounded reverse walk over calls/refs/types)",
|
|
402
|
+
parameters={
|
|
403
|
+
"type": "object",
|
|
404
|
+
"properties": {
|
|
405
|
+
"symbol": {"type": "string"},
|
|
406
|
+
"max_depth": {"type": "integer", "description": "BFS depth (default 1)"},
|
|
407
|
+
"limit": {"type": "integer", "description": "Max items (default 50)"},
|
|
408
|
+
"max_tokens": {"type": "integer", "description": "Token budget (default 2000; 0 = unbounded)"},
|
|
409
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor"},
|
|
410
|
+
},
|
|
411
|
+
"required": ["symbol"],
|
|
412
|
+
}
|
|
413
|
+
),
|
|
414
|
+
MCPToolDefinition(
|
|
415
|
+
name="drift_callees",
|
|
416
|
+
description="What this symbol depends on (depth-bounded forward walk over calls/instantiations)",
|
|
417
|
+
parameters={
|
|
418
|
+
"type": "object",
|
|
419
|
+
"properties": {
|
|
420
|
+
"symbol": {"type": "string"},
|
|
421
|
+
"max_depth": {"type": "integer", "description": "BFS depth (default 1)"},
|
|
422
|
+
"limit": {"type": "integer", "description": "Max items (default 50)"},
|
|
423
|
+
"max_tokens": {"type": "integer", "description": "Token budget (default 2000; 0 = unbounded)"},
|
|
424
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor"},
|
|
425
|
+
},
|
|
426
|
+
"required": ["symbol"],
|
|
427
|
+
}
|
|
428
|
+
),
|
|
429
|
+
MCPToolDefinition(
|
|
430
|
+
name="drift_path",
|
|
431
|
+
description="Shortest dependency path from one symbol to another. Check "
|
|
432
|
+
"`factual` (and per-hop `direction`/`edge_to_next.reversed`) "
|
|
433
|
+
"before treating the path as a real call-order trace: an "
|
|
434
|
+
"undirected or uncertain hop means the path proves connectivity, "
|
|
435
|
+
"not the direction asked about.",
|
|
436
|
+
parameters={
|
|
437
|
+
"type": "object",
|
|
438
|
+
"properties": {
|
|
439
|
+
"from_symbol": {"type": "string"},
|
|
440
|
+
"to_symbol": {"type": "string"},
|
|
441
|
+
"max_tokens": {"type": "integer", "description": "Token budget; middle steps are elided, endpoints kept, `length` stays the true hop count"},
|
|
442
|
+
"alternates": {"type": "integer", "description": "Also return up to N alternative simple paths (ids only; default 0)"},
|
|
443
|
+
},
|
|
444
|
+
"required": ["from_symbol", "to_symbol"],
|
|
445
|
+
}
|
|
446
|
+
),
|
|
447
|
+
MCPToolDefinition(
|
|
448
|
+
name="drift_status",
|
|
449
|
+
description="Index health: file/node/edge counts, freshness, staleness flag",
|
|
450
|
+
parameters={"type": "object", "properties": {}},
|
|
451
|
+
),
|
|
452
|
+
MCPToolDefinition(
|
|
453
|
+
name="drift_communities",
|
|
454
|
+
description="Module clusters (Louvain): sizes, top members, dominant module. Also returns god_nodes (highest-degree architectural linchpins).",
|
|
455
|
+
parameters={
|
|
456
|
+
"type": "object",
|
|
457
|
+
"properties": {
|
|
458
|
+
"limit": {"type": "integer", "description": "Max communities (default 20)"},
|
|
459
|
+
"max_tokens": {"type": "integer", "description": "Token budget (default 2000; 0 = unbounded)"},
|
|
460
|
+
"god_nodes_top_n": {"type": "integer", "description": "How many god nodes to include (default 10)"},
|
|
461
|
+
"cursor": {"type": "string", "description": "Continuation token from a previous next_cursor"},
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
),
|
|
465
|
+
MCPToolDefinition(
|
|
466
|
+
name="drift_learn",
|
|
467
|
+
description="Record tribal knowledge (append-only DB write)",
|
|
468
|
+
parameters={
|
|
469
|
+
"type": "object",
|
|
470
|
+
"properties": {
|
|
471
|
+
"topic": {"type": "string"},
|
|
472
|
+
"content": {"type": "string"},
|
|
473
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
474
|
+
},
|
|
475
|
+
"required": ["topic", "content"],
|
|
476
|
+
}
|
|
477
|
+
),
|
|
478
|
+
MCPToolDefinition(
|
|
479
|
+
name="drift_approve",
|
|
480
|
+
description="Approve a detected pattern by id (flag-only DB write)",
|
|
481
|
+
parameters={
|
|
482
|
+
"type": "object",
|
|
483
|
+
"properties": {
|
|
484
|
+
"pattern_id": {"type": "string"},
|
|
485
|
+
},
|
|
486
|
+
"required": ["pattern_id"],
|
|
487
|
+
}
|
|
488
|
+
),
|
|
489
|
+
MCPToolDefinition(
|
|
490
|
+
name="drift_plan_change",
|
|
491
|
+
description="Predict the blast radius of a planned code change. Resolves the target symbol from a natural-language description, runs drift_impact, and returns an editable checklist plus a baseline snapshot for later verification.",
|
|
492
|
+
parameters={
|
|
493
|
+
"type": "object",
|
|
494
|
+
"properties": {
|
|
495
|
+
"description": {"type": "string", "description": "Natural-language change request, e.g. 'add required audit_tag to jsonable_encoder'"},
|
|
496
|
+
"symbol": {"type": "string", "description": "Optional explicit target symbol; if omitted, resolved from description"},
|
|
497
|
+
"max_depth": {"type": "integer", "description": "Impact traversal depth (default 2)"},
|
|
498
|
+
"max_tokens": {"type": "integer", "description": "Token budget for the embedded impact report (default 3000; 0 = unbounded)"},
|
|
499
|
+
"limit": {"type": "integer", "description": "Max items per impact list"},
|
|
500
|
+
"max_nodes": {"type": "integer", "description": "Cap on node ids across the impact lists"},
|
|
501
|
+
},
|
|
502
|
+
"required": ["description"],
|
|
503
|
+
}
|
|
504
|
+
),
|
|
505
|
+
MCPToolDefinition(
|
|
506
|
+
name="drift_verify_change",
|
|
507
|
+
description="Verify a code change is consistent: incrementally rescan changed files and compare the new impact against the baseline returned by drift_plan_change.",
|
|
508
|
+
parameters={
|
|
509
|
+
"type": "object",
|
|
510
|
+
"properties": {
|
|
511
|
+
"baseline": {"type": "object", "description": "Baseline snapshot from drift_plan_change"},
|
|
512
|
+
"changed_files": {"type": "array", "items": {"type": "string"}, "description": "Optional list of changed file paths; if omitted, auto-detects changed files"},
|
|
513
|
+
"symbol": {"type": "string", "description": "Optional override of the symbol to verify"},
|
|
514
|
+
"max_tokens": {"type": "integer", "description": "Token budget for the embedded impact report (default 3000; 0 = unbounded)"},
|
|
515
|
+
"limit": {"type": "integer", "description": "Max items per impact list"},
|
|
516
|
+
"max_nodes": {"type": "integer", "description": "Cap on node ids across the impact lists"},
|
|
517
|
+
},
|
|
518
|
+
"required": ["baseline"],
|
|
519
|
+
}
|
|
520
|
+
),
|
|
521
|
+
]
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def handle_scan(self, params: Dict[str, Any]) -> MCPResponse:
|
|
525
|
+
"""Handle drift_scan tool invocation."""
|
|
526
|
+
path = Path(params["path"])
|
|
527
|
+
category_filter = params.get("category")
|
|
528
|
+
self._configure_backend(path)
|
|
529
|
+
|
|
530
|
+
errors: List[Dict[str, str]] = []
|
|
531
|
+
if path.is_file() and backend_for_file(path) is not None:
|
|
532
|
+
try:
|
|
533
|
+
results, _, _ = self._scan_file(path, category_filter)
|
|
534
|
+
except SCAN_FILE_ERRORS as e:
|
|
535
|
+
return MCPResponse(
|
|
536
|
+
id=params.get("request_id"),
|
|
537
|
+
error={"code": -32603, "message": f"Scan failed for {path}: {type(e).__name__}: {e}"}
|
|
538
|
+
)
|
|
539
|
+
# P2.11 Part 5: keep the persisted graph consistent with what
|
|
540
|
+
# was just scanned. The incremental machinery sees only this
|
|
541
|
+
# file as changed (content hash), so this is a one-file update.
|
|
542
|
+
self._build_full_graph()
|
|
543
|
+
errors = self.last_graph_errors
|
|
544
|
+
elif path.is_dir():
|
|
545
|
+
results, errors = self._scan_directory(path, category_filter)
|
|
546
|
+
else:
|
|
547
|
+
return MCPResponse(
|
|
548
|
+
id=params.get("request_id"),
|
|
549
|
+
error={"code": -32602, "message": "Invalid path"}
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
scanned = len(results)
|
|
553
|
+
results, truncated, nxt, err = self._page(
|
|
554
|
+
"drift_scan", params, results, default_limit=None, default_tokens=None
|
|
555
|
+
)
|
|
556
|
+
if err:
|
|
557
|
+
return err
|
|
558
|
+
return MCPResponse(
|
|
559
|
+
id=params.get("request_id"),
|
|
560
|
+
result={
|
|
561
|
+
"scanned": scanned,
|
|
562
|
+
"results": results,
|
|
563
|
+
"truncated": truncated,
|
|
564
|
+
"next_cursor": nxt,
|
|
565
|
+
"errors": errors,
|
|
566
|
+
"error_count": len(errors),
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def handle_query(self, params: Dict[str, Any]) -> MCPResponse:
|
|
571
|
+
"""Handle drift_query tool invocation."""
|
|
572
|
+
category = params.get("category")
|
|
573
|
+
approved_only = params.get("approved_only", False)
|
|
574
|
+
limit = params.get("limit", 50)
|
|
575
|
+
|
|
576
|
+
patterns = self.memory.get_patterns(category=category, approved_only=approved_only)
|
|
577
|
+
patterns, truncated, nxt, err = self._page(
|
|
578
|
+
"drift_query", {**params, "limit": limit}, patterns, default_limit=50, default_tokens=None
|
|
579
|
+
)
|
|
580
|
+
if err:
|
|
581
|
+
return err
|
|
582
|
+
|
|
583
|
+
return MCPResponse(
|
|
584
|
+
id=params.get("request_id"),
|
|
585
|
+
result={"patterns": patterns, "count": len(patterns), "truncated": truncated, "next_cursor": nxt}
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# ---- shared P2.11 helpers ----
|
|
589
|
+
|
|
590
|
+
@staticmethod
|
|
591
|
+
def _apply_budget(
|
|
592
|
+
items: List[Dict[str, Any]],
|
|
593
|
+
limit: Optional[int] = None,
|
|
594
|
+
max_tokens: Optional[int] = None,
|
|
595
|
+
) -> Tuple[List[Dict[str, Any]], int]:
|
|
596
|
+
"""Trim a relevance-ordered list (P2.11 token-budget convention).
|
|
597
|
+
|
|
598
|
+
``max_tokens`` is estimated at 4 chars/token on the JSON-serialized
|
|
599
|
+
items; trimming drops from the tail, so callers must pass items
|
|
600
|
+
highest-relevance first. Returns (kept, truncated_count).
|
|
601
|
+
"""
|
|
602
|
+
total = len(items)
|
|
603
|
+
if limit is not None and limit >= 0:
|
|
604
|
+
items = items[:limit]
|
|
605
|
+
if max_tokens is not None and max_tokens > 0:
|
|
606
|
+
budget = max_tokens * 4
|
|
607
|
+
used = 0
|
|
608
|
+
kept: List[Dict[str, Any]] = []
|
|
609
|
+
for it in items:
|
|
610
|
+
cost = len(json.dumps(it, default=str))
|
|
611
|
+
if kept and used + cost > budget:
|
|
612
|
+
break
|
|
613
|
+
kept.append(it)
|
|
614
|
+
used += cost
|
|
615
|
+
items = kept
|
|
616
|
+
return items, total - len(items)
|
|
617
|
+
|
|
618
|
+
# MCP_TOKEN_BUDGET_PLAN.md Lớp 1 — budget for the tools that had none.
|
|
619
|
+
# Convention: *_count fields stay exact, list fields are ordered
|
|
620
|
+
# (depth asc, confidence desc, id) and cut; ``truncated`` reports what
|
|
621
|
+
# was dropped. Defaults follow HARNESS.md ("max_tokens=2000 on impact");
|
|
622
|
+
# pass max_tokens=0/null for the unbounded report.
|
|
623
|
+
# Lớp 2: an edge is "uncertain" below this confidence or when the resolver
|
|
624
|
+
# fanned out over an interface (metadata.strategy == interface_fanout,
|
|
625
|
+
# confidence 0.45). HARNESS.md already tells agents to treat < 0.6 that way.
|
|
626
|
+
UNCERTAIN_CONFIDENCE = 0.6
|
|
627
|
+
UNCERTAIN_RESOLUTIONS = frozenset({"interface_fanout"})
|
|
628
|
+
DEFAULT_MAX_TOKENS = 2000 # search / graph / callers / callees / communities
|
|
629
|
+
# Snippet sizing (P7 continuation, 2026-10-01): when the caller doesn't pin
|
|
630
|
+
# snippet_lines, size the snippet to the symbol's real body (node.end_line)
|
|
631
|
+
# instead of a flat default -- a method longer than DEFAULT_SNIPPET_LINES no
|
|
632
|
+
# longer needs a follow-up Read just to see the rest of it. Capped at
|
|
633
|
+
# MAX_ADAPTIVE_SNIPPET_LINES so one pathological (e.g. generated) method
|
|
634
|
+
# can't blow up a response by default; an explicit snippet_lines/
|
|
635
|
+
# max_source_lines always overrides both.
|
|
636
|
+
DEFAULT_SNIPPET_LINES = 10
|
|
637
|
+
MAX_ADAPTIVE_SNIPPET_LINES = 200
|
|
638
|
+
# Higher than DEFAULT_MAX_TOKENS: drift_impact splits its budget across 8
|
|
639
|
+
# lists (direct/initializer/uncertain callers, entry points, affected
|
|
640
|
+
# files/members, transitive callers). _round_robin_take gives each list a
|
|
641
|
+
# turn per round, but on a hub symbol with ~80+ real callers even that
|
|
642
|
+
# round-robin needs real room to move past everyone's 1-item floor.
|
|
643
|
+
IMPACT_DEFAULT_MAX_TOKENS = 6000
|
|
644
|
+
PLAN_DEFAULT_MAX_TOKENS = 3000 # plan_change / verify_change (impact + plan text)
|
|
645
|
+
# max_tokens=0 ("give me everything") is capped here rather than made
|
|
646
|
+
# truly infinite: an MCP host can outright refuse an oversized tool
|
|
647
|
+
# result instead of just costing more tokens -- observed live, Claude
|
|
648
|
+
# Code rejected a 68k-character drift_impact response on a hub symbol
|
|
649
|
+
# and forced a save-to-file + jq/Bash recovery that cost ~5x more than
|
|
650
|
+
# the request that triggered it. This ceiling stays well under that
|
|
651
|
+
# failure point while still being far more complete than the default.
|
|
652
|
+
IMPACT_UNBOUNDED_MAX_TOKENS = 11000
|
|
653
|
+
# drift_explore (P7 continuation, 2026-10-01): one call combining impact
|
|
654
|
+
# + node detail + dependencies, so it needs its own budget split across
|
|
655
|
+
# all three -- same default as impact alone (it was the highest-value,
|
|
656
|
+
# most-fielded section in practice) and the same "0 still means a
|
|
657
|
+
# generous but bounded ceiling" policy as IMPACT_UNBOUNDED_MAX_TOKENS,
|
|
658
|
+
# applied up front so all three sections share one consistent ceiling
|
|
659
|
+
# instead of impact alone being capped while detail/dependencies stay
|
|
660
|
+
# literally unbounded.
|
|
661
|
+
EXPLORE_DEFAULT_MAX_TOKENS = 6000
|
|
662
|
+
EXPLORE_UNBOUNDED_MAX_TOKENS = 11000
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
def _max_tokens(params: Dict[str, Any], default: Optional[int]) -> Optional[int]:
|
|
666
|
+
"""Budget resolution: absent or null -> ``default``; 0 -> unbounded.
|
|
667
|
+
|
|
668
|
+
The stdio layer forwards ``max_tokens=None`` for an omitted argument,
|
|
669
|
+
so ``params.get("max_tokens", default)`` alone would never apply the
|
|
670
|
+
default over MCP (the baseline bench found drift_graph at 24k tokens
|
|
671
|
+
for exactly this reason).
|
|
672
|
+
"""
|
|
673
|
+
value = params.get("max_tokens")
|
|
674
|
+
return default if value is None else value
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _char_budget(max_tokens: Optional[int]) -> Optional[int]:
|
|
678
|
+
return max_tokens * 4 if max_tokens is not None and max_tokens > 0 else None
|
|
679
|
+
|
|
680
|
+
# ---- Lớp 3: continuation ----
|
|
681
|
+
# A cursor is opaque, self-contained and server-stateless: base64 of
|
|
682
|
+
# {"t": tool, "a": args fingerprint, "o": offset(s), "f": graph fingerprint}.
|
|
683
|
+
# The graph fingerprint is the same stat digest _build_full_graph() caches
|
|
684
|
+
# on, so a cursor minted before a source edit is refused with a clear
|
|
685
|
+
# message instead of silently paging a different graph.
|
|
686
|
+
CURSOR_ARGS = (
|
|
687
|
+
"symbol", "query", "kind", "max_depth", "include_initializers",
|
|
688
|
+
"category", "approved_only", "path",
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
def _graph_fingerprint(self) -> str:
|
|
692
|
+
return self._graph_cache.fingerprint if self._graph_cache is not None else ""
|
|
693
|
+
|
|
694
|
+
def _cursor_args(self, params: Dict[str, Any]) -> str:
|
|
695
|
+
return json.dumps({k: params.get(k) for k in self.CURSOR_ARGS if params.get(k) is not None}, sort_keys=True)
|
|
696
|
+
|
|
697
|
+
def _make_cursor(self, tool: str, params: Dict[str, Any], offset: Any) -> str:
|
|
698
|
+
raw = json.dumps({"t": tool, "a": self._cursor_args(params), "o": offset, "f": self._graph_fingerprint()})
|
|
699
|
+
return base64.urlsafe_b64encode(raw.encode()).decode()
|
|
700
|
+
|
|
701
|
+
def _read_cursor(self, tool: str, params: Dict[str, Any]) -> Tuple[Any, Optional[MCPResponse]]:
|
|
702
|
+
"""(offset, None) for a valid cursor, (None, error) otherwise; (0/{} , None) when absent."""
|
|
703
|
+
cursor = params.get("cursor")
|
|
704
|
+
if not cursor:
|
|
705
|
+
return None, None
|
|
706
|
+
try:
|
|
707
|
+
data = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())
|
|
708
|
+
except (ValueError, UnicodeDecodeError):
|
|
709
|
+
return None, MCPResponse(id=params.get("request_id"),
|
|
710
|
+
error={"code": -32602, "message": "cursor is not an ADL Drift continuation token"})
|
|
711
|
+
if data.get("t") != tool or data.get("a") != self._cursor_args(params):
|
|
712
|
+
return None, MCPResponse(id=params.get("request_id"), error={
|
|
713
|
+
"code": -32602,
|
|
714
|
+
"message": f"cursor was issued by {data.get('t')} with different arguments; repeat the original call"})
|
|
715
|
+
if data.get("f") != self._graph_fingerprint():
|
|
716
|
+
return None, MCPResponse(id=params.get("request_id"), error={
|
|
717
|
+
"code": -32602,
|
|
718
|
+
"message": "cursor is stale: sources changed since it was issued; repeat the original call without cursor"})
|
|
719
|
+
return data.get("o"), None
|
|
720
|
+
|
|
721
|
+
def _page(
|
|
722
|
+
self, tool: str, params: Dict[str, Any], items: List[Any],
|
|
723
|
+
*, default_limit: Optional[int], default_tokens: Optional[int],
|
|
724
|
+
) -> Tuple[List[Any], int, Optional[str], Optional[MCPResponse]]:
|
|
725
|
+
"""Offset-paged _apply_budget: (kept, truncated, next_cursor, error).
|
|
726
|
+
|
|
727
|
+
``truncated`` counts what is still unread after this page (items
|
|
728
|
+
before the offset are not counted again); ``next_cursor`` is set only
|
|
729
|
+
while something remains.
|
|
730
|
+
"""
|
|
731
|
+
offset, err = self._read_cursor(tool, params)
|
|
732
|
+
if err:
|
|
733
|
+
return [], 0, None, err
|
|
734
|
+
offset = int(offset or 0)
|
|
735
|
+
kept, truncated = self._apply_budget(
|
|
736
|
+
items[offset:], params.get("limit", default_limit), self._max_tokens(params, default_tokens)
|
|
737
|
+
)
|
|
738
|
+
nxt = self._make_cursor(tool, params, offset + len(kept)) if truncated else None
|
|
739
|
+
return kept, truncated, nxt, None
|
|
740
|
+
|
|
741
|
+
@staticmethod
|
|
742
|
+
def _cut(items: List[Any], limit: Optional[int], char_budget: Optional[int], cost) -> Tuple[List[Any], int, int]:
|
|
743
|
+
"""Prefix of ``items`` within ``limit`` and ``char_budget``; (kept, dropped, chars_used).
|
|
744
|
+
|
|
745
|
+
Same rule as _apply_budget: the first item is always kept so a
|
|
746
|
+
response never comes back empty under a tiny budget.
|
|
747
|
+
"""
|
|
748
|
+
kept: List[Any] = []
|
|
749
|
+
used = 0
|
|
750
|
+
for it in items:
|
|
751
|
+
if limit is not None and limit >= 0 and len(kept) >= limit:
|
|
752
|
+
break
|
|
753
|
+
c = cost(it)
|
|
754
|
+
if char_budget is not None and kept and used + c > char_budget:
|
|
755
|
+
break
|
|
756
|
+
kept.append(it)
|
|
757
|
+
used += c
|
|
758
|
+
return kept, len(items) - len(kept), used
|
|
759
|
+
|
|
760
|
+
@staticmethod
|
|
761
|
+
def _round_robin_take(
|
|
762
|
+
fields: List[Tuple[str, List[Any], Any]],
|
|
763
|
+
limit: Optional[int],
|
|
764
|
+
remaining: Optional[int],
|
|
765
|
+
remaining_nodes: List[Optional[int]],
|
|
766
|
+
offsets: Dict[str, int],
|
|
767
|
+
truncated: Dict[str, int],
|
|
768
|
+
next_offsets: Dict[str, int],
|
|
769
|
+
) -> Tuple[Dict[str, List[Any]], Optional[int]]:
|
|
770
|
+
"""One item per field per round, instead of exhausting field 1's
|
|
771
|
+
list before field 2 gets a turn -- a hub symbol with hundreds of
|
|
772
|
+
direct callers can otherwise consume the entire shared char budget
|
|
773
|
+
on that one field alone, leaving every other field (``affected_files``
|
|
774
|
+
included) at the bare "always keep >=1" floor. ``fields`` is given in
|
|
775
|
+
priority order: when the shared budget runs out mid-round, fields
|
|
776
|
+
already visited that round keep their extra item and later ones in
|
|
777
|
+
that same round do not -- so priority order still matters, just
|
|
778
|
+
gradually instead of all-or-nothing per field.
|
|
779
|
+
"""
|
|
780
|
+
items_of: Dict[str, List[Any]] = {}
|
|
781
|
+
skip_of: Dict[str, int] = {}
|
|
782
|
+
cost_of: Dict[str, Any] = {}
|
|
783
|
+
kept: Dict[str, List[Any]] = {}
|
|
784
|
+
for name, items, cost in fields:
|
|
785
|
+
skip = int(offsets.get(name, 0))
|
|
786
|
+
skip_of[name] = skip
|
|
787
|
+
items_of[name] = items[skip:]
|
|
788
|
+
cost_of[name] = cost
|
|
789
|
+
kept[name] = []
|
|
790
|
+
active = [name for name in items_of if items_of[name]]
|
|
791
|
+
while active:
|
|
792
|
+
progressed = False
|
|
793
|
+
for name in list(active):
|
|
794
|
+
items = items_of[name]
|
|
795
|
+
i = len(kept[name])
|
|
796
|
+
if i >= len(items):
|
|
797
|
+
active.remove(name)
|
|
798
|
+
continue
|
|
799
|
+
if limit is not None and limit >= 0 and i >= limit:
|
|
800
|
+
active.remove(name)
|
|
801
|
+
continue
|
|
802
|
+
if remaining_nodes[0] is not None and remaining_nodes[0] <= 0:
|
|
803
|
+
active.remove(name)
|
|
804
|
+
continue
|
|
805
|
+
it = items[i]
|
|
806
|
+
c = cost_of[name](it)
|
|
807
|
+
if remaining is not None and kept[name] and remaining - c < 0:
|
|
808
|
+
active.remove(name)
|
|
809
|
+
continue
|
|
810
|
+
kept[name].append(it)
|
|
811
|
+
if remaining is not None:
|
|
812
|
+
remaining = max(0, remaining - c)
|
|
813
|
+
if remaining_nodes[0] is not None:
|
|
814
|
+
remaining_nodes[0] -= 1
|
|
815
|
+
progressed = True
|
|
816
|
+
if not progressed:
|
|
817
|
+
break
|
|
818
|
+
for name, items in items_of.items():
|
|
819
|
+
dropped = len(items) - len(kept[name])
|
|
820
|
+
if dropped:
|
|
821
|
+
truncated[name] = dropped
|
|
822
|
+
next_offsets[name] = skip_of[name] + len(kept[name])
|
|
823
|
+
return kept, remaining
|
|
824
|
+
|
|
825
|
+
def _bound_impact(
|
|
826
|
+
self,
|
|
827
|
+
builder: CallGraphBuilder,
|
|
828
|
+
report: Dict[str, Any],
|
|
829
|
+
*,
|
|
830
|
+
limit: Optional[int] = None,
|
|
831
|
+
max_tokens: Optional[int] = None,
|
|
832
|
+
max_depth: Optional[int] = None,
|
|
833
|
+
max_nodes: Optional[int] = None,
|
|
834
|
+
offsets: Optional[Dict[str, int]] = None,
|
|
835
|
+
) -> Dict[str, Any]:
|
|
836
|
+
"""Bounded copy of ``CallGraphBuilder.get_impact_analysis()`` (Lớp 1).
|
|
837
|
+
|
|
838
|
+
Lớp 3: ``max_nodes`` caps the number of node ids across all lists;
|
|
839
|
+
``offsets`` (from a continuation cursor) skips already-served items
|
|
840
|
+
per list and ``out["_next_offsets"]`` carries the offsets for the
|
|
841
|
+
next page (``None`` when nothing remains).
|
|
842
|
+
|
|
843
|
+
``max_depth`` keeps transitive callers at depth <= max_depth and
|
|
844
|
+
recomputes entry points / files / counts for that horizon (the
|
|
845
|
+
builder report is unbounded; drift_plan_change promised a depth).
|
|
846
|
+
Lists are ordered depth asc, confidence desc, id and cut to
|
|
847
|
+
``limit`` items / ``max_tokens`` in priority order: direct callers,
|
|
848
|
+
entry points, files, transitive callers, members. ``node_languages``
|
|
849
|
+
shrinks to the ids still present. ``truncated`` maps field -> dropped.
|
|
850
|
+
Counts (``*_count``) are never affected by ``limit``/``max_tokens``.
|
|
851
|
+
"""
|
|
852
|
+
out = dict(report)
|
|
853
|
+
nodes = builder._nodes
|
|
854
|
+
cc = {c["id"]: c for c in report.get("caller_confidence", [])}
|
|
855
|
+
icc = {c["id"]: c for c in report.get("initializer_caller_confidence", [])}
|
|
856
|
+
conf = {i: c["confidence"] for i, c in cc.items()}
|
|
857
|
+
# Lớp 2: resolution of the edge that made each direct caller direct.
|
|
858
|
+
seeds = [report.get("target"), *report.get("affected_members", [])]
|
|
859
|
+
for cid, entry in cc.items():
|
|
860
|
+
for seed in seeds:
|
|
861
|
+
prov = builder._edge_strategy(cid, seed, entry.get("edge_type")) if seed else {}
|
|
862
|
+
if prov.get("resolution"):
|
|
863
|
+
entry["resolution"] = prov["resolution"]
|
|
864
|
+
break
|
|
865
|
+
trans = dict(report.get("transitive_callers", {}))
|
|
866
|
+
initializer_trans = dict(report.get("transitive_initializer_callers", {}))
|
|
867
|
+
depth_limited = False
|
|
868
|
+
traversed_depths = [*trans.values(), *initializer_trans.values()]
|
|
869
|
+
if (
|
|
870
|
+
max_depth is not None and max_depth >= 1
|
|
871
|
+
and traversed_depths and max(traversed_depths) > max_depth
|
|
872
|
+
):
|
|
873
|
+
trans = {k: v for k, v in trans.items() if v <= max_depth}
|
|
874
|
+
initializer_trans = {
|
|
875
|
+
k: v for k, v in initializer_trans.items() if v <= max_depth
|
|
876
|
+
}
|
|
877
|
+
depth_limited = True
|
|
878
|
+
direct_all = sorted(report.get("direct_callers", []), key=lambda i: (-conf.get(i, 0.0), i))
|
|
879
|
+
initializers = sorted(report.get("initializer_callers", []))
|
|
880
|
+
uncertain = [i for i in direct_all if i in cc and self._is_uncertain(cc[i])]
|
|
881
|
+
direct = [i for i in direct_all if i not in set(uncertain)]
|
|
882
|
+
trans_items = sorted(trans.items(), key=lambda kv: (kv[1], -conf.get(kv[0], 0.0), kv[0]))
|
|
883
|
+
entry_set = set(report.get("affected_entry_points", []))
|
|
884
|
+
entries = [c for c, _ in trans_items if c in entry_set]
|
|
885
|
+
file_depth: Dict[str, int] = {report.get("target_file"): 0}
|
|
886
|
+
for c, d in trans_items:
|
|
887
|
+
n = nodes.get(c)
|
|
888
|
+
if n is not None:
|
|
889
|
+
file_depth[n.file_path] = min(file_depth.get(n.file_path, d), d)
|
|
890
|
+
if depth_limited:
|
|
891
|
+
files = sorted(file_depth, key=lambda f: (file_depth[f], f))
|
|
892
|
+
out.update({
|
|
893
|
+
"transitive_caller_count": len(trans_items),
|
|
894
|
+
"transitive_initializer_caller_count": len(initializer_trans),
|
|
895
|
+
"affected_entry_count": len(entries),
|
|
896
|
+
"affected_file_count": len(files),
|
|
897
|
+
"max_depth": max((d for _, d in trans_items), default=0),
|
|
898
|
+
"depth_limit": max_depth,
|
|
899
|
+
})
|
|
900
|
+
else:
|
|
901
|
+
files = sorted(report.get("affected_files", []), key=lambda f: (file_depth.get(f, 1 << 30), f))
|
|
902
|
+
members = list(report.get("affected_members", []))
|
|
903
|
+
|
|
904
|
+
truncated: Dict[str, int] = {}
|
|
905
|
+
offsets = dict(offsets or {})
|
|
906
|
+
next_offsets: Dict[str, int] = {}
|
|
907
|
+
remaining_nodes = [max_nodes if max_nodes is not None and max_nodes > 0 else None]
|
|
908
|
+
char_budget = self._char_budget(max_tokens)
|
|
909
|
+
if max_tokens == 0:
|
|
910
|
+
# see IMPACT_UNBOUNDED_MAX_TOKENS -- "unbounded" still gets a
|
|
911
|
+
# generous, safe ceiling instead of literal infinity.
|
|
912
|
+
char_budget = self._char_budget(self.IMPACT_UNBOUNDED_MAX_TOKENS)
|
|
913
|
+
if char_budget is not None or limit is not None or offsets or remaining_nodes[0] is not None:
|
|
914
|
+
skeleton = {
|
|
915
|
+
**out, "direct_callers": [], "caller_confidence": [],
|
|
916
|
+
"initializer_callers": [], "initializer_caller_confidence": [],
|
|
917
|
+
"transitive_callers": {}, "transitive_initializer_callers": {},
|
|
918
|
+
"affected_entry_points": [], "affected_files": [], "affected_members": [],
|
|
919
|
+
"node_languages": {}, "truncated": {},
|
|
920
|
+
}
|
|
921
|
+
remaining = None if char_budget is None else max(0, char_budget - len(json.dumps(skeleton, default=str)))
|
|
922
|
+
initializer_trans_sorted = sorted(initializer_trans.items(), key=lambda kv: (kv[1], kv[0]))
|
|
923
|
+
|
|
924
|
+
# Priority order: the two fields a caller most often actually
|
|
925
|
+
# wants (who calls it directly, what files does it affect)
|
|
926
|
+
# first. _round_robin_take gives every field a turn each round
|
|
927
|
+
# rather than letting field 1 exhaust the whole shared budget
|
|
928
|
+
# before field 2 is even attempted.
|
|
929
|
+
fields: List[Tuple[str, List[Any], Any]] = [
|
|
930
|
+
("direct_callers", direct,
|
|
931
|
+
lambda i: len(json.dumps(i)) + 2 + (len(json.dumps(cc[i], default=str)) + 2 if i in cc else 0)),
|
|
932
|
+
# cost against the relative path -- the same string _rel()
|
|
933
|
+
# emits below -- not the raw absolute path, which can be 2x
|
|
934
|
+
# longer and was making this field look far more expensive
|
|
935
|
+
# than it truncates to.
|
|
936
|
+
("affected_files", files, lambda f: len(json.dumps(self._rel(f))) + 2),
|
|
937
|
+
("affected_entry_points", entries, lambda i: len(json.dumps(i)) + 2),
|
|
938
|
+
("initializer_callers", initializers,
|
|
939
|
+
lambda i: len(json.dumps(i)) + 2
|
|
940
|
+
+ (len(json.dumps(icc[i], default=str)) + 2 if i in icc else 0)),
|
|
941
|
+
("uncertain_callers", uncertain, lambda i: len(json.dumps(cc[i], default=str)) + 2),
|
|
942
|
+
("transitive_callers", trans_items, lambda kv: len(json.dumps(kv[0])) + 6),
|
|
943
|
+
("transitive_initializer_callers", initializer_trans_sorted,
|
|
944
|
+
lambda kv: len(json.dumps(kv[0])) + 6),
|
|
945
|
+
("affected_members", members, lambda i: len(json.dumps(i)) + 2),
|
|
946
|
+
]
|
|
947
|
+
kept_by_field, remaining = self._round_robin_take(
|
|
948
|
+
fields, limit, remaining, remaining_nodes, offsets, truncated, next_offsets
|
|
949
|
+
)
|
|
950
|
+
direct = kept_by_field["direct_callers"]
|
|
951
|
+
files = kept_by_field["affected_files"]
|
|
952
|
+
entries = kept_by_field["affected_entry_points"]
|
|
953
|
+
initializers = kept_by_field["initializer_callers"]
|
|
954
|
+
uncertain = kept_by_field["uncertain_callers"]
|
|
955
|
+
trans_items = kept_by_field["transitive_callers"]
|
|
956
|
+
initializer_trans_items = kept_by_field["transitive_initializer_callers"]
|
|
957
|
+
members = kept_by_field["affected_members"]
|
|
958
|
+
else:
|
|
959
|
+
initializer_trans_items = sorted(
|
|
960
|
+
initializer_trans.items(), key=lambda kv: (kv[1], kv[0])
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
out["direct_callers"] = direct
|
|
964
|
+
out["caller_confidence"] = [cc[i] for i in direct if i in cc]
|
|
965
|
+
out["initializer_callers"] = initializers
|
|
966
|
+
out["initializer_caller_confidence"] = [
|
|
967
|
+
icc[i] for i in initializers if i in icc
|
|
968
|
+
]
|
|
969
|
+
direct_kept = set(direct)
|
|
970
|
+
out["instantiator_confidence"] = [
|
|
971
|
+
e for e in report.get("instantiator_confidence", []) if e["id"] in direct_kept
|
|
972
|
+
]
|
|
973
|
+
out["instantiators"] = [e["id"] for e in out["instantiator_confidence"]]
|
|
974
|
+
out["uncertain_callers"] = [cc[i] for i in uncertain]
|
|
975
|
+
out["uncertain_caller_count"] = len([i for i in direct_all if i in cc and self._is_uncertain(cc[i])])
|
|
976
|
+
out["transitive_callers"] = dict(trans_items)
|
|
977
|
+
out["transitive_initializer_callers"] = dict(initializer_trans_items)
|
|
978
|
+
out["affected_entry_points"] = entries
|
|
979
|
+
out["affected_files"] = [self._rel(f) for f in files]
|
|
980
|
+
out["target_file"] = self._rel(report.get("target_file"))
|
|
981
|
+
out["affected_members"] = members
|
|
982
|
+
keep_ids = ({report.get("target")} | set(direct) | set(initializers)
|
|
983
|
+
| set(uncertain) | set(members)
|
|
984
|
+
| set(out["transitive_callers"])
|
|
985
|
+
| set(out["transitive_initializer_callers"])
|
|
986
|
+
| set(entries) | set(report.get("ambiguous_matches", [])))
|
|
987
|
+
langs = {k: v for k, v in report.get("node_languages", {}).items() if k in keep_ids}
|
|
988
|
+
# Lớp 2: the per-id language map repeats every id once more; keep it
|
|
989
|
+
# only for polyglot results, otherwise a {language: count} summary.
|
|
990
|
+
out["languages"] = dict(Counter(report.get("node_languages", {}).values()))
|
|
991
|
+
if len(set(langs.values())) > 1:
|
|
992
|
+
out["node_languages"] = langs
|
|
993
|
+
else:
|
|
994
|
+
out.pop("node_languages", None)
|
|
995
|
+
out["truncated"] = truncated
|
|
996
|
+
out["_next_offsets"] = next_offsets if truncated else None
|
|
997
|
+
out["summary"] = (
|
|
998
|
+
f"{out['direct_caller_count']} direct callers ({out['uncertain_caller_count']} uncertain, "
|
|
999
|
+
f"{out['instantiator_count']} instantiators), "
|
|
1000
|
+
f"{out['initializer_caller_count']} initializer callers"
|
|
1001
|
+
f" ({'included' if out['include_initializers'] else 'separate'}), "
|
|
1002
|
+
f"{out['transitive_initializer_caller_count']} transitive initializers, "
|
|
1003
|
+
f"{out['transitive_caller_count']} transitive, {out['affected_entry_count']} entry points, "
|
|
1004
|
+
f"{out['affected_file_count']} files"
|
|
1005
|
+
+ (f" within depth {max_depth}" if depth_limited else "")
|
|
1006
|
+
+ (f"; truncated {truncated}" if truncated else "")
|
|
1007
|
+
)
|
|
1008
|
+
return out
|
|
1009
|
+
|
|
1010
|
+
@classmethod
|
|
1011
|
+
def _is_uncertain(cls, item: Dict[str, Any]) -> bool:
|
|
1012
|
+
conf = item.get("confidence")
|
|
1013
|
+
if isinstance(conf, (int, float)) and conf < cls.UNCERTAIN_CONFIDENCE:
|
|
1014
|
+
return True
|
|
1015
|
+
return item.get("resolution") in cls.UNCERTAIN_RESOLUTIONS
|
|
1016
|
+
|
|
1017
|
+
def _rel(self, path: Any) -> Any:
|
|
1018
|
+
"""Lớp 2: file paths project-relative — the absolute prefix repeated on
|
|
1019
|
+
every item was ~20 tokens × items per response for nothing."""
|
|
1020
|
+
if not isinstance(path, str) or not path:
|
|
1021
|
+
return path
|
|
1022
|
+
try:
|
|
1023
|
+
return str(Path(path).relative_to(self.project_root))
|
|
1024
|
+
except ValueError:
|
|
1025
|
+
return path
|
|
1026
|
+
|
|
1027
|
+
def _node_summary(self, builder: CallGraphBuilder, node_id: str) -> Dict[str, Any]:
|
|
1028
|
+
"""Compact node payload: id, name, type, file, line."""
|
|
1029
|
+
node = builder._nodes.get(node_id)
|
|
1030
|
+
if node is None:
|
|
1031
|
+
return {"id": node_id}
|
|
1032
|
+
return {
|
|
1033
|
+
"id": node_id,
|
|
1034
|
+
"qualified_name": node.qualified_name,
|
|
1035
|
+
"name": node.name,
|
|
1036
|
+
"type": node.node_type.name,
|
|
1037
|
+
"file": self._rel(node.file_path),
|
|
1038
|
+
"line": node.line,
|
|
1039
|
+
"language": node.language,
|
|
1040
|
+
"signature": node.signature,
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
def _resolve_symbol(
|
|
1044
|
+
self, builder: CallGraphBuilder, symbol: str, request_id: Any
|
|
1045
|
+
) -> Tuple[Optional[str], List[str], Optional[MCPResponse], bool]:
|
|
1046
|
+
"""find_nodes + uniform not-found error.
|
|
1047
|
+
|
|
1048
|
+
Returns (node_id, matches, error_response, is_fuzzy). When
|
|
1049
|
+
find_nodes's exact/substring tiers find nothing, falls back to the
|
|
1050
|
+
token-overlap guesser (P7 fuzzy fallback) so a behavior-phrased or
|
|
1051
|
+
slightly-off symbol name still resolves to a best guess instead of
|
|
1052
|
+
an error, with ``is_fuzzy=True`` telling the caller to surface the
|
|
1053
|
+
guesses as uncertain.
|
|
1054
|
+
"""
|
|
1055
|
+
matches = builder.find_nodes(symbol)
|
|
1056
|
+
if not matches:
|
|
1057
|
+
fuzzy = self._fuzzy_search_nodes(builder, symbol)
|
|
1058
|
+
if not fuzzy:
|
|
1059
|
+
return None, [], MCPResponse(
|
|
1060
|
+
id=request_id,
|
|
1061
|
+
error={"code": -32602, "message": f"Symbol {symbol} not found"},
|
|
1062
|
+
), False
|
|
1063
|
+
return fuzzy[0]["id"], [m["id"] for m in fuzzy], None, True
|
|
1064
|
+
return matches[0], matches, None, False
|
|
1065
|
+
|
|
1066
|
+
_STOPWORDS = frozenset({
|
|
1067
|
+
"a", "an", "and", "by", "for", "in", "is", "of", "on", "or", "the", "to", "with",
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
_TOKEN_SPLIT_RE = re.compile(
|
|
1071
|
+
r'[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])'
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
@classmethod
|
|
1075
|
+
def _tokenize(cls, text: str) -> List[str]:
|
|
1076
|
+
"""Split an identifier or free-text query into lowercase word tokens.
|
|
1077
|
+
|
|
1078
|
+
Splits camelCase/PascalCase and snake_case/dotted boundaries (so
|
|
1079
|
+
``detectViolations`` yields ``["detect", "violations"]``) and drops
|
|
1080
|
+
stopwords, so a free-text query tokenizes to roughly the same words
|
|
1081
|
+
as the identifiers it describes.
|
|
1082
|
+
"""
|
|
1083
|
+
tokens = [t.lower() for t in cls._TOKEN_SPLIT_RE.split(text) if t]
|
|
1084
|
+
return [t for t in tokens if t not in cls._STOPWORDS]
|
|
1085
|
+
|
|
1086
|
+
@classmethod
|
|
1087
|
+
def _fuzzy_search_nodes(
|
|
1088
|
+
cls,
|
|
1089
|
+
builder: CallGraphBuilder,
|
|
1090
|
+
query: str,
|
|
1091
|
+
kind: Optional[str] = None,
|
|
1092
|
+
limit: int = 10,
|
|
1093
|
+
) -> List[Dict[str, Any]]:
|
|
1094
|
+
"""Token-overlap fallback used when ``_search_nodes``'s exact/substring
|
|
1095
|
+
tiers find nothing.
|
|
1096
|
+
|
|
1097
|
+
Coding questions are often phrased as behavior ("verify
|
|
1098
|
+
detectViolations") rather than an exact identifier, and the tiers in
|
|
1099
|
+
``_search_nodes`` only match literal substrings of a symbol's name —
|
|
1100
|
+
every such query used to burn a full round trip for zero results.
|
|
1101
|
+
This scores nodes by how many query tokens appear (as a whole token,
|
|
1102
|
+
or as a substring of/containing a candidate token) in the node's
|
|
1103
|
+
name or qualified_name, so the agent gets a ranked guess instead of
|
|
1104
|
+
an empty result.
|
|
1105
|
+
"""
|
|
1106
|
+
query_tokens = cls._tokenize(query)
|
|
1107
|
+
if not query_tokens:
|
|
1108
|
+
return []
|
|
1109
|
+
out: List[Dict[str, Any]] = []
|
|
1110
|
+
for nid, node in builder._nodes.items():
|
|
1111
|
+
if kind and node.node_type.name.lower() != kind.lower():
|
|
1112
|
+
continue
|
|
1113
|
+
cand_tokens = set(cls._tokenize(node.name)) | set(cls._tokenize(node.qualified_name))
|
|
1114
|
+
if not cand_tokens:
|
|
1115
|
+
continue
|
|
1116
|
+
hits = sum(
|
|
1117
|
+
1 for qt in query_tokens
|
|
1118
|
+
if qt in cand_tokens or any(qt in ct or ct in qt for ct in cand_tokens)
|
|
1119
|
+
)
|
|
1120
|
+
if hits == 0:
|
|
1121
|
+
continue
|
|
1122
|
+
coverage = hits / len(query_tokens)
|
|
1123
|
+
if coverage < 0.34:
|
|
1124
|
+
continue
|
|
1125
|
+
degree = builder.graph.degree(nid)
|
|
1126
|
+
out.append({
|
|
1127
|
+
"id": nid,
|
|
1128
|
+
"qualified_name": node.qualified_name,
|
|
1129
|
+
"name": node.name,
|
|
1130
|
+
"type": node.node_type.name,
|
|
1131
|
+
"file": node.file_path,
|
|
1132
|
+
"line": node.line,
|
|
1133
|
+
"language": node.language,
|
|
1134
|
+
"signature": node.signature,
|
|
1135
|
+
"degree": degree,
|
|
1136
|
+
"score": round(10 * coverage + math.log2(degree + 1), 2),
|
|
1137
|
+
"fuzzy": True,
|
|
1138
|
+
})
|
|
1139
|
+
out.sort(key=lambda m: (-m["score"], m["id"]))
|
|
1140
|
+
return out[:limit]
|
|
1141
|
+
|
|
1142
|
+
@staticmethod
|
|
1143
|
+
def _search_nodes(
|
|
1144
|
+
builder: CallGraphBuilder, query: str, kind: Optional[str] = None
|
|
1145
|
+
) -> List[Dict[str, Any]]:
|
|
1146
|
+
"""P2.11: ranked symbol search.
|
|
1147
|
+
|
|
1148
|
+
Rank: exact id (100) > exact qualified_name (90, P1.10) > exact name
|
|
1149
|
+
(80) > case-insensitive name (70) > name substring (50) > id /
|
|
1150
|
+
qualified_name substring (30); +log2(degree+1) boost so hubs outrank
|
|
1151
|
+
leaves within a band.
|
|
1152
|
+
"""
|
|
1153
|
+
q = query.lower()
|
|
1154
|
+
out: List[Dict[str, Any]] = []
|
|
1155
|
+
for nid, node in builder._nodes.items():
|
|
1156
|
+
if kind and node.node_type.name.lower() != kind.lower():
|
|
1157
|
+
continue
|
|
1158
|
+
if nid == query:
|
|
1159
|
+
base = 100.0
|
|
1160
|
+
elif node.qualified_name == query:
|
|
1161
|
+
base = 90.0
|
|
1162
|
+
elif node.name == query:
|
|
1163
|
+
base = 80.0
|
|
1164
|
+
elif node.name.lower() == q:
|
|
1165
|
+
base = 70.0
|
|
1166
|
+
elif q and q in node.name.lower():
|
|
1167
|
+
base = 50.0
|
|
1168
|
+
elif q and (q in nid.lower() or q in node.qualified_name.lower()):
|
|
1169
|
+
base = 30.0
|
|
1170
|
+
else:
|
|
1171
|
+
continue
|
|
1172
|
+
degree = builder.graph.degree(nid)
|
|
1173
|
+
out.append({
|
|
1174
|
+
"id": nid,
|
|
1175
|
+
"qualified_name": node.qualified_name,
|
|
1176
|
+
"name": node.name,
|
|
1177
|
+
"type": node.node_type.name,
|
|
1178
|
+
"file": node.file_path,
|
|
1179
|
+
"line": node.line,
|
|
1180
|
+
"language": node.language,
|
|
1181
|
+
"signature": node.signature,
|
|
1182
|
+
"degree": degree,
|
|
1183
|
+
"score": round(base + math.log2(degree + 1), 2),
|
|
1184
|
+
})
|
|
1185
|
+
out.sort(key=lambda m: (-m["score"], m["id"]))
|
|
1186
|
+
return out
|
|
1187
|
+
|
|
1188
|
+
@staticmethod
|
|
1189
|
+
def _module_id_for_file(builder: CallGraphBuilder, file_path: Path) -> Optional[str]:
|
|
1190
|
+
"""Return the graph module node owned by ``file_path``.
|
|
1191
|
+
|
|
1192
|
+
Java module ids come from the source package declaration, not the file
|
|
1193
|
+
path. Use the builder's persisted ownership table first, then fall back
|
|
1194
|
+
to module-node file paths and finally the Python path-derived id.
|
|
1195
|
+
"""
|
|
1196
|
+
raw_path = str(file_path)
|
|
1197
|
+
try:
|
|
1198
|
+
resolved_path = str(file_path.resolve())
|
|
1199
|
+
except OSError:
|
|
1200
|
+
resolved_path = raw_path
|
|
1201
|
+
candidates = {raw_path, resolved_path}
|
|
1202
|
+
|
|
1203
|
+
for module_id, owners in builder._module_owners.items():
|
|
1204
|
+
if candidates.intersection(owners):
|
|
1205
|
+
return module_id
|
|
1206
|
+
for owner in owners:
|
|
1207
|
+
try:
|
|
1208
|
+
if str(Path(owner).resolve()) == resolved_path:
|
|
1209
|
+
return module_id
|
|
1210
|
+
except OSError:
|
|
1211
|
+
continue
|
|
1212
|
+
|
|
1213
|
+
for node_id, node in builder._nodes.items():
|
|
1214
|
+
if node.node_type.name != "MODULE":
|
|
1215
|
+
continue
|
|
1216
|
+
if node.file_path in candidates:
|
|
1217
|
+
return node_id
|
|
1218
|
+
try:
|
|
1219
|
+
if str(Path(node.file_path).resolve()) == resolved_path:
|
|
1220
|
+
return node_id
|
|
1221
|
+
except OSError:
|
|
1222
|
+
continue
|
|
1223
|
+
|
|
1224
|
+
module_id = builder._path_to_module(file_path)
|
|
1225
|
+
if module_id in builder._nodes:
|
|
1226
|
+
return module_id
|
|
1227
|
+
return None
|
|
1228
|
+
|
|
1229
|
+
def _build_graph_result(
|
|
1230
|
+
self,
|
|
1231
|
+
builder: CallGraphBuilder,
|
|
1232
|
+
node_id: str,
|
|
1233
|
+
matches: List[str],
|
|
1234
|
+
is_fuzzy: bool,
|
|
1235
|
+
limit: Optional[int],
|
|
1236
|
+
max_tokens: Optional[int],
|
|
1237
|
+
) -> Dict[str, Any]:
|
|
1238
|
+
"""Shared single-symbol graph payload builder for handle_graph and its batch path.
|
|
1239
|
+
|
|
1240
|
+
P2.11 Part 5: callers/dependencies are node payloads with
|
|
1241
|
+
edge_type + confidence, not bare id lists.
|
|
1242
|
+
"""
|
|
1243
|
+
def _enrich(walk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1244
|
+
out = [{**self._node_summary(builder, w["id"]), **w} for w in walk]
|
|
1245
|
+
for w in out:
|
|
1246
|
+
if self._is_uncertain(w):
|
|
1247
|
+
w["uncertain"] = True
|
|
1248
|
+
return out
|
|
1249
|
+
|
|
1250
|
+
callers = self._order_walk(_enrich(builder.walk_callers(node_id, max_depth=1)))
|
|
1251
|
+
dependencies = self._order_walk(_enrich(builder.walk_callees(node_id, max_depth=None)))
|
|
1252
|
+
callers, callers_truncated = self._apply_budget(callers, limit, max_tokens)
|
|
1253
|
+
dependencies, deps_truncated = self._apply_budget(dependencies, limit, max_tokens)
|
|
1254
|
+
return {
|
|
1255
|
+
"symbol": node_id,
|
|
1256
|
+
"language": builder._nodes[node_id].language,
|
|
1257
|
+
"signature": builder._nodes[node_id].signature,
|
|
1258
|
+
"callers": callers,
|
|
1259
|
+
"dependencies": dependencies,
|
|
1260
|
+
"truncated": callers_truncated + deps_truncated,
|
|
1261
|
+
"is_entry_point": builder._nodes[node_id].is_entry_point,
|
|
1262
|
+
"ambiguous_matches": matches if (len(matches) > 1 or is_fuzzy) else [],
|
|
1263
|
+
"fuzzy_fallback": is_fuzzy,
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
def handle_graph(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1267
|
+
"""Handle drift_graph tool invocation.
|
|
1268
|
+
|
|
1269
|
+
Batch: pass `symbols` (list) instead of `symbol` to fetch call
|
|
1270
|
+
graphs for a cluster of related classes in one round trip (P7
|
|
1271
|
+
batch retrieval) instead of one drift_graph call per symbol —
|
|
1272
|
+
a question spanning ~10 classes used to cost ~10 sequential
|
|
1273
|
+
calls just for this step.
|
|
1274
|
+
"""
|
|
1275
|
+
symbols = params.get("symbols")
|
|
1276
|
+
limit = params.get("limit")
|
|
1277
|
+
max_tokens = self._max_tokens(params, self.DEFAULT_MAX_TOKENS)
|
|
1278
|
+
builder = self._build_full_graph()
|
|
1279
|
+
|
|
1280
|
+
if symbols:
|
|
1281
|
+
per_item_tokens = max_tokens
|
|
1282
|
+
if max_tokens and len(symbols) > 1:
|
|
1283
|
+
per_item_tokens = max(max_tokens // len(symbols), 1)
|
|
1284
|
+
graphs: List[Dict[str, Any]] = []
|
|
1285
|
+
truncated_total = 0
|
|
1286
|
+
for symbol_name in symbols:
|
|
1287
|
+
node_id, matches, err, is_fuzzy = self._resolve_symbol(
|
|
1288
|
+
builder, symbol_name, params.get("request_id")
|
|
1289
|
+
)
|
|
1290
|
+
if err:
|
|
1291
|
+
graphs.append({"symbol": symbol_name, "error": err.error["message"]})
|
|
1292
|
+
continue
|
|
1293
|
+
g = self._build_graph_result(builder, node_id, matches, is_fuzzy, limit, per_item_tokens)
|
|
1294
|
+
if node_id != symbol_name:
|
|
1295
|
+
g["requested_symbol"] = symbol_name
|
|
1296
|
+
truncated_total += g["truncated"]
|
|
1297
|
+
graphs.append(g)
|
|
1298
|
+
return MCPResponse(
|
|
1299
|
+
id=params.get("request_id"),
|
|
1300
|
+
result={
|
|
1301
|
+
"scan_errors": self.last_graph_errors,
|
|
1302
|
+
"graphs": graphs,
|
|
1303
|
+
"count": len(graphs),
|
|
1304
|
+
"truncated": truncated_total,
|
|
1305
|
+
},
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
symbol_name = params["symbol"]
|
|
1309
|
+
node_id, matches, err, is_fuzzy = self._resolve_symbol(
|
|
1310
|
+
builder, symbol_name, params.get("request_id")
|
|
1311
|
+
)
|
|
1312
|
+
if err:
|
|
1313
|
+
return err
|
|
1314
|
+
result = self._build_graph_result(builder, node_id, matches, is_fuzzy, limit, max_tokens)
|
|
1315
|
+
result["scan_errors"] = self.last_graph_errors
|
|
1316
|
+
return MCPResponse(id=params.get("request_id"), result=result)
|
|
1317
|
+
|
|
1318
|
+
@classmethod
|
|
1319
|
+
def _effective_snippet_lines(
|
|
1320
|
+
cls, explicit: Optional[int], node: Optional[GraphNode]
|
|
1321
|
+
) -> int:
|
|
1322
|
+
"""Snippet length for a node: ``explicit`` if the caller set one,
|
|
1323
|
+
otherwise the symbol's real body length (node.end_line - node.line +
|
|
1324
|
+
1) capped at MAX_ADAPTIVE_SNIPPET_LINES, falling back to
|
|
1325
|
+
DEFAULT_SNIPPET_LINES when the node has no end_line (synthetic nodes,
|
|
1326
|
+
pre-P7 cache entries)."""
|
|
1327
|
+
if explicit is not None:
|
|
1328
|
+
return max(1, int(explicit))
|
|
1329
|
+
if node is not None and node.end_line and node.end_line >= node.line:
|
|
1330
|
+
return min(node.end_line - node.line + 1, cls.MAX_ADAPTIVE_SNIPPET_LINES)
|
|
1331
|
+
return cls.DEFAULT_SNIPPET_LINES
|
|
1332
|
+
|
|
1333
|
+
@staticmethod
|
|
1334
|
+
def _read_snippet(file_path: str, line: int, snippet_lines: int) -> Optional[str]:
|
|
1335
|
+
try:
|
|
1336
|
+
fp = Path(file_path)
|
|
1337
|
+
if fp.is_file() and line > 0:
|
|
1338
|
+
lines = fp.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1339
|
+
start = line - 1
|
|
1340
|
+
return "\n".join(lines[start:start + snippet_lines])
|
|
1341
|
+
except OSError:
|
|
1342
|
+
pass
|
|
1343
|
+
return None
|
|
1344
|
+
|
|
1345
|
+
def _handle_search_batch(
|
|
1346
|
+
self,
|
|
1347
|
+
builder: CallGraphBuilder,
|
|
1348
|
+
params: Dict[str, Any],
|
|
1349
|
+
queries: List[str],
|
|
1350
|
+
) -> MCPResponse:
|
|
1351
|
+
"""Batch drift_search: run each query independently so one empty or
|
|
1352
|
+
fuzzy-fallback query doesn't affect the others — the natural unit
|
|
1353
|
+
for a multi-symbol question is a cluster of queries, not one
|
|
1354
|
+
drift_search call per symbol (P7 batch retrieval)."""
|
|
1355
|
+
kind = params.get("kind")
|
|
1356
|
+
limit = params.get("limit")
|
|
1357
|
+
max_tokens = self._max_tokens(params, self.DEFAULT_MAX_TOKENS)
|
|
1358
|
+
per_item_tokens = max_tokens
|
|
1359
|
+
if max_tokens and len(queries) > 1:
|
|
1360
|
+
per_item_tokens = max(max_tokens // len(queries), 1)
|
|
1361
|
+
include_source = bool(params.get("include_source"))
|
|
1362
|
+
requested_snippet_lines = params.get("snippet_lines")
|
|
1363
|
+
char_budget = self._char_budget(per_item_tokens)
|
|
1364
|
+
|
|
1365
|
+
results: List[Dict[str, Any]] = []
|
|
1366
|
+
truncated_total = 0
|
|
1367
|
+
for query in queries:
|
|
1368
|
+
matches = self._search_nodes(builder, query, kind)
|
|
1369
|
+
fuzzy = False
|
|
1370
|
+
if not matches:
|
|
1371
|
+
matches = self._fuzzy_search_nodes(builder, query, kind)
|
|
1372
|
+
fuzzy = bool(matches)
|
|
1373
|
+
matches, truncated = self._apply_budget(matches, limit, per_item_tokens)
|
|
1374
|
+
if include_source and matches:
|
|
1375
|
+
per_match_budget = (
|
|
1376
|
+
max(char_budget // len(matches), 1) if char_budget is not None else None
|
|
1377
|
+
)
|
|
1378
|
+
for m in matches:
|
|
1379
|
+
snippet_lines = self._effective_snippet_lines(
|
|
1380
|
+
requested_snippet_lines, builder._nodes.get(m["id"])
|
|
1381
|
+
)
|
|
1382
|
+
snippet = self._read_snippet(m["file"], m["line"], snippet_lines)
|
|
1383
|
+
if per_match_budget is not None and snippet:
|
|
1384
|
+
lines = snippet.splitlines()
|
|
1385
|
+
while len(lines) > 1 and len("\n".join(lines)) > per_match_budget:
|
|
1386
|
+
lines.pop()
|
|
1387
|
+
snippet = "\n".join(lines)
|
|
1388
|
+
m["snippet"] = snippet
|
|
1389
|
+
truncated_total += truncated
|
|
1390
|
+
results.append({
|
|
1391
|
+
"query": query, "matches": matches, "count": len(matches),
|
|
1392
|
+
"truncated": truncated, "fuzzy_fallback": fuzzy,
|
|
1393
|
+
})
|
|
1394
|
+
return MCPResponse(
|
|
1395
|
+
id=params.get("request_id"),
|
|
1396
|
+
result={"results": results, "count": len(results), "truncated": truncated_total},
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
def handle_search(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1400
|
+
"""drift_search (P2.11): ranked symbol search with kind filter.
|
|
1401
|
+
|
|
1402
|
+
include_source=true embeds a source snippet directly on each
|
|
1403
|
+
returned match (like drift_node's snippet) so a single-symbol
|
|
1404
|
+
question doesn't need a follow-up drift_node call.
|
|
1405
|
+
|
|
1406
|
+
Batch: pass `queries` (list) instead of `query` to run several
|
|
1407
|
+
searches in one call (P7 batch retrieval) — a question spanning
|
|
1408
|
+
several unrelated symbols used to cost one drift_search call per
|
|
1409
|
+
symbol. Batch mode does not support `cursor`.
|
|
1410
|
+
"""
|
|
1411
|
+
builder = self._build_full_graph()
|
|
1412
|
+
queries = params.get("queries")
|
|
1413
|
+
if queries:
|
|
1414
|
+
return self._handle_search_batch(builder, params, queries)
|
|
1415
|
+
|
|
1416
|
+
query = params["query"]
|
|
1417
|
+
matches = self._search_nodes(builder, query, params.get("kind"))
|
|
1418
|
+
fuzzy = False
|
|
1419
|
+
if not matches:
|
|
1420
|
+
matches = self._fuzzy_search_nodes(builder, query, params.get("kind"))
|
|
1421
|
+
fuzzy = bool(matches)
|
|
1422
|
+
matches, truncated, nxt, err = self._page(
|
|
1423
|
+
"drift_search", params, matches, default_limit=20, default_tokens=self.DEFAULT_MAX_TOKENS
|
|
1424
|
+
)
|
|
1425
|
+
if err:
|
|
1426
|
+
return err
|
|
1427
|
+
|
|
1428
|
+
include_source = bool(params.get("include_source"))
|
|
1429
|
+
if include_source and matches:
|
|
1430
|
+
requested_snippet_lines = params.get("snippet_lines")
|
|
1431
|
+
char_budget = self._char_budget(params.get("max_tokens"))
|
|
1432
|
+
per_item_budget = (
|
|
1433
|
+
max(char_budget // len(matches), 1) if char_budget is not None else None
|
|
1434
|
+
)
|
|
1435
|
+
for m in matches:
|
|
1436
|
+
snippet_lines = self._effective_snippet_lines(
|
|
1437
|
+
requested_snippet_lines, builder._nodes.get(m["id"])
|
|
1438
|
+
)
|
|
1439
|
+
snippet = self._read_snippet(m["file"], m["line"], snippet_lines)
|
|
1440
|
+
if per_item_budget is not None and snippet:
|
|
1441
|
+
lines = snippet.splitlines()
|
|
1442
|
+
while len(lines) > 1 and len("\n".join(lines)) > per_item_budget:
|
|
1443
|
+
lines.pop()
|
|
1444
|
+
snippet = "\n".join(lines)
|
|
1445
|
+
m["snippet"] = snippet
|
|
1446
|
+
|
|
1447
|
+
return MCPResponse(
|
|
1448
|
+
id=params.get("request_id"),
|
|
1449
|
+
result={"query": query, "matches": matches,
|
|
1450
|
+
"count": len(matches), "truncated": truncated, "next_cursor": nxt,
|
|
1451
|
+
"fuzzy_fallback": fuzzy},
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
def _build_node_result(
|
|
1455
|
+
self,
|
|
1456
|
+
builder: CallGraphBuilder,
|
|
1457
|
+
node_id: str,
|
|
1458
|
+
matches: List[str],
|
|
1459
|
+
is_fuzzy: bool,
|
|
1460
|
+
requested_snippet_lines: Optional[int],
|
|
1461
|
+
max_source_lines: Optional[int],
|
|
1462
|
+
char_budget: Optional[int],
|
|
1463
|
+
) -> Dict[str, Any]:
|
|
1464
|
+
"""Shared single-node payload builder for handle_node and its batch path."""
|
|
1465
|
+
node = builder._nodes[node_id]
|
|
1466
|
+
snippet_lines = self._effective_snippet_lines(requested_snippet_lines, node)
|
|
1467
|
+
if max_source_lines is not None and int(max_source_lines) > 0:
|
|
1468
|
+
snippet_lines = min(snippet_lines, int(max_source_lines))
|
|
1469
|
+
snippet = self._read_snippet(node.file_path, node.line, snippet_lines)
|
|
1470
|
+
|
|
1471
|
+
in_counts: Counter = Counter(
|
|
1472
|
+
d.get("type") for _, _, d in builder.graph.in_edges(node_id, data=True)
|
|
1473
|
+
)
|
|
1474
|
+
out_counts: Counter = Counter(
|
|
1475
|
+
d.get("type") for _, _, d in builder.graph.out_edges(node_id, data=True)
|
|
1476
|
+
)
|
|
1477
|
+
result = {
|
|
1478
|
+
**self._node_summary(builder, node_id),
|
|
1479
|
+
"module": node.module.split("::", 1)[-1],
|
|
1480
|
+
"signature": node.signature,
|
|
1481
|
+
"docstring": node.docstring,
|
|
1482
|
+
"decorators": node.decorators,
|
|
1483
|
+
"is_async": node.is_async,
|
|
1484
|
+
"is_entry_point": node.is_entry_point,
|
|
1485
|
+
"is_test": node.is_test,
|
|
1486
|
+
"contained_in": node.contained_in,
|
|
1487
|
+
"metadata": node.metadata,
|
|
1488
|
+
"snippet": snippet,
|
|
1489
|
+
"in_edges": dict(in_counts),
|
|
1490
|
+
"out_edges": dict(out_counts),
|
|
1491
|
+
"ambiguous_matches": matches if (len(matches) > 1 or is_fuzzy) else [],
|
|
1492
|
+
"fuzzy_fallback": is_fuzzy,
|
|
1493
|
+
}
|
|
1494
|
+
# Lớp 1: max_tokens trims the snippet from the bottom; everything
|
|
1495
|
+
# else on a node is scalar. truncated = source lines dropped --
|
|
1496
|
+
# either the snippet is shorter than the symbol's real body (capped
|
|
1497
|
+
# adaptive default, or an explicit snippet_lines/max_source_lines
|
|
1498
|
+
# smaller than the body) or max_tokens trimmed it further below.
|
|
1499
|
+
truncated = 0
|
|
1500
|
+
if node.end_line and node.end_line >= node.line:
|
|
1501
|
+
body_lines = node.end_line - node.line + 1
|
|
1502
|
+
if body_lines > snippet_lines:
|
|
1503
|
+
truncated += body_lines - snippet_lines
|
|
1504
|
+
if char_budget is not None and snippet:
|
|
1505
|
+
lines = snippet.splitlines()
|
|
1506
|
+
overhead = len(json.dumps({**result, "snippet": "", "truncated": 0}, default=str))
|
|
1507
|
+
while len(lines) > 1 and overhead + len("\n".join(lines)) > char_budget:
|
|
1508
|
+
lines.pop()
|
|
1509
|
+
truncated += 1
|
|
1510
|
+
result["snippet"] = "\n".join(lines)
|
|
1511
|
+
result["truncated"] = truncated
|
|
1512
|
+
return result
|
|
1513
|
+
|
|
1514
|
+
def _handle_node_batch(
|
|
1515
|
+
self,
|
|
1516
|
+
builder: CallGraphBuilder,
|
|
1517
|
+
params: Dict[str, Any],
|
|
1518
|
+
node_ids: List[str],
|
|
1519
|
+
requested_snippet_lines: Optional[int],
|
|
1520
|
+
max_source_lines: Optional[int],
|
|
1521
|
+
) -> MCPResponse:
|
|
1522
|
+
"""Batch drift_node: resolve + build each id independently so one
|
|
1523
|
+
missing/ambiguous symbol doesn't fail the whole cluster lookup —
|
|
1524
|
+
the natural unit for a multi-class question is a cluster, not a
|
|
1525
|
+
single symbol (P7 batch retrieval)."""
|
|
1526
|
+
total_char_budget = self._char_budget(params.get("max_tokens"))
|
|
1527
|
+
per_item_budget = (
|
|
1528
|
+
max(total_char_budget // max(len(node_ids), 1), 1)
|
|
1529
|
+
if total_char_budget is not None else None
|
|
1530
|
+
)
|
|
1531
|
+
nodes: List[Dict[str, Any]] = []
|
|
1532
|
+
truncated = 0
|
|
1533
|
+
for requested_id in node_ids:
|
|
1534
|
+
node_id, matches, err, is_fuzzy = self._resolve_symbol(
|
|
1535
|
+
builder, requested_id, params.get("request_id")
|
|
1536
|
+
)
|
|
1537
|
+
if err:
|
|
1538
|
+
nodes.append({"id": requested_id, "error": err.error["message"]})
|
|
1539
|
+
continue
|
|
1540
|
+
node_result = self._build_node_result(
|
|
1541
|
+
builder, node_id, matches, is_fuzzy,
|
|
1542
|
+
requested_snippet_lines, max_source_lines, per_item_budget,
|
|
1543
|
+
)
|
|
1544
|
+
if node_id != requested_id:
|
|
1545
|
+
node_result["requested_id"] = requested_id
|
|
1546
|
+
truncated += node_result["truncated"]
|
|
1547
|
+
nodes.append(node_result)
|
|
1548
|
+
return MCPResponse(
|
|
1549
|
+
id=params.get("request_id"),
|
|
1550
|
+
result={"nodes": nodes, "count": len(nodes), "truncated": truncated},
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
def handle_node(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1554
|
+
"""drift_node (P2.11): node detail + source snippet + edge counts.
|
|
1555
|
+
|
|
1556
|
+
Batch: pass `node_ids` (list) instead of `node_id` to fetch several
|
|
1557
|
+
nodes in one call (P7 batch retrieval) — a question spanning
|
|
1558
|
+
several classes used to cost one drift_search + drift_node pair
|
|
1559
|
+
per class.
|
|
1560
|
+
|
|
1561
|
+
snippet_lines, when omitted, is sized to the symbol's real body
|
|
1562
|
+
(node.end_line), capped at MAX_ADAPTIVE_SNIPPET_LINES, instead of a
|
|
1563
|
+
flat default — pass snippet_lines explicitly to override either way.
|
|
1564
|
+
"""
|
|
1565
|
+
builder = self._build_full_graph()
|
|
1566
|
+
requested_snippet_lines = params.get("snippet_lines")
|
|
1567
|
+
max_source_lines = params.get("max_source_lines")
|
|
1568
|
+
|
|
1569
|
+
node_ids = params.get("node_ids")
|
|
1570
|
+
if node_ids:
|
|
1571
|
+
return self._handle_node_batch(builder, params, node_ids, requested_snippet_lines, max_source_lines)
|
|
1572
|
+
|
|
1573
|
+
node_id, matches, err, is_fuzzy = self._resolve_symbol(
|
|
1574
|
+
builder, params["node_id"], params.get("request_id")
|
|
1575
|
+
)
|
|
1576
|
+
if err:
|
|
1577
|
+
return err
|
|
1578
|
+
char_budget = self._char_budget(params.get("max_tokens"))
|
|
1579
|
+
result = self._build_node_result(
|
|
1580
|
+
builder, node_id, matches, is_fuzzy, requested_snippet_lines, max_source_lines, char_budget
|
|
1581
|
+
)
|
|
1582
|
+
return MCPResponse(id=params.get("request_id"), result=result)
|
|
1583
|
+
|
|
1584
|
+
@staticmethod
|
|
1585
|
+
def _order_walk(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1586
|
+
"""Lớp 1: deterministic relevance order before tail-drop — depth asc,
|
|
1587
|
+
confidence desc, id — so a budget keeps the nearest, surest items."""
|
|
1588
|
+
return sorted(
|
|
1589
|
+
items,
|
|
1590
|
+
key=lambda w: (w.get("depth", 0), -float(w.get("confidence") or 0.0), w["id"]),
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
def _handle_walk(self, params: Dict[str, Any], direction: str) -> MCPResponse:
|
|
1594
|
+
"""Shared drift_callers / drift_callees implementation."""
|
|
1595
|
+
builder = self._build_full_graph()
|
|
1596
|
+
node_id, matches, err, is_fuzzy = self._resolve_symbol(
|
|
1597
|
+
builder, params["symbol"], params.get("request_id")
|
|
1598
|
+
)
|
|
1599
|
+
if err:
|
|
1600
|
+
return err
|
|
1601
|
+
max_depth = params.get("max_depth", 1)
|
|
1602
|
+
walk_fn = builder.walk_callers if direction == "callers" else builder.walk_callees
|
|
1603
|
+
items = self._order_walk([
|
|
1604
|
+
{**self._node_summary(builder, w["id"]), **w}
|
|
1605
|
+
for w in walk_fn(node_id, max_depth=max_depth)
|
|
1606
|
+
])
|
|
1607
|
+
# Lớp 2: factual items first, uncertain (low confidence / interface
|
|
1608
|
+
# fan-out) after them, one budget over both so the sure ones survive.
|
|
1609
|
+
certain = [w for w in items if not self._is_uncertain(w)]
|
|
1610
|
+
uncertain = [w for w in items if self._is_uncertain(w)]
|
|
1611
|
+
kept, truncated, nxt, err = self._page(
|
|
1612
|
+
f"drift_{direction}", params, certain + uncertain,
|
|
1613
|
+
default_limit=50, default_tokens=self.DEFAULT_MAX_TOKENS,
|
|
1614
|
+
)
|
|
1615
|
+
if err:
|
|
1616
|
+
return err
|
|
1617
|
+
sure = [w for w in kept if not self._is_uncertain(w)]
|
|
1618
|
+
unsure = [w for w in kept if self._is_uncertain(w)]
|
|
1619
|
+
return MCPResponse(
|
|
1620
|
+
id=params.get("request_id"),
|
|
1621
|
+
result={
|
|
1622
|
+
"symbol": node_id,
|
|
1623
|
+
direction: sure,
|
|
1624
|
+
"uncertain": unsure,
|
|
1625
|
+
"count": len(sure),
|
|
1626
|
+
"uncertain_count": len(unsure),
|
|
1627
|
+
"truncated": truncated,
|
|
1628
|
+
"next_cursor": nxt,
|
|
1629
|
+
"max_depth": max_depth,
|
|
1630
|
+
"ambiguous_matches": matches if (len(matches) > 1 or is_fuzzy) else [],
|
|
1631
|
+
"fuzzy_fallback": is_fuzzy,
|
|
1632
|
+
},
|
|
1633
|
+
)
|
|
1634
|
+
|
|
1635
|
+
def handle_callers(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1636
|
+
"""drift_callers (P2.11): depth-bounded reverse dependency walk."""
|
|
1637
|
+
return self._handle_walk(params, "callers")
|
|
1638
|
+
|
|
1639
|
+
def handle_callees(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1640
|
+
"""drift_callees (P2.11): depth-bounded forward dependency walk."""
|
|
1641
|
+
return self._handle_walk(params, "callees")
|
|
1642
|
+
|
|
1643
|
+
def handle_path(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1644
|
+
"""drift_path (P2.11): shortest path between two symbols.
|
|
1645
|
+
|
|
1646
|
+
`factual` is true only when every hop is a directed, non-uncertain
|
|
1647
|
+
edge — only then does the path order reflect an actual call
|
|
1648
|
+
direction. A "trace how A reaches B" question must check `factual`
|
|
1649
|
+
(or the per-hop `direction`/`edge_to_next.reversed`) before quoting
|
|
1650
|
+
the path as a forward trace; an undirected path (e.g. built from
|
|
1651
|
+
reference edges) only proves A and B are connected, not which one
|
|
1652
|
+
calls the other.
|
|
1653
|
+
"""
|
|
1654
|
+
builder = self._build_full_graph()
|
|
1655
|
+
src_id, src_matches, err, src_fuzzy = self._resolve_symbol(
|
|
1656
|
+
builder, params["from_symbol"], params.get("request_id")
|
|
1657
|
+
)
|
|
1658
|
+
if err:
|
|
1659
|
+
return err
|
|
1660
|
+
dst_id, dst_matches, err, dst_fuzzy = self._resolve_symbol(
|
|
1661
|
+
builder, params["to_symbol"], params.get("request_id")
|
|
1662
|
+
)
|
|
1663
|
+
if err:
|
|
1664
|
+
return err
|
|
1665
|
+
ambiguous_matches = {
|
|
1666
|
+
"from": src_matches if (len(src_matches) > 1 or src_fuzzy) else [],
|
|
1667
|
+
"to": dst_matches if (len(dst_matches) > 1 or dst_fuzzy) else [],
|
|
1668
|
+
}
|
|
1669
|
+
path = builder.find_path(src_id, dst_id)
|
|
1670
|
+
if path is None:
|
|
1671
|
+
return MCPResponse(
|
|
1672
|
+
id=params.get("request_id"),
|
|
1673
|
+
result={
|
|
1674
|
+
"from": src_id, "to": dst_id, "no_path": True,
|
|
1675
|
+
"ambiguous_matches": ambiguous_matches,
|
|
1676
|
+
},
|
|
1677
|
+
)
|
|
1678
|
+
steps = [
|
|
1679
|
+
{**self._node_summary(builder, step["id"]),
|
|
1680
|
+
"edge_to_next": step["edge_to_next"]}
|
|
1681
|
+
for step in path["path"]
|
|
1682
|
+
]
|
|
1683
|
+
# Lớp 1: a path cannot be tail-dropped; under max_tokens the middle
|
|
1684
|
+
# is elided (endpoints always kept), ``length`` stays the true hop
|
|
1685
|
+
# count and ``truncated`` = steps elided.
|
|
1686
|
+
truncated = 0
|
|
1687
|
+
char_budget = self._char_budget(params.get("max_tokens"))
|
|
1688
|
+
if char_budget is not None:
|
|
1689
|
+
while len(steps) > 2 and len(json.dumps(steps, default=str)) > char_budget:
|
|
1690
|
+
steps.pop(len(steps) // 2)
|
|
1691
|
+
truncated += 1
|
|
1692
|
+
path["path"] = steps
|
|
1693
|
+
# Lớp 3: the primary (shortest) path first; up to `alternates` further
|
|
1694
|
+
# simple paths on request, ids only, directed graph only.
|
|
1695
|
+
alternates: List[Dict[str, Any]] = []
|
|
1696
|
+
want = int(params.get("alternates") or 0)
|
|
1697
|
+
if want > 0 and path["direction"] == "directed":
|
|
1698
|
+
alternates = builder.alternate_paths(src_id, dst_id, want)
|
|
1699
|
+
path["alternates"] = alternates
|
|
1700
|
+
hops = [s["edge_to_next"] for s in path["path"] if s.get("edge_to_next")]
|
|
1701
|
+
factual = path["direction"] == "directed" and not any(self._is_uncertain(h) for h in hops)
|
|
1702
|
+
uncertain_hops = sum(1 for h in hops if self._is_uncertain(h))
|
|
1703
|
+
return MCPResponse(
|
|
1704
|
+
id=params.get("request_id"),
|
|
1705
|
+
result={
|
|
1706
|
+
"from": src_id, "to": dst_id, "no_path": False, **path,
|
|
1707
|
+
"factual": factual,
|
|
1708
|
+
"truncated": truncated,
|
|
1709
|
+
"ambiguous_matches": ambiguous_matches,
|
|
1710
|
+
"summary": f"{path['length']} hops, {path['direction']}, "
|
|
1711
|
+
f"{uncertain_hops} uncertain hop(s)" + (f", {truncated} elided" if truncated else ""),
|
|
1712
|
+
},
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
def handle_status(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1716
|
+
"""drift_status (P2.11): index freshness + health, without
|
|
1717
|
+
triggering a rebuild (a rebuild would mask staleness)."""
|
|
1718
|
+
meta = self.memory.get_graph_metadata(self.project_path)
|
|
1719
|
+
files = self._find_files(self.project_path)
|
|
1720
|
+
max_mtime = 0.0
|
|
1721
|
+
for fp in files:
|
|
1722
|
+
try:
|
|
1723
|
+
max_mtime = max(max_mtime, fp.stat().st_mtime)
|
|
1724
|
+
except OSError:
|
|
1725
|
+
continue
|
|
1726
|
+
stale = (
|
|
1727
|
+
meta is None
|
|
1728
|
+
or meta["graph_version"] != GRAPH_SCHEMA_VERSION
|
|
1729
|
+
or max_mtime > (meta["saved_at"] or 0.0)
|
|
1730
|
+
)
|
|
1731
|
+
return MCPResponse(
|
|
1732
|
+
id=params.get("request_id"),
|
|
1733
|
+
result={
|
|
1734
|
+
"project": str(self.project_path),
|
|
1735
|
+
"indexed": meta is not None,
|
|
1736
|
+
"stale": stale,
|
|
1737
|
+
"source_files": len(files),
|
|
1738
|
+
"current_schema_version": GRAPH_SCHEMA_VERSION,
|
|
1739
|
+
"graph": meta,
|
|
1740
|
+
"scan_errors": self.last_graph_errors,
|
|
1741
|
+
},
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
def handle_communities(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1745
|
+
"""drift_communities (P2.11): Louvain clusters for repo orientation."""
|
|
1746
|
+
builder = self._build_full_graph()
|
|
1747
|
+
membership = detect_communities(builder.graph)
|
|
1748
|
+
groups: Dict[int, List[str]] = {}
|
|
1749
|
+
for nid, cid in membership.items():
|
|
1750
|
+
groups.setdefault(cid, []).append(nid)
|
|
1751
|
+
|
|
1752
|
+
communities: List[Dict[str, Any]] = []
|
|
1753
|
+
for cid, members in groups.items():
|
|
1754
|
+
by_degree = sorted(members, key=lambda n: -builder.graph.degree(n))
|
|
1755
|
+
modules = Counter(
|
|
1756
|
+
builder._nodes[n].module for n in members if n in builder._nodes
|
|
1757
|
+
)
|
|
1758
|
+
communities.append({
|
|
1759
|
+
"id": cid,
|
|
1760
|
+
"size": len(members),
|
|
1761
|
+
"top_members": by_degree[:5],
|
|
1762
|
+
"dominant_module": modules.most_common(1)[0][0] if modules else None,
|
|
1763
|
+
})
|
|
1764
|
+
communities.sort(key=lambda c: (-c["size"], c["id"]))
|
|
1765
|
+
communities, truncated, nxt, err = self._page(
|
|
1766
|
+
"drift_communities", params, communities, default_limit=20, default_tokens=self.DEFAULT_MAX_TOKENS
|
|
1767
|
+
)
|
|
1768
|
+
if err:
|
|
1769
|
+
return err
|
|
1770
|
+
god_nodes = builder.god_nodes(top_n=params.get("god_nodes_top_n", 10))
|
|
1771
|
+
return MCPResponse(
|
|
1772
|
+
id=params.get("request_id"),
|
|
1773
|
+
result={
|
|
1774
|
+
"communities": communities,
|
|
1775
|
+
"count": len(communities),
|
|
1776
|
+
"total_communities": len(groups),
|
|
1777
|
+
"truncated": truncated,
|
|
1778
|
+
"next_cursor": nxt,
|
|
1779
|
+
"god_nodes": god_nodes,
|
|
1780
|
+
},
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
def handle_learn(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1784
|
+
"""drift_learn (P2.11 Part 3): record tribal knowledge.
|
|
1785
|
+
|
|
1786
|
+
Append-only DB write; inputs length-capped, no filesystem access.
|
|
1787
|
+
"""
|
|
1788
|
+
topic = (params.get("topic") or "").strip()
|
|
1789
|
+
content = (params.get("content") or "").strip()
|
|
1790
|
+
tags = params.get("tags") or []
|
|
1791
|
+
if not topic or not content:
|
|
1792
|
+
return MCPResponse(
|
|
1793
|
+
id=params.get("request_id"),
|
|
1794
|
+
error={"code": -32602, "message": "topic and content are required"},
|
|
1795
|
+
)
|
|
1796
|
+
if len(topic) > self.MAX_TOPIC_LEN or len(content) > self.MAX_CONTENT_LEN:
|
|
1797
|
+
return MCPResponse(
|
|
1798
|
+
id=params.get("request_id"),
|
|
1799
|
+
error={"code": -32602, "message": (
|
|
1800
|
+
f"topic must be <= {self.MAX_TOPIC_LEN} chars and content "
|
|
1801
|
+
f"<= {self.MAX_CONTENT_LEN} chars"
|
|
1802
|
+
)},
|
|
1803
|
+
)
|
|
1804
|
+
if not isinstance(tags, list) or len(tags) > self.MAX_TAGS \
|
|
1805
|
+
or any(not isinstance(t, str) for t in tags):
|
|
1806
|
+
return MCPResponse(
|
|
1807
|
+
id=params.get("request_id"),
|
|
1808
|
+
error={"code": -32602, "message": f"tags must be <= {self.MAX_TAGS} strings"},
|
|
1809
|
+
)
|
|
1810
|
+
knowledge_id = self.memory.add_tribal_knowledge(topic, content, tags)
|
|
1811
|
+
return MCPResponse(
|
|
1812
|
+
id=params.get("request_id"),
|
|
1813
|
+
result={"id": knowledge_id, "topic": topic, "stored": True},
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
def handle_approve(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1817
|
+
"""drift_approve (P2.11 Part 3): flag a pattern as approved.
|
|
1818
|
+
|
|
1819
|
+
Id validated against existing rows — flag-only DB write.
|
|
1820
|
+
"""
|
|
1821
|
+
pattern_id = (params.get("pattern_id") or "").strip()
|
|
1822
|
+
existing = self.memory.get_pattern(pattern_id) if pattern_id else None
|
|
1823
|
+
if existing is None:
|
|
1824
|
+
return MCPResponse(
|
|
1825
|
+
id=params.get("request_id"),
|
|
1826
|
+
error={"code": -32602, "message": f"Pattern {pattern_id!r} not found"},
|
|
1827
|
+
)
|
|
1828
|
+
self.memory.approve_pattern(pattern_id)
|
|
1829
|
+
return MCPResponse(
|
|
1830
|
+
id=params.get("request_id"),
|
|
1831
|
+
result={
|
|
1832
|
+
"approved": True,
|
|
1833
|
+
"pattern_id": pattern_id,
|
|
1834
|
+
"name": existing.get("name"),
|
|
1835
|
+
"category": existing.get("category"),
|
|
1836
|
+
},
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
def _impact_report_with_fallback(
|
|
1840
|
+
self, builder: CallGraphBuilder, symbol: str, include_initializers: bool
|
|
1841
|
+
) -> Tuple[Dict[str, Any], bool, List[str]]:
|
|
1842
|
+
"""``get_impact_analysis`` + a fuzzy-guess second try, shared by
|
|
1843
|
+
handle_impact and handle_explore.
|
|
1844
|
+
|
|
1845
|
+
``get_impact_analysis`` merges across every distinct exact/substring
|
|
1846
|
+
match itself (so it intentionally does not collapse to a single id
|
|
1847
|
+
the way ``_resolve_symbol`` does) -- fuzzy fallback is therefore only
|
|
1848
|
+
tried as a second step, when that exact/substring search finds
|
|
1849
|
+
nothing at all. Without this, a behavior-phrased or slightly-off
|
|
1850
|
+
symbol name forced a drift_search round trip just to find the exact
|
|
1851
|
+
name before drift_impact would accept it, the single most common
|
|
1852
|
+
multi-call chain observed in real agent sessions.
|
|
1853
|
+
"""
|
|
1854
|
+
report = builder.get_impact_analysis(symbol, include_initializers=include_initializers)
|
|
1855
|
+
fuzzy_fallback = False
|
|
1856
|
+
ambiguous_matches: List[str] = []
|
|
1857
|
+
if "error" in report:
|
|
1858
|
+
fuzzy = self._fuzzy_search_nodes(builder, symbol)
|
|
1859
|
+
if fuzzy:
|
|
1860
|
+
report = builder.get_impact_analysis(
|
|
1861
|
+
fuzzy[0]["id"], include_initializers=include_initializers
|
|
1862
|
+
)
|
|
1863
|
+
fuzzy_fallback = True
|
|
1864
|
+
ambiguous_matches = [m["id"] for m in fuzzy]
|
|
1865
|
+
return report, fuzzy_fallback, ambiguous_matches
|
|
1866
|
+
|
|
1867
|
+
def handle_impact(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1868
|
+
"""Handle drift_impact - blast-radius for a symbol."""
|
|
1869
|
+
builder = self._build_full_graph()
|
|
1870
|
+
symbol = params["symbol"]
|
|
1871
|
+
include_initializers = bool(params.get("include_initializers", False))
|
|
1872
|
+
report, fuzzy_fallback, ambiguous_matches = self._impact_report_with_fallback(
|
|
1873
|
+
builder, symbol, include_initializers
|
|
1874
|
+
)
|
|
1875
|
+
if "error" in report:
|
|
1876
|
+
return MCPResponse(
|
|
1877
|
+
id=params.get("request_id"),
|
|
1878
|
+
error={"code": -32602, "message": report["error"]},
|
|
1879
|
+
)
|
|
1880
|
+
offsets, err = self._read_cursor("drift_impact", params)
|
|
1881
|
+
if err:
|
|
1882
|
+
return err
|
|
1883
|
+
report = self._bound_impact(
|
|
1884
|
+
builder, report,
|
|
1885
|
+
limit=params.get("limit"),
|
|
1886
|
+
max_tokens=self._max_tokens(params, self.IMPACT_DEFAULT_MAX_TOKENS),
|
|
1887
|
+
max_depth=params.get("max_depth"),
|
|
1888
|
+
max_nodes=params.get("max_nodes"),
|
|
1889
|
+
offsets=offsets,
|
|
1890
|
+
)
|
|
1891
|
+
nxt = report.pop("_next_offsets")
|
|
1892
|
+
report["next_cursor"] = self._make_cursor("drift_impact", params, nxt) if nxt else None
|
|
1893
|
+
report["scan_errors"] = self.last_graph_errors
|
|
1894
|
+
report["fuzzy_fallback"] = fuzzy_fallback
|
|
1895
|
+
if fuzzy_fallback:
|
|
1896
|
+
# get_impact_analysis's own ambiguous_matches (multiple exact
|
|
1897
|
+
# matches merged by _distinct_impact_targets) only applies to the
|
|
1898
|
+
# exact/substring path; a fuzzy resolution replaces it with its
|
|
1899
|
+
# own candidate list instead of stacking on top of it.
|
|
1900
|
+
report["ambiguous_matches"] = ambiguous_matches
|
|
1901
|
+
return MCPResponse(id=params.get("request_id"), result=report)
|
|
1902
|
+
|
|
1903
|
+
def handle_explore(self, params: Dict[str, Any]) -> MCPResponse:
|
|
1904
|
+
"""drift_explore: everything about one symbol in a single call.
|
|
1905
|
+
|
|
1906
|
+
Combines what used to take up to 3 round trips on the same symbol
|
|
1907
|
+
(the most common multi-tool chains in real agent sessions, after
|
|
1908
|
+
search->impact itself: impact->graph, impact->callers, graph->
|
|
1909
|
+
callers) -- resolved detail (signature/docstring/snippet, like
|
|
1910
|
+
drift_node), blast radius (direct + transitive callers, like
|
|
1911
|
+
drift_impact), and dependencies (what it calls, like drift_graph's
|
|
1912
|
+
``dependencies``) -- into one response. A new, additive tool:
|
|
1913
|
+
drift_node/drift_impact/drift_graph/drift_callers/drift_callees are
|
|
1914
|
+
unchanged and still the right call for a narrower, single-aspect
|
|
1915
|
+
question or for paging through a huge list via ``cursor`` (this
|
|
1916
|
+
tool does not support one).
|
|
1917
|
+
|
|
1918
|
+
Token budget (default EXPLORE_DEFAULT_MAX_TOKENS, 0 = unbounded) is
|
|
1919
|
+
split across the three sections -- impact gets the largest share
|
|
1920
|
+
since it has the most fields and was the highest-value part in
|
|
1921
|
+
practice; ``*_truncated`` on each section reports what was cut.
|
|
1922
|
+
"""
|
|
1923
|
+
builder = self._build_full_graph()
|
|
1924
|
+
symbol = params["symbol"]
|
|
1925
|
+
include_initializers = bool(params.get("include_initializers", False))
|
|
1926
|
+
report, fuzzy_fallback, ambiguous_matches = self._impact_report_with_fallback(
|
|
1927
|
+
builder, symbol, include_initializers
|
|
1928
|
+
)
|
|
1929
|
+
if "error" in report:
|
|
1930
|
+
return MCPResponse(
|
|
1931
|
+
id=params.get("request_id"),
|
|
1932
|
+
error={"code": -32602, "message": report["error"]},
|
|
1933
|
+
)
|
|
1934
|
+
target_id = report["target"]
|
|
1935
|
+
|
|
1936
|
+
total_tokens = self._max_tokens(params, self.EXPLORE_DEFAULT_MAX_TOKENS)
|
|
1937
|
+
if total_tokens == 0:
|
|
1938
|
+
# "unbounded" still gets a generous, safe ceiling (same policy,
|
|
1939
|
+
# same value as IMPACT_UNBOUNDED_MAX_TOKENS) instead of letting
|
|
1940
|
+
# detail/dependencies stay literally uncapped while impact alone
|
|
1941
|
+
# would get bounded by _bound_impact's own such a check.
|
|
1942
|
+
total_tokens = self.EXPLORE_UNBOUNDED_MAX_TOKENS
|
|
1943
|
+
impact_tokens = max(int(total_tokens * 0.55), 1)
|
|
1944
|
+
deps_tokens = max(int(total_tokens * 0.25), 1)
|
|
1945
|
+
detail_tokens = max(total_tokens - impact_tokens - deps_tokens, 1)
|
|
1946
|
+
|
|
1947
|
+
impact = self._bound_impact(
|
|
1948
|
+
builder, report,
|
|
1949
|
+
limit=params.get("limit"),
|
|
1950
|
+
max_tokens=impact_tokens,
|
|
1951
|
+
max_depth=params.get("max_depth"),
|
|
1952
|
+
max_nodes=params.get("max_nodes"),
|
|
1953
|
+
)
|
|
1954
|
+
impact.pop("_next_offsets", None)
|
|
1955
|
+
for key in ("query", "target", "target_name", "target_file",
|
|
1956
|
+
"target_language", "ambiguous_matches"):
|
|
1957
|
+
impact.pop(key, None)
|
|
1958
|
+
|
|
1959
|
+
detail = self._build_node_result(
|
|
1960
|
+
builder, target_id, [], False,
|
|
1961
|
+
params.get("snippet_lines"), params.get("max_source_lines"),
|
|
1962
|
+
self._char_budget(detail_tokens),
|
|
1963
|
+
)
|
|
1964
|
+
detail.pop("ambiguous_matches", None)
|
|
1965
|
+
detail.pop("fuzzy_fallback", None)
|
|
1966
|
+
detail_truncated = detail.pop("truncated", 0)
|
|
1967
|
+
|
|
1968
|
+
callees_depth = params.get("callees_depth", 1)
|
|
1969
|
+
dependencies = self._order_walk([
|
|
1970
|
+
{**self._node_summary(builder, w["id"]), **w}
|
|
1971
|
+
for w in builder.walk_callees(target_id, max_depth=callees_depth)
|
|
1972
|
+
])
|
|
1973
|
+
for w in dependencies:
|
|
1974
|
+
if self._is_uncertain(w):
|
|
1975
|
+
w["uncertain"] = True
|
|
1976
|
+
dependencies, deps_truncated = self._apply_budget(dependencies, params.get("limit"), deps_tokens)
|
|
1977
|
+
|
|
1978
|
+
return MCPResponse(
|
|
1979
|
+
id=params.get("request_id"),
|
|
1980
|
+
result={
|
|
1981
|
+
"symbol": symbol,
|
|
1982
|
+
"target": target_id,
|
|
1983
|
+
"fuzzy_fallback": fuzzy_fallback,
|
|
1984
|
+
"ambiguous_matches": ambiguous_matches,
|
|
1985
|
+
"detail": detail,
|
|
1986
|
+
"detail_truncated": detail_truncated,
|
|
1987
|
+
"impact": impact,
|
|
1988
|
+
"dependencies": dependencies,
|
|
1989
|
+
"dependencies_truncated": deps_truncated,
|
|
1990
|
+
"scan_errors": self.last_graph_errors,
|
|
1991
|
+
},
|
|
1992
|
+
)
|
|
1993
|
+
|
|
1994
|
+
# ---- harness workflow tools (Antigravity predict-edit-prove loop) ----
|
|
1995
|
+
|
|
1996
|
+
def _resolve_symbol_from_description(
|
|
1997
|
+
self, builder: CallGraphBuilder, description: str, request_id: Any
|
|
1998
|
+
) -> Tuple[Optional[str], Optional[MCPResponse]]:
|
|
1999
|
+
"""Try to find a symbol query from a natural-language change description.
|
|
2000
|
+
|
|
2001
|
+
1. Look for backtick/quoted identifiers.
|
|
2002
|
+
2. Try likely symbol tokens (dotted, underscored, CamelCase, or any
|
|
2003
|
+
identifier-like word) — the first one that matches *anything* in
|
|
2004
|
+
the graph wins.
|
|
2005
|
+
3. Fall back to drift_search on the full description.
|
|
2006
|
+
|
|
2007
|
+
Returns (query, error_response); either is set. Bugfix: this used to
|
|
2008
|
+
resolve the winning token down to a single ``matches[0]`` node id via
|
|
2009
|
+
``_resolve_symbol`` before returning it, which silently discarded any
|
|
2010
|
+
ambiguity that token itself carried (e.g. an overloaded factory
|
|
2011
|
+
method) before ``get_impact_analysis`` ever got a chance to merge or
|
|
2012
|
+
report it. It now returns the raw token/id string unresolved, so the
|
|
2013
|
+
caller can feed it straight into ``get_impact_analysis`` and let that
|
|
2014
|
+
function's own matches/targets merge and ``ambiguous_*`` reporting run
|
|
2015
|
+
as designed.
|
|
2016
|
+
"""
|
|
2017
|
+
# Candidate extraction: backticks, quotes, or identifier-like tokens.
|
|
2018
|
+
candidates: List[str] = []
|
|
2019
|
+
for match in re.finditer(r"`([^`]+)`|\"([^\"]+)\"|'([^']+)'", description):
|
|
2020
|
+
candidates.extend(p for p in match.groups() if p)
|
|
2021
|
+
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_.]*", description):
|
|
2022
|
+
if len(token) >= 2 and token not in candidates:
|
|
2023
|
+
candidates.append(token)
|
|
2024
|
+
|
|
2025
|
+
# Resolve in priority order: quoted candidates first, then symbol-shaped
|
|
2026
|
+
# tokens (dot/underscore/CamelCase), then any remaining word.
|
|
2027
|
+
def _priority(token: str) -> int:
|
|
2028
|
+
if "." in token or "_" in token or token[0].isupper():
|
|
2029
|
+
return 0
|
|
2030
|
+
return 1
|
|
2031
|
+
|
|
2032
|
+
for cand in sorted(candidates, key=_priority):
|
|
2033
|
+
if builder.find_nodes(cand):
|
|
2034
|
+
return cand, None
|
|
2035
|
+
|
|
2036
|
+
# Fallback: ranked search on the full description (already relevance-
|
|
2037
|
+
# ranked by _search_nodes, so matches[0]["id"] here is a deliberate
|
|
2038
|
+
# best guess rather than an arbitrary insertion-order pick).
|
|
2039
|
+
matches = self._search_nodes(builder, description)
|
|
2040
|
+
if matches:
|
|
2041
|
+
return matches[0]["id"], None
|
|
2042
|
+
|
|
2043
|
+
return None, MCPResponse(
|
|
2044
|
+
id=request_id,
|
|
2045
|
+
error={
|
|
2046
|
+
"code": -32602,
|
|
2047
|
+
"message": f"Could not resolve a target symbol from: {description!r}",
|
|
2048
|
+
},
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
def _compact_baseline(self, full: Dict[str, Any]) -> Dict[str, Any]:
|
|
2052
|
+
"""Lớp 2: the agent carries only id + counts; the caller / file sets
|
|
2053
|
+
verify needs live in cortex.db (`impact_baselines`). On JUnit5 hubs the
|
|
2054
|
+
full snapshot alone was 15-45k tokens, paid on the way out *and* back."""
|
|
2055
|
+
return {
|
|
2056
|
+
"id": self.memory.save_impact_baseline(full),
|
|
2057
|
+
"symbol": full["symbol"],
|
|
2058
|
+
"node_count": full["node_count"],
|
|
2059
|
+
"calls_edges": full["calls_edges"],
|
|
2060
|
+
"direct_caller_count": full["direct_caller_count"],
|
|
2061
|
+
"affected_file_count": len(full.get("affected_files", [])),
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
def _expand_baseline(self, baseline: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
2065
|
+
"""Accept a compact baseline ({id, ...}) or a full pre-Lớp-2 one."""
|
|
2066
|
+
if "direct_callers" in baseline:
|
|
2067
|
+
return baseline, None
|
|
2068
|
+
bid = baseline.get("id")
|
|
2069
|
+
if not bid:
|
|
2070
|
+
return None, "baseline has neither an id nor direct_callers; pass the baseline object from drift_plan_change"
|
|
2071
|
+
stored = self.memory.load_impact_baseline(str(bid))
|
|
2072
|
+
if stored is None:
|
|
2073
|
+
return None, f"baseline {bid} not found in this project's cortex.db; run drift_plan_change again"
|
|
2074
|
+
return stored, None
|
|
2075
|
+
|
|
2076
|
+
def _impact_baseline(self, builder: CallGraphBuilder, symbol: str) -> Dict[str, Any]:
|
|
2077
|
+
"""Snapshot blast-radius-critical facts for consistency checks."""
|
|
2078
|
+
calls = sum(
|
|
2079
|
+
1 for _, _, d in builder.graph.edges(data=True)
|
|
2080
|
+
if d.get("type") == EdgeType.CALLS.name
|
|
2081
|
+
)
|
|
2082
|
+
report = builder.get_impact_analysis(symbol)
|
|
2083
|
+
return {
|
|
2084
|
+
"symbol": symbol,
|
|
2085
|
+
"node_count": len(builder._nodes),
|
|
2086
|
+
"calls_edges": calls,
|
|
2087
|
+
"direct_callers": sorted(report.get("direct_callers", [])),
|
|
2088
|
+
"direct_caller_count": report.get("direct_caller_count", 0),
|
|
2089
|
+
"affected_files": sorted(report.get("affected_files", [])),
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
def handle_plan_change(self, params: Dict[str, Any]) -> MCPResponse:
|
|
2093
|
+
"""Predict the blast radius of a planned change and produce a checklist.
|
|
2094
|
+
|
|
2095
|
+
Input: description (required), symbol (optional).
|
|
2096
|
+
Output: resolved symbol, impact report, affected files, editable plan,
|
|
2097
|
+
and a baseline snapshot to pass to drift_verify_change.
|
|
2098
|
+
"""
|
|
2099
|
+
description = (params.get("description") or "").strip()
|
|
2100
|
+
if not description:
|
|
2101
|
+
return MCPResponse(
|
|
2102
|
+
id=params.get("request_id"),
|
|
2103
|
+
error={"code": -32602, "message": "description is required"},
|
|
2104
|
+
)
|
|
2105
|
+
|
|
2106
|
+
builder = self._build_full_graph()
|
|
2107
|
+
symbol = params.get("symbol", "").strip()
|
|
2108
|
+
request_id = params.get("request_id")
|
|
2109
|
+
|
|
2110
|
+
if symbol:
|
|
2111
|
+
query = symbol
|
|
2112
|
+
else:
|
|
2113
|
+
query, err = self._resolve_symbol_from_description(
|
|
2114
|
+
builder, description, request_id
|
|
2115
|
+
)
|
|
2116
|
+
if err:
|
|
2117
|
+
return err
|
|
2118
|
+
|
|
2119
|
+
# Bugfix: `query` is passed through unresolved (not pre-collapsed to
|
|
2120
|
+
# matches[0] via _resolve_symbol) so get_impact_analysis performs its
|
|
2121
|
+
# own matches/targets merge and reports ambiguous_matches/
|
|
2122
|
+
# ambiguous_targets correctly for an ambiguous name (see drift_path's
|
|
2123
|
+
# equivalent fix) instead of silently analyzing one arbitrary target.
|
|
2124
|
+
impact_report = builder.get_impact_analysis(query)
|
|
2125
|
+
if "error" in impact_report:
|
|
2126
|
+
return MCPResponse(
|
|
2127
|
+
id=request_id,
|
|
2128
|
+
error={"code": -32602, "message": impact_report["error"]},
|
|
2129
|
+
)
|
|
2130
|
+
node_id = impact_report["target"]
|
|
2131
|
+
# Lớp 1: max_depth was advertised (default 2) but never applied; the
|
|
2132
|
+
# embedded report is now depth- and token-bounded like drift_impact.
|
|
2133
|
+
impact_report = self._bound_impact(
|
|
2134
|
+
builder, impact_report,
|
|
2135
|
+
limit=params.get("limit"),
|
|
2136
|
+
max_tokens=self._max_tokens(params, self.PLAN_DEFAULT_MAX_TOKENS),
|
|
2137
|
+
max_depth=params.get("max_depth", 2),
|
|
2138
|
+
max_nodes=params.get("max_nodes"),
|
|
2139
|
+
)
|
|
2140
|
+
impact_report.pop("_next_offsets", None)
|
|
2141
|
+
impact_report["scan_errors"] = self.last_graph_errors
|
|
2142
|
+
|
|
2143
|
+
affected_files = impact_report.get("affected_files", [])
|
|
2144
|
+
more_files = impact_report["truncated"].get("affected_files", 0)
|
|
2145
|
+
|
|
2146
|
+
plan_steps: List[str] = [
|
|
2147
|
+
f"Edit the definition of {node_id}.",
|
|
2148
|
+
]
|
|
2149
|
+
if impact_report["direct_caller_count"]:
|
|
2150
|
+
plan_steps.append(
|
|
2151
|
+
f"Update {impact_report['direct_caller_count']} direct caller(s) across "
|
|
2152
|
+
f"{impact_report['affected_file_count']} file(s): {', '.join(affected_files)}"
|
|
2153
|
+
+ (f" (+{more_files} more)." if more_files else ".")
|
|
2154
|
+
)
|
|
2155
|
+
else:
|
|
2156
|
+
plan_steps.append("No direct callers found — confirm the change surface with drift_search if needed.")
|
|
2157
|
+
plan_steps.append("After editing, call drift_verify_change with the baseline to rescan and prove consistency.")
|
|
2158
|
+
|
|
2159
|
+
# Lớp 2: affected_files / direct_callers are already in `impact`;
|
|
2160
|
+
# repeating them here was the biggest duplicate in the baseline bench.
|
|
2161
|
+
result = {
|
|
2162
|
+
"symbol": node_id,
|
|
2163
|
+
"description": description,
|
|
2164
|
+
"summary": impact_report["summary"],
|
|
2165
|
+
"impact": impact_report,
|
|
2166
|
+
"plan": "\n".join(f"{i + 1}. {step}" for i, step in enumerate(plan_steps)),
|
|
2167
|
+
"baseline": self._compact_baseline(self._impact_baseline(builder, query)),
|
|
2168
|
+
"next_tool": "drift_verify_change",
|
|
2169
|
+
}
|
|
2170
|
+
return MCPResponse(id=request_id, result=result)
|
|
2171
|
+
|
|
2172
|
+
def handle_verify_change(self, params: Dict[str, Any]) -> MCPResponse:
|
|
2173
|
+
"""Verify a code change: rescan changed files and compare to a baseline.
|
|
2174
|
+
|
|
2175
|
+
Input: baseline (required, from drift_plan_change), changed_files (optional),
|
|
2176
|
+
symbol (optional override).
|
|
2177
|
+
Output: scan result, consistency verdict, before/after metrics, next actions.
|
|
2178
|
+
"""
|
|
2179
|
+
baseline = params.get("baseline")
|
|
2180
|
+
if not isinstance(baseline, dict):
|
|
2181
|
+
return MCPResponse(
|
|
2182
|
+
id=params.get("request_id"),
|
|
2183
|
+
error={"code": -32602, "message": "baseline is required (pass the baseline from drift_plan_change)"},
|
|
2184
|
+
)
|
|
2185
|
+
|
|
2186
|
+
baseline, problem = self._expand_baseline(baseline)
|
|
2187
|
+
if problem:
|
|
2188
|
+
return MCPResponse(id=params.get("request_id"), error={"code": -32602, "message": problem})
|
|
2189
|
+
|
|
2190
|
+
symbol = (params.get("symbol") or baseline.get("symbol") or "").strip()
|
|
2191
|
+
if not symbol:
|
|
2192
|
+
return MCPResponse(
|
|
2193
|
+
id=params.get("request_id"),
|
|
2194
|
+
error={"code": -32602, "message": "baseline is missing a symbol"},
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
request_id = params.get("request_id")
|
|
2198
|
+
changed_files = params.get("changed_files") or []
|
|
2199
|
+
|
|
2200
|
+
# 1. Rescan changed files (auto-detect if not provided).
|
|
2201
|
+
if changed_files:
|
|
2202
|
+
scanned = 0
|
|
2203
|
+
errors: List[Dict[str, str]] = []
|
|
2204
|
+
for raw in changed_files:
|
|
2205
|
+
fp = Path(raw)
|
|
2206
|
+
if not fp.is_absolute():
|
|
2207
|
+
fp = (self.project_path / fp).resolve()
|
|
2208
|
+
if fp.is_file() and backend_for_file(fp) is not None:
|
|
2209
|
+
try:
|
|
2210
|
+
self._scan_file(fp, None)
|
|
2211
|
+
scanned += 1
|
|
2212
|
+
except SCAN_FILE_ERRORS as e:
|
|
2213
|
+
errors.append({"file": str(fp), "error": f"{type(e).__name__}: {e}"})
|
|
2214
|
+
# Rebuild graph so subsequent impact uses fresh data.
|
|
2215
|
+
builder = self._build_full_graph()
|
|
2216
|
+
scan_errors = self.last_graph_errors + errors
|
|
2217
|
+
else:
|
|
2218
|
+
scanner = IncrementalScanner(self.memory, self.parser, self.extractor, self.registry)
|
|
2219
|
+
files = self._find_files(self.project_path)
|
|
2220
|
+
changed, _, _deleted = scanner.classify_files(self.project_path, files)
|
|
2221
|
+
# MCP_GRAPH_CACHE_PLAN.md Lớp 2: _build_full_graph() already does
|
|
2222
|
+
# "rebuild if anything changed, else the persisted graph" — and
|
|
2223
|
+
# serves the in-memory copy when nothing changed.
|
|
2224
|
+
builder = self._build_full_graph()
|
|
2225
|
+
scan_errors = self.last_graph_errors
|
|
2226
|
+
scanned = len(changed)
|
|
2227
|
+
|
|
2228
|
+
# 2. Re-run impact on the target symbol.
|
|
2229
|
+
after_report = builder.get_impact_analysis(symbol)
|
|
2230
|
+
if "error" in after_report:
|
|
2231
|
+
return MCPResponse(
|
|
2232
|
+
id=request_id,
|
|
2233
|
+
error={"code": -32602, "message": after_report["error"]},
|
|
2234
|
+
)
|
|
2235
|
+
after_baseline = self._impact_baseline(builder, symbol)
|
|
2236
|
+
# Lớp 1: the embedded report is bounded; the consistency check above
|
|
2237
|
+
# and below uses the full baselines, never the bounded copy.
|
|
2238
|
+
after_report = self._bound_impact(
|
|
2239
|
+
builder, after_report,
|
|
2240
|
+
limit=params.get("limit"),
|
|
2241
|
+
max_tokens=self._max_tokens(params, self.PLAN_DEFAULT_MAX_TOKENS),
|
|
2242
|
+
max_nodes=params.get("max_nodes"),
|
|
2243
|
+
)
|
|
2244
|
+
after_report.pop("_next_offsets", None)
|
|
2245
|
+
|
|
2246
|
+
# 3. Consistency check against baseline.
|
|
2247
|
+
problems: List[str] = []
|
|
2248
|
+
before_nodes = baseline.get("node_count", 0)
|
|
2249
|
+
after_nodes = after_baseline["node_count"]
|
|
2250
|
+
if after_nodes < before_nodes:
|
|
2251
|
+
problems.append(f"node count dropped {before_nodes} -> {after_nodes}")
|
|
2252
|
+
|
|
2253
|
+
before_calls = baseline.get("calls_edges", 0)
|
|
2254
|
+
after_calls = after_baseline["calls_edges"]
|
|
2255
|
+
if before_calls > 0 and after_calls < before_calls * 0.9:
|
|
2256
|
+
problems.append(
|
|
2257
|
+
f"CALLS edges collapsed {before_calls} -> {after_calls} (>10% loss)"
|
|
2258
|
+
)
|
|
2259
|
+
|
|
2260
|
+
before_callers = set(baseline.get("direct_callers", []))
|
|
2261
|
+
after_callers = set(after_baseline["direct_callers"])
|
|
2262
|
+
lost = before_callers - after_callers
|
|
2263
|
+
gained = after_callers - before_callers
|
|
2264
|
+
if lost:
|
|
2265
|
+
problems.append(f"lost {len(lost)} caller edge(s): {sorted(lost)}")
|
|
2266
|
+
|
|
2267
|
+
consistent = not problems and not scan_errors
|
|
2268
|
+
verdict = "CONSISTENT" if consistent else "INCONSISTENT"
|
|
2269
|
+
|
|
2270
|
+
# 4. Suggest next actions.
|
|
2271
|
+
next_actions: List[str] = []
|
|
2272
|
+
if scan_errors:
|
|
2273
|
+
next_actions.append("Fix scan errors and re-run drift_verify_change.")
|
|
2274
|
+
if gained:
|
|
2275
|
+
next_actions.append(
|
|
2276
|
+
f"Review {len(gained)} newly-resolved caller(s): {sorted(gained)}."
|
|
2277
|
+
)
|
|
2278
|
+
if lost:
|
|
2279
|
+
next_actions.append(
|
|
2280
|
+
"Caller edges were lost — check that all call sites were updated and rescan."
|
|
2281
|
+
)
|
|
2282
|
+
if consistent:
|
|
2283
|
+
next_actions.append("Graph is consistent. You can proceed to the next change or finish.")
|
|
2284
|
+
|
|
2285
|
+
result = {
|
|
2286
|
+
"scanned": scanned,
|
|
2287
|
+
"scan_errors": scan_errors,
|
|
2288
|
+
"consistent": consistent,
|
|
2289
|
+
"verdict": verdict,
|
|
2290
|
+
"symbol": symbol,
|
|
2291
|
+
"node_count": {"before": before_nodes, "after": after_nodes},
|
|
2292
|
+
"calls_edges": {"before": before_calls, "after": after_calls},
|
|
2293
|
+
"direct_callers": {
|
|
2294
|
+
"before": baseline.get("direct_caller_count", 0),
|
|
2295
|
+
"after": after_baseline["direct_caller_count"],
|
|
2296
|
+
"lost": sorted(lost),
|
|
2297
|
+
"gained": sorted(gained),
|
|
2298
|
+
},
|
|
2299
|
+
# Lớp 2: the before/after file lists are in the baseline (input)
|
|
2300
|
+
# and in `impact`; only the delta is new information.
|
|
2301
|
+
"affected_files": {
|
|
2302
|
+
"before": len(baseline.get("affected_files", [])),
|
|
2303
|
+
"after": len(after_baseline["affected_files"]),
|
|
2304
|
+
"added": sorted(self._rel(f) for f in set(after_baseline["affected_files"]) - set(baseline.get("affected_files", []))),
|
|
2305
|
+
"removed": sorted(self._rel(f) for f in set(baseline.get("affected_files", [])) - set(after_baseline["affected_files"])),
|
|
2306
|
+
},
|
|
2307
|
+
"summary": f"{verdict}: {after_report['summary']}"
|
|
2308
|
+
+ (f"; lost {len(lost)}, gained {len(gained)}" if lost or gained else ""),
|
|
2309
|
+
"impact": after_report,
|
|
2310
|
+
"next_actions": next_actions,
|
|
2311
|
+
}
|
|
2312
|
+
return MCPResponse(id=request_id, result=result)
|
|
2313
|
+
|
|
2314
|
+
def _build_full_graph(self) -> CallGraphBuilder:
|
|
2315
|
+
"""Build or incrementally update the call graph.
|
|
2316
|
+
|
|
2317
|
+
Per-file failures are recorded in ``self.last_graph_errors`` (P2.5)
|
|
2318
|
+
and attached to graph/impact responses.
|
|
2319
|
+
|
|
2320
|
+
MCP_GRAPH_CACHE_PLAN.md Lớp 1: the builder from the previous call is
|
|
2321
|
+
kept in memory with the stat-based source fingerprint it was built or
|
|
2322
|
+
loaded against. An unchanged fingerprint returns it without touching
|
|
2323
|
+
SQLite or hashing any file (``last_graph_errors`` is left as-is: the
|
|
2324
|
+
graph did not change, so neither did its per-file errors). A hit whose
|
|
2325
|
+
newest source mtime lies inside ``GRAPH_CACHE_RACY_WINDOW_SECONDS`` of
|
|
2326
|
+
the cached fingerprint's stat time is confirmed by the content-hash
|
|
2327
|
+
classification first, since such a file may have been rewritten
|
|
2328
|
+
without altering its size/mtime.
|
|
2329
|
+
"""
|
|
2330
|
+
taken_at = time.time()
|
|
2331
|
+
# Lớp 2: discover once; the fingerprint, the change classification
|
|
2332
|
+
# and the deleted-file prune below all consume this same list.
|
|
2333
|
+
files = self._find_files(self.project_path)
|
|
2334
|
+
max_mtime, fingerprint = CortexMemory._source_state(self.project_path, files)
|
|
2335
|
+
cache = self._graph_cache
|
|
2336
|
+
cache_hit = cache is not None and cache.fingerprint == fingerprint
|
|
2337
|
+
cached_update = {"mode": "cached", "invalidated_files": [], "reason": "no source changes"}
|
|
2338
|
+
if cache_hit and max_mtime < cache.taken_at - GRAPH_CACHE_RACY_WINDOW_SECONDS:
|
|
2339
|
+
self.last_graph_update = cached_update
|
|
2340
|
+
return cache.builder
|
|
2341
|
+
|
|
2342
|
+
scanner = IncrementalScanner(self.memory, self.parser, self.extractor, self.registry)
|
|
2343
|
+
changed, unchanged, deleted = scanner.classify_files(self.project_path, files)
|
|
2344
|
+
|
|
2345
|
+
if cache_hit and not changed and not deleted:
|
|
2346
|
+
# Racy-window hit confirmed clean by content hash; re-stamp so the
|
|
2347
|
+
# next call outside the window takes the fast path.
|
|
2348
|
+
self._graph_cache = _GraphCacheEntry(fingerprint, taken_at, cache.builder)
|
|
2349
|
+
self.last_graph_update = cached_update
|
|
2350
|
+
return cache.builder
|
|
2351
|
+
|
|
2352
|
+
self.last_graph_errors = []
|
|
2353
|
+
if not changed and not deleted:
|
|
2354
|
+
builder = self.memory.load_graph(self.project_path, files=files)
|
|
2355
|
+
if builder is not None:
|
|
2356
|
+
self._graph_cache = _GraphCacheEntry(fingerprint, taken_at, builder)
|
|
2357
|
+
self.last_graph_update = cached_update
|
|
2358
|
+
return builder
|
|
2359
|
+
# Nothing changed but no valid persisted graph: rebuild from the
|
|
2360
|
+
# cached per-file results below.
|
|
2361
|
+
mode, reason = "full_rebuild", "persisted graph unavailable"
|
|
2362
|
+
else:
|
|
2363
|
+
# MCP_INCREMENTAL_REBUILD_PLAN.md Lớp 1 — same decision as
|
|
2364
|
+
# DriftCommands.build_graph(incremental=True): a Java edit that
|
|
2365
|
+
# changes any declaration surface (or deletes a .java file) can
|
|
2366
|
+
# alter resolution anywhere, so it is a deliberate full rebuild;
|
|
2367
|
+
# a body-only edit re-resolves just the changed units on top of
|
|
2368
|
+
# the persisted graph.
|
|
2369
|
+
builder = None
|
|
2370
|
+
if any(fp.suffix != ".java" for fp in (*changed, *deleted)):
|
|
2371
|
+
# MCP_INCREMENTAL_REBUILD_PLAN.md Decision #9: only Java has a
|
|
2372
|
+
# declaration-fingerprint scope rule. A Python declaration
|
|
2373
|
+
# change would leave callers in *unchanged* files unresolved
|
|
2374
|
+
# (Decision #8), so any non-Java change keeps the pre-Lớp-1
|
|
2375
|
+
# behaviour — correct, full rebuild — until that rule exists.
|
|
2376
|
+
mode, reason = "full_rebuild", "non-Java change: no incremental scope rule for this language yet"
|
|
2377
|
+
else:
|
|
2378
|
+
scope, reason = java_incremental_scope(self.project_path, scanner, changed, deleted)
|
|
2379
|
+
if scope is None:
|
|
2380
|
+
mode = "full_rebuild"
|
|
2381
|
+
else:
|
|
2382
|
+
mode = "dependency_scoped"
|
|
2383
|
+
changed = [fp for fp in changed if fp in scope]
|
|
2384
|
+
if mode != "full_rebuild":
|
|
2385
|
+
# allow_stale: the source fingerprint has moved by definition;
|
|
2386
|
+
# this path removes and rebuilds the invalidated scope itself.
|
|
2387
|
+
builder = self.memory.load_graph(self.project_path, allow_stale=True)
|
|
2388
|
+
if builder is None:
|
|
2389
|
+
mode, reason = "full_rebuild", "persisted graph unavailable"
|
|
2390
|
+
else:
|
|
2391
|
+
# Mirror DriftCommands: the persisted graph is keyed by the
|
|
2392
|
+
# scan root; reapply the project root and drop the derived
|
|
2393
|
+
# caches so changed files get the same module namespace a
|
|
2394
|
+
# full rebuild would assign.
|
|
2395
|
+
builder.project_root = self.project_root.resolve()
|
|
2396
|
+
builder._project_languages = None
|
|
2397
|
+
builder._java_build_module_roots = None
|
|
2398
|
+
builder._java_namespace_visibility = None
|
|
2399
|
+
|
|
2400
|
+
self.last_graph_update = {
|
|
2401
|
+
"mode": mode,
|
|
2402
|
+
"invalidated_files": [str(fp) for fp in (*changed, *deleted)],
|
|
2403
|
+
"reason": reason,
|
|
2404
|
+
}
|
|
2405
|
+
full_rebuild = builder is None
|
|
2406
|
+
if full_rebuild:
|
|
2407
|
+
builder = CallGraphBuilder(project_root=self.project_root)
|
|
2408
|
+
if mode == "full_rebuild" and (changed or deleted) and reason != "persisted graph unavailable":
|
|
2409
|
+
# Deliberate full rebuild (structural Java change): every
|
|
2410
|
+
# file is re-parsed and its cached result refreshed, exactly
|
|
2411
|
+
# like DriftCommands.build_graph(incremental=False).
|
|
2412
|
+
changed, unchanged, deleted = list(files), [], []
|
|
2413
|
+
|
|
2414
|
+
for fp in deleted + changed:
|
|
2415
|
+
builder.remove_file(str(fp))
|
|
2416
|
+
|
|
2417
|
+
parses: Dict[Path, Any] = {}
|
|
2418
|
+
results: Dict[Path, Any] = {}
|
|
2419
|
+
|
|
2420
|
+
for fp in changed:
|
|
2421
|
+
try:
|
|
2422
|
+
self._configure_backend(self.project_path, self._backend_name(fp))
|
|
2423
|
+
pr = self.parser.parse_file(fp)
|
|
2424
|
+
ar = self.extractor._build(pr, fp)
|
|
2425
|
+
# P1.11: this path skips full detection, but the graph still
|
|
2426
|
+
# needs ROUTES patterns to mark route handlers as entry points.
|
|
2427
|
+
ar.patterns = self._route_patterns(pr, fp)
|
|
2428
|
+
parses[fp] = pr
|
|
2429
|
+
results[fp] = ar
|
|
2430
|
+
except SCAN_FILE_ERRORS as e:
|
|
2431
|
+
self.last_graph_errors.append({
|
|
2432
|
+
"file": str(fp), "error": f"{type(e).__name__}: {e}",
|
|
2433
|
+
})
|
|
2434
|
+
scanner.store_results(self.project_path, list(results.items()))
|
|
2435
|
+
|
|
2436
|
+
results.update(scanner.load_cached_results(self.project_path, unchanged))
|
|
2437
|
+
|
|
2438
|
+
# A persisted builder already owns every unchanged node/edge; adding
|
|
2439
|
+
# those results again repopulates pending indexes and defeats the
|
|
2440
|
+
# scoped update (mirrored from DriftCommands). A fresh builder needs
|
|
2441
|
+
# the complete set.
|
|
2442
|
+
changed_set = set(changed)
|
|
2443
|
+
for fp, ar in results.items():
|
|
2444
|
+
if full_rebuild or fp in changed_set:
|
|
2445
|
+
builder.add_file_result(ar)
|
|
2446
|
+
|
|
2447
|
+
# Full rebuild: re-parse unchanged files too, or their outbound
|
|
2448
|
+
# CALLS edges are lost (P0.5 fix, mirrored from DriftCommands).
|
|
2449
|
+
if full_rebuild:
|
|
2450
|
+
for fp in results.keys():
|
|
2451
|
+
if fp not in parses:
|
|
2452
|
+
try:
|
|
2453
|
+
parses[fp] = self._parse_file(fp)
|
|
2454
|
+
except SCAN_FILE_ERRORS as e:
|
|
2455
|
+
self.last_graph_errors.append({
|
|
2456
|
+
"file": str(fp), "error": f"{type(e).__name__}: {e}",
|
|
2457
|
+
})
|
|
2458
|
+
|
|
2459
|
+
if parses:
|
|
2460
|
+
self._resolve_graph(builder, parses, results)
|
|
2461
|
+
builder.reconcile_edges()
|
|
2462
|
+
|
|
2463
|
+
scanner.prune_deleted_files(self.project_path, {str(f.resolve()) for f in files})
|
|
2464
|
+
self.memory.save_graph(self.project_path, builder, files=files)
|
|
2465
|
+
self._graph_cache = _GraphCacheEntry(fingerprint, taken_at, builder)
|
|
2466
|
+
return builder
|
|
2467
|
+
|
|
2468
|
+
def handle_why(self, params: Dict[str, Any]) -> MCPResponse:
|
|
2469
|
+
"""Handle drift_why tool - retrieve tribal knowledge."""
|
|
2470
|
+
topic = params["topic"]
|
|
2471
|
+
knowledge = self.memory.get_tribal_knowledge(topic=topic)
|
|
2472
|
+
|
|
2473
|
+
# Also do semantic search
|
|
2474
|
+
similar = self.memory.search_similar(topic, limit=5)
|
|
2475
|
+
|
|
2476
|
+
return MCPResponse(
|
|
2477
|
+
id=params.get("request_id"),
|
|
2478
|
+
result={
|
|
2479
|
+
"topic": topic,
|
|
2480
|
+
"knowledge": knowledge,
|
|
2481
|
+
"similar_patterns": similar,
|
|
2482
|
+
}
|
|
2483
|
+
)
|
|
2484
|
+
|
|
2485
|
+
# drift_context v2 (P2.11 Part 4): closure BFS follows dependency edges
|
|
2486
|
+
# plus CONTAINS, in both directions, at most this many hops from a seed.
|
|
2487
|
+
CONTEXT_CLOSURE_HOPS = 2
|
|
2488
|
+
CONTEXT_SEEDS = 5
|
|
2489
|
+
|
|
2490
|
+
def handle_context(self, params: Dict[str, Any]) -> MCPResponse:
|
|
2491
|
+
"""drift_context v2 (P2.11 Part 4) — graph-ranked context assembly.
|
|
2492
|
+
|
|
2493
|
+
1. Seed: query terms ranked against node names (drift_search ranking).
|
|
2494
|
+
2. Closure: BFS <= 2 hops from top-5 seeds over IMPACT_EDGE_TYPES +
|
|
2495
|
+
CONTAINS, both directions.
|
|
2496
|
+
3. Rank: 1/(1+dist) * (1+log2(degree+1)) * community-overlap bonus,
|
|
2497
|
+
normalized to [0, 1].
|
|
2498
|
+
4. Assemble: symbols (signature + first docstring line + file:line),
|
|
2499
|
+
patterns on closure files, tribal knowledge by term match,
|
|
2500
|
+
framework hint.
|
|
2501
|
+
5. Trim to max_tokens (default 2000), lowest score first.
|
|
2502
|
+
"""
|
|
2503
|
+
query = params["query"]
|
|
2504
|
+
file_path = params.get("file_path")
|
|
2505
|
+
max_tokens = int(self._max_tokens(params, self.DEFAULT_MAX_TOKENS) or 0)
|
|
2506
|
+
builder = self._build_full_graph()
|
|
2507
|
+
|
|
2508
|
+
# 1. Seeds — best score per node across the full query + its terms.
|
|
2509
|
+
terms = [t for t in re.split(r"[^\w.]+", query) if len(t) >= 3]
|
|
2510
|
+
best: Dict[str, Dict[str, Any]] = {}
|
|
2511
|
+
for term in [query] + terms:
|
|
2512
|
+
for m in self._search_nodes(builder, term):
|
|
2513
|
+
cur = best.get(m["id"])
|
|
2514
|
+
if cur is None or m["score"] > cur["score"]:
|
|
2515
|
+
best[m["id"]] = m
|
|
2516
|
+
seeds = sorted(best.values(), key=lambda m: -m["score"])[:self.CONTEXT_SEEDS]
|
|
2517
|
+
seed_ids = [s["id"] for s in seeds]
|
|
2518
|
+
# The current file's module node anchors the closure when provided.
|
|
2519
|
+
if file_path:
|
|
2520
|
+
mod_id = self._module_id_for_file(builder, Path(file_path))
|
|
2521
|
+
if mod_id in builder._nodes and mod_id not in seed_ids:
|
|
2522
|
+
seed_ids.append(mod_id)
|
|
2523
|
+
|
|
2524
|
+
# 2. Closure
|
|
2525
|
+
closure_types = set(CallGraphBuilder.IMPACT_EDGE_TYPES) | {EdgeType.CONTAINS.name}
|
|
2526
|
+
dist: Dict[str, int] = {sid: 0 for sid in seed_ids}
|
|
2527
|
+
q: deque = deque((sid, 0) for sid in seed_ids)
|
|
2528
|
+
while q:
|
|
2529
|
+
cur, d = q.popleft()
|
|
2530
|
+
if d >= self.CONTEXT_CLOSURE_HOPS:
|
|
2531
|
+
continue
|
|
2532
|
+
neighbors = [
|
|
2533
|
+
v for _, v, dd in builder.graph.out_edges(cur, data=True)
|
|
2534
|
+
if dd.get("type") in closure_types
|
|
2535
|
+
] + [
|
|
2536
|
+
u for u, _, dd in builder.graph.in_edges(cur, data=True)
|
|
2537
|
+
if dd.get("type") in closure_types
|
|
2538
|
+
]
|
|
2539
|
+
for n in neighbors:
|
|
2540
|
+
if n not in dist:
|
|
2541
|
+
dist[n] = d + 1
|
|
2542
|
+
q.append((n, d + 1))
|
|
2543
|
+
|
|
2544
|
+
# 3. Rank
|
|
2545
|
+
membership = detect_communities(builder.graph)
|
|
2546
|
+
seed_comms = {membership.get(sid) for sid in seed_ids} - {None}
|
|
2547
|
+
raw: Dict[str, float] = {}
|
|
2548
|
+
for nid, d in dist.items():
|
|
2549
|
+
if nid not in builder._nodes:
|
|
2550
|
+
continue
|
|
2551
|
+
degree = builder.graph.degree(nid)
|
|
2552
|
+
bonus = 1.5 if membership.get(nid) in seed_comms else 1.0
|
|
2553
|
+
raw[nid] = (1.0 / (1.0 + d)) * (1.0 + math.log2(degree + 1)) * bonus
|
|
2554
|
+
max_raw = max(raw.values(), default=1.0)
|
|
2555
|
+
|
|
2556
|
+
# 4. Assemble
|
|
2557
|
+
context_items: List[MCPContextItem] = []
|
|
2558
|
+
file_score: Dict[str, float] = {}
|
|
2559
|
+
for nid, score_raw in raw.items():
|
|
2560
|
+
node = builder._nodes[nid]
|
|
2561
|
+
score = round(score_raw / max_raw, 4)
|
|
2562
|
+
file_score[node.file_path] = max(file_score.get(node.file_path, 0.0), score)
|
|
2563
|
+
doc_lines = (node.docstring or "").strip().splitlines()
|
|
2564
|
+
context_items.append(MCPContextItem(
|
|
2565
|
+
type="symbol",
|
|
2566
|
+
content=f"{node.node_type.name.lower()} {nid}: {node.signature or node.name}",
|
|
2567
|
+
source=f"{self._rel(node.file_path)}:{node.line}",
|
|
2568
|
+
relevance_score=score,
|
|
2569
|
+
metadata={
|
|
2570
|
+
"docstring": doc_lines[0] if doc_lines else None,
|
|
2571
|
+
"distance": dist[nid],
|
|
2572
|
+
},
|
|
2573
|
+
))
|
|
2574
|
+
|
|
2575
|
+
for p in self.memory.get_patterns():
|
|
2576
|
+
rel = 0.8 * file_score.get(p.get("file_path") or "", 0.0)
|
|
2577
|
+
if rel <= 0:
|
|
2578
|
+
continue
|
|
2579
|
+
context_items.append(MCPContextItem(
|
|
2580
|
+
type="pattern",
|
|
2581
|
+
content=f"{p['name']}: {p.get('description') or ''}",
|
|
2582
|
+
source=p.get("file_path"),
|
|
2583
|
+
relevance_score=round(rel, 4),
|
|
2584
|
+
))
|
|
2585
|
+
|
|
2586
|
+
lowered = [t.lower() for t in [query] + terms]
|
|
2587
|
+
for k in self.memory.get_tribal_knowledge():
|
|
2588
|
+
haystack = f"{k['topic']} {k['content']}".lower()
|
|
2589
|
+
if any(t in haystack for t in lowered):
|
|
2590
|
+
context_items.append(MCPContextItem(
|
|
2591
|
+
type="tribal_knowledge",
|
|
2592
|
+
content=k["content"],
|
|
2593
|
+
source=f"tribal:{k['topic']}",
|
|
2594
|
+
relevance_score=0.9,
|
|
2595
|
+
))
|
|
2596
|
+
|
|
2597
|
+
if self.config.frameworks:
|
|
2598
|
+
context_items.append(MCPContextItem(
|
|
2599
|
+
type="framework",
|
|
2600
|
+
content=f"Detected frameworks: {', '.join(self.config.frameworks)}",
|
|
2601
|
+
relevance_score=1.0,
|
|
2602
|
+
))
|
|
2603
|
+
|
|
2604
|
+
# 5. Trim
|
|
2605
|
+
context_items.sort(key=lambda i: -(i.relevance_score or 0.0))
|
|
2606
|
+
payloads = [item.model_dump() for item in context_items]
|
|
2607
|
+
payloads, truncated = self._apply_budget(payloads, None, max_tokens)
|
|
2608
|
+
|
|
2609
|
+
return MCPResponse(
|
|
2610
|
+
id=params.get("request_id"),
|
|
2611
|
+
result={
|
|
2612
|
+
"query": query,
|
|
2613
|
+
"seeds": seed_ids,
|
|
2614
|
+
"context_items": payloads,
|
|
2615
|
+
"item_count": len(payloads),
|
|
2616
|
+
"truncated": truncated,
|
|
2617
|
+
}
|
|
2618
|
+
)
|
|
2619
|
+
|
|
2620
|
+
def _scan_file(self, path: Path, category_filter: Optional[str] = None):
|
|
2621
|
+
"""Scan a single file and return pattern dicts plus parse/extract results."""
|
|
2622
|
+
self._configure_backend(path, self._backend_name(path))
|
|
2623
|
+
parse_result = self.parser.parse_file(path)
|
|
2624
|
+
patterns = self.registry.run_all(parse_result.tree, parse_result.source_bytes, path)
|
|
2625
|
+
ar = self.extractor._build(parse_result, path)
|
|
2626
|
+
|
|
2627
|
+
if category_filter:
|
|
2628
|
+
patterns = [p for p in patterns if p.category.name.lower() == category_filter.lower()]
|
|
2629
|
+
|
|
2630
|
+
# Store in memory
|
|
2631
|
+
self.memory.store_patterns(patterns)
|
|
2632
|
+
|
|
2633
|
+
# P1.11: carry patterns on the result so the graph builder can mark
|
|
2634
|
+
# route handlers (incl. ones nested in a factory) as entry points.
|
|
2635
|
+
ar.patterns = patterns
|
|
2636
|
+
|
|
2637
|
+
results = [{
|
|
2638
|
+
"name": p.name,
|
|
2639
|
+
"category": p.category.name,
|
|
2640
|
+
"description": p.description,
|
|
2641
|
+
"file": str(p.file_path),
|
|
2642
|
+
"line": p.position.line,
|
|
2643
|
+
"confidence": p.confidence,
|
|
2644
|
+
"metadata": p.metadata,
|
|
2645
|
+
} for p in patterns]
|
|
2646
|
+
return results, parse_result, ar
|
|
2647
|
+
|
|
2648
|
+
def _scan_directory(
|
|
2649
|
+
self, path: Path, category_filter: Optional[str] = None
|
|
2650
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, str]]]:
|
|
2651
|
+
"""Scan all supported files in a directory incrementally.
|
|
2652
|
+
|
|
2653
|
+
Returns (pattern dicts, per-file errors) — P2.5: failures are
|
|
2654
|
+
reported, not swallowed.
|
|
2655
|
+
"""
|
|
2656
|
+
files = self._find_files(path)
|
|
2657
|
+
scanner = IncrementalScanner(self.memory, self.parser, self.extractor, self.registry)
|
|
2658
|
+
changed, unchanged, deleted = scanner.classify_files(path, files)
|
|
2659
|
+
|
|
2660
|
+
all_results: List[Dict[str, Any]] = []
|
|
2661
|
+
errors: List[Dict[str, str]] = []
|
|
2662
|
+
parses: Dict[Path, Any] = {}
|
|
2663
|
+
results: Dict[Path, Any] = {}
|
|
2664
|
+
pending_metadata: List[Any] = []
|
|
2665
|
+
|
|
2666
|
+
if should_parallelize(len(changed)):
|
|
2667
|
+
for backend_name, backend_files in self._group_paths(changed).items():
|
|
2668
|
+
self._configure_backend(path, backend_name)
|
|
2669
|
+
for fp, ar, patterns, err in parallel_analyze(
|
|
2670
|
+
backend_files,
|
|
2671
|
+
backend_name=backend_name,
|
|
2672
|
+
category_filter=category_filter,
|
|
2673
|
+
):
|
|
2674
|
+
if err is not None or ar is None:
|
|
2675
|
+
errors.append({"file": str(fp), "error": err or "unknown"})
|
|
2676
|
+
continue
|
|
2677
|
+
self.memory.store_patterns(patterns)
|
|
2678
|
+
for p in patterns:
|
|
2679
|
+
all_results.append({
|
|
2680
|
+
"name": p.name,
|
|
2681
|
+
"category": p.category.name,
|
|
2682
|
+
"description": p.description,
|
|
2683
|
+
"file": str(p.file_path),
|
|
2684
|
+
"line": p.position.line,
|
|
2685
|
+
"confidence": p.confidence,
|
|
2686
|
+
"metadata": p.metadata,
|
|
2687
|
+
})
|
|
2688
|
+
ar.patterns = patterns
|
|
2689
|
+
results[fp] = ar
|
|
2690
|
+
pending_metadata.append((fp, ar))
|
|
2691
|
+
scanner.store_results(path, pending_metadata)
|
|
2692
|
+
pending_metadata = []
|
|
2693
|
+
# Re-parse in main proc to get Trees for resolve_calls.
|
|
2694
|
+
for fp in list(results.keys()):
|
|
2695
|
+
try:
|
|
2696
|
+
parses[fp] = self._parse_file(fp)
|
|
2697
|
+
except SCAN_FILE_ERRORS as e:
|
|
2698
|
+
results.pop(fp, None)
|
|
2699
|
+
errors.append({"file": str(fp), "error": f"{type(e).__name__}: {e}"})
|
|
2700
|
+
else:
|
|
2701
|
+
for file_path in changed:
|
|
2702
|
+
try:
|
|
2703
|
+
file_results, pr, ar = self._scan_file(file_path, category_filter)
|
|
2704
|
+
all_results.extend(file_results)
|
|
2705
|
+
if pr and ar:
|
|
2706
|
+
parses[file_path] = pr
|
|
2707
|
+
results[file_path] = ar
|
|
2708
|
+
pending_metadata.append((file_path, ar))
|
|
2709
|
+
except SCAN_FILE_ERRORS as e:
|
|
2710
|
+
errors.append({"file": str(file_path), "error": f"{type(e).__name__}: {e}"})
|
|
2711
|
+
scanner.store_results(path, pending_metadata)
|
|
2712
|
+
|
|
2713
|
+
# Load unchanged results for graph completeness
|
|
2714
|
+
for file_path in unchanged:
|
|
2715
|
+
ar = scanner.load_cached_result(path, file_path)
|
|
2716
|
+
if ar is not None:
|
|
2717
|
+
results[file_path] = ar
|
|
2718
|
+
|
|
2719
|
+
# Update graph incrementally
|
|
2720
|
+
if results:
|
|
2721
|
+
# MCP_INCREMENTAL_REBUILD_PLAN.md Lớp 1: same rule as
|
|
2722
|
+
# _build_full_graph / DriftCommands.build_graph — keep the
|
|
2723
|
+
# persisted graph (allow_stale) unless a Java declaration surface
|
|
2724
|
+
# changed or a .java file was deleted, which is a full rebuild.
|
|
2725
|
+
builder = None
|
|
2726
|
+
# Decision #9: non-Java changes have no scope rule → full rebuild.
|
|
2727
|
+
needs_full = any(fp.suffix != ".java" for fp in (*changed, *deleted))
|
|
2728
|
+
if not needs_full and (changed or deleted):
|
|
2729
|
+
scope, _reason = java_incremental_scope(path, scanner, changed, deleted)
|
|
2730
|
+
needs_full = scope is None
|
|
2731
|
+
if not needs_full:
|
|
2732
|
+
builder = self.memory.load_graph(path, allow_stale=True)
|
|
2733
|
+
if builder is not None:
|
|
2734
|
+
builder.project_root = self.project_root.resolve()
|
|
2735
|
+
builder._project_languages = None
|
|
2736
|
+
builder._java_build_module_roots = None
|
|
2737
|
+
builder._java_namespace_visibility = None
|
|
2738
|
+
full_rebuild = builder is None
|
|
2739
|
+
if full_rebuild:
|
|
2740
|
+
builder = CallGraphBuilder(project_root=self.project_root)
|
|
2741
|
+
for fp in deleted + changed:
|
|
2742
|
+
builder.remove_file(str(fp))
|
|
2743
|
+
# Fresh builder has no nodes for unchanged files — add everything.
|
|
2744
|
+
# Loaded builder already contains them — add only changed files.
|
|
2745
|
+
add_paths = results.keys() if full_rebuild else [fp for fp in changed if fp in results]
|
|
2746
|
+
for fp in add_paths:
|
|
2747
|
+
builder.add_file_result(results[fp])
|
|
2748
|
+
# Full rebuild: re-parse unchanged files too, or their outbound
|
|
2749
|
+
# CALLS edges are lost (P0.5 fix, mirrored from DriftCommands).
|
|
2750
|
+
if full_rebuild:
|
|
2751
|
+
for fp in results.keys():
|
|
2752
|
+
if fp not in parses:
|
|
2753
|
+
try:
|
|
2754
|
+
parses[fp] = self._parse_file(fp)
|
|
2755
|
+
except SCAN_FILE_ERRORS as e:
|
|
2756
|
+
errors.append({"file": str(fp), "error": f"{type(e).__name__}: {e}"})
|
|
2757
|
+
if parses:
|
|
2758
|
+
self._resolve_graph(builder, parses, results)
|
|
2759
|
+
builder.reconcile_edges()
|
|
2760
|
+
scanner.prune_deleted_files(path, {str(f.resolve()) for f in files})
|
|
2761
|
+
self.memory.save_graph(path, builder)
|
|
2762
|
+
# MCP_GRAPH_CACHE_PLAN.md Lớp 1: this graph carries the full
|
|
2763
|
+
# detector set (e.g. java_test → is_test) that the ROUTES-only
|
|
2764
|
+
# _build_full_graph() path does not; drop the in-memory copy so
|
|
2765
|
+
# the next query reloads what was just persisted.
|
|
2766
|
+
self._graph_cache = None
|
|
2767
|
+
# P2.19: taint analysis over the freshly built graph.
|
|
2768
|
+
python_parses = {
|
|
2769
|
+
file_path: parse for file_path, parse in parses.items()
|
|
2770
|
+
if self._backend_name(file_path) == "python"
|
|
2771
|
+
}
|
|
2772
|
+
self._run_taint_pass(path, builder, python_parses, all_results, category_filter)
|
|
2773
|
+
|
|
2774
|
+
return all_results, errors
|
|
2775
|
+
|
|
2776
|
+
def _run_taint_pass(
|
|
2777
|
+
self,
|
|
2778
|
+
project_path: Path,
|
|
2779
|
+
builder: CallGraphBuilder,
|
|
2780
|
+
parses: Dict[Path, Any],
|
|
2781
|
+
results: List[Dict[str, Any]],
|
|
2782
|
+
category_filter: Optional[str],
|
|
2783
|
+
) -> None:
|
|
2784
|
+
"""P2.19: run taint analysis, persist paths, surface CRITICAL TAINT
|
|
2785
|
+
findings. Best-effort — never aborts a scan."""
|
|
2786
|
+
if not parses:
|
|
2787
|
+
return
|
|
2788
|
+
try:
|
|
2789
|
+
from adl_drift.detectors.taint import TaintAnalyzer
|
|
2790
|
+
from adl_drift.utils.types import PatternCategory, SourcePosition
|
|
2791
|
+
paths = TaintAnalyzer().analyze(builder, parses)
|
|
2792
|
+
except Exception:
|
|
2793
|
+
return
|
|
2794
|
+
analyzed_fids = set()
|
|
2795
|
+
try:
|
|
2796
|
+
for fp, pr in parses.items():
|
|
2797
|
+
module = builder._path_to_module(fp)
|
|
2798
|
+
for _, nid in builder._iter_func_cst(pr.tree.root_node, pr.source_bytes, module):
|
|
2799
|
+
analyzed_fids.add(nid)
|
|
2800
|
+
except Exception:
|
|
2801
|
+
pass
|
|
2802
|
+
try:
|
|
2803
|
+
self.memory.store_taint_paths(project_path, paths, replace_function_ids=list(analyzed_fids))
|
|
2804
|
+
except Exception:
|
|
2805
|
+
pass
|
|
2806
|
+
if category_filter and category_filter.lower() != "taint":
|
|
2807
|
+
return
|
|
2808
|
+
for p in paths:
|
|
2809
|
+
chain_str = " -> ".join(p.chain)
|
|
2810
|
+
pat = Pattern(
|
|
2811
|
+
category=PatternCategory.TAINT,
|
|
2812
|
+
name=f"taint_{p.sink_category}",
|
|
2813
|
+
description=f"{p.sink_category}: {chain_str}",
|
|
2814
|
+
position=SourcePosition(
|
|
2815
|
+
line=p.sink_site.line, column=p.sink_site.column,
|
|
2816
|
+
end_line=p.sink_site.end_line, end_column=p.sink_site.end_column,
|
|
2817
|
+
),
|
|
2818
|
+
file_path=Path(p.file_path),
|
|
2819
|
+
confidence=p.confidence,
|
|
2820
|
+
metadata={
|
|
2821
|
+
"severity": "CRITICAL", "sink": p.sink,
|
|
2822
|
+
"sink_category": p.sink_category, "source": p.source_label,
|
|
2823
|
+
"binding": p.binding_name or "", "chain": chain_str,
|
|
2824
|
+
"cross_function": p.cross_function, "function": p.function_id,
|
|
2825
|
+
},
|
|
2826
|
+
)
|
|
2827
|
+
try:
|
|
2828
|
+
self.memory.store_pattern(pat)
|
|
2829
|
+
except Exception:
|
|
2830
|
+
pass
|
|
2831
|
+
results.append({
|
|
2832
|
+
"name": pat.name, "category": pat.category.name,
|
|
2833
|
+
"description": pat.description, "file": str(pat.file_path),
|
|
2834
|
+
"line": pat.position.line, "confidence": pat.confidence,
|
|
2835
|
+
"metadata": pat.metadata,
|
|
2836
|
+
})
|
|
2837
|
+
|
|
2838
|
+
def _find_files(self, path: Path) -> List[Path]:
|
|
2839
|
+
return sorted(
|
|
2840
|
+
(file for files in self._file_groups(path).values() for file in files),
|
|
2841
|
+
key=str,
|
|
2842
|
+
)
|
|
2843
|
+
|
|
2844
|
+
@classmethod
|
|
2845
|
+
def _find_python_files(cls, path: Path) -> List[Path]:
|
|
2846
|
+
"""Compatibility wrapper for downstream Python-only callers."""
|
|
2847
|
+
from adl_drift.lang.registry import get_backend
|
|
2848
|
+
|
|
2849
|
+
return discover_files(path, get_backend("python"))
|