Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
'deno.lock',
'deno.json',
'pnpm-lock.yaml',
'bun.lock',
'npm-shrinkwrap.json',
'packages.config',
'project.assets.json',
Expand Down Expand Up @@ -165,6 +166,7 @@
'npm-shrinkwrap.json',
'.npmrc',
'pnpm-lock.yaml',
'bun.lock',
'deno.lock',
'deno.json',
],
Expand Down
113 changes: 113 additions & 0 deletions cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py
Original file line number Diff line number Diff line change
@@ -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]
11 changes: 10 additions & 1 deletion cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion cycode/cli/files_collector/sca/sca_file_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
205 changes: 205 additions & 0 deletions tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading