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/__init__.py
ADDED
descry/_env.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Subprocess environment sanitization.
|
|
2
|
+
|
|
3
|
+
Filters environment variables matching known credential patterns before
|
|
4
|
+
passing env to subprocess calls (rust-analyzer, scip-typescript, git,
|
|
5
|
+
ast-grep, descry.generate). Keeps tooling vars (RUSTUP_*, CARGO_*,
|
|
6
|
+
GITHUB_WORKSPACE, AWS_REGION, etc.) intact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_SECRET_PATTERNS = re.compile(
|
|
14
|
+
r"(?i)("
|
|
15
|
+
r"secret|token|password|passphrase|credential|"
|
|
16
|
+
r"api[_-]?key|private[_-]?key|ssh[_-]?key|"
|
|
17
|
+
r"aws[_-](?:access|secret|session|security)|"
|
|
18
|
+
r"github[_-](?:token|pat|oauth)|"
|
|
19
|
+
r"openai[_-]|anthropic[_-]|"
|
|
20
|
+
r"gcp[_-]|azure[_-](?:storage|subscription|tenant|client)[_-]|"
|
|
21
|
+
r"stripe[_-]|slack[_-]|"
|
|
22
|
+
r"sentry[_-]|_dsn$|"
|
|
23
|
+
r"(?:database|redis|mongodb?|postgres|amqp|mysql)[_-]?(?:url|uri)$"
|
|
24
|
+
r")"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def safe_env() -> dict[str, str]:
|
|
29
|
+
"""Return os.environ minus variables matching credential patterns."""
|
|
30
|
+
return {k: v for k, v in os.environ.items() if not _SECRET_PATTERNS.search(k)}
|
descry/_graph.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Graph schema versioning.
|
|
2
|
+
|
|
3
|
+
Centralized load helper for codebase_graph.json with schema_version check.
|
|
4
|
+
All callers that deserialize the graph should use load_graph_with_schema to
|
|
5
|
+
ensure they reject out-of-date graphs instead of silently producing wrong
|
|
6
|
+
results.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CURRENT_SCHEMA = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GraphSchemaError(Exception):
|
|
17
|
+
"""Raised when a graph file's schema_version does not match CURRENT_SCHEMA."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_graph_with_schema(path: Path | str) -> dict:
|
|
21
|
+
"""Load a codebase graph JSON file and verify its schema_version.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
GraphSchemaError: if schema_version is missing or mismatched.
|
|
25
|
+
FileNotFoundError: if path does not exist.
|
|
26
|
+
json.JSONDecodeError: if path is not valid JSON.
|
|
27
|
+
"""
|
|
28
|
+
with open(path) as f:
|
|
29
|
+
data = json.load(f)
|
|
30
|
+
ver = data.get("schema_version")
|
|
31
|
+
if ver != CURRENT_SCHEMA:
|
|
32
|
+
raise GraphSchemaError(
|
|
33
|
+
f"Graph schema version {ver!r} (expected {CURRENT_SCHEMA}); "
|
|
34
|
+
f"run 'descry index' to rebuild."
|
|
35
|
+
)
|
|
36
|
+
return data
|
descry/ast_grep.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ast-grep based CALLS extraction for improved accuracy.
|
|
4
|
+
|
|
5
|
+
Uses tree-sitter via ast-grep to find function calls with higher precision
|
|
6
|
+
than regex-based detection. Handles:
|
|
7
|
+
- Method chaining: foo.bar().baz()
|
|
8
|
+
- Generic calls: Vec::<T>::new()
|
|
9
|
+
- Closures: |x| process(x)
|
|
10
|
+
- Macro invocations: vec![], println!()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import Iterator
|
|
16
|
+
|
|
17
|
+
from descry._env import safe_env
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_calls_rust(file_path: str) -> Iterator[dict]:
|
|
21
|
+
"""Extract function calls from a Rust file using ast-grep.
|
|
22
|
+
|
|
23
|
+
Yields dicts with:
|
|
24
|
+
- callee: str - function/method name being called
|
|
25
|
+
- lineno: int - line number
|
|
26
|
+
- full_text: str - full call expression text
|
|
27
|
+
|
|
28
|
+
Uses multiple patterns to capture:
|
|
29
|
+
- Direct calls: function(args)
|
|
30
|
+
- Method calls: receiver.method(args)
|
|
31
|
+
- Qualified calls: Type::method(args)
|
|
32
|
+
"""
|
|
33
|
+
# Patterns to match different call types
|
|
34
|
+
patterns = [
|
|
35
|
+
"$FUNC($$$ARGS)", # Direct function calls
|
|
36
|
+
"$RECEIVER.$METHOD($$$ARGS)", # Method calls on expressions
|
|
37
|
+
"$RECEIVER.$METHOD()", # No-arg method calls
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
seen = set() # Deduplicate by (lineno, callee)
|
|
41
|
+
|
|
42
|
+
for pattern in patterns:
|
|
43
|
+
try:
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
# `--json` first so `--` terminates options cleanly and the
|
|
46
|
+
# following `file_path` cannot be mis-parsed as a flag even
|
|
47
|
+
# if a local source file literally begins with `-`.
|
|
48
|
+
["sg", "run", "-p", pattern, "-l", "rust", "--json", "--", file_path],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=30,
|
|
52
|
+
env=safe_env(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
matches = json.loads(result.stdout) if result.stdout else []
|
|
59
|
+
|
|
60
|
+
for match in matches:
|
|
61
|
+
meta = match.get("metaVariables", {}).get("single", {})
|
|
62
|
+
|
|
63
|
+
# Try METHOD first (for method calls), then FUNC (for direct calls)
|
|
64
|
+
func_meta = meta.get("METHOD") or meta.get("FUNC", {})
|
|
65
|
+
func_name = func_meta.get("text", "") if func_meta else ""
|
|
66
|
+
|
|
67
|
+
if not func_name:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# Skip malformed matches - ast-grep can capture entire method chains
|
|
71
|
+
# as the "function" when matching builder patterns like Router::new().merge().layer()
|
|
72
|
+
if "\n" in func_name or len(func_name) > 100:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
# Skip control flow keywords that look like calls
|
|
76
|
+
if func_name in ("if", "for", "while", "match", "loop", "return"):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Skip common macros (they end with !)
|
|
80
|
+
if func_name.endswith("!"):
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# ast-grep uses 0-based line numbers, convert to 1-based
|
|
84
|
+
lineno = match.get("range", {}).get("start", {}).get("line", 0) + 1
|
|
85
|
+
|
|
86
|
+
# Deduplicate
|
|
87
|
+
key = (lineno, func_name)
|
|
88
|
+
if key in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
|
|
92
|
+
# For method calls, include receiver context if it's a type
|
|
93
|
+
receiver_meta = meta.get("RECEIVER", {})
|
|
94
|
+
receiver_text = receiver_meta.get("text", "") if receiver_meta else ""
|
|
95
|
+
|
|
96
|
+
# If receiver looks like a type (Self, starts with uppercase), qualify the call
|
|
97
|
+
if receiver_text in ("self", "Self") or (
|
|
98
|
+
receiver_text
|
|
99
|
+
and receiver_text[0].isupper()
|
|
100
|
+
and "::" not in func_name
|
|
101
|
+
):
|
|
102
|
+
qualified_name = f"{receiver_text}.{func_name}"
|
|
103
|
+
else:
|
|
104
|
+
qualified_name = func_name
|
|
105
|
+
|
|
106
|
+
yield {
|
|
107
|
+
"callee": qualified_name,
|
|
108
|
+
"lineno": lineno,
|
|
109
|
+
"full_text": match.get("text", ""),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def extract_calls_typescript(file_path: str) -> Iterator[dict]:
|
|
117
|
+
"""Extract function calls from a TypeScript file using ast-grep.
|
|
118
|
+
|
|
119
|
+
Yields dicts with:
|
|
120
|
+
- callee: str - function/method name being called
|
|
121
|
+
- lineno: int - line number
|
|
122
|
+
- full_text: str - full call expression text
|
|
123
|
+
|
|
124
|
+
Uses multiple patterns to capture:
|
|
125
|
+
- Direct calls: function(args)
|
|
126
|
+
- Method calls: receiver.method(args)
|
|
127
|
+
- Chained calls: obj.foo().bar()
|
|
128
|
+
"""
|
|
129
|
+
# Patterns to match different call types
|
|
130
|
+
patterns = [
|
|
131
|
+
"$FUNC($$$ARGS)", # Direct function calls
|
|
132
|
+
"$RECEIVER.$METHOD($$$ARGS)", # Method calls on expressions
|
|
133
|
+
"$RECEIVER.$METHOD()", # No-arg method calls
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
seen = set() # Deduplicate by (lineno, callee)
|
|
137
|
+
|
|
138
|
+
for pattern in patterns:
|
|
139
|
+
try:
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
# See note in Rust branch re: `--` separator for safety.
|
|
142
|
+
[
|
|
143
|
+
"sg",
|
|
144
|
+
"run",
|
|
145
|
+
"-p",
|
|
146
|
+
pattern,
|
|
147
|
+
"-l",
|
|
148
|
+
"typescript",
|
|
149
|
+
"--json",
|
|
150
|
+
"--",
|
|
151
|
+
file_path,
|
|
152
|
+
],
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
timeout=30,
|
|
156
|
+
env=safe_env(),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if result.returncode != 0:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
matches = json.loads(result.stdout) if result.stdout else []
|
|
163
|
+
|
|
164
|
+
for match in matches:
|
|
165
|
+
meta = match.get("metaVariables", {}).get("single", {})
|
|
166
|
+
|
|
167
|
+
# Try METHOD first (for method calls), then FUNC (for direct calls)
|
|
168
|
+
func_meta = meta.get("METHOD") or meta.get("FUNC", {})
|
|
169
|
+
func_name = func_meta.get("text", "") if func_meta else ""
|
|
170
|
+
|
|
171
|
+
if not func_name:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Skip control flow and common JS patterns
|
|
175
|
+
if func_name in (
|
|
176
|
+
"if",
|
|
177
|
+
"for",
|
|
178
|
+
"while",
|
|
179
|
+
"switch",
|
|
180
|
+
"catch",
|
|
181
|
+
"function",
|
|
182
|
+
"class",
|
|
183
|
+
"return",
|
|
184
|
+
"await",
|
|
185
|
+
"new",
|
|
186
|
+
):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# ast-grep uses 0-based line numbers, convert to 1-based
|
|
190
|
+
lineno = match.get("range", {}).get("start", {}).get("line", 0) + 1
|
|
191
|
+
|
|
192
|
+
# Deduplicate
|
|
193
|
+
key = (lineno, func_name)
|
|
194
|
+
if key in seen:
|
|
195
|
+
continue
|
|
196
|
+
seen.add(key)
|
|
197
|
+
|
|
198
|
+
yield {
|
|
199
|
+
"callee": func_name,
|
|
200
|
+
"lineno": lineno,
|
|
201
|
+
"full_text": match.get("text", ""),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_ast_grep_available() -> bool:
|
|
209
|
+
"""Check if ast-grep (sg) is available on the system."""
|
|
210
|
+
try:
|
|
211
|
+
result = subprocess.run(
|
|
212
|
+
["sg", "--version"], capture_output=True, timeout=5, env=safe_env()
|
|
213
|
+
)
|
|
214
|
+
return result.returncode == 0
|
|
215
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Cache the availability check
|
|
220
|
+
_AST_GREP_AVAILABLE = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def ast_grep_available() -> bool:
|
|
224
|
+
"""Cached check for ast-grep availability."""
|
|
225
|
+
global _AST_GREP_AVAILABLE
|
|
226
|
+
if _AST_GREP_AVAILABLE is None:
|
|
227
|
+
_AST_GREP_AVAILABLE = is_ast_grep_available()
|
|
228
|
+
return _AST_GREP_AVAILABLE
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def extract_imports_typescript(file_path: str) -> dict:
|
|
232
|
+
"""Extract import declarations from a TypeScript file using ast-grep.
|
|
233
|
+
|
|
234
|
+
Returns a dict with:
|
|
235
|
+
- imports: dict mapping local names to (module_path, import_type)
|
|
236
|
+
- namespaces: dict mapping namespace alias to module_path
|
|
237
|
+
|
|
238
|
+
Import types:
|
|
239
|
+
- "named": import { foo } from 'module'
|
|
240
|
+
- "default": import foo from 'module'
|
|
241
|
+
- "namespace": import * as foo from 'module'
|
|
242
|
+
- "type": import type { Foo } from 'module'
|
|
243
|
+
"""
|
|
244
|
+
result = {
|
|
245
|
+
"imports": {}, # local_name -> (module_path, import_type)
|
|
246
|
+
"namespaces": {}, # alias -> module_path
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Pattern 1: Named imports - import { foo, bar as baz } from 'module'
|
|
250
|
+
patterns = [
|
|
251
|
+
# Named imports
|
|
252
|
+
("import { $$$IMPORTS } from $SOURCE", "named"),
|
|
253
|
+
# Default import
|
|
254
|
+
("import $DEFAULT from $SOURCE", "default"),
|
|
255
|
+
# Namespace import
|
|
256
|
+
("import * as $ALIAS from $SOURCE", "namespace"),
|
|
257
|
+
# Type-only imports
|
|
258
|
+
("import type { $$$TYPES } from $SOURCE", "type"),
|
|
259
|
+
# Default with named: import Default, { named } from 'module'
|
|
260
|
+
("import $DEFAULT, { $$$NAMED } from $SOURCE", "default_named"),
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
for pattern, import_type in patterns:
|
|
264
|
+
try:
|
|
265
|
+
proc_result = subprocess.run(
|
|
266
|
+
# See note in Rust branch re: `--` separator for safety.
|
|
267
|
+
[
|
|
268
|
+
"sg",
|
|
269
|
+
"run",
|
|
270
|
+
"-p",
|
|
271
|
+
pattern,
|
|
272
|
+
"-l",
|
|
273
|
+
"typescript",
|
|
274
|
+
"--json",
|
|
275
|
+
"--",
|
|
276
|
+
file_path,
|
|
277
|
+
],
|
|
278
|
+
capture_output=True,
|
|
279
|
+
text=True,
|
|
280
|
+
timeout=30,
|
|
281
|
+
env=safe_env(),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if proc_result.returncode != 0:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
matches = json.loads(proc_result.stdout) if proc_result.stdout else []
|
|
288
|
+
|
|
289
|
+
for match in matches:
|
|
290
|
+
meta = match.get("metaVariables", {})
|
|
291
|
+
single = meta.get("single", {})
|
|
292
|
+
multi = meta.get("multi", {})
|
|
293
|
+
|
|
294
|
+
# Extract source module
|
|
295
|
+
source_meta = single.get("SOURCE", {})
|
|
296
|
+
source = source_meta.get("text", "").strip("'\"")
|
|
297
|
+
if not source:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
if import_type == "namespace":
|
|
301
|
+
# import * as alias from 'module'
|
|
302
|
+
alias_meta = single.get("ALIAS", {})
|
|
303
|
+
alias = alias_meta.get("text", "")
|
|
304
|
+
if alias:
|
|
305
|
+
result["namespaces"][alias] = source
|
|
306
|
+
result["imports"][alias] = (source, "namespace")
|
|
307
|
+
|
|
308
|
+
elif import_type == "default":
|
|
309
|
+
# import Default from 'module'
|
|
310
|
+
default_meta = single.get("DEFAULT", {})
|
|
311
|
+
default_name = default_meta.get("text", "")
|
|
312
|
+
if default_name:
|
|
313
|
+
result["imports"][default_name] = (source, "default")
|
|
314
|
+
|
|
315
|
+
elif import_type in ("named", "type"):
|
|
316
|
+
# import { foo, bar as baz } from 'module'
|
|
317
|
+
imports_meta = (
|
|
318
|
+
multi.get("IMPORTS", [])
|
|
319
|
+
if import_type == "named"
|
|
320
|
+
else multi.get("TYPES", [])
|
|
321
|
+
)
|
|
322
|
+
for imp in imports_meta:
|
|
323
|
+
text = imp.get("text", "").strip()
|
|
324
|
+
if not text:
|
|
325
|
+
continue
|
|
326
|
+
# Handle aliased imports: "foo as bar"
|
|
327
|
+
if " as " in text:
|
|
328
|
+
parts = text.split(" as ")
|
|
329
|
+
alias = parts[1].strip()
|
|
330
|
+
result["imports"][alias] = (source, import_type)
|
|
331
|
+
else:
|
|
332
|
+
result["imports"][text] = (source, import_type)
|
|
333
|
+
|
|
334
|
+
elif import_type == "default_named":
|
|
335
|
+
# import Default, { named } from 'module'
|
|
336
|
+
default_meta = single.get("DEFAULT", {})
|
|
337
|
+
default_name = default_meta.get("text", "")
|
|
338
|
+
if default_name:
|
|
339
|
+
result["imports"][default_name] = (source, "default")
|
|
340
|
+
|
|
341
|
+
named_meta = multi.get("NAMED", [])
|
|
342
|
+
for imp in named_meta:
|
|
343
|
+
text = imp.get("text", "").strip()
|
|
344
|
+
if not text:
|
|
345
|
+
continue
|
|
346
|
+
if " as " in text:
|
|
347
|
+
parts = text.split(" as ")
|
|
348
|
+
alias = parts[1].strip()
|
|
349
|
+
result["imports"][alias] = (source, "named")
|
|
350
|
+
else:
|
|
351
|
+
result["imports"][text] = (source, "named")
|
|
352
|
+
|
|
353
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def extract_imports_typescript_batch(file_paths: list) -> dict:
|
|
360
|
+
"""Extract imports from multiple TypeScript files efficiently.
|
|
361
|
+
|
|
362
|
+
Processes files individually but caches results for repeated calls.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
file_paths: List of file paths to process
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Dict mapping file paths to their import data:
|
|
369
|
+
{
|
|
370
|
+
"path/to/file.ts": {
|
|
371
|
+
"imports": {"name": ("module", "type")},
|
|
372
|
+
"namespaces": {"alias": "module"},
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
"""
|
|
376
|
+
results = {}
|
|
377
|
+
for file_path in file_paths:
|
|
378
|
+
try:
|
|
379
|
+
results[file_path] = extract_imports_typescript(file_path)
|
|
380
|
+
except Exception:
|
|
381
|
+
results[file_path] = {"imports": {}, "namespaces": {}}
|
|
382
|
+
return results
|