wildlint
Static checks distilled from real upstream bugs — the kind off-the-shelf linters miss because they look like ordinary, working code.
Every rule here was born from a concrete bug that was found and fixed in a public project, then generalized to the smallest static check that still catches the class without flooding you with false positives. If a bug could not be turned into a low-noise rule, it is documented as not-shipped rather than added as noise (see Not shipped).
What it catches
Real bugs, phrased the way you'd search them:
- "my argparse flag parses but does nothing" — an option whose
destis never read (WL004) x.replace(prefix, "")corrupts values containing the marker twice — meantstr.removeprefix/removesuffix(WL001)s[-k]raises IndexError on short inputs — deep negative indexing (WL003)millify(999999)returns'1000k'not'1M'— rounding rollover in number/byte humanizers (WP001).replace(second=0)crashes on a baredatetime.date— datetime-subclass confusion (WP002)
Install
pip install wildlint
Use
wildlint path/to/code # scan a file or directory (default: .)
wildlint --select WL001,WL002 src/
wildlint --pedantic src/ # also run opt-in, higher-false-positive rules
wildlint --format json src/ # machine-readable output
When walking a directory, common junk (.venv, __pycache__, build, dist,
.git, node_modules, …) is skipped automatically — pass --no-default-exclude
to scan everything, or --exclude 'glob/*' to drop more. Explicit file and
directory arguments are always scanned as-is. Silence a finding inline with a
trailing # noqa (all codes) or # noqa: WL001,WL002 (specific).
Exits non-zero when anything is found or a file could not be analysed (a syntax error, non-UTF-8, or a missing path); the diagnostic goes to stderr and findings stay on stdout, so it drops straight into CI or a pre-commit hook.
pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/patchwright/wildlint
rev: v0.6.0
hooks:
- id: wildlint
CI (GitHub Actions)
- run: pip install wildlint
- run: wildlint src/
Configuration
[tool.wildlint] in pyproject.toml sets defaults that CLI flags override:
[tool.wildlint]
pedantic = true # run opt-in rules by default
select = ["WL001"] # restrict to these codes
exclude = ["vendor/*"] # additional path globs to skip
Rules
| Code | Tier | Catches | Distilled from |
|---|---|---|---|
| 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 |
| WL002 | pedantic | s.split(' ') where s.split() was meant — keeps empty tokens and skips whitespace collapsing/trimming, leaking blanks downstream. Advisory and opt-in: only an exact single-space literal fires, and it's frequently intentional. |
derek73/python-nameparser#164 |
| 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 |
| WL004 | default | An argparse option whose dest is never read — the flag parses, then silently vanishes. Fires only when sibling dests on the same namespace are read in the file (so consumption is local and the gap is an oversight). Bails on vars()/getattr/**-splat namespaces and on definitions-only files. |
un33k/python-slugify#180 |
| WL005 | pedantic | not A and B or C — and binds tighter than or, so the leading not A and guards only B, not the trailing or branches; meant not A and (B or C). Explicitly parenthesized and-chains ((not A and B) or C) are recognized as intentional and suppressed. Opt-in: the compound can be a legitimate condition. |
alexanderlukanin13/coolname#34 |
The default tier is WL001 and WL004 — both have effectively zero false
positives. WL002, WL003, and WL005 are opt-in via --pedantic: real bug classes,
but they also fire on legitimate code, so the default stays strictly precision.
Each rule is verified against the actual pre-fix source of the project it came
from — see the tests, and the rule docstrings in src/wildlint/checkers.py.
Property-test templates
Some bug classes have no stable AST signature — the same wrong behaviour is
reached by different code each time, so any static rule broad enough to catch
them all also flags mountains of correct code. The archetype is the
rounding-rollover bug in number / byte / SI-prefix humanizers
(boltons#403,
millify#13,
numerize#17,
si-prefix#17): four distinct
implementations of one invariant break (<=-vs-<, a missing carry after
rounding, rounding an unrounded boundary). millify(999999) returns '1000k'
instead of '1M'.
What they share is a falsifiable property: a humanizer must never emit a
mantissa >= base while a larger unit is still available. wildlint ships that
check two ways.
Run it directly (dependency-free, in your own test suite or CI):
from wildlint.property_templates import find_rollover
from millify import millify
def test_no_rounding_rollover():
violations = find_rollover(millify, base=1000) # 1000=SI, 1024=bytes
assert not violations, "\n".join(str(v) for v in violations)
find_rollover sweeps the dangerous boundary inputs (values that round up
across a unit boundary) and returns the concrete violations. Pass units=[...]
(small→large) for an exact check that won't flag legitimate overflow at the
largest unit.
The same two-way model covers the date/datetime-subclass confusion bug
(deepdiff#602): a function
written assuming datetime.datetime that calls .replace(second=0, microsecond=0) (or reads .hour) crashes on a bare datetime.date, because
datetime is a subclass of date — so any isinstance(x, date) dispatch
admits dates the code cannot handle.
from wildlint.property_templates import find_date_kwargs
def test_does_not_crash_on_date():
violations = find_date_kwargs(truncate) # probes with a bare date and time
assert not violations, "\n".join(str(v) for v in violations)
find_date_kwargs records only TypeError/AttributeError whose message cites
a time-only field (hour, minute, second, …); an unrelated crash is a
different class and is skipped.
Or render a paste-ready template:
wildlint --template rollover --func millify --import-from millify --base 1000
wildlint --template date-time-kwargs --func truncate --import-from deepdiff
wildlint --template roundtrip --func encodebytes --import-from base62 --inverse decodebytes
| Code | Catches | Distilled from |
|---|---|---|
| WP001 | A humanizer emits a mantissa >= base while a larger unit is available ('1000k' instead of '1M') because the unit is chosen before the mantissa is rounded. |
boltons#403, millify#13, numerize#17, si-prefix#17 |
| WP002 | A function accepting a temporal value unconditionally reads a datetime-only field (.replace(second=0, microsecond=0) or .hour) and crashes on a bare datetime.date — datetime is a subclass of date, so isinstance(x, date) admits dates the code can't handle. |
deepdiff#602 |
| WP003 | An encode/decode pair is not mutually inverse (inverse(forward(x)) != x). The archetype is a byte↔string codec that routes through an integer (int.from_bytes), so leading 0x00 bytes carry no weight and are silently dropped: decodebytes(encodebytes(b"\x00\x01")) == b"\x01". |
suminb/base62#22 |
Bugs considered but not shipped
Some real bugs do not generalize into a low-false-positive static rule. They are
recorded in NON_GENERALIZED in checkers.py so the reasoning is preserved:
- break-vs-continue (mnamer#371) — whether
breakshould becontinueis entirely loop-intent dependent. - sign-doubling (humanize#326) — a numeric-formatting concern, not a syntactic pattern.
- validation-branch-order (validators#463) — specific to one parser's control flow.
- radix-from-ignored-param (shortuuid#115) — requires matching a docstring contract to the implementation.
- rng-from-unordered-set — iterating a set into a
randompopulation (directly, or vialist(some_set)feedingrandom.choicesweights) is non-deterministic across processes:PYTHONHASHSEEDvaries per worker, so set iteration order — and item↔weight alignment — changes run to run. The bare form (random.choice({1,2,3})) is rare; the real class (set→list→positional use) is only visible cross-process and is best caught by a reproducibility property test (run twice under differingPYTHONHASHSEED, assert identical output), not a static rule.
Adding a rule
A checker is any object with code, name, tier, and
check(tree, path, source=None) -> list[Finding]. Append an instance to CHECKERS in
checkers.py and add positive/negative tests mirroring the wild bug. That's the
whole extension surface — the suite grows one real bug at a time.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file wildlint-0.6.0.tar.gz.
File metadata
- Download URL: wildlint-0.6.0.tar.gz
- Upload date:
- Size: 38.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b146437311fba1431f2a94d10d36593cb34ba6143e7dce75941f31935922e252
|
|
| MD5 |
c48fa2844a5b5f0a6417868d1354bffb
|
|
| BLAKE2b-256 |
67cdde1efca12aa4474ba00310e3c7457f22b2fe7a35b797fef70db22072dfe5
|
Provenance
The following attestation bundles were made for wildlint-0.6.0.tar.gz:
Publisher:
release.yml on patchwright/wildlint
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wildlint-0.6.0.tar.gz -
Subject digest:
b146437311fba1431f2a94d10d36593cb34ba6143e7dce75941f31935922e252 - Sigstore transparency entry: 2040051502
- Sigstore integration time:
-
Permalink:
patchwright/wildlint@805d2042bdd8e61e589126609eaba32020db3354 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/patchwright
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@805d2042bdd8e61e589126609eaba32020db3354 -
Trigger Event:
push
-
Statement type:
File details
Details for the file wildlint-0.6.0-py3-none-any.whl.
File metadata
- Download URL: wildlint-0.6.0-py3-none-any.whl
- Upload date:
- Size: 28.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3969c8bf63aeb8872a099bd5706f442ecc6a04165870e3656a3890cfad91a9e7
|
|
| MD5 |
3db205cb7871cabd2cc223529f9bd2c6
|
|
| BLAKE2b-256 |
8ebce3efebe8f2c3f4120e5cced5d2eb9c46c8c64b5a3aa9ea9eaea7e4ba3c96
|
Provenance
The following attestation bundles were made for wildlint-0.6.0-py3-none-any.whl:
Publisher:
release.yml on patchwright/wildlint
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wildlint-0.6.0-py3-none-any.whl -
Subject digest:
3969c8bf63aeb8872a099bd5706f442ecc6a04165870e3656a3890cfad91a9e7 - Sigstore transparency entry: 2040051606
- Sigstore integration time:
-
Permalink:
patchwright/wildlint@805d2042bdd8e61e589126609eaba32020db3354 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/patchwright
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@805d2042bdd8e61e589126609eaba32020db3354 -
Trigger Event:
push
-
Statement type: