ab49450467
New thefuck/compose.py resolve() iterates the auto-run sources (learned-db exact, error-suggestion, history, path guess, help) per round on the previous round's script until stable, a revisit aborts or no source has a safe candidate; dangerous candidates are declined mid-round and consultation continues. Output-aware sources get a synthesized Command wrapping the round's script with the original failure's output. Zero accepted steps return None so the original failing script is never auto-executed; no round cap (termination contract: visited-set + fixed-range/word-count-preserving sources, stated in the module docstring; a compose-local memo acts only as a determinism tripwire). fix_command runs the inline chain through the composer; under settings.repeat or a non-nushell shell it records immediately before the run unless the only step replayed a learned-db exact hit; under nushell it closes the learned db first and records on returncode 0, forgets on failure (poison self-heal; CorrectedCommand.run now stashes the nu returncode on the instance). learned.forget(original, corrected) is the exact inverse of record (cmd: + matching-count word:/part: deletes, assembly failures are a documented no-op); learned.close_db is idempotent and atexit now registers the stable close_db method exactly once across close/reopen cycles. Test migration (authorized): test_fix_command_learned.py rebased to composer semantics (per-round consultation incl. error-suggestion; input-aware fakes so round 2 declines, preserving the recordings==[] pin in test_exact_learned_wins_over_guess).
527 lines
19 KiB
Python
527 lines
19 KiB
Python
import pytest
|
|
from mock import Mock, patch
|
|
from thefuck import danger as real_danger
|
|
from thefuck.entrypoints.fix_command import fix_command
|
|
from thefuck.shells import Nushell
|
|
from thefuck.types import Command, CorrectedCommand
|
|
|
|
ORIGINAL = "git psuh origin main"
|
|
CORRECTED = "git push origin main"
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_learned(monkeypatch):
|
|
state = {"correction": None, "suggestion": None, "history": None,
|
|
"guess": None, "help": None, "recordings": [], "calls": [],
|
|
"forgets": [], "order": [], "closes": 0}
|
|
# The real gate fail-safes to True without bashlex, which would
|
|
# make every auto-apply test platform-dependent; the danger
|
|
# override tests re-install the real module in both consumers.
|
|
fake_danger = Mock()
|
|
fake_danger.is_dangerous.return_value = False
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.danger", fake_danger
|
|
)
|
|
monkeypatch.setattr("thefuck.compose.danger", fake_danger)
|
|
|
|
def scripted(key, script):
|
|
value = state[key]
|
|
if callable(value):
|
|
return value(script)
|
|
# Input-aware: a constant candidate is scripted for the
|
|
# original script only, so round 2 of the composition
|
|
# declines instead of accepting it again.
|
|
return value if script == ORIGINAL else None
|
|
|
|
def script_source(key, label):
|
|
def fake(script):
|
|
state["calls"].append(label)
|
|
return scripted(key, script)
|
|
return fake
|
|
|
|
def command_source(key, label):
|
|
def fake(command):
|
|
state["calls"].append(label)
|
|
return scripted(key, command.script)
|
|
return fake
|
|
|
|
def fake_record(original, corrected):
|
|
state["order"].append("record")
|
|
state["recordings"].append((original, corrected))
|
|
|
|
def fake_forget(original, corrected):
|
|
state["order"].append("forget")
|
|
state["forgets"].append((original, corrected))
|
|
|
|
def fake_close_db():
|
|
state["order"].append("close")
|
|
state["closes"] += 1
|
|
|
|
monkeypatch.setattr(
|
|
"thefuck.compose.get_correction",
|
|
script_source("correction", "learned")
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.compose.get_suggestion_candidates",
|
|
command_source("suggestion", "error-suggestion")
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.compose.get_history_correction",
|
|
command_source("history", "history")
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.compose.guess_from_path", script_source("guess", "path")
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.compose.get_help_correction", script_source("help", "help")
|
|
)
|
|
fake_learned = Mock(record=fake_record, forget=fake_forget,
|
|
close_db=fake_close_db)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.learned", fake_learned
|
|
)
|
|
return state
|
|
|
|
|
|
@pytest.fixture
|
|
def known_args():
|
|
return Mock(
|
|
force_command="git psuh origin main", yes=False, debug=False,
|
|
repeat=False
|
|
)
|
|
|
|
|
|
class TestLearnedAutoApply(object):
|
|
def test_auto_applies_learned_correction(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["correction"] = CORRECTED
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([])
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command", lambda _: None
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
):
|
|
fix_command(known_args)
|
|
mock_run.assert_called_once()
|
|
|
|
def test_learned_skips_rule_matching(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["correction"] = CORRECTED
|
|
get_corrected = Mock()
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
get_corrected
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run"), patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
):
|
|
fix_command(known_args)
|
|
get_corrected.assert_not_called()
|
|
|
|
def test_shows_corrected_command_on_auto_apply(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["correction"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run"), patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
) as mock_show:
|
|
fix_command(known_args)
|
|
assert mock_show.call_count == 1
|
|
shown_cmd = mock_show.call_args[0][0]
|
|
assert shown_cmd.script == CORRECTED
|
|
|
|
|
|
class TestGuessAutoApply(object):
|
|
def test_guess_records_and_auto_applies(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["guess"] = CORRECTED
|
|
get_corrected = Mock()
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
get_corrected
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
):
|
|
fix_command(known_args)
|
|
mock_run.assert_called_once()
|
|
get_corrected.assert_not_called()
|
|
assert mock_learned["recordings"] == [(ORIGINAL, CORRECTED)]
|
|
|
|
def test_exact_learned_wins_over_guess(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["correction"] = "git push origin dev"
|
|
mock_learned["guess"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run"), patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
) as mock_show:
|
|
fix_command(known_args)
|
|
shown_cmd = mock_show.call_args[0][0]
|
|
assert shown_cmd.script == "git push origin dev"
|
|
assert mock_learned["recordings"] == []
|
|
|
|
|
|
class TestResolverChainOrder(object):
|
|
# Round 1 consults [learned, error-suggestion, history, path,
|
|
# help] up to the round's winner; round 2 consults all five with
|
|
# the corrected script, the input-aware fakes decline and the
|
|
# composition ends at a stable point.
|
|
def test_full_chain_consulted_in_order_when_only_help_hits(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["help"] = CORRECTED
|
|
get_corrected = Mock()
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
get_corrected
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run"), patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
):
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["calls"] == [
|
|
"learned", "error-suggestion", "history", "path", "help",
|
|
"learned", "error-suggestion", "history", "path", "help",
|
|
]
|
|
get_corrected.assert_not_called()
|
|
|
|
@pytest.mark.parametrize("hit_source,expected_calls", [
|
|
("history", ["learned", "error-suggestion", "history",
|
|
"learned", "error-suggestion", "history",
|
|
"path", "help"]),
|
|
("guess", ["learned", "error-suggestion", "history", "path",
|
|
"learned", "error-suggestion", "history", "path",
|
|
"help"]),
|
|
])
|
|
def test_earlier_hit_stops_the_round(
|
|
self, mock_learned, known_args, settings, monkeypatch, hit_source,
|
|
expected_calls
|
|
):
|
|
mock_learned[hit_source] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run"), patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
):
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["calls"] == expected_calls
|
|
|
|
def test_history_receives_command_object(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
history_mock = Mock(return_value=None)
|
|
monkeypatch.setattr(
|
|
"thefuck.compose.get_history_correction", history_mock
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command", lambda _: None
|
|
)
|
|
|
|
with pytest.raises(SystemExit):
|
|
fix_command(known_args)
|
|
|
|
assert history_mock.call_count == 1
|
|
assert history_mock.call_args[0][0].script == ORIGINAL
|
|
|
|
|
|
class TestResolverAutoApply(object):
|
|
@pytest.mark.parametrize("source", ["history", "help"])
|
|
def test_resolver_hit_records_and_auto_applies(
|
|
self, mock_learned, known_args, settings, monkeypatch, source
|
|
):
|
|
mock_learned[source] = CORRECTED
|
|
get_corrected = Mock()
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
get_corrected
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
) as mock_show:
|
|
fix_command(known_args)
|
|
mock_run.assert_called_once()
|
|
get_corrected.assert_not_called()
|
|
assert mock_show.call_args[0][0].script == CORRECTED
|
|
assert mock_learned["recordings"] == [(ORIGINAL, CORRECTED)]
|
|
|
|
|
|
class TestComposition(object):
|
|
def test_two_sources_compose_into_one_run(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["guess"] = "git psuh origin main"
|
|
mock_learned["history"] = (
|
|
lambda script: CORRECTED if script == "git psuh origin main"
|
|
else None)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
) as mock_show:
|
|
fix_command(known_args)
|
|
mock_run.assert_called_once()
|
|
assert mock_show.call_args[0][0].script == CORRECTED
|
|
assert mock_learned["recordings"] == [(ORIGINAL, CORRECTED)]
|
|
|
|
|
|
class TestDangerOverride(object):
|
|
@pytest.mark.parametrize(
|
|
"hit_source",
|
|
["correction", "suggestion", "history", "guess", "help"])
|
|
def test_dangerous_hit_asks_instead_of_auto_running(
|
|
self, mock_learned, known_args, settings, monkeypatch, hit_source
|
|
):
|
|
# The real gate: a danger-flagged candidate from ANY source —
|
|
# a seeded learned-db exact hit included — is declined by the
|
|
# composer, falls to select_command and nothing auto-runs. The
|
|
# abort via the mocked selection keeps the pin free of
|
|
# terminal IO.
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.danger", real_danger
|
|
)
|
|
monkeypatch.setattr("thefuck.compose.danger", real_danger)
|
|
mock_learned[hit_source] = "rm -rf /"
|
|
select = Mock(return_value=None)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command", select
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"
|
|
) as mock_show:
|
|
with pytest.raises(SystemExit):
|
|
fix_command(known_args)
|
|
|
|
select.assert_called_once()
|
|
mock_run.assert_not_called()
|
|
mock_show.assert_not_called()
|
|
|
|
|
|
class TestRecordOnSelection(object):
|
|
def test_records_user_selection(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
selected = CorrectedCommand(
|
|
script=CORRECTED, side_effect=None, priority=100
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([selected]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command",
|
|
lambda _: selected
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run"):
|
|
fix_command(known_args)
|
|
assert mock_learned["recordings"] == [(ORIGINAL, CORRECTED)]
|
|
|
|
def test_does_not_record_on_abort(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([])
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command", lambda _: None
|
|
)
|
|
|
|
with pytest.raises(SystemExit):
|
|
fix_command(known_args)
|
|
assert mock_learned["recordings"] == []
|
|
|
|
|
|
class TestFallthrough(object):
|
|
def test_falls_through_to_rules_when_no_learned(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
selected = CorrectedCommand(
|
|
script=CORRECTED, side_effect=None, priority=100
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([selected]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command",
|
|
lambda _: selected
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run:
|
|
fix_command(known_args)
|
|
mock_run.assert_called_once()
|
|
|
|
def test_falls_through_to_rules_when_all_resolvers_decline(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
selected = CorrectedCommand(
|
|
script=CORRECTED, side_effect=None, priority=100
|
|
)
|
|
select = Mock(return_value=selected)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
|
lambda _: iter([selected]),
|
|
)
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.select_command", select
|
|
)
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run") as mock_run:
|
|
fix_command(known_args)
|
|
mock_run.assert_called_once()
|
|
|
|
assert mock_learned["calls"] == [
|
|
"learned", "error-suggestion", "history", "path", "help"
|
|
]
|
|
assert select.call_count == 1
|
|
|
|
|
|
class TestOutcomeLearning(object):
|
|
@pytest.fixture
|
|
def nushell(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"thefuck.entrypoints.fix_command.shell", Nushell())
|
|
|
|
def _run_with_returncode(self, order, returncode):
|
|
def run(cmd_self, old_cmd):
|
|
order.append("run")
|
|
cmd_self.returncode = returncode
|
|
return run
|
|
|
|
def test_success_records_after_run(
|
|
self, mock_learned, known_args, settings, monkeypatch, nushell
|
|
):
|
|
mock_learned["guess"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run",
|
|
autospec=True) as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"):
|
|
mock_run.side_effect = self._run_with_returncode(
|
|
mock_learned["order"], 0)
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["order"] == ["close", "run", "record"]
|
|
assert mock_learned["recordings"] == [(ORIGINAL, CORRECTED)]
|
|
assert mock_learned["forgets"] == []
|
|
|
|
def test_failure_forgets_after_run(
|
|
self, mock_learned, known_args, settings, monkeypatch, nushell
|
|
):
|
|
mock_learned["guess"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run",
|
|
autospec=True) as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"):
|
|
mock_run.side_effect = self._run_with_returncode(
|
|
mock_learned["order"], 1)
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["order"] == ["close", "run", "forget"]
|
|
assert mock_learned["forgets"] == [(ORIGINAL, CORRECTED)]
|
|
assert mock_learned["recordings"] == []
|
|
|
|
def test_learned_replay_failure_still_forgets(
|
|
self, mock_learned, known_args, settings, monkeypatch, nushell
|
|
):
|
|
# Poison self-heal: a seeded exact hit that fails on rerun is
|
|
# removed, so the no-rerecord carve-out must not skip the
|
|
# outcome gating.
|
|
mock_learned["correction"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run",
|
|
autospec=True) as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"):
|
|
mock_run.side_effect = self._run_with_returncode(
|
|
mock_learned["order"], 1)
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["order"] == ["close", "run", "forget"]
|
|
assert mock_learned["forgets"] == [(ORIGINAL, CORRECTED)]
|
|
|
|
def test_repeat_records_before_run_without_outcome_gating(
|
|
self, mock_learned, known_args, settings, monkeypatch, nushell
|
|
):
|
|
known_args.repeat = True
|
|
mock_learned["guess"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run",
|
|
autospec=True) as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"):
|
|
mock_run.side_effect = self._run_with_returncode(
|
|
mock_learned["order"], 1)
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["order"] == ["record", "run"]
|
|
assert mock_learned["recordings"] == [(ORIGINAL, CORRECTED)]
|
|
assert mock_learned["closes"] == 0
|
|
|
|
def test_other_shells_record_before_run(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["guess"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run",
|
|
autospec=True) as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"):
|
|
mock_run.side_effect = self._run_with_returncode(
|
|
mock_learned["order"], 1)
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["order"] == ["record", "run"]
|
|
assert mock_learned["closes"] == 0
|
|
|
|
def test_learned_only_win_does_not_rerecord_on_other_shells(
|
|
self, mock_learned, known_args, settings, monkeypatch
|
|
):
|
|
mock_learned["correction"] = CORRECTED
|
|
|
|
with patch("thefuck.types.CorrectedCommand.run",
|
|
autospec=True) as mock_run, patch(
|
|
"thefuck.logs.show_corrected_command"):
|
|
mock_run.side_effect = self._run_with_returncode(
|
|
mock_learned["order"], 0)
|
|
fix_command(known_args)
|
|
|
|
assert mock_learned["order"] == ["run"]
|
|
assert mock_learned["recordings"] == []
|
|
|
|
|
|
class TestNushellRunStashesReturncode(object):
|
|
def test_run_stashes_nu_returncode_on_the_instance(
|
|
self, settings, monkeypatch
|
|
):
|
|
settings.alter_history = False
|
|
monkeypatch.setattr("thefuck.types.shell", Nushell())
|
|
completed = Mock(returncode=3)
|
|
with patch("thefuck.types.subprocess.run",
|
|
return_value=completed) as mock_spawn:
|
|
corrected = CorrectedCommand("git push", None, 0)
|
|
corrected.run(Command("git psuh", ""))
|
|
mock_spawn.assert_called_once_with(["nu", "-c", "git push"])
|
|
assert corrected.returncode == 3
|