From 7fa7d531fa8ece70dd1d8455dc474035a60d5778 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Sun, 28 Jun 2026 16:54:33 +0300 Subject: [PATCH 1/6] CM-67195: Add Bun package manager support to SCA local scans Add a dedicated Bun restore handler so `cycode scan -t sca` correctly restores Bun project dependencies before scanning, mirroring the existing pnpm/yarn pattern. - New RestoreBunDependencies: detects Bun via bun.lock or a packageManager/engines bun signal; reads an existing bun.lock directly, otherwise runs `bun install --ignore-scripts` to generate it. - Enforce Bun >=1.2 (text bun.lock) before generating a lockfile; skip with a warning on older or missing Bun. - Register the handler ahead of the npm fallback and add bun.lock to npm's alternative-lockfile guard so npm does not claim Bun projects. - Add bun.lock to the SCA supported files and npm ecosystem map. - Unit tests for detection, version gating, restore, and cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- cycode/cli/consts.py | 2 + .../sca/npm/restore_bun_dependencies.py | 113 ++++++++++ .../sca/npm/restore_npm_dependencies.py | 11 +- .../files_collector/sca/sca_file_collector.py | 4 +- .../sca/npm/test_restore_bun_dependencies.py | 205 ++++++++++++++++++ .../sca/npm/test_restore_npm_dependencies.py | 9 + 6 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py 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..7d6cb5c2 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. Tracked on CM-67195. """ 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 From e41b00ec4ff739a77231d02647b450501f1620ae Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Sun, 28 Jun 2026 16:54:34 +0300 Subject: [PATCH 2/6] CM-67195: Centralize package-manager version gate in BaseRestoreDependencies Move the Bun-specific version check into the base class as a reusable, opt-in capability so the logic lives in one place instead of a single handler. - BaseRestoreDependencies gains parse_tool_version() and is_supported_tool_version(), enforced automatically right before the install command runs. - Handlers opt in by overriding get_version_command() and get_minimum_supported_version() (both default to None = no check), so every existing handler is unchanged. - RestoreBunDependencies now just declares ['bun', '--version'] and (1, 2); its bespoke _parse_bun_version/_is_supported_bun_version are removed. - Version-parsing and gate tests moved to the base-class test module. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sca/base_restore_dependencies.py | 63 ++++++++++ .../sca/npm/restore_bun_dependencies.py | 47 ++------ .../sca/npm/test_restore_bun_dependencies.py | 68 ++++------- .../sca/test_base_restore_dependencies.py | 114 +++++++++++++++++- 4 files changed, 211 insertions(+), 81 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index 06431f72..ccfe2cdf 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,3 +1,4 @@ +import re from abc import ABC, abstractmethod from pathlib import Path from typing import Optional @@ -16,6 +17,19 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: return join_paths(get_file_dir(path), generated_file_name) +def parse_tool_version(raw_version: Optional[str]) -> Optional[tuple[int, ...]]: + """Parse a leading dotted numeric version (e.g. '1.2.3') into a tuple of ints. + + Returns None when the input is empty or has no recognizable leading version. + """ + if not raw_version: + return None + match = re.match(r'(\d+(?:\.\d+)*)', raw_version.strip()) + if not match: + return None + return tuple(int(part) for part in match.group(1).split('.')) + + def execute_commands( commands: list[list[str]], timeout: int, @@ -84,6 +98,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if not self.verify_restore_file_already_exist(restore_file_path): + if not self.is_supported_tool_version(): + return None + output = execute_commands( commands=self.get_commands(manifest_file_path), timeout=self.command_timeout, @@ -157,6 +174,52 @@ def get_any_restore_file_already_exist(self, document: Document, restore_file_pa def verify_restore_file_already_exist(restore_file_path: str) -> bool: return Path(restore_file_path).is_file() + def get_version_command(self) -> Optional[list[str]]: + """Command that prints the package manager's version (e.g. ['bun', '--version']). + + Override together with get_minimum_supported_version() to require a minimum tool + version before generating a lockfile. Defaults to None (no version check). + """ + return None + + def get_minimum_supported_version(self) -> Optional[tuple[int, ...]]: + """Minimum supported (major, minor, ...) version, or None for no requirement.""" + return None + + def is_supported_tool_version(self) -> bool: + """Verify the installed package manager meets get_minimum_supported_version(). + + Returns True when no minimum is declared (the default for all handlers), so existing + handlers are unaffected. Only runs the version command for handlers that opt in. + """ + minimum_version = self.get_minimum_supported_version() + version_command = self.get_version_command() + if minimum_version is None or version_command is None: + return True + + tool_name = version_command[0] + minimum_str = '.'.join(str(part) for part in minimum_version) + + raw_version = shell(command=version_command, timeout=self.command_timeout, silent_exc_info=True) + version = parse_tool_version(raw_version) + if version is None: + logger.warning( + 'Could not determine %s version; %s+ is required to restore dependencies, %s', + tool_name, + minimum_str, + {'raw_version': raw_version}, + ) + return False + if version < minimum_version: + logger.warning( + 'Unsupported %s version; %s+ is required to restore dependencies, %s', + tool_name, + minimum_str, + {'detected_version': '.'.join(str(part) for part in version)}, + ) + return False + return True + @abstractmethod def is_project(self, document: Document) -> bool: pass diff --git a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py index 2bf0d647..f19ac6b0 100644 --- a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py @@ -1,5 +1,4 @@ import json -import re from pathlib import Path from typing import Optional @@ -8,7 +7,6 @@ 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') @@ -39,16 +37,6 @@ def _indicates_bun(package_json_content: Optional[str]) -> bool: 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) @@ -63,27 +51,6 @@ def is_project(self, document: Document) -> bool: 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 @@ -96,11 +63,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 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 - + # Lockfile absent — generate it via `bun install`. The base class enforces the + # minimum Bun version (declared below) before running the command, otherwise an + # older Bun would emit a binary bun.lockb that we cannot parse. return super().try_restore_dependencies(document) def get_commands(self, manifest_file_path: str) -> list[list[str]]: @@ -111,3 +76,9 @@ def get_lock_file_name(self) -> str: def get_lock_file_names(self) -> list[str]: return [BUN_LOCK_FILE_NAME] + + def get_version_command(self) -> list[str]: + return BUN_VERSION_COMMAND + + def get_minimum_supported_version(self) -> tuple[int, ...]: + return MINIMUM_BUN_VERSION 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 index 17f189df..7d42f530 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py @@ -7,12 +7,13 @@ from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import ( BUN_LOCK_FILE_NAME, + MINIMUM_BUN_VERSION, RestoreBunDependencies, - _parse_bun_version, ) from cycode.cli.models import Document -_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies' +# The minimum-version gate lives in the base class; patch shell/execute_commands there. +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' @pytest.fixture @@ -95,70 +96,53 @@ def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependenc 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) +class TestBunVersionRequirement: + """Bun declares the minimum version; the gate itself is exercised in the base-class tests.""" - 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')) + def test_declares_version_command(self, restore_bun: RestoreBunDependencies) -> None: + assert restore_bun.get_version_command() == ['bun', '--version'] - 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_declares_minimum_version(self, restore_bun: RestoreBunDependencies) -> None: + assert restore_bun.get_minimum_supported_version() == MINIMUM_BUN_VERSION == (1, 2) def test_old_version_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + # No bun.lock on disk -> generate path -> base version gate rejects old Bun 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, + patch(f'{_BASE_MODULE}.shell', return_value='1.1.38'), + patch(f'{_BASE_MODULE}.execute_commands') as mock_execute, ): result = restore_bun.try_restore_dependencies(doc) - assert result is None - mock_super.assert_not_called() + assert result is None + mock_execute.assert_not_called() - def test_missing_bun_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + def test_supported_version_runs_bun_install(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')) + lock_path = tmp_path / BUN_LOCK_FILE_NAME + + def side_effect(*_args: object, **_kwargs: object) -> str: + lock_path.write_text('{"lockfileVersion": 1}\n') + return 'output' with ( - patch(f'{_BUN_MODULE}.shell', return_value=None), - patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect) as mock_execute, ): result = restore_bun.try_restore_dependencies(doc) - assert result is None - mock_super.assert_not_called() + assert result is not None + mock_execute.assert_called_once() 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: + with patch(f'{_BASE_MODULE}.shell') as mock_shell: result = restore_bun.try_restore_dependencies(doc) assert result is not None mock_shell.assert_not_called() @@ -184,7 +168,7 @@ def side_effect( return 'output' with ( - patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'), patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect), ): result = restore_bun.try_restore_dependencies(doc) diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py index b291a95f..466092cf 100644 --- a/tests/cli/files_collector/sca/test_base_restore_dependencies.py +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -11,7 +11,7 @@ import pytest import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, parse_tool_version from cycode.cli.models import Document _LOCK_FILE_NAME = 'generated.lock' @@ -37,6 +37,16 @@ def get_lock_file_names(self) -> list[str]: return [_LOCK_FILE_NAME] +class _VersionedRestoreHandler(_MinimalRestoreHandler): + """Concrete subclass that opts in to the minimum-version gate.""" + + def get_version_command(self) -> list[str]: + return ['faketool', '--version'] + + def get_minimum_supported_version(self) -> tuple[int, ...]: + return (1, 2) + + @pytest.fixture def mock_ctx(tmp_path: Path) -> typer.Context: ctx = MagicMock(spec=typer.Context) @@ -50,6 +60,11 @@ def handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler: return _MinimalRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) +@pytest.fixture +def versioned_handler(mock_ctx: typer.Context) -> _VersionedRestoreHandler: + return _VersionedRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) + + def _make_doc(tmp_path: Path) -> Document: manifest = tmp_path / _MANIFEST_FILE_NAME manifest.write_text('content') @@ -146,3 +161,100 @@ def test_generated_file_content_available_in_document_after_deletion( assert not lock_path.exists() assert result is not None assert result.content == expected + + +class TestParseToolVersion: + def test_parses_full_semver(self) -> None: + assert parse_tool_version('1.2.3') == (1, 2, 3) + + def test_parses_major_minor(self) -> None: + assert parse_tool_version('1.2') == (1, 2) + + def test_parses_with_surrounding_whitespace(self) -> None: + assert parse_tool_version(' 1.2.0\n') == (1, 2, 0) + + def test_parses_leading_version_with_suffix(self) -> None: + assert parse_tool_version('1.2.3-canary.1') == (1, 2, 3) + + def test_none_input_returns_none(self) -> None: + assert parse_tool_version(None) is None + + def test_empty_input_returns_none(self) -> None: + assert parse_tool_version('') is None + + def test_non_version_string_returns_none(self) -> None: + assert parse_tool_version('not-a-version') is None + + +class TestVersionGateDefault: + """Handlers that do not opt in must never run a version check.""" + + def test_no_minimum_declared_is_supported(self, handler: _MinimalRestoreHandler) -> None: + with patch(f'{_BASE_MODULE}.shell') as mock_shell: + assert handler.is_supported_tool_version() is True + mock_shell.assert_not_called() + + def test_restore_runs_without_version_check(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + with ( + patch(f'{_BASE_MODULE}.shell') as mock_shell, + patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)), + ): + result = handler.try_restore_dependencies(doc) + assert result is not None + mock_shell.assert_not_called() + + +class TestVersionGateOptIn: + def test_supported_version_passes(self, versioned_handler: _VersionedRestoreHandler) -> None: + with patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'): + assert versioned_handler.is_supported_tool_version() is True + + def test_equal_to_minimum_passes(self, versioned_handler: _VersionedRestoreHandler) -> None: + with patch(f'{_BASE_MODULE}.shell', return_value='1.2.0'): + assert versioned_handler.is_supported_tool_version() is True + + def test_old_version_fails(self, versioned_handler: _VersionedRestoreHandler) -> None: + with patch(f'{_BASE_MODULE}.shell', return_value='1.1.38'): + assert versioned_handler.is_supported_tool_version() is False + + def test_missing_tool_fails(self, versioned_handler: _VersionedRestoreHandler) -> None: + with patch(f'{_BASE_MODULE}.shell', return_value=None): + assert versioned_handler.is_supported_tool_version() is False + + def test_old_version_skips_restore_without_running_command( + self, versioned_handler: _VersionedRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + with ( + patch(f'{_BASE_MODULE}.shell', return_value='1.1.38'), + patch(f'{_BASE_MODULE}.execute_commands') as mock_execute, + ): + result = versioned_handler.try_restore_dependencies(doc) + assert result is None + mock_execute.assert_not_called() + + def test_supported_version_runs_restore(self, versioned_handler: _VersionedRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + with ( + patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)) as mock_execute, + ): + result = versioned_handler.try_restore_dependencies(doc) + assert result is not None + assert result.content == _LOCK_CONTENT + mock_execute.assert_called_once() + + def test_preexisting_lockfile_skips_version_check( + self, versioned_handler: _VersionedRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + lock_path.write_text('pre-existing content') + with patch(f'{_BASE_MODULE}.shell') as mock_shell: + result = versioned_handler.try_restore_dependencies(doc) + assert result is not None + assert result.content == 'pre-existing content' + mock_shell.assert_not_called() From 7a664a7ef3c92f9ca1c906716275fa030b1b9bb8 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Sun, 28 Jun 2026 17:03:55 +0300 Subject: [PATCH 3/6] CM-67195: Tolerate leading 'v' in parse_tool_version Some tools print versions with a leading 'v' (e.g. `node --version` -> v20.1.0). Since parse_tool_version is now a shared base-class helper, make it strip an optional leading 'v' so a future opt-in handler isn't misparsed as "could not determine version". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/files_collector/sca/base_restore_dependencies.py | 7 ++++--- .../files_collector/sca/test_base_restore_dependencies.py | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index ccfe2cdf..c31b4199 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -18,13 +18,14 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: def parse_tool_version(raw_version: Optional[str]) -> Optional[tuple[int, ...]]: - """Parse a leading dotted numeric version (e.g. '1.2.3') into a tuple of ints. + """Parse a leading dotted numeric version (e.g. '1.2.3' or 'v20.1.0') into a tuple of ints. - Returns None when the input is empty or has no recognizable leading version. + Tolerates an optional leading 'v' (some tools print e.g. `v20.1.0`). Returns None when the + input is empty or has no recognizable leading version. """ if not raw_version: return None - match = re.match(r'(\d+(?:\.\d+)*)', raw_version.strip()) + match = re.match(r'v?(\d+(?:\.\d+)*)', raw_version.strip()) if not match: return None return tuple(int(part) for part in match.group(1).split('.')) diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py index 466092cf..7bc7899c 100644 --- a/tests/cli/files_collector/sca/test_base_restore_dependencies.py +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -176,6 +176,9 @@ def test_parses_with_surrounding_whitespace(self) -> None: def test_parses_leading_version_with_suffix(self) -> None: assert parse_tool_version('1.2.3-canary.1') == (1, 2, 3) + def test_parses_leading_v_prefix(self) -> None: + assert parse_tool_version('v20.1.0') == (20, 1, 0) + def test_none_input_returns_none(self) -> None: assert parse_tool_version(None) is None From f4ad5420ed4791d84a1112936259be6d7c88161b Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Mon, 29 Jun 2026 10:50:30 +0300 Subject: [PATCH 4/6] CM-67195: Move version gate back into the Bun handler (review feedback) Per review: the minimum-version check is only used by Bun, so keep it co-located in the Bun handler rather than as generic machinery on the base class. - Revert BaseRestoreDependencies to its original form (no version hooks, no parse_tool_version). - Restore _parse_bun_version + _is_supported_bun_version in restore_bun_dependencies.py; the generate path verifies Bun >=1.2 before running `bun install`. - Move the version-parse/gate tests back to the Bun test module. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sca/base_restore_dependencies.py | 64 ---------- .../sca/npm/restore_bun_dependencies.py | 47 +++++-- .../sca/npm/test_restore_bun_dependencies.py | 68 ++++++---- .../sca/test_base_restore_dependencies.py | 117 +----------------- 4 files changed, 81 insertions(+), 215 deletions(-) diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index c31b4199..06431f72 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -1,4 +1,3 @@ -import re from abc import ABC, abstractmethod from pathlib import Path from typing import Optional @@ -17,20 +16,6 @@ def build_dep_tree_path(path: str, generated_file_name: str) -> str: return join_paths(get_file_dir(path), generated_file_name) -def parse_tool_version(raw_version: Optional[str]) -> Optional[tuple[int, ...]]: - """Parse a leading dotted numeric version (e.g. '1.2.3' or 'v20.1.0') into a tuple of ints. - - Tolerates an optional leading 'v' (some tools print e.g. `v20.1.0`). Returns None when the - input is empty or has no recognizable leading version. - """ - if not raw_version: - return None - match = re.match(r'v?(\d+(?:\.\d+)*)', raw_version.strip()) - if not match: - return None - return tuple(int(part) for part in match.group(1).split('.')) - - def execute_commands( commands: list[list[str]], timeout: int, @@ -99,9 +84,6 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if not self.verify_restore_file_already_exist(restore_file_path): - if not self.is_supported_tool_version(): - return None - output = execute_commands( commands=self.get_commands(manifest_file_path), timeout=self.command_timeout, @@ -175,52 +157,6 @@ def get_any_restore_file_already_exist(self, document: Document, restore_file_pa def verify_restore_file_already_exist(restore_file_path: str) -> bool: return Path(restore_file_path).is_file() - def get_version_command(self) -> Optional[list[str]]: - """Command that prints the package manager's version (e.g. ['bun', '--version']). - - Override together with get_minimum_supported_version() to require a minimum tool - version before generating a lockfile. Defaults to None (no version check). - """ - return None - - def get_minimum_supported_version(self) -> Optional[tuple[int, ...]]: - """Minimum supported (major, minor, ...) version, or None for no requirement.""" - return None - - def is_supported_tool_version(self) -> bool: - """Verify the installed package manager meets get_minimum_supported_version(). - - Returns True when no minimum is declared (the default for all handlers), so existing - handlers are unaffected. Only runs the version command for handlers that opt in. - """ - minimum_version = self.get_minimum_supported_version() - version_command = self.get_version_command() - if minimum_version is None or version_command is None: - return True - - tool_name = version_command[0] - minimum_str = '.'.join(str(part) for part in minimum_version) - - raw_version = shell(command=version_command, timeout=self.command_timeout, silent_exc_info=True) - version = parse_tool_version(raw_version) - if version is None: - logger.warning( - 'Could not determine %s version; %s+ is required to restore dependencies, %s', - tool_name, - minimum_str, - {'raw_version': raw_version}, - ) - return False - if version < minimum_version: - logger.warning( - 'Unsupported %s version; %s+ is required to restore dependencies, %s', - tool_name, - minimum_str, - {'detected_version': '.'.join(str(part) for part in version)}, - ) - return False - return True - @abstractmethod def is_project(self, document: Document) -> bool: pass diff --git a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py index f19ac6b0..2bf0d647 100644 --- a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py @@ -1,4 +1,5 @@ import json +import re from pathlib import Path from typing import Optional @@ -7,6 +8,7 @@ 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') @@ -37,6 +39,16 @@ def _indicates_bun(package_json_content: Optional[str]) -> bool: 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) @@ -51,6 +63,27 @@ def is_project(self, document: Document) -> bool: 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 @@ -63,9 +96,11 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: logger.debug('Using existing bun.lock, %s', {'path': str(lockfile_path)}) return Document(relative_path, content, self.is_git_diff) - # Lockfile absent — generate it via `bun install`. The base class enforces the - # minimum Bun version (declared below) before running the command, otherwise an - # older Bun would emit a binary bun.lockb that we cannot parse. + # 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]]: @@ -76,9 +111,3 @@ def get_lock_file_name(self) -> str: def get_lock_file_names(self) -> list[str]: return [BUN_LOCK_FILE_NAME] - - def get_version_command(self) -> list[str]: - return BUN_VERSION_COMMAND - - def get_minimum_supported_version(self) -> tuple[int, ...]: - return MINIMUM_BUN_VERSION 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 index 7d42f530..17f189df 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py @@ -7,13 +7,12 @@ from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import ( BUN_LOCK_FILE_NAME, - MINIMUM_BUN_VERSION, RestoreBunDependencies, + _parse_bun_version, ) from cycode.cli.models import Document -# The minimum-version gate lives in the base class; patch shell/execute_commands there. -_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies' @pytest.fixture @@ -96,53 +95,70 @@ def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependenc assert commands == [['bun', 'install', '--ignore-scripts']] -class TestBunVersionRequirement: - """Bun declares the minimum version; the gate itself is exercised in the base-class tests.""" +_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_declares_version_command(self, restore_bun: RestoreBunDependencies) -> None: - assert restore_bun.get_version_command() == ['bun', '--version'] + 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')) - def test_declares_minimum_version(self, restore_bun: RestoreBunDependencies) -> None: - assert restore_bun.get_minimum_supported_version() == MINIMUM_BUN_VERSION == (1, 2) + 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: - # No bun.lock on disk -> generate path -> base version gate rejects old Bun 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'{_BASE_MODULE}.shell', return_value='1.1.38'), - patch(f'{_BASE_MODULE}.execute_commands') as mock_execute, + 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_execute.assert_not_called() + assert result is None + mock_super.assert_not_called() - def test_supported_version_runs_bun_install(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + 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')) - lock_path = tmp_path / BUN_LOCK_FILE_NAME - - def side_effect(*_args: object, **_kwargs: object) -> str: - lock_path.write_text('{"lockfileVersion": 1}\n') - return 'output' with ( - patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'), - patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect) as mock_execute, + 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 not None - mock_execute.assert_called_once() + 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'{_BASE_MODULE}.shell') as mock_shell: + 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() @@ -168,7 +184,7 @@ def side_effect( return 'output' with ( - patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'), + 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) diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py index 7bc7899c..b291a95f 100644 --- a/tests/cli/files_collector/sca/test_base_restore_dependencies.py +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -11,7 +11,7 @@ import pytest import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, parse_tool_version +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies from cycode.cli.models import Document _LOCK_FILE_NAME = 'generated.lock' @@ -37,16 +37,6 @@ def get_lock_file_names(self) -> list[str]: return [_LOCK_FILE_NAME] -class _VersionedRestoreHandler(_MinimalRestoreHandler): - """Concrete subclass that opts in to the minimum-version gate.""" - - def get_version_command(self) -> list[str]: - return ['faketool', '--version'] - - def get_minimum_supported_version(self) -> tuple[int, ...]: - return (1, 2) - - @pytest.fixture def mock_ctx(tmp_path: Path) -> typer.Context: ctx = MagicMock(spec=typer.Context) @@ -60,11 +50,6 @@ def handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler: return _MinimalRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) -@pytest.fixture -def versioned_handler(mock_ctx: typer.Context) -> _VersionedRestoreHandler: - return _VersionedRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) - - def _make_doc(tmp_path: Path) -> Document: manifest = tmp_path / _MANIFEST_FILE_NAME manifest.write_text('content') @@ -161,103 +146,3 @@ def test_generated_file_content_available_in_document_after_deletion( assert not lock_path.exists() assert result is not None assert result.content == expected - - -class TestParseToolVersion: - def test_parses_full_semver(self) -> None: - assert parse_tool_version('1.2.3') == (1, 2, 3) - - def test_parses_major_minor(self) -> None: - assert parse_tool_version('1.2') == (1, 2) - - def test_parses_with_surrounding_whitespace(self) -> None: - assert parse_tool_version(' 1.2.0\n') == (1, 2, 0) - - def test_parses_leading_version_with_suffix(self) -> None: - assert parse_tool_version('1.2.3-canary.1') == (1, 2, 3) - - def test_parses_leading_v_prefix(self) -> None: - assert parse_tool_version('v20.1.0') == (20, 1, 0) - - def test_none_input_returns_none(self) -> None: - assert parse_tool_version(None) is None - - def test_empty_input_returns_none(self) -> None: - assert parse_tool_version('') is None - - def test_non_version_string_returns_none(self) -> None: - assert parse_tool_version('not-a-version') is None - - -class TestVersionGateDefault: - """Handlers that do not opt in must never run a version check.""" - - def test_no_minimum_declared_is_supported(self, handler: _MinimalRestoreHandler) -> None: - with patch(f'{_BASE_MODULE}.shell') as mock_shell: - assert handler.is_supported_tool_version() is True - mock_shell.assert_not_called() - - def test_restore_runs_without_version_check(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: - doc = _make_doc(tmp_path) - lock_path = tmp_path / _LOCK_FILE_NAME - with ( - patch(f'{_BASE_MODULE}.shell') as mock_shell, - patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)), - ): - result = handler.try_restore_dependencies(doc) - assert result is not None - mock_shell.assert_not_called() - - -class TestVersionGateOptIn: - def test_supported_version_passes(self, versioned_handler: _VersionedRestoreHandler) -> None: - with patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'): - assert versioned_handler.is_supported_tool_version() is True - - def test_equal_to_minimum_passes(self, versioned_handler: _VersionedRestoreHandler) -> None: - with patch(f'{_BASE_MODULE}.shell', return_value='1.2.0'): - assert versioned_handler.is_supported_tool_version() is True - - def test_old_version_fails(self, versioned_handler: _VersionedRestoreHandler) -> None: - with patch(f'{_BASE_MODULE}.shell', return_value='1.1.38'): - assert versioned_handler.is_supported_tool_version() is False - - def test_missing_tool_fails(self, versioned_handler: _VersionedRestoreHandler) -> None: - with patch(f'{_BASE_MODULE}.shell', return_value=None): - assert versioned_handler.is_supported_tool_version() is False - - def test_old_version_skips_restore_without_running_command( - self, versioned_handler: _VersionedRestoreHandler, tmp_path: Path - ) -> None: - doc = _make_doc(tmp_path) - with ( - patch(f'{_BASE_MODULE}.shell', return_value='1.1.38'), - patch(f'{_BASE_MODULE}.execute_commands') as mock_execute, - ): - result = versioned_handler.try_restore_dependencies(doc) - assert result is None - mock_execute.assert_not_called() - - def test_supported_version_runs_restore(self, versioned_handler: _VersionedRestoreHandler, tmp_path: Path) -> None: - doc = _make_doc(tmp_path) - lock_path = tmp_path / _LOCK_FILE_NAME - with ( - patch(f'{_BASE_MODULE}.shell', return_value='1.2.5'), - patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)) as mock_execute, - ): - result = versioned_handler.try_restore_dependencies(doc) - assert result is not None - assert result.content == _LOCK_CONTENT - mock_execute.assert_called_once() - - def test_preexisting_lockfile_skips_version_check( - self, versioned_handler: _VersionedRestoreHandler, tmp_path: Path - ) -> None: - doc = _make_doc(tmp_path) - lock_path = tmp_path / _LOCK_FILE_NAME - lock_path.write_text('pre-existing content') - with patch(f'{_BASE_MODULE}.shell') as mock_shell: - result = versioned_handler.try_restore_dependencies(doc) - assert result is not None - assert result.content == 'pre-existing content' - mock_shell.assert_not_called() From f2e883f01f329d562331e00fdede7ee66dba88ea Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Mon, 29 Jun 2026 15:28:49 +0300 Subject: [PATCH 5/6] Update restore_npm_dependencies.py --- cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7d6cb5c2..1135cad4 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -31,7 +31,7 @@ def is_project(self, document: Document) -> bool: 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. Tracked on CM-67195. + packageManager/engines signal. """ if Path(document.path).name != NPM_MANIFEST_FILE_NAME: return False From a1b47e01daf284d2927bb78b9727ea9fdd34602b Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Mon, 29 Jun 2026 15:56:13 +0300 Subject: [PATCH 6/6] CM-67195: Fix ruff W291 trailing whitespace in restore_npm_dependencies Co-Authored-By: Claude Opus 4.8 (1M context) --- cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1135cad4..9416f58c 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -31,7 +31,7 @@ def is_project(self, document: Document) -> bool: 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. + packageManager/engines signal. """ if Path(document.path).name != NPM_MANIFEST_FILE_NAME: return False