From 2d2daa23bd5afb19753238640e1942a8b6acc94a Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 22 Feb 2026 22:59:37 +0200 Subject: [PATCH 01/11] CM-59977 moved common SCA options to a separate file --- .../cli/apps/report/sbom/path/path_command.py | 25 +++++----- cycode/cli/apps/sca_options.py | 47 +++++++++++++++++++ cycode/cli/apps/scan/scan_command.py | 40 ++++------------ 3 files changed, 68 insertions(+), 44 deletions(-) create mode 100644 cycode/cli/apps/sca_options.py diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index a127bfc7..a3ffa578 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -1,11 +1,17 @@ import time from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated import typer from cycode.cli import consts from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception from cycode.cli.files_collector.path_documents import get_relevant_documents from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed @@ -14,8 +20,6 @@ from cycode.cli.utils.progress_bar import SbomReportProgressBarSection from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config -_SCA_RICH_HELP_PANEL = 'SCA options' - def path_command( ctx: typer.Context, @@ -23,18 +27,11 @@ def path_command( Path, typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False), ], - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, ) -> None: - ctx.obj['maven_settings_file'] = maven_settings_file + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] diff --git a/cycode/cli/apps/sca_options.py b/cycode/cli/apps/sca_options.py new file mode 100644 index 00000000..3c904ee6 --- /dev/null +++ b/cycode/cli/apps/sca_options.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Annotated, Optional + +import typer + +_SCA_RICH_HELP_PANEL = 'SCA options' + +NoRestoreOption = Annotated[ + bool, + typer.Option( + '--no-restore', + help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +GradleAllSubProjectsOption = Annotated[ + bool, + typer.Option( + '--gradle-all-sub-projects', + help='When specified, Cycode will run gradle restore command for all sub projects. ' + 'Should run from root project directory [b]only[/]!', + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + +MavenSettingsFileOption = Annotated[ + Optional[Path], + typer.Option( + '--maven-settings-file', + show_default=False, + help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', + dir_okay=False, + rich_help_panel=_SCA_RICH_HELP_PANEL, + ), +] + + +def apply_sca_restore_options_to_context( + ctx: typer.Context, + no_restore: bool, + gradle_all_sub_projects: bool, + maven_settings_file: Optional[Path], +) -> None: + ctx.obj['no_restore'] = no_restore + ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects + ctx.obj['maven_settings_file'] = maven_settings_file diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9892f1b6..7aab9d27 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,12 @@ import click import typer +from cycode.cli.apps.sca_options import ( + GradleAllSubProjectsOption, + MavenSettingsFileOption, + NoRestoreOption, + apply_sca_restore_options_to_context, +) from cycode.cli.apps.scan.remote_url_resolver import _try_get_git_remote_url from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( @@ -72,33 +78,9 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, - no_restore: Annotated[ - bool, - typer.Option( - '--no-restore', - help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - gradle_all_sub_projects: Annotated[ - bool, - typer.Option( - '--gradle-all-sub-projects', - help='When specified, Cycode will run gradle restore command for all sub projects. ' - 'Should run from root project directory [b]only[/]!', - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = False, - maven_settings_file: Annotated[ - Optional[Path], - typer.Option( - '--maven-settings-file', - show_default=False, - help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.', - dir_okay=False, - rich_help_panel=_SCA_RICH_HELP_PANEL, - ), - ] = None, + no_restore: NoRestoreOption = False, + gradle_all_sub_projects: GradleAllSubProjectsOption = False, + maven_settings_file: MavenSettingsFileOption = None, export_type: Annotated[ ExportTypeOption, typer.Option( @@ -152,10 +134,8 @@ def scan_command( ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor - ctx.obj['maven_settings_file'] = maven_settings_file ctx.obj['report'] = report - ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects - ctx.obj['no_restore'] = no_restore + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client From 3a72533e62d6df712d8129c13d7b77ece632b25d Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 22 Feb 2026 23:02:15 +0200 Subject: [PATCH 02/11] CM-59977 split NPM package managers logic to separate files for maintainability --- .../sca/base_restore_dependencies.py | 32 +- .../sca/npm/restore_deno_dependencies.py | 46 +++ .../sca/npm/restore_npm_dependencies.py | 159 ++------ .../sca/npm/restore_pnpm_dependencies.py | 70 ++++ .../sca/npm/restore_yarn_dependencies.py | 70 ++++ .../files_collector/sca/sca_file_collector.py | 14 +- .../sca/npm/test_restore_deno_dependencies.py | 67 ++++ .../sca/npm/test_restore_npm_dependencies.py | 359 +++--------------- .../sca/npm/test_restore_pnpm_dependencies.py | 91 +++++ .../sca/npm/test_restore_yarn_dependencies.py | 91 +++++ 10 files changed, 560 insertions(+), 439 deletions(-) create mode 100644 cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py create mode 100644 cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 80ef4183..c4bf15f8 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,5 +1,5 @@ -import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Optional import typer @@ -19,6 +19,9 @@ def execute_commands( output_file_path: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: + if not commands: + return None + try: outputs = [] @@ -79,22 +82,43 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: restore_file_content = get_file_content(restore_file_path) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) + def get_manifest_dir(self, document: Document) -> Optional[str]: + """Return the directory containing the manifest file, resolving monitor-mode paths. + + Uses the same path resolution as get_manifest_file_path() to ensure consistency. + Falls back to document.absolute_path when the resolved manifest path is ambiguous. + """ + manifest_file_path = self.get_manifest_file_path(document) + if manifest_file_path: + parent = Path(manifest_file_path).parent + # Skip '.' (no parent) and filesystem root (its own parent) + if parent != Path('.') and parent != parent.parent: + return str(parent) + + base = document.absolute_path or document.path + if base: + parent = Path(base).parent + if parent != Path('.') and parent != parent.parent: + return str(parent) + + return None + def get_working_directory(self, document: Document) -> Optional[str]: - return os.path.dirname(document.absolute_path) + return str(Path(document.absolute_path).parent) def get_restored_lock_file_name(self, restore_file_path: str) -> str: return self.get_lock_file_name() def get_any_restore_file_already_exist(self, document: Document, restore_file_paths: list[str]) -> str: for restore_file_path in restore_file_paths: - if os.path.isfile(restore_file_path): + if Path(restore_file_path).is_file(): return restore_file_path return build_dep_tree_path(document.absolute_path, self.get_lock_file_name()) @staticmethod def verify_restore_file_already_exist(restore_file_path: str) -> bool: - return os.path.isfile(restore_file_path) + return Path(restore_file_path).is_file() @abstractmethod def is_project(self, document: Document) -> bool: diff --git a/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py new file mode 100644 index 00000000..d3aeb5e5 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Deno Restore Dependencies') + +DENO_MANIFEST_FILE_NAMES = ('deno.json', 'deno.jsonc') +DENO_LOCK_FILE_NAME = 'deno.lock' + + +class RestoreDenoDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name in DENO_MANIFEST_FILE_NAMES + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + if not manifest_dir: + return None + + lockfile_path = Path(manifest_dir) / DENO_LOCK_FILE_NAME + if not lockfile_path.is_file(): + logger.debug('No deno.lock found alongside deno.json, skipping deno restore, %s', {'path': document.path}) + return None + + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, DENO_LOCK_FILE_NAME) + logger.debug('Using existing deno.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [] + + def get_lock_file_name(self) -> str: + return DENO_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [DENO_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index 9f8c0b66..dd0b1aca 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,21 +1,17 @@ -import os -from typing import Optional +from pathlib import Path import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content from cycode.logger import get_logger logger = get_logger('NPM Restore Dependencies') -NPM_PROJECT_FILE_EXTENSIONS = ['.json'] -NPM_LOCK_FILE_NAME = 'package-lock.json' -# Alternative lockfiles that should prevent npm install from running -ALTERNATIVE_LOCK_FILES = ['yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] -NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES] NPM_MANIFEST_FILE_NAME = 'package.json' +NPM_LOCK_FILE_NAME = 'package-lock.json' +# These lockfiles indicate another package manager owns the project — NPM should not run +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,128 +19,25 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout) def is_project(self, document: Document) -> bool: - return any(document.path.endswith(ext) for ext in NPM_PROJECT_FILE_EXTENSIONS) - - def _resolve_manifest_directory(self, document: Document) -> Optional[str]: - """Resolve the directory containing the manifest file. - - Uses the same path resolution logic as get_manifest_file_path() to ensure consistency. - Falls back to absolute_path or document.path if needed. - - Returns: - Directory path if resolved, None otherwise. - """ - manifest_file_path = self.get_manifest_file_path(document) - manifest_dir = os.path.dirname(manifest_file_path) if manifest_file_path else None - - # Fallback: if manifest_dir is empty or root, try using absolute_path or document.path - if not manifest_dir or manifest_dir == os.sep or manifest_dir == '.': - base_path = document.absolute_path if document.absolute_path else document.path - if base_path: - manifest_dir = os.path.dirname(base_path) + """Match only package.json files that are not managed by Yarn or pnpm. - return manifest_dir - - def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], list[str]]: - """Find the first existing lockfile in the manifest directory. - - Args: - manifest_dir: Directory to search for lockfiles. - - Returns: - Tuple of (lockfile_path if found, list of checked lockfiles with status). + Yarn and pnpm projects are handled by their dedicated handlers, which run before + this one in the handler list. This handler is the npm fallback. """ - lock_file_paths = [os.path.join(manifest_dir, lock_file_name) for lock_file_name in NPM_LOCK_FILE_NAMES] - - existing_lock_file = None - checked_lockfiles = [] - for lock_file_path in lock_file_paths: - lock_file_name = os.path.basename(lock_file_path) - exists = os.path.isfile(lock_file_path) - checked_lockfiles.append(f'{lock_file_name}: {"exists" if exists else "not found"}') - if exists: - existing_lock_file = lock_file_path - break + if Path(document.path).name != NPM_MANIFEST_FILE_NAME: + return False - return existing_lock_file, checked_lockfiles + manifest_dir = self.get_manifest_dir(document) + if manifest_dir: + for lock_file in _ALTERNATIVE_LOCK_FILES: + if (Path(manifest_dir) / lock_file).is_file(): + logger.debug( + 'Skipping npm restore: alternative lockfile detected, %s', + {'path': document.path, 'lockfile': lock_file}, + ) + return False - def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]: - """Create a Document from an existing lockfile. - - Args: - document: Original document (package.json). - lockfile_path: Path to the existing lockfile. - - Returns: - Document with lockfile content if successful, None otherwise. - """ - lock_file_name = os.path.basename(lockfile_path) - logger.info( - 'Skipping npm install: using existing lockfile, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - - relative_restore_file_path = build_dep_tree_path(document.path, lock_file_name) - restore_file_content = get_file_content(lockfile_path) - - if restore_file_content is not None: - logger.debug( - 'Successfully loaded lockfile content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'content_size': len(restore_file_content)}, - ) - return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) - - logger.warning( - 'Lockfile exists but could not read content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - return None - - def try_restore_dependencies(self, document: Document) -> Optional[Document]: - """Override to prevent npm install when any lockfile exists. - - The base class uses document.absolute_path which might be None or incorrect. - We need to use the same path resolution logic as get_manifest_file_path() - to ensure we check for lockfiles in the correct location. - - If any lockfile exists (package-lock.json, pnpm-lock.yaml, yarn.lock, deno.lock), - we use it directly without running npm install to avoid generating invalid lockfiles. - """ - # Check if this is a project file first (same as base class caller does) - if not self.is_project(document): - logger.debug('Skipping restore: document is not recognized as npm project, %s', {'path': document.path}) - return None - - # Resolve the manifest directory - manifest_dir = self._resolve_manifest_directory(document) - if not manifest_dir: - logger.debug( - 'Cannot determine manifest directory, proceeding with base class restore flow, %s', - {'path': document.path}, - ) - return super().try_restore_dependencies(document) - - # Check for existing lockfiles - logger.debug( - 'Checking for existing lockfiles in directory, %s', {'directory': manifest_dir, 'path': document.path} - ) - existing_lock_file, checked_lockfiles = self._find_existing_lockfile(manifest_dir) - - logger.debug( - 'Lockfile check results, %s', - {'path': document.path, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - - # If any lockfile exists, use it directly without running npm install - if existing_lock_file: - return self._create_document_from_lockfile(document, existing_lock_file) - - # No lockfile exists, proceed with the normal restore flow which will run npm install - logger.info( - 'No existing lockfile found, proceeding with npm install to generate package-lock.json, %s', - {'path': document.path, 'directory': manifest_dir, 'checked_lockfiles': ', '.join(checked_lockfiles)}, - ) - return super().try_restore_dependencies(document) + return True def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ @@ -159,22 +52,16 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: ] ] - def get_restored_lock_file_name(self, restore_file_path: str) -> str: - return os.path.basename(restore_file_path) - def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME def get_lock_file_names(self) -> list[str]: - return NPM_LOCK_FILE_NAMES + return [NPM_LOCK_FILE_NAME] @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: - # Remove package.json from the path if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME): - # Use os.path.dirname to handle both Unix (/) and Windows (\) separators - # This is cross-platform and handles edge cases correctly - dir_path = os.path.dirname(manifest_file_path) - # If dir_path is empty or just '.', return an empty string (package.json in current dir) + parent = Path(manifest_file_path).parent + dir_path = str(parent) return dir_path if dir_path and dir_path != '.' else '' return manifest_file_path diff --git a/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py new file mode 100644 index 00000000..bce7eff6 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pnpm Restore Dependencies') + +PNPM_MANIFEST_FILE_NAME = 'package.json' +PNPM_LOCK_FILE_NAME = 'pnpm-lock.yaml' + + +def _indicates_pnpm(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses pnpm.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('pnpm'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'pnpm' in engines + + +class RestorePnpmDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != PNPM_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PNPM_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_pnpm(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PNPM_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pnpm + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PNPM_LOCK_FILE_NAME) + logger.debug('Using existing pnpm-lock.yaml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but pnpm is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pnpm', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return PNPM_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PNPM_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py new file mode 100644 index 00000000..79b0c4ec --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Yarn Restore Dependencies') + +YARN_MANIFEST_FILE_NAME = 'package.json' +YARN_LOCK_FILE_NAME = 'yarn.lock' + + +def _indicates_yarn(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Yarn.""" + if not package_json_content: + return False + try: + data = json.loads(package_json_content) + except (json.JSONDecodeError, ValueError): + return False + + package_manager = data.get('packageManager', '') + if isinstance(package_manager, str) and package_manager.startswith('yarn'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'yarn' in engines + + +class RestoreYarnDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != YARN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / YARN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_yarn(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / YARN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running yarn + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, YARN_LOCK_FILE_NAME) + logger.debug('Using existing yarn.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but yarn is indicated in package.json — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['yarn', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return YARN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [YARN_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 41f70316..f8bd6907 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -9,8 +9,14 @@ from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import RestoreDenoDependencies from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import RestorePnpmDependencies +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import RestoreYarnDependencies from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import RestoreNugetDependencies +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import RestoreComposerDependencies +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import RestorePipenvDependencies +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import RestorePoetryDependencies from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import RestoreRubyDependencies from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import RestoreSbtDependencies from cycode.cli.models import Document @@ -130,8 +136,14 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreSbtDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreGoDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout), - RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallack RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestoreComposerDependencies(ctx, is_git_diff, build_dep_tree_timeout), ] diff --git a/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py new file mode 100644 index 00000000..edbfeab3 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py @@ -0,0 +1,67 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import ( + DENO_LOCK_FILE_NAME, + DENO_MANIFEST_FILE_NAMES, + RestoreDenoDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_deno(mock_ctx: typer.Context) -> RestoreDenoDependencies: + return RestoreDenoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + @pytest.mark.parametrize('filename', DENO_MANIFEST_FILE_NAMES) + def test_deno_manifest_files_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: + doc = Document(filename, '{}') + assert restore_deno.is_project(doc) is True + + @pytest.mark.parametrize('filename', ['package.json', 'tsconfig.json', 'deno.ts', 'main.ts', 'deno.lock']) + def test_non_deno_manifest_files_do_not_match( + self, restore_deno: RestoreDenoDependencies, filename: str + ) -> None: + doc = Document(filename, '') + assert restore_deno.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_deno_lock_returned(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + deno_lock_content = '{"version": "3", "packages": {}}' + (tmp_path / 'deno.json').write_text('{"imports": {}}') + (tmp_path / 'deno.lock').write_text(deno_lock_content) + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is not None + assert DENO_LOCK_FILE_NAME in result.path + assert result.content == deno_lock_content + + def test_no_deno_lock_returns_none(self, restore_deno: RestoreDenoDependencies, tmp_path: Path) -> None: + (tmp_path / 'deno.json').write_text('{"imports": {}}') + + doc = Document(str(tmp_path / 'deno.json'), '{"imports": {}}', absolute_path=str(tmp_path / 'deno.json')) + result = restore_deno.try_restore_dependencies(doc) + + assert result is None + + def test_get_lock_file_name(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_lock_file_name() == DENO_LOCK_FILE_NAME + + def test_get_commands_returns_empty(self, restore_deno: RestoreDenoDependencies) -> None: + assert restore_deno.get_commands('/path/to/deno.json') == [] diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index af990085..1efd20a9 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -5,7 +5,6 @@ import typer from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import ( - ALTERNATIVE_LOCK_FILES, NPM_LOCK_FILE_NAME, RestoreNpmDependencies, ) @@ -14,7 +13,6 @@ @pytest.fixture def mock_ctx(tmp_path: Path) -> typer.Context: - """Create a mock typer context.""" ctx = MagicMock(spec=typer.Context) ctx.obj = {'monitor': False} ctx.params = {'path': str(tmp_path)} @@ -22,326 +20,91 @@ def mock_ctx(tmp_path: Path) -> typer.Context: @pytest.fixture -def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies: - """Create a RestoreNpmDependencies instance.""" +def restore_npm(mock_ctx: typer.Context) -> RestoreNpmDependencies: return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) -class TestRestoreNpmDependenciesAlternativeLockfiles: - """Test that lockfiles prevent npm install from running.""" +class TestIsProject: + def test_package_json_with_no_lockfile_matches(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is True - @pytest.mark.parametrize( - ('lockfile_name', 'lockfile_content', 'expected_content'), - [ - ('pnpm-lock.yaml', 'lockfileVersion: 5.4\n', 'lockfileVersion: 5.4\n'), - ('yarn.lock', '# yarn lockfile v1\n', '# yarn lockfile v1\n'), - ('deno.lock', '{"version": 2}\n', '{"version": 2}\n'), - ('package-lock.json', '{"lockfileVersion": 2}\n', '{"lockfileVersion": 2}\n'), - ], - ) - def test_lockfile_exists_should_skip_npm_install( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - lockfile_name: str, - lockfile_content: str, - expected_content: str, + def test_package_json_with_yarn_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test that when any lockfile exists, npm install is skipped.""" - # Setup: Create package.json and lockfile - package_json_path = tmp_path / 'package.json' - lockfile_path = tmp_path / lockfile_name - - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - lockfile_path.write_text(lockfile_content) + """Yarn projects are handled by RestoreYarnDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """pnpm projects are handled by RestorePnpmDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_npm.is_project(doc) is False - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) + def test_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + doc = Document('tsconfig.json', '{}') + assert restore_npm.is_project(doc) is False - # Execute - result = restore_npm_dependencies.try_restore_dependencies(document) + def test_arbitrary_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('jest.config.json', '.eslintrc.json', 'settings.json', 'bom.json'): + doc = Document(filename, '{}') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - # Verify: Should return lockfile content without running npm install - assert result is not None - assert lockfile_name in result.path - assert result.content == expected_content + def test_non_json_file_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: + for filename in ('readme.txt', 'script.js', 'Makefile'): + doc = Document(filename, '') + assert restore_npm.is_project(doc) is False, f'Expected False for {filename}' - def test_no_lockfile_exists_should_proceed_with_normal_flow( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when no lockfile exists, normal flow proceeds (will run npm install).""" - # Setup: Create only package.json (no lockfile) - package_json_path = tmp_path / 'package.json' - package_json_path.write_text('{"name": "test", "version": "1.0.0"}') - document = Document( - path=str(package_json_path), - content=package_json_path.read_text(), - absolute_path=str(package_json_path), - ) +class TestTryRestoreDependencies: + def test_no_lockfile_calls_base_class(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + """When no lockfile exists, the base class (npm install) should be invoked.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - # Mock the base class's try_restore_dependencies to verify it's called with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - # Execute - restore_npm_dependencies.try_restore_dependencies(document) - - # Verify: Should call parent's try_restore_dependencies (which will run npm install) - mock_super.assert_called_once_with(document) + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - -class TestRestoreNpmDependenciesPathResolution: - """Test path resolution scenarios.""" - - @pytest.mark.parametrize( - 'has_absolute_path', - [True, False], - ) - def test_path_resolution_with_different_path_types( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - has_absolute_path: bool, + def test_lockfile_in_different_directory_still_calls_base_class( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path ) -> None: - """Test path resolution with absolute or relative paths.""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path) if has_absolute_path else None, - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_in_monitor_mode(self, tmp_path: Path) -> None: - """Test path resolution in monitor mode.""" - # Setup monitor mode context - ctx = MagicMock(spec=typer.Context) - ctx.obj = {'monitor': True} - ctx.params = {'path': str(tmp_path)} - - restore_npm = RestoreNpmDependencies(ctx, is_git_diff=False, command_timeout=30) - - # Create files in a subdirectory - subdir = tmp_path / 'project' - subdir.mkdir() - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - # Document with a relative path - document = Document( - path='project/package.json', - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - def test_path_resolution_with_nested_directory( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test path resolution with a nested directory structure.""" - subdir = tmp_path / 'src' / 'app' - subdir.mkdir(parents=True) - - package_json_path = subdir / 'package.json' - pnpm_lock_path = subdir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is not None - assert result.content == 'lockfileVersion: 5.4\n' - - -class TestRestoreNpmDependenciesEdgeCases: - """Test edge cases and error scenarios.""" - - def test_empty_lockfile_should_still_be_used( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that the empty lockfile is still used (prevents npm install).""" - package_json_path = tmp_path / 'package.json' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('') # Empty file - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should still return the empty lockfile (prevents npm install) - assert result is not None - assert result.content == '' - - def test_multiple_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple lockfiles exist, the first one found is used (package-lock.json has priority).""" - package_json_path = tmp_path / 'package.json' - package_lock_path = tmp_path / 'package-lock.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - package_lock_path.write_text('{"lockfileVersion": 2}\n') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use package-lock.json (first in the check order) - assert result is not None - assert 'package-lock.json' in result.path - assert result.content == '{"lockfileVersion": 2}\n' - - def test_multiple_alternative_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that when multiple alternative lockfiles exist (but no package-lock.json), - the first one found is used.""" - package_json_path = tmp_path / 'package.json' - yarn_lock_path = tmp_path / 'yarn.lock' - pnpm_lock_path = tmp_path / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - yarn_lock_path.write_text('# yarn lockfile\n') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') - - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - result = restore_npm_dependencies.try_restore_dependencies(document) - - # Should use yarn.lock (first in ALTERNATIVE_LOCK_FILES list) - assert result is not None - assert 'yarn.lock' in result.path - assert result.content == '# yarn lockfile\n' - - def test_lockfile_in_different_directory_should_not_be_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that lockfile in a different directory is not found.""" - package_json_path = tmp_path / 'package.json' + (tmp_path / 'package.json').write_text('{"name": "test"}') other_dir = tmp_path / 'other' other_dir.mkdir() - pnpm_lock_path = other_dir / 'pnpm-lock.yaml' - - package_json_path.write_text('{"name": "test"}') - pnpm_lock_path.write_text('lockfileVersion: 5.4\n') + (other_dir / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) - document = Document( - path=str(package_json_path), - content='{"name": "test"}', - absolute_path=str(package_json_path), - ) - - # Mock the base class to verify it's called (since lockfile not found) with patch.object( - restore_npm_dependencies.__class__.__bases__[0], - 'try_restore_dependencies', - return_value=None, + restore_npm.__class__.__bases__[0], 'try_restore_dependencies', return_value=None ) as mock_super: - restore_npm_dependencies.try_restore_dependencies(document) - - # Should proceed with normal flow since lockfile not in same directory - mock_super.assert_called_once_with(document) - - def test_non_json_file_should_not_trigger_restore( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path - ) -> None: - """Test that non-JSON files don't trigger restore.""" - text_file = tmp_path / 'readme.txt' - text_file.write_text('Some text') - - document = Document( - path=str(text_file), - content='Some text', - absolute_path=str(text_file), - ) - - # Should return None because is_project() returns False - result = restore_npm_dependencies.try_restore_dependencies(document) - - assert result is None - - -class TestRestoreNpmDependenciesHelperMethods: - """Test helper methods.""" - - def test_is_project_with_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project identifies JSON files correctly.""" - document = Document('package.json', '{}') - assert restore_npm_dependencies.is_project(document) is True + restore_npm.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) - document = Document('tsconfig.json', '{}') - assert restore_npm_dependencies.is_project(document) is True - def test_is_project_with_non_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test is_project returns False for non-JSON files.""" - document = Document('readme.txt', 'text') - assert restore_npm_dependencies.is_project(document) is False +class TestGetLockFileName: + def test_get_lock_file_name(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_name() == NPM_LOCK_FILE_NAME - document = Document('script.js', 'code') - assert restore_npm_dependencies.is_project(document) is False + def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] - def test_get_lock_file_name(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_name returns the correct name.""" - assert restore_npm_dependencies.get_lock_file_name() == NPM_LOCK_FILE_NAME - def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test get_lock_file_names returns all lockfile names.""" - lock_file_names = restore_npm_dependencies.get_lock_file_names() - assert NPM_LOCK_FILE_NAME in lock_file_names - for alt_lock in ALTERNATIVE_LOCK_FILES: - assert alt_lock in lock_file_names +class TestPrepareManifestFilePath: + def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.prepare_manifest_file_path_for_command('/path/to/package.json') == '/path/to' - def test_prepare_manifest_file_path_for_command(self, restore_npm_dependencies: RestoreNpmDependencies) -> None: - """Test prepare_manifest_file_path_for_command removes package.json from the path.""" - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('/path/to/package.json') - assert result == '/path/to' + def test_package_json_in_cwd_returns_empty_string(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.prepare_manifest_file_path_for_command('package.json') == '' - result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json') - assert result == '' + def test_non_package_json_path_returned_unchanged(self, restore_npm: RestoreNpmDependencies) -> None: + assert restore_npm.prepare_manifest_file_path_for_command('/path/to/') == '/path/to/' diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py new file mode 100644 index 00000000..312cce83 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_pnpm_dependencies import ( + PNPM_LOCK_FILE_NAME, + RestorePnpmDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pnpm(mock_ctx: typer.Context) -> RestorePnpmDependencies: + return RestorePnpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_pnpm_lock_matches(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_package_manager_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_engines_pnpm_matches(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "engines": {"pnpm": ">=8"}}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is True + + def test_package_json_with_no_pnpm_signal_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_pnpm.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_pnpm.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_pnpm.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_pnpm: RestorePnpmDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_pnpm.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pnpm_lock_returned_directly(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + pnpm_lock_content = 'lockfileVersion: 5.4\n\npackages:\n /package@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text(pnpm_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert PNPM_LOCK_FILE_NAME in result.path + assert result.content == pnpm_lock_content + + def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None: + assert restore_pnpm.get_lock_file_name() == PNPM_LOCK_FILE_NAME + + def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None: + commands = restore_pnpm.get_commands('/path/to/package.json') + assert commands == [['pnpm', 'install', '--ignore-scripts']] diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py new file mode 100644 index 00000000..13e321c9 --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_yarn_dependencies import ( + YARN_LOCK_FILE_NAME, + RestoreYarnDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_yarn(mock_ctx: typer.Context) -> RestoreYarnDependencies: + return RestoreYarnDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_yarn_lock_matches(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_package_manager_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_engines_yarn_matches(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "engines": {"yarn": ">=1.22"}}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is True + + def test_package_json_with_no_yarn_signal_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_package_json_with_pnpm_lock_does_not_match( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'pnpm-lock.yaml').write_text('lockfileVersion: 5.4\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_yarn.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_yarn.is_project(doc) is False + + def test_package_manager_npm_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + content = '{"name": "test", "packageManager": "npm@9.0.0"}' + doc = Document('package.json', content) + assert restore_yarn.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_yarn: RestoreYarnDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_yarn.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_yarn_lock_returned_directly(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + yarn_lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'yarn.lock').write_text(yarn_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert YARN_LOCK_FILE_NAME in result.path + assert result.content == yarn_lock_content + + def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None: + assert restore_yarn.get_lock_file_name() == YARN_LOCK_FILE_NAME + + def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None: + commands = restore_yarn.get_commands('/path/to/package.json') + assert commands == [['yarn', 'install', '--ignore-scripts']] From fcb6756d5fc2dd6474ddf6a3ff6374b94dae0c68 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 22 Feb 2026 23:03:07 +0200 Subject: [PATCH 03/11] CM-59977 added parity for restore commands with backend --- .../sca/go/restore_go_dependencies.py | 8 +- .../cli/files_collector/sca/php/__init__.py | 0 .../sca/php/restore_composer_dependencies.py | 54 +++++++++ .../files_collector/sca/python/__init__.py | 0 .../sca/python/restore_pipenv_dependencies.py | 45 ++++++++ .../sca/python/restore_poetry_dependencies.py | 62 +++++++++++ tests/cli/files_collector/sca/php/__init__.py | 0 .../php/test_restore_composer_dependencies.py | 82 ++++++++++++++ .../files_collector/sca/python/__init__.py | 0 .../test_restore_pipenv_dependencies.py | 73 +++++++++++++ .../test_restore_poetry_dependencies.py | 103 ++++++++++++++++++ 11 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/files_collector/sca/php/__init__.py create mode 100644 cycode/cli/files_collector/sca/php/restore_composer_dependencies.py create mode 100644 cycode/cli/files_collector/sca/python/__init__.py create mode 100644 cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py create mode 100644 cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py create mode 100644 tests/cli/files_collector/sca/php/__init__.py create mode 100644 tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/__init__.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index 7c24e330..ac958fb7 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from typing import Optional import typer @@ -18,13 +18,13 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True) def try_restore_dependencies(self, document: Document) -> Optional[Document]: - manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME) - lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME) + manifest_exists = (Path(self.get_working_directory(document)) / BUILD_GO_FILE_NAME).is_file() + lock_exists = (Path(self.get_working_directory(document)) / BUILD_GO_LOCK_FILE_NAME).is_file() if not manifest_exists or not lock_exists: logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found') - manifest_files_exists = manifest_exists & lock_exists + manifest_files_exists = manifest_exists and lock_exists if not manifest_files_exists: return None diff --git a/cycode/cli/files_collector/sca/php/__init__.py b/cycode/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py new file mode 100644 index 00000000..98b3564c --- /dev/null +++ b/cycode/cli/files_collector/sca/php/restore_composer_dependencies.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Composer Restore Dependencies') + +COMPOSER_MANIFEST_FILE_NAME = 'composer.json' +COMPOSER_LOCK_FILE_NAME = 'composer.lock' + + +class RestoreComposerDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == COMPOSER_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / COMPOSER_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running composer + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, COMPOSER_LOCK_FILE_NAME) + logger.debug('Using existing composer.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] + + def get_lock_file_name(self) -> str: + return COMPOSER_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [COMPOSER_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/__init__.py b/cycode/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py new file mode 100644 index 00000000..df91707c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Pipenv Restore Dependencies') + +PIPENV_MANIFEST_FILE_NAME = 'Pipfile' +PIPENV_LOCK_FILE_NAME = 'Pipfile.lock' + + +class RestorePipenvDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + return Path(document.path).name == PIPENV_MANIFEST_FILE_NAME + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIPENV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running pipenv + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIPENV_LOCK_FILE_NAME) + logger.debug('Using existing Pipfile.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['pipenv', 'lock']] + + def get_lock_file_name(self) -> str: + return PIPENV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIPENV_LOCK_FILE_NAME] diff --git a/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py new file mode 100644 index 00000000..f681bd63 --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Optional + +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import get_file_content +from cycode.logger import get_logger + +logger = get_logger('Poetry Restore Dependencies') + +POETRY_MANIFEST_FILE_NAME = 'pyproject.toml' +POETRY_LOCK_FILE_NAME = 'poetry.lock' + +# Section header that signals this pyproject.toml is managed by Poetry +_POETRY_TOOL_SECTION = '[tool.poetry]' + + +def _indicates_poetry(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses Poetry.""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION in pyproject_content + + +class RestorePoetryDependencies(BaseRestoreDependencies): + def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: + super().__init__(ctx, is_git_diff, command_timeout) + + def is_project(self, document: Document) -> bool: + if Path(document.path).name != POETRY_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / POETRY_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_poetry(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / POETRY_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running poetry + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, POETRY_LOCK_FILE_NAME) + logger.debug('Using existing poetry.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent but Poetry is indicated in pyproject.toml — generate it + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['poetry', 'lock']] + + def get_lock_file_name(self) -> str: + return POETRY_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [POETRY_LOCK_FILE_NAME] diff --git a/tests/cli/files_collector/sca/php/__init__.py b/tests/cli/files_collector/sca/php/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py new file mode 100644 index 00000000..463eeddb --- /dev/null +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -0,0 +1,82 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.php.restore_composer_dependencies import ( + COMPOSER_LOCK_FILE_NAME, + RestoreComposerDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_composer(mock_ctx: typer.Context) -> RestoreComposerDependencies: + return RestoreComposerDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_composer_json_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_json_in_subdir_matches(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('myapp/composer.json', '{"name": "vendor/project"}\n') + assert restore_composer.is_project(doc) is True + + def test_composer_lock_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('composer.lock', '{"_readme": []}\n') + assert restore_composer.is_project(doc) is False + + def test_package_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('package.json', '{"name": "test"}\n') + assert restore_composer.is_project(doc) is False + + def test_other_json_does_not_match(self, restore_composer: RestoreComposerDependencies) -> None: + doc = Document('config.json', '{"setting": "value"}\n') + assert restore_composer.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_composer_lock_returned_directly( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + (tmp_path / 'composer.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert COMPOSER_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: + assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + + def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: + commands = restore_composer.get_commands('/path/to/composer.json') + assert commands == [ + [ + 'composer', + 'update', + '--no-cache', + '--no-install', + '--no-scripts', + '--ignore-platform-reqs', + ] + ] diff --git a/tests/cli/files_collector/sca/python/__init__.py b/tests/cli/files_collector/sca/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py new file mode 100644 index 00000000..9d34a7e3 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -0,0 +1,73 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pipenv_dependencies import ( + PIPENV_LOCK_FILE_NAME, + RestorePipenvDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_pipenv(mock_ctx: typer.Context) -> RestorePipenvDependencies: + return RestorePipenvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pipfile_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_in_subdir_matches(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('myapp/Pipfile', '[[source]]\nname = "pypi"\n') + assert restore_pipenv.is_project(doc) is True + + def test_pipfile_lock_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('Pipfile.lock', '{"default": {}}\n') + assert restore_pipenv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pipenv.is_project(doc) is False + + def test_pyproject_toml_does_not_match(self, restore_pipenv: RestorePipenvDependencies) -> None: + doc = Document('pyproject.toml', '[build-system]\nrequires = ["setuptools"]\n') + assert restore_pipenv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_pipfile_lock_returned_directly( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + (tmp_path / 'Pipfile.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'Pipfile'), + '[[source]]\nname = "pypi"\n', + absolute_path=str(tmp_path / 'Pipfile'), + ) + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert PIPENV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> None: + assert restore_pipenv.get_lock_file_name() == PIPENV_LOCK_FILE_NAME + + def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: + commands = restore_pipenv.get_commands('/path/to/Pipfile') + assert commands == [['pipenv', 'lock']] diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py new file mode 100644 index 00000000..d02e60ab --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -0,0 +1,103 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_poetry_dependencies import ( + POETRY_LOCK_FILE_NAME, + RestorePoetryDependencies, +) +from cycode.cli.models import Document + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_poetry(mock_ctx: typer.Context) -> RestorePoetryDependencies: + return RestorePoetryDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_poetry_lock_matches( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text('# This file is generated by Poetry\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_with_tool_poetry_section_matches( + self, restore_poetry: RestorePoetryDependencies + ) -> None: + content = '[tool.poetry]\nname = "my-project"\nversion = "1.0.0"\n' + doc = Document('pyproject.toml', content) + assert restore_poetry.is_project(doc) is True + + def test_pyproject_toml_without_poetry_section_does_not_match( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + content = '[build-system]\nrequires = ["setuptools"]\n' + (tmp_path / 'pyproject.toml').write_text(content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + content, + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_poetry.is_project(doc) is False + + def test_setup_py_does_not_match(self, restore_poetry: RestorePoetryDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_poetry.is_project(doc) is False + + def test_empty_content_does_not_match( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_poetry.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_poetry_lock_returned_directly( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + (tmp_path / 'poetry.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert POETRY_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> None: + assert restore_poetry.get_lock_file_name() == POETRY_LOCK_FILE_NAME + + def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None: + commands = restore_poetry.get_commands('/path/to/pyproject.toml') + assert commands == [['poetry', 'lock']] From a9d93b10e898511910a2bf5627db590ab53180b6 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 22 Feb 2026 23:05:52 +0200 Subject: [PATCH 04/11] CM-59977 updated docs --- README.md | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2abfd3b2..b512c813 100644 --- a/README.md +++ b/README.md @@ -668,15 +668,33 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co > [!NOTE] > This option is only available to SCA scans. -We use the sbt-dependency-lock plugin to restore the lock file for SBT projects. -To disable lock restore in use `--no-restore` option. - -Prerequisites: -* `sbt-dependency-lock` plugin: Install the plugin by adding the following line to `project/plugins.sbt`: - - ```text - addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") - ``` +When running an SCA scan, Cycode CLI automatically attempts to restore (generate) a dependency lockfile for each supported manifest file it finds. This allows scanning transitive dependencies, not just the ones listed directly in the manifest. To skip this step and scan only direct dependencies, use the `--no-restore` flag. + +The following ecosystems support automatic lockfile restoration: + +| Ecosystem | Manifest file | Lockfile generated | Tool invoked (when lockfile is absent) | +|---|---|---|---| +| npm | `package.json` | `package-lock.json` | `npm install --package-lock-only --ignore-scripts --no-audit` | +| Yarn | `package.json` | `yarn.lock` | `yarn install --ignore-scripts` | +| pnpm | `package.json` | `pnpm-lock.yaml` | `pnpm install --ignore-scripts` | +| Deno | `deno.json` / `deno.jsonc` | `deno.lock` | *(read existing lockfile only)* | +| Go | `go.mod` | `go.mod.graph` | `go list -m -json all` + `go mod graph` | +| Maven | `pom.xml` | `bcde.mvndeps` | `mvn dependency:tree` | +| Gradle | `build.gradle` / `build.gradle.kts` | `gradle-dependencies-generated.txt` | `gradle dependencies -q --console plain` | +| SBT | `build.sbt` | `build.sbt.lock` | `sbt dependencyLockWrite` | +| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | +| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | +| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | +| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | + +If a lockfile already exists alongside the manifest, Cycode reads it directly without running any install command. + +**SBT prerequisite:** The `sbt-dependency-lock` plugin must be installed. Add the following line to `project/plugins.sbt`: + +```text +addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") +``` ### Repository Scan @@ -1309,9 +1327,11 @@ For example:\ The `path` subcommand supports the following additional options: -| Option | Description | -|-------------------------|----------------------------------------------------------------------------------------------------------------------------------| -| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree | +| Option | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `--no-restore` | Skip lockfile restoration and scan direct dependencies only. See [Lock Restore Option](#lock-restore-option) for details. | +| `--gradle-all-sub-projects` | Run the Gradle restore command for all sub-projects (use from the root of a multi-project Gradle build). | +| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree. | # Import Command From 7f43ba40b66556fa8955b69af9c80e940d13b3bd Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 23 Feb 2026 08:54:28 +0200 Subject: [PATCH 05/11] CM-59965 add additional logging for SCA verbose mode (#392) --- .../sca/base_restore_dependencies.py | 30 +++++++++++++++++-- .../sca/go/restore_go_dependencies.py | 4 ++- .../files_collector/sca/sca_file_collector.py | 17 +++++++++-- cycode/cli/utils/shell_executor.py | 17 +++++++++-- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index c4bf15f8..709242a1 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -7,6 +7,9 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_file_content, get_file_dir, get_path_from_context, join_paths from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('SCA Restore') def build_dep_tree_path(path: str, generated_file_name: str) -> str: @@ -19,9 +22,18 @@ def execute_commands( output_file_path: Optional[str] = None, working_directory: Optional[str] = None, ) -> Optional[str]: + logger.debug( + 'Executing restore commands, %s', + { + 'commands_count': len(commands), + 'timeout_sec': timeout, + 'working_directory': working_directory, + 'output_file_path': output_file_path, + }, + ) + if not commands: return None - try: outputs = [] @@ -35,7 +47,8 @@ def execute_commands( if output_file_path: with open(output_file_path, 'w', encoding='UTF-8') as output_file: output_file.writelines(joined_output) - except Exception: + except Exception as e: + logger.debug('Unexpected error during command execution', exc_info=e) return None return joined_output @@ -78,8 +91,21 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + else: + logger.debug( + 'Lock file already exists, skipping restore commands, %s', + {'restore_file_path': restore_file_path}, + ) restore_file_content = get_file_content(restore_file_path) + logger.debug( + 'Restore file loaded, %s', + { + 'restore_file_path': restore_file_path, + 'content_size': len(restore_file_content) if restore_file_content else 0, + 'content_empty': not restore_file_content, + }, + ) return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_manifest_dir(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py index ac958fb7..b98fbaf5 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -4,8 +4,10 @@ import typer from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies -from cycode.cli.logger import logger from cycode.cli.models import Document +from cycode.logger import get_logger + +logger = get_logger('Go Restore Dependencies') GO_PROJECT_FILE_EXTENSIONS = ['.mod', '.sum'] GO_RESTORE_FILE_NAME = 'go.mod.graph' diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index f8bd6907..7b80851c 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -112,11 +112,17 @@ def _try_restore_dependencies( restore_dependencies_document = restore_dependencies.restore(document) if restore_dependencies_document is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) return None if restore_dependencies_document.content is None: - logger.warning('Error occurred while trying to generate dependencies tree, %s', {'filename': document.path}) + logger.warning( + 'Error occurred while trying to generate dependencies tree, %s', + {'filename': document.path, 'handler': type(restore_dependencies).__name__}, + ) restore_dependencies_document.content = '' else: is_monitor_action = ctx.obj.get('monitor', False) @@ -130,6 +136,13 @@ def _try_restore_dependencies( def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRestoreDependencies]: build_dep_tree_timeout = int(os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS', BUILD_DEP_TREE_TIMEOUT)) + logger.debug( + 'SCA restore handler timeout, %s', + { + 'timeout_sec': build_dep_tree_timeout, + 'source': 'env' if os.getenv('CYCODE_BUILD_DEP_TREE_TIMEOUT_SECONDS') else 'default', + }, + ) return [ RestoreGradleDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreMavenDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/cycode/cli/utils/shell_executor.py b/cycode/cli/utils/shell_executor.py index 2529890b..b39d2a0b 100644 --- a/cycode/cli/utils/shell_executor.py +++ b/cycode/cli/utils/shell_executor.py @@ -1,4 +1,5 @@ import subprocess +import time from typing import Optional, Union import click @@ -21,15 +22,27 @@ def shell( logger.debug('Executing shell command: %s', command) try: + start = time.monotonic() result = subprocess.run( # noqa: S603 command, cwd=working_directory, timeout=timeout, check=True, capture_output=True ) - logger.debug('Shell command executed successfully') + duration_sec = round(time.monotonic() - start, 2) + stdout = result.stdout.decode('UTF-8').strip() + stderr = result.stderr.decode('UTF-8').strip() - return result.stdout.decode('UTF-8').strip() + logger.debug( + 'Shell command executed successfully, %s', + {'duration_sec': duration_sec, 'stdout': stdout if stdout else '', 'stderr': stderr if stderr else ''}, + ) + + return stdout except subprocess.CalledProcessError as e: if not silent_exc_info: logger.debug('Error occurred while running shell command', exc_info=e) + if e.stdout: + logger.debug('Shell command stdout: %s', e.stdout.decode('UTF-8').strip()) + if e.stderr: + logger.debug('Shell command stderr: %s', e.stderr.decode('UTF-8').strip()) except subprocess.TimeoutExpired as e: logger.debug('Command timed out', exc_info=e) raise typer.Abort(f'Command "{command}" timed out') from e From b898d35e978789953f04dfffcb6c0f094fe7e43b Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Sun, 22 Feb 2026 23:02:15 +0200 Subject: [PATCH 06/11] CM-59977 split NPM package managers logic to separate files for maintainability --- cycode/cli/files_collector/sca/base_restore_dependencies.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 709242a1..f2e0bf69 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -31,6 +31,8 @@ def execute_commands( 'output_file_path': output_file_path, }, ) + if not commands: + return None if not commands: return None From f8984e2e5892423aaa8a666e1984360b7450db4d Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 23 Feb 2026 11:28:53 +0200 Subject: [PATCH 07/11] CM-59977 internal cr fixes --- cycode/cli/files_collector/sca/base_restore_dependencies.py | 2 -- cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py | 2 +- cycode/cli/files_collector/sca/sca_file_collector.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index f2e0bf69..e5037f4d 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -34,8 +34,6 @@ def execute_commands( if not commands: return None - if not commands: - return None try: outputs = [] diff --git a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py index dd0b1aca..d07bc4a5 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -11,7 +11,7 @@ NPM_MANIFEST_FILE_NAME = 'package.json' NPM_LOCK_FILE_NAME = 'package-lock.json' # These lockfiles indicate another package manager owns the project — NPM should not run -_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml') +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock') class RestoreNpmDependencies(BaseRestoreDependencies): diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 7b80851c..b194deef 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -152,7 +152,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout), - RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallack + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestorePipenvDependencies(ctx, is_git_diff, build_dep_tree_timeout), From 15bd64138e224fe868ae2d17185c3c567d1bbf2d Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 23 Feb 2026 11:40:39 +0200 Subject: [PATCH 08/11] CM-59977 formatting --- .../sca/npm/test_restore_deno_dependencies.py | 4 +--- .../sca/python/test_restore_poetry_dependencies.py | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py index edbfeab3..2d6e9a4b 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_deno_dependencies.py @@ -32,9 +32,7 @@ def test_deno_manifest_files_match(self, restore_deno: RestoreDenoDependencies, assert restore_deno.is_project(doc) is True @pytest.mark.parametrize('filename', ['package.json', 'tsconfig.json', 'deno.ts', 'main.ts', 'deno.lock']) - def test_non_deno_manifest_files_do_not_match( - self, restore_deno: RestoreDenoDependencies, filename: str - ) -> None: + def test_non_deno_manifest_files_do_not_match(self, restore_deno: RestoreDenoDependencies, filename: str) -> None: doc = Document(filename, '') assert restore_deno.is_project(doc) is False diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py index d02e60ab..73f0d14f 100644 --- a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -37,9 +37,7 @@ def test_pyproject_toml_with_poetry_lock_matches( ) assert restore_poetry.is_project(doc) is True - def test_pyproject_toml_with_tool_poetry_section_matches( - self, restore_poetry: RestorePoetryDependencies - ) -> None: + def test_pyproject_toml_with_tool_poetry_section_matches(self, restore_poetry: RestorePoetryDependencies) -> None: content = '[tool.poetry]\nname = "my-project"\nversion = "1.0.0"\n' doc = Document('pyproject.toml', content) assert restore_poetry.is_project(doc) is True @@ -64,9 +62,7 @@ def test_setup_py_does_not_match(self, restore_poetry: RestorePoetryDependencies doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') assert restore_poetry.is_project(doc) is False - def test_empty_content_does_not_match( - self, restore_poetry: RestorePoetryDependencies, tmp_path: Path - ) -> None: + def test_empty_content_does_not_match(self, restore_poetry: RestorePoetryDependencies, tmp_path: Path) -> None: (tmp_path / 'pyproject.toml').write_text('') doc = Document( str(tmp_path / 'pyproject.toml'), From 0a4adbe11614755b7521bac40df7819b45fede60 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 23 Feb 2026 12:02:05 +0200 Subject: [PATCH 09/11] CM-59977 fixed restore test to use OS-agnostic paths --- .../sca/npm/test_restore_npm_dependencies.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index 1efd20a9..aa145de3 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -101,10 +101,13 @@ def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNp class TestPrepareManifestFilePath: def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: - assert restore_npm.prepare_manifest_file_path_for_command('/path/to/package.json') == '/path/to' + path = str(Path('/path/to/package.json')) + expected = str(Path('/path/to')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == expected def test_package_json_in_cwd_returns_empty_string(self, restore_npm: RestoreNpmDependencies) -> None: assert restore_npm.prepare_manifest_file_path_for_command('package.json') == '' def test_non_package_json_path_returned_unchanged(self, restore_npm: RestoreNpmDependencies) -> None: - assert restore_npm.prepare_manifest_file_path_for_command('/path/to/') == '/path/to/' + path = str(Path('/path/to/')) + assert restore_npm.prepare_manifest_file_path_for_command(path) == path From 04e4833779fdb7874b94f8e17c36c68f9a68aac6 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 23 Feb 2026 12:09:23 +0200 Subject: [PATCH 10/11] CM-59977 linting --- cycode/cli/files_collector/sca/base_restore_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index f64e680f..ac391727 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -31,7 +31,7 @@ def execute_commands( 'output_file_path': output_file_path, }, ) - + if not commands: return None From d094f97bc1d723c1b16eb970b435279692f1e678 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 23 Feb 2026 16:59:17 +0200 Subject: [PATCH 11/11] CM-59977 add support for cdx 1.6 sboms --- cycode/cli/cli_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index bd88faea..ed277cc6 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -46,6 +46,7 @@ class SbomFormatOption(StrEnum): SPDX_2_2 = 'spdx-2.2' SPDX_2_3 = 'spdx-2.3' CYCLONEDX_1_4 = 'cyclonedx-1.4' + CYCLONEDX_1_6 = 'cyclonedx-1.6' class SbomOutputFormatOption(StrEnum):