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/scip/cache.py
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
"""Per-project SCIP generation with incremental caching.
|
|
2
|
+
|
|
3
|
+
This module manages SCIP index generation for multiple languages with smart caching
|
|
4
|
+
to avoid regenerating indices for unchanged projects.
|
|
5
|
+
|
|
6
|
+
Supported:
|
|
7
|
+
- Rust crates (via rust-analyzer)
|
|
8
|
+
- TypeScript/JavaScript packages (via scip-typescript)
|
|
9
|
+
- Python packages (via scip-python)
|
|
10
|
+
|
|
11
|
+
Performance optimizations:
|
|
12
|
+
- Pre-warm rust-analyzer cache with parallel workers
|
|
13
|
+
- Run Rust and TypeScript generation concurrently
|
|
14
|
+
- Dynamic worker count based on available memory
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from descry._env import safe_env
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from typing import Dict, List, Tuple
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ScipCacheManager:
|
|
37
|
+
"""Manages per-project SCIP generation with incremental caching.
|
|
38
|
+
|
|
39
|
+
SCIP indices are generated per-project and cached based on source file
|
|
40
|
+
checksums. When a project's sources haven't changed, the cached SCIP
|
|
41
|
+
index is reused.
|
|
42
|
+
|
|
43
|
+
Cache location: {project_root}/.descry_cache/scip/
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_DEFAULT_EXCLUDED_DIRS = {
|
|
47
|
+
"target",
|
|
48
|
+
"node_modules",
|
|
49
|
+
"dist",
|
|
50
|
+
"docs",
|
|
51
|
+
".git",
|
|
52
|
+
"__pycache__",
|
|
53
|
+
"build",
|
|
54
|
+
"vendor",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
project_root: Path,
|
|
60
|
+
excluded_dirs: set[str] | None = None,
|
|
61
|
+
scip_timeout_minutes: int | None = None,
|
|
62
|
+
scip_extra_args: list[str] | None = None,
|
|
63
|
+
scip_skip_crates: list[str] | None = None,
|
|
64
|
+
scip_toolchain: str | None = None,
|
|
65
|
+
):
|
|
66
|
+
"""Initialize the cache manager.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
project_root: Root directory of the project
|
|
70
|
+
excluded_dirs: Directory names to skip during discovery.
|
|
71
|
+
Defaults to a standard set including target, node_modules,
|
|
72
|
+
dist, docs, .git, __pycache__, build, and vendor.
|
|
73
|
+
scip_timeout_minutes: Timeout in minutes for SCIP generation.
|
|
74
|
+
0 means unlimited. None means use env var or default.
|
|
75
|
+
scip_extra_args: Extra arguments to pass to rust-analyzer scip.
|
|
76
|
+
Defaults to ["--exclude-vendored-libraries"].
|
|
77
|
+
scip_skip_crates: Crate names to skip during SCIP generation.
|
|
78
|
+
Useful for crates that trigger rust-analyzer bugs.
|
|
79
|
+
scip_toolchain: Rust toolchain to use for rust-analyzer
|
|
80
|
+
(e.g. "1.92.0"). Uses `rustup run <toolchain>` prefix.
|
|
81
|
+
None means use the default rust-analyzer on PATH.
|
|
82
|
+
"""
|
|
83
|
+
self.project_root = project_root
|
|
84
|
+
self.cache_dir = project_root / ".descry_cache" / "scip"
|
|
85
|
+
self.checksums_file = self.cache_dir / "checksums.json"
|
|
86
|
+
self.excluded_dirs = (
|
|
87
|
+
excluded_dirs if excluded_dirs is not None else self._DEFAULT_EXCLUDED_DIRS
|
|
88
|
+
)
|
|
89
|
+
self._scip_timeout_minutes = scip_timeout_minutes
|
|
90
|
+
self._scip_extra_args = (
|
|
91
|
+
scip_extra_args
|
|
92
|
+
if scip_extra_args is not None
|
|
93
|
+
else ["--exclude-vendored-libraries"]
|
|
94
|
+
)
|
|
95
|
+
self._scip_skip_crates = set(scip_skip_crates) if scip_skip_crates else set()
|
|
96
|
+
self._scip_toolchain = scip_toolchain
|
|
97
|
+
|
|
98
|
+
def get_projects(self) -> List[Tuple[str, str]]:
|
|
99
|
+
"""Auto-discover all indexable projects.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of (project_name, project_type) tuples.
|
|
103
|
+
project_type is one of: "rust", "typescript", "python"
|
|
104
|
+
"""
|
|
105
|
+
projects = []
|
|
106
|
+
projects.extend((name, "rust") for name in self.get_rust_crates())
|
|
107
|
+
projects.extend((name, "typescript") for name in self.get_typescript_packages())
|
|
108
|
+
projects.extend((name, "python") for name in self.get_python_packages())
|
|
109
|
+
return sorted(projects)
|
|
110
|
+
|
|
111
|
+
def get_crates(self) -> List[str]:
|
|
112
|
+
"""Auto-discover Rust crates (backwards compatibility alias)."""
|
|
113
|
+
return self.get_rust_crates()
|
|
114
|
+
|
|
115
|
+
def get_rust_crates(self) -> List[str]:
|
|
116
|
+
"""Auto-discover Rust crates in workspace.
|
|
117
|
+
|
|
118
|
+
Finds directories containing Cargo.toml with a src/ subdirectory.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of crate directory names (e.g., ['backend', 'database', 'api'])
|
|
122
|
+
"""
|
|
123
|
+
crates = []
|
|
124
|
+
|
|
125
|
+
# Check for workspace members in root Cargo.toml
|
|
126
|
+
root_cargo = self.project_root / "Cargo.toml"
|
|
127
|
+
if root_cargo.exists():
|
|
128
|
+
for cargo_toml in self.project_root.glob("*/Cargo.toml"):
|
|
129
|
+
crate_dir = cargo_toml.parent
|
|
130
|
+
# Skip hidden directories and common non-crate directories
|
|
131
|
+
if crate_dir.name.startswith("."):
|
|
132
|
+
continue
|
|
133
|
+
if crate_dir.name in self.excluded_dirs:
|
|
134
|
+
continue
|
|
135
|
+
if (crate_dir / "src").exists():
|
|
136
|
+
crates.append(crate_dir.name)
|
|
137
|
+
|
|
138
|
+
return sorted(crates)
|
|
139
|
+
|
|
140
|
+
def get_typescript_packages(self) -> List[str]:
|
|
141
|
+
"""Auto-discover TypeScript/JavaScript packages.
|
|
142
|
+
|
|
143
|
+
Finds directories containing package.json with TypeScript/JavaScript source files.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
List of package directory names (e.g., ['webapp', 'dashboard'])
|
|
147
|
+
"""
|
|
148
|
+
packages = []
|
|
149
|
+
|
|
150
|
+
for package_json in self.project_root.glob("*/package.json"):
|
|
151
|
+
pkg_dir = package_json.parent
|
|
152
|
+
# Skip hidden directories
|
|
153
|
+
if pkg_dir.name.startswith("."):
|
|
154
|
+
continue
|
|
155
|
+
# Skip excluded directories
|
|
156
|
+
if pkg_dir.name in self.excluded_dirs:
|
|
157
|
+
continue
|
|
158
|
+
# Check for TypeScript/JavaScript source files
|
|
159
|
+
has_ts = list(pkg_dir.glob("src/**/*.ts")) or list(
|
|
160
|
+
pkg_dir.glob("src/**/*.tsx")
|
|
161
|
+
)
|
|
162
|
+
has_js = list(pkg_dir.glob("src/**/*.js")) or list(
|
|
163
|
+
pkg_dir.glob("src/**/*.jsx")
|
|
164
|
+
)
|
|
165
|
+
if has_ts or has_js:
|
|
166
|
+
packages.append(pkg_dir.name)
|
|
167
|
+
|
|
168
|
+
return sorted(packages)
|
|
169
|
+
|
|
170
|
+
def get_python_packages(self) -> List[str]:
|
|
171
|
+
"""Auto-discover Python packages for scip-python.
|
|
172
|
+
|
|
173
|
+
Two discovery modes:
|
|
174
|
+
|
|
175
|
+
1. **Monorepo layout**: directories directly under project_root that
|
|
176
|
+
carry their own `pyproject.toml` (or `setup.py`/`setup.cfg`) and
|
|
177
|
+
contain `.py` files. This mirrors get_rust_crates / get_typescript
|
|
178
|
+
_packages.
|
|
179
|
+
2. **Single-package layout**: project_root itself carries a
|
|
180
|
+
`pyproject.toml` and `src/`-layout or top-level `.py` files. In
|
|
181
|
+
that case we return a single synthetic package named after the
|
|
182
|
+
project_root directory.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of package directory names to index (e.g. ``['backend',
|
|
186
|
+
'workers']`` or ``['descry']``).
|
|
187
|
+
"""
|
|
188
|
+
packages: list[str] = []
|
|
189
|
+
|
|
190
|
+
# Case 1: monorepo with per-subdir pyproject.toml.
|
|
191
|
+
for marker in self.project_root.glob("*/pyproject.toml"):
|
|
192
|
+
pkg_dir = marker.parent
|
|
193
|
+
if pkg_dir.name.startswith(".") or pkg_dir.name in self.excluded_dirs:
|
|
194
|
+
continue
|
|
195
|
+
if not any(pkg_dir.rglob("*.py")):
|
|
196
|
+
continue
|
|
197
|
+
packages.append(pkg_dir.name)
|
|
198
|
+
for marker in self.project_root.glob("*/setup.py"):
|
|
199
|
+
pkg_dir = marker.parent
|
|
200
|
+
if pkg_dir.name.startswith(".") or pkg_dir.name in self.excluded_dirs:
|
|
201
|
+
continue
|
|
202
|
+
if pkg_dir.name in packages:
|
|
203
|
+
continue # already picked up via pyproject
|
|
204
|
+
if not any(pkg_dir.rglob("*.py")):
|
|
205
|
+
continue
|
|
206
|
+
packages.append(pkg_dir.name)
|
|
207
|
+
|
|
208
|
+
# Case 2: single-package root. Only treat as such when we did not
|
|
209
|
+
# already find per-subdir packages — otherwise the root project
|
|
210
|
+
# would shadow the monorepo children.
|
|
211
|
+
if not packages:
|
|
212
|
+
root_marker_exists = (
|
|
213
|
+
(self.project_root / "pyproject.toml").exists()
|
|
214
|
+
or (self.project_root / "setup.py").exists()
|
|
215
|
+
or (self.project_root / "setup.cfg").exists()
|
|
216
|
+
)
|
|
217
|
+
if root_marker_exists and any(
|
|
218
|
+
p for p in self.project_root.rglob("*.py") if self._py_in_scope(p)
|
|
219
|
+
):
|
|
220
|
+
# Synthetic: use the project_root name so .scip filenames
|
|
221
|
+
# don't collide across projects.
|
|
222
|
+
packages.append(self.project_root.name)
|
|
223
|
+
|
|
224
|
+
return sorted(packages)
|
|
225
|
+
|
|
226
|
+
def _py_in_scope(self, path: Path) -> bool:
|
|
227
|
+
"""True if `path` is a Python source file inside project_root and
|
|
228
|
+
not under a hidden or excluded directory.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
rel = path.relative_to(self.project_root)
|
|
232
|
+
except ValueError:
|
|
233
|
+
return False
|
|
234
|
+
parts = rel.parts
|
|
235
|
+
for part in parts[:-1]:
|
|
236
|
+
if part.startswith(".") or part in self.excluded_dirs:
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
def needs_update(self, project: str, project_type: str = "rust") -> bool:
|
|
241
|
+
"""Check if project SCIP needs regeneration.
|
|
242
|
+
|
|
243
|
+
A project needs regeneration if:
|
|
244
|
+
1. No cached SCIP file exists
|
|
245
|
+
2. The source checksum has changed
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
project: Name of the project to check
|
|
249
|
+
project_type: Type of project ("rust" or "typescript")
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if SCIP should be regenerated
|
|
253
|
+
"""
|
|
254
|
+
scip_file = self.cache_dir / f"{project}.scip"
|
|
255
|
+
if not scip_file.exists():
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
checksums = self._load_checksums()
|
|
259
|
+
current_hash = self._hash_project(project, project_type)
|
|
260
|
+
return checksums.get(project) != current_hash
|
|
261
|
+
|
|
262
|
+
def update_changed(self, parallel: bool = True) -> Dict[str, Path]:
|
|
263
|
+
"""Update SCIP for changed Rust crates only (backwards compatible).
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
parallel: Whether to generate SCIP for multiple crates in parallel
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Dictionary mapping crate names to their SCIP file paths
|
|
270
|
+
"""
|
|
271
|
+
return self.update_changed_rust(parallel)
|
|
272
|
+
|
|
273
|
+
def update_changed_rust(self, parallel: bool = True) -> Dict[str, Path]:
|
|
274
|
+
"""Update SCIP for changed Rust crates only.
|
|
275
|
+
|
|
276
|
+
Pre-warms rust-analyzer cache before generation for better performance.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
parallel: Whether to generate SCIP for multiple crates in parallel
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dictionary mapping crate names to their SCIP file paths
|
|
283
|
+
"""
|
|
284
|
+
crates = self.get_rust_crates()
|
|
285
|
+
if not crates:
|
|
286
|
+
logger.info("No Rust crates found in project")
|
|
287
|
+
return {}
|
|
288
|
+
|
|
289
|
+
# Filter out crates configured to be skipped
|
|
290
|
+
if self._scip_skip_crates:
|
|
291
|
+
skipped = [c for c in crates if c in self._scip_skip_crates]
|
|
292
|
+
if skipped:
|
|
293
|
+
logger.info(f"SCIP: Skipping configured crates: {skipped}")
|
|
294
|
+
crates = [c for c in crates if c not in self._scip_skip_crates]
|
|
295
|
+
|
|
296
|
+
changed = [c for c in crates if self.needs_update(c, "rust")]
|
|
297
|
+
|
|
298
|
+
if changed:
|
|
299
|
+
logger.info(
|
|
300
|
+
f"SCIP: Regenerating for {len(changed)} changed Rust crate(s): {changed}"
|
|
301
|
+
)
|
|
302
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
|
|
304
|
+
# Pre-warm cache if generating multiple crates (amortizes the cost)
|
|
305
|
+
if len(changed) > 1:
|
|
306
|
+
self._prime_rust_analyzer_cache()
|
|
307
|
+
|
|
308
|
+
if parallel and len(changed) > 1:
|
|
309
|
+
max_workers = self._get_max_workers(len(changed))
|
|
310
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
311
|
+
results = list(executor.map(self._generate_rust_scip, changed))
|
|
312
|
+
else:
|
|
313
|
+
results = [self._generate_rust_scip(c) for c in changed]
|
|
314
|
+
|
|
315
|
+
# Update checksums for successfully generated crates
|
|
316
|
+
checksums = self._load_checksums()
|
|
317
|
+
for crate, success in zip(changed, results):
|
|
318
|
+
if success:
|
|
319
|
+
checksums[crate] = self._hash_project(crate, "rust")
|
|
320
|
+
self._save_checksums(checksums)
|
|
321
|
+
else:
|
|
322
|
+
logger.debug("SCIP: All Rust crates up-to-date")
|
|
323
|
+
|
|
324
|
+
return self._get_scip_paths(crates)
|
|
325
|
+
|
|
326
|
+
def update_changed_typescript(self, parallel: bool = False) -> Dict[str, Path]:
|
|
327
|
+
"""Update SCIP for changed TypeScript packages.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
parallel: Whether to generate SCIP for multiple packages in parallel
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Dictionary mapping package names to their SCIP file paths
|
|
334
|
+
"""
|
|
335
|
+
packages = self.get_typescript_packages()
|
|
336
|
+
if not packages:
|
|
337
|
+
logger.info("No TypeScript packages found in project")
|
|
338
|
+
return {}
|
|
339
|
+
|
|
340
|
+
changed = [p for p in packages if self.needs_update(p, "typescript")]
|
|
341
|
+
|
|
342
|
+
if changed:
|
|
343
|
+
logger.info(
|
|
344
|
+
f"SCIP: Regenerating for {len(changed)} changed TypeScript package(s): {changed}"
|
|
345
|
+
)
|
|
346
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
|
|
348
|
+
if parallel and len(changed) > 1:
|
|
349
|
+
max_workers = self._get_max_workers(len(changed))
|
|
350
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
351
|
+
results = list(
|
|
352
|
+
executor.map(self._generate_typescript_scip, changed)
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
results = [self._generate_typescript_scip(p) for p in changed]
|
|
356
|
+
|
|
357
|
+
# Update checksums for successfully generated packages
|
|
358
|
+
checksums = self._load_checksums()
|
|
359
|
+
for pkg, success in zip(changed, results):
|
|
360
|
+
if success:
|
|
361
|
+
checksums[pkg] = self._hash_project(pkg, "typescript")
|
|
362
|
+
self._save_checksums(checksums)
|
|
363
|
+
else:
|
|
364
|
+
logger.debug("SCIP: All TypeScript packages up-to-date")
|
|
365
|
+
|
|
366
|
+
return self._get_scip_paths(packages)
|
|
367
|
+
|
|
368
|
+
def update_changed_python(self, parallel: bool = False) -> Dict[str, Path]:
|
|
369
|
+
"""Update SCIP for changed Python packages.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
parallel: Whether to generate SCIP for multiple packages in parallel
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Dictionary mapping package names to their SCIP file paths
|
|
376
|
+
"""
|
|
377
|
+
packages = self.get_python_packages()
|
|
378
|
+
if not packages:
|
|
379
|
+
logger.info("No Python packages found in project")
|
|
380
|
+
return {}
|
|
381
|
+
|
|
382
|
+
changed = [p for p in packages if self.needs_update(p, "python")]
|
|
383
|
+
|
|
384
|
+
if changed:
|
|
385
|
+
logger.info(
|
|
386
|
+
f"SCIP: Regenerating for {len(changed)} changed Python package(s): {changed}"
|
|
387
|
+
)
|
|
388
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
|
|
390
|
+
if parallel and len(changed) > 1:
|
|
391
|
+
max_workers = self._get_max_workers(len(changed))
|
|
392
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
393
|
+
results = list(executor.map(self._generate_python_scip, changed))
|
|
394
|
+
else:
|
|
395
|
+
results = [self._generate_python_scip(p) for p in changed]
|
|
396
|
+
|
|
397
|
+
# Update checksums for successfully generated packages
|
|
398
|
+
checksums = self._load_checksums()
|
|
399
|
+
for pkg, success in zip(changed, results):
|
|
400
|
+
if success:
|
|
401
|
+
checksums[pkg] = self._hash_project(pkg, "python")
|
|
402
|
+
self._save_checksums(checksums)
|
|
403
|
+
else:
|
|
404
|
+
logger.debug("SCIP: All Python packages up-to-date")
|
|
405
|
+
|
|
406
|
+
return self._get_scip_paths(packages)
|
|
407
|
+
|
|
408
|
+
def update_all(self, parallel: bool = False) -> Dict[str, Path]:
|
|
409
|
+
"""Update SCIP for all changed projects (Rust, TypeScript, Python).
|
|
410
|
+
|
|
411
|
+
Runs each language's generation concurrently since they use
|
|
412
|
+
independent tools with no shared resources.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
parallel: Whether to generate SCIP in parallel within each language
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Dictionary mapping project names to their SCIP file paths
|
|
419
|
+
"""
|
|
420
|
+
results = {}
|
|
421
|
+
|
|
422
|
+
# Run all three languages concurrently
|
|
423
|
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
424
|
+
futures = {
|
|
425
|
+
executor.submit(self.update_changed_rust, parallel): "rust",
|
|
426
|
+
executor.submit(self.update_changed_typescript, parallel): "typescript",
|
|
427
|
+
executor.submit(self.update_changed_python, parallel): "python",
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for future in as_completed(futures):
|
|
431
|
+
lang = futures[future]
|
|
432
|
+
try:
|
|
433
|
+
lang_results = future.result()
|
|
434
|
+
results.update(lang_results)
|
|
435
|
+
except Exception as e:
|
|
436
|
+
logger.error(f"SCIP: {lang} generation failed: {e}")
|
|
437
|
+
|
|
438
|
+
return results
|
|
439
|
+
|
|
440
|
+
def _generate_rust_scip(self, crate: str) -> bool:
|
|
441
|
+
"""Generate SCIP for a single Rust crate.
|
|
442
|
+
|
|
443
|
+
Uses rust-analyzer's scip command to generate the index.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
crate: Name of the crate to generate SCIP for
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
True if generation succeeded, False otherwise
|
|
450
|
+
"""
|
|
451
|
+
crate_path = self.project_root / crate
|
|
452
|
+
output_path = self.cache_dir / f"{crate}.scip"
|
|
453
|
+
|
|
454
|
+
# First-time generation is much slower as rust-analyzer builds workspace analysis
|
|
455
|
+
# Check if any SCIP files exist to determine if this is likely a first run
|
|
456
|
+
existing_scip = (
|
|
457
|
+
list(self.cache_dir.glob("*.scip")) if self.cache_dir.exists() else []
|
|
458
|
+
)
|
|
459
|
+
is_first_run = len(existing_scip) == 0
|
|
460
|
+
|
|
461
|
+
timeout_seconds = self._get_timeout(is_first_run)
|
|
462
|
+
timeout_str = (
|
|
463
|
+
"unlimited" if timeout_seconds is None else f"{timeout_seconds // 60}min"
|
|
464
|
+
)
|
|
465
|
+
logger.info(f"SCIP: Generating index for {crate}... (timeout: {timeout_str})")
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
# rust-analyzer scip runs from workspace root to share analysis cache
|
|
469
|
+
cmd = []
|
|
470
|
+
if self._scip_toolchain:
|
|
471
|
+
cmd.extend(["rustup", "run", self._scip_toolchain])
|
|
472
|
+
cmd.extend(
|
|
473
|
+
[
|
|
474
|
+
"rust-analyzer",
|
|
475
|
+
"scip",
|
|
476
|
+
str(crate_path),
|
|
477
|
+
"--output",
|
|
478
|
+
str(output_path),
|
|
479
|
+
]
|
|
480
|
+
+ self._scip_extra_args
|
|
481
|
+
)
|
|
482
|
+
result = subprocess.run(
|
|
483
|
+
cmd,
|
|
484
|
+
capture_output=True,
|
|
485
|
+
text=True,
|
|
486
|
+
timeout=timeout_seconds,
|
|
487
|
+
cwd=str(self.project_root),
|
|
488
|
+
env=safe_env(),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if result.returncode == 0 and output_path.exists():
|
|
492
|
+
size_kb = output_path.stat().st_size / 1024
|
|
493
|
+
logger.info(f"SCIP: Generated {crate}.scip ({size_kb:.1f} KB)")
|
|
494
|
+
return True
|
|
495
|
+
else:
|
|
496
|
+
stderr_tail = (
|
|
497
|
+
result.stderr[-500:] if len(result.stderr) > 500 else result.stderr
|
|
498
|
+
)
|
|
499
|
+
logger.warning(
|
|
500
|
+
f"SCIP: Failed to generate for {crate} "
|
|
501
|
+
f"(exit={result.returncode}, "
|
|
502
|
+
f"output_exists={output_path.exists()}, "
|
|
503
|
+
f"stderr_len={len(result.stderr)}): {stderr_tail}"
|
|
504
|
+
)
|
|
505
|
+
return False
|
|
506
|
+
|
|
507
|
+
except subprocess.TimeoutExpired:
|
|
508
|
+
logger.warning(f"SCIP: Generation timed out for {crate}")
|
|
509
|
+
return False
|
|
510
|
+
except Exception as e:
|
|
511
|
+
logger.warning(f"SCIP: Error generating for {crate}: {e}")
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
def _generate_typescript_scip(self, package: str) -> bool:
|
|
515
|
+
"""Generate SCIP for a single TypeScript/JavaScript package.
|
|
516
|
+
|
|
517
|
+
Uses scip-typescript to generate the index.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
package: Name of the package to generate SCIP for
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
True if generation succeeded, False otherwise
|
|
524
|
+
"""
|
|
525
|
+
package_path = self.project_root / package
|
|
526
|
+
output_path = self.cache_dir / f"{package}.scip"
|
|
527
|
+
|
|
528
|
+
# Check if this is a first run for timeout calculation
|
|
529
|
+
existing_scip = (
|
|
530
|
+
list(self.cache_dir.glob("*.scip")) if self.cache_dir.exists() else []
|
|
531
|
+
)
|
|
532
|
+
is_first_run = len(existing_scip) == 0
|
|
533
|
+
|
|
534
|
+
timeout_seconds = self._get_timeout(is_first_run)
|
|
535
|
+
timeout_str = (
|
|
536
|
+
"unlimited" if timeout_seconds is None else f"{timeout_seconds // 60}min"
|
|
537
|
+
)
|
|
538
|
+
logger.info(
|
|
539
|
+
f"SCIP: Generating TypeScript index for {package}... (timeout: {timeout_str})"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Build command with optional flags
|
|
543
|
+
cmd = [
|
|
544
|
+
"scip-typescript",
|
|
545
|
+
"index",
|
|
546
|
+
"--output",
|
|
547
|
+
str(output_path),
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
# Add --infer-tsconfig for SvelteKit projects that may not have
|
|
551
|
+
# a standard tsconfig.json at root, or have complex extends chains
|
|
552
|
+
svelte_config = package_path / "svelte.config.js"
|
|
553
|
+
vite_config = package_path / "vite.config.ts"
|
|
554
|
+
if svelte_config.exists() or vite_config.exists():
|
|
555
|
+
cmd.append("--infer-tsconfig")
|
|
556
|
+
logger.debug(
|
|
557
|
+
f"SCIP: Using --infer-tsconfig for SvelteKit project {package}"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
# scip-typescript needs to be run from the package directory
|
|
562
|
+
result = subprocess.run(
|
|
563
|
+
cmd,
|
|
564
|
+
capture_output=True,
|
|
565
|
+
text=True,
|
|
566
|
+
timeout=timeout_seconds,
|
|
567
|
+
cwd=str(package_path),
|
|
568
|
+
env=safe_env(),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
if result.returncode == 0 and output_path.exists():
|
|
572
|
+
size_kb = output_path.stat().st_size / 1024
|
|
573
|
+
logger.info(f"SCIP: Generated {package}.scip ({size_kb:.1f} KB)")
|
|
574
|
+
return True
|
|
575
|
+
else:
|
|
576
|
+
logger.warning(
|
|
577
|
+
f"SCIP: Failed to generate for {package}: {result.stderr[:200]}"
|
|
578
|
+
)
|
|
579
|
+
return False
|
|
580
|
+
|
|
581
|
+
except subprocess.TimeoutExpired:
|
|
582
|
+
logger.warning(f"SCIP: Generation timed out for {package}")
|
|
583
|
+
return False
|
|
584
|
+
except Exception as e:
|
|
585
|
+
logger.warning(f"SCIP: Error generating for {package}: {e}")
|
|
586
|
+
return False
|
|
587
|
+
|
|
588
|
+
def _generate_python_scip(self, package: str) -> bool:
|
|
589
|
+
"""Generate SCIP for a single Python package.
|
|
590
|
+
|
|
591
|
+
Uses scip-python to generate the index. scip-python resolves a
|
|
592
|
+
project via its nearest `pyproject.toml` / `setup.py`, so we run
|
|
593
|
+
the binary from the package directory. For the single-package
|
|
594
|
+
root case (where the synthetic package name equals the project
|
|
595
|
+
root's basename), we run directly in project_root.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
package: Name of the package directory to index (or the project
|
|
599
|
+
root's basename for the single-package layout).
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
True if generation succeeded, False otherwise.
|
|
603
|
+
"""
|
|
604
|
+
# Resolve the working directory: prefer a matching subdirectory,
|
|
605
|
+
# fall back to project_root for the synthetic single-package case.
|
|
606
|
+
candidate = self.project_root / package
|
|
607
|
+
if candidate.is_dir() and (
|
|
608
|
+
(candidate / "pyproject.toml").exists()
|
|
609
|
+
or (candidate / "setup.py").exists()
|
|
610
|
+
or (candidate / "setup.cfg").exists()
|
|
611
|
+
):
|
|
612
|
+
work_dir = candidate
|
|
613
|
+
else:
|
|
614
|
+
work_dir = self.project_root
|
|
615
|
+
|
|
616
|
+
output_path = self.cache_dir / f"{package}.scip"
|
|
617
|
+
|
|
618
|
+
existing_scip = (
|
|
619
|
+
list(self.cache_dir.glob("*.scip")) if self.cache_dir.exists() else []
|
|
620
|
+
)
|
|
621
|
+
is_first_run = len(existing_scip) == 0
|
|
622
|
+
timeout_seconds = self._get_timeout(is_first_run)
|
|
623
|
+
timeout_str = (
|
|
624
|
+
"unlimited" if timeout_seconds is None else f"{timeout_seconds // 60}min"
|
|
625
|
+
)
|
|
626
|
+
logger.info(
|
|
627
|
+
f"SCIP: Generating Python index for {package}... (timeout: {timeout_str})"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
# scip-python accepts --project-name + --output, plus a positional
|
|
631
|
+
# project-root. The project-root is absolute so a leading-dash
|
|
632
|
+
# filename attack via ``os.chdir`` cannot forge a flag. No shell.
|
|
633
|
+
cmd = [
|
|
634
|
+
"scip-python",
|
|
635
|
+
"index",
|
|
636
|
+
"--project-name",
|
|
637
|
+
package,
|
|
638
|
+
"--output",
|
|
639
|
+
str(output_path),
|
|
640
|
+
"--cwd",
|
|
641
|
+
str(work_dir),
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
try:
|
|
645
|
+
result = subprocess.run(
|
|
646
|
+
cmd,
|
|
647
|
+
capture_output=True,
|
|
648
|
+
text=True,
|
|
649
|
+
timeout=timeout_seconds,
|
|
650
|
+
cwd=str(work_dir),
|
|
651
|
+
env=safe_env(),
|
|
652
|
+
)
|
|
653
|
+
if result.returncode == 0 and output_path.exists():
|
|
654
|
+
size_kb = output_path.stat().st_size / 1024
|
|
655
|
+
logger.info(f"SCIP: Generated {package}.scip ({size_kb:.1f} KB)")
|
|
656
|
+
return True
|
|
657
|
+
stderr_tail = (
|
|
658
|
+
result.stderr[-500:] if len(result.stderr) > 500 else result.stderr
|
|
659
|
+
)
|
|
660
|
+
logger.warning(
|
|
661
|
+
f"SCIP: Failed to generate Python index for {package} "
|
|
662
|
+
f"(exit={result.returncode}, output_exists={output_path.exists()}): "
|
|
663
|
+
f"{stderr_tail}"
|
|
664
|
+
)
|
|
665
|
+
return False
|
|
666
|
+
except subprocess.TimeoutExpired:
|
|
667
|
+
logger.warning(f"SCIP: Python generation timed out for {package}")
|
|
668
|
+
return False
|
|
669
|
+
except FileNotFoundError:
|
|
670
|
+
logger.debug("SCIP: scip-python binary not on PATH")
|
|
671
|
+
return False
|
|
672
|
+
except Exception as e:
|
|
673
|
+
logger.warning(f"SCIP: Error generating Python index for {package}: {e}")
|
|
674
|
+
return False
|
|
675
|
+
|
|
676
|
+
def _get_max_workers(self, num_items: int) -> int:
|
|
677
|
+
"""Get number of parallel workers based on memory constraints.
|
|
678
|
+
|
|
679
|
+
Modern systems (32GB+ RAM) can safely run 3-4 rust-analyzer instances.
|
|
680
|
+
Override with DESCRY_SCIP_WORKERS environment variable.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
num_items: Number of items to process
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
Number of workers to use
|
|
687
|
+
"""
|
|
688
|
+
env_workers = os.environ.get("DESCRY_SCIP_WORKERS")
|
|
689
|
+
if env_workers:
|
|
690
|
+
return min(int(env_workers), num_items)
|
|
691
|
+
|
|
692
|
+
# Detect available memory and adjust
|
|
693
|
+
try:
|
|
694
|
+
import psutil
|
|
695
|
+
|
|
696
|
+
mem_gb = psutil.virtual_memory().total / (1024**3)
|
|
697
|
+
if mem_gb >= 32:
|
|
698
|
+
return min(4, num_items) # 4 workers for 32GB+
|
|
699
|
+
elif mem_gb >= 16:
|
|
700
|
+
return min(3, num_items) # 3 workers for 16GB+
|
|
701
|
+
else:
|
|
702
|
+
return min(2, num_items) # Conservative for <16GB
|
|
703
|
+
except ImportError:
|
|
704
|
+
# psutil not available, use safe default
|
|
705
|
+
return min(3, num_items)
|
|
706
|
+
|
|
707
|
+
def _get_prime_threads(self) -> int:
|
|
708
|
+
"""Get thread count for rust-analyzer cache priming.
|
|
709
|
+
|
|
710
|
+
Uses DESCRY_PRIME_THREADS env or defaults based on available CPUs.
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
Number of threads for cache priming
|
|
714
|
+
"""
|
|
715
|
+
env_threads = os.environ.get("DESCRY_PRIME_THREADS")
|
|
716
|
+
if env_threads:
|
|
717
|
+
return int(env_threads)
|
|
718
|
+
# Default: use available CPUs minus 2 (leave room for system)
|
|
719
|
+
import multiprocessing
|
|
720
|
+
|
|
721
|
+
return max(2, multiprocessing.cpu_count() - 2)
|
|
722
|
+
|
|
723
|
+
def _prime_rust_analyzer_cache(self) -> bool:
|
|
724
|
+
"""Pre-warm rust-analyzer cache with parallel workers.
|
|
725
|
+
|
|
726
|
+
Uses `rust-analyzer analysis-stats` which can utilize multiple threads
|
|
727
|
+
for initial analysis, reducing subsequent SCIP generation time.
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
True if cache priming succeeded, False otherwise
|
|
731
|
+
"""
|
|
732
|
+
num_threads = self._get_prime_threads()
|
|
733
|
+
logger.info(f"SCIP: Pre-warming rust-analyzer cache ({num_threads} threads)...")
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
# Use analysis-stats to prime the cache - it analyzes the workspace
|
|
737
|
+
# without generating SCIP, which can help subsequent SCIP generation
|
|
738
|
+
result = subprocess.run(
|
|
739
|
+
[
|
|
740
|
+
"rust-analyzer",
|
|
741
|
+
"analysis-stats",
|
|
742
|
+
"--parallel",
|
|
743
|
+
str(self.project_root),
|
|
744
|
+
],
|
|
745
|
+
capture_output=True,
|
|
746
|
+
text=True,
|
|
747
|
+
timeout=600, # 10 minute timeout for cache priming
|
|
748
|
+
cwd=str(self.project_root),
|
|
749
|
+
env={**safe_env(), "RUST_ANALYZER_THREADS": str(num_threads)},
|
|
750
|
+
)
|
|
751
|
+
# analysis-stats returns non-zero for warnings (like cyclic deps)
|
|
752
|
+
# but still warms the cache, so we consider it a success
|
|
753
|
+
if result.returncode == 0:
|
|
754
|
+
logger.info("SCIP: Cache priming complete")
|
|
755
|
+
else:
|
|
756
|
+
# Warnings are logged but don't prevent caching
|
|
757
|
+
logger.info("SCIP: Cache priming complete (with warnings)")
|
|
758
|
+
logger.debug(f"SCIP: analysis-stats output: {result.stderr[:200]}")
|
|
759
|
+
return True
|
|
760
|
+
except subprocess.TimeoutExpired:
|
|
761
|
+
logger.warning("SCIP: Cache priming timed out")
|
|
762
|
+
return False
|
|
763
|
+
except FileNotFoundError:
|
|
764
|
+
logger.debug("SCIP: rust-analyzer not found for cache priming")
|
|
765
|
+
return False
|
|
766
|
+
except Exception as e:
|
|
767
|
+
logger.debug(f"SCIP: Cache priming error: {e}")
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
def _get_timeout(self, is_first_run: bool = False) -> int | None:
|
|
771
|
+
"""Get timeout in seconds based on config, environment, and run type.
|
|
772
|
+
|
|
773
|
+
Priority: config value > env var > default (no timeout)
|
|
774
|
+
"""
|
|
775
|
+
# Config value takes priority
|
|
776
|
+
if self._scip_timeout_minutes is not None:
|
|
777
|
+
if self._scip_timeout_minutes == 0:
|
|
778
|
+
return None # 0 means unlimited
|
|
779
|
+
return self._scip_timeout_minutes * 60
|
|
780
|
+
|
|
781
|
+
# Then check environment variable
|
|
782
|
+
env_timeout = os.environ.get("DESCRY_SCIP_TIMEOUT", "").lower()
|
|
783
|
+
if env_timeout in ("0", "none", "unlimited", ""):
|
|
784
|
+
return None
|
|
785
|
+
else:
|
|
786
|
+
return int(env_timeout) * 60
|
|
787
|
+
|
|
788
|
+
def _hash_project(self, project: str, project_type: str = "rust") -> str:
|
|
789
|
+
"""Hash project sources for change detection.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
project: Name of the project to hash
|
|
793
|
+
project_type: Type of project ("rust" or "typescript")
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
16-character hex hash string
|
|
797
|
+
"""
|
|
798
|
+
if project_type == "rust":
|
|
799
|
+
return self._hash_rust_crate(project)
|
|
800
|
+
elif project_type == "typescript":
|
|
801
|
+
return self._hash_typescript_package(project)
|
|
802
|
+
elif project_type == "python":
|
|
803
|
+
return self._hash_python_package(project)
|
|
804
|
+
else:
|
|
805
|
+
raise ValueError(f"Unknown project type: {project_type}")
|
|
806
|
+
|
|
807
|
+
def _hash_rust_crate(self, crate: str) -> str:
|
|
808
|
+
"""Hash Rust crate sources + toolchain for change detection.
|
|
809
|
+
|
|
810
|
+
Hashes:
|
|
811
|
+
- Cargo.toml (dependencies affect types)
|
|
812
|
+
- Workspace Cargo.lock (if present) — resolved dep versions affect types
|
|
813
|
+
- All .rs files in the crate
|
|
814
|
+
- Pinned rust toolchain and scip extra args (so cache invalidates when
|
|
815
|
+
the user changes either)
|
|
816
|
+
"""
|
|
817
|
+
crate_path = self.project_root / crate
|
|
818
|
+
hasher = hashlib.sha256()
|
|
819
|
+
|
|
820
|
+
# Hash pinned toolchain + extra args (cache-key component).
|
|
821
|
+
hasher.update(f"toolchain:{self._scip_toolchain or ''}".encode())
|
|
822
|
+
hasher.update(f"extra_args:{'|'.join(self._scip_extra_args)}".encode())
|
|
823
|
+
|
|
824
|
+
# Hash Cargo.toml
|
|
825
|
+
cargo_toml = crate_path / "Cargo.toml"
|
|
826
|
+
if cargo_toml.exists():
|
|
827
|
+
hasher.update(cargo_toml.read_bytes())
|
|
828
|
+
|
|
829
|
+
# Hash workspace Cargo.lock (resolved dependency versions affect types).
|
|
830
|
+
cargo_lock = self.project_root / "Cargo.lock"
|
|
831
|
+
if cargo_lock.exists():
|
|
832
|
+
hasher.update(cargo_lock.read_bytes())
|
|
833
|
+
|
|
834
|
+
# Hash all .rs files (sorted for determinism)
|
|
835
|
+
rs_files = sorted(crate_path.rglob("*.rs"))
|
|
836
|
+
for rs_file in rs_files:
|
|
837
|
+
# Skip target directory
|
|
838
|
+
if "target" in rs_file.parts:
|
|
839
|
+
continue
|
|
840
|
+
try:
|
|
841
|
+
# Include relative path in hash so renames are detected
|
|
842
|
+
rel_path = rs_file.relative_to(crate_path)
|
|
843
|
+
hasher.update(str(rel_path).encode())
|
|
844
|
+
hasher.update(rs_file.read_bytes())
|
|
845
|
+
except (OSError, ValueError):
|
|
846
|
+
pass
|
|
847
|
+
|
|
848
|
+
return hasher.hexdigest()[:16]
|
|
849
|
+
|
|
850
|
+
def _hash_typescript_package(self, package: str) -> str:
|
|
851
|
+
"""Hash TypeScript/JavaScript package sources for change detection.
|
|
852
|
+
|
|
853
|
+
Hashes:
|
|
854
|
+
- package.json (dependencies affect types)
|
|
855
|
+
- tsconfig.json if present
|
|
856
|
+
- All .ts, .tsx, .js, .jsx files in src/
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
package: Name of the package to hash
|
|
860
|
+
|
|
861
|
+
Returns:
|
|
862
|
+
16-character hex hash string
|
|
863
|
+
"""
|
|
864
|
+
pkg_path = self.project_root / package
|
|
865
|
+
hasher = hashlib.sha256()
|
|
866
|
+
|
|
867
|
+
# Hash package.json
|
|
868
|
+
package_json = pkg_path / "package.json"
|
|
869
|
+
if package_json.exists():
|
|
870
|
+
hasher.update(package_json.read_bytes())
|
|
871
|
+
|
|
872
|
+
# Hash tsconfig.json if present
|
|
873
|
+
tsconfig = pkg_path / "tsconfig.json"
|
|
874
|
+
if tsconfig.exists():
|
|
875
|
+
hasher.update(tsconfig.read_bytes())
|
|
876
|
+
|
|
877
|
+
# Hash all source files (sorted for determinism)
|
|
878
|
+
extensions = ("*.ts", "*.tsx", "*.js", "*.jsx")
|
|
879
|
+
source_files = []
|
|
880
|
+
for ext in extensions:
|
|
881
|
+
source_files.extend(pkg_path.glob(f"src/**/{ext}"))
|
|
882
|
+
source_files = sorted(source_files)
|
|
883
|
+
|
|
884
|
+
for src_file in source_files:
|
|
885
|
+
# Skip node_modules and dist directories
|
|
886
|
+
if "node_modules" in src_file.parts or "dist" in src_file.parts:
|
|
887
|
+
continue
|
|
888
|
+
try:
|
|
889
|
+
# Include relative path in hash so renames are detected
|
|
890
|
+
rel_path = src_file.relative_to(pkg_path)
|
|
891
|
+
hasher.update(str(rel_path).encode())
|
|
892
|
+
hasher.update(src_file.read_bytes())
|
|
893
|
+
except (OSError, ValueError):
|
|
894
|
+
pass
|
|
895
|
+
|
|
896
|
+
return hasher.hexdigest()[:16]
|
|
897
|
+
|
|
898
|
+
def _hash_python_package(self, package: str) -> str:
|
|
899
|
+
"""Hash Python package sources for change detection.
|
|
900
|
+
|
|
901
|
+
Hashes:
|
|
902
|
+
- pyproject.toml / setup.py / setup.cfg / requirements*.txt
|
|
903
|
+
(dep/version changes affect type resolution)
|
|
904
|
+
- All .py files recursively under the package (excluding hidden /
|
|
905
|
+
excluded dirs)
|
|
906
|
+
|
|
907
|
+
Args:
|
|
908
|
+
package: Name of the package to hash. May be a subdir name or
|
|
909
|
+
the project_root basename (single-package layout).
|
|
910
|
+
|
|
911
|
+
Returns:
|
|
912
|
+
16-character hex hash string.
|
|
913
|
+
"""
|
|
914
|
+
candidate = self.project_root / package
|
|
915
|
+
if candidate.is_dir() and (
|
|
916
|
+
(candidate / "pyproject.toml").exists()
|
|
917
|
+
or (candidate / "setup.py").exists()
|
|
918
|
+
or (candidate / "setup.cfg").exists()
|
|
919
|
+
):
|
|
920
|
+
pkg_path = candidate
|
|
921
|
+
else:
|
|
922
|
+
pkg_path = self.project_root
|
|
923
|
+
|
|
924
|
+
hasher = hashlib.sha256()
|
|
925
|
+
|
|
926
|
+
for name in (
|
|
927
|
+
"pyproject.toml",
|
|
928
|
+
"setup.py",
|
|
929
|
+
"setup.cfg",
|
|
930
|
+
"requirements.txt",
|
|
931
|
+
"requirements-dev.txt",
|
|
932
|
+
"uv.lock",
|
|
933
|
+
"poetry.lock",
|
|
934
|
+
"Pipfile.lock",
|
|
935
|
+
):
|
|
936
|
+
marker = pkg_path / name
|
|
937
|
+
if marker.exists():
|
|
938
|
+
hasher.update(f"{name}:".encode())
|
|
939
|
+
hasher.update(marker.read_bytes())
|
|
940
|
+
|
|
941
|
+
# Hash every in-scope .py file, sorted for determinism.
|
|
942
|
+
py_files = []
|
|
943
|
+
for candidate_path in pkg_path.rglob("*.py"):
|
|
944
|
+
rel = candidate_path.relative_to(pkg_path)
|
|
945
|
+
parts = rel.parts
|
|
946
|
+
skip = False
|
|
947
|
+
for part in parts[:-1]:
|
|
948
|
+
if part.startswith(".") or part in self.excluded_dirs:
|
|
949
|
+
skip = True
|
|
950
|
+
break
|
|
951
|
+
if not skip:
|
|
952
|
+
py_files.append(candidate_path)
|
|
953
|
+
py_files.sort()
|
|
954
|
+
for py_file in py_files:
|
|
955
|
+
try:
|
|
956
|
+
rel_path = py_file.relative_to(pkg_path)
|
|
957
|
+
hasher.update(str(rel_path).encode())
|
|
958
|
+
hasher.update(py_file.read_bytes())
|
|
959
|
+
except (OSError, ValueError):
|
|
960
|
+
pass
|
|
961
|
+
|
|
962
|
+
return hasher.hexdigest()[:16]
|
|
963
|
+
|
|
964
|
+
def _get_scip_paths(self, crates: List[str]) -> Dict[str, Path]:
|
|
965
|
+
"""Get paths to existing SCIP files.
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
crates: List of crate names
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
Dictionary mapping crate names to SCIP file paths (only existing files)
|
|
972
|
+
"""
|
|
973
|
+
paths = {}
|
|
974
|
+
for crate in crates:
|
|
975
|
+
scip_file = self.cache_dir / f"{crate}.scip"
|
|
976
|
+
if scip_file.exists():
|
|
977
|
+
paths[crate] = scip_file
|
|
978
|
+
return paths
|
|
979
|
+
|
|
980
|
+
def _load_checksums(self) -> Dict[str, str]:
|
|
981
|
+
"""Load cached checksums from disk."""
|
|
982
|
+
if self.checksums_file.exists():
|
|
983
|
+
try:
|
|
984
|
+
with open(self.checksums_file) as f:
|
|
985
|
+
return json.load(f)
|
|
986
|
+
except (json.JSONDecodeError, OSError):
|
|
987
|
+
return {}
|
|
988
|
+
return {}
|
|
989
|
+
|
|
990
|
+
def _save_checksums(self, checksums: Dict[str, str]):
|
|
991
|
+
"""Save checksums to disk."""
|
|
992
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
993
|
+
with open(self.checksums_file, "w") as f:
|
|
994
|
+
json.dump(checksums, f, indent=2)
|
|
995
|
+
|
|
996
|
+
def get_all_scip_files(self) -> List[Path]:
|
|
997
|
+
"""Get all existing SCIP files in the cache.
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
List of paths to SCIP files
|
|
1001
|
+
"""
|
|
1002
|
+
if not self.cache_dir.exists():
|
|
1003
|
+
return []
|
|
1004
|
+
return sorted(self.cache_dir.glob("*.scip"))
|
|
1005
|
+
|
|
1006
|
+
def clear_cache(self):
|
|
1007
|
+
"""Clear all cached SCIP files and checksums."""
|
|
1008
|
+
import shutil
|
|
1009
|
+
|
|
1010
|
+
if self.cache_dir.exists():
|
|
1011
|
+
shutil.rmtree(self.cache_dir)
|
|
1012
|
+
logger.info("SCIP: Cache cleared")
|
|
1013
|
+
|
|
1014
|
+
def validate_coverage(self) -> Dict[str, dict]:
|
|
1015
|
+
"""Validate SCIP coverage for all projects.
|
|
1016
|
+
|
|
1017
|
+
Checks each SCIP file exists and reports basic statistics.
|
|
1018
|
+
|
|
1019
|
+
Returns:
|
|
1020
|
+
Dictionary mapping project names to coverage info:
|
|
1021
|
+
{
|
|
1022
|
+
"project_name": {
|
|
1023
|
+
"exists": bool,
|
|
1024
|
+
"size_kb": float,
|
|
1025
|
+
"project_type": str,
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
"""
|
|
1029
|
+
coverage = {}
|
|
1030
|
+
|
|
1031
|
+
for project, project_type in self.get_projects():
|
|
1032
|
+
scip_file = self.cache_dir / f"{project}.scip"
|
|
1033
|
+
if scip_file.exists():
|
|
1034
|
+
size_kb = scip_file.stat().st_size / 1024
|
|
1035
|
+
coverage[project] = {
|
|
1036
|
+
"exists": True,
|
|
1037
|
+
"size_kb": round(size_kb, 1),
|
|
1038
|
+
"project_type": project_type,
|
|
1039
|
+
}
|
|
1040
|
+
else:
|
|
1041
|
+
coverage[project] = {
|
|
1042
|
+
"exists": False,
|
|
1043
|
+
"size_kb": 0,
|
|
1044
|
+
"project_type": project_type,
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return coverage
|