From 356031e631f0b521a615b99f88cdd2ba8561fc7f Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Wed, 4 Feb 2026 14:55:41 +0000 Subject: [PATCH] Initial upload: dependency freshness checker CLI tool --- src/depcheck/cli.py | 211 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/depcheck/cli.py diff --git a/src/depcheck/cli.py b/src/depcheck/cli.py new file mode 100644 index 0000000..5dfec89 --- /dev/null +++ b/src/depcheck/cli.py @@ -0,0 +1,211 @@ +"""Main CLI module for depcheck.""" + +import sys +from pathlib import Path +from typing import Optional + +import click + +from depcheck import __version__ +from depcheck.analyzers import CVEAnalyzer, load_cve_database +from depcheck.config import Config +from depcheck.models import ScanResult, Severity +from depcheck.parsers import ParserRegistry, create_parser_registry +from depcheck.reporters import JSONReporter, TerminalReporter + + +@click.group() +@click.version_option(version=__version__, prog_name="depcheck") +@click.option("--config", type=click.Path(exists=True), help="Path to config file") +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output") +@click.option("-q", "--quiet", is_flag=True, help="Suppress non-essential output") +@click.pass_context +def main(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool) -> None: + """Dependency Freshness Checker - Monitor outdated dependencies across package managers.""" + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + ctx.obj["quiet"] = quiet + + +@main.command() +@click.argument("path", type=click.Path(exists=True), default=".") +@click.option("--json", "json_output", is_flag=True, help="Output in JSON format") +@click.option("--ci", is_flag=True, help="CI/CD mode (non-interactive, proper exit codes)") +@click.option( + "--fail-level", + type=click.Choice([s.value for s in Severity]), + default=Severity.MEDIUM.value, + help="Severity level that causes non-zero exit code", +) +@click.option( + "--package-manager", + type=click.Choice(["npm", "pip", "go", "cargo", "auto"]), + default="auto", + help="Package manager to use", +) +@click.option( + "--exclude-dev", + is_flag=True, + help="Exclude dev dependencies", +) +@click.pass_context +def scan( + ctx: click.Context, + path: str, + json_output: bool, + ci: bool, + fail_level: str, + package_manager: str, + exclude_dev: bool, +) -> None: + """Scan dependency files for outdated packages and vulnerabilities.""" + verbose = ctx.obj.get("verbose", False) + quiet = ctx.obj.get("quiet", False) + + config = Config( + fail_level=Severity(fail_level), + output_format="json" if json_output else "terminal", + verbose=verbose, + quiet=quiet, + include_dev=not exclude_dev, + ) + + scan_path = Path(path) + + registry = create_parser_registry() + cve_analyzer = CVEAnalyzer(load_cve_database()) + + if json_output or ci: + reporter: JSONReporter | TerminalReporter = JSONReporter(config) + else: + reporter = TerminalReporter(config) + + result = ScanResult() + + try: + if scan_path.is_file(): + result = _scan_file(scan_path, registry, cve_analyzer, config) + else: + result = _scan_directory(scan_path, registry, cve_analyzer, config) + except Exception as e: + if not quiet: + reporter.print_error(f"Scan failed: {str(e)}") + sys.exit(2) + + if json_output: + print(reporter.report(result)) + else: + if result.scan_errors and verbose: + for error in result.scan_errors: + reporter.print_warning(error) + reporter.report(result) + + exit_code = reporter.get_exit_code(result) if ci else 0 + sys.exit(exit_code) + + +def _scan_file( + file_path: Path, + registry: ParserRegistry, + cve_analyzer: CVEAnalyzer, + config: Config, +) -> ScanResult: + """Scan a single dependency file.""" + result = ScanResult(source_file=str(file_path)) + + parser = registry.get_parser(file_path) + if parser is None: + result.scan_errors.append(f"No parser found for {file_path}") + return result + + dependencies = parser.parse(file_path) + for dep in dependencies: + if not config.include_dev and dep.category in ["devDependencies", "devDependencies"]: + continue + + vulnerabilities = cve_analyzer.analyze(dep) + for vuln in vulnerabilities: + result.vulnerabilities.append((dep, vuln)) + + result.dependencies = dependencies + result.package_manager = parser.package_manager + result.source_file = str(file_path) + + return result + + +def _scan_directory( + directory: Path, + registry: ParserRegistry, + cve_analyzer: CVEAnalyzer, + config: Config, +) -> ScanResult: + """Scan a directory for all supported dependency files.""" + result = ScanResult() + + if package_manager := _get_package_manager_from_args(directory, registry): + for pattern in _get_package_file_patterns(package_manager): + for file_path in directory.rglob(pattern): + if _should_skip_file(file_path, config): + continue + file_result = _scan_file(file_path, registry, cve_analyzer, config) + result.dependencies.extend(file_result.dependencies) + result.vulnerabilities.extend(file_result.vulnerabilities) + result.scan_errors.extend(file_result.scan_errors) + else: + for file_path in _find_all_dependency_files(directory): + if _should_skip_file(file_path, config): + continue + file_result = _scan_file(file_path, registry, cve_analyzer, config) + result.dependencies.extend(file_result.dependencies) + result.vulnerabilities.extend(file_result.vulnerabilities) + result.scan_errors.extend(file_result.scan_errors) + + if result.dependencies: + result.package_manager = result.dependencies[0].package_manager + + return result + + +def _get_package_manager_from_args(directory: Path, registry: ParserRegistry) -> Optional[str]: + """Determine package manager from directory contents.""" + for parser in registry.get_all_parsers(): + for pattern in parser.get_file_patterns(): + if (directory / pattern).exists(): + return parser.package_manager.value + return None + + +def _get_package_file_patterns(package_manager: str) -> list[str]: + """Get file patterns for a package manager.""" + patterns = { + "npm": ["package.json"], + "pip": ["requirements.txt", "pyproject.toml"], + "go": ["go.mod"], + "cargo": ["Cargo.toml"], + } + return patterns.get(package_manager, []) + + +def _find_all_dependency_files(directory: Path) -> list[Path]: + """Find all dependency files in a directory.""" + patterns = ["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml"] + files: list[Path] = [] + for pattern in patterns: + files.extend(directory.rglob(pattern)) + return sorted(set(files)) + + +def _should_skip_file(file_path: Path, config: Config) -> bool: + """Check if a file should be skipped.""" + for pattern in config.ignore_patterns: + if pattern in str(file_path): + return True + return False + + +@main.command() +@click.pass_context +def check_update(ctx: click.Context) -> None: + """Check if a new version of depcheck is available.""" + click.echo("Update check not implemented in offline mode.")