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.
Files changed (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. 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"))