from __future__ import annotations import logging import os import time from dataclasses import dataclass from pathlib import Path from vuln_scan.core.dependency_graph import DependencyGraph from vuln_scan.core.registry import EcosystemRegistry logger = logging.getLogger(__name__) DEFAULT_EXCLUDED_DIRS = ( "node_modules", "venv", ".venv", "env", ".env", "vendor", "dist", "build", ".git", ".tox", "__pycache__", ) @dataclass(frozen=True) class WorkspaceScanner: base_path: Path project_dir: str | None = None excluded_path_fragments: tuple[str, ...] = () def scan(self, registry: EcosystemRegistry) -> DependencyGraph: start_ns = time.perf_counter_ns() base = self.base_path.resolve() root = (base / (self.project_dir or "")).resolve() logger.info("Scanning dir %s", str(root)) logger.debug("Starting filesystem walk for root: %s", str(root)) logger.debug( "Workspace scan config: base_path=%s project_dir=%s excluded_fragments=%s", str(base), self.project_dir, self.excluded_path_fragments, ) graph = DependencyGraph() scanned_manifests = 0 for dirpath, dirnames, filenames in os.walk(root): # Prune excluded dirs IN-PLACE — os.walk won't descend into them dirnames[:] = [ d for d in dirnames if d not in DEFAULT_EXCLUDED_DIRS and not any(frag and frag in d for frag in self.excluded_path_fragments) ] for filename in filenames: full_path = Path(dirpath) / filename rel = full_path.relative_to(base).as_posix() # Still check user-excluded fragments on the full relative path if any(frag and frag in rel for frag in self.excluded_path_fragments): continue handler = registry.for_manifest(rel) if handler is None: continue manifest_start_ns = time.perf_counter_ns() parsed = handler.parse_manifest( rel, full_path, ) manifest_elapsed_ms = (time.perf_counter_ns() - manifest_start_ns) / 1_000_000 deps_count = len(parsed.dependencies) scanned_manifests += 1 logger.debug( "Scanned %s file and found %d packages (%.2fms)", str(full_path), deps_count, manifest_elapsed_ms, ) for dep in parsed.dependencies: graph.add_dependency(rel, dep) elapsed_ms = (time.perf_counter_ns() - start_ns) / 1_000_000 logger.info( "Done scanning workspace: %d manifest(s), %d package(s), %.2fms elapsed", scanned_manifests, graph.dependency_count(), elapsed_ms, ) return graph