willitbite 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.
willitbite/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Decide which ruff warnings can actually reach you at runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,187 @@
1
+ """Who calls this, and do any of them leave the argument out?
2
+
3
+ A function that mutates its own mutable default is wrong. That is a property of
4
+ the function and it does not depend on anybody else. Whether the wrongness can
5
+ reach you is a property of the program, and it turns on one thing: some caller
6
+ has to omit the argument. If every call site passes an explicit value, the
7
+ shared default is never the object being mutated and the bug sits there waiting
8
+ for a caller who does not yet exist.
9
+
10
+ Both facts are worth reporting, and they are different facts. So this produces a
11
+ second verdict of its own and the first one stays visible underneath it.
12
+
13
+ **Matching is by name, and it is deliberately generous.** Resolving ``x.send()``
14
+ to a definition properly needs type inference, which this does not have. So a
15
+ call is counted whenever the called name matches, wherever it appears. That
16
+ over-matches: an unrelated ``send`` in another module is counted too. The
17
+ over-matching is in the safe direction, because an extra caller can only ever
18
+ add an omission and an omission is the answer that keeps the warning.
19
+
20
+ **Everything unresolvable stays unresolved.** A ``**kwargs`` splat might be
21
+ carrying the argument, a ``*args`` splat might be filling the position, and a
22
+ function with no visible caller at all might be a public entry point that half
23
+ the internet calls. None of those is evidence of safety, so each of them leaves
24
+ the verdict where it was. The only thing that downgrades a warning here is a
25
+ complete set of call sites that every one of them supplies the argument.
26
+ """
27
+
28
+ import ast
29
+ import os
30
+ from collections import defaultdict
31
+ from dataclasses import dataclass
32
+
33
+ #: Directories that are never the project's own source. Scanning them wastes
34
+ #: time and, worse, a vendored copy of the same function invents call sites the
35
+ #: project does not have.
36
+ SKIP_DIRS = frozenset(
37
+ {
38
+ ".git",
39
+ ".hg",
40
+ ".svn",
41
+ ".tox",
42
+ ".nox",
43
+ ".venv",
44
+ "venv",
45
+ "env",
46
+ ".mypy_cache",
47
+ ".pytest_cache",
48
+ ".ruff_cache",
49
+ "__pycache__",
50
+ "node_modules",
51
+ "site-packages",
52
+ "build",
53
+ "dist",
54
+ }
55
+ )
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Call:
60
+ """One call, remembered well enough to answer questions about its arguments."""
61
+
62
+ filename: str
63
+ line: int
64
+ node: ast.Call
65
+ attribute: bool #: written as ``x.name(...)`` rather than ``name(...)``
66
+
67
+
68
+ def python_files(root):
69
+ """Every ``.py`` file under ``root``, skipping the directories above."""
70
+ root = str(root)
71
+ if os.path.isfile(root):
72
+ yield root
73
+ return
74
+ for folder, subfolders, names in os.walk(root):
75
+ subfolders[:] = [d for d in subfolders if d not in SKIP_DIRS]
76
+ for name in names:
77
+ if name.endswith(".py"):
78
+ yield os.path.join(folder, name)
79
+
80
+
81
+ def index(root):
82
+ """Map every called name under ``root`` to the calls that use it.
83
+
84
+ A file that cannot be read or parsed is skipped rather than fatal. The index
85
+ is a source of evidence, and missing evidence is already handled: it leaves
86
+ warnings where they are.
87
+ """
88
+ found = defaultdict(list)
89
+ for filename in python_files(root):
90
+ # Closed explicitly rather than left to the collector: this runs once
91
+ # per file across a whole source tree, and a few thousand open handles
92
+ # is its own kind of failure.
93
+ try:
94
+ with open(filename, encoding="utf-8") as handle:
95
+ tree = ast.parse(handle.read())
96
+ except (OSError, SyntaxError, ValueError, UnicodeDecodeError):
97
+ continue
98
+ for node in ast.walk(tree):
99
+ if not isinstance(node, ast.Call):
100
+ continue
101
+ callee = node.func
102
+ if isinstance(callee, ast.Name):
103
+ found[callee.id].append(Call(filename, node.lineno, node, False))
104
+ elif isinstance(callee, ast.Attribute):
105
+ found[callee.attr].append(Call(filename, node.lineno, node, True))
106
+ return found
107
+
108
+
109
+ def position(fn, name):
110
+ """Where ``name`` sits in the signature, or None if it is keyword-only."""
111
+ positional = fn.args.posonlyargs + fn.args.args
112
+ for i, arg in enumerate(positional):
113
+ if arg.arg == name:
114
+ return i
115
+ return None
116
+
117
+
118
+ def binds_self(fn):
119
+ """Does a bound call to this function consume the first parameter?
120
+
121
+ Reading the decorator list is enough here: ``staticmethod`` takes no
122
+ receiver, and ``classmethod`` takes one exactly as an instance method does.
123
+ A module-level function whose first parameter is called ``self`` would be
124
+ misread, which is rare enough to be worth the simplicity, and the misreading
125
+ lands on the conservative side.
126
+ """
127
+ first = (fn.args.posonlyargs + fn.args.args)[:1]
128
+ if not first or first[0].arg not in ("self", "cls"):
129
+ return False
130
+ for decorator in fn.decorator_list:
131
+ label = decorator.id if isinstance(decorator, ast.Name) else getattr(decorator, "attr", "")
132
+ if label == "staticmethod":
133
+ return False
134
+ return True
135
+
136
+
137
+ def supplies(call, name, index_in_signature, drop_self):
138
+ """Does this call site pass the parameter? True, False, or None for unknown."""
139
+ for keyword in call.node.keywords:
140
+ if keyword.arg == name:
141
+ return True
142
+ if any(keyword.arg is None for keyword in call.node.keywords):
143
+ return None # a **splat could be carrying it
144
+ if index_in_signature is None:
145
+ return False # keyword-only, and no keyword of that name was given
146
+ if any(isinstance(arg, ast.Starred) for arg in call.node.args):
147
+ return None # a *splat could be filling the position
148
+ wanted = index_in_signature - 1 if (drop_self and call.attribute) else index_in_signature
149
+ if wanted < 0:
150
+ return None
151
+ return len(call.node.args) > wanted
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Reach:
156
+ """What the call sites collectively say about one parameter."""
157
+
158
+ supplying: int
159
+ omitting: tuple
160
+ unknown: tuple
161
+
162
+ @property
163
+ def total(self):
164
+ return self.supplying + len(self.omitting) + len(self.unknown)
165
+
166
+ @property
167
+ def unreachable(self):
168
+ """True only when every call site was resolved and all of them supply it."""
169
+ return self.total > 0 and not self.omitting and not self.unknown
170
+
171
+
172
+ def reach(fn, name, calls):
173
+ """Read the call sites of ``fn`` for what they do with ``name``."""
174
+ sites = calls.get(fn.name, ())
175
+ where = position(fn, name)
176
+ drop_self = binds_self(fn)
177
+
178
+ supplying, omitting, unknown = 0, [], []
179
+ for site in sites:
180
+ answer = supplies(site, name, where, drop_self)
181
+ if answer is True:
182
+ supplying += 1
183
+ elif answer is False:
184
+ omitting.append(site)
185
+ else:
186
+ unknown.append(site)
187
+ return Reach(supplying, tuple(omitting), tuple(unknown))
willitbite/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """The command line: run ruff, decide every warning, report what can bite."""
2
+
3
+ import argparse
4
+ import ast
5
+ import io
6
+ import json
7
+ import sys
8
+ from collections import defaultdict
9
+
10
+ from . import __version__, callsites, escape, mutation, ruffrun
11
+ from .verdict import BITES, CALLEE, LATENT, ORDER, SAFE, Verdict
12
+
13
+ ANALYSERS = {"B023": escape.analyse, "B006": mutation.analyse}
14
+
15
+ RULE_TITLES = {
16
+ "B023": "closures capturing a loop variable",
17
+ "B006": "mutable default arguments",
18
+ }
19
+
20
+
21
+ def decide(findings, root=None):
22
+ """Attach a verdict to every finding, parsing each file once.
23
+
24
+ The call-site pass is a second look, not a first one. Reading every Python
25
+ file under ``root`` costs real time on a large tree, and it can only ever
26
+ change a B006 answer that already came back BITES, which on the codebases
27
+ this was built against was one warning in a hundred and thirty-three. So
28
+ the index is built only if there is something for it to decide, and a run
29
+ that finds nothing biting never pays for it at all.
30
+ """
31
+ by_file = defaultdict(list)
32
+ for finding in findings:
33
+ by_file[finding["filename"]].append(finding)
34
+
35
+ trees = {}
36
+ decided = []
37
+ for filename, group in by_file.items():
38
+ try:
39
+ source = io.open(filename, encoding="utf-8").read()
40
+ tree = ast.parse(source)
41
+ except (OSError, SyntaxError) as exc:
42
+ for finding in group:
43
+ decided.append({**finding, "verdict": Verdict(CALLEE, f"unreadable: {exc}")})
44
+ continue
45
+ trees[filename] = tree
46
+ parents = escape.parent_map(tree)
47
+ for finding in group:
48
+ analyse = ANALYSERS.get(finding["code"])
49
+ if analyse is None:
50
+ continue
51
+ decided.append(
52
+ {**finding, "verdict": analyse(tree, finding["line"], parents)}
53
+ )
54
+
55
+ if root is not None:
56
+ pending = [
57
+ row
58
+ for row in decided
59
+ if row["code"] == "B006" and row["verdict"].kind == BITES
60
+ ]
61
+ if pending:
62
+ calls = callsites.index(root)
63
+ for row in pending:
64
+ tree = trees.get(row["filename"])
65
+ if tree is None:
66
+ continue
67
+ row["verdict"] = mutation.analyse(tree, row["line"], calls=calls)
68
+ return decided
69
+
70
+
71
+ def _counts(rows):
72
+ tally = defaultdict(int)
73
+ for row in rows:
74
+ tally[row["verdict"].kind] += 1
75
+ return tally
76
+
77
+
78
+ def render(decided, show_all=False):
79
+ """Human-readable report. Returns the lines."""
80
+ lines = []
81
+ by_rule = defaultdict(list)
82
+ for row in decided:
83
+ by_rule[row["code"]].append(row)
84
+
85
+ total_bites = 0
86
+ total_latent = 0
87
+ for code in sorted(by_rule):
88
+ rows = by_rule[code]
89
+ tally = _counts(rows)
90
+ total_bites += tally[BITES]
91
+ total_latent += tally[LATENT]
92
+ lines.append(f"{code} {RULE_TITLES.get(code, '')}")
93
+ # The latent column is omitted when it is empty rather than printed as a
94
+ # permanent zero, since only B006 can produce one.
95
+ latent = f"{tally[LATENT]} latent " if tally[LATENT] else ""
96
+ lines.append(
97
+ f" {len(rows)} warning(s) {tally[BITES]} can bite {latent}"
98
+ f"{tally[CALLEE]} depend on a callee {tally[SAFE]} safe"
99
+ )
100
+ wanted = ORDER if show_all else (BITES, LATENT, CALLEE)
101
+ for kind in wanted:
102
+ for row in sorted(rows, key=lambda r: (r["filename"], r["line"])):
103
+ if row["verdict"].kind != kind:
104
+ continue
105
+ lines.append(f" [{kind}] {row['filename']}:{row['line']}")
106
+ lines.append(f" {row['verdict'].reason}")
107
+ lines.append("")
108
+
109
+ tail = f", {total_latent} latent" if total_latent else ""
110
+ if not decided:
111
+ lines.append("No B006 or B023 warnings found.")
112
+ elif total_bites == 0:
113
+ lines.append(
114
+ f"Nothing here can bite today. {len(decided)} warning(s), "
115
+ f"0 reachable defects{tail}."
116
+ )
117
+ else:
118
+ lines.append(
119
+ f"{total_bites} of {len(decided)} warning(s) can actually bite{tail}."
120
+ )
121
+ return lines
122
+
123
+
124
+ def as_json(decided):
125
+ return json.dumps(
126
+ {
127
+ "warnings": len(decided),
128
+ "bites": sum(1 for r in decided if r["verdict"].kind == BITES),
129
+ "latent": sum(1 for r in decided if r["verdict"].kind == LATENT),
130
+ "results": [
131
+ {
132
+ "code": r["code"],
133
+ "filename": r["filename"],
134
+ "line": r["line"],
135
+ "verdict": r["verdict"].kind,
136
+ "reason": r["verdict"].reason,
137
+ }
138
+ for r in decided
139
+ ],
140
+ },
141
+ indent=2,
142
+ )
143
+
144
+
145
+ def main(argv=None):
146
+ parser = argparse.ArgumentParser(
147
+ prog="willitbite",
148
+ description=(
149
+ "Decide which of ruff's loop-closure and mutable-default warnings "
150
+ "can actually reach you at runtime."
151
+ ),
152
+ )
153
+ parser.add_argument("path", nargs="?", default=".", help="file or directory to inspect")
154
+ parser.add_argument("--version", action="version", version=f"willitbite {__version__}")
155
+ parser.add_argument(
156
+ "--json", dest="json_in", metavar="FILE",
157
+ help="read ruff's JSON output from FILE instead of running ruff",
158
+ )
159
+ parser.add_argument("--json-out", action="store_true", help="print results as JSON")
160
+ parser.add_argument("--all", action="store_true", help="list the safe warnings too")
161
+ parser.add_argument(
162
+ "--exit-zero", action="store_true",
163
+ help="always exit 0, even when something can bite",
164
+ )
165
+ args = parser.parse_args(argv)
166
+
167
+ try:
168
+ if args.json_in:
169
+ findings = ruffrun.parse(io.open(args.json_in, encoding="utf-8").read())
170
+ else:
171
+ findings = ruffrun.run(args.path)
172
+ except (ruffrun.RuffMissing, RuntimeError, OSError) as exc:
173
+ print(str(exc), file=sys.stderr)
174
+ return 2
175
+
176
+ decided = decide(findings, root=args.path)
177
+
178
+ if args.json_out:
179
+ print(as_json(decided))
180
+ else:
181
+ print("\n".join(render(decided, show_all=args.all)))
182
+
183
+ if args.exit_zero:
184
+ return 0
185
+ return 1 if any(r["verdict"].kind == BITES for r in decided) else 0
186
+
187
+
188
+ if __name__ == "__main__":
189
+ raise SystemExit(main())
willitbite/escape.py ADDED
@@ -0,0 +1,215 @@
1
+ """B023: does a closure that captures a loop variable outlive its iteration?
2
+
3
+ Ruff flags every closure inside a loop that reads the loop variable. That is a
4
+ description of a shape, not of a bug. The bug is late binding: if the closure is
5
+ still callable after the loop moves on, every copy of it sees the loop
6
+ variable's final value. If the closure is built and consumed inside the same
7
+ iteration, it sees the value it was written next to.
8
+
9
+ So the question here is never "does it capture". Ruff answered that. It is
10
+ "can this closure still be called once the loop variable has changed".
11
+
12
+ For a nested ``def NAME``, the evidence is what the loop body does with NAME:
13
+
14
+ every reference is ``NAME(...)`` -> SAFE, it runs in its own iteration
15
+ a bare reference that is stored -> BITES
16
+ a bare reference passed to ``f(NAME)`` -> CALLEE, depends on f
17
+
18
+ For a ``lambda``, the evidence is the construct that owns it: an argument to
19
+ ``sorted`` runs now; ``.append`` and ``functools.partial`` keep it for later.
20
+ """
21
+
22
+ import ast
23
+
24
+ from .verdict import BITES, CALLEE, SAFE, Verdict
25
+
26
+ LOOPS = (ast.For, ast.AsyncFor, ast.While)
27
+
28
+ #: Builtins that call the closure and drop it before the expression finishes.
29
+ CONSUMING = frozenset(
30
+ {"sorted", "min", "max", "sum", "any", "all", "sort", "nlargest", "nsmallest"}
31
+ )
32
+
33
+ #: Builtins that return a lazy iterator holding the closure. Safe only if the
34
+ #: result is consumed in the same iteration, which is a separate question.
35
+ LAZY = frozenset({"map", "filter", "groupby"})
36
+
37
+ #: Attribute calls whose whole purpose is to keep the callable for later.
38
+ KEEPING_ATTRS = frozenset(
39
+ {
40
+ "append",
41
+ "add",
42
+ "insert",
43
+ "extend",
44
+ "submit",
45
+ "apply_async",
46
+ "put",
47
+ "add_done_callback",
48
+ "connect",
49
+ "register",
50
+ "subscribe",
51
+ "schedule",
52
+ "create_task",
53
+ "run_in_executor",
54
+ "call_later",
55
+ "call_soon",
56
+ "setdefault",
57
+ }
58
+ )
59
+
60
+ #: Constructors and helpers that store the callable in the object they return.
61
+ KEEPING_CALLS = frozenset(
62
+ {"partial", "Thread", "Timer", "Process", "create_task", "ensure_future"}
63
+ )
64
+
65
+
66
+ def parent_map(tree):
67
+ """Map every node to its parent. ast exposes children only."""
68
+ parents = {}
69
+ for node in ast.walk(tree):
70
+ for child in ast.iter_child_nodes(node):
71
+ parents[child] = node
72
+ return parents
73
+
74
+
75
+ def innermost_closure(tree, line):
76
+ """The lambda or def enclosing ``line``, innermost when several do.
77
+
78
+ Ruff points at the captured name, not at the closure, so the closure has to
79
+ be found by span. The one that starts latest is the innermost.
80
+ """
81
+ holding = [
82
+ n
83
+ for n in ast.walk(tree)
84
+ if isinstance(n, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef))
85
+ and n.lineno <= line <= getattr(n, "end_lineno", n.lineno)
86
+ ]
87
+ return max(holding, key=lambda n: n.lineno) if holding else None
88
+
89
+
90
+ def enclosing_loop(node, parents):
91
+ cur = node
92
+ while cur in parents:
93
+ cur = parents[cur]
94
+ if isinstance(cur, LOOPS):
95
+ return cur
96
+ return None
97
+
98
+
99
+ def _callee_name(call):
100
+ fn = call.func
101
+ if isinstance(fn, ast.Name):
102
+ return fn.id
103
+ if isinstance(fn, ast.Attribute):
104
+ return fn.attr
105
+ return None
106
+
107
+
108
+ def classify_def(fn, loop, parents):
109
+ """A nested def: what does the loop body do with its name?"""
110
+ if loop is None:
111
+ return Verdict(CALLEE, f"`{fn.name}` is not inside a loop in this file")
112
+
113
+ name = fn.name
114
+ called = 0
115
+ stored = []
116
+ passed = []
117
+ for node in ast.walk(loop):
118
+ if not isinstance(node, ast.Name) or node.id != name:
119
+ continue
120
+ if not isinstance(node.ctx, ast.Load):
121
+ continue
122
+ up = parents.get(node)
123
+ if isinstance(up, ast.Call) and up.func is node:
124
+ called += 1
125
+ elif isinstance(up, ast.Call):
126
+ passed.append(_callee_name(up) or "a call")
127
+ else:
128
+ stored.append(type(up).__name__)
129
+
130
+ if stored:
131
+ where = ", ".join(sorted(set(stored)))
132
+ return Verdict(
133
+ BITES,
134
+ f"`{name}` is stored rather than called ({where}), so it can run "
135
+ f"after the loop variable has changed",
136
+ )
137
+ keepers = sorted({p for p in passed if p in KEEPING_ATTRS or p in KEEPING_CALLS})
138
+ if keepers:
139
+ who = ", ".join(keepers)
140
+ return Verdict(
141
+ BITES,
142
+ f"`{name}` is stored rather than called: it is handed to {who}(), "
143
+ f"which keeps it past the end of the iteration",
144
+ )
145
+ if passed:
146
+ runners = {p for p in passed if p in CONSUMING}
147
+ rest = sorted(set(passed) - runners)
148
+ if not rest:
149
+ who = ", ".join(sorted(runners))
150
+ return Verdict(
151
+ SAFE, f"`{name}` is only ever an argument to {who}(), which runs it now"
152
+ )
153
+ who = ", ".join(rest)
154
+ return Verdict(
155
+ CALLEE,
156
+ f"`{name}` is handed to {who}(), so it bites only if {who} keeps it "
157
+ f"instead of calling it",
158
+ )
159
+ if called:
160
+ return Verdict(
161
+ SAFE,
162
+ f"`{name}` is only ever called directly, {called} time(s) in the "
163
+ f"same iteration",
164
+ )
165
+ return Verdict(
166
+ BITES,
167
+ f"`{name}` is never used inside the loop, so whatever holds it outlives "
168
+ f"the iteration",
169
+ )
170
+
171
+
172
+ def classify_lambda(node, parents):
173
+ """A lambda: which construct owns it?"""
174
+ cur = node
175
+ while cur in parents:
176
+ up = parents[cur]
177
+ if isinstance(up, (ast.Assign, ast.AnnAssign, ast.NamedExpr)):
178
+ return Verdict(BITES, "the lambda is bound to a name, so it is called later")
179
+ if isinstance(up, (ast.Dict, ast.List, ast.Set, ast.Tuple)):
180
+ return Verdict(BITES, "the lambda is stored in a container")
181
+ if isinstance(up, ast.Return):
182
+ return Verdict(BITES, "the lambda is returned out of the function")
183
+ if isinstance(up, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
184
+ return Verdict(BITES, "the lambda is collected by a comprehension")
185
+ if isinstance(up, ast.Call):
186
+ name = _callee_name(up)
187
+ if name in KEEPING_ATTRS or name in KEEPING_CALLS:
188
+ return Verdict(BITES, f"the lambda is handed to {name}(), which keeps it")
189
+ if name in CONSUMING:
190
+ return Verdict(
191
+ SAFE, f"the lambda is an argument to {name}(), which runs it now"
192
+ )
193
+ if name in LAZY:
194
+ return Verdict(
195
+ CALLEE,
196
+ f"the lambda is an argument to {name}(), which is lazy, so it is "
197
+ f"safe only if the result is consumed in this iteration",
198
+ )
199
+ return Verdict(
200
+ CALLEE,
201
+ f"the lambda is handed to {name}(), so it bites only if {name} keeps it",
202
+ )
203
+ cur = up
204
+ return Verdict(CALLEE, "the owning construct could not be identified")
205
+
206
+
207
+ def analyse(tree, line, parents=None):
208
+ """Decide one B023 warning reported at ``line``."""
209
+ parents = parent_map(tree) if parents is None else parents
210
+ closure = innermost_closure(tree, line)
211
+ if closure is None:
212
+ return Verdict(CALLEE, "no closure found at this line")
213
+ if isinstance(closure, ast.Lambda):
214
+ return classify_lambda(closure, parents)
215
+ return classify_def(closure, enclosing_loop(closure, parents), parents)
willitbite/mutation.py ADDED
@@ -0,0 +1,268 @@
1
+ """B006: is a mutable default argument ever actually mutated?
2
+
3
+ A mutable default is evaluated once, at definition time, and shared by every
4
+ call that omits the argument. That is only a bug when the function changes it,
5
+ because the change is then visible to the next caller. A default that is only
6
+ read behaves exactly like the immutable one the author probably imagined.
7
+
8
+ So the decisive question is mutation, and there is one trap in asking it. A
9
+ great many functions open with ``items = items or []`` or ``items = list(items)``,
10
+ which rebinds the name to a fresh object before anything is appended. The shared
11
+ default is never touched. Mutation that happens after a rebind is mutation of
12
+ the new object, so position matters and a plain "does the name appear on the
13
+ left of an append" is not enough.
14
+ """
15
+
16
+ import ast
17
+
18
+ from .callsites import reach
19
+ from .verdict import BITES, CALLEE, LATENT, SAFE, Verdict
20
+
21
+ #: Methods that change the receiver in place.
22
+ MUTATORS = frozenset(
23
+ {
24
+ "append",
25
+ "extend",
26
+ "insert",
27
+ "add",
28
+ "update",
29
+ "setdefault",
30
+ "pop",
31
+ "popitem",
32
+ "remove",
33
+ "clear",
34
+ "sort",
35
+ "discard",
36
+ }
37
+ )
38
+
39
+ #: Calls that cannot change what they are given: they either only read it, or
40
+ #: they build a new object from it. Passing the default to one of these is not
41
+ #: an escape, so they must be excluded or every read looks like a risk.
42
+ NON_MUTATING = frozenset(
43
+ {
44
+ # build a copy
45
+ "list", "dict", "set", "tuple", "frozenset", "copy", "deepcopy", "sorted",
46
+ # read only
47
+ "len", "any", "all", "sum", "min", "max", "enumerate", "iter", "reversed",
48
+ "bool", "str", "repr", "print", "isinstance", "join", "format", "next",
49
+ }
50
+ )
51
+
52
+ #: Factories that produce a mutable object, so ``x=dict()`` is as shared as ``x={}``.
53
+ MUTABLE_FACTORIES = frozenset(
54
+ {"list", "dict", "set", "defaultdict", "Counter", "OrderedDict", "deque"}
55
+ )
56
+
57
+
58
+ def mutable_defaults(fn):
59
+ """Parameter names whose default is a mutable object.
60
+
61
+ Ruff reports the offending default's position, but recovering the parameter
62
+ name from that is fiddlier than reading the signature directly, and the
63
+ signature is what a reader checks the verdict against.
64
+ """
65
+ if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
66
+ return []
67
+
68
+ args = fn.args
69
+ names = []
70
+
71
+ positional = args.posonlyargs + args.args
72
+ if args.defaults:
73
+ paired = zip(positional[len(positional) - len(args.defaults) :], args.defaults)
74
+ for arg, default in paired:
75
+ if _is_mutable(default):
76
+ names.append(arg.arg)
77
+
78
+ for arg, default in zip(args.kwonlyargs, args.kw_defaults):
79
+ if default is not None and _is_mutable(default):
80
+ names.append(arg.arg)
81
+
82
+ return names
83
+
84
+
85
+ def _is_mutable(node):
86
+ if isinstance(node, (ast.List, ast.Dict, ast.Set)):
87
+ return True
88
+ if isinstance(node, ast.Call):
89
+ fn = node.func
90
+ name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "")
91
+ return name in MUTABLE_FACTORIES
92
+ return False
93
+
94
+
95
+ def _rebind_line(fn, name):
96
+ """First line where ``name`` is assigned, or None.
97
+
98
+ After a rebind the name refers to a new object, so anything done to it from
99
+ that point on cannot reach the shared default.
100
+ """
101
+ augmented = {
102
+ node.target
103
+ for node in ast.walk(fn)
104
+ if isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name)
105
+ }
106
+ lines = [
107
+ node.lineno
108
+ for node in ast.walk(fn)
109
+ if isinstance(node, ast.Name)
110
+ and node.id == name
111
+ and isinstance(node.ctx, ast.Store)
112
+ # ``items += [1]`` on a list calls __iadd__, which extends in place. It
113
+ # is a Store node but it does not produce a new object, so counting it
114
+ # as a rebind would hide the very mutation it performs.
115
+ and node not in augmented
116
+ ]
117
+ return min(lines) if lines else None
118
+
119
+
120
+ def _mutations(fn, name):
121
+ """Every in-place change to ``name``, as (line, description)."""
122
+ found = []
123
+ for node in ast.walk(fn):
124
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
125
+ receiver = node.func.value
126
+ if isinstance(receiver, ast.Name) and receiver.id == name:
127
+ if node.func.attr in MUTATORS:
128
+ found.append((node.lineno, f"{name}.{node.func.attr}()"))
129
+ elif isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name):
130
+ if node.value.id == name and isinstance(node.ctx, (ast.Store, ast.Del)):
131
+ verb = "assigned" if isinstance(node.ctx, ast.Store) else "deleted"
132
+ found.append((node.lineno, f"{name}[...] {verb}"))
133
+ elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
134
+ if node.target.id == name:
135
+ found.append((node.lineno, f"{name} += ..."))
136
+ return found
137
+
138
+
139
+ def _escapes_unchanged(fn, name):
140
+ """Is the default handed to something that could keep or mutate it?
141
+
142
+ A default that is only read inside the function can still be mutated a frame
143
+ down if it is passed on by reference. Calls that provably cannot change it
144
+ are excluded, or every ``len(items)`` would look like a risk.
145
+ """
146
+ for node in ast.walk(fn):
147
+ if not isinstance(node, ast.Call):
148
+ continue
149
+ callee = node.func
150
+ callee_name = (
151
+ callee.id if isinstance(callee, ast.Name) else getattr(callee, "attr", None)
152
+ )
153
+ if callee_name in NON_MUTATING:
154
+ continue
155
+ for arg in node.args:
156
+ if isinstance(arg, ast.Name) and arg.id == name:
157
+ return callee_name or "a call"
158
+ for kw in node.keywords:
159
+ if isinstance(kw.value, ast.Name) and kw.value.id == name:
160
+ return callee_name or "a call"
161
+ return None
162
+
163
+
164
+ def _reaching_mutations(fn, name):
165
+ """The mutations that touch the shared default, and the rebind line if any."""
166
+ rebound = _rebind_line(fn, name)
167
+ mutations = _mutations(fn, name)
168
+ return [m for m in mutations if rebound is None or m[0] < rebound], rebound, mutations
169
+
170
+
171
+ def _defect_clause(name, reaching):
172
+ """The half of the reason that describes the defect itself.
173
+
174
+ Shared by BITES and LATENT so the two answers never drift into describing
175
+ the same defect differently. Only what follows this clause changes.
176
+ """
177
+ where = ", ".join(f"{what} on line {line}" for line, what in sorted(reaching))
178
+ return f"`{name}` is mutated before it is rebound ({where})"
179
+
180
+
181
+ def analyse_param(fn, name):
182
+ """Decide one parameter of one function, from its body alone."""
183
+ reaching, rebound, mutations = _reaching_mutations(fn, name)
184
+
185
+ if reaching:
186
+ return Verdict(
187
+ BITES,
188
+ f"{_defect_clause(name, reaching)}, so every call that omits it sees "
189
+ f"the previous call's changes",
190
+ )
191
+
192
+ if rebound is not None:
193
+ detail = " before any mutation" if mutations else ""
194
+ return Verdict(
195
+ SAFE,
196
+ f"`{name}` is rebound on line {rebound}{detail}, so the shared default "
197
+ f"is never touched",
198
+ )
199
+
200
+ escapee = _escapes_unchanged(fn, name)
201
+ if escapee:
202
+ return Verdict(
203
+ CALLEE,
204
+ f"`{name}` is never mutated here but is passed to {escapee}(), which "
205
+ f"could mutate it a frame down",
206
+ )
207
+
208
+ return Verdict(SAFE, f"`{name}` is never mutated, so sharing it is harmless")
209
+
210
+
211
+ def _with_call_sites(fn, name, verdict, calls):
212
+ """Ask the callers whether the defect in ``fn`` can currently fire.
213
+
214
+ Only a complete answer changes anything. If every call site was resolved and
215
+ every one of them passes the argument, the shared default is never the
216
+ object being mutated and the defect is waiting rather than happening. Any
217
+ other shape leaves the verdict alone: an unresolved splat, a caller that
218
+ omits it, or no caller at all are each a reason to keep looking, not a
219
+ reason to clear it.
220
+ """
221
+ if calls is None:
222
+ return verdict
223
+
224
+ found = reach(fn, name, calls)
225
+ reaching, _, _ = _reaching_mutations(fn, name)
226
+ clause = _defect_clause(name, reaching)
227
+
228
+ if found.total == 0:
229
+ return Verdict(
230
+ BITES,
231
+ f"{clause}, and no call site of `{fn.name}()` was found under this path, "
232
+ f"so nothing here shows that callers pass it",
233
+ )
234
+ if found.unreachable:
235
+ plural = "" if found.total == 1 else "s"
236
+ return Verdict(
237
+ LATENT,
238
+ f"{clause}, but all {found.total} call site{plural} of `{fn.name}()` pass "
239
+ f"it explicitly, so the shared default is never the one being changed",
240
+ )
241
+ return verdict
242
+
243
+
244
+ def analyse(tree, line, parents=None, calls=None):
245
+ """Decide one B006 warning reported at ``line``.
246
+
247
+ ``calls`` is the optional call index from :mod:`willitbite.callsites`.
248
+ Without it the answer is about the function; with it the answer is about
249
+ the program, which is a strictly narrower and more useful claim.
250
+ """
251
+ from .escape import innermost_closure
252
+
253
+ fn = innermost_closure(tree, line)
254
+ if fn is None or isinstance(fn, ast.Lambda):
255
+ return Verdict(CALLEE, "no function definition found at this line")
256
+
257
+ names = mutable_defaults(fn)
258
+ if not names:
259
+ return Verdict(CALLEE, f"no mutable default found in the signature of `{fn.name}`")
260
+
261
+ decided = [(n, analyse_param(fn, n)) for n in names]
262
+ for name, verdict in decided:
263
+ if verdict.kind == BITES:
264
+ return _with_call_sites(fn, name, verdict, calls)
265
+ for _, verdict in decided:
266
+ if verdict.kind == CALLEE:
267
+ return verdict
268
+ return decided[0][1]
willitbite/ruffrun.py ADDED
@@ -0,0 +1,102 @@
1
+ """Get the warnings from ruff, or from a file ruff already wrote.
2
+
3
+ This does not reimplement the rules. Ruff decides what is a candidate and does
4
+ it faster and more correctly than a hand-rolled matcher would; the value here is
5
+ entirely in what happens to the list afterwards. Two consequences follow.
6
+
7
+ Ruff is invoked with ``--isolated`` so the answer does not depend on whether the
8
+ project being inspected happens to enable these rules. A team that has not
9
+ adopted B006 and B023 yet is exactly the team that wants this, and their config
10
+ would otherwise return nothing.
11
+
12
+ Ruff is not a dependency. It is looked up as a subprocess and its absence is
13
+ reported as the ordinary situation it is, with the ``--json`` route available
14
+ for anyone who would rather run their own ruff and pipe the result in.
15
+ """
16
+
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+
22
+ #: The rules this tool can decide. Adding one means adding an analyser.
23
+ SUPPORTED = ("B006", "B023")
24
+
25
+
26
+ class RuffMissing(RuntimeError):
27
+ """ruff is not on PATH."""
28
+
29
+
30
+ def ruff_available():
31
+ return shutil.which("ruff") is not None or _module_available()
32
+
33
+
34
+ def _module_available():
35
+ try:
36
+ subprocess.run(
37
+ [sys.executable, "-m", "ruff", "--version"],
38
+ capture_output=True,
39
+ check=True,
40
+ )
41
+ return True
42
+ except (subprocess.CalledProcessError, OSError):
43
+ return False
44
+
45
+
46
+ def _ruff_command():
47
+ if shutil.which("ruff"):
48
+ return ["ruff"]
49
+ if _module_available():
50
+ return [sys.executable, "-m", "ruff"]
51
+ raise RuffMissing(
52
+ "ruff was not found. Install it with `pip install ruff`, or run ruff "
53
+ "yourself and pass its output with --json."
54
+ )
55
+
56
+
57
+ def run(path, rules=SUPPORTED):
58
+ """Run ruff over ``path`` and return its findings for ``rules``."""
59
+ command = _ruff_command() + [
60
+ "check",
61
+ # The project's own configuration is deliberately ignored: the people
62
+ # who need this are the ones who have not enabled these rules yet.
63
+ "--isolated",
64
+ "--select",
65
+ ",".join(rules),
66
+ "--output-format",
67
+ "json",
68
+ str(path),
69
+ ]
70
+ # A non-zero exit only means ruff found something, which is the normal case
71
+ # here, so the return code is not checked. A real failure shows up as
72
+ # unparseable output and is reported as that.
73
+ finished = subprocess.run(command, capture_output=True, text=True)
74
+ if not finished.stdout.strip():
75
+ if finished.returncode not in (0, 1):
76
+ raise RuntimeError(finished.stderr.strip() or "ruff failed")
77
+ return []
78
+ return parse(finished.stdout)
79
+
80
+
81
+ def parse(text):
82
+ """Parse ruff's JSON output into the shape the analysers want."""
83
+ try:
84
+ raw = json.loads(text)
85
+ except json.JSONDecodeError as exc:
86
+ raise RuntimeError(f"could not read ruff output as JSON: {exc}") from exc
87
+
88
+ findings = []
89
+ for item in raw:
90
+ code = item.get("code")
91
+ if code not in SUPPORTED:
92
+ continue
93
+ location = item.get("location") or {}
94
+ findings.append(
95
+ {
96
+ "code": code,
97
+ "filename": item.get("filename", ""),
98
+ "line": location.get("row", 0),
99
+ "message": item.get("message", ""),
100
+ }
101
+ )
102
+ return findings
willitbite/verdict.py ADDED
@@ -0,0 +1,50 @@
1
+ """The answers this tool gives, and why there are more than two.
2
+
3
+ A linter has two states, warned and silent. Reachability needs more, because
4
+ twice over the honest answer is neither.
5
+
6
+ CALLEE exists because deciding whether a closure escapes sometimes depends on a
7
+ function this tool cannot see into. LATENT exists because a function can be
8
+ genuinely wrong and still be unreachable: a mutable default that the function
9
+ mutates is a defect, but it only fires when some caller omits the argument, and
10
+ sometimes no caller does. Collapsing either case into "safe" or "bites" would be
11
+ a guess presented as an answer, so each gets its own verdict and names the thing
12
+ the reader has to look at.
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+
17
+ BITES = "BITES"
18
+ LATENT = "LATENT"
19
+ SAFE = "SAFE"
20
+ CALLEE = "CALLEE"
21
+
22
+ #: Report order: the reader wants the actionable ones first.
23
+ ORDER = (BITES, LATENT, CALLEE, SAFE)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Verdict:
28
+ """One warning, decided.
29
+
30
+ kind: BITES, LATENT, CALLEE or SAFE.
31
+ reason: a sentence a reader can check against the source without rerunning
32
+ anything. Never a restatement of the rule.
33
+ """
34
+
35
+ kind: str
36
+ reason: str
37
+
38
+ @property
39
+ def actionable(self) -> bool:
40
+ """Can this reach you as the code stands today?
41
+
42
+ LATENT is deliberately excluded. It is a real defect and it is reported
43
+ as one, but nothing calls it in a way that triggers it, so failing a
44
+ build on it would fail every build until somebody rewrote code that
45
+ currently works.
46
+ """
47
+ return self.kind == BITES
48
+
49
+ def __str__(self) -> str:
50
+ return f"{self.kind}: {self.reason}"
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.5
2
+ Name: willitbite
3
+ Version: 0.1.0
4
+ Summary: Tell which of ruff's loop-closure and mutable-default warnings can actually reach you at runtime
5
+ Project-URL: Homepage, https://github.com/muhzuhaib/willitbite
6
+ Project-URL: Issues, https://github.com/muhzuhaib/willitbite/issues
7
+ Project-URL: Changelog, https://github.com/muhzuhaib/willitbite/blob/main/CHANGELOG.md
8
+ Author: Muhammad Zuhaib Zahid
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Muhammad Zuhaib Zahid
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: cli,linting,python,ruff,static-analysis,triage
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Topic :: Software Development :: Quality Assurance
40
+ Requires-Python: >=3.10
41
+ Provides-Extra: dev
42
+ Requires-Dist: pytest>=8.0; extra == 'dev'
43
+ Requires-Dist: ruff>=0.6; extra == 'dev'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # willitbite
47
+
48
+ [![CI](https://github.com/muhzuhaib/willitbite/actions/workflows/ci.yml/badge.svg)](https://github.com/muhzuhaib/willitbite/actions/workflows/ci.yml)
49
+ [![PyPI](https://img.shields.io/pypi/v/willitbite.svg)](https://pypi.org/project/willitbite/)
50
+
51
+ Ruff will tell you that 99 closures in your codebase capture a loop variable. It will not tell you
52
+ that none of them can actually bite you.
53
+
54
+ `willitbite` takes the two rules that produce most of the noise when a project first adopts ruff and
55
+ asks the question the linter cannot: **can this one reach you at runtime?**
56
+
57
+ Here it is on [ragflow](https://github.com/infiniflow/ragflow), 1,259 files of Python, at commit
58
+ `0c28d59`:
59
+
60
+ ```
61
+ $ willitbite .
62
+ B006 mutable default arguments
63
+ 34 warning(s) 0 can bite 1 latent 13 depend on a callee 20 safe
64
+ [LATENT] agent/component/message.py:159
65
+ `kwargs` is mutated before it is rebound (kwargs[...] assigned on line
66
+ 181), but all 2 call sites of `get_kwargs()` pass it explicitly, so the
67
+ shared default is never the one being changed
68
+
69
+ B023 closures capturing a loop variable
70
+ 99 warning(s) 0 can bite 15 depend on a callee 84 safe
71
+
72
+ Nothing here can bite today. 133 warning(s), 0 reachable defects, 1 latent.
73
+ ```
74
+
75
+ 133 warnings, ten seconds, and 29 of them are worth a person's time: the one latent
76
+ defect and the 28 that turn on a function the tool cannot see into. The other 104 need nobody.
77
+
78
+ ## Why this exists
79
+
80
+ Both rules describe a shape, and the shape is not the bug.
81
+
82
+ **B023** flags every closure inside a loop that reads the loop variable. The bug it is looking for is
83
+ late binding: if the closure is still callable after the loop moves on, every copy sees the loop
84
+ variable's final value. A closure that is built and called inside the same iteration sees the value
85
+ it was written next to, which is what the author meant.
86
+
87
+ **B006** flags every mutable default argument. A mutable default is evaluated once and shared by
88
+ every call that omits it, but that only matters if the function changes it. A default that is only
89
+ read behaves exactly like the immutable one the author probably had in mind.
90
+
91
+ So a team switching these rules on faces a few hundred warnings, most of which are fine, with no way
92
+ to tell which is which except by reading all of them. That is the job this does.
93
+
94
+ ## Install and run
95
+
96
+ ```
97
+ pip install willitbite
98
+ willitbite ./src
99
+ ```
100
+
101
+ Python 3.10 or newer. To work from a checkout instead:
102
+
103
+ ```
104
+ git clone https://github.com/muhzuhaib/willitbite
105
+ pip install ./willitbite
106
+ ```
107
+
108
+ Ruff is called as a subprocess if it is on your PATH. If you would rather run your own:
109
+
110
+ ```
111
+ ruff check --select B006,B023 --output-format json ./src > warnings.json
112
+ willitbite --json warnings.json
113
+ ```
114
+
115
+ | Flag | Effect |
116
+ | --- | --- |
117
+ | `--all` | list the safe warnings too, not just the actionable ones |
118
+ | `--json-out` | print results as JSON |
119
+ | `--exit-zero` | always exit 0, for a first run that should not fail a build |
120
+
121
+ Exit code is 1 when something can bite, 0 when nothing can, 2 when the tool could not run.
122
+
123
+ ## Design decisions
124
+
125
+ **A linter has two states, warned and silent. This has four.** Two of the four exist because the
126
+ honest answer is sometimes neither of the other two.
127
+
128
+ `CALLEE` is for a closure handed to `run_with_retry(...)`, which is safe if that helper calls it and
129
+ dangerous if it stores it. Guessing safe would clear a real defect. Guessing unsafe would raise a
130
+ false alarm on every codebase this was built against. So it names the function you have to look at
131
+ and stops there.
132
+
133
+ `LATENT` is for a function that is genuinely wrong and that nothing currently calls in the way that
134
+ would hurt. It has its own section below.
135
+
136
+ **Ruff finds the candidates; this decides them.** Reimplementing the rules would be slower, less
137
+ correct, and would drift from ruff's behaviour. Ruff is invoked with `--isolated` on purpose, so the
138
+ answer does not depend on whether the project has enabled these rules: a team that has not adopted
139
+ them yet is exactly the team that wants this.
140
+
141
+ **Ruff is not a dependency.** A tool that reports on your linter should not pin a version of it.
142
+
143
+ **Every verdict carries a reason you can check without rerunning anything.** The reason names the
144
+ identifier and the line, never restates the rule. A verdict you have to take on trust is worth about
145
+ as much as the warning it replaced.
146
+
147
+ ## Latent defects: real, but nothing calls them that way
148
+
149
+ A function that mutates its own mutable default is wrong on its own terms. Whether the wrongness can
150
+ reach you is a separate question, and it is answered in the callers: the shared default is only ever
151
+ the object being mutated when somebody omits the argument.
152
+
153
+ So B006 warnings that survive the first pass get a second one, across every `.py` file under the
154
+ path you gave. If some caller omits the argument, the verdict stays `BITES`. If every caller passes
155
+ it explicitly, the verdict becomes `LATENT`: still a defect, still reported, but nothing in the tree
156
+ triggers it today.
157
+
158
+ ```
159
+ B006 mutable default arguments
160
+ 2 warning(s) 1 can bite 1 latent 0 depend on a callee 0 safe
161
+ [BITES] live.py:1
162
+ `cache` is mutated before it is rebound (cache[...] assigned on line 2),
163
+ so every call that omits it sees the previous call's changes
164
+ [LATENT] lib.py:1
165
+ `items` is mutated before it is rebound (items.append() on line 2), but all
166
+ 2 call sites of `collect()` pass it explicitly, so the shared default is
167
+ never the one being changed
168
+ ```
169
+
170
+ Those two functions have the same shape. Only their callers differ.
171
+
172
+ **A latent defect does not fail the build.** The exit code answers "can this bite today", and this
173
+ one cannot, so failing on it would fail every build until somebody rewrote code that currently
174
+ works. It is in the report because it will bite the first caller who leaves the argument out.
175
+
176
+ ## Known boundary: which callers get matched
177
+
178
+ Call sites are matched **by name**. Resolving `x.send()` to a definition properly needs type
179
+ inference, which this does not do, so a call counts whenever the called name matches, wherever it
180
+ appears. That over-matches, and the over-matching is deliberate: an unrelated `send` elsewhere can
181
+ only add an omission, and an omission is the answer that keeps the warning.
182
+
183
+ Three things count as no evidence at all, and each of them leaves a warning at `BITES`:
184
+
185
+ - a `**kwargs` splat at the call site, which might be carrying the argument
186
+ - a `*args` splat, which might be filling the position
187
+ - **no caller anywhere**, which usually means a public entry point called from outside the tree you
188
+ scanned, and is the case most likely to bite a stranger
189
+
190
+ The index is only built when something came back `BITES`, so a run that finds nothing reachable
191
+ never pays for the scan.
192
+
193
+ ## Where this came from
194
+
195
+ It was written while triaging 138 ruff warnings across two large open-source Python codebases by
196
+ hand. All 99 loop-closure warnings turned out to be false alarms, as did every mutable-default
197
+ warning that was actually reachable. Reading them one at a time to learn that took most of a day,
198
+ which seemed like a poor way to spend the next one.
199
+
200
+ ## License
201
+
202
+ MIT
@@ -0,0 +1,12 @@
1
+ willitbite/__init__.py,sha256=FUpJ5MNR6noX2iHBhedkhyY9nVUQpvGv_PrKJc8NWkw,91
2
+ willitbite/callsites.py,sha256=lMhP8WryZ_-VEW2PZ_UVDwXPrJMLGYfbPnCHNUrkS3k,6905
3
+ willitbite/cli.py,sha256=i7zcjFtCKQsRK8rcELpXK2jMQ74Af7KmyiBpONoAdwY,6424
4
+ willitbite/escape.py,sha256=z2pApKQafp--FblwzgSyEnNWl7sON3r6Mg9ONWA0nLU,7578
5
+ willitbite/mutation.py,sha256=RRboicC-T5m_gAcFKEi09o-biC1E2kvMYtMs3PJOyYk,9818
6
+ willitbite/ruffrun.py,sha256=jNzk6DVkhw7TcNmdLnvPeg5GQeA_ycn9dWf-ceygR3o,3322
7
+ willitbite/verdict.py,sha256=zjEnrwSa--SLcq3rnOlholue92R03uIFun3RFB9Dvcw,1667
8
+ willitbite-0.1.0.dist-info/METADATA,sha256=AMCHzdI17OTMZjqZkiUVMnPiRpOGdzymN-f_SmHEqtw,9487
9
+ willitbite-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ willitbite-0.1.0.dist-info/entry_points.txt,sha256=W0qIMfC-7u2cL8lG6wel2vQheL7kCUdr7T-h3bB1Xm4,51
11
+ willitbite-0.1.0.dist-info/licenses/LICENSE,sha256=18n8-_Lz8Si5JYibyhHrPuKT21C1_-muoU4gmhOWouc,1078
12
+ willitbite-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ willitbite = willitbite.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Zuhaib Zahid
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.