descry-codegraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
descry/handlers.py ADDED
@@ -0,0 +1,1907 @@
1
+ """DescryService — Core business logic for codebase knowledge graph tools.
2
+
3
+ No MCP dependency. All tool logic lives here; MCP/CLI/Web are thin wrappers.
4
+ """
5
+
6
+ import asyncio
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from collections import defaultdict
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from descry._env import safe_env
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Server version for tracking
24
+ SERVER_VERSION = "0.1.0"
25
+
26
+
27
+ # --- Configuration ---
28
+
29
+
30
+ _DEFAULT_PROJECT_MARKERS = [
31
+ ".git",
32
+ "Cargo.toml",
33
+ "package.json",
34
+ "pyproject.toml",
35
+ ".descry.toml",
36
+ ]
37
+ _DEFAULT_API_PREFIXES = ["/api/v1", "/api/v2", "/api"]
38
+ _DEFAULT_EXCLUDED_DIRS = {
39
+ "target",
40
+ "node_modules",
41
+ "dist",
42
+ "docs",
43
+ ".git",
44
+ "__pycache__",
45
+ "build",
46
+ "vendor",
47
+ }
48
+
49
+ _DEFAULT_TEST_PATH_PATTERNS = (
50
+ "/tests/",
51
+ "/test/",
52
+ "/_test/",
53
+ "/spec/",
54
+ "/testing/",
55
+ "/fixtures/",
56
+ "/mocks/",
57
+ "/__tests__/",
58
+ )
59
+ _DEFAULT_TEST_FILE_SUFFIXES = (
60
+ "_test.rs",
61
+ ".test.ts",
62
+ ".spec.ts",
63
+ "_test.py",
64
+ ".test.js",
65
+ ".spec.js",
66
+ ".test.tsx",
67
+ ".spec.tsx",
68
+ )
69
+ _DEFAULT_CODE_EXTENSIONS = {
70
+ ".rs",
71
+ ".py",
72
+ ".ts",
73
+ ".tsx",
74
+ ".js",
75
+ ".jsx",
76
+ ".svelte",
77
+ ".go",
78
+ ".java",
79
+ ".css",
80
+ ".scss",
81
+ ".html",
82
+ }
83
+ _DEFAULT_CHURN_EXCLUSIONS = [
84
+ ".descry_cache/",
85
+ ".beads/",
86
+ "Cargo.lock",
87
+ "package-lock.json",
88
+ "yarn.lock",
89
+ "pnpm-lock.yaml",
90
+ ]
91
+ _DEFAULT_SYNTAX_LANG_MAP = {
92
+ ".rs": "rust",
93
+ ".py": "python",
94
+ ".ts": "typescript",
95
+ ".tsx": "typescript",
96
+ ".js": "javascript",
97
+ ".jsx": "javascript",
98
+ ".svelte": "svelte",
99
+ ".proto": "protobuf",
100
+ ".json": "json",
101
+ ".toml": "toml",
102
+ ".yaml": "yaml",
103
+ ".yml": "yaml",
104
+ ".md": "markdown",
105
+ ".rb": "ruby",
106
+ ".go": "go",
107
+ ".java": "java",
108
+ ".css": "css",
109
+ ".scss": "scss",
110
+ ".html": "html",
111
+ }
112
+
113
+
114
+ def _env(key: str, default: str = "") -> str:
115
+ """Read env var with default."""
116
+ return os.environ.get(key, default)
117
+
118
+
119
+ _TOOLCHAIN_REGEX = re.compile(r"^[A-Za-z0-9._\-]+$")
120
+ _MODEL_HF_REGEX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-/]*$")
121
+ _SHELL_METACHARS = set("\x00;|&`$\n\r\t<>")
122
+
123
+
124
+ def _validate_toolchain(value: str) -> str:
125
+ """Validate scip_rust_toolchain TOML value. Raises ValueError on bad input."""
126
+ if not value or value.startswith("-") or not _TOOLCHAIN_REGEX.match(value):
127
+ raise ValueError(f"Invalid scip.rust.toolchain: {value!r}")
128
+ return value
129
+
130
+
131
+ def _validate_scip_extra_arg(arg: str) -> str:
132
+ """Validate a scip_extra_args entry.
133
+
134
+ Accepts bare positional args; flags must start with '--' (blocks short-flag
135
+ smuggling). Rejects shell metacharacters.
136
+ """
137
+ if not arg:
138
+ raise ValueError("Empty scip extra_args entry")
139
+ if arg.startswith("-") and not arg.startswith("--"):
140
+ raise ValueError(f"scip_extra_args short flag not allowed: {arg!r}")
141
+ if any(c in _SHELL_METACHARS for c in arg):
142
+ raise ValueError(f"scip_extra_args contains shell metacharacters: {arg!r}")
143
+ return arg
144
+
145
+
146
+ def _validate_embedding_model(value: str, project_root: Path) -> str:
147
+ """Validate [embeddings] model TOML value.
148
+
149
+ - If looks like a local path (starts with '/' or contains '..'), must
150
+ resolve inside project_root.
151
+ - Otherwise must match HuggingFace repo-id regex.
152
+ """
153
+ if not value:
154
+ raise ValueError("Empty embeddings.model")
155
+ if value.startswith("/") or ".." in value.split("/"):
156
+ resolved = Path(value).resolve()
157
+ root = project_root.resolve()
158
+ if not (resolved == root or root in resolved.parents):
159
+ raise ValueError(
160
+ f"embeddings.model local path {value!r} outside project root"
161
+ )
162
+ return value
163
+ if not _MODEL_HF_REGEX.match(value):
164
+ raise ValueError(f"Invalid embeddings.model repo id: {value!r}")
165
+ return value
166
+
167
+
168
+ @dataclass
169
+ class DescryConfig:
170
+ """Configuration for a Descry project."""
171
+
172
+ project_root: Path = field(default_factory=Path.cwd)
173
+ cache_dir: Path | None = None
174
+ max_stale_hours: float = 24
175
+ enable_scip: bool = True
176
+ enable_embeddings: bool = True
177
+ # Opt-in: use tree-sitter for TS/TSX/JS first-pass parsing. Requires
178
+ # descry-codegraph[ast]. Falls back to regex per-file when the grammar
179
+ # hits errors or the extra isn't installed.
180
+ use_tree_sitter_ts: bool = False
181
+ openapi_path: Path | None = None
182
+ project_markers: list[str] = field(
183
+ default_factory=lambda: list(_DEFAULT_PROJECT_MARKERS)
184
+ )
185
+ backend_handler_patterns: list[str] = field(default_factory=list)
186
+ frontend_api_patterns: list[str] = field(default_factory=list)
187
+ api_prefixes: list[str] = field(default_factory=lambda: list(_DEFAULT_API_PREFIXES))
188
+ excluded_dirs: set[str] = field(default_factory=lambda: set(_DEFAULT_EXCLUDED_DIRS))
189
+
190
+ # Embeddings
191
+ embedding_model: str = "jinaai/jina-code-embeddings-0.5b"
192
+
193
+ # Test detection
194
+ test_path_patterns: tuple[str, ...] = field(
195
+ default_factory=lambda: _DEFAULT_TEST_PATH_PATTERNS
196
+ )
197
+ test_file_suffixes: tuple[str, ...] = field(
198
+ default_factory=lambda: _DEFAULT_TEST_FILE_SUFFIXES
199
+ )
200
+
201
+ # Code files
202
+ code_extensions: set[str] = field(
203
+ default_factory=lambda: set(_DEFAULT_CODE_EXTENSIONS)
204
+ )
205
+
206
+ # Git
207
+ churn_exclusions: list[str] = field(
208
+ default_factory=lambda: list(_DEFAULT_CHURN_EXCLUSIONS)
209
+ )
210
+ git_timeout: int = 30
211
+
212
+ # Timeouts
213
+ scip_timeout_minutes: int = 0 # 0 = unlimited
214
+ index_timeout_minutes: int = 30 # Overall index timeout (0 = unlimited)
215
+ embedding_timeout: int = 60
216
+ query_timeout_ms: int = 4000
217
+
218
+ # Query limits
219
+ max_depth: int = 3
220
+ max_nodes: int = 100
221
+ max_children_per_level: int = 10
222
+ max_callers_shown: int = 15
223
+
224
+ # SCIP
225
+ scip_extra_args: list[str] = field(
226
+ default_factory=lambda: ["--exclude-vendored-libraries"]
227
+ )
228
+ scip_skip_crates: list[str] = field(default_factory=list)
229
+ scip_rust_toolchain: str | None = (
230
+ None # e.g. "1.92.0" to use `rustup run 1.92.0 rust-analyzer`
231
+ )
232
+
233
+ # Syntax highlighting
234
+ syntax_lang_map: dict[str, str] = field(
235
+ default_factory=lambda: dict(_DEFAULT_SYNTAX_LANG_MAP)
236
+ )
237
+
238
+ def __post_init__(self):
239
+ self.project_root = Path(self.project_root)
240
+ if self.cache_dir is None:
241
+ self.cache_dir = self.project_root / ".descry_cache"
242
+ else:
243
+ self.cache_dir = Path(self.cache_dir)
244
+
245
+ @property
246
+ def graph_path(self) -> Path:
247
+ return self.cache_dir / "codebase_graph.json"
248
+
249
+ @property
250
+ def resolved_project_root(self) -> Path:
251
+ """Project root with all symlinks resolved.
252
+
253
+ Use for containment checks (api_source, api_index, descry_index).
254
+ Kept as a property instead of mutating project_root so existing test
255
+ equality assertions against tmp_path continue to work.
256
+ """
257
+ return self.project_root.resolve()
258
+
259
+ @classmethod
260
+ def auto_detect(cls, cwd: Path | None = None) -> "DescryConfig":
261
+ """Auto-detect project root from cwd and build config."""
262
+ start = Path(cwd) if cwd else Path.cwd()
263
+ markers = _DEFAULT_PROJECT_MARKERS
264
+
265
+ for path in [start] + list(start.parents):
266
+ for marker in markers:
267
+ if (path / marker).exists():
268
+ return cls(project_root=path)
269
+ if path == Path.home():
270
+ break
271
+
272
+ return cls(project_root=start)
273
+
274
+ @staticmethod
275
+ def _load_toml(project_root: Path) -> dict:
276
+ """Load .descry.toml from project root if it exists.
277
+
278
+ Rejects files > 1 MiB (TOML-parse DoS guard per A.4).
279
+
280
+ Returns:
281
+ Parsed TOML data as dict, or empty dict if not found/invalid.
282
+ """
283
+ import tomllib
284
+
285
+ toml_path = Path(project_root) / ".descry.toml"
286
+ if not toml_path.exists():
287
+ return {}
288
+ try:
289
+ if toml_path.stat().st_size > 1 * 1024 * 1024:
290
+ logger.warning(f".descry.toml exceeds 1 MiB cap; ignoring: {toml_path}")
291
+ return {}
292
+ with open(toml_path, "rb") as f:
293
+ return tomllib.load(f)
294
+ except Exception as e:
295
+ logger.warning(f"Failed to parse .descry.toml: {e}")
296
+ return {}
297
+
298
+ def _apply_toml(self, data: dict) -> None:
299
+ """Apply parsed TOML data to this config instance.
300
+
301
+ Maps TOML sections/keys to config fields.
302
+ """
303
+ if not data:
304
+ return
305
+
306
+ # [project]
307
+ project = data.get("project", {})
308
+ if "excluded_dirs" in project:
309
+ self.excluded_dirs = set(project["excluded_dirs"])
310
+ if "max_stale_hours" in project:
311
+ self.max_stale_hours = project["max_stale_hours"]
312
+
313
+ # [features]
314
+ features = data.get("features", {})
315
+ if "enable_scip" in features:
316
+ self.enable_scip = features["enable_scip"]
317
+ if "enable_embeddings" in features:
318
+ self.enable_embeddings = features["enable_embeddings"]
319
+ if "use_tree_sitter_ts" in features:
320
+ self.use_tree_sitter_ts = features["use_tree_sitter_ts"]
321
+
322
+ # [embeddings]
323
+ embeddings = data.get("embeddings", {})
324
+ if "model" in embeddings:
325
+ try:
326
+ self.embedding_model = _validate_embedding_model(
327
+ embeddings["model"], self.project_root
328
+ )
329
+ except ValueError as e:
330
+ logger.warning(
331
+ f"Invalid [embeddings] model in .descry.toml: {e}; "
332
+ f"falling back to default {self.embedding_model!r}"
333
+ )
334
+
335
+ # [test_detection]
336
+ test_detection = data.get("test_detection", {})
337
+ if "path_patterns" in test_detection:
338
+ self.test_path_patterns = tuple(test_detection["path_patterns"])
339
+ if "file_suffixes" in test_detection:
340
+ self.test_file_suffixes = tuple(test_detection["file_suffixes"])
341
+
342
+ # [code_files]
343
+ code_files = data.get("code_files", {})
344
+ if "extensions" in code_files:
345
+ self.code_extensions = set(code_files["extensions"])
346
+
347
+ # [git]
348
+ git = data.get("git", {})
349
+ if "churn_exclusions" in git:
350
+ self.churn_exclusions = git["churn_exclusions"]
351
+ if "timeout" in git:
352
+ self.git_timeout = git["timeout"]
353
+
354
+ # [timeouts]
355
+ timeouts = data.get("timeouts", {})
356
+ if "scip_minutes" in timeouts:
357
+ self.scip_timeout_minutes = timeouts["scip_minutes"]
358
+ if "index_minutes" in timeouts:
359
+ self.index_timeout_minutes = timeouts["index_minutes"]
360
+ if "embedding_seconds" in timeouts:
361
+ self.embedding_timeout = timeouts["embedding_seconds"]
362
+ if "query_ms" in timeouts:
363
+ self.query_timeout_ms = timeouts["query_ms"]
364
+
365
+ # [query]
366
+ query = data.get("query", {})
367
+ if "max_depth" in query:
368
+ self.max_depth = query["max_depth"]
369
+ if "max_nodes" in query:
370
+ self.max_nodes = query["max_nodes"]
371
+ if "max_children_per_level" in query:
372
+ self.max_children_per_level = query["max_children_per_level"]
373
+ if "max_callers_shown" in query:
374
+ self.max_callers_shown = query["max_callers_shown"]
375
+
376
+ # [scip]
377
+ scip = data.get("scip", {})
378
+ if "extra_args" in scip:
379
+ try:
380
+ self.scip_extra_args = [
381
+ _validate_scip_extra_arg(a) for a in scip["extra_args"]
382
+ ]
383
+ except ValueError as e:
384
+ logger.warning(
385
+ f"Invalid [scip] extra_args in .descry.toml: {e}; "
386
+ f"keeping default {self.scip_extra_args!r}"
387
+ )
388
+ if "skip_crates" in scip:
389
+ self.scip_skip_crates = scip["skip_crates"]
390
+
391
+ # [scip.rust]
392
+ scip_rust = scip.get("rust", {})
393
+ if "toolchain" in scip_rust:
394
+ try:
395
+ self.scip_rust_toolchain = _validate_toolchain(scip_rust["toolchain"])
396
+ except ValueError as e:
397
+ logger.warning(
398
+ f"Invalid [scip.rust] toolchain in .descry.toml: {e}; ignoring"
399
+ )
400
+
401
+ # [syntax.lang_map] — merges with defaults (additive)
402
+ syntax = data.get("syntax", {})
403
+ lang_map = syntax.get("lang_map", {})
404
+ if lang_map:
405
+ self.syntax_lang_map.update(lang_map)
406
+
407
+ @classmethod
408
+ def from_env(cls) -> "DescryConfig":
409
+ """Build config: auto_detect -> apply TOML -> apply env vars."""
410
+ log_level = _env("DESCRY_LOG_LEVEL", "WARNING")
411
+ logging.getLogger("descry").setLevel(
412
+ getattr(logging, log_level.upper(), logging.WARNING)
413
+ )
414
+
415
+ # Step 1: auto-detect project root
416
+ config = cls.auto_detect()
417
+
418
+ # Step 2: apply TOML (overrides defaults)
419
+ toml_data = cls._load_toml(config.project_root)
420
+ config._apply_toml(toml_data)
421
+
422
+ # Step 3: apply env vars (override TOML)
423
+ cache_dir_str = _env("DESCRY_CACHE_DIR")
424
+ no_scip = _env("DESCRY_NO_SCIP").lower() in ("1", "true", "yes")
425
+ no_embeddings = _env("DESCRY_NO_EMBEDDINGS").lower() in ("1", "true", "yes")
426
+
427
+ if cache_dir_str:
428
+ config.cache_dir = Path(cache_dir_str)
429
+ if no_scip:
430
+ config.enable_scip = False
431
+ if no_embeddings:
432
+ config.enable_embeddings = False
433
+ return config
434
+
435
+
436
+ # --- Format Helpers (module-level pure functions) ---
437
+
438
+
439
+ def format_search_result(
440
+ node: dict, rank: int = 0, show_score: bool = False, score: float = 0.0
441
+ ) -> str:
442
+ """Format a single search result with enriched information."""
443
+ meta = node.get("metadata", {})
444
+ node_type = node.get("type", "?")[:3]
445
+ name = meta.get("name", "unknown")
446
+
447
+ node_id = node.get("id", "")
448
+ file_path = ""
449
+ lineno = meta.get("lineno")
450
+ if node_id.startswith("FILE:"):
451
+ file_path = node_id.split("::")[0].replace("FILE:", "")
452
+
453
+ if file_path and lineno is not None:
454
+ location = f"{file_path}:{lineno}"
455
+ elif file_path:
456
+ location = file_path
457
+ else:
458
+ location = node_id
459
+
460
+ token_count = meta.get("token_count", 0)
461
+ in_degree = meta.get("in_degree", 0)
462
+
463
+ score_str = f"[{score:.2f}] " if show_score else ""
464
+ metrics_str = (
465
+ f"({token_count} toks, {in_degree} callers)" if token_count or in_degree else ""
466
+ )
467
+
468
+ lines = [f"{score_str}[{node_type}] {name} | {location} {metrics_str}".rstrip()]
469
+
470
+ sig = meta.get("signature", "")
471
+ if sig:
472
+ lines.append(f" {sig}")
473
+
474
+ docstring = meta.get("docstring", "")
475
+ if docstring:
476
+ doc_lines = [line.strip() for line in docstring.split("\n") if line.strip()]
477
+ if doc_lines:
478
+ doc_preview = doc_lines[0]
479
+ if len(doc_preview) > 120:
480
+ doc_preview = doc_preview[:117] + "..."
481
+ lines.append(f" {doc_preview}")
482
+
483
+ return "\n".join(lines)
484
+
485
+
486
+ def format_compact_result(node: dict, rank: int = 0) -> str:
487
+ """Format a single search result in compact single-line format."""
488
+ meta = node.get("metadata", {})
489
+ node_type = node.get("type", "?")[:3]
490
+ name = meta.get("name", "unknown")
491
+
492
+ parent_name = meta.get("parent_name", "")
493
+ display_name = f"{parent_name}.{name}" if parent_name else name
494
+
495
+ node_id = node.get("id", "")
496
+ file_path = ""
497
+ lineno = meta.get("lineno")
498
+ if node_id.startswith("FILE:"):
499
+ file_path = node_id.split("::")[0].replace("FILE:", "")
500
+ parts = file_path.split("/")
501
+ if len(parts) > 2:
502
+ file_path = "/".join(parts[-2:])
503
+
504
+ location = f"{file_path}:{lineno}" if lineno else file_path
505
+
506
+ token_count = meta.get("token_count", 0)
507
+ in_degree = meta.get("in_degree", 0)
508
+
509
+ return f"{rank}. [{node_type}] {display_name} | {location} ({token_count}t, {in_degree}c)"
510
+
511
+
512
+ def is_natural_language_query(terms: list[str]) -> bool:
513
+ """Detect if query terms represent natural language vs code identifiers."""
514
+ import re
515
+
516
+ text = " ".join(terms).lower()
517
+
518
+ nl_indicators = [
519
+ "how to",
520
+ "what is",
521
+ "where is",
522
+ "where are",
523
+ "find the",
524
+ "show me",
525
+ "get the",
526
+ "look for",
527
+ "search for",
528
+ "related to",
529
+ "that handles",
530
+ "that does",
531
+ "responsible for",
532
+ "used for",
533
+ "deals with",
534
+ ]
535
+ if any(p in text for p in nl_indicators):
536
+ return True
537
+
538
+ if terms and terms[0].lower() in ("how", "what", "where", "why", "which", "find"):
539
+ return True
540
+
541
+ code_patterns = [
542
+ r"[a-z]+_[a-z]+",
543
+ r"[a-z]+[A-Z][a-z]+",
544
+ r"[A-Z][a-z]+[A-Z]",
545
+ r"::",
546
+ ]
547
+ for pattern in code_patterns:
548
+ if re.search(pattern, text):
549
+ return False
550
+
551
+ return len(terms) >= 3
552
+
553
+
554
+ def reciprocal_rank_fusion(
555
+ tfidf_results: list, semantic_results: list, k: int = 60
556
+ ) -> list:
557
+ """Combine rankings using Reciprocal Rank Fusion."""
558
+ rrf_scores = defaultdict(float)
559
+ node_lookup = {}
560
+
561
+ for rank, node in enumerate(tfidf_results):
562
+ node_id = node["id"]
563
+ rrf_scores[node_id] += 1.0 / (k + rank + 1)
564
+ node_lookup[node_id] = node
565
+
566
+ for rank, (node, _) in enumerate(semantic_results):
567
+ node_id = node["id"]
568
+ rrf_scores[node_id] += 1.0 / (k + rank + 1)
569
+ node_lookup[node_id] = node
570
+
571
+ sorted_ids = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True)
572
+ return [(node_lookup[nid], rrf_scores[nid]) for nid in sorted_ids]
573
+
574
+
575
+ # --- Lazy imports ---
576
+
577
+
578
+ def _try_import_query():
579
+ try:
580
+ from descry.query import GraphQuerier, _get_syntax_lang
581
+
582
+ return GraphQuerier, _get_syntax_lang
583
+ except ImportError:
584
+ return None, None
585
+
586
+
587
+ def _try_import_cross_lang():
588
+ try:
589
+ from descry.cross_lang import CrossLangTracer
590
+
591
+ return CrossLangTracer
592
+ except ImportError:
593
+ return None
594
+
595
+
596
+ def _try_import_embeddings(enabled: bool):
597
+ if not enabled:
598
+ return False, None, None
599
+ try:
600
+ from descry.embeddings import (
601
+ embeddings_available,
602
+ SemanticSearcher,
603
+ get_embeddings_status,
604
+ )
605
+
606
+ return embeddings_available(), SemanticSearcher, get_embeddings_status
607
+ except ImportError:
608
+ return False, None, None
609
+
610
+
611
+ def _try_import_scip(enabled: bool):
612
+ if not enabled:
613
+ return False, None, None
614
+ try:
615
+ from descry.scip.support import scip_available, get_scip_status
616
+
617
+ return scip_available(), scip_available, get_scip_status
618
+ except ImportError:
619
+ return False, None, None
620
+
621
+
622
+ def _try_import_git_history():
623
+ try:
624
+ from descry.git_history import GitHistoryAnalyzer, GitError
625
+
626
+ return True, GitHistoryAnalyzer, GitError
627
+ except ImportError:
628
+ return False, None, Exception
629
+
630
+
631
+ # --- DescryService ---
632
+
633
+
634
+ class DescryService:
635
+ """Core business logic. No MCP dependency."""
636
+
637
+ def __init__(self, config: DescryConfig | None = None):
638
+ self.config = config or DescryConfig.from_env()
639
+
640
+ # Lazy-loaded modules
641
+ self._GraphQuerier, self._get_syntax_lang = _try_import_query()
642
+ self._CrossLangTracer = _try_import_cross_lang()
643
+
644
+ scip_ok, self._scip_available_fn, self._get_scip_status_fn = _try_import_scip(
645
+ self.config.enable_scip
646
+ )
647
+ self._scip_loaded = scip_ok
648
+
649
+ emb_ok, self._SemanticSearcher, self._get_embeddings_status_fn = (
650
+ _try_import_embeddings(self.config.enable_embeddings)
651
+ )
652
+ self._semantic_available = emb_ok
653
+
654
+ git_ok, self._GitHistoryAnalyzer, self._GitError = _try_import_git_history()
655
+ self._git_available = git_ok
656
+
657
+ # Instance-level caches (replaces module globals)
658
+ self._graph_cache_lock = asyncio.Lock()
659
+ self._querier_cache_lock = asyncio.Lock()
660
+ self._semantic_cache_lock = asyncio.Lock()
661
+ self._git_cache_lock = asyncio.Lock()
662
+ self._dedup_cache_lock = asyncio.Lock()
663
+
664
+ self._graph_cache = {"mtime": 0, "nodes": 0, "edges": 0}
665
+ self._querier_cache = {"mtime": 0, "instance": None}
666
+ self._semantic_cache = {
667
+ "mtime": 0,
668
+ "instance": None,
669
+ "loading": False,
670
+ "error": None,
671
+ }
672
+ self._git_cache = {"analyzer": None, "graph_mtime": None}
673
+ self._dedup_cache: dict[str, tuple[float, str]] = {}
674
+ self._max_dedup_entries = 100
675
+
676
+ # --- Internal helpers ---
677
+
678
+ def _get_graph_status(self) -> tuple[bool, str, float | None]:
679
+ gp = self.config.graph_path
680
+ if not gp.exists():
681
+ return False, "Graph not found", None
682
+ mtime = gp.stat().st_mtime
683
+ age_hours = (time.time() - mtime) / 3600
684
+ return True, f"{age_hours:.1f}h ago", age_hours
685
+
686
+ async def _update_cache(self):
687
+ async with self._graph_cache_lock:
688
+ gp = self.config.graph_path
689
+ if gp.exists():
690
+ mtime = gp.stat().st_mtime
691
+ if mtime != self._graph_cache["mtime"]:
692
+ try:
693
+ from descry._graph import load_graph_with_schema
694
+
695
+ data = load_graph_with_schema(gp)
696
+ self._graph_cache = {
697
+ "mtime": mtime,
698
+ "nodes": len(data.get("nodes", [])),
699
+ "edges": len(data.get("edges", [])),
700
+ }
701
+ except Exception as e:
702
+ logger.warning(f"Failed to update graph cache: {e}")
703
+
704
+ async def _get_querier(self):
705
+ if self._GraphQuerier is None:
706
+ return None
707
+ async with self._querier_cache_lock:
708
+ gp = self.config.graph_path
709
+ if not gp.exists():
710
+ self._querier_cache = {"mtime": 0, "instance": None}
711
+ return None
712
+ mtime = gp.stat().st_mtime
713
+ if mtime != self._querier_cache["mtime"]:
714
+ self._querier_cache = {
715
+ "mtime": mtime,
716
+ "instance": self._GraphQuerier(str(gp), config=self.config),
717
+ }
718
+ return self._querier_cache["instance"]
719
+
720
+ async def _get_git_analyzer(self):
721
+ async with self._git_cache_lock:
722
+ gp = self.config.graph_path
723
+ current_mtime = gp.stat().st_mtime if gp.exists() else None
724
+ if (
725
+ self._git_cache["graph_mtime"] != current_mtime
726
+ or self._git_cache["analyzer"] is None
727
+ ):
728
+ q = await self._get_querier()
729
+ self._git_cache["analyzer"] = self._GitHistoryAnalyzer(
730
+ str(self.config.project_root),
731
+ graph_querier=q,
732
+ churn_exclusions=self.config.churn_exclusions,
733
+ code_extensions=self.config.code_extensions,
734
+ git_timeout=self.config.git_timeout,
735
+ )
736
+ self._git_cache["graph_mtime"] = current_mtime
737
+ return self._git_cache["analyzer"]
738
+
739
+ async def _check_dedup(self, content_hash: str, graph_mtime: float) -> str | None:
740
+ async with self._dedup_cache_lock:
741
+ if content_hash in self._dedup_cache:
742
+ cached_mtime, node_id = self._dedup_cache[content_hash]
743
+ if cached_mtime == graph_mtime:
744
+ return f"[Source shown above - see {node_id}]"
745
+ return None
746
+
747
+ async def _record_dedup(self, content_hash: str, graph_mtime: float, node_id: str):
748
+ async with self._dedup_cache_lock:
749
+ if len(self._dedup_cache) >= self._max_dedup_entries:
750
+ keys_to_remove = list(self._dedup_cache.keys())[:20]
751
+ for key in keys_to_remove:
752
+ del self._dedup_cache[key]
753
+ self._dedup_cache[content_hash] = (graph_mtime, node_id)
754
+
755
+ def _clear_dedup_cache(self):
756
+ self._dedup_cache = {}
757
+
758
+ def reset_caches(self):
759
+ """Reset all cached instances. Call after reindex."""
760
+ self._graph_cache = {"mtime": 0, "nodes": 0, "edges": 0}
761
+ self._querier_cache = {"mtime": 0, "instance": None}
762
+ self._semantic_cache = {
763
+ "mtime": 0,
764
+ "instance": None,
765
+ "loading": False,
766
+ "error": None,
767
+ }
768
+ self._git_cache = {"analyzer": None, "graph_mtime": None}
769
+ self._dedup_cache = {}
770
+ # The module-level file-content cache in query.py is keyed on mtime,
771
+ # so it self-invalidates after an edit; but a reindex can shift file
772
+ # contents even without an mtime bump (copies, git checkouts). Clear
773
+ # it explicitly so long-running MCP/web processes never serve stale
774
+ # source lines after `descry index`.
775
+ from descry.query import _clear_file_cache
776
+
777
+ _clear_file_cache()
778
+
779
+ async def _format_response(
780
+ self, content: str, include_header: bool = True, max_lines: int = 500
781
+ ) -> str:
782
+ lines = content.split("\n")
783
+ if len(lines) > max_lines:
784
+ content = (
785
+ "\n".join(lines[:max_lines])
786
+ + f"\n\n[Safety truncated: {len(lines) - max_lines} more lines]"
787
+ )
788
+
789
+ if not include_header:
790
+ return content
791
+
792
+ exists, age_str, age_hours = self._get_graph_status()
793
+ if not exists:
794
+ return f"[Graph: NOT FOUND]\n\n{content}"
795
+
796
+ await self._update_cache()
797
+ stale = (
798
+ " STALE" if age_hours and age_hours > self.config.max_stale_hours else ""
799
+ )
800
+ header = f"[{self._graph_cache['nodes']:,}n/{self._graph_cache['edges']:,}e | {age_str}{stale}]"
801
+ return f"{header}\n\n{content}"
802
+
803
+ async def _load_embeddings_background(self):
804
+ async with self._semantic_cache_lock:
805
+ if (
806
+ self._semantic_cache["instance"] is not None
807
+ or self._semantic_cache["loading"]
808
+ ):
809
+ return
810
+ self._semantic_cache["loading"] = True
811
+ self._semantic_cache["error"] = None
812
+
813
+ try:
814
+
815
+ def load_sync():
816
+ return self._SemanticSearcher(
817
+ str(self.config.graph_path), model_name=self.config.embedding_model
818
+ )
819
+
820
+ searcher = await asyncio.wait_for(
821
+ asyncio.to_thread(load_sync), timeout=60.0
822
+ )
823
+
824
+ async with self._semantic_cache_lock:
825
+ gp = self.config.graph_path
826
+ mtime = gp.stat().st_mtime if gp.exists() else 0
827
+ self._semantic_cache["mtime"] = mtime
828
+ self._semantic_cache["instance"] = searcher
829
+ logger.info("Pre-warm: embeddings ready")
830
+ except asyncio.TimeoutError:
831
+ async with self._semantic_cache_lock:
832
+ self._semantic_cache["error"] = "Timeout loading embeddings (60s)"
833
+ logger.warning("Pre-warm: embeddings load timed out")
834
+ except Exception as e:
835
+ async with self._semantic_cache_lock:
836
+ self._semantic_cache["error"] = str(e)
837
+ logger.warning(f"Pre-warm: embeddings failed: {e}")
838
+ finally:
839
+ async with self._semantic_cache_lock:
840
+ self._semantic_cache["loading"] = False
841
+
842
+ async def _get_semantic_searcher(self):
843
+ """Single-flight accessor for the semantic searcher (E.1).
844
+
845
+ Returns the cached SemanticSearcher, rebuilding if
846
+ codebase_graph.json mtime changed. Returns None if embeddings are
847
+ unavailable (module missing or disabled) or if the graph file does
848
+ not exist.
849
+
850
+ Uses _semantic_cache_lock; construction runs in asyncio.to_thread so
851
+ the event loop is not blocked by model load. Callers that want to
852
+ surface an "embeddings loading" UX string should check
853
+ _semantic_cache["loading"] / ["error"] before calling this helper.
854
+ """
855
+ if not self._semantic_available or self._SemanticSearcher is None:
856
+ return None
857
+ gp = self.config.graph_path
858
+ if not gp.exists():
859
+ return None
860
+ mtime = gp.stat().st_mtime
861
+
862
+ async with self._semantic_cache_lock:
863
+ if (
864
+ self._semantic_cache["instance"] is not None
865
+ and self._semantic_cache["mtime"] == mtime
866
+ ):
867
+ return self._semantic_cache["instance"]
868
+ if self._semantic_cache["loading"]:
869
+ return None
870
+ self._semantic_cache["loading"] = True
871
+ self._semantic_cache["error"] = None
872
+
873
+ try:
874
+
875
+ def load_sync():
876
+ return self._SemanticSearcher(
877
+ str(gp), model_name=self.config.embedding_model
878
+ )
879
+
880
+ searcher = await asyncio.wait_for(
881
+ asyncio.to_thread(load_sync), timeout=self.config.embedding_timeout
882
+ )
883
+ async with self._semantic_cache_lock:
884
+ self._semantic_cache["mtime"] = mtime
885
+ self._semantic_cache["instance"] = searcher
886
+ self._semantic_cache["error"] = None
887
+ return searcher
888
+ except asyncio.TimeoutError:
889
+ async with self._semantic_cache_lock:
890
+ self._semantic_cache["error"] = (
891
+ f"Timeout loading embeddings ({self.config.embedding_timeout}s)"
892
+ )
893
+ logger.warning("Embeddings load timed out")
894
+ return None
895
+ except Exception as e:
896
+ async with self._semantic_cache_lock:
897
+ self._semantic_cache["error"] = str(e)
898
+ logger.warning(f"Embeddings load failed: {e}")
899
+ return None
900
+ finally:
901
+ async with self._semantic_cache_lock:
902
+ self._semantic_cache["loading"] = False
903
+
904
+ # --- Public API (handle_* → service methods) ---
905
+
906
+ async def health(self) -> str:
907
+ """Quick diagnostic check."""
908
+ health = {
909
+ "status": "ok",
910
+ "version": SERVER_VERSION,
911
+ "project_root": str(self.config.project_root),
912
+ "cache_dir": str(self.config.cache_dir),
913
+ "warm": False,
914
+ "features": {
915
+ "scip": False,
916
+ "embeddings": False,
917
+ "embeddings_loading": False,
918
+ "embeddings_error": None,
919
+ "git_history": self._git_available,
920
+ },
921
+ "graph": {
922
+ "exists": False,
923
+ "nodes": 0,
924
+ "edges": 0,
925
+ "age_hours": None,
926
+ },
927
+ }
928
+
929
+ async with self._querier_cache_lock:
930
+ health["warm"] = self._querier_cache["instance"] is not None
931
+
932
+ exists, age_str, age_hours = self._get_graph_status()
933
+ health["graph"]["exists"] = exists
934
+ health["graph"]["age_hours"] = age_hours
935
+ if exists:
936
+ await self._update_cache()
937
+ health["graph"]["nodes"] = self._graph_cache["nodes"]
938
+ health["graph"]["edges"] = self._graph_cache["edges"]
939
+
940
+ if self._scip_loaded and self._get_scip_status_fn:
941
+ scip_status = self._get_scip_status_fn()
942
+ health["features"]["scip"] = scip_status.get("available", False)
943
+ if scip_status.get("disabled_by_env"):
944
+ health["features"]["scip_note"] = "Disabled by DESCRY_NO_SCIP"
945
+
946
+ async with self._semantic_cache_lock:
947
+ health["features"]["embeddings"] = (
948
+ self._semantic_cache["instance"] is not None
949
+ )
950
+ health["features"]["embeddings_loading"] = self._semantic_cache["loading"]
951
+ health["features"]["embeddings_error"] = self._semantic_cache.get("error")
952
+
953
+ if not self._semantic_available:
954
+ if not self.config.enable_embeddings:
955
+ health["features"]["embeddings_note"] = (
956
+ "Disabled by DESCRY_NO_EMBEDDINGS"
957
+ )
958
+ else:
959
+ health["features"]["embeddings_note"] = (
960
+ "sentence-transformers not installed"
961
+ )
962
+
963
+ if not exists:
964
+ health["status"] = "no_graph"
965
+ elif age_hours and age_hours > self.config.max_stale_hours:
966
+ health["status"] = "stale"
967
+
968
+ return json.dumps(health, indent=2)
969
+
970
+ async def status(self) -> str:
971
+ """Check graph existence and freshness."""
972
+ exists, age_str, age_hours = self._get_graph_status()
973
+ if not exists:
974
+ return "Graph: NOT FOUND. Run descry index first."
975
+
976
+ await self._update_cache()
977
+ stale = (
978
+ " (STALE - run descry index)"
979
+ if age_hours and age_hours > self.config.max_stale_hours
980
+ else ""
981
+ )
982
+ result = f"Graph: {self._graph_cache['nodes']:,} nodes, {self._graph_cache['edges']:,} edges, updated {age_str}{stale}"
983
+
984
+ if self._scip_loaded and self._get_scip_status_fn:
985
+ scip_status = self._get_scip_status_fn()
986
+ if scip_status.get("available"):
987
+ indexers = scip_status.get("indexers", {})
988
+ enabled = [
989
+ name for name, info in indexers.items() if info.get("available")
990
+ ]
991
+ scip_info = f"\nSCIP: Enabled ({', '.join(enabled) or 'none'})"
992
+ scip_cache_dir = self.config.cache_dir / "scip"
993
+ if scip_cache_dir.exists():
994
+ scip_files = list(scip_cache_dir.glob("*.scip"))
995
+ scip_info += f", {len(scip_files)} project(s) cached"
996
+ elif scip_status.get("disabled_by_env"):
997
+ scip_info = "\nSCIP: Disabled (DESCRY_NO_SCIP=1)"
998
+ else:
999
+ scip_info = "\nSCIP: Unavailable (no indexers found)"
1000
+ else:
1001
+ scip_info = "\nSCIP: Not loaded"
1002
+ result += scip_info
1003
+
1004
+ if self._semantic_available and self._get_embeddings_status_fn:
1005
+ emb_status = self._get_embeddings_status_fn(str(self.config.graph_path))
1006
+ if emb_status.get("cached"):
1007
+ emb_info = (
1008
+ f"\nEmbeddings: Ready ({emb_status.get('node_count', 0):,} nodes)"
1009
+ )
1010
+ elif emb_status.get("stale"):
1011
+ emb_info = (
1012
+ "\nEmbeddings: STALE (graph updated, will rebuild on first search)"
1013
+ )
1014
+ else:
1015
+ emb_info = "\nEmbeddings: Not generated (will build on first search)"
1016
+ else:
1017
+ emb_info = "\nEmbeddings: Unavailable"
1018
+ result += emb_info
1019
+
1020
+ return result
1021
+
1022
+ async def ensure(self, max_age_hours: float = 24) -> str:
1023
+ """Ensure graph exists and is fresh."""
1024
+ exists, age_str, age_hours = self._get_graph_status()
1025
+
1026
+ if not exists:
1027
+ result = await self.index(".")
1028
+ return f"Generated new graph.\n{result}"
1029
+
1030
+ if age_hours and age_hours > max_age_hours:
1031
+ result = await self.index(".")
1032
+ return f"Refreshed stale graph ({age_hours:.0f}h old).\n{result}"
1033
+
1034
+ await self._update_cache()
1035
+ return f"Graph ready: {self._graph_cache['nodes']:,}n/{self._graph_cache['edges']:,}e, {age_str}"
1036
+
1037
+ async def index(self, path: str = ".") -> str:
1038
+ """Regenerate the codebase graph.
1039
+
1040
+ A.9: `path` must resolve inside project_root. Prevents prompt-injected
1041
+ MCP clients from forcing indexing of arbitrary directories.
1042
+ """
1043
+ if path == ".":
1044
+ index_path = str(self.config.project_root)
1045
+ else:
1046
+ try:
1047
+ resolved = Path(path).resolve(strict=False)
1048
+ if not resolved.is_relative_to(self.config.resolved_project_root):
1049
+ return (
1050
+ f"Index path {path!r} outside project root; "
1051
+ f"must be relative to {self.config.project_root}."
1052
+ )
1053
+ index_path = str(resolved)
1054
+ except (OSError, ValueError) as e:
1055
+ return f"Invalid index path {path!r}: {e}"
1056
+
1057
+ try:
1058
+ timeout = (
1059
+ self.config.index_timeout_minutes * 60
1060
+ if self.config.index_timeout_minutes
1061
+ else None
1062
+ )
1063
+ result = await asyncio.to_thread(
1064
+ subprocess.run,
1065
+ [sys.executable, "-m", "descry.generate", index_path],
1066
+ capture_output=True,
1067
+ text=True,
1068
+ cwd=str(self.config.project_root),
1069
+ timeout=timeout,
1070
+ env=safe_env(),
1071
+ )
1072
+
1073
+ if result.returncode == 0:
1074
+ output = result.stdout.strip() or result.stderr.strip()
1075
+ # Full cache flush post-reindex. Previously only the dedup
1076
+ # cache was cleared here and the CLI/MCP paths relied on
1077
+ # mtime-triggered rebuilds for querier/semantic/git caches;
1078
+ # the web path called `reset_caches()` explicitly. Align the
1079
+ # three interfaces so a reindex via any entry point leaves
1080
+ # the service in the same state.
1081
+ self.reset_caches()
1082
+
1083
+ scip_status = ""
1084
+ if (
1085
+ self._scip_loaded
1086
+ and self._scip_available_fn
1087
+ and self._scip_available_fn()
1088
+ ):
1089
+ scip_cache_dir = self.config.cache_dir / "scip"
1090
+ if scip_cache_dir.exists():
1091
+ scip_files = list(scip_cache_dir.glob("*.scip"))
1092
+ if scip_files:
1093
+ scip_status = (
1094
+ f"\nSCIP: {len(scip_files)} project(s) indexed"
1095
+ )
1096
+
1097
+ embeddings_status = ""
1098
+ if (
1099
+ self._semantic_available
1100
+ and self._SemanticSearcher
1101
+ and self.config.graph_path.exists()
1102
+ ):
1103
+ try:
1104
+ # Force-rebuild: reset cache then construct in a thread
1105
+ # so model load doesn't block the event loop (C.1).
1106
+ async with self._semantic_cache_lock:
1107
+ self._semantic_cache = {
1108
+ "mtime": 0,
1109
+ "instance": None,
1110
+ "loading": False,
1111
+ "error": None,
1112
+ }
1113
+ logger.info("Generating embeddings for semantic search...")
1114
+
1115
+ def _build_searcher():
1116
+ return self._SemanticSearcher(
1117
+ str(self.config.graph_path),
1118
+ force_rebuild=True,
1119
+ model_name=self.config.embedding_model,
1120
+ )
1121
+
1122
+ searcher = await asyncio.to_thread(_build_searcher)
1123
+ # Seed the cache with the freshly-built instance so
1124
+ # the next search() call reuses it.
1125
+ async with self._semantic_cache_lock:
1126
+ gp = self.config.graph_path
1127
+ mtime = gp.stat().st_mtime if gp.exists() else 0
1128
+ self._semantic_cache["mtime"] = mtime
1129
+ self._semantic_cache["instance"] = searcher
1130
+ embeddings_status = (
1131
+ f"\nEmbeddings: {len(searcher.nodes):,} nodes indexed"
1132
+ )
1133
+ except Exception as e:
1134
+ embeddings_status = f"\nEmbeddings: Failed ({e})"
1135
+ logger.warning(f"Embeddings generation failed: {e}")
1136
+
1137
+ return f"Index complete.{scip_status}{embeddings_status}\n{output}"
1138
+ else:
1139
+ return f"Index failed:\n{result.stderr}"
1140
+
1141
+ except subprocess.TimeoutExpired:
1142
+ mins = self.config.index_timeout_minutes
1143
+ return f"Index timed out after {mins} minutes. Set [timeouts] index_minutes in .descry.toml to increase."
1144
+ except Exception as e:
1145
+ return f"Index error: {e}"
1146
+
1147
+ async def callers(self, name: str, limit: int = 20) -> str:
1148
+ """Find all callers of a symbol."""
1149
+ q = await self._get_querier()
1150
+ if not q:
1151
+ return "ERROR: Graph not found. Run descry ensure first."
1152
+
1153
+ all_callers = q.get_callers(name)
1154
+ fuzzy_note = ""
1155
+
1156
+ if not all_callers:
1157
+ all_callers = q.get_callers(name, fuzzy=True)
1158
+ if all_callers:
1159
+ fuzzy_note = " (fuzzy match)"
1160
+
1161
+ if not all_callers:
1162
+ result = (
1163
+ f"No callers of '{name}'. Try descry search to verify symbol exists."
1164
+ )
1165
+ else:
1166
+ total_count = len(all_callers)
1167
+ callers = sorted(all_callers)[:limit]
1168
+ lines = [f"{len(callers)} caller(s) of '{name}'{fuzzy_note}:"]
1169
+ for caller in callers:
1170
+ node_info = q.get_node_info(caller)
1171
+ if node_info:
1172
+ lineno = node_info.get("metadata", {}).get("lineno")
1173
+ if lineno:
1174
+ file_path = caller.split("::")[0].replace("FILE:", "")
1175
+ lines.append(f" {caller} ({file_path}:{lineno})")
1176
+ else:
1177
+ lines.append(f" {caller}")
1178
+ else:
1179
+ lines.append(f" {caller}")
1180
+ if len(callers) < total_count:
1181
+ lines.append(
1182
+ f" ... ({total_count - len(callers)} more, limit {limit})"
1183
+ )
1184
+ result = "\n".join(lines)
1185
+
1186
+ return await self._format_response(result)
1187
+
1188
+ async def callees(self, name: str, limit: int = 20) -> str:
1189
+ """Find what a symbol calls."""
1190
+ q = await self._get_querier()
1191
+ if not q:
1192
+ return "ERROR: Graph not found. Run descry ensure first."
1193
+
1194
+ matches = q.find_nodes_by_name(name)
1195
+ func_matches = [m for m in matches if m["type"] in ("Function", "Method")]
1196
+ fuzzy_note = ""
1197
+
1198
+ if not func_matches:
1199
+ matches = q.find_nodes_by_name(name, fuzzy=True)
1200
+ func_matches = [m for m in matches if m["type"] in ("Function", "Method")]
1201
+ if func_matches:
1202
+ fuzzy_note = " (fuzzy match)"
1203
+
1204
+ if not func_matches:
1205
+ return f"No function '{name}' found. Try descry search."
1206
+
1207
+ node = func_matches[0]
1208
+ callees = q.get_callees(node["id"])
1209
+ callees = sorted(callees)[:limit]
1210
+
1211
+ display_name = node.get("metadata", {}).get("name", name)
1212
+ if not callees:
1213
+ result = f"'{display_name}'{fuzzy_note} calls no tracked functions."
1214
+ else:
1215
+ lines = [f"'{display_name}'{fuzzy_note} calls {len(callees)} function(s):"]
1216
+ for callee in callees:
1217
+ callee_info = q.get_node_info(callee)
1218
+ if callee_info:
1219
+ lineno = callee_info.get("metadata", {}).get("lineno")
1220
+ if lineno:
1221
+ file_path = callee.split("::")[0].replace("FILE:", "")
1222
+ lines.append(f" {callee} ({file_path}:{lineno})")
1223
+ else:
1224
+ lines.append(f" {callee}")
1225
+ else:
1226
+ lines.append(f" {callee}")
1227
+ result = "\n".join(lines)
1228
+
1229
+ return await self._format_response(result)
1230
+
1231
+ async def context(
1232
+ self,
1233
+ node_id: str,
1234
+ brief: bool = False,
1235
+ full: bool = False,
1236
+ expand_callees: bool = False,
1237
+ deduplicate: bool = False,
1238
+ depth: int = 1,
1239
+ max_tokens: int = 2000,
1240
+ callee_budget: int = 2000,
1241
+ head_lines: int | None = None,
1242
+ max_output_tokens: int | None = None,
1243
+ ) -> str:
1244
+ """Get full context for a symbol."""
1245
+ q = await self._get_querier()
1246
+ if not q:
1247
+ return "ERROR: Graph not found. Run descry ensure first."
1248
+
1249
+ gp = self.config.graph_path
1250
+ # Capture mtime + hash once so the check and record use the same
1251
+ # (graph_mtime, content_hash) pair. Previously the mtime was stat'd
1252
+ # twice — if the graph rewrote between the two stats, the dedup
1253
+ # record could claim content from graph_A belongs to graph_B.
1254
+ graph_mtime: float | None = None
1255
+ content_hash: str | None = None
1256
+ if deduplicate and gp.exists():
1257
+ try:
1258
+ graph_mtime = gp.stat().st_mtime
1259
+ except OSError:
1260
+ graph_mtime = None
1261
+ if deduplicate and graph_mtime is not None:
1262
+ # usedforsecurity=False: this hash is only a dedup key for the
1263
+ # in-memory context cache, never a trust anchor.
1264
+ content_hash = hashlib.md5(
1265
+ node_id.encode(), usedforsecurity=False
1266
+ ).hexdigest()
1267
+ dedup_ref = await self._check_dedup(content_hash, graph_mtime)
1268
+ if dedup_ref:
1269
+ brief_result = q.get_context_prompt(node_id, brief=True)
1270
+ return await self._format_response(
1271
+ f"{brief_result}\n\n{dedup_ref}", include_header=False, max_lines=50
1272
+ )
1273
+
1274
+ result = q.get_context_prompt(
1275
+ node_id,
1276
+ depth=depth,
1277
+ max_tokens=max_tokens,
1278
+ full=full,
1279
+ head_lines=head_lines,
1280
+ expand_callees=expand_callees,
1281
+ callee_budget=callee_budget,
1282
+ brief=brief,
1283
+ max_output_tokens=max_output_tokens,
1284
+ )
1285
+
1286
+ if deduplicate and graph_mtime is not None and content_hash is not None:
1287
+ await self._record_dedup(content_hash, graph_mtime, node_id)
1288
+
1289
+ if brief:
1290
+ return await self._format_response(
1291
+ result, include_header=False, max_lines=50
1292
+ )
1293
+
1294
+ depth_note = f" [depth={depth}]" if depth > 1 else ""
1295
+ full_note = " [full=true]" if full else ""
1296
+ head_note = f" [head_lines={head_lines}]" if head_lines else ""
1297
+ expand_note = " [expand_callees=true]" if expand_callees else ""
1298
+ ml = 2000 if (full or expand_callees) else 500
1299
+ return await self._format_response(
1300
+ result + depth_note + full_note + head_note + expand_note,
1301
+ include_header=False,
1302
+ max_lines=ml,
1303
+ )
1304
+
1305
+ async def flow(
1306
+ self,
1307
+ start: str,
1308
+ direction: str = "forward",
1309
+ depth: int = 3,
1310
+ target: str | None = None,
1311
+ inline_threshold: int = 100,
1312
+ ) -> str:
1313
+ """Trace call flow from a starting symbol."""
1314
+ q = await self._get_querier()
1315
+ if not q:
1316
+ return "ERROR: Graph not found. Run descry ensure first."
1317
+
1318
+ result = q.trace_flow(
1319
+ start_name=start,
1320
+ direction=direction,
1321
+ depth=depth,
1322
+ target=target,
1323
+ inline_threshold=inline_threshold,
1324
+ )
1325
+ return await self._format_response(result, include_header=True, max_lines=300)
1326
+
1327
+ async def search(
1328
+ self,
1329
+ terms: list[str],
1330
+ compact: bool = True,
1331
+ limit: int = 10,
1332
+ lang: str | None = None,
1333
+ crate: str | None = None,
1334
+ symbol_type: str | None = None,
1335
+ exclude_tests: bool = False,
1336
+ ) -> str:
1337
+ """Search symbol names and docstrings."""
1338
+ q = await self._get_querier()
1339
+ if not q:
1340
+ return await self._format_response(
1341
+ "ERROR: Graph not found. Run descry ensure first.",
1342
+ include_header=True,
1343
+ )
1344
+
1345
+ filters = []
1346
+ if lang and lang != "all":
1347
+ filters.append(f"lang={lang}")
1348
+ if crate:
1349
+ filters.append(f"crate={crate}")
1350
+ if symbol_type and symbol_type != "all":
1351
+ filters.append(f"type={symbol_type}")
1352
+ if exclude_tests:
1353
+ filters.append("exclude_tests")
1354
+ if compact:
1355
+ filters.append("compact")
1356
+ filter_note = f" [{', '.join(filters)}]" if filters else ""
1357
+
1358
+ tfidf_results = q.search_docs(
1359
+ terms,
1360
+ lang=lang,
1361
+ crate=crate,
1362
+ symbol_type=symbol_type,
1363
+ exclude_tests=exclude_tests,
1364
+ )[: limit * 2]
1365
+
1366
+ semantic_results = []
1367
+ search_method = "keyword"
1368
+
1369
+ if (
1370
+ self._semantic_available
1371
+ and self._SemanticSearcher
1372
+ and self.config.graph_path.exists()
1373
+ ):
1374
+ use_semantic = is_natural_language_query(terms) or len(tfidf_results) < 3
1375
+ if use_semantic:
1376
+ try:
1377
+ # E.1/C.1: use the single-flight async helper (runs in
1378
+ # asyncio.to_thread and respects the cache lock).
1379
+ searcher = await self._get_semantic_searcher()
1380
+ if searcher is not None:
1381
+ query = " ".join(terms)
1382
+ # searcher.search is CPU-bound; wrap in to_thread.
1383
+ semantic_results = await asyncio.to_thread(
1384
+ searcher.search, query, limit=limit * 2, min_score=0.25
1385
+ )
1386
+ search_method = "hybrid"
1387
+ except Exception as e:
1388
+ logger.warning(f"Semantic search failed, using keyword only: {e}")
1389
+
1390
+ if semantic_results and tfidf_results:
1391
+ combined = reciprocal_rank_fusion(tfidf_results, semantic_results)
1392
+ results = [node for node, _ in combined[:limit]]
1393
+ search_method = "hybrid"
1394
+ elif tfidf_results:
1395
+ results = tfidf_results[:limit]
1396
+ else:
1397
+ results = []
1398
+
1399
+ if not results:
1400
+ result = f"No matches for '{' '.join(terms)}'{filter_note}."
1401
+ elif compact:
1402
+ lines = [
1403
+ f"{len(results)} match(es) for '{' '.join(terms)}' [{search_method}]{filter_note}:"
1404
+ ]
1405
+ for i, node in enumerate(results, 1):
1406
+ lines.append(format_compact_result(node, rank=i))
1407
+ lines.append("")
1408
+ lines.append("Use `descry context` with node ID for details.")
1409
+ result = "\n".join(lines)
1410
+ else:
1411
+ lines = [
1412
+ f"{len(results)} match(es) for '{' '.join(terms)}' [{search_method}]{filter_note}:\n"
1413
+ ]
1414
+ for i, node in enumerate(results, 1):
1415
+ lines.append(format_search_result(node, rank=i))
1416
+ lines.append("")
1417
+ result = "\n".join(lines).rstrip()
1418
+
1419
+ return await self._format_response(result)
1420
+
1421
+ async def structure(self, filename: str) -> str:
1422
+ """Show file structure."""
1423
+ q = await self._get_querier()
1424
+ if not q:
1425
+ return "ERROR: Graph not found. Run descry ensure first."
1426
+
1427
+ matches = q.find_nodes_by_name(filename)
1428
+ file_matches = [m for m in matches if m["type"] == "File"]
1429
+
1430
+ if not file_matches:
1431
+ return f"File '{filename}' not found. Check spelling."
1432
+
1433
+ node_id = file_matches[0]["id"]
1434
+ lines = [f"{node_id}:"]
1435
+
1436
+ defs = []
1437
+ imports = set()
1438
+ for edge in q.outgoing[node_id]:
1439
+ if edge["relation"] == "DEFINES":
1440
+ target = q.nodes.get(edge["target"])
1441
+ if target:
1442
+ defs.append(target)
1443
+ elif edge["relation"] == "IMPORTS":
1444
+ target = edge["target"]
1445
+ if target.startswith("MODULE:"):
1446
+ imports.add(target.replace("MODULE:", ""))
1447
+ else:
1448
+ imports.add(target)
1449
+
1450
+ if imports:
1451
+ sorted_imports = sorted(imports)
1452
+ lines.append(f" Imports: {', '.join(sorted_imports[:10])}")
1453
+ if len(sorted_imports) > 10:
1454
+ lines.append(f" (+{len(sorted_imports) - 10} more)")
1455
+
1456
+ for type_name, type_filter in [
1457
+ ("Const", "Constant"),
1458
+ ("Class", "Class"),
1459
+ ("Fn", "Function"),
1460
+ ]:
1461
+ items = sorted(
1462
+ [d["metadata"]["name"] for d in defs if d["type"] == type_filter]
1463
+ )
1464
+ if items:
1465
+ lines.append(f" {type_name}: {', '.join(items[:15])}")
1466
+ if len(items) > 15:
1467
+ lines.append(f" (+{len(items) - 15} more)")
1468
+
1469
+ configs = [d for d in defs if d["type"] == "Configuration"]
1470
+ if configs:
1471
+ lines.append(" Config:")
1472
+ for cfg in sorted(configs, key=lambda x: x["metadata"].get("lineno", 0)):
1473
+ meta = cfg["metadata"]
1474
+ sig = meta.get("signature", meta.get("name", "?"))
1475
+ lineno = meta.get("lineno", "?")
1476
+ lines.append(f" L{lineno}: {sig}")
1477
+
1478
+ return await self._format_response("\n".join(lines))
1479
+
1480
+ async def flatten(self, class_node_id: str) -> str:
1481
+ """Show effective API of a class including inherited methods."""
1482
+ q = await self._get_querier()
1483
+ if not q:
1484
+ return "ERROR: Graph not found. Run descry ensure first."
1485
+
1486
+ result = q.flatten_class(class_node_id)
1487
+ return await self._format_response(result, include_header=False, max_lines=150)
1488
+
1489
+ async def semantic(self, query: str, limit: int = 10) -> str:
1490
+ """Pure semantic search using embeddings only."""
1491
+ if not self._semantic_available or not self._SemanticSearcher:
1492
+ return (
1493
+ "Semantic search not available. Install dependencies:\n"
1494
+ " pip install sentence-transformers numpy\n\n"
1495
+ "Falling back to TF-IDF keyword search..."
1496
+ )
1497
+
1498
+ gp = self.config.graph_path
1499
+ if not gp.exists():
1500
+ return "ERROR: Graph not found. Run descry ensure first."
1501
+
1502
+ # Show a friendly message if a background pre-warm is in flight.
1503
+ async with self._semantic_cache_lock:
1504
+ if self._semantic_cache["loading"]:
1505
+ return "Embeddings loading in background (~2-3s). Try again shortly."
1506
+
1507
+ # E.1/C.1: single-flight helper wraps the model load in asyncio.to_thread.
1508
+ searcher = await self._get_semantic_searcher()
1509
+ if searcher is None:
1510
+ err = self._semantic_cache.get("error")
1511
+ if err:
1512
+ return f"Failed to initialize semantic search: {err}"
1513
+ return "Semantic search unavailable."
1514
+
1515
+ try:
1516
+ results = await asyncio.to_thread(searcher.search, query, limit=limit)
1517
+ except Exception as e:
1518
+ return f"Semantic search error: {e}"
1519
+
1520
+ if not results:
1521
+ result = f"No semantic matches for '{query}'."
1522
+ else:
1523
+ lines = [f"{len(results)} semantic match(es) for '{query}':\n"]
1524
+ for i, (node, score) in enumerate(results, 1):
1525
+ lines.append(
1526
+ format_search_result(node, rank=i, show_score=True, score=score)
1527
+ )
1528
+ lines.append("")
1529
+ result = "\n".join(lines).rstrip()
1530
+
1531
+ return await self._format_response(result)
1532
+
1533
+ async def quick(self, name: str, full: bool = False, brief: bool = False) -> str:
1534
+ """Find symbol and show full context in one step."""
1535
+ q = await self._get_querier()
1536
+ if not q:
1537
+ return "ERROR: Graph not found. Run descry ensure first."
1538
+
1539
+ matches = q.find_nodes_by_name(name)
1540
+
1541
+ def type_priority(node):
1542
+ t = node.get("type", "")
1543
+ if t in ("Function", "Method"):
1544
+ return 0
1545
+ if t == "Class":
1546
+ return 1
1547
+ return 2
1548
+
1549
+ matches.sort(key=type_priority)
1550
+
1551
+ if not matches:
1552
+ matches = q.find_nodes_by_name(name, fuzzy=True)
1553
+ matches.sort(key=type_priority)
1554
+
1555
+ if not matches:
1556
+ return f"No symbol found for '{name}'. Try descry search to explore."
1557
+
1558
+ best_match = matches[0]
1559
+ node_id = best_match["id"]
1560
+
1561
+ if brief:
1562
+ context = q.get_context_prompt(node_id, brief=True)
1563
+ other_matches = (
1564
+ f"\n*({len(matches) - 1} other matches)*" if len(matches) > 1 else ""
1565
+ )
1566
+ return await self._format_response(
1567
+ context + other_matches, include_header=False, max_lines=50
1568
+ )
1569
+
1570
+ header_lines = [
1571
+ f"### Quick Context for `{name}`",
1572
+ "",
1573
+ format_search_result(best_match, rank=1),
1574
+ "",
1575
+ ]
1576
+
1577
+ if len(matches) > 1:
1578
+ header_lines.append(f"*({len(matches) - 1} other matches available)*")
1579
+ header_lines.append("")
1580
+
1581
+ context = q.get_context_prompt(node_id, full=full)
1582
+ result = "\n".join(header_lines) + "\n" + context
1583
+ if full:
1584
+ result += "\n [full=true]"
1585
+
1586
+ ml = 2000 if full else 500
1587
+ return await self._format_response(result, include_header=False, max_lines=ml)
1588
+
1589
+ async def impls(self, method: str, trait_name: str | None = None) -> str:
1590
+ """Find all implementations of a trait method."""
1591
+ q = await self._get_querier()
1592
+ if not q:
1593
+ return "ERROR: Graph not found. Run descry ensure first."
1594
+
1595
+ results = q.find_trait_impls(method, trait_name)
1596
+
1597
+ if not results:
1598
+ trait_filter = f" for trait '{trait_name}'" if trait_name else ""
1599
+ return (
1600
+ f"No trait implementations found for method '{method}'{trait_filter}.\n\n"
1601
+ f"Note: trait_impl metadata is only available for recently indexed code. "
1602
+ f"Run descry index to regenerate the graph with this metadata."
1603
+ )
1604
+
1605
+ by_trait = defaultdict(list)
1606
+ for node in results:
1607
+ meta = node.get("metadata", {})
1608
+ impl_trait = meta.get("trait_impl", "unknown")
1609
+ by_trait[impl_trait].append(node)
1610
+
1611
+ lines = [f"### Implementations of `{method}`"]
1612
+ if trait_name:
1613
+ lines.append(f"*Filtered to trait: {trait_name}*")
1614
+ lines.append("")
1615
+ lines.append(f"Found {len(results)} implementation(s):\n")
1616
+
1617
+ for trait, nodes in sorted(by_trait.items()):
1618
+ lines.append(f"**{trait}** ({len(nodes)} impl):")
1619
+ for node in nodes:
1620
+ meta = node.get("metadata", {})
1621
+ nid = node.get("id", "")
1622
+ parts = nid.split("::")
1623
+ struct_name = parts[-2] if len(parts) >= 2 else "?"
1624
+ file_path = (
1625
+ nid.split("::")[0].replace("FILE:", "")
1626
+ if nid.startswith("FILE:")
1627
+ else ""
1628
+ )
1629
+ lineno = meta.get("lineno", "?")
1630
+ sig = meta.get("signature", f"fn {method}(...)")
1631
+ lines.append(f" - **{struct_name}**::{method}")
1632
+ lines.append(f" {file_path}:{lineno}")
1633
+ lines.append(f" `{sig}`")
1634
+ lines.append("")
1635
+
1636
+ return await self._format_response("\n".join(lines))
1637
+
1638
+ async def path(
1639
+ self,
1640
+ start: str,
1641
+ end: str,
1642
+ max_depth: int = 10,
1643
+ direction: str = "forward",
1644
+ ) -> str:
1645
+ """Find shortest call path between two symbols."""
1646
+ q = await self._get_querier()
1647
+ if not q:
1648
+ return "ERROR: Graph not found. Run descry ensure first."
1649
+
1650
+ result_path = q.find_call_path(
1651
+ start, end, max_depth=max_depth, direction=direction
1652
+ )
1653
+
1654
+ if not result_path:
1655
+ return (
1656
+ f"No path found from '{start}' to '{end}' within {max_depth} hops.\n\n"
1657
+ f"Direction: {direction} ('{start}' {'calls' if direction == 'forward' else 'is called by'} '{end}')\n\n"
1658
+ f"Try:\n"
1659
+ f"- Verify both symbols exist with descry search\n"
1660
+ f"- Increase max_depth if the path is longer\n"
1661
+ f"- Try the opposite direction"
1662
+ )
1663
+
1664
+ lines = [
1665
+ f"### Call Path: `{start}` -> `{end}` ({len(result_path)} hop{'s' if len(result_path) != 1 else ''})\n"
1666
+ ]
1667
+
1668
+ for i, hop in enumerate(result_path, 1):
1669
+ caller_name = hop.get("caller_name", "?")
1670
+ callee_name = hop.get("callee_name", "?")
1671
+ file_path = hop.get("file_path", "")
1672
+ call_line = hop.get("call_line")
1673
+ snippet = hop.get("call_snippet", "")
1674
+
1675
+ lines.append(f"**{i}. {caller_name}** -> **{callee_name}**")
1676
+ if file_path and call_line:
1677
+ lines.append(f" {file_path}:{call_line}")
1678
+ elif file_path:
1679
+ lines.append(f" {file_path}")
1680
+
1681
+ if snippet:
1682
+ lang = (
1683
+ self._get_syntax_lang(file_path)
1684
+ if file_path and self._get_syntax_lang
1685
+ else ""
1686
+ )
1687
+ lines.append(f"```{lang}")
1688
+ for line in snippet.split("\n"):
1689
+ lines.append(f" {line}")
1690
+ lines.append("```")
1691
+ lines.append("")
1692
+
1693
+ return await self._format_response("\n".join(lines), include_header=True)
1694
+
1695
+ async def cross_lang(
1696
+ self,
1697
+ mode: str = "endpoint",
1698
+ method: str | None = None,
1699
+ path: str | None = None,
1700
+ tag: str | None = None,
1701
+ ) -> str:
1702
+ """Trace API calls from frontend to backend handlers via OpenAPI spec."""
1703
+ if not self._CrossLangTracer:
1704
+ return "Cross-language tracing not available. Missing cross_lang module."
1705
+
1706
+ openapi_path = self.config.openapi_path
1707
+ if openapi_path is None:
1708
+ return "Cross-language tracing not configured. Set openapi_path in config."
1709
+ if not openapi_path.exists():
1710
+ return f"OpenAPI spec not found at {openapi_path}."
1711
+
1712
+ graph_path = (
1713
+ str(self.config.graph_path) if self.config.graph_path.exists() else None
1714
+ )
1715
+ tracer = self._CrossLangTracer(str(openapi_path), graph_path)
1716
+
1717
+ if mode == "stats":
1718
+ stats = tracer.get_stats()
1719
+ lines = [
1720
+ "### Cross-Language Tracing Stats",
1721
+ "",
1722
+ f"**OpenAPI Spec**: {stats['openapi_path']}",
1723
+ f"**Total Endpoints**: {stats['total_endpoints']}",
1724
+ f"**Linked to Graph**: {stats['linked_to_graph']}",
1725
+ "",
1726
+ "Use mode='list' to see endpoints by tag.",
1727
+ "Use mode='endpoint' with method/path to lookup a specific handler.",
1728
+ ]
1729
+ return "\n".join(lines)
1730
+
1731
+ elif mode == "list":
1732
+ endpoints = tracer.list_endpoints(tag=tag)
1733
+ if not endpoints:
1734
+ tag_note = f" for tag '{tag}'" if tag else ""
1735
+ return f"No endpoints found{tag_note}."
1736
+
1737
+ tag_note = f" (tag: {tag})" if tag else ""
1738
+ lines = [f"### API Endpoints{tag_note}", ""]
1739
+
1740
+ current_path = None
1741
+ for ep in endpoints:
1742
+ if ep["path"] != current_path:
1743
+ if current_path is not None:
1744
+ lines.append("")
1745
+ current_path = ep["path"]
1746
+ lines.append(f"**{ep['path']}**")
1747
+
1748
+ handler = ep["operationId"]
1749
+ node_id = ep.get("node_id")
1750
+ summary = ep.get("summary", "")
1751
+
1752
+ if node_id:
1753
+ file_info = node_id.split("::")[0].replace("FILE:", "")
1754
+ lines.append(f" {ep['method']:6s} -> `{handler}` ({file_info})")
1755
+ else:
1756
+ lines.append(f" {ep['method']:6s} -> `{handler}` (not in graph)")
1757
+
1758
+ if summary:
1759
+ lines.append(
1760
+ f" {summary[:60]}{'...' if len(summary) > 60 else ''}"
1761
+ )
1762
+
1763
+ lines.append("")
1764
+ lines.append(f"*{len(endpoints)} endpoint(s) total*")
1765
+ return "\n".join(lines)
1766
+
1767
+ elif mode == "endpoint":
1768
+ if not method or not path:
1769
+ return (
1770
+ "Endpoint mode requires 'method' and 'path' arguments.\n"
1771
+ "Example: method='GET', path='/api/v1/deployments'"
1772
+ )
1773
+
1774
+ info = tracer.get_handler_info(method.upper(), path)
1775
+ if not info:
1776
+ return (
1777
+ f"No handler found for {method.upper()} {path}.\n\n"
1778
+ f"Try listing endpoints with: mode='list', tag='<resource>'"
1779
+ )
1780
+
1781
+ lines = [
1782
+ f"### Handler for {method.upper()} {info['path']}",
1783
+ "",
1784
+ f"**Operation ID**: `{info['operationId']}`",
1785
+ f"**Tags**: {', '.join(info.get('tags', [])) or 'none'}",
1786
+ ]
1787
+
1788
+ if info.get("summary"):
1789
+ lines.append(f"**Summary**: {info['summary']}")
1790
+
1791
+ node_id = info.get("node_id")
1792
+ if node_id:
1793
+ lines.append("")
1794
+ lines.append(f"**Graph Node**: `{node_id}`")
1795
+
1796
+ q = await self._get_querier()
1797
+ if q:
1798
+ node_info = q.get_node_info(node_id)
1799
+ if node_info:
1800
+ meta = node_info.get("metadata", {})
1801
+ if meta.get("lineno"):
1802
+ file_path = node_id.split("::")[0].replace("FILE:", "")
1803
+ lines.append(f"**Location**: {file_path}:{meta['lineno']}")
1804
+ if meta.get("signature"):
1805
+ lines.append(f"**Signature**: `{meta['signature']}`")
1806
+
1807
+ lines.append("")
1808
+ lines.append(
1809
+ "Use `descry context` with this node_id for full details."
1810
+ )
1811
+ else:
1812
+ lines.append("")
1813
+ lines.append(
1814
+ "*Handler not found in graph - may need to run descry index*"
1815
+ )
1816
+
1817
+ return "\n".join(lines)
1818
+
1819
+ else:
1820
+ return f"Unknown mode '{mode}'. Use 'endpoint', 'list', or 'stats'."
1821
+
1822
+ async def churn(
1823
+ self,
1824
+ time_range: str | None = None,
1825
+ path_filter: str | None = None,
1826
+ limit: int = 20,
1827
+ mode: str = "symbols",
1828
+ exclude_generated: bool = True,
1829
+ ) -> str:
1830
+ """Find code churn hotspots."""
1831
+ if not self._git_available:
1832
+ return "Git history tools not available. Missing git_history module."
1833
+
1834
+ try:
1835
+ analyzer = await self._get_git_analyzer()
1836
+ result = await asyncio.to_thread(
1837
+ analyzer.get_churn,
1838
+ time_range=time_range,
1839
+ path_filter=path_filter,
1840
+ limit=limit,
1841
+ mode=mode,
1842
+ exclude_generated=exclude_generated,
1843
+ )
1844
+ return await self._format_response(result)
1845
+ except self._GitError as e:
1846
+ return str(e)
1847
+ except Exception as e:
1848
+ logger.exception("Error in churn")
1849
+ return f"Error analyzing churn: {e}"
1850
+
1851
+ async def evolution(
1852
+ self,
1853
+ name: str,
1854
+ time_range: str | None = None,
1855
+ limit: int = 10,
1856
+ show_diff: bool = False,
1857
+ crate: str | None = None,
1858
+ ) -> str:
1859
+ """Track how a symbol has changed over time."""
1860
+ if not self._git_available:
1861
+ return "Git history tools not available. Missing git_history module."
1862
+
1863
+ try:
1864
+ analyzer = await self._get_git_analyzer()
1865
+ result = await asyncio.to_thread(
1866
+ analyzer.get_evolution,
1867
+ name=name,
1868
+ time_range=time_range,
1869
+ limit=limit,
1870
+ show_diff=show_diff,
1871
+ crate=crate,
1872
+ )
1873
+ return await self._format_response(result)
1874
+ except self._GitError as e:
1875
+ return str(e)
1876
+ except Exception as e:
1877
+ logger.exception("Error in evolution")
1878
+ return f"Error analyzing evolution: {e}"
1879
+
1880
+ async def changes(
1881
+ self,
1882
+ commit_range: str | None = None,
1883
+ time_range: str | None = None,
1884
+ path_filter: str | None = None,
1885
+ show_callers: bool = True,
1886
+ limit: int = 50,
1887
+ ) -> str:
1888
+ """Analyze change impact for a commit range."""
1889
+ if not self._git_available:
1890
+ return "Git history tools not available. Missing git_history module."
1891
+
1892
+ try:
1893
+ analyzer = await self._get_git_analyzer()
1894
+ result = await asyncio.to_thread(
1895
+ analyzer.get_changes,
1896
+ commit_range=commit_range,
1897
+ time_range=time_range,
1898
+ path_filter=path_filter,
1899
+ show_callers=show_callers,
1900
+ limit=limit,
1901
+ )
1902
+ return await self._format_response(result)
1903
+ except self._GitError as e:
1904
+ return str(e)
1905
+ except Exception as e:
1906
+ logger.exception("Error in changes")
1907
+ return f"Error analyzing changes: {e}"