Skip to content

Commit f2b80d0

Browse files
omer-rothclaude
andcommitted
CM-68642: stop SCA scan on restore command failure with --stop-on-error
execute_commands swallowed a non-zero restore exit (shell() returns None) into an empty-string success, so restore() never returned None and the --stop-on-error escalation in _try_restore_dependencies was unreachable. Treat a None from shell() as a failed command and propagate None. Add tests exercising the real execute_commands with only shell() mocked, covering the failure sentinel the previous mocked tests skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8be08ed commit f2b80d0

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

cycode/cli/files_collector/sca/base_restore_dependencies.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ def execute_commands(
4040

4141
for command in commands:
4242
command_output = shell(command=command, timeout=timeout, working_directory=working_directory)
43+
if command_output is None: # shell returns None when the command exited non-zero
44+
logger.debug('Restore command failed, %s', {'command': command})
45+
return None
4346
if command_output:
4447
outputs.append(command_output)
4548

tests/cli/files_collector/sca/test_base_restore_dependencies.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pytest
1212
import typer
1313

14-
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies
14+
from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, execute_commands
1515
from cycode.cli.models import Document
1616

1717
_LOCK_FILE_NAME = 'generated.lock'
@@ -68,6 +68,39 @@ def side_effect(
6868
return side_effect
6969

7070

71+
class TestExecuteCommands:
72+
"""Directly test the shell-failure sentinel handling in execute_commands."""
73+
74+
def test_returns_none_when_a_command_fails(self) -> None:
75+
"""shell() returns None on non-zero exit; execute_commands must propagate None, not ''."""
76+
with patch(f'{_BASE_MODULE}.shell', return_value=None):
77+
result = execute_commands([['poetry', 'lock']], timeout=30)
78+
79+
assert result is None
80+
81+
def test_stops_at_first_failing_command(self) -> None:
82+
"""A failure in an earlier command short-circuits; later commands do not run."""
83+
mock_shell = MagicMock(side_effect=[None, 'should-not-run'])
84+
with patch(f'{_BASE_MODULE}.shell', mock_shell):
85+
result = execute_commands([['a'], ['b']], timeout=30)
86+
87+
assert result is None
88+
assert mock_shell.call_count == 1
89+
90+
def test_empty_output_success_is_not_treated_as_failure(self) -> None:
91+
"""A successful command with empty stdout ('') must NOT be treated as a failure."""
92+
with patch(f'{_BASE_MODULE}.shell', return_value=''):
93+
result = execute_commands([['poetry', 'lock']], timeout=30)
94+
95+
assert result == ''
96+
97+
def test_joins_successful_outputs(self) -> None:
98+
with patch(f'{_BASE_MODULE}.shell', side_effect=['out1', 'out2']):
99+
result = execute_commands([['a'], ['b']], timeout=30)
100+
101+
assert result == 'out1\nout2'
102+
103+
71104
class TestCleanupGeneratedFile:
72105
def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None:
73106
doc = _make_doc(tmp_path)
@@ -132,6 +165,24 @@ def test_failed_command_returns_none_and_no_file_created(
132165
assert result is None
133166
assert not lock_path.exists()
134167

168+
def test_shell_failure_propagates_to_none_and_no_lockfile(
169+
self, handler: _MinimalRestoreHandler, tmp_path: Path
170+
) -> None:
171+
"""End-to-end failure path: shell() returns None (non-zero exit) -> restore returns None.
172+
173+
Regression for the stop-on-error bug: execute_commands must NOT swallow a failed
174+
command into an empty-string success. This exercises the real execute_commands with
175+
only shell() mocked (the layer where a non-zero exit is signalled as None).
176+
"""
177+
doc = _make_doc(tmp_path)
178+
lock_path = tmp_path / _LOCK_FILE_NAME
179+
180+
with patch(f'{_BASE_MODULE}.shell', return_value=None):
181+
result = handler.try_restore_dependencies(doc)
182+
183+
assert result is None, 'A failed restore command must return None so stop-on-error can fire'
184+
assert not lock_path.exists()
185+
135186
def test_generated_file_content_available_in_document_after_deletion(
136187
self, handler: _MinimalRestoreHandler, tmp_path: Path
137188
) -> None:

0 commit comments

Comments
 (0)