wildlint 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.
- wildlint/__init__.py +15 -0
- wildlint/__main__.py +6 -0
- wildlint/checkers.py +245 -0
- wildlint/cli.py +85 -0
- wildlint-0.1.0.dist-info/METADATA +96 -0
- wildlint-0.1.0.dist-info/RECORD +10 -0
- wildlint-0.1.0.dist-info/WHEEL +5 -0
- wildlint-0.1.0.dist-info/entry_points.txt +2 -0
- wildlint-0.1.0.dist-info/licenses/LICENSE +21 -0
- wildlint-0.1.0.dist-info/top_level.txt +1 -0
wildlint/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""wildlint — static checks distilled from real upstream bugs.
|
|
2
|
+
|
|
3
|
+
Each rule was born from a concrete bug fixed in a public project, generalized to
|
|
4
|
+
the smallest form that catches the class without flooding you with false
|
|
5
|
+
positives. See ``checkers.py`` for the rule provenance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .checkers import CHECKERS, Finding, check_source
|
|
11
|
+
from .cli import main
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["CHECKERS", "Finding", "check_source", "main", "__version__"]
|
wildlint/__main__.py
ADDED
wildlint/checkers.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Detector registry for wildlint.
|
|
2
|
+
|
|
3
|
+
Each checker is distilled from a *real* bug found in the wild (a public upstream
|
|
4
|
+
PR), generalized into the smallest static rule that still catches the class
|
|
5
|
+
without drowning the user in false positives. Checkers that could only be made
|
|
6
|
+
to fire with an unacceptable false-positive rate are documented in
|
|
7
|
+
``NON_GENERALIZED`` rather than shipped.
|
|
8
|
+
|
|
9
|
+
A checker is any object exposing ``code``, ``name``, ``tier`` and a
|
|
10
|
+
``check(tree, path) -> list[Finding]`` method. Register one by appending an
|
|
11
|
+
instance to ``CHECKERS``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
DEFAULT = "default" # low false-positive; on unless deselected
|
|
20
|
+
PEDANTIC = "pedantic" # higher false-positive; opt-in via --pedantic
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Finding:
|
|
25
|
+
path: str
|
|
26
|
+
line: int
|
|
27
|
+
col: int
|
|
28
|
+
code: str
|
|
29
|
+
message: str
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _str_const(node: ast.expr) -> str | None:
|
|
36
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
37
|
+
return node.value
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
# WL001 — replace-to-empty used as a prefix/suffix strip
|
|
43
|
+
# Origin: nephila/giturlparse PR #149
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
class ReplaceToEmptyPrefix:
|
|
46
|
+
"""``x.replace(P, "")`` guarded by ``x.startswith(P)`` / ``x.endswith(P)``.
|
|
47
|
+
|
|
48
|
+
``str.replace`` removes *every* occurrence, so a value that contains the
|
|
49
|
+
marker twice is silently corrupted (``"/blob/x/blob/y" -> "x/y"``). The
|
|
50
|
+
author meant ``str.removeprefix`` / ``str.removesuffix``. Narrow by design:
|
|
51
|
+
fires only when the *same receiver* is guarded by the *same* literal.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
code = "WL001"
|
|
55
|
+
name = "replace-to-empty-prefix"
|
|
56
|
+
tier = DEFAULT
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _guard(test: ast.expr) -> tuple[str, str, str] | None:
|
|
60
|
+
if not (isinstance(test, ast.Call) and isinstance(test.func, ast.Attribute)):
|
|
61
|
+
return None
|
|
62
|
+
method = test.func.attr
|
|
63
|
+
if method not in ("startswith", "endswith"):
|
|
64
|
+
return None
|
|
65
|
+
if len(test.args) != 1 or test.keywords:
|
|
66
|
+
return None
|
|
67
|
+
literal = _str_const(test.args[0])
|
|
68
|
+
if literal is None:
|
|
69
|
+
return None
|
|
70
|
+
suggestion = "removeprefix" if method == "startswith" else "removesuffix"
|
|
71
|
+
return ast.unparse(test.func.value), literal, suggestion
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _is_replace_to_empty(node: ast.Call, receiver_src: str, literal: str) -> bool:
|
|
75
|
+
if not (isinstance(node.func, ast.Attribute) and node.func.attr == "replace"):
|
|
76
|
+
return False
|
|
77
|
+
if len(node.args) != 2 or node.keywords:
|
|
78
|
+
return False
|
|
79
|
+
if _str_const(node.args[0]) != literal or _str_const(node.args[1]) != "":
|
|
80
|
+
return False
|
|
81
|
+
return ast.unparse(node.func.value) == receiver_src
|
|
82
|
+
|
|
83
|
+
def check(self, tree: ast.AST, path: str) -> list[Finding]:
|
|
84
|
+
out: list[Finding] = []
|
|
85
|
+
for node in ast.walk(tree):
|
|
86
|
+
if not isinstance(node, ast.If):
|
|
87
|
+
continue
|
|
88
|
+
guard = self._guard(node.test)
|
|
89
|
+
if guard is None:
|
|
90
|
+
continue
|
|
91
|
+
receiver_src, literal, suggestion = guard
|
|
92
|
+
for inner in ast.walk(node):
|
|
93
|
+
if isinstance(inner, ast.Call) and self._is_replace_to_empty(
|
|
94
|
+
inner, receiver_src, literal
|
|
95
|
+
):
|
|
96
|
+
out.append(
|
|
97
|
+
Finding(
|
|
98
|
+
path,
|
|
99
|
+
inner.lineno,
|
|
100
|
+
inner.col_offset,
|
|
101
|
+
self.code,
|
|
102
|
+
f'.replace({literal!r}, "") guarded by '
|
|
103
|
+
f"{'startswith' if suggestion == 'removeprefix' else 'endswith'}"
|
|
104
|
+
f"({literal!r}) removes every occurrence; "
|
|
105
|
+
f"use str.{suggestion}({literal!r})",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
# WL002 — str.split(' ') instead of str.split()
|
|
113
|
+
# Origin: derek73/python-nameparser PR #164
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
class SplitSingleSpace:
|
|
116
|
+
"""``s.split(' ')`` where ``s.split()`` was almost certainly meant.
|
|
117
|
+
|
|
118
|
+
``"a b ".split(' ')`` -> ``['a', '', 'b', '']`` keeps empty tokens and does
|
|
119
|
+
not collapse runs or trim ends, while ``.split()`` does both. The single
|
|
120
|
+
blanks then leak downstream (``['']`` where ``[]`` was expected, a leading
|
|
121
|
+
space on a field). Only an *exact single space* literal fires — ``' '`` or
|
|
122
|
+
``','`` are treated as deliberate delimiters and left alone.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
code = "WL002"
|
|
126
|
+
name = "split-single-space"
|
|
127
|
+
tier = DEFAULT
|
|
128
|
+
|
|
129
|
+
def check(self, tree: ast.AST, path: str) -> list[Finding]:
|
|
130
|
+
out: list[Finding] = []
|
|
131
|
+
for node in ast.walk(tree):
|
|
132
|
+
if not (
|
|
133
|
+
isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
134
|
+
):
|
|
135
|
+
continue
|
|
136
|
+
if node.func.attr not in ("split", "rsplit"):
|
|
137
|
+
continue
|
|
138
|
+
if not node.args:
|
|
139
|
+
continue
|
|
140
|
+
if _str_const(node.args[0]) != " ":
|
|
141
|
+
continue
|
|
142
|
+
out.append(
|
|
143
|
+
Finding(
|
|
144
|
+
path,
|
|
145
|
+
node.lineno,
|
|
146
|
+
node.col_offset,
|
|
147
|
+
self.code,
|
|
148
|
+
f".{node.func.attr}(' ') keeps empty tokens and will not "
|
|
149
|
+
"collapse/trim whitespace; use "
|
|
150
|
+
f".{node.func.attr}() unless single-space splitting is intended",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- #
|
|
157
|
+
# WL003 — deep negative index without a length guard (PEDANTIC)
|
|
158
|
+
# Origin: savoirfairelinux/num2words PR #661
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
class NegativeIndexNoGuard:
|
|
161
|
+
"""``x[-k]`` with ``k >= 2`` — IndexError if the sequence is shorter than k.
|
|
162
|
+
|
|
163
|
+
The num2words bug indexed ``number_str[-2]`` unconditionally; ``"0"`` has
|
|
164
|
+
length 1 and crashed. Pedantic because ``x[-2]`` is frequently safe (the
|
|
165
|
+
length is known from context the checker cannot see), so this is opt-in.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
code = "WL003"
|
|
169
|
+
name = "negative-index-no-guard"
|
|
170
|
+
tier = PEDANTIC
|
|
171
|
+
|
|
172
|
+
def check(self, tree: ast.AST, path: str) -> list[Finding]:
|
|
173
|
+
out: list[Finding] = []
|
|
174
|
+
for node in ast.walk(tree):
|
|
175
|
+
if not isinstance(node, ast.Subscript):
|
|
176
|
+
continue
|
|
177
|
+
idx = node.slice
|
|
178
|
+
if (
|
|
179
|
+
isinstance(idx, ast.UnaryOp)
|
|
180
|
+
and isinstance(idx.op, ast.USub)
|
|
181
|
+
and isinstance(idx.operand, ast.Constant)
|
|
182
|
+
and isinstance(idx.operand.value, int)
|
|
183
|
+
and idx.operand.value >= 2
|
|
184
|
+
):
|
|
185
|
+
target = ast.unparse(node.value)
|
|
186
|
+
out.append(
|
|
187
|
+
Finding(
|
|
188
|
+
path,
|
|
189
|
+
node.lineno,
|
|
190
|
+
node.col_offset,
|
|
191
|
+
self.code,
|
|
192
|
+
f"{target}[-{idx.operand.value}] raises IndexError if "
|
|
193
|
+
f"len({target}) < {idx.operand.value}; add a length guard",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
CHECKERS = [
|
|
200
|
+
ReplaceToEmptyPrefix(),
|
|
201
|
+
SplitSingleSpace(),
|
|
202
|
+
NegativeIndexNoGuard(),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def select_checkers(*, pedantic: bool = False, codes: set[str] | None = None) -> list:
|
|
207
|
+
"""Return the active checkers.
|
|
208
|
+
|
|
209
|
+
``pedantic`` includes the opt-in tier. ``codes`` (e.g. ``{"WL001"}``)
|
|
210
|
+
restricts to those rules and, when given, overrides the tier filter.
|
|
211
|
+
"""
|
|
212
|
+
if codes is not None:
|
|
213
|
+
return [c for c in CHECKERS if c.code in codes]
|
|
214
|
+
return [c for c in CHECKERS if c.tier == DEFAULT or pedantic]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_source(
|
|
218
|
+
source: str,
|
|
219
|
+
path: str = "<unknown>",
|
|
220
|
+
*,
|
|
221
|
+
pedantic: bool = False,
|
|
222
|
+
codes: set[str] | None = None,
|
|
223
|
+
) -> list[Finding]:
|
|
224
|
+
"""Run the selected checkers over one source string; sorted findings."""
|
|
225
|
+
tree = ast.parse(source)
|
|
226
|
+
findings: list[Finding] = []
|
|
227
|
+
for checker in select_checkers(pedantic=pedantic, codes=codes):
|
|
228
|
+
findings.extend(checker.check(tree, path))
|
|
229
|
+
findings.sort(key=lambda f: (f.line, f.col, f.code))
|
|
230
|
+
return findings
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Bug classes considered but NOT shipped — each would only fire with an
|
|
234
|
+
# unacceptable false-positive rate as a purely-static rule. Kept here so the
|
|
235
|
+
# reasoning is not lost and a future, smarter implementation can revisit.
|
|
236
|
+
NON_GENERALIZED = {
|
|
237
|
+
"break-vs-continue": "jkwill87/mnamer #371 — whether `break` should be "
|
|
238
|
+
"`continue` is entirely loop-intent dependent; both are usually correct.",
|
|
239
|
+
"sign-doubling": "python-humanize/humanize #326 — negative whole+fraction "
|
|
240
|
+
"double-sign is a numeric-formatting specific, not a syntactic, pattern.",
|
|
241
|
+
"validation-branch-order": "python-validators/validators #463 — the unsafe "
|
|
242
|
+
"ordering of `,` vs `-`/`/` handling is specific to that parser's structure.",
|
|
243
|
+
"radix-from-ignored-param": "skorokithakis/shortuuid #115 — requires reading "
|
|
244
|
+
"the docstring contract ('alphabet is ignored') and matching it to impl.",
|
|
245
|
+
}
|
wildlint/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command-line entry point for wildlint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .checkers import CHECKERS, Finding, check_source
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iter_python_files(paths: list[str]):
|
|
13
|
+
for raw in paths:
|
|
14
|
+
p = Path(raw)
|
|
15
|
+
if p.is_dir():
|
|
16
|
+
yield from sorted(p.rglob("*.py"))
|
|
17
|
+
elif p.suffix == ".py":
|
|
18
|
+
yield p
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_file(
|
|
22
|
+
path: Path, *, pedantic: bool = False, codes: set[str] | None = None
|
|
23
|
+
) -> list[Finding]:
|
|
24
|
+
try:
|
|
25
|
+
source = path.read_text(encoding="utf-8")
|
|
26
|
+
except (OSError, UnicodeDecodeError):
|
|
27
|
+
return []
|
|
28
|
+
try:
|
|
29
|
+
return check_source(source, str(path), pedantic=pedantic, codes=codes)
|
|
30
|
+
except SyntaxError:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
from . import __version__
|
|
36
|
+
|
|
37
|
+
rules = ", ".join(f"{c.code} ({c.name}, {c.tier})" for c in CHECKERS)
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="wildlint",
|
|
40
|
+
description="Static checks distilled from real upstream bugs. "
|
|
41
|
+
f"Rules: {rules}.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"paths", nargs="*", default=["."], help="files or dirs (default: .)"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--pedantic",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="also run opt-in higher-false-positive rules",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--select",
|
|
53
|
+
metavar="CODES",
|
|
54
|
+
help="comma-separated rule codes to run exclusively, e.g. WL001,WL002",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
58
|
+
)
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
args = _build_parser().parse_args(argv)
|
|
64
|
+
codes = (
|
|
65
|
+
{c.strip().upper() for c in args.select.split(",") if c.strip()}
|
|
66
|
+
if args.select
|
|
67
|
+
else None
|
|
68
|
+
)
|
|
69
|
+
paths = args.paths or ["."]
|
|
70
|
+
|
|
71
|
+
findings: list[Finding] = []
|
|
72
|
+
for file in _iter_python_files(paths):
|
|
73
|
+
findings.extend(check_file(file, pedantic=args.pedantic, codes=codes))
|
|
74
|
+
|
|
75
|
+
for f in findings:
|
|
76
|
+
print(f)
|
|
77
|
+
|
|
78
|
+
if findings:
|
|
79
|
+
print(f"\n{len(findings)} finding(s).", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wildlint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static checks distilled from real upstream bugs that off-the-shelf linters miss.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/patchwright/wildlint
|
|
7
|
+
Project-URL: Issues, https://github.com/patchwright/wildlint/issues
|
|
8
|
+
Keywords: lint,linter,ast,static-analysis,bugs,removeprefix
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# wildlint
|
|
18
|
+
|
|
19
|
+
[](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
|
|
20
|
+
[](https://pypi.org/project/wildlint/)
|
|
21
|
+
|
|
22
|
+
Static checks distilled from **real upstream bugs** — the kind off-the-shelf
|
|
23
|
+
linters miss because they look like ordinary, working code.
|
|
24
|
+
|
|
25
|
+
Every rule here was born from a concrete bug that was found and fixed in a
|
|
26
|
+
public project, then generalized to the smallest static check that still catches
|
|
27
|
+
the *class* without flooding you with false positives. If a bug could not be
|
|
28
|
+
turned into a low-noise rule, it is documented as not-shipped rather than added
|
|
29
|
+
as noise (see [Not shipped](#bugs-considered-but-not-shipped)).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install wildlint
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
wildlint path/to/code # scan a file or directory (default: .)
|
|
41
|
+
wildlint --select WL001,WL002 src/
|
|
42
|
+
wildlint --pedantic src/ # also run opt-in, higher-false-positive rules
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Exits non-zero when anything is found, so it drops straight into CI or a
|
|
46
|
+
pre-commit hook.
|
|
47
|
+
|
|
48
|
+
### pre-commit
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
# .pre-commit-config.yaml
|
|
52
|
+
repos:
|
|
53
|
+
- repo: https://github.com/patchwright/wildlint
|
|
54
|
+
rev: v0.1.0
|
|
55
|
+
hooks:
|
|
56
|
+
- id: wildlint
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### CI (GitHub Actions)
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- run: pip install wildlint
|
|
63
|
+
- run: wildlint src/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
| Code | Tier | Catches | Distilled from |
|
|
69
|
+
|-------|----------|---------|----------------|
|
|
70
|
+
| WL001 | default | `x.replace(P, "")` guarded by `x.startswith(P)`/`endswith(P)` — removes *every* occurrence, silently corrupting values that contain the marker twice. Meant `str.removeprefix`/`removesuffix`. | [nephila/giturlparse#149](https://github.com/nephila/giturlparse/pull/149) |
|
|
71
|
+
| WL002 | default | `s.split(' ')` where `s.split()` was meant — keeps empty tokens and skips whitespace collapsing/trimming, leaking blanks downstream. Advisory: only an exact single-space literal fires. | [derek73/python-nameparser#164](https://github.com/derek73/python-nameparser/pull/164) |
|
|
72
|
+
| WL003 | pedantic | `x[-k]` with `k >= 2` — `IndexError` when the sequence is shorter than `k`. Opt-in because deep negative indexing is often provably safe from context the checker can't see. | [savoirfairelinux/num2words#661](https://github.com/savoirfairelinux/num2words/pull/661) |
|
|
73
|
+
|
|
74
|
+
Each rule is verified against the *actual pre-fix source* of the project it came
|
|
75
|
+
from — see the tests, and the rule docstrings in `src/wildlint/checkers.py`.
|
|
76
|
+
|
|
77
|
+
## Bugs considered but not shipped
|
|
78
|
+
|
|
79
|
+
Some real bugs do not generalize into a low-false-positive static rule. They are
|
|
80
|
+
recorded in `NON_GENERALIZED` in `checkers.py` so the reasoning is preserved:
|
|
81
|
+
|
|
82
|
+
- **break-vs-continue** ([mnamer#371](https://github.com/jkwill87/mnamer/pull/371)) — whether `break` should be `continue` is entirely loop-intent dependent.
|
|
83
|
+
- **sign-doubling** ([humanize#326](https://github.com/python-humanize/humanize/pull/326)) — a numeric-formatting concern, not a syntactic pattern.
|
|
84
|
+
- **validation-branch-order** ([validators#463](https://github.com/python-validators/validators/pull/463)) — specific to one parser's control flow.
|
|
85
|
+
- **radix-from-ignored-param** ([shortuuid#115](https://github.com/skorokithakis/shortuuid/pull/115)) — requires matching a docstring contract to the implementation.
|
|
86
|
+
|
|
87
|
+
## Adding a rule
|
|
88
|
+
|
|
89
|
+
A checker is any object with `code`, `name`, `tier`, and
|
|
90
|
+
`check(tree, path) -> list[Finding]`. Append an instance to `CHECKERS` in
|
|
91
|
+
`checkers.py` and add positive/negative tests mirroring the wild bug. That's the
|
|
92
|
+
whole extension surface — the suite grows one real bug at a time.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
wildlint/__init__.py,sha256=dyj72Fjmt-fmByp2zSkBvxVe5pE4qAEpSfU4DjawL3w,490
|
|
2
|
+
wildlint/__main__.py,sha256=hCKx5U22ApzqOPylHHCHVYdOorDWigDefj31P-E1d0c,117
|
|
3
|
+
wildlint/checkers.py,sha256=mu9RPpNbfIOFpWQ7S8UwucRmmTpw3KbYnNIAnXr2E1A,9488
|
|
4
|
+
wildlint/cli.py,sha256=vOPCrxhiVSaNu4GViKS4DF7EcYlc4NssCOKbGMMel2Q,2239
|
|
5
|
+
wildlint-0.1.0.dist-info/licenses/LICENSE,sha256=11DE3zrWsWwkP3B70LTau54zWqchBlfJC5XQfsWTXLE,1068
|
|
6
|
+
wildlint-0.1.0.dist-info/METADATA,sha256=koX27MsWLCSJXLJlUUf1efz9zjsRLvYXzDSzA1UBOHQ,4262
|
|
7
|
+
wildlint-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
wildlint-0.1.0.dist-info/entry_points.txt,sha256=Qe0ROTsVZP9fjyk80qx3yvwo_5Xf-K3xVZQcYRA7qf8,43
|
|
9
|
+
wildlint-0.1.0.dist-info/top_level.txt,sha256=HD-ukuR0QoYvzFRVtWCcgjDn-uA0uhy1cG_DZ1ONRmo,9
|
|
10
|
+
wildlint-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 patchwright
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wildlint
|