Compose fixes to a fixpoint and learn only from success

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).
This commit is contained in:
Alexander
2026-09-14 21:02:14 +02:00
parent 58f6cca9cb
commit ab49450467
8 changed files with 895 additions and 94 deletions
+254 -65
View File
@@ -2,61 +2,92 @@ import pytest
from mock import Mock, patch
from thefuck import danger as real_danger
from thefuck.entrypoints.fix_command import fix_command
from thefuck.types import CorrectedCommand
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, "guess": None, "history": None,
"help": None, "recordings": [], "calls": []}
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.
# 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 fake_get_correction(script):
state["calls"].append("correction")
return state["correction"]
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 fake_history(command):
state["calls"].append("history")
return state["history"]
def script_source(key, label):
def fake(script):
state["calls"].append(label)
return scripted(key, script)
return fake
def fake_guess(script):
state["calls"].append("guess")
return state["guess"]
def fake_help(script):
state["calls"].append("help")
return state["help"]
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.entrypoints.fix_command.get_correction", fake_get_correction
"thefuck.compose.get_correction",
script_source("correction", "learned")
)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_history_correction", fake_history
"thefuck.compose.get_suggestion_candidates",
command_source("suggestion", "error-suggestion")
)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.guess_from_path", fake_guess
"thefuck.compose.get_history_correction",
command_source("history", "history")
)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_help_correction", fake_help
"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
)
monkeypatch.setattr("thefuck.entrypoints.fix_command.record", fake_record)
return state
@pytest.fixture
def known_args():
return Mock(
force_command="git psuh origin main", yes=False, debug=False, repeat=False
force_command="git psuh origin main", yes=False, debug=False,
repeat=False
)
@@ -64,9 +95,10 @@ class TestLearnedAutoApply(object):
def test_auto_applies_learned_correction(
self, mock_learned, known_args, settings, monkeypatch
):
mock_learned["correction"] = "git push origin main"
mock_learned["correction"] = CORRECTED
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_corrected_commands", lambda _: iter([])
"thefuck.entrypoints.fix_command.get_corrected_commands",
lambda _: iter([])
)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.select_command", lambda _: None
@@ -81,10 +113,11 @@ class TestLearnedAutoApply(object):
def test_learned_skips_rule_matching(
self, mock_learned, known_args, settings, monkeypatch
):
mock_learned["correction"] = "git push origin main"
mock_learned["correction"] = CORRECTED
get_corrected = Mock()
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_corrected_commands", get_corrected
"thefuck.entrypoints.fix_command.get_corrected_commands",
get_corrected
)
with patch("thefuck.types.CorrectedCommand.run"), patch(
@@ -96,7 +129,7 @@ class TestLearnedAutoApply(object):
def test_shows_corrected_command_on_auto_apply(
self, mock_learned, known_args, settings, monkeypatch
):
mock_learned["correction"] = "git push origin main"
mock_learned["correction"] = CORRECTED
with patch("thefuck.types.CorrectedCommand.run"), patch(
"thefuck.logs.show_corrected_command"
@@ -104,17 +137,18 @@ class TestLearnedAutoApply(object):
fix_command(known_args)
assert mock_show.call_count == 1
shown_cmd = mock_show.call_args[0][0]
assert shown_cmd.script == "git push origin main"
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"] = "git push origin main"
mock_learned["guess"] = CORRECTED
get_corrected = Mock()
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_corrected_commands", get_corrected
"thefuck.entrypoints.fix_command.get_corrected_commands",
get_corrected
)
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
@@ -123,15 +157,13 @@ class TestGuessAutoApply(object):
fix_command(known_args)
mock_run.assert_called_once()
get_corrected.assert_not_called()
assert mock_learned["recordings"] == [
("git psuh origin main", "git push origin main")
]
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"] = "git push origin main"
mock_learned["guess"] = CORRECTED
with patch("thefuck.types.CorrectedCommand.run"), patch(
"thefuck.logs.show_corrected_command"
@@ -143,13 +175,18 @@ class TestGuessAutoApply(object):
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"] = "git push origin main"
mock_learned["help"] = CORRECTED
get_corrected = Mock()
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_corrected_commands", get_corrected
"thefuck.entrypoints.fix_command.get_corrected_commands",
get_corrected
)
with patch("thefuck.types.CorrectedCommand.run"), patch(
@@ -158,18 +195,24 @@ class TestResolverChainOrder(object):
fix_command(known_args)
assert mock_learned["calls"] == [
"correction", "history", "guess", "help"
"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", ["correction", "history"]),
("guess", ["correction", "history", "guess"]),
("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_chain(
self, mock_learned, known_args, settings, hit_source, expected_calls
def test_earlier_hit_stops_the_round(
self, mock_learned, known_args, settings, monkeypatch, hit_source,
expected_calls
):
mock_learned[hit_source] = "git push origin main"
mock_learned[hit_source] = CORRECTED
with patch("thefuck.types.CorrectedCommand.run"), patch(
"thefuck.logs.show_corrected_command"
@@ -183,7 +226,7 @@ class TestResolverChainOrder(object):
):
history_mock = Mock(return_value=None)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_history_correction", history_mock
"thefuck.compose.get_history_correction", history_mock
)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_corrected_commands",
@@ -197,7 +240,7 @@ class TestResolverChainOrder(object):
fix_command(known_args)
assert history_mock.call_count == 1
assert history_mock.call_args[0][0].script == "git psuh origin main"
assert history_mock.call_args[0][0].script == ORIGINAL
class TestResolverAutoApply(object):
@@ -205,10 +248,11 @@ class TestResolverAutoApply(object):
def test_resolver_hit_records_and_auto_applies(
self, mock_learned, known_args, settings, monkeypatch, source
):
mock_learned[source] = "git push origin main"
mock_learned[source] = CORRECTED
get_corrected = Mock()
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.get_corrected_commands", get_corrected
"thefuck.entrypoints.fix_command.get_corrected_commands",
get_corrected
)
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
@@ -217,25 +261,44 @@ class TestResolverAutoApply(object):
fix_command(known_args)
mock_run.assert_called_once()
get_corrected.assert_not_called()
assert mock_show.call_args[0][0].script == "git push origin main"
assert mock_learned["recordings"] == [
("git psuh origin main", "git push origin main")
]
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", "history", "guess", "help"])
"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 — reaches
# select_command and nothing auto-runs. The abort via the
# mocked selection keeps the pin free of terminal IO.
# 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(
@@ -262,27 +325,27 @@ class TestRecordOnSelection(object):
self, mock_learned, known_args, settings, monkeypatch
):
selected = CorrectedCommand(
script="git push origin main", side_effect=None, priority=100
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
"thefuck.entrypoints.fix_command.select_command",
lambda _: selected
)
with patch("thefuck.types.CorrectedCommand.run"):
fix_command(known_args)
assert mock_learned["recordings"] == [
("git psuh origin main", "git push origin main")
]
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([])
"thefuck.entrypoints.fix_command.get_corrected_commands",
lambda _: iter([])
)
monkeypatch.setattr(
"thefuck.entrypoints.fix_command.select_command", lambda _: None
@@ -298,14 +361,15 @@ class TestFallthrough(object):
self, mock_learned, known_args, settings, monkeypatch
):
selected = CorrectedCommand(
script="git push origin main", side_effect=None, priority=100
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
"thefuck.entrypoints.fix_command.select_command",
lambda _: selected
)
with patch("thefuck.types.CorrectedCommand.run") as mock_run:
@@ -316,7 +380,7 @@ class TestFallthrough(object):
self, mock_learned, known_args, settings, monkeypatch
):
selected = CorrectedCommand(
script="git push origin main", side_effect=None, priority=100
script=CORRECTED, side_effect=None, priority=100
)
select = Mock(return_value=selected)
monkeypatch.setattr(
@@ -332,6 +396,131 @@ class TestFallthrough(object):
mock_run.assert_called_once()
assert mock_learned["calls"] == [
"correction", "history", "guess", "help"
"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