diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py
index ac391727..06431f72 100644
--- a/cycode/cli/files_collector/sca/base_restore_dependencies.py
+++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py
@@ -92,7 +92,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]:
)
if output is None: # one of the commands failed
return None
+ file_was_generated = True
else:
+ file_was_generated = False
logger.debug(
'Lock file already exists, skipping restore commands, %s',
{'restore_file_path': restore_file_path},
@@ -107,6 +109,14 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]:
'content_empty': not restore_file_content,
},
)
+
+ if file_was_generated:
+ try:
+ Path(restore_file_path).unlink(missing_ok=True)
+ logger.debug('Cleaned up generated restore file, %s', {'restore_file_path': restore_file_path})
+ except Exception as e:
+ logger.debug('Failed to clean up generated restore file', exc_info=e)
+
return Document(relative_restore_file_path, restore_file_content, self.is_git_diff)
def get_manifest_dir(self, document: Document) -> Optional[str]:
diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py
index 34499bdf..740ccca9 100644
--- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py
+++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py
@@ -1,4 +1,5 @@
from os import path
+from pathlib import Path
from typing import Optional
import typer
@@ -9,7 +10,10 @@
execute_commands,
)
from cycode.cli.models import Document
-from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths
+from cycode.cli.utils.path_utils import get_file_content, join_paths
+from cycode.logger import get_logger
+
+logger = get_logger('Maven Restore Dependencies')
BUILD_MAVEN_FILE_NAME = 'pom.xml'
MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json'
@@ -42,15 +46,8 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]:
if document.content is None:
return self.restore_from_secondary_command(document, manifest_file_path)
- restore_dependencies_document = super().try_restore_dependencies(document)
- if restore_dependencies_document is None:
- return None
-
- restore_dependencies_document.content = get_file_content(
- join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name())
- )
-
- return restore_dependencies_document
+ # super() reads the content and cleans up any generated file; no re-read needed
+ return super().try_restore_dependencies(document)
def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]:
restore_content = execute_commands(
@@ -62,11 +59,17 @@ def restore_from_secondary_command(self, document: Document, manifest_file_path:
return None
restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME)
+ content = get_file_content(restore_file_path)
+
+ try:
+ Path(restore_file_path).unlink(missing_ok=True)
+ except Exception as e:
+ logger.debug('Failed to clean up generated maven dep tree file', exc_info=e)
+
return Document(
path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME),
- content=get_file_content(restore_file_path),
+ content=content,
is_git_diff_format=self.is_git_diff,
- absolute_path=restore_file_path,
)
def create_secondary_restore_commands(self, manifest_file_path: str) -> list[list[str]]:
diff --git a/tests/cli/files_collector/sca/__init__.py b/tests/cli/files_collector/sca/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/files_collector/sca/go/__init__.py b/tests/cli/files_collector/sca/go/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py
new file mode 100644
index 00000000..633d24e8
--- /dev/null
+++ b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py
@@ -0,0 +1,90 @@
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.go.restore_go_dependencies import (
+ GO_RESTORE_FILE_NAME,
+ RestoreGoDependencies,
+)
+from cycode.cli.models import Document
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_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_go(mock_ctx: typer.Context) -> RestoreGoDependencies:
+ return RestoreGoDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
+
+
+class TestIsProject:
+ def test_go_mod_matches(self, restore_go: RestoreGoDependencies) -> None:
+ doc = Document('go.mod', 'module example.com/mymod\ngo 1.21\n')
+ assert restore_go.is_project(doc) is True
+
+ def test_go_sum_matches(self, restore_go: RestoreGoDependencies) -> None:
+ doc = Document('go.sum', 'github.com/pkg/errors v0.9.1 h1:...\n')
+ assert restore_go.is_project(doc) is True
+
+ def test_go_in_subdir_matches(self, restore_go: RestoreGoDependencies) -> None:
+ doc = Document('myapp/go.mod', 'module example.com/mymod\n')
+ assert restore_go.is_project(doc) is True
+
+ def test_pom_xml_does_not_match(self, restore_go: RestoreGoDependencies) -> None:
+ doc = Document('pom.xml', '')
+ assert restore_go.is_project(doc) is False
+
+
+class TestCleanup:
+ def test_generated_output_file_is_deleted_after_restore(
+ self, restore_go: RestoreGoDependencies, tmp_path: Path
+ ) -> None:
+ # Go handler requires both go.mod and go.sum to be present
+ (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n')
+ (tmp_path / 'go.sum').write_text('github.com/pkg/errors v0.9.1 h1:abc\n')
+ doc = Document(
+ str(tmp_path / 'go.mod'),
+ 'module example.com/test\ngo 1.21\n',
+ absolute_path=str(tmp_path / 'go.mod'),
+ )
+ output_path = tmp_path / GO_RESTORE_FILE_NAME
+
+ def side_effect(
+ commands: list,
+ timeout: int,
+ output_file_path: Optional[str] = None,
+ working_directory: Optional[str] = None,
+ ) -> str:
+ # Go uses create_output_file_manually=True; output_file_path is provided
+ target = output_file_path or str(output_path)
+ Path(target).write_text('example.com/test github.com/pkg/errors@v0.9.1\n')
+ return 'graph output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_go.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not output_path.exists(), f'{GO_RESTORE_FILE_NAME} must be deleted after restore'
+
+ def test_missing_go_sum_returns_none(self, restore_go: RestoreGoDependencies, tmp_path: Path) -> None:
+ (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n')
+ # go.sum intentionally absent
+ doc = Document(
+ str(tmp_path / 'go.mod'),
+ 'module example.com/test\ngo 1.21\n',
+ absolute_path=str(tmp_path / 'go.mod'),
+ )
+
+ result = restore_go.try_restore_dependencies(doc)
+
+ assert result is None
diff --git a/tests/cli/files_collector/sca/maven/__init__.py b/tests/cli/files_collector/sca/maven/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py
new file mode 100644
index 00000000..72ca8a7d
--- /dev/null
+++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py
@@ -0,0 +1,120 @@
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import (
+ BUILD_GRADLE_DEP_TREE_FILE_NAME,
+ BUILD_GRADLE_FILE_NAME,
+ BUILD_GRADLE_KTS_FILE_NAME,
+ RestoreGradleDependencies,
+)
+from cycode.cli.models import Document
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+@pytest.fixture
+def mock_ctx(tmp_path: Path) -> typer.Context:
+ ctx = MagicMock(spec=typer.Context)
+ ctx.obj = {'monitor': False, 'gradle_all_sub_projects': False}
+ ctx.params = {'path': str(tmp_path)}
+ return ctx
+
+
+@pytest.fixture
+def restore_gradle(mock_ctx: typer.Context) -> RestoreGradleDependencies:
+ return RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
+
+
+class TestIsProject:
+ def test_build_gradle_matches(self, restore_gradle: RestoreGradleDependencies) -> None:
+ doc = Document('build.gradle', 'apply plugin: "java"\n')
+ assert restore_gradle.is_project(doc) is True
+
+ def test_build_gradle_kts_matches(self, restore_gradle: RestoreGradleDependencies) -> None:
+ doc = Document('build.gradle.kts', 'plugins { java }\n')
+ assert restore_gradle.is_project(doc) is True
+
+ def test_pom_xml_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None:
+ doc = Document('pom.xml', '')
+ assert restore_gradle.is_project(doc) is False
+
+ def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None:
+ doc = Document('settings.gradle', 'rootProject.name = "test"')
+ assert restore_gradle.is_project(doc) is False
+
+
+class TestCleanup:
+ def test_generated_dep_tree_file_is_deleted_after_restore(
+ self, restore_gradle: RestoreGradleDependencies, tmp_path: Path
+ ) -> None:
+ (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n')
+ doc = Document(
+ str(tmp_path / BUILD_GRADLE_FILE_NAME),
+ 'apply plugin: "java"\n',
+ absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME),
+ )
+ output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME
+
+ def side_effect(
+ commands: list,
+ timeout: int,
+ output_file_path: Optional[str] = None,
+ working_directory: Optional[str] = None,
+ ) -> str:
+ # Gradle uses create_output_file_manually=True; output_file_path is provided
+ target = output_file_path or str(output_path)
+ Path(target).write_text('compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n')
+ return 'dep tree output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_gradle.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_dep_tree_file_is_not_deleted(
+ self, restore_gradle: RestoreGradleDependencies, tmp_path: Path
+ ) -> None:
+ dep_tree_content = 'compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n'
+ (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n')
+ output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME
+ output_path.write_text(dep_tree_content)
+ doc = Document(
+ str(tmp_path / BUILD_GRADLE_FILE_NAME),
+ 'apply plugin: "java"\n',
+ absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME),
+ )
+
+ result = restore_gradle.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert output_path.exists(), f'Pre-existing {BUILD_GRADLE_DEP_TREE_FILE_NAME} must not be deleted'
+
+ def test_kts_build_file_also_cleaned_up(self, restore_gradle: RestoreGradleDependencies, tmp_path: Path) -> None:
+ (tmp_path / BUILD_GRADLE_KTS_FILE_NAME).write_text('plugins { java }\n')
+ doc = Document(
+ str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME),
+ 'plugins { java }\n',
+ absolute_path=str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME),
+ )
+ output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME
+
+ def side_effect(
+ commands: list,
+ timeout: int,
+ output_file_path: Optional[str] = None,
+ working_directory: Optional[str] = None,
+ ) -> str:
+ target = output_file_path or str(output_path)
+ Path(target).write_text('compileClasspath\n')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_gradle.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore'
diff --git a/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py
new file mode 100644
index 00000000..f365bd92
--- /dev/null
+++ b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py
@@ -0,0 +1,124 @@
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import (
+ BUILD_MAVEN_FILE_NAME,
+ MAVEN_CYCLONE_DEP_TREE_FILE_NAME,
+ MAVEN_DEP_TREE_FILE_NAME,
+ RestoreMavenDependencies,
+)
+from cycode.cli.models import Document
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+_MAVEN_MODULE = 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies'
+
+
+@pytest.fixture
+def mock_ctx(tmp_path: Path) -> typer.Context:
+ ctx = MagicMock(spec=typer.Context)
+ ctx.obj = {'monitor': False, 'maven_settings_file': None}
+ ctx.params = {'path': str(tmp_path)}
+ return ctx
+
+
+@pytest.fixture
+def restore_maven(mock_ctx: typer.Context) -> RestoreMavenDependencies:
+ return RestoreMavenDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
+
+
+class TestIsProject:
+ def test_pom_xml_matches(self, restore_maven: RestoreMavenDependencies) -> None:
+ doc = Document('pom.xml', '')
+ assert restore_maven.is_project(doc) is True
+
+ def test_pom_xml_in_subdir_matches(self, restore_maven: RestoreMavenDependencies) -> None:
+ doc = Document('mymodule/pom.xml', '')
+ assert restore_maven.is_project(doc) is True
+
+ def test_build_gradle_does_not_match(self, restore_maven: RestoreMavenDependencies) -> None:
+ doc = Document('build.gradle', '')
+ assert restore_maven.is_project(doc) is False
+
+
+class TestCleanup:
+ def test_generated_bom_is_deleted_after_primary_restore(
+ self, restore_maven: RestoreMavenDependencies, tmp_path: Path
+ ) -> None:
+ """Primary path: super().try_restore_dependencies() generates target/bom.json and cleans it up."""
+ pom_content = '4.0.0'
+ (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content)
+ target_dir = tmp_path / 'target'
+ target_dir.mkdir()
+ bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME
+ doc = Document(
+ str(tmp_path / BUILD_MAVEN_FILE_NAME),
+ pom_content,
+ absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME),
+ )
+
+ def side_effect(
+ commands: list,
+ timeout: int,
+ output_file_path: Optional[str] = None,
+ working_directory: Optional[str] = None,
+ ) -> str:
+ bom_path.write_text('{"bomFormat": "CycloneDX", "components": []}')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_maven.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert result.content is not None, 'Document content must be populated even after file deletion'
+ assert not bom_path.exists(), f'target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must be deleted after restore'
+
+ def test_generated_dep_tree_is_deleted_after_secondary_restore(
+ self, restore_maven: RestoreMavenDependencies, tmp_path: Path
+ ) -> None:
+ """Secondary path (content=None): mvn dependency:tree generates bcde.mvndeps and it must be cleaned up."""
+ (tmp_path / BUILD_MAVEN_FILE_NAME).write_text('')
+ dep_tree_path = tmp_path / MAVEN_DEP_TREE_FILE_NAME
+ # content=None triggers the secondary command path
+ doc = Document(
+ str(tmp_path / BUILD_MAVEN_FILE_NAME),
+ None,
+ absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME),
+ )
+
+ def side_effect(
+ commands: list,
+ timeout: int,
+ output_file_path: Optional[str] = None,
+ working_directory: Optional[str] = None,
+ ) -> str:
+ dep_tree_path.write_text('[INFO] com.example:my-app:jar:1.0.0\n')
+ return '[INFO] BUILD SUCCESS'
+
+ with patch(f'{_MAVEN_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_maven.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert result.content is not None
+ assert not dep_tree_path.exists(), f'{MAVEN_DEP_TREE_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_bom_is_not_deleted(self, restore_maven: RestoreMavenDependencies, tmp_path: Path) -> None:
+ pom_content = '4.0.0'
+ (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content)
+ target_dir = tmp_path / 'target'
+ target_dir.mkdir()
+ bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME
+ bom_path.write_text('{"bomFormat": "CycloneDX", "components": [{"name": "requests"}]}')
+ doc = Document(
+ str(tmp_path / BUILD_MAVEN_FILE_NAME),
+ pom_content,
+ absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME),
+ )
+
+ result = restore_maven.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert bom_path.exists(), f'Pre-existing target/{MAVEN_CYCLONE_DEP_TREE_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 aa145de3..c418b659 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
@@ -1,4 +1,5 @@
from pathlib import Path
+from typing import Optional
from unittest.mock import MagicMock, patch
import pytest
@@ -99,6 +100,44 @@ def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNp
assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME]
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_npm: RestoreNpmDependencies, 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'))
+ lock_path = tmp_path / NPM_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": 3}')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_npm.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{NPM_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None:
+ (tmp_path / 'package.json').write_text('{"name": "test"}')
+ lock_path = tmp_path / NPM_LOCK_FILE_NAME
+ lock_path.write_text('{"lockfileVersion": 3, "packages": {}}')
+ doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json'))
+
+ result = restore_npm.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {NPM_LOCK_FILE_NAME} must not be deleted'
+
+
class TestPrepareManifestFilePath:
def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None:
path = str(Path('/path/to/package.json'))
diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py
index 312cce83..88502578 100644
--- a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py
+++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py
@@ -1,5 +1,6 @@
from pathlib import Path
-from unittest.mock import MagicMock
+from typing import Optional
+from unittest.mock import MagicMock, patch
import pytest
import typer
@@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None
def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None:
commands = restore_pnpm.get_commands('/path/to/package.json')
assert commands == [['pnpm', 'install', '--ignore-scripts']]
+
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path
+ ) -> None:
+ # pnpm: no pre-existing pnpm-lock.yaml but package.json indicates pnpm
+ content = '{"name": "test", "packageManager": "pnpm@8.6.2"}'
+ (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 / PNPM_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: 5.4\n')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_pnpm.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{PNPM_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None:
+ lock_content = 'lockfileVersion: 5.4\n\npackages:\n /pkg@1.0.0:\n resolution: {}\n'
+ (tmp_path / 'package.json').write_text('{"name": "test"}')
+ lock_path = tmp_path / PNPM_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_pnpm.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {PNPM_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py
index 13e321c9..88175031 100644
--- a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py
+++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py
@@ -1,5 +1,6 @@
from pathlib import Path
-from unittest.mock import MagicMock
+from typing import Optional
+from unittest.mock import MagicMock, patch
import pytest
import typer
@@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None
def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None:
commands = restore_yarn.get_commands('/path/to/package.json')
assert commands == [['yarn', 'install', '--ignore-scripts']]
+
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_yarn: RestoreYarnDependencies, tmp_path: Path
+ ) -> None:
+ # Yarn: no pre-existing yarn.lock but package.json indicates yarn
+ content = '{"name": "test", "packageManager": "yarn@4.0.2"}'
+ (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 / YARN_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('# yarn lockfile v1\n')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_yarn.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{YARN_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None:
+ lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n'
+ (tmp_path / 'package.json').write_text('{"name": "test"}')
+ lock_path = tmp_path / YARN_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_yarn.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {YARN_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/nuget/__init__.py b/tests/cli/files_collector/sca/nuget/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py
new file mode 100644
index 00000000..0ec13441
--- /dev/null
+++ b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py
@@ -0,0 +1,89 @@
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import (
+ NUGET_LOCK_FILE_NAME,
+ RestoreNugetDependencies,
+)
+from cycode.cli.models import Document
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_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_nuget(mock_ctx: typer.Context) -> RestoreNugetDependencies:
+ return RestoreNugetDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
+
+
+class TestIsProject:
+ def test_csproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None:
+ doc = Document('MyProject.csproj', '')
+ assert restore_nuget.is_project(doc) is True
+
+ def test_vbproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None:
+ doc = Document('MyProject.vbproj', '')
+ assert restore_nuget.is_project(doc) is True
+
+ def test_sln_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None:
+ doc = Document('MySolution.sln', '')
+ assert restore_nuget.is_project(doc) is False
+
+ def test_packages_json_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None:
+ doc = Document('packages.json', '{}')
+ assert restore_nuget.is_project(doc) is False
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_nuget: RestoreNugetDependencies, tmp_path: Path
+ ) -> None:
+ (tmp_path / 'MyProject.csproj').write_text('')
+ doc = Document(
+ str(tmp_path / 'MyProject.csproj'),
+ '',
+ absolute_path=str(tmp_path / 'MyProject.csproj'),
+ )
+ lock_path = tmp_path / NUGET_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('{"version": 1, "dependencies": {}}')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_nuget.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{NUGET_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, restore_nuget: RestoreNugetDependencies, tmp_path: Path) -> None:
+ lock_content = '{"version": 1, "dependencies": {"net8.0": {}}}'
+ (tmp_path / 'MyProject.csproj').write_text('')
+ lock_path = tmp_path / NUGET_LOCK_FILE_NAME
+ lock_path.write_text(lock_content)
+ doc = Document(
+ str(tmp_path / 'MyProject.csproj'),
+ '',
+ absolute_path=str(tmp_path / 'MyProject.csproj'),
+ )
+
+ result = restore_nuget.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {NUGET_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py
index 463eeddb..6e3ea53b 100644
--- a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py
+++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py
@@ -1,5 +1,6 @@
from pathlib import Path
-from unittest.mock import MagicMock
+from typing import Optional
+from unittest.mock import MagicMock, patch
import pytest
import typer
@@ -68,6 +69,54 @@ def test_existing_composer_lock_returned_directly(
def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None:
assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_composer: RestoreComposerDependencies, tmp_path: Path
+ ) -> None:
+ manifest_content = '{"name": "vendor/project"}\n'
+ (tmp_path / 'composer.json').write_text(manifest_content)
+ doc = Document(str(tmp_path / 'composer.json'), manifest_content, absolute_path=str(tmp_path / 'composer.json'))
+ lock_path = tmp_path / COMPOSER_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('{"_readme": [], "packages": []}')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_composer.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{COMPOSER_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(
+ self, restore_composer: RestoreComposerDependencies, tmp_path: Path
+ ) -> None:
+ lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n'
+ (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n')
+ lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME
+ lock_path.write_text(lock_content)
+ doc = Document(
+ str(tmp_path / 'composer.json'),
+ '{"name": "vendor/project"}\n',
+ absolute_path=str(tmp_path / 'composer.json'),
+ )
+
+ result = restore_composer.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {COMPOSER_LOCK_FILE_NAME} must not be deleted'
+
+
+class TestGetCommands:
def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None:
commands = restore_composer.get_commands('/path/to/composer.json')
assert commands == [
diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py
index 9d34a7e3..a6d97320 100644
--- a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py
+++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py
@@ -1,5 +1,6 @@
from pathlib import Path
-from unittest.mock import MagicMock
+from typing import Optional
+from unittest.mock import MagicMock, patch
import pytest
import typer
@@ -71,3 +72,47 @@ def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) ->
def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None:
commands = restore_pipenv.get_commands('/path/to/Pipfile')
assert commands == [['pipenv', 'lock']]
+
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path
+ ) -> None:
+ manifest_content = '[[source]]\nname = "pypi"\n'
+ (tmp_path / 'Pipfile').write_text(manifest_content)
+ doc = Document(str(tmp_path / 'Pipfile'), manifest_content, absolute_path=str(tmp_path / 'Pipfile'))
+ lock_path = tmp_path / PIPENV_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('{"_meta": {}, "default": {}, "develop": {}}')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_pipenv.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{PIPENV_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(
+ self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path
+ ) -> None:
+ lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n'
+ (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n')
+ lock_path = tmp_path / PIPENV_LOCK_FILE_NAME
+ lock_path.write_text(lock_content)
+ doc = Document(
+ str(tmp_path / 'Pipfile'), '[[source]]\nname = "pypi"\n', absolute_path=str(tmp_path / 'Pipfile')
+ )
+
+ result = restore_pipenv.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {PIPENV_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py
index 73f0d14f..cf4c312b 100644
--- a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py
+++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py
@@ -1,5 +1,6 @@
from pathlib import Path
-from unittest.mock import MagicMock
+from typing import Optional
+from unittest.mock import MagicMock, patch
import pytest
import typer
@@ -97,3 +98,52 @@ def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) ->
def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None:
commands = restore_poetry.get_commands('/path/to/pyproject.toml')
assert commands == [['poetry', 'lock']]
+
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_poetry: RestorePoetryDependencies, tmp_path: Path
+ ) -> None:
+ # Poetry: no pre-existing poetry.lock but pyproject.toml indicates poetry
+ manifest_content = '[tool.poetry]\nname = "test"\nversion = "1.0.0"\n'
+ (tmp_path / 'pyproject.toml').write_text(manifest_content)
+ doc = Document(
+ str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml')
+ )
+ lock_path = tmp_path / POETRY_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('# This file is generated by Poetry\n')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_poetry.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{POETRY_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(
+ self, restore_poetry: RestorePoetryDependencies, tmp_path: Path
+ ) -> None:
+ lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n'
+ (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n')
+ lock_path = tmp_path / POETRY_LOCK_FILE_NAME
+ lock_path.write_text(lock_content)
+ doc = Document(
+ str(tmp_path / 'pyproject.toml'),
+ '[tool.poetry]\nname = "test"\n',
+ absolute_path=str(tmp_path / 'pyproject.toml'),
+ )
+
+ result = restore_poetry.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {POETRY_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/ruby/__init__.py b/tests/cli/files_collector/sca/ruby/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py
new file mode 100644
index 00000000..ac3e9d73
--- /dev/null
+++ b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py
@@ -0,0 +1,89 @@
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import (
+ RUBY_LOCK_FILE_NAME,
+ RestoreRubyDependencies,
+)
+from cycode.cli.models import Document
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_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_ruby(mock_ctx: typer.Context) -> RestoreRubyDependencies:
+ return RestoreRubyDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
+
+
+class TestIsProject:
+ def test_gemfile_matches(self, restore_ruby: RestoreRubyDependencies) -> None:
+ doc = Document('Gemfile', "source 'https://rubygems.org'\n")
+ assert restore_ruby.is_project(doc) is True
+
+ def test_gemfile_in_subdir_matches(self, restore_ruby: RestoreRubyDependencies) -> None:
+ doc = Document('myapp/Gemfile', "source 'https://rubygems.org'\n")
+ assert restore_ruby.is_project(doc) is True
+
+ def test_gemfile_lock_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None:
+ doc = Document('Gemfile.lock', 'GEM\n remote: https://rubygems.org/\n')
+ assert restore_ruby.is_project(doc) is False
+
+ def test_other_file_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None:
+ doc = Document('Rakefile', '')
+ assert restore_ruby.is_project(doc) is False
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_ruby: RestoreRubyDependencies, tmp_path: Path
+ ) -> None:
+ (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\n")
+ doc = Document(
+ str(tmp_path / 'Gemfile'),
+ "source 'https://rubygems.org'\n",
+ absolute_path=str(tmp_path / 'Gemfile'),
+ )
+ lock_path = tmp_path / RUBY_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('GEM\n remote: https://rubygems.org/\n specs:\n')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_ruby.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{RUBY_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, restore_ruby: RestoreRubyDependencies, tmp_path: Path) -> None:
+ lock_content = 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n'
+ (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\ngem 'rake'\n")
+ lock_path = tmp_path / RUBY_LOCK_FILE_NAME
+ lock_path.write_text(lock_content)
+ doc = Document(
+ str(tmp_path / 'Gemfile'),
+ "source 'https://rubygems.org'\ngem 'rake'\n",
+ absolute_path=str(tmp_path / 'Gemfile'),
+ )
+
+ result = restore_ruby.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {RUBY_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/sbt/__init__.py b/tests/cli/files_collector/sca/sbt/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py
new file mode 100644
index 00000000..415e5f94
--- /dev/null
+++ b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py
@@ -0,0 +1,89 @@
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import (
+ SBT_LOCK_FILE_NAME,
+ RestoreSbtDependencies,
+)
+from cycode.cli.models import Document
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_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_sbt(mock_ctx: typer.Context) -> RestoreSbtDependencies:
+ return RestoreSbtDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
+
+
+class TestIsProject:
+ def test_sbt_file_matches(self, restore_sbt: RestoreSbtDependencies) -> None:
+ doc = Document('build.sbt', 'name := "my-project"\n')
+ assert restore_sbt.is_project(doc) is True
+
+ def test_sbt_in_subdir_matches(self, restore_sbt: RestoreSbtDependencies) -> None:
+ doc = Document('myapp/build.sbt', 'name := "my-project"\n')
+ assert restore_sbt.is_project(doc) is True
+
+ def test_build_gradle_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None:
+ doc = Document('build.gradle', '')
+ assert restore_sbt.is_project(doc) is False
+
+ def test_pom_xml_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None:
+ doc = Document('pom.xml', '')
+ assert restore_sbt.is_project(doc) is False
+
+
+class TestCleanup:
+ def test_generated_lockfile_is_deleted_after_restore(
+ self, restore_sbt: RestoreSbtDependencies, tmp_path: Path
+ ) -> None:
+ (tmp_path / 'build.sbt').write_text('name := "test"\n')
+ doc = Document(
+ str(tmp_path / 'build.sbt'),
+ 'name := "test"\n',
+ absolute_path=str(tmp_path / 'build.sbt'),
+ )
+ lock_path = tmp_path / SBT_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('[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]')
+ return 'output'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = restore_sbt.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert not lock_path.exists(), f'{SBT_LOCK_FILE_NAME} must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, restore_sbt: RestoreSbtDependencies, tmp_path: Path) -> None:
+ lock_content = '[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]'
+ (tmp_path / 'build.sbt').write_text('name := "test"\n')
+ lock_path = tmp_path / SBT_LOCK_FILE_NAME
+ lock_path.write_text(lock_content)
+ doc = Document(
+ str(tmp_path / 'build.sbt'),
+ 'name := "test"\n',
+ absolute_path=str(tmp_path / 'build.sbt'),
+ )
+
+ result = restore_sbt.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert lock_path.exists(), f'Pre-existing {SBT_LOCK_FILE_NAME} must not be deleted'
diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py
new file mode 100644
index 00000000..b291a95f
--- /dev/null
+++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py
@@ -0,0 +1,148 @@
+"""Tests for BaseRestoreDependencies cleanup behavior.
+
+Verifies that lock files generated by restore commands are deleted after
+scanning, while pre-existing lock files are left untouched.
+"""
+
+from pathlib import Path
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+import typer
+
+from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies
+from cycode.cli.models import Document
+
+_LOCK_FILE_NAME = 'generated.lock'
+_MANIFEST_FILE_NAME = 'manifest.txt'
+_LOCK_CONTENT = 'generated lock content'
+
+_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies'
+
+
+class _MinimalRestoreHandler(BaseRestoreDependencies):
+ """Minimal concrete subclass for directly testing BaseRestoreDependencies."""
+
+ def is_project(self, document: Document) -> bool:
+ return document.path.endswith(_MANIFEST_FILE_NAME)
+
+ def get_commands(self, manifest_file_path: str) -> list[list[str]]:
+ return [['echo', 'fake']]
+
+ def get_lock_file_name(self) -> str:
+ return _LOCK_FILE_NAME
+
+ def get_lock_file_names(self) -> list[str]:
+ return [_LOCK_FILE_NAME]
+
+
+@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 handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler:
+ return _MinimalRestoreHandler(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')
+ return Document(str(manifest), 'content', absolute_path=str(manifest))
+
+
+def _make_execute_side_effect(lock_path: Path, content: str = _LOCK_CONTENT) -> object:
+ """Returns an execute_commands side_effect that writes the lock file."""
+
+ def side_effect(
+ commands: list, timeout: int, output_file_path: Optional[str] = None, working_directory: Optional[str] = None
+ ) -> str:
+ lock_path.write_text(content)
+ return 'output'
+
+ return side_effect
+
+
+class TestCleanupGeneratedFile:
+ def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None:
+ doc = _make_doc(tmp_path)
+ lock_path = tmp_path / _LOCK_FILE_NAME
+
+ with 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
+ assert result.content == _LOCK_CONTENT
+ assert not lock_path.exists(), 'Generated lock file must be deleted after restore'
+
+ def test_preexisting_lockfile_is_not_deleted(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None:
+ doc = _make_doc(tmp_path)
+ lock_path = tmp_path / _LOCK_FILE_NAME
+ lock_path.write_text('pre-existing content')
+
+ result = handler.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert result.content == 'pre-existing content'
+ assert lock_path.exists(), 'Pre-existing lock file must not be deleted'
+
+ def test_returned_document_content_matches_generated_file(
+ self, handler: _MinimalRestoreHandler, tmp_path: Path
+ ) -> None:
+ doc = _make_doc(tmp_path)
+ expected = '{"dependencies": {"requests": "^2.31"}}'
+ lock_path = tmp_path / _LOCK_FILE_NAME
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)):
+ result = handler.try_restore_dependencies(doc)
+
+ assert result is not None
+ assert result.content == expected
+
+ def test_cleanup_does_not_raise_when_generated_file_missing(
+ self, handler: _MinimalRestoreHandler, tmp_path: Path
+ ) -> None:
+ """unlink(missing_ok=True) must not raise even if the command didn't create the file."""
+ doc = _make_doc(tmp_path)
+
+ def side_effect(**_kwargs: object) -> str:
+ return 'output' # returned non-None but didn't create the file
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect):
+ result = handler.try_restore_dependencies(doc)
+
+ # File was never created; content is None but no exception raised
+ assert result is not None
+ assert result.content is None
+
+ def test_failed_command_returns_none_and_no_file_created(
+ self, handler: _MinimalRestoreHandler, tmp_path: Path
+ ) -> None:
+ doc = _make_doc(tmp_path)
+ lock_path = tmp_path / _LOCK_FILE_NAME
+
+ with patch(f'{_BASE_MODULE}.execute_commands', return_value=None):
+ result = handler.try_restore_dependencies(doc)
+
+ assert result is None
+ assert not lock_path.exists()
+
+ def test_generated_file_content_available_in_document_after_deletion(
+ self, handler: _MinimalRestoreHandler, tmp_path: Path
+ ) -> None:
+ """The Document must carry the file content even after the file is removed."""
+ doc = _make_doc(tmp_path)
+ lock_path = tmp_path / _LOCK_FILE_NAME
+ expected = 'important scan data'
+
+ with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)):
+ result = handler.try_restore_dependencies(doc)
+
+ assert not lock_path.exists()
+ assert result is not None
+ assert result.content == expected