fixpm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fixpm/__init__.py +3 -0
- fixpm/__main__.py +3 -0
- fixpm/cli.py +132 -0
- fixpm/corrector.py +118 -0
- fixpm/detector.py +223 -0
- fixpm/distance.py +63 -0
- fixpm/interactive.py +28 -0
- fixpm/packages.py +204 -0
- fixpm/rules/__init__.py +6 -0
- fixpm/rules/base.py +109 -0
- fixpm/rules/npm.py +97 -0
- fixpm/rules/npx.py +29 -0
- fixpm/rules/pnpm.py +58 -0
- fixpm/rules/yarn.py +48 -0
- fixpm/shell/__init__.py +1 -0
- fixpm/shell/fixpm.bash +79 -0
- fixpm/shell/fixpm.zsh +34 -0
- fixpm-0.1.0.dist-info/METADATA +199 -0
- fixpm-0.1.0.dist-info/RECORD +22 -0
- fixpm-0.1.0.dist-info/WHEEL +4 -0
- fixpm-0.1.0.dist-info/entry_points.txt +2 -0
- fixpm-0.1.0.dist-info/licenses/LICENSE +21 -0
fixpm/__init__.py
ADDED
fixpm/__main__.py
ADDED
fixpm/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""CLI entry point.
|
|
2
|
+
|
|
3
|
+
Modes:
|
|
4
|
+
fixpm fix $FIXPM_LAST_COMMAND (set by shell hook)
|
|
5
|
+
fixpm <command words...> fix an explicit command line
|
|
6
|
+
fixpm --dry-run <cmd> print fixes without prompting/executing
|
|
7
|
+
fixpm --init zsh|bash emit the hook script for eval "$(...)"
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from importlib import resources
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .corrector import get_corrections
|
|
21
|
+
from .interactive import choose
|
|
22
|
+
from .rules import all_specs, spec_for_binary
|
|
23
|
+
from .rules.base import KIND_LABEL
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
add_completion=False,
|
|
27
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
28
|
+
help="Fix broken npm / npx / pnpm / yarn commands.",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ENV_COMMAND = "FIXPM_LAST_COMMAND"
|
|
32
|
+
ENV_EXIT_CODE = "FIXPM_LAST_EXIT_CODE"
|
|
33
|
+
|
|
34
|
+
_HOOKS = {"bash": "fixpm.bash", "zsh": "fixpm.zsh"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_callback(value: bool) -> None:
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(f"fixpm {__version__}")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback(invoke_without_command=True)
|
|
44
|
+
def main(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
command: list[str] | None = typer.Argument(
|
|
47
|
+
None, help="Failed command to fix. Reads $FIXPM_LAST_COMMAND when omitted."
|
|
48
|
+
),
|
|
49
|
+
manager: str | None = typer.Option(
|
|
50
|
+
None, "--manager", "-m",
|
|
51
|
+
help="Force a package manager instead of auto-detecting.",
|
|
52
|
+
),
|
|
53
|
+
dry_run: bool = typer.Option(
|
|
54
|
+
False, "--dry-run", help="Print fixes without prompting or executing."
|
|
55
|
+
),
|
|
56
|
+
version: bool = typer.Option(
|
|
57
|
+
False, "--version", "-V", callback=_version_callback, is_eager=True,
|
|
58
|
+
help="Show version and exit.",
|
|
59
|
+
),
|
|
60
|
+
init_shell: str | None = typer.Option(
|
|
61
|
+
None, "--init",
|
|
62
|
+
help='Emit a hook script for the given shell ("bash" or "zsh"). '
|
|
63
|
+
'Use inside eval: eval "$(fixpm --init zsh)".',
|
|
64
|
+
),
|
|
65
|
+
) -> None:
|
|
66
|
+
if init_shell is not None:
|
|
67
|
+
_emit_hook(init_shell)
|
|
68
|
+
return
|
|
69
|
+
if ctx.invoked_subcommand is not None:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
text = " ".join(command).strip() if command \
|
|
73
|
+
else os.environ.get(ENV_COMMAND, "").strip()
|
|
74
|
+
if not text:
|
|
75
|
+
typer.secho("No command provided.", fg="red")
|
|
76
|
+
typer.echo(
|
|
77
|
+
'Run `fixpm <failed command>` or install a shell hook first: '
|
|
78
|
+
'`fixpm --init zsh`.'
|
|
79
|
+
)
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
|
|
82
|
+
spec = None
|
|
83
|
+
if manager:
|
|
84
|
+
spec = spec_for_binary(manager)
|
|
85
|
+
if spec is None:
|
|
86
|
+
names = ", ".join(sorted({s.binaries[0] for s in all_specs()}))
|
|
87
|
+
raise typer.BadParameter(f"unknown manager {manager!r}; pick one of: {names}")
|
|
88
|
+
|
|
89
|
+
corrections = get_corrections(text, spec=spec)
|
|
90
|
+
if not corrections:
|
|
91
|
+
typer.secho(f"No fix found for: {text}", fg="yellow")
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
if dry_run:
|
|
95
|
+
for c in corrections:
|
|
96
|
+
typer.echo(f" [{KIND_LABEL[c.kind]}] {c.command}")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
choice = choose(corrections)
|
|
100
|
+
if choice is None:
|
|
101
|
+
typer.echo("Cancelled.")
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
typer.secho(f"-> {choice.command}", fg="cyan", bold=True)
|
|
105
|
+
if not choice.executable:
|
|
106
|
+
typer.echo("This fix needs manual arguments — copy it and fill them in.")
|
|
107
|
+
raise typer.Exit(code=0)
|
|
108
|
+
|
|
109
|
+
raise typer.Exit(code=subprocess.run(choice.command, shell=True).returncode)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _emit_hook(shell: str) -> None:
|
|
113
|
+
filename = _HOOKS.get(shell)
|
|
114
|
+
if filename is None:
|
|
115
|
+
supported = ", ".join(sorted(_HOOKS))
|
|
116
|
+
typer.secho(f"Unsupported shell {shell!r}. Supported: {supported}",
|
|
117
|
+
fg="red", err=True)
|
|
118
|
+
raise typer.Exit(code=1)
|
|
119
|
+
script = (resources.files("fixpm.shell") / filename).read_text(encoding="utf-8")
|
|
120
|
+
# Write LF-only bytes directly: Windows pipes translate "\n" to "\r\n",
|
|
121
|
+
# and a unix shell sourcing CRLF text ends up with broken hook definitions
|
|
122
|
+
# (functions named "...{\r"). Git Bash/MSYS silently tolerates CRLF, WSL
|
|
123
|
+
# and native Linux do not — so the raw byte stream must be LF.
|
|
124
|
+
sys.stdout.buffer.write(script.replace("\r\n", "\n").encode("utf-8"))
|
|
125
|
+
sys.stdout.buffer.flush()
|
|
126
|
+
rc_file = "~/.zshrc" if shell == "zsh" else "~/.bashrc"
|
|
127
|
+
typer.secho(f"# Add this line to your {rc_file}: "
|
|
128
|
+
f'eval "$(fixpm --init {shell})"', dim=True, err=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
app()
|
fixpm/corrector.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Turn analyzer issues into concrete, ranked replacement commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
from .detector import analyze, tokenize
|
|
8
|
+
from .distance import similarity
|
|
9
|
+
from .packages import NpmRegistry, base_name, format_downloads
|
|
10
|
+
from .rules.base import Correction, Issue, IssueKind, ManagerSpec
|
|
11
|
+
|
|
12
|
+
_client: NpmRegistry | None = None
|
|
13
|
+
|
|
14
|
+
# Placeholder shown when a fix needs manual input (never auto-executed).
|
|
15
|
+
_PLACEHOLDERS = {
|
|
16
|
+
"run": "<script>",
|
|
17
|
+
"exec": "<package> [args...]",
|
|
18
|
+
"dlx": "<package> [args...]",
|
|
19
|
+
"add": "<package>",
|
|
20
|
+
"install": "<package>",
|
|
21
|
+
"remove": "<package>",
|
|
22
|
+
"uninstall": "<package>",
|
|
23
|
+
"why": "<package>",
|
|
24
|
+
"info": "<package>",
|
|
25
|
+
"create": "<initializer>",
|
|
26
|
+
"config": "<key> <value>",
|
|
27
|
+
"cache": "<action>",
|
|
28
|
+
"pkg": "<action>",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_client() -> NpmRegistry:
|
|
33
|
+
global _client
|
|
34
|
+
if _client is None:
|
|
35
|
+
_client = NpmRegistry()
|
|
36
|
+
return _client
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_corrections(
|
|
40
|
+
text: str,
|
|
41
|
+
*,
|
|
42
|
+
spec: ManagerSpec | None = None,
|
|
43
|
+
client: NpmRegistry | None = None,
|
|
44
|
+
) -> list[Correction]:
|
|
45
|
+
"""Top fix suggestions (max 3), best first."""
|
|
46
|
+
tokens, spec, issues = analyze(text, spec)
|
|
47
|
+
if spec is None or not issues:
|
|
48
|
+
return []
|
|
49
|
+
client = client or _default_client()
|
|
50
|
+
corrections: list[Correction] = []
|
|
51
|
+
for issue in issues:
|
|
52
|
+
corrections.extend(_fix_issue(tokens, spec, issue, client))
|
|
53
|
+
best: dict[str, Correction] = {}
|
|
54
|
+
for c in corrections:
|
|
55
|
+
if c.command not in best or c.score > best[c.command].score:
|
|
56
|
+
best[c.command] = c
|
|
57
|
+
ranked = sorted(best.values(), key=lambda c: (-c.score, c.command))
|
|
58
|
+
return ranked[:3]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ratio_score(ratio: float) -> float:
|
|
62
|
+
return round(0.5 + 0.45 * ratio, 3)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _replace(tokens: list[str], index: int, new_token: str) -> str:
|
|
66
|
+
patched = list(tokens)
|
|
67
|
+
patched[index] = new_token
|
|
68
|
+
return shlex.join(patched)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fix_issue(tokens: list[str], spec: ManagerSpec, issue: Issue,
|
|
72
|
+
client: NpmRegistry) -> list[Correction]:
|
|
73
|
+
kind = issue.kind
|
|
74
|
+
|
|
75
|
+
if kind is IssueKind.SUBCOMMAND_TYPO:
|
|
76
|
+
out = []
|
|
77
|
+
hinted = spec.typo_hints.get(issue.token.lower())
|
|
78
|
+
for cand in issue.candidates[:3]:
|
|
79
|
+
score = (0.95 if cand == hinted
|
|
80
|
+
else _ratio_score(similarity(issue.token.lower(), cand)))
|
|
81
|
+
out.append(Correction(_replace(tokens, issue.index, cand),
|
|
82
|
+
kind, score, issue.message))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
if kind is IssueKind.FLAG_TYPO:
|
|
86
|
+
return [
|
|
87
|
+
Correction(_replace(tokens, issue.index, cand), kind,
|
|
88
|
+
_ratio_score(similarity(issue.token, cand)),
|
|
89
|
+
issue.message)
|
|
90
|
+
for cand in issue.candidates[:2]
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
if kind is IssueKind.MISSING_DASHES:
|
|
94
|
+
return [Correction(_replace(tokens, issue.index, issue.candidates[0]),
|
|
95
|
+
kind, 0.9, issue.message)]
|
|
96
|
+
|
|
97
|
+
if kind is IssueKind.ARG_REQUIRED:
|
|
98
|
+
placeholder = _PLACEHOLDERS.get(issue.token, "<arguments>")
|
|
99
|
+
return [Correction(f"{shlex.join(tokens)} {placeholder}", kind, 0.6,
|
|
100
|
+
issue.message, False)]
|
|
101
|
+
|
|
102
|
+
if kind is IssueKind.PACKAGE_TYPO:
|
|
103
|
+
name = base_name(issue.token)
|
|
104
|
+
out = []
|
|
105
|
+
for s in client.suggest(name)[:3]:
|
|
106
|
+
if s.name.lower() == name.lower():
|
|
107
|
+
continue
|
|
108
|
+
new_token = issue.token.replace(name, s.name, 1)
|
|
109
|
+
detail = (f"package '{name}' -> '{s.name}' "
|
|
110
|
+
f"({format_downloads(s.weekly_downloads)}/week)")
|
|
111
|
+
out.append(Correction(_replace(tokens, issue.index, new_token),
|
|
112
|
+
kind, s.score, detail))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["get_corrections", "tokenize"]
|
fixpm/detector.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Classify what went wrong in a failed package-manager command line.
|
|
2
|
+
|
|
3
|
+
One generic engine consumes every ``ManagerSpec``; per-manager knowledge lives
|
|
4
|
+
entirely in the rule tables under ``fixpm.rules``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
|
|
12
|
+
from .distance import rank
|
|
13
|
+
from .rules import spec_for_binary
|
|
14
|
+
from .rules.base import Issue, IssueKind, ManagerSpec
|
|
15
|
+
|
|
16
|
+
_PKG_RE = re.compile(
|
|
17
|
+
r"^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$", re.IGNORECASE
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tokenize(text: str) -> list[str]:
|
|
22
|
+
try:
|
|
23
|
+
return shlex.split(text)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return text.split()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def looks_like_package(token: str) -> bool:
|
|
29
|
+
"""Conservative npm-package-name shape check (paths, globs and files out)."""
|
|
30
|
+
if not token or any(c in token for c in "*~=\\"):
|
|
31
|
+
return False
|
|
32
|
+
if token.startswith((".", "~")):
|
|
33
|
+
return False
|
|
34
|
+
if "/" in token and not token.startswith("@"):
|
|
35
|
+
return False
|
|
36
|
+
return bool(_PKG_RE.match(token))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _nearest(token: str, pool: tuple[str, ...], top: int = 2,
|
|
40
|
+
min_score: float = 0.6) -> list[str]:
|
|
41
|
+
return [c for c, _ in rank(token.lower(), [p.lower() for p in sorted(pool)],
|
|
42
|
+
top=top, min_score=min_score)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sub_candidates(token: str, spec: ManagerSpec) -> list[str]:
|
|
46
|
+
hint = spec.typo_hints.get(token.lower())
|
|
47
|
+
if hint:
|
|
48
|
+
return [hint]
|
|
49
|
+
return _nearest(token, tuple(spec.vocabulary), top=3, min_score=0.55)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def analyze(
|
|
53
|
+
text: str, spec: ManagerSpec | None = None
|
|
54
|
+
) -> tuple[list[str], ManagerSpec | None, list[Issue]]:
|
|
55
|
+
"""Return ``(tokens, spec_or_None, issues)`` for a failed command line."""
|
|
56
|
+
tokens = tokenize(text)
|
|
57
|
+
located = _locate(tokens, spec)
|
|
58
|
+
if located is None:
|
|
59
|
+
return tokens, None, []
|
|
60
|
+
index, spec = located
|
|
61
|
+
if spec.package_first:
|
|
62
|
+
return tokens, spec, _analyze_package_first(spec, tokens, index + 1)
|
|
63
|
+
return tokens, spec, _analyze_subcommand(spec, tokens, index + 1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _locate(tokens: list[str],
|
|
67
|
+
spec: ManagerSpec | None) -> tuple[int, ManagerSpec] | None:
|
|
68
|
+
if spec is not None:
|
|
69
|
+
for i, t in enumerate(tokens[:4]):
|
|
70
|
+
if t in spec.binaries:
|
|
71
|
+
return i, spec
|
|
72
|
+
return None
|
|
73
|
+
for i, t in enumerate(tokens[:4]):
|
|
74
|
+
found = spec_for_binary(t)
|
|
75
|
+
if found is not None:
|
|
76
|
+
return i, found
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _analyze_subcommand(spec: ManagerSpec, tokens: list[str],
|
|
81
|
+
start: int) -> list[Issue]:
|
|
82
|
+
issues: list[Issue] = []
|
|
83
|
+
rest = tokens[start:]
|
|
84
|
+
|
|
85
|
+
# Leading global flags, e.g. `npm --prefix ./x install`
|
|
86
|
+
i = 0
|
|
87
|
+
while i < len(rest) and rest[i].startswith("-"):
|
|
88
|
+
flag = rest[i]
|
|
89
|
+
if flag in spec.value_flags and i + 1 < len(rest):
|
|
90
|
+
i += 2
|
|
91
|
+
continue
|
|
92
|
+
if flag not in spec.global_flags:
|
|
93
|
+
near = _nearest(flag, spec.global_flags)
|
|
94
|
+
if near:
|
|
95
|
+
issues.append(Issue(
|
|
96
|
+
IssueKind.FLAG_TYPO, flag, start + i,
|
|
97
|
+
f"Unknown global flag '{flag}' — did you mean {near[0]}?",
|
|
98
|
+
tuple(near),
|
|
99
|
+
))
|
|
100
|
+
i += 1
|
|
101
|
+
|
|
102
|
+
if i >= len(rest):
|
|
103
|
+
return issues
|
|
104
|
+
|
|
105
|
+
sub = rest[i]
|
|
106
|
+
sub_abs = start + i
|
|
107
|
+
canon = spec.canonical(sub)
|
|
108
|
+
|
|
109
|
+
if canon is None:
|
|
110
|
+
candidates = _sub_candidates(sub, spec)
|
|
111
|
+
if candidates:
|
|
112
|
+
issues.append(Issue(
|
|
113
|
+
IssueKind.SUBCOMMAND_TYPO, sub, sub_abs,
|
|
114
|
+
f"'{sub}' is not a {spec.name} command",
|
|
115
|
+
tuple(candidates),
|
|
116
|
+
))
|
|
117
|
+
# One fix at a time: stop validating after an unknown command.
|
|
118
|
+
return issues
|
|
119
|
+
|
|
120
|
+
# Chain commands like `yarn global add <pkg>` — absorb the sub-verb.
|
|
121
|
+
extra = 0
|
|
122
|
+
if canon == "global" and spec.sub_verbs and \
|
|
123
|
+
i + 1 < len(rest) and rest[i + 1] in spec.sub_verbs:
|
|
124
|
+
extra = 1
|
|
125
|
+
canon = f"global {rest[i + 1]}"
|
|
126
|
+
effective = canon.rsplit(" ", 1)[-1]
|
|
127
|
+
allowed = spec.flags.get(effective, ())
|
|
128
|
+
|
|
129
|
+
positional: list[tuple[int, str]] = []
|
|
130
|
+
j = i + 1 + extra
|
|
131
|
+
while j < len(rest):
|
|
132
|
+
token = rest[j]
|
|
133
|
+
if token == "--": # passthrough separator: everything after is exempt
|
|
134
|
+
break
|
|
135
|
+
if token.startswith("-"):
|
|
136
|
+
if allowed and token not in allowed and token not in spec.global_flags:
|
|
137
|
+
near = _nearest(token, allowed)
|
|
138
|
+
if near:
|
|
139
|
+
issues.append(Issue(
|
|
140
|
+
IssueKind.FLAG_TYPO, token, start + j,
|
|
141
|
+
f"Unknown flag '{token}' for `{spec.name} "
|
|
142
|
+
f"{effective}` — did you mean {near[0]}?",
|
|
143
|
+
tuple(near),
|
|
144
|
+
))
|
|
145
|
+
if token in spec.value_flags:
|
|
146
|
+
j += 1 # skip the consumed value
|
|
147
|
+
else:
|
|
148
|
+
positional.append((j, token))
|
|
149
|
+
j += 1
|
|
150
|
+
|
|
151
|
+
# `npm install express save-dev` -> missing dashes on `save-dev`
|
|
152
|
+
dashless = {f.lstrip("-"): f for f in allowed}
|
|
153
|
+
flagged: set[int] = set()
|
|
154
|
+
for jj, token in positional:
|
|
155
|
+
if len(token) > 1 and token in dashless:
|
|
156
|
+
issues.append(Issue(
|
|
157
|
+
IssueKind.MISSING_DASHES, token, start + jj,
|
|
158
|
+
f"Missing flag prefix — did you mean {dashless[token]}?",
|
|
159
|
+
(dashless[token],),
|
|
160
|
+
))
|
|
161
|
+
flagged.add(jj)
|
|
162
|
+
positional = [(jj, t) for jj, t in positional if jj not in flagged]
|
|
163
|
+
|
|
164
|
+
if canon in spec.arg_required and not positional and extra == 0:
|
|
165
|
+
issues.append(Issue(
|
|
166
|
+
IssueKind.ARG_REQUIRED, sub, sub_abs,
|
|
167
|
+
f"`{spec.name} {sub}` needs an argument "
|
|
168
|
+
f"(see `{spec.name} help {effective}`)",
|
|
169
|
+
(),
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
if canon in spec.package_commands or canon.startswith("global "):
|
|
173
|
+
checked = 0
|
|
174
|
+
for jj, token in positional:
|
|
175
|
+
if checked >= 2:
|
|
176
|
+
break
|
|
177
|
+
if token.lower() in spec.vocabulary or not looks_like_package(token):
|
|
178
|
+
continue
|
|
179
|
+
issues.append(Issue(
|
|
180
|
+
IssueKind.PACKAGE_TYPO, token, start + jj,
|
|
181
|
+
f"Check package name '{token}'",
|
|
182
|
+
(),
|
|
183
|
+
))
|
|
184
|
+
checked += 1
|
|
185
|
+
return issues
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _analyze_package_first(spec: ManagerSpec, tokens: list[str],
|
|
189
|
+
start: int) -> list[Issue]:
|
|
190
|
+
issues: list[Issue] = []
|
|
191
|
+
rest = tokens[start:]
|
|
192
|
+
i = 0
|
|
193
|
+
while i < len(rest) and rest[i].startswith("-"):
|
|
194
|
+
flag = rest[i]
|
|
195
|
+
if flag in spec.value_flags and i + 1 < len(rest):
|
|
196
|
+
i += 2
|
|
197
|
+
continue
|
|
198
|
+
if flag not in spec.global_flags:
|
|
199
|
+
near = _nearest(flag, spec.global_flags)
|
|
200
|
+
if near:
|
|
201
|
+
issues.append(Issue(
|
|
202
|
+
IssueKind.FLAG_TYPO, flag, start + i,
|
|
203
|
+
f"Unknown flag '{flag}' — did you mean {near[0]}?",
|
|
204
|
+
tuple(near),
|
|
205
|
+
))
|
|
206
|
+
i += 1
|
|
207
|
+
|
|
208
|
+
if i >= len(rest):
|
|
209
|
+
issues.append(Issue(
|
|
210
|
+
IssueKind.ARG_REQUIRED, spec.name, start - 1,
|
|
211
|
+
f"`{spec.name}` needs a package or binary to run",
|
|
212
|
+
(),
|
|
213
|
+
))
|
|
214
|
+
return issues
|
|
215
|
+
|
|
216
|
+
pkg = rest[i]
|
|
217
|
+
if looks_like_package(pkg):
|
|
218
|
+
issues.append(Issue(
|
|
219
|
+
IssueKind.PACKAGE_TYPO, pkg, start + i,
|
|
220
|
+
f"Check package name '{pkg}'",
|
|
221
|
+
(),
|
|
222
|
+
))
|
|
223
|
+
return issues
|
fixpm/distance.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""String-similarity primitives.
|
|
2
|
+
|
|
3
|
+
We use optimal-string-alignment (a restricted Damerau-Levenshtein) so common
|
|
4
|
+
transpositions ("svae" -> "save") cost 1, not 2. Pure stdlib, zero deps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def edit_distance(a: str, b: str) -> int:
|
|
11
|
+
if a == b:
|
|
12
|
+
return 0
|
|
13
|
+
la, lb = len(a), len(b)
|
|
14
|
+
if la == 0 or lb == 0:
|
|
15
|
+
return la + lb
|
|
16
|
+
prev2: list[int] | None = None
|
|
17
|
+
prev = list(range(lb + 1))
|
|
18
|
+
for i in range(1, la + 1):
|
|
19
|
+
cur = [i] + [0] * lb
|
|
20
|
+
ca = a[i - 1]
|
|
21
|
+
for j in range(1, lb + 1):
|
|
22
|
+
cost = 0 if ca == b[j - 1] else 1
|
|
23
|
+
v = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
|
|
24
|
+
if (
|
|
25
|
+
prev2 is not None
|
|
26
|
+
and i > 1
|
|
27
|
+
and j > 1
|
|
28
|
+
and ca == b[j - 2]
|
|
29
|
+
and a[i - 2] == b[j - 1]
|
|
30
|
+
):
|
|
31
|
+
v = min(v, prev2[j - 2] + 1)
|
|
32
|
+
cur[j] = v
|
|
33
|
+
prev2, prev = prev, cur
|
|
34
|
+
return prev[lb]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def similarity(a: str, b: str) -> float:
|
|
38
|
+
"""Normalized similarity in [0, 1]; 1 means identical."""
|
|
39
|
+
m = max(len(a), len(b))
|
|
40
|
+
if m == 0:
|
|
41
|
+
return 1.0
|
|
42
|
+
return 1.0 - edit_distance(a, b) / m
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rank(
|
|
46
|
+
target: str,
|
|
47
|
+
candidates: list[str],
|
|
48
|
+
*,
|
|
49
|
+
top: int = 3,
|
|
50
|
+
min_score: float = 0.6,
|
|
51
|
+
) -> list[tuple[str, float]]:
|
|
52
|
+
"""Best matches for *target*, sorted by score desc then length/name asc."""
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
scored: list[tuple[str, float]] = []
|
|
55
|
+
for c in candidates:
|
|
56
|
+
if c in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(c)
|
|
59
|
+
s = similarity(target, c)
|
|
60
|
+
if s >= min_score:
|
|
61
|
+
scored.append((c, s))
|
|
62
|
+
scored.sort(key=lambda t: (-t[1], len(t[0]), t[0]))
|
|
63
|
+
return scored[:top]
|
fixpm/interactive.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Arrow-key interactive selection (questionary / prompt_toolkit)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
from questionary import Choice
|
|
7
|
+
|
|
8
|
+
from .rules.base import KIND_LABEL, Correction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def choose(corrections: list[Correction]) -> Correction | None:
|
|
12
|
+
choices = [
|
|
13
|
+
Choice(
|
|
14
|
+
title=f"{c.command} · {KIND_LABEL[c.kind]} · {int(c.score * 100)}%",
|
|
15
|
+
value=c,
|
|
16
|
+
)
|
|
17
|
+
for c in corrections
|
|
18
|
+
]
|
|
19
|
+
choices.append(Choice(title="Skip — do nothing", value=None))
|
|
20
|
+
try:
|
|
21
|
+
return questionary.select(
|
|
22
|
+
"Apply a fix:",
|
|
23
|
+
choices=choices,
|
|
24
|
+
qmark="?",
|
|
25
|
+
instruction="(↑/↓ move · enter select · ctrl+c cancel)",
|
|
26
|
+
).ask()
|
|
27
|
+
except (KeyboardInterrupt, EOFError):
|
|
28
|
+
return None
|