wildlint 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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,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
+ [![CI](https://github.com/patchwright/wildlint/actions/workflows/ci.yml/badge.svg)](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
20
+ [![PyPI](https://img.shields.io/pypi/v/wildlint.svg)](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,80 @@
1
+ # wildlint
2
+
3
+ [![CI](https://github.com/patchwright/wildlint/actions/workflows/ci.yml/badge.svg)](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/wildlint.svg)](https://pypi.org/project/wildlint/)
5
+
6
+ Static checks distilled from **real upstream bugs** — the kind off-the-shelf
7
+ linters miss because they look like ordinary, working code.
8
+
9
+ Every rule here was born from a concrete bug that was found and fixed in a
10
+ public project, then generalized to the smallest static check that still catches
11
+ the *class* without flooding you with false positives. If a bug could not be
12
+ turned into a low-noise rule, it is documented as not-shipped rather than added
13
+ as noise (see [Not shipped](#bugs-considered-but-not-shipped)).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install wildlint
19
+ ```
20
+
21
+ ## Use
22
+
23
+ ```bash
24
+ wildlint path/to/code # scan a file or directory (default: .)
25
+ wildlint --select WL001,WL002 src/
26
+ wildlint --pedantic src/ # also run opt-in, higher-false-positive rules
27
+ ```
28
+
29
+ Exits non-zero when anything is found, so it drops straight into CI or a
30
+ pre-commit hook.
31
+
32
+ ### pre-commit
33
+
34
+ ```yaml
35
+ # .pre-commit-config.yaml
36
+ repos:
37
+ - repo: https://github.com/patchwright/wildlint
38
+ rev: v0.1.0
39
+ hooks:
40
+ - id: wildlint
41
+ ```
42
+
43
+ ### CI (GitHub Actions)
44
+
45
+ ```yaml
46
+ - run: pip install wildlint
47
+ - run: wildlint src/
48
+ ```
49
+
50
+ ## Rules
51
+
52
+ | Code | Tier | Catches | Distilled from |
53
+ |-------|----------|---------|----------------|
54
+ | 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) |
55
+ | 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) |
56
+ | 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) |
57
+
58
+ Each rule is verified against the *actual pre-fix source* of the project it came
59
+ from — see the tests, and the rule docstrings in `src/wildlint/checkers.py`.
60
+
61
+ ## Bugs considered but not shipped
62
+
63
+ Some real bugs do not generalize into a low-false-positive static rule. They are
64
+ recorded in `NON_GENERALIZED` in `checkers.py` so the reasoning is preserved:
65
+
66
+ - **break-vs-continue** ([mnamer#371](https://github.com/jkwill87/mnamer/pull/371)) — whether `break` should be `continue` is entirely loop-intent dependent.
67
+ - **sign-doubling** ([humanize#326](https://github.com/python-humanize/humanize/pull/326)) — a numeric-formatting concern, not a syntactic pattern.
68
+ - **validation-branch-order** ([validators#463](https://github.com/python-validators/validators/pull/463)) — specific to one parser's control flow.
69
+ - **radix-from-ignored-param** ([shortuuid#115](https://github.com/skorokithakis/shortuuid/pull/115)) — requires matching a docstring contract to the implementation.
70
+
71
+ ## Adding a rule
72
+
73
+ A checker is any object with `code`, `name`, `tier`, and
74
+ `check(tree, path) -> list[Finding]`. Append an instance to `CHECKERS` in
75
+ `checkers.py` and add positive/negative tests mirroring the wild bug. That's the
76
+ whole extension surface — the suite grows one real bug at a time.
77
+
78
+ ## License
79
+
80
+ MIT.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wildlint"
7
+ version = "0.1.0"
8
+ description = "Static checks distilled from real upstream bugs that off-the-shelf linters miss."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["lint", "linter", "ast", "static-analysis", "bugs", "removeprefix"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Software Development :: Quality Assurance",
16
+ "License :: OSI Approved :: MIT License",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/patchwright/wildlint"
21
+ Issues = "https://github.com/patchwright/wildlint/issues"
22
+
23
+ [project.scripts]
24
+ wildlint = "wildlint:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__"]
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m wildlint``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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
+ }
@@ -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
+ [![CI](https://github.com/patchwright/wildlint/actions/workflows/ci.yml/badge.svg)](https://github.com/patchwright/wildlint/actions/workflows/ci.yml)
20
+ [![PyPI](https://img.shields.io/pypi/v/wildlint.svg)](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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/wildlint/__init__.py
5
+ src/wildlint/__main__.py
6
+ src/wildlint/checkers.py
7
+ src/wildlint/cli.py
8
+ src/wildlint.egg-info/PKG-INFO
9
+ src/wildlint.egg-info/SOURCES.txt
10
+ src/wildlint.egg-info/dependency_links.txt
11
+ src/wildlint.egg-info/entry_points.txt
12
+ src/wildlint.egg-info/top_level.txt
13
+ tests/test_wildlint.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wildlint = wildlint:main
@@ -0,0 +1 @@
1
+ wildlint
@@ -0,0 +1,134 @@
1
+ """Tests for wildlint detectors.
2
+
3
+ Positive cases mirror the real upstream bug each rule was distilled from;
4
+ negative cases are the legitimate code the rule must stay silent on.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from wildlint.checkers import check_source
10
+
11
+ # --------------------------------------------------------------------------- #
12
+ # WL001 — replace-to-empty prefix/suffix (giturlparse #149)
13
+ # --------------------------------------------------------------------------- #
14
+
15
+
16
+ def _codes(src: str, **kw) -> list[str]:
17
+ return [f.code for f in check_source(src, "t.py", **kw)]
18
+
19
+
20
+ def test_wl001_fires_on_startswith_replace_empty():
21
+ src = (
22
+ "def f(path):\n"
23
+ " if path.startswith('/blob/'):\n"
24
+ " return path.replace('/blob/', '')\n"
25
+ )
26
+ assert _codes(src) == ["WL001"]
27
+
28
+
29
+ def test_wl001_fires_on_endswith_suffix():
30
+ src = (
31
+ "def f(name):\n"
32
+ " if name.endswith('.py'):\n"
33
+ " return name.replace('.py', '')\n"
34
+ )
35
+ assert _codes(src) == ["WL001"]
36
+
37
+
38
+ def test_wl001_silent_when_replacement_not_empty():
39
+ src = (
40
+ "def f(p):\n"
41
+ " if p.startswith('/x/'):\n"
42
+ " return p.replace('/x/', '/y/')\n"
43
+ )
44
+ assert "WL001" not in _codes(src)
45
+
46
+
47
+ def test_wl001_silent_when_literals_differ():
48
+ src = (
49
+ "def f(p):\n"
50
+ " if p.startswith('/a/'):\n"
51
+ " return p.replace('/b/', '')\n"
52
+ )
53
+ assert "WL001" not in _codes(src)
54
+
55
+
56
+ def test_wl001_silent_without_guard():
57
+ # No startswith/endswith guard -> not our pattern (could be intentional).
58
+ assert "WL001" not in _codes("def f(p):\n return p.replace('/x/', '')\n")
59
+
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # WL002 — split single space (nameparser #164)
63
+ # --------------------------------------------------------------------------- #
64
+
65
+
66
+ def test_wl002_fires_on_split_single_space():
67
+ assert _codes("x = name.split(' ')\n") == ["WL002"]
68
+
69
+
70
+ def test_wl002_fires_on_rsplit_single_space():
71
+ assert _codes("x = name.rsplit(' ')\n") == ["WL002"]
72
+
73
+
74
+ def test_wl002_silent_on_bare_split():
75
+ assert "WL002" not in _codes("x = name.split()\n")
76
+
77
+
78
+ def test_wl002_silent_on_double_space():
79
+ # Two spaces is a deliberate delimiter, not the bug.
80
+ assert "WL002" not in _codes("x = name.split(' ')\n")
81
+
82
+
83
+ def test_wl002_silent_on_comma():
84
+ assert "WL002" not in _codes("x = name.split(',')\n")
85
+
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # WL003 — deep negative index, PEDANTIC (num2words #661)
89
+ # --------------------------------------------------------------------------- #
90
+
91
+
92
+ def test_wl003_off_by_default():
93
+ assert _codes("x = s[-2]\n") == []
94
+
95
+
96
+ def test_wl003_fires_when_pedantic():
97
+ assert _codes("x = s[-2]\n", pedantic=True) == ["WL003"]
98
+
99
+
100
+ def test_wl003_silent_on_last_element():
101
+ # x[-1] is the idiom for "last item" and never out of bounds on non-empty.
102
+ assert _codes("x = s[-1]\n", pedantic=True) == []
103
+
104
+
105
+ def test_wl003_fires_on_deeper_index_when_pedantic():
106
+ assert _codes("x = s[-3]\n", pedantic=True) == ["WL003"]
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # selection / tier behavior
111
+ # --------------------------------------------------------------------------- #
112
+
113
+
114
+ def test_select_restricts_to_codes():
115
+ src = "a = name.split(' ')\nif p.startswith('/x/'):\n p = p.replace('/x/', '')\n"
116
+ assert _codes(src) == ["WL001", "WL002"] or _codes(src) == ["WL002", "WL001"]
117
+ assert _codes(src, codes={"WL002"}) == ["WL002"]
118
+
119
+
120
+ def test_select_can_pick_pedantic_rule_without_flag():
121
+ # Explicit --select overrides the tier filter.
122
+ assert _codes("x = s[-2]\n", codes={"WL003"}) == ["WL003"]
123
+
124
+
125
+ def test_clean_source_has_no_findings():
126
+ src = "def f(p):\n return p.removeprefix('/x/').split()\n"
127
+ assert check_source(src, "t.py", pedantic=True) == []
128
+
129
+
130
+ def test_syntax_error_does_not_crash_check_source():
131
+ import pytest
132
+
133
+ with pytest.raises(SyntaxError):
134
+ check_source("def (:\n", "t.py")