drift-linter 0.1.15__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rosie
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,467 @@
1
+ Metadata-Version: 2.4
2
+ Name: drift-linter
3
+ Version: 0.1.15
4
+ Summary: Static linter for the quiet ways code and config fall out of sync
5
+ Author-email: Rosie <rosie-6@ilands.app>
6
+ License-Expression: MIT
7
+ Keywords: linter,static-analysis,config,python
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # drift
20
+
21
+ Your code and your config are in a long-distance relationship.
22
+ drift finds where they've stopped talking.
23
+
24
+ Zero dependencies. One file. Point it at a Python project and it flags the
25
+ quiet ways code and configuration fall out of sync, before the crash does.
26
+
27
+ ## Install
28
+
29
+ pip install drift-linter
30
+
31
+ Yes, the distribution is `drift-linter`: the name `drift` on PyPI belongs to
32
+ a dead-squatted Python-2-era package (0.0.7, roughly a decade stale), and
33
+ squatting on a name that's already someone else's is not how you start.
34
+ The tool itself stays `drift`:
35
+
36
+ drift path/to/project
37
+ drift bot.py config_loader.py
38
+ drift --json --strict .
39
+
40
+ ## Rules
41
+
42
+ **R1 unexpected_kwarg** — calls passing keyword arguments the callee cannot
43
+ accept. Story: I once shipped a patch that called `SignalHistory(total_r=...)`
44
+ while the class still lacked the field. The bot crashed on startup. My fault,
45
+ my fix. drift catches that class of bug statically: if a call passes a keyword
46
+ the callee's signature doesn't declare, that's a partial patch apply waiting
47
+ to happen.
48
+
49
+ **R2 config_drift** — config keys read but never defined (they will silently
50
+ default, and silent defaults are how trading bots lose money), and keys
51
+ defined but never read (dead config is how settings stop mattering). Reads
52
+ with an explicit fallback (`config.get(key, default)`) are reported as
53
+ warnings, not errors — that's a documented default, not a silent one.
54
+
55
+ **R3 magic_number** — bare numbers doing a named constant's job. `0.4` three
56
+ times is a margin that escaped; it should have a name and a config key.
57
+ Int literals report as ints (`7 appears 3 times`, not `7.0`); repetition
58
+ inside test files is skipped — vectors and fixtures are data, not
59
+ unnamed constants.
60
+
61
+ **R4 phantom_name** — names used but never defined. The typo that doesn't
62
+ crash your linter, just silently defaults.
63
+
64
+ ## Usage
65
+
66
+ python3 drift.py path/to/project
67
+ python3 drift.py bot.py config_loader.py
68
+ python3 drift.py --json --strict .
69
+
70
+ Exit code 1 when there are errors (or warnings with `--strict`), 0 otherwise.
71
+ Rule ids: R1, R2, R3, R4. Pick with `--rules R1,R4`.
72
+
73
+ ## What it understands
74
+
75
+ - Class `__init__` and module-level function signatures, same-file and
76
+ cross-file, with simple inheritance, `**kwargs`, positional-only args.
77
+ - Config dicts assigned to config-ish names (`config`, `settings`, `env`, ...,
78
+ including `DEFAULT_SETTINGS`-style constants), `.env` / `.env.example`
79
+ files, and reads via `config.get()`, `os.getenv()`, `os.environ`, subscripts,
80
+ and local aliases (`s = self.state.settings`).
81
+ - Config loaded from outside the scanned code (`json.load`, `yaml.safe_load`,
82
+ `tomllib.load`, `os.environ.copy()`) is treated as external: its keys can't
83
+ be known, so reads through it are never flagged. A literal `json.loads('{...}')`
84
+ is the opposite — its inline keys count as defined.
85
+ - Helper lookups like `get_val(["a", "b"], default)` prove keys are read
86
+ (no false "dead config") without inventing errors about them.
87
+ - Repeated numeric literals, except the ones everyone uses (0, 1, 2) and
88
+ constants already named in UPPER_CASE.
89
+ - Names defined by assignment, imports, parameters, comprehensions, walrus.
90
+ - `from x import *` (R4): star imports resolve against the scanned tree —
91
+ static `__all__` wins exactly, otherwise every non-underscore module-level
92
+ name is exported, and transitive chains follow. Unresolvable targets
93
+ (external modules, dynamic `__all__`, import cycles) keep the legacy
94
+ whole-file skip: drift never guesses what a star might have brought in.
95
+
96
+ ## What it does not understand (yet)
97
+
98
+ - Dynamic code: exec, eval, getattr chains, metaprogramming.
99
+ - Cross-file phantom names outside star imports (R4 is per-file; R1 and R2
100
+ are project-wide; `from x import *` resolves cross-file since v0.1.14).
101
+ - Classes without `__init__` that inherit from unknown bases.
102
+ - Ambiguous names (two classes with the same name) are skipped, not guessed.
103
+ - R1 matches call targets by name, not import path. Same-file resolution is
104
+ precise (self./cls./ClassName. methods, same-project files); a call to a
105
+ name that only exists as a same-named function elsewhere in the scan can
106
+ still be misattributed. That is the cost of a zero-dependency static pass;
107
+ use `--rules` to scope, or rename.
108
+
109
+ Honest about limits, like any good linter should be.
110
+
111
+ ## Changelog
112
+
113
+ - **0.1.15 → PyPI** — drift is now installable: `pip install drift-linter`.
114
+ Distribution name is `drift-linter` because `drift` on PyPI is a
115
+ dead-squatted Python-2-era Django package (0.0.7, ~a decade stale); the
116
+ tool itself stays `drift`. Wheel + sdist built from a PEP 621 pyproject
117
+ (setuptools>=77, PEP 639 `license = "MIT"`), clean-venv install verified,
118
+ scan output byte-identical to a direct `python3 drift.py` run.
119
+
120
+ - **0.1.15** — field test #8 (python-dateutil 2.9.0, 18 files). The corpus
121
+ itself: 22 warnings, all real and hand-verified (Gauss Easter
122
+ algorithm, day-of-year math, TZif byte reads — low urgency, textbook
123
+ style, same verdict class as Sarah's 360.0). The run's real finds were
124
+ drift's own, in three places. R1: `WindowsError` is a py2 builtin that
125
+ py3 keeps as an OSError alias on Windows; `except WindowsError:` in
126
+ platform-guarded compat code was flagged phantom — now a known compat
127
+ name (pyflakes precedent), and a typo control still flags. R3: buckets
128
+ keyed by exact value, not float() — a mixed int/float family used to
129
+ print "7.0 appears 45 times" at an int anchor (44 int 7s plus
130
+ relativedelta's `days / 7.0`); now it prints "7 / 7.0 appears 45
131
+ times", and ints past 2^53 no longer collapse into one bucket. JS
132
+ engine (found by field parity on the real tree, not the corpus): the
133
+ hand-typed BUILTINS list was missing the OSError family and Warning
134
+ classes (`except FileNotFoundError:` false-flagged in the browser),
135
+ and chained tuple assignment `a, b = c = expr` dropped the chain tail
136
+ (phantom `weekdays`, lost `range(7)`) — fixed, with the builtins list
137
+ now generated from dir(builtins) and a message tiebreaker in the final
138
+ sort so same-line buckets order identically in both engines. 105/105
139
+ tests (6 new), 92/92 parity (5 new corpus cases), field parity
140
+ byte-identical on the dateutil tree, self-scan clean, demo
141
+ browser-verified.
142
+
143
+ - **0.1.14** — cross-file R4, both engines. `from x import *` no longer
144
+ blanks a whole file: sibling star imports resolve against the scan tree
145
+ (static `__all__` wins exactly; otherwise every non-underscore
146
+ module-level name, including plain `import x` re-exports and sibling
147
+ submodule names from relative from-imports; transitive chains resolve
148
+ recursively). Unresolvable targets (external modules, dynamic `__all__`,
149
+ import cycles) still keep the legacy whole-file skip — a blind spot that
150
+ reports a clean bill of health by policy is the v0.1.12 footgun all over
151
+ again, so partial resolution never guesses. Python engine (Phase A) plus
152
+ the JS mirror (Phase B): 99/99 tests, 87/87 parity byte-identical
153
+ (13 new corpus cases: sibling resolve, typo flagged, `__all__` honored
154
+ and empty, underscore not exported, external skip, relative package,
155
+ transitive chain, mixed skip, cycle skip, plain-import re-export,
156
+ sibling submodule name, dynamic `__all__` skip), self-scan clean, demo
157
+ browser-verified.
158
+
159
+ - **0.1.13** — the data-collection fix, both engines (Sarah's field
160
+ report, verification-script round). Sarah ran drift 0.1.12 on her own
161
+ machine and came back with a disagreement: `verify_refactor.py` flagged
162
+ `21 appears 3 times`, and the 21s were dates in a test matrix (Jan 21,
163
+ Jun 21, Dec 21). Her defense: "data isn't a promise to future me, it's
164
+ data. Naming them DAY_21 would make the script worse." She's right —
165
+ R3's premise is "same literal N times = one constant copied N times, a
166
+ sync hazard"; three different dates sharing a digit are not one constant
167
+ and are not meant to stay in sync. Fix: literals nested in collection
168
+ literals (List/Tuple/Set, at any depth before a statement boundary) are
169
+ data named by the collection — a date matrix, a port list, a fixture —
170
+ and don't count toward repetition. Same spirit as the existing dict-value
171
+ and test-file exclusions. Literals in logic positions (assignments,
172
+ compares, call args, ranges) still flag: the requests control
173
+ (`3 appears 3 times`) and the click positional-3 test both hold. Verified
174
+ both matrix shapes (row tuples AND `D(1, 21)` call-arg rows) clean, both
175
+ engines; Sarah's refactored algiers_sun.py still 0 findings; self-scan
176
+ clean. 88/88 tests (3 new), 74 parity cases byte-identical.
177
+
178
+ - **0.1.12** — field test #7 (Sarah's Algiers sunset calculator, 2 files).
179
+ Her code: 1 finding, and it's a real one — `360.0` appears 6 times in
180
+ algiers_sun.py (angle wrap, longitude → day-fraction, hour angle →
181
+ day-fraction; lines 20/24/36/41/42), warning severity, every occurrence
182
+ verified by hand. Textbook NOAA-math style, low urgency, correct to flag.
183
+ The interesting find of the run was in drift itself: `--rules
184
+ magic_number` silently scanned NOTHING. The CLI only understood the R1–R4
185
+ codes, so any descriptive name (or typo) produced a clean bill of health
186
+ — the one output a tool like this must never produce by accident.
187
+ `--rules` now accepts both forms (`R3` / `magic_number`, case-insensitive)
188
+ and rejects unknown names with exit code 2; `analyze()` raises ValueError
189
+ for API callers. JS engine unchanged (demo runs all rules, no filtering
190
+ to go wrong). 85/85 tests, 74 parity cases byte-identical, self-scan
191
+ clean, demo browser-verified.
192
+
193
+ - **Field test #6 (tenacity, 12 src files, master @ 26f719d 2026-08-06)** —
194
+ second fully clean test, and the config-heaviest corpus yet. tenacity is
195
+ nothing but configuration objects: every retry/stop/wait strategy is a
196
+ class whose `__init__` stores user settings, and `BaseRetrying` takes a
197
+ dozen config-ish parameters. 0 findings — and the silence is verified, not
198
+ assumed. All 12 files hand-read: every attribute bound in `__init__` is
199
+ read somewhere, the deprecated `initial` param in wait_exponential_jitter
200
+ warns and reassigns, the two `sys.version_info >= (3, x)` idioms are
201
+ correctly skipped as version tuples, zero env reads exist to misfire on.
202
+ Planted-bug control on a copied tree: 4 decoys (one per rule) all caught
203
+ at the right file and line — dead config key, unexpected kwarg, phantom
204
+ name, repeated magic number. One decoy rejection worth recording: my first
205
+ R2 decoy was `TIMEOUT_BUDGET`, and drift correctly did NOT treat it as
206
+ config — BUDGET is not a config word, and a name that could be money
207
+ shouldn't be scanned as settings. Renamed to `RETRY_OPTIONS`, flagged
208
+ instantly. The v0.1.9–0.1.11 R2 hardening (literal-returning methods,
209
+ binding shapes, user-set key contracts) holds on the corpus most likely to
210
+ break it. No version bump: nothing to fix, nothing changed.
211
+ - **0.1.11** — the binding-shape fix, both engines. R2 tracked config
212
+ receivers only when a name was bound by `=`; every other binding form
213
+ (with, async with, for, async for, except-as) left a config-ISH bound
214
+ name looking like a config object. So `async with
215
+ aiohttp.ClientSession() as options:` made `options.get("timeout", 30)`
216
+ look like a config read with a fallback, `with open(...) as config:`
217
+ made `config["retries"]` look like an undefined config key, and the
218
+ for/except twins did the same. Those bound names are now plain locals
219
+ in both engines — unless the source is itself config-ish (`with
220
+ Config() as config:` keeps reading config, `for config in cfg:` too),
221
+ mirroring the existing `options = fetch()` rule for assignments. While
222
+ probing, the JS parser had a real gap underneath: statement-level
223
+ `parseFor` stored the iterable as an ARRAY of expressions and walk()
224
+ recursed into it as if it were a node, so every load inside a
225
+ for/async-for iterable was invisible to the browser engine —
226
+ `for x in agen():` never flagged `agen`. parseFor now unwraps a single
227
+ iterable and wraps multi-iterables (`for z in a(), b():`) as a Tuple,
228
+ exactly like Python's ast, plus a defensive array guard in the
229
+ walker. 82/82 tests (5 new), parity 69 → 74 cases (5 new), all
230
+ byte-identical, self-scan clean, field-test corpora unchanged
231
+ (click/pyjwt/requests identical before and after).
232
+ - **0.1.10** — field test on click (17 src files, the CLI framework
233
+ underneath httpie). 8 warnings, and this time the warning was the
234
+ story: click is clean, drift was wrong 7 times out of 8. R3's
235
+ repetition rule counted values the code had already named. Three new
236
+ exemptions, one principle — a literal is not magic when the code
237
+ names it: dict values (the `_ansi_colors = {"green": 32, ...}` table
238
+ IS the named constant; 30/32/36 were flagged AT its definition),
239
+ signature defaults (`width: int = 36`, `col_max: int = 30` — the
240
+ parameter is the name), and keyword-argument values (`stacklevel=3`
241
+ x5 in core.py — the keyword is the name; the same 3 passed
242
+ positionally still flags). Also: `1 << 32` bit-magnitude idioms are
243
+ the named form of 2**32, not a magic 32. The JS engine had a real
244
+ gap underneath all this: parseArgs parsed defaults and threw them
245
+ away, so the browser engine couldn't see `width=36` at all — defaults
246
+ are now stored, walked, and exempted in both engines. click: 8 → 4
247
+ warnings; the one real finding stands (three undocumented `return
248
+ 127` exit codes in open_url, comment on only the third); the other
249
+ three are labeled residue (60/24 time conversions in format_eta,
250
+ Win32 `GetStdHandle(-10)` handles, ANSI background offset). 77/77
251
+ unit tests (4 new), parity corpus 68 → 69 cases, both engines
252
+ byte-identical, self-scan clean.
253
+
254
+ - **0.1.9** — field test on PyJWT (26 files, library + test suite): 9
255
+ findings, all of them drift's fault. R2 missed the cleanest defaults
256
+ pattern in the wild: `self.options = self._get_default_options()` where
257
+ the method body is a bare `return {dict literal}`. PyJWS and PyJWT both
258
+ build their option tables that way, so every read of `verify_signature`,
259
+ `require`, `strict_aud` and `enforce_minimum_key_length` was flagged
260
+ read-but-never-defined. R2 now tracks literal-returning methods (their
261
+ keys are definitions when assigned to a config-ish target, including
262
+ `self.options = {...}` attribute targets); non-literal calls still
263
+ demote config-named locals to plain dicts. R3 fixed two things: int
264
+ literals were reported as floats (`7.0 appears 3 times` for `(x + 7) //
265
+ 8` — now `7`), and the repetition heuristic fired on test data (65537
266
+ x6, 1024 x5, leeway 5 x3 are vectors, not unnamed constants) — R3 now
267
+ skips test files like R2. PyJWT: 9 findings → 3, all labeled residue:
268
+ the ceil-div-by-8 idiom (7/8 in utils), base64 padding `% 4` plus
269
+ `stacklevel=4`, and EC coordinate length 32 — the warning doing its
270
+ job, not noise. 0 real bugs in PyJWT, 2 real bugs in drift, fixed.
271
+ Parity corpus 62 → 68 cases; 73/73 unit tests; both engines
272
+ byte-identical; self-scan clean.
273
+
274
+ - **0.1.8** — field test on requests (20 files): 2 warnings, both failed
275
+ manual verification. R3 now skips three more structural idioms: version
276
+ tuples/lists used directly in comparisons (`assert (3, 0, 2) <=
277
+ (major, minor, patch) < (8, 0, 0)`, `[1, 3, 4] < crypto_version_list`),
278
+ literals compared against a subscript (`_ver[0] == 3` is a Python-major
279
+ check, not a magic constant), and chained HTTP status ranges
280
+ (`400 <= r.status_code < 500`). requests: 2 warnings → 1 (the remaining
281
+ one is four `3`s doing byte/count work in encoding detection — the
282
+ warning doing its job, not noise). The field test also exposed two engine
283
+ gaps fixed in the JS port: the parser dropped the expressions in
284
+ `raise X()` and `assert X` entirely, so every rule was blind inside them;
285
+ and R4's use-line lists were emitted in unspecified AST-walk order — both
286
+ engines now sort them, so findings point at the first source occurrence
287
+ and parity is deterministic. Parity corpus 60 → 62 cases; 67/67 unit
288
+ tests; both engines byte-identical; self-scan clean.
289
+ - Field test #3 (python-dotenv 1.2.2, 20 files including its test
290
+ suite): zero findings and zero misses. Every file hand-checked — no
291
+ dead config, no phantom names, no dead version idioms; the conditional
292
+ `Popen` import in cli.py is guarded by the same `sys.platform ==
293
+ "win32"` check that uses it, and every `os.environ` access is external
294
+ by design. A planted-bug control run confirmed the whole tree is
295
+ scanned, so the clean result is real. First field test with nothing to
296
+ fix on either side — the httpie and requests hardening holds.
297
+ - **0.1.7** — field test on httpie (89 files): 8 findings, all three
298
+ config_drift findings failed manual verification, each for a distinct
299
+ reason. R2 now resolves the RECEIVER, not just its tail name: an
300
+ attribute chain (`X.config`, `lexer.options`) counts as config only when
301
+ its ROOT is config-ish or self/cls — `lexer.options.get('precise')` is a
302
+ pygments lexer option dict, not the app config, and is no longer flagged.
303
+ Reads through config objects with an external key contract
304
+ (`env.config.get(...)`, bare `self.get(...)` inside a config class) are
305
+ warning-tier, not errors: httpie's `disable_update_warnings` and
306
+ `developer_mode` are documented user-set keys, and a UserDict config's
307
+ missing keys default by design. Bare `self['k']`/`self.get('k')` inside a
308
+ config-named class (or a class carrying a DEFAULTS-style dict) now counts
309
+ as a read, so `Config.default_options` is no longer dead config. R3 skips
310
+ version tuples under named constants (`(3, 7)`) and slice bounds
311
+ (`url[3:]`) — both were flagged as repeated magic numbers on httpie.
312
+ httpie: 8 findings / 2 errors → 5 findings / 0 errors. Parity corpus
313
+ 56 → 60 cases; 64/64 unit tests; both engines byte-identical; self-scan
314
+ clean.
315
+ - **0.1.6** — `match` statements, end to end. Two real bugs found in the
316
+ Python reference while probing it: a dict pattern
317
+ (`case {'cmd': c, **rest}:`) crashed `_add_match_names` outright on the
318
+ current `MatchMapping` AST shape, and `case [a] as whole:` silently
319
+ missed the inner capture `a` (MatchAs recursion read the wrong field).
320
+ Both fixed with regression tests. The JS demo engine went from a
321
+ "tolerant stub" that skipped case bodies (phantom-name false positives on
322
+ every capture, and `case {'cmd': c, **rest}:` bodies silently dropped) to
323
+ a full pattern parser: capture/value/literal/wildcard/sequence (incl. open
324
+ sequences like `case host, *_ if ...:`), mapping with `**rest`, class
325
+ patterns (positional + keyword, attrs never bind), or-patterns with
326
+ Python's intersection semantics (`case a | b:` binds only names bound by
327
+ EVERY alternative), `as` patterns, guards, tuple subjects, and nested
328
+ matches. `match`/`case` are now true soft keywords: `case = 1`,
329
+ `for case in ...`, `def match():` all parse as ordinary code, matching
330
+ CPython. Pattern value/class names (`Color.RED`, `Point(x=...)`) load
331
+ their roots exactly like the reference, so a genuinely undefined class
332
+ name is still flagged. Parity corpus 47 → 56 cases, plus a 21-case
333
+ edge sweep; both engines byte-identical on all of them. Demo sample now
334
+ includes a match block.
335
+ - **0.1.5** — the browser demo engine catches up with the CLI. The JS
336
+ engine's R4 still had the async gap Python fixed in 0.1.4
337
+ (`async with ... as x` bindings were never registered as definitions, so
338
+ the demo flagged `session`/`resp` as phantom names); R2 was a stripped
339
+ port with the wrong bucket semantics (`os.environ['K']` errored as a hard
340
+ read, `.get(k, default)` errored instead of warning, `os.getenv` was
341
+ silently ignored, no external-source tracking, no aliases, no scope
342
+ awareness, no dash/underscore normalization, no env-doc awareness).
343
+ R2 is now a faithful port of the Python reference: soft/ext/env/list read
344
+ buckets, scope-keyed aliases/ext_names/plain_vars, comprehension
345
+ shadowing, config-source classification, handler maps, super-init and
346
+ pluginargument defs, get_option/set_option tracking, test-file skip,
347
+ `.env` + `.env.example` docs, and dash/underscore normalization. R1 got
348
+ the v0.1.3 treatment too: attribute calls on unresolvable receivers
349
+ (`unittest.main`, `obj.x`) are never guessed against module-level
350
+ functions, `self.`/`cls.` calls resolve against the enclosing class's
351
+ methods (inherited included), `ClassName.method(...)` checks class
352
+ methods, and cross-file `@pytest.fixture` calls are treated as closure
353
+ calls. The JS parser also learned the constructs real code uses:
354
+ `match`/`case` as soft-keyword names (`for case in ...`), set
355
+ comprehensions and bare generator expressions as sole call args, `not in`
356
+ comparisons, `| ^ & << >>` operators, implicit adjacent-string
357
+ concatenation (`f"a" f"b"`), `yield a, b`, lambda params without
358
+ annotation-eating (`lambda f: (f.x, f.y)`), attribute access on keywords
359
+ (`.match(`), and a comment-handling bug that swallowed the newline after
360
+ `stmt # comment` and desynced the whole indent stack. f-string scans no
361
+ longer treat attribute tails (`kw.arg`) or dotted calls (`'.'.join(...)`)
362
+ as bare loads. The parity corpus grew 25 → 47 cases; both engines produce
363
+ byte-identical findings on all 47, and the JS engine now self-scans
364
+ drift.py + test_drift.py clean (parse errors 71 → 0). JS engine version
365
+ now tracks the CLI (0.1.6).
366
+ - **0.1.4** — streamlink field test: 184 findings, every one verified by
367
+ hand. Zero real bugs in streamlink, but drift had seven distinct
368
+ false-positive classes hiding them. Fixed, with regression tests: - R4: `async with ... as x` (including tuple unpacking), `async for x in
369
+ ...`, and `match` pattern bindings were never registered as definitions
370
+ — that alone was 17 phantom-name errors (nursery, frame_id, cm, ...).
371
+ Sphinx-injected `tags` in `docs/conf.py` is now known.
372
+ - R1: bare-name calls resolved against same-named functions anywhere in
373
+ the project, ignoring the file's own imports (`get_version` from
374
+ versioningit resolved to a CDP method). Resolution is now import-aware:
375
+ external imports are skipped, same-project imports resolve to the right
376
+ file, and `@pytest.fixture`-decorated callees are never guessed (a
377
+ direct call to a fixture name is the fixture's returned closure).
378
+ - R2: inline dict literals passed to config-ish constructors
379
+ (`super().__init__({...})`, `*Options(...)`) count as definitions;
380
+ `@pluginargument("key")` decorators define keys; `get_option`/`set_option`/
381
+ `.set`/`.update` are tracked; dash and underscore key forms are the same
382
+ key (streamlink Options normalizes `_`→`-`); handler maps
383
+ (`_MAP_GETTERS`/`_MAP_SETTERS`-style dicts of key→callable) are wired
384
+ keys, not dead config; a local `options = fetch(...)` is a plain dict,
385
+ not config, but `dict(cfg)` copies stay config; test files are skipped
386
+ (they deliberately read missing keys).
387
+ Result on streamlink: 184 → 108 findings, errors 23 → 0 — and the 4
388
+ remaining dead-config warnings are REAL: `sbscokr`'s `id` option and
389
+ twitch's `disable-ads`/`disable-hosting`/`disable-reruns` are declared but
390
+ never read anywhere in the plugin. Confirmed by reading the code.
391
+ Known limitations left: deprecated-alias maps and setter-mapped options
392
+ whose reads are fully dynamic (soop's `afreeca-*`, `_OPTIONS_HTTP_ATTRS`).
393
+ - **0.1.3** — R1 no longer misattributes calls: `self.`/`cls.` calls resolve
394
+ against the enclosing class's own methods first (a same-named module-level
395
+ function is a different callee), inherited methods count, and attribute
396
+ calls on unresolvable receivers (`unittest.main`, `obj.x`) are skipped
397
+ instead of being guessed against module-level functions.
398
+ `ClassName.method(...)` calls are checked against the class's method
399
+ signature. Crossedge's 7 `exit_prices(position_side=...)` errors were all
400
+ false positives — the PaperBot method accepts that kwarg; errors 7 → 0.
401
+ - **0.1.2** — R2 precision pass driven by the crossedge field test (215
402
+ findings, 93 of them config-drift false positives): external config sources
403
+ (`json.load` / `yaml` / `tomllib` / `os.environ`) are no longer treated as
404
+ in-repo definitions; `DEFAULT_SETTINGS`-style constants count as config;
405
+ alias receivers are scope-aware (a loop variable reusing a config alias's
406
+ name in another function is not fooled); `.get(k, default)` downgrades to a
407
+ warning; `.env.example` counts as env documentation. Crossedge errors went
408
+ 89 → 0; the 8 remaining dead-config warnings were confirmed real.
409
+ - **0.1.1** — R4 builtins generated from the interpreter instead of a
410
+ hand-typed list (FileNotFoundError regression, found on the httpie field
411
+ test); R2 learned `os.environ` and `.env` files.
412
+
413
+ ## Why it exists
414
+
415
+ Every rule here comes from a bug I actually shipped or fixed in real trading
416
+ code. The fixes kept teaching the same lesson: code and config drift apart
417
+ quietly, and the crash comes later, at 3am, in production. drift is the
418
+ 3am-crash insurance I wish I'd had.
419
+
420
+ — Rosie
421
+
422
+ ## Field report (v0.1.4, streamlink)
423
+
424
+ streamlink is a mature, heavily tested project — a hard target for a young
425
+ linter. 184 findings. Verified every one by reading the code:
426
+
427
+ - 6 R1 errors → all drift bugs (import-blind resolution, fixture callees)
428
+ - 17 R4 errors → all drift bugs (async-with / async-for / match bindings)
429
+ - 46 R2 errors → all drift blind spots (inline constructor dicts,
430
+ pluginargument decorators, dash/underscore key normalization)
431
+ - 4 R2 warnings → **REAL FINDINGS**: options declared but never read:
432
+ `sbscokr` `id`, twitch `disable-ads` / `disable-hosting` / `disable-reruns`
433
+ - 98 R3 warnings → mostly idiomatic hardcoded values (status codes,
434
+ timeouts); low signal by design, warning-only
435
+
436
+ Zero NameErrors, zero wrong-kwarg crashes — the right answer for a project
437
+ with 200+ contributors and CI on every PR. And the 4 dead options are exactly
438
+ what drift is for: flags users can pass that do nothing.
439
+
440
+ — field notes, 2026-08-14
441
+
442
+ ## Field report (v0.1.1)
443
+
444
+ Ran against two real open-source projects and one trading bot in the wild.
445
+
446
+ **httpie** (mature, heavily tested): 39 findings → 33 after the builtins fix.
447
+ Every remaining finding verified as a false positive of a known class
448
+ (`subprocess.run` misattributed to a same-named module function; env vars read
449
+ but set externally; pygments lexer options read via `.get()`). Zero real bugs —
450
+ the right answer for a well-maintained codebase, and a good calibration check.
451
+
452
+ **InstaPy** (bot, less maintained): 52 findings, same false-positive classes.
453
+ The config cluster pointed at a genuine robustness gap (config keys expected
454
+ with no schema), even though none were in-repo literal bugs.
455
+
456
+ **crypto-paper-bot** (a trading bot mid-refactor): 215 findings. 13 phantom
457
+ names — 3 were drift's own builtins bug, **10 were real missing imports**
458
+ left behind by a monolithic → multi-file split: `urllib`, `asdict`, `logger`,
459
+ `today_key`, `fetch_candles`, `diagnostics`, `time` used but never imported.
460
+ Every one is a NameError waiting for its code path to run. Fix: seven one-line
461
+ imports (one lazy import to avoid a circular dependency) + one decision item
462
+ (`diagnostics()` was never ported out of the monolith).
463
+
464
+ Also learned (and documented): `self.method()` calls resolve to the wrong
465
+ same-named module function — R1 should prefer the enclosing class's method.
466
+
467
+ — field notes, 2026-08-13