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 ADDED
@@ -0,0 +1,4 @@
1
+ """Descry — Codebase knowledge graph: polyglot indexer, semantic search, call-graph analysis."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
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