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/__init__.py +4 -0
- descry/_env.py +30 -0
- descry/_graph.py +36 -0
- descry/ast_grep.py +382 -0
- descry/cli.py +457 -0
- descry/cross_lang.py +404 -0
- descry/embeddings.py +593 -0
- descry/generate.py +3579 -0
- descry/git_history.py +1631 -0
- descry/handlers.py +1907 -0
- descry/mcp_server.py +355 -0
- descry/query.py +2345 -0
- descry/scip/__init__.py +0 -0
- descry/scip/cache.py +1047 -0
- descry/scip/parser.py +531 -0
- descry/scip/pb2.py +92 -0
- descry/scip/support.py +174 -0
- descry/tree_sitter_parser.py +356 -0
- descry/web/__init__.py +0 -0
- descry/web/server.py +1457 -0
- descry/web/web/index.html +2444 -0
- descry/web/web/style.css +1791 -0
- descry_codegraph-0.1.0.dist-info/METADATA +345 -0
- descry_codegraph-0.1.0.dist-info/RECORD +27 -0
- descry_codegraph-0.1.0.dist-info/WHEEL +4 -0
- descry_codegraph-0.1.0.dist-info/entry_points.txt +5 -0
- descry_codegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
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}"
|