Skip to content

Commit 9e09d23

Browse files
authored
Merge branch 'main' into dd/package-cooldown
2 parents 1d000ec + 641747d commit 9e09d23

6 files changed

Lines changed: 342 additions & 2 deletions

File tree

cycode/cli/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
'deno.lock',
103103
'deno.json',
104104
'pnpm-lock.yaml',
105+
'bun.lock',
105106
'npm-shrinkwrap.json',
106107
'packages.config',
107108
'project.assets.json',
@@ -165,6 +166,7 @@
165166
'npm-shrinkwrap.json',
166167
'.npmrc',
167168
'pnpm-lock.yaml',
169+
'bun.lock',
168170
'deno.lock',
169171
'deno.json',
170172
],
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import json
2+
import re
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
import typer
7+
8+
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
9+
from cycode.cli.models import Document
10+
from cycode.cli.utils.path_utils import get_file_content
11+
from cycode.cli.utils.shell_executor import shell
12+
from cycode.logger import get_logger
13+
14+
logger = get_logger('Bun Restore Dependencies')
15+
16+
BUN_MANIFEST_FILE_NAME = 'package.json'
17+
BUN_LOCK_FILE_NAME = 'bun.lock'
18+
19+
# Only Bun >=1.2 produces the text-based `bun.lock` lockfile that we parse.
20+
# Older Bun versions emit a binary `bun.lockb`, which is not supported.
21+
MINIMUM_BUN_VERSION = (1, 2)
22+
BUN_VERSION_COMMAND = ['bun', '--version']
23+
24+
25+
def _indicates_bun(package_json_content: Optional[str]) -> bool:
26+
"""Return True if package.json content signals that this project uses Bun."""
27+
if not package_json_content:
28+
return False
29+
try:
30+
data = json.loads(package_json_content)
31+
except (json.JSONDecodeError, ValueError):
32+
return False
33+
34+
package_manager = data.get('packageManager', '')
35+
if isinstance(package_manager, str) and package_manager.startswith('bun'):
36+
return True
37+
38+
engines = data.get('engines', {})
39+
return isinstance(engines, dict) and 'bun' in engines
40+
41+
42+
def _parse_bun_version(raw_version: Optional[str]) -> Optional[tuple[int, int]]:
43+
"""Parse the (major, minor) version from `bun --version` output (e.g. '1.2.3')."""
44+
if not raw_version:
45+
return None
46+
match = re.match(r'(\d+)\.(\d+)', raw_version.strip())
47+
if not match:
48+
return None
49+
return int(match.group(1)), int(match.group(2))
50+
51+
52+
class RestoreBunDependencies(BaseRestoreDependencies):
53+
def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None:
54+
super().__init__(ctx, is_git_diff, command_timeout)
55+
56+
def is_project(self, document: Document) -> bool:
57+
if Path(document.path).name != BUN_MANIFEST_FILE_NAME:
58+
return False
59+
60+
manifest_dir = self.get_manifest_dir(document)
61+
if manifest_dir and (Path(manifest_dir) / BUN_LOCK_FILE_NAME).is_file():
62+
return True
63+
64+
return _indicates_bun(document.content)
65+
66+
def _is_supported_bun_version(self) -> bool:
67+
"""Verify that the installed Bun is >=1.2, which is required to generate a text bun.lock."""
68+
raw_version = shell(command=BUN_VERSION_COMMAND, timeout=self.command_timeout, silent_exc_info=True)
69+
version = _parse_bun_version(raw_version)
70+
minimum = '.'.join(str(part) for part in MINIMUM_BUN_VERSION)
71+
if version is None:
72+
logger.warning(
73+
'Could not determine Bun version; Bun %s+ is required to restore Bun dependencies, %s',
74+
minimum,
75+
{'raw_version': raw_version},
76+
)
77+
return False
78+
if version < MINIMUM_BUN_VERSION:
79+
logger.warning(
80+
'Unsupported Bun version; Bun %s+ is required to restore Bun dependencies, %s',
81+
minimum,
82+
{'detected_version': '.'.join(str(part) for part in version)},
83+
)
84+
return False
85+
return True
86+
87+
def try_restore_dependencies(self, document: Document) -> Optional[Document]:
88+
manifest_dir = self.get_manifest_dir(document)
89+
lockfile_path = Path(manifest_dir) / BUN_LOCK_FILE_NAME if manifest_dir else None
90+
91+
if lockfile_path and lockfile_path.is_file():
92+
# Lockfile already exists — read it directly without running bun.
93+
# A text bun.lock only exists when generated by Bun >=1.2, so no version check is needed here.
94+
content = get_file_content(str(lockfile_path))
95+
relative_path = build_dep_tree_path(document.path, BUN_LOCK_FILE_NAME)
96+
logger.debug('Using existing bun.lock, %s', {'path': str(lockfile_path)})
97+
return Document(relative_path, content, self.is_git_diff)
98+
99+
# Lockfile absent — must generate it via `bun install`. This requires Bun >=1.2,
100+
# otherwise an older Bun would emit a binary bun.lockb that we cannot parse.
101+
if not self._is_supported_bun_version():
102+
return None
103+
104+
return super().try_restore_dependencies(document)
105+
106+
def get_commands(self, manifest_file_path: str) -> list[list[str]]:
107+
return [['bun', 'install', '--ignore-scripts']]
108+
109+
def get_lock_file_name(self) -> str:
110+
return BUN_LOCK_FILE_NAME
111+
112+
def get_lock_file_names(self) -> list[str]:
113+
return [BUN_LOCK_FILE_NAME]

cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
NPM_MANIFEST_FILE_NAME = 'package.json'
1212
NPM_LOCK_FILE_NAME = 'package-lock.json'
1313
# These lockfiles indicate another package manager owns the project — NPM should not run
14-
_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock')
14+
_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock', 'bun.lock')
1515

1616

1717
class RestoreNpmDependencies(BaseRestoreDependencies):
@@ -23,6 +23,15 @@ def is_project(self, document: Document) -> bool:
2323
2424
Yarn and pnpm projects are handled by their dedicated handlers, which run before
2525
this one in the handler list. This handler is the npm fallback.
26+
27+
NOTE: this guard only excludes a project when an alternative lockfile is *physically
28+
present on disk*. It does not inspect the `packageManager`/`engines` signal in
29+
package.json. So a project that declares e.g. `packageManager: "bun@..."` (or pnpm)
30+
but has no lockfile yet is claimed by BOTH the dedicated handler and this npm fallback,
31+
and both restores run. This is pre-existing behavior shared by pnpm/yarn/bun and is
32+
accepted for now (a real Bun/pnpm project ships a lockfile, so npm correctly skips).
33+
If this ever needs tightening, also skip here when package.json declares a non-npm
34+
packageManager/engines signal.
2635
"""
2736
if Path(document.path).name != NPM_MANIFEST_FILE_NAME:
2837
return False

cycode/cli/files_collector/sca/sca_file_collector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from cycode.cli.files_collector.sca.go.restore_go_dependencies import RestoreGoDependencies
1111
from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import RestoreGradleDependencies
1212
from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import RestoreMavenDependencies
13+
from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import RestoreBunDependencies
1314
from cycode.cli.files_collector.sca.npm.restore_deno_dependencies import RestoreDenoDependencies
1415
from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import RestoreNpmDependencies
1516
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
157158
RestoreNugetDependencies(ctx, is_git_diff, build_dep_tree_timeout),
158159
RestoreYarnDependencies(ctx, is_git_diff, build_dep_tree_timeout),
159160
RestorePnpmDependencies(ctx, is_git_diff, build_dep_tree_timeout),
161+
RestoreBunDependencies(ctx, is_git_diff, build_dep_tree_timeout),
160162
RestoreDenoDependencies(ctx, is_git_diff, build_dep_tree_timeout),
161-
RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn & Pnpm for fallback
163+
RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn, Pnpm & Bun for fallback
162164
RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout),
163165
RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml
164166
RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout),
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
from pathlib import Path
2+
from typing import Optional
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
import typer
7+
8+
from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import (
9+
BUN_LOCK_FILE_NAME,
10+
RestoreBunDependencies,
11+
_parse_bun_version,
12+
)
13+
from cycode.cli.models import Document
14+
15+
_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies'
16+
17+
18+
@pytest.fixture
19+
def mock_ctx(tmp_path: Path) -> typer.Context:
20+
ctx = MagicMock(spec=typer.Context)
21+
ctx.obj = {'monitor': False}
22+
ctx.params = {'path': str(tmp_path)}
23+
return ctx
24+
25+
26+
@pytest.fixture
27+
def restore_bun(mock_ctx: typer.Context) -> RestoreBunDependencies:
28+
return RestoreBunDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
29+
30+
31+
class TestIsProject:
32+
def test_package_json_with_bun_lock_matches(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
33+
(tmp_path / 'package.json').write_text('{"name": "test"}')
34+
(tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n')
35+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
36+
assert restore_bun.is_project(doc) is True
37+
38+
def test_package_json_with_package_manager_bun_matches(self, restore_bun: RestoreBunDependencies) -> None:
39+
content = '{"name": "test", "packageManager": "bun@1.1.0"}'
40+
doc = Document('package.json', content)
41+
assert restore_bun.is_project(doc) is True
42+
43+
def test_package_json_with_engines_bun_matches(self, restore_bun: RestoreBunDependencies) -> None:
44+
content = '{"name": "test", "engines": {"bun": ">=1"}}'
45+
doc = Document('package.json', content)
46+
assert restore_bun.is_project(doc) is True
47+
48+
def test_package_json_with_no_bun_signal_does_not_match(
49+
self, restore_bun: RestoreBunDependencies, tmp_path: Path
50+
) -> None:
51+
(tmp_path / 'package.json').write_text('{"name": "test"}')
52+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
53+
assert restore_bun.is_project(doc) is False
54+
55+
def test_package_json_with_yarn_lock_does_not_match(
56+
self, restore_bun: RestoreBunDependencies, tmp_path: Path
57+
) -> None:
58+
(tmp_path / 'package.json').write_text('{"name": "test"}')
59+
(tmp_path / 'yarn.lock').write_text('# yarn lockfile v1\n')
60+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
61+
assert restore_bun.is_project(doc) is False
62+
63+
def test_tsconfig_json_does_not_match(self, restore_bun: RestoreBunDependencies) -> None:
64+
doc = Document('tsconfig.json', '{"compilerOptions": {}}')
65+
assert restore_bun.is_project(doc) is False
66+
67+
def test_package_manager_yarn_does_not_match(self, restore_bun: RestoreBunDependencies) -> None:
68+
content = '{"name": "test", "packageManager": "yarn@4.0.0"}'
69+
doc = Document('package.json', content)
70+
assert restore_bun.is_project(doc) is False
71+
72+
def test_invalid_json_content_does_not_match(self, restore_bun: RestoreBunDependencies) -> None:
73+
doc = Document('package.json', 'not valid json')
74+
assert restore_bun.is_project(doc) is False
75+
76+
77+
class TestTryRestoreDependencies:
78+
def test_existing_bun_lock_returned_directly(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
79+
bun_lock_content = '{"lockfileVersion": 1, "packages": {"package": ["package@1.0.0", "", {}, ""]}}\n'
80+
(tmp_path / 'package.json').write_text('{"name": "test"}')
81+
(tmp_path / 'bun.lock').write_text(bun_lock_content)
82+
83+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
84+
result = restore_bun.try_restore_dependencies(doc)
85+
86+
assert result is not None
87+
assert BUN_LOCK_FILE_NAME in result.path
88+
assert result.content == bun_lock_content
89+
90+
def test_get_lock_file_name(self, restore_bun: RestoreBunDependencies) -> None:
91+
assert restore_bun.get_lock_file_name() == BUN_LOCK_FILE_NAME
92+
93+
def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependencies) -> None:
94+
commands = restore_bun.get_commands('/path/to/package.json')
95+
assert commands == [['bun', 'install', '--ignore-scripts']]
96+
97+
98+
_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
99+
100+
101+
class TestParseBunVersion:
102+
def test_parses_full_semver(self) -> None:
103+
assert _parse_bun_version('1.2.3') == (1, 2)
104+
105+
def test_parses_with_surrounding_whitespace(self) -> None:
106+
assert _parse_bun_version(' 1.2.0\n') == (1, 2)
107+
108+
def test_none_input_returns_none(self) -> None:
109+
assert _parse_bun_version(None) is None
110+
111+
def test_non_version_string_returns_none(self) -> None:
112+
assert _parse_bun_version('not-a-version') is None
113+
114+
115+
class TestBunVersionGate:
116+
def test_supported_version_proceeds_to_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
117+
content = '{"name": "test", "packageManager": "bun@1.2.0"}'
118+
(tmp_path / 'package.json').write_text(content)
119+
doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json'))
120+
121+
with (
122+
patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'),
123+
patch.object(
124+
restore_bun.__class__.__bases__[0], 'try_restore_dependencies', return_value=None
125+
) as mock_super,
126+
):
127+
restore_bun.try_restore_dependencies(doc)
128+
mock_super.assert_called_once_with(doc)
129+
130+
def test_old_version_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
131+
content = '{"name": "test", "packageManager": "bun@1.1.0"}'
132+
(tmp_path / 'package.json').write_text(content)
133+
doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json'))
134+
135+
with (
136+
patch(f'{_BUN_MODULE}.shell', return_value='1.1.38'),
137+
patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super,
138+
):
139+
result = restore_bun.try_restore_dependencies(doc)
140+
assert result is None
141+
mock_super.assert_not_called()
142+
143+
def test_missing_bun_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
144+
content = '{"name": "test", "packageManager": "bun@1.2.0"}'
145+
(tmp_path / 'package.json').write_text(content)
146+
doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json'))
147+
148+
with (
149+
patch(f'{_BUN_MODULE}.shell', return_value=None),
150+
patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super,
151+
):
152+
result = restore_bun.try_restore_dependencies(doc)
153+
assert result is None
154+
mock_super.assert_not_called()
155+
156+
def test_existing_lockfile_skips_version_check(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
157+
(tmp_path / 'package.json').write_text('{"name": "test"}')
158+
(tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n')
159+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
160+
161+
with patch(f'{_BUN_MODULE}.shell') as mock_shell:
162+
result = restore_bun.try_restore_dependencies(doc)
163+
assert result is not None
164+
mock_shell.assert_not_called()
165+
166+
167+
class TestCleanup:
168+
def test_generated_lockfile_is_deleted_after_restore(
169+
self, restore_bun: RestoreBunDependencies, tmp_path: Path
170+
) -> None:
171+
# bun: no pre-existing bun.lock but package.json indicates bun (supported version installed)
172+
content = '{"name": "test", "packageManager": "bun@1.2.0"}'
173+
(tmp_path / 'package.json').write_text(content)
174+
doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json'))
175+
lock_path = tmp_path / BUN_LOCK_FILE_NAME
176+
177+
def side_effect(
178+
commands: list,
179+
timeout: int,
180+
output_file_path: Optional[str] = None,
181+
working_directory: Optional[str] = None,
182+
) -> str:
183+
lock_path.write_text('{"lockfileVersion": 1}\n')
184+
return 'output'
185+
186+
with (
187+
patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'),
188+
patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect),
189+
):
190+
result = restore_bun.try_restore_dependencies(doc)
191+
192+
assert result is not None
193+
assert not lock_path.exists(), f'{BUN_LOCK_FILE_NAME} must be deleted after restore'
194+
195+
def test_preexisting_lockfile_is_not_deleted(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None:
196+
lock_content = '{"lockfileVersion": 1, "packages": {"pkg": ["pkg@1.0.0", "", {}, ""]}}\n'
197+
(tmp_path / 'package.json').write_text('{"name": "test"}')
198+
lock_path = tmp_path / BUN_LOCK_FILE_NAME
199+
lock_path.write_text(lock_content)
200+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
201+
202+
result = restore_bun.try_restore_dependencies(doc)
203+
204+
assert result is not None
205+
assert lock_path.exists(), f'Pre-existing {BUN_LOCK_FILE_NAME} must not be deleted'

tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ def test_package_json_with_pnpm_lock_does_not_match(
4949
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
5050
assert restore_npm.is_project(doc) is False
5151

52+
def test_package_json_with_bun_lock_does_not_match(
53+
self, restore_npm: RestoreNpmDependencies, tmp_path: Path
54+
) -> None:
55+
"""Bun projects are handled by RestoreBunDependencies — NPM should not claim them."""
56+
(tmp_path / 'package.json').write_text('{"name": "test"}')
57+
(tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n')
58+
doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
59+
assert restore_npm.is_project(doc) is False
60+
5261
def test_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None:
5362
doc = Document('tsconfig.json', '{}')
5463
assert restore_npm.is_project(doc) is False

0 commit comments

Comments
 (0)