diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 9ef19eb2..a134f3f4 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -102,6 +102,7 @@ 'deno.lock', 'deno.json', 'pnpm-lock.yaml', + 'bun.lock', 'npm-shrinkwrap.json', 'packages.config', 'project.assets.json', @@ -165,6 +166,7 @@ 'npm-shrinkwrap.json', '.npmrc', 'pnpm-lock.yaml', + 'bun.lock', 'deno.lock', 'deno.json', ], diff --git a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py new file mode 100644 index 00000000..2bf0d647 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py @@ -0,0 +1,113 @@ +import json +import re +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.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Bun Restore Dependencies') + +BUN_MANIFEST_FILE_NAME = 'package.json' +BUN_LOCK_FILE_NAME = 'bun.lock' + +# Only Bun >=1.2 produces the text-based `bun.lock` lockfile that we parse. +# Older Bun versions emit a binary `bun.lockb`, which is not supported. +MINIMUM_BUN_VERSION = (1, 2) +BUN_VERSION_COMMAND = ['bun', '--version'] + + +def _indicates_bun(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Bun.""" + 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('bun'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'bun' in engines + + +def _parse_bun_version(raw_version: Optional[str]) -> Optional[tuple[int, int]]: + """Parse the (major, minor) version from `bun --version` output (e.g. '1.2.3').""" + if not raw_version: + return None + match = re.match(r'(\d+)\.(\d+)', raw_version.strip()) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +class RestoreBunDependencies(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 != BUN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / BUN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_bun(document.content) + + def _is_supported_bun_version(self) -> bool: + """Verify that the installed Bun is >=1.2, which is required to generate a text bun.lock.""" + raw_version = shell(command=BUN_VERSION_COMMAND, timeout=self.command_timeout, silent_exc_info=True) + version = _parse_bun_version(raw_version) + minimum = '.'.join(str(part) for part in MINIMUM_BUN_VERSION) + if version is None: + logger.warning( + 'Could not determine Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'raw_version': raw_version}, + ) + return False + if version < MINIMUM_BUN_VERSION: + logger.warning( + 'Unsupported Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'detected_version': '.'.join(str(part) for part in version)}, + ) + return False + return True + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / BUN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running bun. + # A text bun.lock only exists when generated by Bun >=1.2, so no version check is needed here. + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, BUN_LOCK_FILE_NAME) + logger.debug('Using existing bun.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — must generate it via `bun install`. This requires Bun >=1.2, + # otherwise an older Bun would emit a binary bun.lockb that we cannot parse. + if not self._is_supported_bun_version(): + return None + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['bun', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return BUN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [BUN_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 d07bc4a5..9416f58c 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', 'deno.lock') +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock', 'bun.lock') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,6 +23,15 @@ def is_project(self, document: Document) -> bool: 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. + + NOTE: this guard only excludes a project when an alternative lockfile is *physically + present on disk*. It does not inspect the `packageManager`/`engines` signal in + package.json. So a project that declares e.g. `packageManager: "bun@..."` (or pnpm) + but has no lockfile yet is claimed by BOTH the dedicated handler and this npm fallback, + and both restores run. This is pre-existing behavior shared by pnpm/yarn/bun and is + accepted for now (a real Bun/pnpm project ships a lockfile, so npm correctly skips). + If this ever needs tightening, also skip here when package.json declares a non-npm + packageManager/engines signal. """ if Path(document.path).name != NPM_MANIFEST_FILE_NAME: return False diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index b57061b0..6bcfd494 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -10,6 +10,7 @@ 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_bun_dependencies import RestoreBunDependencies 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 @@ -157,8 +158,9 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreNugetDependencies(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), + RestoreBunDependencies(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 fallback + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn, Pnpm & Bun for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py new file mode 100644 index 00000000..17f189df --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py @@ -0,0 +1,205 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import ( + BUN_LOCK_FILE_NAME, + RestoreBunDependencies, + _parse_bun_version, +) +from cycode.cli.models import Document + +_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies' + + +@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_bun(mock_ctx: typer.Context) -> RestoreBunDependencies: + return RestoreBunDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_bun_lock_matches(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_package_manager_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_engines_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "engines": {"bun": ">=1"}}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_no_bun_signal_does_not_match( + self, restore_bun: RestoreBunDependencies, 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_bun.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_bun: RestoreBunDependencies, 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_bun.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_bun.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_bun.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_bun_lock_returned_directly(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + bun_lock_content = '{"lockfileVersion": 1, "packages": {"package": ["package@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text(bun_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert BUN_LOCK_FILE_NAME in result.path + assert result.content == bun_lock_content + + def test_get_lock_file_name(self, restore_bun: RestoreBunDependencies) -> None: + assert restore_bun.get_lock_file_name() == BUN_LOCK_FILE_NAME + + def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependencies) -> None: + commands = restore_bun.get_commands('/path/to/package.json') + assert commands == [['bun', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestParseBunVersion: + def test_parses_full_semver(self) -> None: + assert _parse_bun_version('1.2.3') == (1, 2) + + def test_parses_with_surrounding_whitespace(self) -> None: + assert _parse_bun_version(' 1.2.0\n') == (1, 2) + + def test_none_input_returns_none(self) -> None: + assert _parse_bun_version(None) is None + + def test_non_version_string_returns_none(self) -> None: + assert _parse_bun_version('not-a-version') is None + + +class TestBunVersionGate: + def test_supported_version_proceeds_to_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch.object( + restore_bun.__class__.__bases__[0], 'try_restore_dependencies', return_value=None + ) as mock_super, + ): + restore_bun.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) + + def test_old_version_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.1.38'), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_missing_bun_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value=None), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_existing_lockfile_skips_version_check(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + with patch(f'{_BUN_MODULE}.shell') as mock_shell: + result = restore_bun.try_restore_dependencies(doc) + assert result is not None + mock_shell.assert_not_called() + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + # bun: no pre-existing bun.lock but package.json indicates bun (supported version installed) + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / BUN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 1}\n') + return 'output' + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect), + ): + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{BUN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + lock_content = '{"lockfileVersion": 1, "packages": {"pkg": ["pkg@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / BUN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {BUN_LOCK_FILE_NAME} must not be deleted' 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 c418b659..95f94da0 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 @@ -49,6 +49,15 @@ def test_package_json_with_pnpm_lock_does_not_match( 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_bun_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Bun projects are handled by RestoreBunDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\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_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: doc = Document('tsconfig.json', '{}') assert restore_npm.is_project(doc) is False