Files
thefuck/tests/test_learned.py
T
Alexander 7d89277717 Scale typo gates with length and refuse destructive auto-runs
Similarity requirements now scale with word length (typo.floor_ok:
single edit or ratio >= max(0.6, 1 - 3/max(len,3)), first char kept;
zsh spdist / nushell did_you_mean derivation in the source). The
fixed-cutoff gates are swapped: history_resolver._similar ->
floor_ok with the divergence cap deleted (every diverged token is
gated per-token, not counted), learned.guess_from_path and the help
resolver candidate gates -> floor_ok, so _TOKEN_CUTOFF,
_MAX_DIVERGED, GUESS_CUTOFF, _CUTOFF and their difflib plumbing are
gone; unique-survivor, no-op and which/'/'/'.'/flag guards kept.

New thefuck/danger.py is_dangerous(script) parses via bashlex
directly and fail-safes to True when bashlex is unavailable or the
script refuses to parse (the flat fallback is head-only); with a
tree it matches rm/rmdir recursive flags, dd/mkfs*/shred/wipefs/
mkswap heads, git push --force/-f (not --force-with-lease),
chmod/chown -R with a 777-style mode, kill -9, fork-bomb shapes,
pipe-to-shell tails and file redirects outside /tmp and /dev/null.
fix_command checks it before ANY auto-run, learned-db exact hits
included: dangerous candidates fall through to rules+ask.

Test-migration inventory (authorized semantic inversions):
- tests/resolvers/test_history_resolver.py: declines-3-diverged ->
  corrects (cap deleted); 0.8-cutoff boundary arithmetic re-based to
  floor boundaries (len-3 0.6 / len-10 0.7 / len-30 0.9);
  _TOKEN_CUTOFF import removed with the constant; declines-just-
  below-cutoff re-based to the len-20 floor 0.85; added a 17-char
  below-floor decline.
- tests/test_learned.py: returns-none-below-cutoff re-based to a
  same-first-char below-floor pair (0.6 < len-10 floor 0.7); added a
  ratio-0.7 acceptance pin; single-edit-under-cutoff renamed.
- tests/resolvers/test_help_resolver.py: transposition comments
  re-based to the floor; added a below-floor subcommand decline.
- tests/entrypoints/test_fix_command_learned.py: exact-learned-wins
  fixture's 'git push --force' correction (now correctly refused)
  replaced by benign scripts; mock_learned stubs danger benign for
  platform-neutral auto-apply tests; added TestDangerOverride
  (real module, all four sources: reaches select_command, nothing
  auto-runs).
2026-09-14 20:42:26 +02:00

280 lines
12 KiB
Python

import pytest
from thefuck import shell_ast
from thefuck.learned import LearnedCorrections
@pytest.fixture
def learned(tmp_path):
lc = LearnedCorrections()
lc._db = {}
return lc
class TestRecord(object):
def test_noop_when_scripts_identical(self, learned):
learned.record("git push", "git push")
assert len(learned.db) == 0
def test_stores_full_command_mapping(self, learned):
learned.record("git psuh origin main", "git push origin main")
assert (
learned.db["cmd:git psuh origin main"]["corrected"]
== "git push origin main"
)
def test_increments_count_on_repeat(self, learned):
learned.record("git psuh", "git push")
learned.record("git psuh", "git push")
assert learned.db["cmd:git psuh"]["count"] == 2
def test_updates_timestamp(self, learned):
learned.record("git psuh", "git push")
first_ts = learned.db["cmd:git psuh"]["timestamp"]
learned.record("git psuh", "git push")
assert learned.db["cmd:git psuh"]["timestamp"] >= first_ts
def test_stores_command_word_replacement(self, learned):
learned.record("pyhton script.py", "python script.py")
assert learned.db["word:pyhton"]["replacement"] == "python"
def test_stores_subcommand_replacement(self, learned):
learned.record("git psuh origin main", "git push origin main")
assert learned.db["part:git:psuh"]["replacement"] == "push"
def test_stores_multiple_word_diffs(self, learned):
learned.record("gti comit -m msg", "git commit -m msg")
assert learned.db["word:gti"]["replacement"] == "git"
assert learned.db["part:git:comit"]["replacement"] == "commit"
def test_no_word_level_when_lengths_differ(self, learned):
learned.record("git push", "git push --set-upstream origin main")
assert "cmd:git push" in learned.db
assert not any(
k.startswith("word:") or k.startswith("part:") for k in learned.db
)
def test_updates_correction_on_changed_choice(self, learned):
learned.record("apt install vim", "sudo apt install vim")
learned.record("apt install vim", "apt-get install vim")
assert learned.db["cmd:apt install vim"]["corrected"] == "apt-get install vim"
assert learned.db["cmd:apt install vim"]["count"] == 2
class TestGetCorrection(object):
def test_exact_full_command_match(self, learned):
learned.record("git psuh origin main", "git push origin main")
assert learned.get_correction("git psuh origin main") == "git push origin main"
def test_generalises_subcommand_to_different_args(self, learned):
learned.record("git psuh origin main", "git push origin main")
assert learned.get_correction("git psuh origin dev") == "git push origin dev"
def test_generalises_command_word(self, learned):
learned.record("pyhton script.py", "python script.py")
assert learned.get_correction("pyhton other.py") == "python other.py"
def test_combined_command_and_subcommand(self, learned):
learned.record("gti comit -m msg", "git commit -m msg")
assert learned.get_correction('gti comit -m "other"') == 'git commit -m "other"'
def test_returns_none_when_no_match(self, learned):
assert learned.get_correction("totally unknown cmd") is None
def test_returns_none_for_empty_script(self, learned):
assert learned.get_correction("") is None
def test_prefers_full_command_over_word_level(self, learned):
learned.record("git psuh origin main", "git push origin main")
learned.db["cmd:git psuh origin main"]["corrected"] = (
"git push --force origin main"
)
assert (
learned.get_correction("git psuh origin main")
== "git push --force origin main"
)
def test_word_level_only_replaces_known_tokens(self, learned):
learned.record("git psuh origin main", "git push origin main")
result = learned.get_correction("git psuh origin dev")
assert result == "git push origin dev"
def test_cross_resolve_command_and_part(self, learned):
"""When both cmd word and subcommand are typos, parts stored
under the corrected cmd name still resolve."""
learned.record("gti psuh origin main", "git push origin main")
assert learned.get_correction("gti psuh origin dev") == "git push origin dev"
def test_single_word_command(self, learned):
learned.record("sl", "ls")
assert learned.get_correction("sl") == "ls"
class TestGuessFromPath(object):
@pytest.fixture
def path_bins(self, monkeypatch):
def setup(executables, existing=()):
monkeypatch.setattr('thefuck.learned.get_all_executables',
lambda: list(executables))
monkeypatch.setattr('thefuck.learned.which',
lambda token: token in existing)
return setup
def test_guesses_unique_close_match(self, learned, path_bins):
path_bins(executables=['clear', 'grep', 'sed'])
assert learned.guess_from_path('cler') == 'clear'
def test_keeps_arguments(self, learned, path_bins):
path_bins(executables=['python', 'pydoc', 'grep'])
assert (learned.guess_from_path('pyhton script.py')
== 'python script.py')
def test_returns_none_when_ambiguous(self, learned, path_bins):
path_bins(executables=['clear', 'clean'])
assert learned.guess_from_path('clea') is None
def test_returns_none_when_token_is_executable(self, learned, path_bins):
path_bins(executables=['clear'], existing=['clear'])
assert learned.guess_from_path('clear') is None
def test_returns_none_for_path_like_token(self, learned, path_bins):
path_bins(executables=['git', 'grep', 'sed'])
assert learned.guess_from_path('./gti push') is None
def test_returns_none_for_token_with_extension(self, learned, path_bins):
path_bins(executables=['git', 'grep', 'sed'])
assert learned.guess_from_path('giti.py x') is None
def test_returns_none_when_first_char_differs(self, learned, path_bins):
path_bins(executables=['top'])
assert learned.guess_from_path('htop') is None
def test_returns_none_below_length_scaled_floor(self, learned,
path_bins):
# 10-char token vs executable sharing only the 6 leading
# chars scores 2 * 6 / 20 = 0.6, under the len-10 floor 0.7,
# and the pair is not a single edit.
path_bins(executables=['burnnoabcd'])
assert learned.guess_from_path('burnno1234 file') is None
def test_guesses_via_ratio_above_scaled_floor(self, learned,
path_bins):
# Three trailing substitutions score exactly the len-10
# floor 0.7 — a pair the old fixed 0.8 cutoff declined — and
# no other executable shares the first char, so the ratio
# path alone admits the match.
path_bins(executables=['abcdefgxyz', 'grep', 'sed'])
assert (learned.guess_from_path('abcdefghij file')
== 'abcdefgxyz file')
def test_guesses_after_sudo(self, learned, path_bins):
path_bins(executables=['clear', 'grep', 'sed'])
assert learned.guess_from_path('sudo cler') == 'sudo clear'
def test_returns_none_for_empty_script(self, learned, path_bins):
path_bins(executables=['git'])
assert learned.guess_from_path('') is None
class TestGuessFromPathSegments(object):
pytestmark = pytest.mark.skipif(
not shell_ast.AST_AVAILABLE, reason='bashlex required')
@pytest.fixture
def path_bins(self, monkeypatch):
def setup(executables, existing=()):
monkeypatch.setattr('thefuck.learned.get_all_executables',
lambda: list(executables))
monkeypatch.setattr('thefuck.learned.which',
lambda token: token in existing)
return setup
def test_fixes_head_of_every_pipe_segment(self, learned, path_bins):
path_bins(executables=['git', 'grep', 'sed'])
assert (learned.guess_from_path('gi psuh | gre -i foo')
== 'git psuh | grep -i foo')
def test_fixes_single_edit_typos(self, learned, path_bins):
# gti -> git scores 2 * 2 / 6 = 0.667 and greo -> grep scores
# 2 * 3 / 8 = 0.75; each is a first-char-equal single edit
# with one executable match.
path_bins(executables=['git', 'grep', 'sed'])
assert (learned.guess_from_path('gti psuh | greo -i foo')
== 'git psuh | grep -i foo')
def test_declines_single_edit_ambiguity(self, learned, path_bins):
# gti is one edit from both git (transposition) and gui
# (substitution), each scoring 2 * 2 / 6 = 0.667, so two
# candidates survive and the segment is skipped.
path_bins(executables=['git', 'gui'])
assert learned.guess_from_path('gti psuh') is None
def test_fixes_only_segment_with_unknown_head(self, learned, path_bins):
path_bins(executables=['git', 'grep', 'sed'], existing=['git'])
assert (learned.guess_from_path('git psuh | gre -i foo')
== 'git psuh | grep -i foo')
def test_returns_none_when_all_heads_executable(self, learned, path_bins):
path_bins(executables=['git', 'grep'], existing=['git', 'grep'])
assert learned.guess_from_path('git psuh | grep -i foo') is None
def test_skips_ambiguous_segment_fixes_others(self, learned, path_bins):
path_bins(executables=['clear', 'clean', 'grep'])
assert (learned.guess_from_path('clea psuh | gre -i foo')
== 'clea psuh | grep -i foo')
def test_guesses_after_sudo_in_pipe(self, learned, path_bins):
path_bins(executables=['clear', 'grep'])
assert (learned.guess_from_path('sudo cler | gre -i foo')
== 'sudo clear | grep -i foo')
def test_guesses_after_sudo_flat(self, learned, path_bins):
path_bins(executables=['clear', 'grep', 'sed'])
assert learned.guess_from_path('sudo cler') == 'sudo clear'
def test_replaces_quoted_head_whole(self, learned, path_bins):
path_bins(executables=['clear', 'grep', 'sed'])
assert learned.guess_from_path('"cler" psuh') == 'clear psuh'
def test_preserves_spacing_between_words(self, learned, path_bins):
path_bins(executables=['clear', 'grep', 'sed'])
assert learned.guess_from_path('cler psuh') == 'clear psuh'
def test_unparseable_script_falls_back_to_flat_view(
self, learned, path_bins):
path_bins(executables=['clear', 'grep', 'sed'])
assert (
learned.guess_from_path('cler psuh; case $x in y) ;; esac')
== 'clear psuh; case $x in y) ;; esac')
class TestClear(object):
def test_removes_all_entries(self, learned):
learned.record("git psuh", "git push")
learned.record("pyhton x.py", "python x.py")
learned.clear()
assert len(learned.db) == 0
def test_no_matches_after_clear(self, learned):
learned.record("git psuh", "git push")
learned.clear()
assert learned.get_correction("git psuh") is None
class TestRoundTrip(object):
def test_record_then_match(self, learned):
learned.record("docker bilud .", "docker build .")
assert learned.get_correction("docker bilud .") == "docker build ."
def test_record_then_generalise(self, learned):
learned.record("docker bilud -t foo .", "docker build -t foo .")
assert (
learned.get_correction("docker bilud -t bar .") == "docker build -t bar ."
)
def test_multiple_distinct_commands(self, learned):
learned.record("git psuh", "git push")
learned.record("pyhton x.py", "python x.py")
assert learned.get_correction("git psuh") == "git push"
assert learned.get_correction("pyhton y.py") == "python y.py"