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/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