From edb4e20018abf98a3b62feb0a01e4f684bae8277 Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 17:08:26 +0200 Subject: [PATCH 1/7] CM-55551 - fix annotation mismatch --- .../sca/go/restore_go_dependencies.py | 2 +- .../sca/maven/restore_gradle_dependencies.py | 2 +- .../sca/maven/restore_maven_dependencies.py | 2 +- .../sca/npm/restore_npm_dependencies.py | 149 +++++++++++++++++- .../sca/nuget/restore_nuget_dependencies.py | 2 +- .../sca/ruby/restore_ruby_dependencies.py | 2 +- .../sca/sbt/restore_sbt_dependencies.py | 2 +- 7 files changed, 149 insertions(+), 12 deletions(-) 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 b57812b9..7c24e330 100644 --- a/cycode/cli/files_collector/sca/go/restore_go_dependencies.py +++ b/cycode/cli/files_collector/sca/go/restore_go_dependencies.py @@ -44,5 +44,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return GO_RESTORE_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 4e4f36eb..d2687bf6 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -41,7 +41,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] def get_working_directory(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 50e55f10..22ec33db 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -34,7 +34,7 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return join_paths('target', MAVEN_CYCLONE_DEP_TREE_FILE_NAME) - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] def try_restore_dependencies(self, document: Document) -> Optional[Document]: 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 120d7de7..a6004ab8 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -1,16 +1,21 @@ import os +from typing import Optional import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +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('NPM Restore Dependencies') NPM_PROJECT_FILE_EXTENSIONS = ['.json'] NPM_LOCK_FILE_NAME = 'package-lock.json' -NPM_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, 'yarn.lock', 'pnpm-lock.yaml', 'deno.lock'] +# 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' - - class RestoreNpmDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(ctx, is_git_diff, command_timeout) @@ -18,6 +23,131 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) 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) + + 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). + """ + all_lock_file_names = [NPM_LOCK_FILE_NAME] + ALTERNATIVE_LOCK_FILES + lock_file_paths = [ + os.path.join(manifest_dir, lock_file_name) + for lock_file_name in all_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 + + return existing_lock_file, checked_lockfiles + + 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) + else: + 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) + def get_commands(self, manifest_file_path: str) -> list[list[str]]: return [ [ @@ -37,9 +167,16 @@ def get_restored_lock_file_name(self, restore_file_path: str) -> str: def get_lock_file_name(self) -> str: return NPM_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return NPM_LOCK_FILE_NAMES @staticmethod def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: - return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') + # Remove package.json from the path + if manifest_file_path.endswith(NPM_MANIFEST_FILE_NAME): + # Handle both cases: with separator (e.g., '/path/to/package.json') and without (e.g., 'package.json') + if os.sep in manifest_file_path: + return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') + else: + return '' + return manifest_file_path diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 1e439bbd..95ced0ff 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -20,5 +20,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py index 5e0fbe75..a8358270 100644 --- a/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py +++ b/cycode/cli/files_collector/sca/ruby/restore_ruby_dependencies.py @@ -15,5 +15,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return RUBY_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] diff --git a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py index bb2a9626..c9529d6a 100644 --- a/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py +++ b/cycode/cli/files_collector/sca/sbt/restore_sbt_dependencies.py @@ -15,5 +15,5 @@ def get_commands(self, manifest_file_path: str) -> list[list[str]]: def get_lock_file_name(self) -> str: return SBT_LOCK_FILE_NAME - def get_lock_file_names(self) -> str: + def get_lock_file_names(self) -> list[str]: return [self.get_lock_file_name()] From b285b3b1a41efb55f643ebf1a776ffb5dc54c74e Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 17:08:47 +0200 Subject: [PATCH 2/7] CM-55551 - fix inconsistent path resolution between the lockfile check and the manifest path resolution, to guarantee restore is skipped when any npm lockfile exists --- .../files_collector/sca/sca_file_collector.py | 6 +- .../sca/npm/test_restore_npm_dependencies.py | 350 ++++++++++++++++++ 2 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index 0c206c66..41f70316 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -153,7 +153,11 @@ def _add_dependencies_tree_documents( continue if restore_dependencies_document.path in documents_to_add: - logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path) + # Lockfile was already collected during file discovery, so we skip adding it again + logger.debug( + 'Lockfile already exists in scan, skipping duplicate document, %s', + {'path': restore_dependencies_document.path, 'source': 'restore'}, + ) else: logger.debug('Adding dependencies tree document, %s', restore_dependencies_document.path) documents_to_add[restore_dependencies_document.path] = restore_dependencies_document 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 new file mode 100644 index 00000000..4e47177c --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -0,0 +1,350 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import ( + ALTERNATIVE_LOCK_FILES, + NPM_LOCK_FILE_NAME, + RestoreNpmDependencies, +) +from cycode.cli.models import Document + + +@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)} + return ctx + + +@pytest.fixture +def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies: + """Create a RestoreNpmDependencies instance.""" + return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestRestoreNpmDependenciesAlternativeLockfiles: + """Test that lockfiles prevent npm install from running.""" + + @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, + ) -> 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) + + document = Document( + path=str(package_json_path), + content=package_json_path.read_text(), + absolute_path=str(package_json_path), + ) + + # Execute + result = restore_npm_dependencies.try_restore_dependencies(document) + + # 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_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), + ) + + # 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, + ) as mock_super: + # Execute + result = 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) + + +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, + ) -> 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' + 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') + + 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, + ) as mock_super: + result = 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 + + 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 + + document = Document('script.js', 'code') + assert restore_npm_dependencies.is_project(document) is False + + 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 + + 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' + + result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json') + assert result == '' From f4d0a5ecfcc6bbf26891fb3b628fd906778e0fba Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 17:35:42 +0200 Subject: [PATCH 3/7] CM-55551 - improve NPM_LOCK_FILE_NAMES initialization --- .../sca/npm/restore_npm_dependencies.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) 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 a6004ab8..b2e886d6 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -14,7 +14,7 @@ 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_LOCK_FILE_NAMES = [NPM_LOCK_FILE_NAME, *ALTERNATIVE_LOCK_FILES] NPM_MANIFEST_FILE_NAME = 'package.json' class RestoreNpmDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: @@ -25,10 +25,10 @@ def is_project(self, document: Document) -> bool: 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. """ @@ -45,17 +45,16 @@ def _resolve_manifest_directory(self, document: Document) -> Optional[str]: 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). """ - all_lock_file_names = [NPM_LOCK_FILE_NAME] + ALTERNATIVE_LOCK_FILES lock_file_paths = [ os.path.join(manifest_dir, lock_file_name) - for lock_file_name in all_lock_file_names + for lock_file_name in NPM_LOCK_FILE_NAMES ] existing_lock_file = None @@ -74,11 +73,11 @@ 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. """ @@ -106,7 +105,7 @@ def _create_document_from_lockfile( 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. From 3bb58370b1f33ce043f1f551263c19b74a4964eb Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 17:47:06 +0200 Subject: [PATCH 4/7] CM-55551 - fix linter check comments --- .../sca/npm/restore_npm_dependencies.py | 22 +++++++++---------- tests/cli/files_collector/sca/npm/__init__.py | 0 .../sca/npm/test_restore_npm_dependencies.py | 9 ++++---- 3 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 tests/cli/files_collector/sca/npm/__init__.py 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 b2e886d6..a1a744fe 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -77,7 +77,7 @@ def _create_document_from_lockfile( Args: document: Original document (package.json). lockfile_path: Path to the existing lockfile. - + Returns: Document with lockfile content if successful, None otherwise. """ @@ -96,12 +96,12 @@ def _create_document_from_lockfile( {'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) - else: - logger.warning( - 'Lockfile exists but could not read content, %s', - {'path': document.path, 'lockfile': lock_file_name, 'lockfile_path': lockfile_path}, - ) - return None + + 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. @@ -109,7 +109,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 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. """ @@ -128,7 +128,8 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 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}) + 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( @@ -176,6 +177,5 @@ def prepare_manifest_file_path_for_command(manifest_file_path: str) -> str: # Handle both cases: with separator (e.g., '/path/to/package.json') and without (e.g., 'package.json') if os.sep in manifest_file_path: return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') - else: - return '' + return '' return manifest_file_path diff --git a/tests/cli/files_collector/sca/npm/__init__.py b/tests/cli/files_collector/sca/npm/__init__.py new file mode 100644 index 00000000..e69de29b 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 4e47177c..9bca3a0f 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 @@ -31,7 +31,7 @@ class TestRestoreNpmDependenciesAlternativeLockfiles: """Test that lockfiles prevent npm install from running.""" @pytest.mark.parametrize( - 'lockfile_name,lockfile_content,expected_content', + ('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'), @@ -90,7 +90,7 @@ def test_no_lockfile_exists_should_proceed_with_normal_flow( return_value=None, ) as mock_super: # Execute - result = restore_npm_dependencies.try_restore_dependencies(document) + 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) @@ -239,7 +239,8 @@ def test_multiple_lockfiles_should_use_first_found( 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.""" + """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' @@ -285,7 +286,7 @@ def test_lockfile_in_different_directory_should_not_be_found( 'try_restore_dependencies', return_value=None, ) as mock_super: - result = restore_npm_dependencies.try_restore_dependencies(document) + 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) From 228079496c5728266085ce45cbdc69c2a589b83d Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 17:51:23 +0200 Subject: [PATCH 5/7] CM-55551 - fix linter check comments #2 --- .../cli/files_collector/sca/npm/restore_npm_dependencies.py | 6 +++--- .../sca/npm/test_restore_npm_dependencies.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 a1a744fe..2fceadfd 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -96,7 +96,7 @@ def _create_document_from_lockfile( {'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}, @@ -109,7 +109,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 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. """ @@ -128,7 +128,7 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: return super().try_restore_dependencies(document) # Check for existing lockfiles - logger.debug('Checking for existing lockfiles in directory, %s', + 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) 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 9bca3a0f..4cdc07b1 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 @@ -31,7 +31,7 @@ class TestRestoreNpmDependenciesAlternativeLockfiles: """Test that lockfiles prevent npm install from running.""" @pytest.mark.parametrize( - ('lockfile_name,lockfile_content,expected_content'), + ('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'), @@ -239,7 +239,7 @@ def test_multiple_lockfiles_should_use_first_found( 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), + """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' From e81e0cc9e8feaeeca313d81453aca2fd7be3a829 Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 17:55:46 +0200 Subject: [PATCH 6/7] CM-55551 - format files --- .../sca/npm/restore_npm_dependencies.py | 16 +++--- .../sca/npm/test_restore_npm_dependencies.py | 54 +++++++++---------- 2 files changed, 32 insertions(+), 38 deletions(-) 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 2fceadfd..4e9ecb27 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -16,6 +16,8 @@ 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' + + class RestoreNpmDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(ctx, is_git_diff, command_timeout) @@ -52,10 +54,7 @@ def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], lis Returns: Tuple of (lockfile_path if found, list of checked lockfiles with status). """ - lock_file_paths = [ - os.path.join(manifest_dir, lock_file_name) - for lock_file_name in NPM_LOCK_FILE_NAMES - ] + 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 = [] @@ -69,9 +68,7 @@ def _find_existing_lockfile(self, manifest_dir: str) -> tuple[Optional[str], lis return existing_lock_file, checked_lockfiles - def _create_document_from_lockfile( - self, document: Document, lockfile_path: str - ) -> Optional[Document]: + def _create_document_from_lockfile(self, document: Document, lockfile_path: str) -> Optional[Document]: """Create a Document from an existing lockfile. Args: @@ -128,8 +125,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 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}) + 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( 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 4cdc07b1..af990085 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 @@ -40,12 +40,12 @@ class TestRestoreNpmDependenciesAlternativeLockfiles: ], ) 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, + self, + restore_npm_dependencies: RestoreNpmDependencies, + tmp_path: Path, + lockfile_name: str, + lockfile_content: str, + expected_content: str, ) -> None: """Test that when any lockfile exists, npm install is skipped.""" # Setup: Create package.json and lockfile @@ -70,7 +70,7 @@ def test_lockfile_exists_should_skip_npm_install( assert result.content == expected_content def test_no_lockfile_exists_should_proceed_with_normal_flow( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + 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) @@ -85,9 +85,9 @@ def test_no_lockfile_exists_should_proceed_with_normal_flow( # 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_dependencies.__class__.__bases__[0], + 'try_restore_dependencies', + return_value=None, ) as mock_super: # Execute restore_npm_dependencies.try_restore_dependencies(document) @@ -104,10 +104,10 @@ class TestRestoreNpmDependenciesPathResolution: [True, False], ) def test_path_resolution_with_different_path_types( - self, - restore_npm_dependencies: RestoreNpmDependencies, - tmp_path: Path, - has_absolute_path: bool, + self, + restore_npm_dependencies: RestoreNpmDependencies, + tmp_path: Path, + has_absolute_path: bool, ) -> None: """Test path resolution with absolute or relative paths.""" package_json_path = tmp_path / 'package.json' @@ -127,9 +127,7 @@ def test_path_resolution_with_different_path_types( assert result is not None assert result.content == 'lockfileVersion: 5.4\n' - def test_path_resolution_in_monitor_mode( - self, tmp_path: Path - ) -> None: + 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) @@ -160,7 +158,7 @@ def test_path_resolution_in_monitor_mode( assert result.content == 'lockfileVersion: 5.4\n' def test_path_resolution_with_nested_directory( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path ) -> None: """Test path resolution with a nested directory structure.""" subdir = tmp_path / 'src' / 'app' @@ -188,7 +186,7 @@ class TestRestoreNpmDependenciesEdgeCases: """Test edge cases and error scenarios.""" def test_empty_lockfile_should_still_be_used( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + 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' @@ -210,7 +208,7 @@ def test_empty_lockfile_should_still_be_used( assert result.content == '' def test_multiple_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + 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' @@ -237,7 +235,7 @@ def test_multiple_lockfiles_should_use_first_found( assert result.content == '{"lockfileVersion": 2}\n' def test_multiple_alternative_lockfiles_should_use_first_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + 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.""" @@ -263,7 +261,7 @@ def test_multiple_alternative_lockfiles_should_use_first_found( assert result.content == '# yarn lockfile\n' def test_lockfile_in_different_directory_should_not_be_found( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + 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' @@ -282,9 +280,9 @@ def test_lockfile_in_different_directory_should_not_be_found( # 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_dependencies.__class__.__bases__[0], + 'try_restore_dependencies', + return_value=None, ) as mock_super: restore_npm_dependencies.try_restore_dependencies(document) @@ -292,7 +290,7 @@ def test_lockfile_in_different_directory_should_not_be_found( mock_super.assert_called_once_with(document) def test_non_json_file_should_not_trigger_restore( - self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path + self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path ) -> None: """Test that non-JSON files don't trigger restore.""" text_file = tmp_path / 'readme.txt' @@ -340,9 +338,7 @@ def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependenc for alt_lock in ALTERNATIVE_LOCK_FILES: assert alt_lock in lock_file_names - def test_prepare_manifest_file_path_for_command( - self, restore_npm_dependencies: RestoreNpmDependencies - ) -> None: + 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' From 5395dbfd8c1f56fe4a59e3760dc7d5fe7528efc6 Mon Sep 17 00:00:00 2001 From: Eran Geiger Date: Thu, 1 Jan 2026 18:03:56 +0200 Subject: [PATCH 7/7] CM-55551 - fix OS compatability --- .../files_collector/sca/npm/restore_npm_dependencies.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 4e9ecb27..9f8c0b66 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -172,8 +172,9 @@ def get_lock_file_names(self) -> list[str]: 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): - # Handle both cases: with separator (e.g., '/path/to/package.json') and without (e.g., 'package.json') - if os.sep in manifest_file_path: - return manifest_file_path.replace(os.sep + NPM_MANIFEST_FILE_NAME, '') - return '' + # 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) + return dir_path if dir_path and dir_path != '.' else '' return manifest_file_path