willitbite 0.1.0__tar.gz → 0.1.1__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.
Files changed (25) hide show
  1. {willitbite-0.1.0 → willitbite-0.1.1}/CHANGELOG.md +10 -0
  2. {willitbite-0.1.0 → willitbite-0.1.1}/PKG-INFO +9 -2
  3. {willitbite-0.1.0 → willitbite-0.1.1}/README.md +8 -1
  4. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/__init__.py +1 -1
  5. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/callsites.py +36 -2
  6. {willitbite-0.1.0 → willitbite-0.1.1}/tests/test_callsites.py +64 -0
  7. {willitbite-0.1.0 → willitbite-0.1.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  8. {willitbite-0.1.0 → willitbite-0.1.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  9. {willitbite-0.1.0 → willitbite-0.1.1}/.github/ISSUE_TEMPLATE/wrong_verdict.yml +0 -0
  10. {willitbite-0.1.0 → willitbite-0.1.1}/.github/dependabot.yml +0 -0
  11. {willitbite-0.1.0 → willitbite-0.1.1}/.github/workflows/ci.yml +0 -0
  12. {willitbite-0.1.0 → willitbite-0.1.1}/.github/workflows/release.yml +0 -0
  13. {willitbite-0.1.0 → willitbite-0.1.1}/.gitignore +0 -0
  14. {willitbite-0.1.0 → willitbite-0.1.1}/CONTRIBUTING.md +0 -0
  15. {willitbite-0.1.0 → willitbite-0.1.1}/LICENSE +0 -0
  16. {willitbite-0.1.0 → willitbite-0.1.1}/SECURITY.md +0 -0
  17. {willitbite-0.1.0 → willitbite-0.1.1}/pyproject.toml +0 -0
  18. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/cli.py +0 -0
  19. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/escape.py +0 -0
  20. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/mutation.py +0 -0
  21. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/ruffrun.py +0 -0
  22. {willitbite-0.1.0 → willitbite-0.1.1}/src/willitbite/verdict.py +0 -0
  23. {willitbite-0.1.0 → willitbite-0.1.1}/tests/test_cli.py +0 -0
  24. {willitbite-0.1.0 → willitbite-0.1.1}/tests/test_escape.py +0 -0
  25. {willitbite-0.1.0 → willitbite-0.1.1}/tests/test_mutation.py +0 -0
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.1] - 2026-09-09
9
+
10
+ ### Fixed
11
+
12
+ - Call sites written through an import alias (`from lib import collect as c`,
13
+ then `c(...)`) were invisible to the call-site pass. A warning whose only
14
+ visible caller supplied the argument could be cleared as `LATENT` while the
15
+ call that omits the argument sat in the same tree. Aliased calls are now
16
+ indexed under the imported name as well as the alias.
17
+
8
18
  ## [0.1.0] - 2026-09-08
9
19
 
10
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: willitbite
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Tell which of ruff's loop-closure and mutable-default warnings can actually reach you at runtime
5
5
  Project-URL: Homepage, https://github.com/muhzuhaib/willitbite
6
6
  Project-URL: Issues, https://github.com/muhzuhaib/willitbite/issues
@@ -180,12 +180,19 @@ inference, which this does not do, so a call counts whenever the called name mat
180
180
  appears. That over-matches, and the over-matching is deliberate: an unrelated `send` elsewhere can
181
181
  only add an omission, and an omission is the answer that keeps the warning.
182
182
 
183
- Three things count as no evidence at all, and each of them leaves a warning at `BITES`:
183
+ Import aliases are resolved before the name match runs: `from lib import collect as c` followed by
184
+ `c(...)` is counted as a call to `collect`, including through relative imports. This matters because
185
+ an alias hides callers without adding any, and a caller the index cannot see counts as absent, which
186
+ is the direction a false `LATENT` comes from.
187
+
188
+ Four things count as no evidence at all, and each of them leaves a warning at `BITES`:
184
189
 
185
190
  - a `**kwargs` splat at the call site, which might be carrying the argument
186
191
  - a `*args` splat, which might be filling the position
187
192
  - **no caller anywhere**, which usually means a public entry point called from outside the tree you
188
193
  scanned, and is the case most likely to bite a stranger
194
+ - a name that reaches the function some other way: a star import, a rebinding like
195
+ `handler = collect`, or dispatch that only exists at runtime
189
196
 
190
197
  The index is only built when something came back `BITES`, so a run that finds nothing reachable
191
198
  never pays for the scan.
@@ -135,12 +135,19 @@ inference, which this does not do, so a call counts whenever the called name mat
135
135
  appears. That over-matches, and the over-matching is deliberate: an unrelated `send` elsewhere can
136
136
  only add an omission, and an omission is the answer that keeps the warning.
137
137
 
138
- Three things count as no evidence at all, and each of them leaves a warning at `BITES`:
138
+ Import aliases are resolved before the name match runs: `from lib import collect as c` followed by
139
+ `c(...)` is counted as a call to `collect`, including through relative imports. This matters because
140
+ an alias hides callers without adding any, and a caller the index cannot see counts as absent, which
141
+ is the direction a false `LATENT` comes from.
142
+
143
+ Four things count as no evidence at all, and each of them leaves a warning at `BITES`:
139
144
 
140
145
  - a `**kwargs` splat at the call site, which might be carrying the argument
141
146
  - a `*args` splat, which might be filling the position
142
147
  - **no caller anywhere**, which usually means a public entry point called from outside the tree you
143
148
  scanned, and is the case most likely to bite a stranger
149
+ - a name that reaches the function some other way: a star import, a rebinding like
150
+ `handler = collect`, or dispatch that only exists at runtime
144
151
 
145
152
  The index is only built when something came back `BITES`, so a run that finds nothing reachable
146
153
  never pays for the scan.
@@ -1,3 +1,3 @@
1
1
  """Decide which ruff warnings can actually reach you at runtime."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -15,7 +15,10 @@ to a definition properly needs type inference, which this does not have. So a
15
15
  call is counted whenever the called name matches, wherever it appears. That
16
16
  over-matches: an unrelated ``send`` in another module is counted too. The
17
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.
18
+ add an omission and an omission is the answer that keeps the warning. Import
19
+ aliases are resolved (``from lib import collect as c`` files ``c(...)`` under
20
+ ``collect`` too), because an alias hides callers without adding any, and
21
+ hidden callers are the one direction this pass must never err in.
19
22
 
20
23
  **Everything unresolvable stays unresolved.** A ``**kwargs`` splat might be
21
24
  carrying the argument, a ``*args`` splat might be filling the position, and a
@@ -78,6 +81,29 @@ def python_files(root):
78
81
  yield os.path.join(folder, name)
79
82
 
80
83
 
84
+ def import_aliases(tree):
85
+ """Map each local alias in ``tree`` to the imported name it stands for.
86
+
87
+ ``from lib import collect as c`` makes ``c(...)`` a call to ``collect``, so
88
+ the index needs both spellings or the aliased callers vanish, and a caller
89
+ the index cannot see is a caller it counts as absent.
90
+
91
+ Only ``as`` bindings belong here. An import without one already matches by
92
+ its own name, and indexing it a second time would double-count its callers.
93
+ ``import lib as l`` aliases a module, not a function. A star import names
94
+ nothing at all. Relative imports work because the module path is never
95
+ consulted, only the name being imported.
96
+ """
97
+ aliases = {}
98
+ for node in ast.walk(tree):
99
+ if not isinstance(node, ast.ImportFrom) or node.module is None:
100
+ continue
101
+ for alias in node.names:
102
+ if alias.asname is not None:
103
+ aliases[alias.asname] = alias.name
104
+ return aliases
105
+
106
+
81
107
  def index(root):
82
108
  """Map every called name under ``root`` to the calls that use it.
83
109
 
@@ -95,12 +121,20 @@ def index(root):
95
121
  tree = ast.parse(handle.read())
96
122
  except (OSError, SyntaxError, ValueError, UnicodeDecodeError):
97
123
  continue
124
+ aliases = import_aliases(tree)
98
125
  for node in ast.walk(tree):
99
126
  if not isinstance(node, ast.Call):
100
127
  continue
101
128
  callee = node.func
102
129
  if isinstance(callee, ast.Name):
103
- found[callee.id].append(Call(filename, node.lineno, node, False))
130
+ call = Call(filename, node.lineno, node, False)
131
+ found[callee.id].append(call)
132
+ # Also under the imported name, so the definition sees its
133
+ # aliased callers. Purely additive: the alias spelling stays
134
+ # indexed for any same-named function of its own.
135
+ resolved = aliases.get(callee.id)
136
+ if resolved is not None:
137
+ found[resolved].append(call)
104
138
  elif isinstance(callee, ast.Attribute):
105
139
  found[callee.attr].append(Call(filename, node.lineno, node, True))
106
140
  return found
@@ -179,6 +179,48 @@ def test_a_bound_method_call_that_omits_it_still_bites(tmp_path):
179
179
  assert v.kind == BITES
180
180
 
181
181
 
182
+ # --------------------------------------------------------------------------
183
+ # Import aliases.
184
+ #
185
+ # ``from lib import collect as c`` spells every call ``c(...)``. The pass
186
+ # matches by name, so before aliases were resolved those callers were simply
187
+ # absent from the index, and absence reads as safety here: a warning whose
188
+ # only aliased caller omitted the argument came back LATENT with the call
189
+ # that triggers it sitting in the same tree. These are the tests that earn
190
+ # the feature.
191
+ # --------------------------------------------------------------------------
192
+
193
+
194
+ def test_a_call_through_an_import_alias_is_still_found(tmp_path):
195
+ v = decide(
196
+ tmp_path,
197
+ {
198
+ "lib.py": MUTATES,
199
+ "app.py": "from lib import collect as c\ncollect(1, [])\nc(2)\n",
200
+ },
201
+ )
202
+ assert v.kind == BITES
203
+
204
+
205
+ def test_an_alias_supplying_the_argument_is_evidence_for_latent(tmp_path):
206
+ v = decide(
207
+ tmp_path,
208
+ {"lib.py": MUTATES, "app.py": "from lib import collect as c\nc(1, [])\n"},
209
+ )
210
+ assert v.kind == LATENT
211
+
212
+
213
+ def test_a_relative_import_alias_resolves_the_same_way(tmp_path):
214
+ v = decide(
215
+ tmp_path,
216
+ {
217
+ "lib.py": MUTATES,
218
+ "pkg/app.py": "from ..lib import collect as c\ncollect(1, [])\nc(2)\n",
219
+ },
220
+ )
221
+ assert v.kind == BITES
222
+
223
+
182
224
  # --------------------------------------------------------------------------
183
225
  # The index itself.
184
226
  # --------------------------------------------------------------------------
@@ -210,6 +252,28 @@ def test_index_records_attribute_calls_under_the_attribute_name(tmp_path):
210
252
  assert len(calls["send"]) == 2
211
253
 
212
254
 
255
+ def test_index_files_an_aliased_call_under_both_names(tmp_path):
256
+ calls = project(
257
+ tmp_path, {"app.py": "from lib import collect as c\nc(1)\nc(2)\n"}
258
+ )
259
+ assert len(calls["c"]) == 2
260
+ assert len(calls["collect"]) == 2
261
+
262
+
263
+ def test_an_import_without_an_alias_is_indexed_exactly_once(tmp_path):
264
+ calls = project(
265
+ tmp_path, {"app.py": "from lib import collect\ncollect(1)\ncollect(2)\n"}
266
+ )
267
+ assert len(calls["collect"]) == 2
268
+
269
+
270
+ def test_a_star_import_resolves_nothing(tmp_path):
271
+ """A star import names no local alias, so there is nothing to resolve."""
272
+ calls = project(tmp_path, {"app.py": "from lib import *\nc(1)\n"})
273
+ assert len(calls["c"]) == 1
274
+ assert len(calls["collect"]) == 0
275
+
276
+
213
277
  # --------------------------------------------------------------------------
214
278
  # The pass must not disturb anything else.
215
279
  # --------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes