python-constricter 0.2.2__tar.gz → 0.2.3__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 (22) hide show
  1. {python_constricter-0.2.2 → python_constricter-0.2.3}/PKG-INFO +176 -26
  2. {python_constricter-0.2.2 → python_constricter-0.2.3}/README.md +175 -25
  3. {python_constricter-0.2.2 → python_constricter-0.2.3}/pyproject.toml +14 -2
  4. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/__init__.py +3 -1
  5. python_constricter-0.2.3/src/constricter/annotations.py +653 -0
  6. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/baseline.py +1 -5
  7. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/checker.py +220 -47
  8. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/cli.py +122 -56
  9. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/config.py +19 -9
  10. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/explain.py +7 -0
  11. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/fixes.py +7 -1
  12. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/jsonc.py +22 -3
  13. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/notebook.py +7 -2
  14. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/project.py +35 -10
  15. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/pylint_plugin.py +5 -2
  16. python_constricter-0.2.2/src/constricter/annotations.py +0 -290
  17. {python_constricter-0.2.2 → python_constricter-0.2.3}/LICENSE.md +0 -0
  18. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/__main__.py +0 -0
  19. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/flake8_plugin.py +0 -0
  20. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/noqa.py +0 -0
  21. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/py.typed +0 -0
  22. {python_constricter-0.2.2 → python_constricter-0.2.3}/src/constricter/report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-constricter
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Lint rules: every local variable is typed where it's first bound. A flake8 plugin, a pylint plugin and a standalone CLI.
5
5
  Author: Ivy Duggan
6
6
  Requires-Python: >=3.11
@@ -51,6 +51,8 @@ Import-Name: constricter
51
51
 
52
52
  [snake](https://www.asciiart.eu/art/595284d82d1f8d6d)
53
53
 
54
+ <https://pypi.org/project/python-constricter/>
55
+
54
56
  ---
55
57
 
56
58
  Lint rules: every local variable is typed where it's first bound. Ships as a flake8 plugin, a pylint
@@ -89,27 +91,32 @@ class bodies too. Statements are read in source order, and only a name's first b
89
91
  | `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
90
92
  | `LVA005` | an annotation with `Any`, `object` or a generic without its parameters | name the real type |
91
93
  | `LVA006` | an annotation nested `nesting` deep (5 by default) | a `type` alias for a part of it |
94
+ | `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
92
95
 
93
96
  Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
94
97
  `global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
95
- enum members (a class whose base's name ends in `Enum` or `Flag`).
98
+ enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
99
+ or `Flag`).
96
100
 
97
101
  A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
98
102
  `type-comments`, or automatically in a module written to run on Python 2: one that imports
99
103
  `print_function`, `unicode_literals`, `absolute_import`, `division`, `with_statement`, `generators`
100
104
  or `nested_scopes` from `__future__`.
101
105
 
106
+ `LVA007` compares a block on its own: an `if`'s body and its `orelse`, a `try`'s body and its
107
+ `except`s, and the like, are different blocks, since they don't both run in the same pass.
108
+
102
109
  ## Levels
103
110
 
104
111
  Each level makes one more code an error. The rest are warnings: the CLI prints them (as `::warning`
105
112
  or SARIF `warning` in those formats) but exits 0; flake8 and pylint report errors only.
106
113
 
107
- | Level | Errors | Warnings |
108
- | ----------------- | -------------------------------- | -------------------------------------- |
109
- | `relaxed` / `0` | none | `LVA001`–`LVA004` |
110
- | `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`, `LVA006` |
111
- | `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`, `LVA006` |
112
- | `suffocate` / `3` | all | none |
114
+ | Level | Errors | Warnings |
115
+ | ----------------- | -------------------------------- | ------------------------------------- |
116
+ | `relaxed` / `0` | none | `LVA001`–`LVA004`, `LVA007` |
117
+ | `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`–`LVA007` |
118
+ | `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`–`LVA007` |
119
+ | `suffocate` / `3` | all | none |
113
120
 
114
121
  `LVA005` and `LVA006` aren't reported at `relaxed`.
115
122
 
@@ -119,14 +126,14 @@ Python 3.11+, no runtime dependencies.
119
126
 
120
127
  | Tool | Setup | Reports | Suppress |
121
128
  | ------ | ----------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
122
- | CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA006` | `# noqa: LVA001` |
123
- | flake8 | install it (on by default) | `LVA001`–`LVA006` | `# noqa: LVA001` |
124
- | pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9106` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
125
- | ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA006` | `# noqa: LVA001` |
129
+ | CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
130
+ | flake8 | install it (on by default) | `LVA001`–`LVA007` | `# noqa: LVA001` |
131
+ | pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9107` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
132
+ | ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
126
133
 
127
134
  pylint symbols: `unannotated-local-variable`, `untyped-for-or-match-variable`,
128
135
  `comment-typed-for-variable`, `unannotated-module-or-class-variable`, `vague-annotation`,
129
- `deeply-nested-annotation`.
136
+ `deeply-nested-annotation`, `redundant-annotation`.
130
137
 
131
138
  Options:
132
139
 
@@ -277,8 +284,9 @@ Trunk) pick the plugin up once it's installed alongside them.
277
284
  Without `lint.external`, ruff flags `# noqa: LVA00x` (RUF102) and `--fix` deletes it.
278
285
 
279
286
  The CLI defaults to `.`, checks `*.py` and `*.ipynb`, and skips hidden dirs, `__pycache__`, `venv`,
280
- `site-packages`, `build`, `dist` and `node_modules`. Exit codes: `0` no errors, `1` errors, `2` an
281
- unreadable or unparsable file, or a bad `pyproject.toml`.
287
+ `site-packages`, `build`, `dist` and `node_modules` by directory name; `--exclude` adds more
288
+ directory names (or globs) to skip the same way, on top of matching whole paths and file names. Exit
289
+ codes: `0` no errors, `1` errors, `2` an unreadable or unparsable file, or a bad `pyproject.toml`.
282
290
 
283
291
  ```bash
284
292
  pip install python-constricter # once the first release is out; until then:
@@ -326,6 +334,7 @@ With [uv](https://docs.astral.sh/uv/) installed (CI pins 0.12.17):
326
334
 
327
335
  ```bash
328
336
  export UV_PROJECT_ENVIRONMENT=local/.venv
337
+ uv venv --prompt constricter local/.venv # the prompt name; uv sync reuses this venv
329
338
  uv sync --locked --no-install-project --no-build # the dev group: hash-checked wheels from uv.lock
330
339
  uv pip install --python local/.venv --no-deps --no-build-isolation -e .
331
340
  ```
@@ -337,16 +346,18 @@ Checks (as CI runs them): `ruff check .` (every rule, preview included), `ruff f
337
346
  `constricter --coverage --all-scopes --fail-under=100 src tests`, `pytest --cov` (100% branch
338
347
  coverage). Everything generated goes in `local/`. Python is indented with 4 spaces.
339
348
 
340
- After editing the `dev` group, run `uv lock` (CI fails until you do). Dependabot updates `uv.lock`,
341
- the npm lock and the actions weekly.
349
+ After editing a dependency group, run `uv lock` (CI fails until you do). Dependabot updates
350
+ `uv.lock`, the npm lock and the actions weekly.
342
351
 
343
352
  Fuzzing (`tests/test_fuzz.py`) runs with the tests: hypothesmith generates valid Python, which must
344
353
  never crash the checker and must stay valid after `--fix`. For a large real codebase, run
345
354
  `local/.venv/bin/python tests/corpus.py [PATH]` by hand: it checks PATH (default: this Python's
346
- standard library, about 660 files in two seconds) at `suffocate` and prints the time, the offences
355
+ standard library, about 730 files in a few seconds) at `suffocate` and prints the time, the offences
347
356
  per code, and any crash. `local/.venv/bin/python tests/corpus_fix.py [PATH]` runs
348
357
  `--fix --unsafe-fixes` on a copy of it (in `local/corpus-fix/`) and checks every file still compiles
349
- and a second pass has nothing left to fix (the standard library: about 3,800 fixes, none breaking).
358
+ and a second pass has nothing left to fix. CI's Corpus job runs both against the standard library
359
+ and, from the pinned `corpus` dependency group (`requests`, `flask`, `django`, `sqlalchemy` — a tiny
360
+ HTTP client, two web frameworks and an ORM), the same way.
350
361
 
351
362
  CI also runs the tests on PyPy 3.11 and free-threaded Python 3.14, which install only the `test`
352
363
  dependency group: every dev tool doesn't have wheels for them, and the tests don't need them all.
@@ -371,7 +382,7 @@ Everything else is on. Some of these may be revisited.
371
382
  | typos | the word `astroid` | A real package name. |
372
383
  | harden-runner | `egress-policy: audit` on macOS and Windows, in the release jobs (release.yml, build.yml), and in the weekly external-link check | harden-runner supports only audit on GitHub's macOS and Windows runners; the release jobs haven't run yet; external links can go anywhere. |
373
384
  | reuse | `reuse lint` not run (the files still comply: `REUSE.toml` covers them) | No recent release ships a wheel for Python 3.11+, so installing it builds from source with an unpinned `poetry-core`. |
374
- | zizmor | `self-repository` on the CI job that runs the repository's root action | zizmor wants `$/`, and actionlint rejects a bare `$/` (it has no path), so that one line uses `./`. |
385
+ | zizmor | `self-repository` (`.github/zizmor.yml`) | Scorecard reads the `$/` form it wants as an unpinned third-party action, so local actions stay `./`. |
375
386
 
376
387
  To apply the rulesets in `.github/rulesets/` (repo admin):
377
388
 
@@ -392,10 +403,8 @@ Done:
392
403
  (Docs), pytest on Linux, macOS and Windows × Python 3.11–3.14 (Test), and the sdist and wheel,
393
404
  `twine check` and a wheel smoke test (Build).
394
405
  - **security.yml** runs on pushes, PRs and weekly: CodeQL (Python and Actions), zizmor (pedantic),
395
- actionlint (kjanat's fork, which reads the `$/` self-repository syntax the workflows use),
396
- pip-audit on the lock, and dependency review on PRs.
397
- - **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly. Its pin check misreads the `$/`
398
- references as unpinned actions, so it flags them.
406
+ actionlint, pip-audit on the lock, and dependency review on PRs.
407
+ - **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly.
399
408
  - **release.yml** runs on `v*` tags: CI, then **build.yml** (a reusable workflow) builds the dists,
400
409
  checks the tag matches the version, and attests their provenance (SLSA v1 Build Level 3, as the
401
410
  build and attestation run in a reusable workflow), then PyPI (trusted publishing), then a GitHub
@@ -423,7 +432,7 @@ Done:
423
432
  badges this project's CI keeps true.
424
433
  - **Baselines**, a **smarter `--fix`** (containers, same-module return types), **notebooks**, and
425
434
  JSON with comments and trailing commas wherever constricter reads JSON.
426
- - **Fuzzing**, a manual **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
435
+ - **Fuzzing**, a **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
427
436
  notebooks**, and **SLSA Build Level 3 provenance** (GitHub's artifact attestations, from a
428
437
  reusable build workflow) on each release.
429
438
  - **Python 3.11+**, the oldest version still maintained after 3.10's end of life in October 2026.
@@ -434,8 +443,107 @@ Done:
434
443
  Test jobs and Scorecard included.
435
444
  - **Stdin**, **`gitlab`, `junit` and `rdjson` output**, **safe and `--unsafe-fixes`**, **per-file
436
445
  ignores**, **`--exit-zero`** and **`--output-file`**, **tox and nox** snippets, **PyPy 3.11 and
437
- free-threaded 3.14** in CI, and a manual **`--fix` corpus run** (`tests/corpus_fix.py`).
446
+ free-threaded 3.14** in CI, and a **`--fix` corpus run** (`tests/corpus_fix.py`).
438
447
  - **Cross-module `--fix`** in the CLI (the flake8 and pylint plugins see one file at a time).
448
+ - **CI's Corpus job** runs `tests/corpus.py` and `tests/corpus_fix.py` against the runner's Python
449
+ standard library on every push and PR.
450
+ - **`project.Index`** sorts modules by name so `calls` finds a module/submodule import by prefix
451
+ (`bisect`) instead of scanning every indexed module.
452
+ - **Enum bases and factory calls resolve by import origin** (`annotations.factories`,
453
+ `checker._is_enum`'s `imported_from` check), so an aliased or re-exported `Enum`/`NamedTuple`/...
454
+ is still recognised; the bare-name lists remain a fallback for one imported some other way.
455
+ - **A general path-exclusion mechanism**: `--exclude` globs also match a directory name during a
456
+ directory walk, folding the built-in skip list (`__pycache__`, `node_modules`, hidden dirs, ...)
457
+ into the same mechanism instead of a separate hardcoded check.
458
+ - **`_FileRun` split** into `_CheckRun`, `_BaselineRun` and `_CoverageRun` (one per mode-group,
459
+ instead of one struct with fields only some modes populate), and `_check_path` and
460
+ `_baseline_path` share a `_read_checked` read-and-report-errors wrapper.
461
+ - **LVA007: duplicate/redundant typing.** A name annotated again with the type it already has, in
462
+ the same straight-line block; a warning at every level, an error at `suffocate`.
463
+ - **`--fix` infers more**: `not x` (always a real `bool`, unlike a comparison, which sqlalchemy's
464
+ own corpus data proves isn't safe to assume — it overloads `<`/`==` to build query expressions); a
465
+ table of builtins with a fixed, un-overloadable return type (`len`→`int`,
466
+ `isinstance`/`hasattr`/`callable`/`issubclass`→`bool`, `str`/`repr`/`chr`→`str`, `int`/`float`
467
+ →themselves, ...); and copying an already-known local's type for a plain `x = y` (from its own
468
+ annotation, an earlier fix in the same scope, or an annotated parameter) — a guessed source's type
469
+ copies too, marked just as guessed, so a chain of copies still converges in one `--fix` pass
470
+ instead of needing a second. Verified on the eighteen-codebase corpus below: fixed rose from
471
+ 12,104 to 13,608 of the same 94,523 found (12.8% → 14.4%), no crashes, nothing left to fix on a
472
+ second pass anywhere, and `requests`' own test suite (not just its compile check) passed
473
+ identically — 617 passed, 15 skipped, 1 xfailed — before and after `--fix --unsafe-fixes` on its
474
+ source.
475
+ - **`--fix` infers subscripts and attributes** of an already-typed local: `container[key]` (its
476
+ element type from a `list`, `dict` or homogeneous `tuple[T, ...]`; the same `list`/`str`/`bytes`
477
+ type back for a slice; nothing for a fixed-length heterogeneous tuple, since the element varies
478
+ with the index) and `obj.attr` (a class-level annotated attribute of a class defined in the same
479
+ module — not one only assigned in `__init__`, which would need dataflow across methods to see).
480
+ Both build on `_Scope.types`, so a guessed source's uncertainty carries through automatically, the
481
+ same as a plain copy. Re-verified on the corpus: no crashes, still converges in one `--fix` pass,
482
+ `requests`' test suite still passes identically, and fixed rose further (e.g. standard library
483
+ 4,431 → 4,450, mypy 1,939 → 1,996, django 2,408 → 2,414, sqlalchemy 830 → 848, pydantic 434 →
484
+ 447).
485
+ - **A permanent, pinned corpus.** By hand (`tests/corpus.py`/`corpus_fix.py`,
486
+ `--unsafe-fixes --all-scopes`), against eighteen real packages, to choose it — OpenCV's Python
487
+ bindings (the original idea) turned out to be a poor fit, since they're mostly thin C bindings,
488
+ not hand-annotated Python:
489
+
490
+ | Codebase | Version | Files | Left un-typed | Fixed | LVA006 @5 | LVA007 |
491
+ | ---------------- | ------- | ----: | ------------: | ---------: | --------: | -----: |
492
+ | standard library | 3.11.16 | 732 | 24,173 | 3,828 | 0 | 0 |
493
+ | mypy | 2.3.1 | 195 | 9,853 | 1,777 | 0 | 0 |
494
+ | pylint | 4.0.8 | 178 | 3,609 | 486 | 0 | 0 |
495
+ | libcst | 1.9.0 | 297 | 3,407 | 1,042 | 201 | 0 |
496
+ | uiautomator2 | 3.7.0 | 32 | 888 | 86 | 0 | 0 |
497
+ | requests | 2.34.2 | 19 | 372 | 45 | 0 | 0 |
498
+ | flask | 3.1.3 | 24 | 410 | 29 | 0 | 0 |
499
+ | click | 8.5.0 | 17 | 567 | 86 | 0 | 0 |
500
+ | praw | 8.0.3 | 89 | 602 | 114 | 0 | 0 |
501
+ | boto3 | 1.43.99 | 39 | 483 | 93 | 0 | 0 |
502
+ | django | 6.1.1 | 907 | 15,648 | 2,248 | 0 | 0 |
503
+ | pydantic | 2.13.5 | 105 | 2,998 | 367 | 0 | 0 |
504
+ | attrs | 26.1.0 | 13 | 336 | 60 | 0 | 0 |
505
+ | aiohttp | 3.14.3 | 55 | 1,590 | 217 | 0 | 0 |
506
+ | paramiko | 5.0.0 | 41 | 1,272 | 241 | 0 | 0 |
507
+ | scrapy | 2.19.0 | 179 | 1,814 | 389 | 1 | 0 |
508
+ | sqlalchemy | 2.0.54 | 257 | 12,556 | 643 | 7 | 0 |
509
+ | rich | 15.0.0 | 100 | 1,841 | 353 | 0 | 0 |
510
+ | **Total** | | | **82,419** | **12,104** | | |
511
+
512
+ No crashes on any of them, and `--unsafe-fixes` left nothing broken or nothing unfixed on a second
513
+ pass, on any of them; `--nesting`'s default (5) never fires on fourteen of the eighteen, and
514
+ LVA007 found nothing on any of them, at any nesting — strong evidence it isn't noisy
515
+ (`--nesting`'s default is still worth revisiting some day: `libcst`, deeply nested CST types, is
516
+ by far the most affected, `sqlalchemy` and `scrapy` are the only other two to hit it at all at the
517
+ default, and 3 already reported 124 times on sqlalchemy, 45 on mypy). Chose four to run
518
+ permanently in CI (see the Corpus job): **`requests`** (tiny, so a fast check; extremely stable
519
+ and widely known; the canonical "makes external API calls" library; unlike the dev tools,
520
+ representative of typical, lightly-typed real-world code), **`flask`** and **`django`** (two web
521
+ frameworks, more decorator/class-heavy than `requests`; `django` pinned to the 5.2 LTS, since 6.x
522
+ needs Python 3.12+) and **`sqlalchemy`** (an ORM, and the corpus most likely to exercise
523
+ `LVA006`). Each runs as its own Corpus (`package`) matrix job, from a new `corpus` dependency
524
+ group.
525
+
526
+ Also validated `requests` specifically: cloned `v2.34.2` (its source checkout, with its own test
527
+ suite, not just the installed wheel), ran `--fix --unsafe-fixes --all-scopes` on `src/requests/`,
528
+ and ran its own test suite before and after. Identical both times: 617 passed, 15 skipped, 1
529
+ xfailed — the inferred types changed nothing about its runtime behaviour. One found a real, if
530
+ inert, mistake in the "guessed" heuristic: `internetSettings = winreg.OpenKey(...)` (Windows-only,
531
+ guarded by `sys.platform == "win32"`, so untested by this run) got annotated
532
+ `internetSettings: winreg.OpenKey = ...` — `winreg.OpenKey` is a _function_, not a class, but its
533
+ PascalCase name (a Windows API convention, not Python's) fools the capitalised-name "constructs a
534
+ class" heuristic (`annotations._constructs`).
535
+
536
+ - **`--fix` infers `self.attr` and `str`/`bytes` method calls.** `classes` (used for `obj.attr`) now
537
+ also collects `self.x: T = ...` from anywhere in a method's body, not just class-level
538
+ annotations; a method whose first parameter is literally named `self` has it typed as its class
539
+ (`_owners`, by the method's `id()`, not by name — a same-named method on an unrelated class isn't
540
+ confused with it), so `self.attr` resolves the same way `obj.attr` already did. Separately, a
541
+ fixed table of `str`/`bytes` methods whose return type doesn't depend on their arguments (`strip`,
542
+ `split`, `startswith`, `encode`, `decode`, ...) makes `some_str.strip()` on an already-typed local
543
+ as certain as a builtin function call — not a guess, unlike an arbitrary method call, which stays
544
+ guessed. Re-verified on the corpus: no crashes, still converges in one `--fix` pass on all six
545
+ re-checked (standard library, mypy, `requests`, `flask`, `django`, `sqlalchemy`), and fixed rose
546
+ further still (e.g. mypy 1,996 → 2,074, sqlalchemy 848 → 1,039, `requests` 52 → 57).
439
547
 
440
548
  Next:
441
549
 
@@ -443,6 +551,48 @@ Next:
443
551
  CPython 3.10 one).
444
552
  2. Revisit the [disabled rules](#disabled-rules) as tools change (last checked 2026-09-22: COM812,
445
553
  one-line DOC201/DOC402 and `max-args` came back on; the rest can't go yet).
554
+ 3. **Raise `--fix`'s auto-fix rate further.** Attributes, subscripts, `self.attr` and `str`/`bytes`
555
+ method calls are done (see Done, above); what's left: a method call on an arbitrary class's
556
+ instance (`x = obj.method()`, unlike `str`/`bytes` isn't resolvable without following the
557
+ method's own return annotation, the same as `classes` already does for a field, extended to
558
+ methods) and `list`/`dict` methods whose return is the receiver's own element type (`list.pop`,
559
+ `dict.pop`, ...), which need the same element-type parsing `_subscripted` already does, just
560
+ reached from a method call instead of a subscript. `BinOp` (`a + b`, ~5% of the original sample)
561
+ would need operand types plus knowing the operator isn't overloaded to something else — riskier,
562
+ lower value, likely skip.
563
+ 4. **LVA008: a type that could narrow.** A warning at `constrict`, an error at `suffocate`: a
564
+ declared type that every value assigned to the name (across its lifetime, not just its first
565
+ binding) is consistent with a strictly narrower one, e.g. a `str` only ever assigned `"0"` or
566
+ `"1"` (could be `bool`), or a `float` only ever incremented, never divided (could be `int`).
567
+ Needs whole-variable value-flow analysis across every reassignment in a scope, not just a first
568
+ binding, which is a different (and much bigger) kind of check than `LVA001`–`LVA007`; wants its
569
+ own design pass (what counts as "consistent with" a type, how far to follow calls and mutation,
570
+ false-positive risk on a codebase this analysis can't fully see) before it's worth building.
571
+ 5. **LVA009: a reassignment that changes the type.** An error: `count: int = 0` later reassigned
572
+ `count = "done"` in the same scope. A real, common bug class (mypy already treats this as a type
573
+ error by default), but needs the same value-flow machinery as `LVA008` (infer every
574
+ reassignment's type with `annotations.inferred`, not just the first binding's), plus real
575
+ subtyping awareness to avoid noise `LVA008` doesn't have to worry about: a declared `X | None`
576
+ later assigned a plain `X` is normal Optional narrowing, not a bug, so the check needs to know
577
+ that's consistent rather than comparing annotation text like `LVA007` does; a name reused for
578
+ genuinely unrelated purposes (a sentinel, a generic helper handling more than one type by design)
579
+ is a real, if rarer, source of false positives to design around. Depends on `LVA008`'s design
580
+ work (same value-flow pass could likely serve both checks).
581
+ 6. **LVA010: a declared union only one branch ever uses.** A warning: `x: int | str = 0` where every
582
+ value ever assigned across `x`'s lifetime is consistent with only `int`, never `str` — the union
583
+ is wider than the code actually exercises, and could narrow to `int`. The complement of `LVA008`
584
+ (inferring a narrower type from values with no declared type to compare against) and `LVA009` (a
585
+ reassignment that breaks a declared type, not just widens what's already declared as a union);
586
+ shares the same value-flow machinery and design questions as both.
587
+ 7. **Show how each fix was inferred.** `annotations.inferred` now decides a fix through one of
588
+ several mechanisms (a literal, a container of literals, a same/cross-module function's declared
589
+ return type, a fixed-return builtin, a class it constructs, a copy of an already-typed local, a
590
+ subscript or an attribute of one), but `Offence.fix` keeps only the resulting annotation text,
591
+ not which one produced it. Surfacing that (`--diff`, or a verbose/explain mode) would help trust
592
+ and debug a fix, especially a guessed one. Needs every inference helper (`_scalar`, `_container`,
593
+ `_called`, `_subscripted`, the copy and attribute checks in `inferred` itself) to report a reason
594
+ alongside the type, not just the type — a real (if mechanical) change through most of
595
+ `annotations.py`'s inference path, not a one-line addition.
446
596
 
447
597
  After the first release (these need it on PyPI, or a published tag):
448
598
 
@@ -31,6 +31,8 @@
31
31
 
32
32
  [snake](https://www.asciiart.eu/art/595284d82d1f8d6d)
33
33
 
34
+ <https://pypi.org/project/python-constricter/>
35
+
34
36
  ---
35
37
 
36
38
  Lint rules: every local variable is typed where it's first bound. Ships as a flake8 plugin, a pylint
@@ -69,27 +71,32 @@ class bodies too. Statements are read in source order, and only a name's first b
69
71
  | `LVA004` | with `all-scopes`: the same as `LVA001`, in a module or class body | `name: T = ...` (`ClassVar[T]` in a dataclass) |
70
72
  | `LVA005` | an annotation with `Any`, `object` or a generic without its parameters | name the real type |
71
73
  | `LVA006` | an annotation nested `nesting` deep (5 by default) | a `type` alias for a part of it |
74
+ | `LVA007` | a name annotated again with the type it already has, in the same block | drop the second annotation |
72
75
 
73
76
  Exempt: comprehensions, `except ... as`, imports, `def`/`class`, `type` aliases, parameters,
74
77
  `global`/`nonlocal`, and `_`; in module and class bodies, dunder names (`__all__`, `__slots__`) and
75
- enum members (a class whose base's name ends in `Enum` or `Flag`).
78
+ enum members (a base imported from `enum`, however it's aliased, or else whose name ends in `Enum`
79
+ or `Flag`).
76
80
 
77
81
  A `# type:` comment (`x = 1 # type: int`, `with f() as x: # type: T`) counts as an annotation with
78
82
  `type-comments`, or automatically in a module written to run on Python 2: one that imports
79
83
  `print_function`, `unicode_literals`, `absolute_import`, `division`, `with_statement`, `generators`
80
84
  or `nested_scopes` from `__future__`.
81
85
 
86
+ `LVA007` compares a block on its own: an `if`'s body and its `orelse`, a `try`'s body and its
87
+ `except`s, and the like, are different blocks, since they don't both run in the same pass.
88
+
82
89
  ## Levels
83
90
 
84
91
  Each level makes one more code an error. The rest are warnings: the CLI prints them (as `::warning`
85
92
  or SARIF `warning` in those formats) but exits 0; flake8 and pylint report errors only.
86
93
 
87
- | Level | Errors | Warnings |
88
- | ----------------- | -------------------------------- | -------------------------------------- |
89
- | `relaxed` / `0` | none | `LVA001`–`LVA004` |
90
- | `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`, `LVA006` |
91
- | `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`, `LVA006` |
92
- | `suffocate` / `3` | all | none |
94
+ | Level | Errors | Warnings |
95
+ | ----------------- | -------------------------------- | ------------------------------------- |
96
+ | `relaxed` / `0` | none | `LVA001`–`LVA004`, `LVA007` |
97
+ | `strict` / `1` | `LVA001`, `LVA004` (the default) | `LVA002`, `LVA003`, `LVA005`–`LVA007` |
98
+ | `constrict` / `2` | `LVA001`, `LVA004`, `LVA002` | `LVA003`, `LVA005`–`LVA007` |
99
+ | `suffocate` / `3` | all | none |
93
100
 
94
101
  `LVA005` and `LVA006` aren't reported at `relaxed`.
95
102
 
@@ -99,14 +106,14 @@ Python 3.11+, no runtime dependencies.
99
106
 
100
107
  | Tool | Setup | Reports | Suppress |
101
108
  | ------ | ----------------------------------------------------- | ------------------------------- | ------------------------------------------------ |
102
- | CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA006` | `# noqa: LVA001` |
103
- | flake8 | install it (on by default) | `LVA001`–`LVA006` | `# noqa: LVA001` |
104
- | pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9106` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
105
- | ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA006` | `# noqa: LVA001` |
109
+ | CLI | `constricter [PATH...] [--level L] [--format F] [-q]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
110
+ | flake8 | install it (on by default) | `LVA001`–`LVA007` | `# noqa: LVA001` |
111
+ | pylint | `load-plugins = ["constricter.pylint_plugin"]` | `C9101`–`C9107` (symbols below) | `# noqa: LVA001` or `# pylint: disable=<symbol>` |
112
+ | ruff | run the CLI after ruff; set `lint.external = ["LVA"]` | `LVA001`–`LVA007` | `# noqa: LVA001` |
106
113
 
107
114
  pylint symbols: `unannotated-local-variable`, `untyped-for-or-match-variable`,
108
115
  `comment-typed-for-variable`, `unannotated-module-or-class-variable`, `vague-annotation`,
109
- `deeply-nested-annotation`.
116
+ `deeply-nested-annotation`, `redundant-annotation`.
110
117
 
111
118
  Options:
112
119
 
@@ -257,8 +264,9 @@ Trunk) pick the plugin up once it's installed alongside them.
257
264
  Without `lint.external`, ruff flags `# noqa: LVA00x` (RUF102) and `--fix` deletes it.
258
265
 
259
266
  The CLI defaults to `.`, checks `*.py` and `*.ipynb`, and skips hidden dirs, `__pycache__`, `venv`,
260
- `site-packages`, `build`, `dist` and `node_modules`. Exit codes: `0` no errors, `1` errors, `2` an
261
- unreadable or unparsable file, or a bad `pyproject.toml`.
267
+ `site-packages`, `build`, `dist` and `node_modules` by directory name; `--exclude` adds more
268
+ directory names (or globs) to skip the same way, on top of matching whole paths and file names. Exit
269
+ codes: `0` no errors, `1` errors, `2` an unreadable or unparsable file, or a bad `pyproject.toml`.
262
270
 
263
271
  ```bash
264
272
  pip install python-constricter # once the first release is out; until then:
@@ -306,6 +314,7 @@ With [uv](https://docs.astral.sh/uv/) installed (CI pins 0.12.17):
306
314
 
307
315
  ```bash
308
316
  export UV_PROJECT_ENVIRONMENT=local/.venv
317
+ uv venv --prompt constricter local/.venv # the prompt name; uv sync reuses this venv
309
318
  uv sync --locked --no-install-project --no-build # the dev group: hash-checked wheels from uv.lock
310
319
  uv pip install --python local/.venv --no-deps --no-build-isolation -e .
311
320
  ```
@@ -317,16 +326,18 @@ Checks (as CI runs them): `ruff check .` (every rule, preview included), `ruff f
317
326
  `constricter --coverage --all-scopes --fail-under=100 src tests`, `pytest --cov` (100% branch
318
327
  coverage). Everything generated goes in `local/`. Python is indented with 4 spaces.
319
328
 
320
- After editing the `dev` group, run `uv lock` (CI fails until you do). Dependabot updates `uv.lock`,
321
- the npm lock and the actions weekly.
329
+ After editing a dependency group, run `uv lock` (CI fails until you do). Dependabot updates
330
+ `uv.lock`, the npm lock and the actions weekly.
322
331
 
323
332
  Fuzzing (`tests/test_fuzz.py`) runs with the tests: hypothesmith generates valid Python, which must
324
333
  never crash the checker and must stay valid after `--fix`. For a large real codebase, run
325
334
  `local/.venv/bin/python tests/corpus.py [PATH]` by hand: it checks PATH (default: this Python's
326
- standard library, about 660 files in two seconds) at `suffocate` and prints the time, the offences
335
+ standard library, about 730 files in a few seconds) at `suffocate` and prints the time, the offences
327
336
  per code, and any crash. `local/.venv/bin/python tests/corpus_fix.py [PATH]` runs
328
337
  `--fix --unsafe-fixes` on a copy of it (in `local/corpus-fix/`) and checks every file still compiles
329
- and a second pass has nothing left to fix (the standard library: about 3,800 fixes, none breaking).
338
+ and a second pass has nothing left to fix. CI's Corpus job runs both against the standard library
339
+ and, from the pinned `corpus` dependency group (`requests`, `flask`, `django`, `sqlalchemy` — a tiny
340
+ HTTP client, two web frameworks and an ORM), the same way.
330
341
 
331
342
  CI also runs the tests on PyPy 3.11 and free-threaded Python 3.14, which install only the `test`
332
343
  dependency group: every dev tool doesn't have wheels for them, and the tests don't need them all.
@@ -351,7 +362,7 @@ Everything else is on. Some of these may be revisited.
351
362
  | typos | the word `astroid` | A real package name. |
352
363
  | harden-runner | `egress-policy: audit` on macOS and Windows, in the release jobs (release.yml, build.yml), and in the weekly external-link check | harden-runner supports only audit on GitHub's macOS and Windows runners; the release jobs haven't run yet; external links can go anywhere. |
353
364
  | reuse | `reuse lint` not run (the files still comply: `REUSE.toml` covers them) | No recent release ships a wheel for Python 3.11+, so installing it builds from source with an unpinned `poetry-core`. |
354
- | zizmor | `self-repository` on the CI job that runs the repository's root action | zizmor wants `$/`, and actionlint rejects a bare `$/` (it has no path), so that one line uses `./`. |
365
+ | zizmor | `self-repository` (`.github/zizmor.yml`) | Scorecard reads the `$/` form it wants as an unpinned third-party action, so local actions stay `./`. |
355
366
 
356
367
  To apply the rulesets in `.github/rulesets/` (repo admin):
357
368
 
@@ -372,10 +383,8 @@ Done:
372
383
  (Docs), pytest on Linux, macOS and Windows × Python 3.11–3.14 (Test), and the sdist and wheel,
373
384
  `twine check` and a wheel smoke test (Build).
374
385
  - **security.yml** runs on pushes, PRs and weekly: CodeQL (Python and Actions), zizmor (pedantic),
375
- actionlint (kjanat's fork, which reads the `$/` self-repository syntax the workflows use),
376
- pip-audit on the lock, and dependency review on PRs.
377
- - **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly. Its pin check misreads the `$/`
378
- references as unpinned actions, so it flags them.
386
+ actionlint, pip-audit on the lock, and dependency review on PRs.
387
+ - **scorecard.yml** runs OpenSSF Scorecard on `main` and weekly.
379
388
  - **release.yml** runs on `v*` tags: CI, then **build.yml** (a reusable workflow) builds the dists,
380
389
  checks the tag matches the version, and attests their provenance (SLSA v1 Build Level 3, as the
381
390
  build and attestation run in a reusable workflow), then PyPI (trusted publishing), then a GitHub
@@ -403,7 +412,7 @@ Done:
403
412
  badges this project's CI keeps true.
404
413
  - **Baselines**, a **smarter `--fix`** (containers, same-module return types), **notebooks**, and
405
414
  JSON with comments and trailing commas wherever constricter reads JSON.
406
- - **Fuzzing**, a manual **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
415
+ - **Fuzzing**, a **corpus run** (`tests/corpus.py`), an **adoption guide**, **`--fix` for
407
416
  notebooks**, and **SLSA Build Level 3 provenance** (GitHub's artifact attestations, from a
408
417
  reusable build workflow) on each release.
409
418
  - **Python 3.11+**, the oldest version still maintained after 3.10's end of life in October 2026.
@@ -414,8 +423,107 @@ Done:
414
423
  Test jobs and Scorecard included.
415
424
  - **Stdin**, **`gitlab`, `junit` and `rdjson` output**, **safe and `--unsafe-fixes`**, **per-file
416
425
  ignores**, **`--exit-zero`** and **`--output-file`**, **tox and nox** snippets, **PyPy 3.11 and
417
- free-threaded 3.14** in CI, and a manual **`--fix` corpus run** (`tests/corpus_fix.py`).
426
+ free-threaded 3.14** in CI, and a **`--fix` corpus run** (`tests/corpus_fix.py`).
418
427
  - **Cross-module `--fix`** in the CLI (the flake8 and pylint plugins see one file at a time).
428
+ - **CI's Corpus job** runs `tests/corpus.py` and `tests/corpus_fix.py` against the runner's Python
429
+ standard library on every push and PR.
430
+ - **`project.Index`** sorts modules by name so `calls` finds a module/submodule import by prefix
431
+ (`bisect`) instead of scanning every indexed module.
432
+ - **Enum bases and factory calls resolve by import origin** (`annotations.factories`,
433
+ `checker._is_enum`'s `imported_from` check), so an aliased or re-exported `Enum`/`NamedTuple`/...
434
+ is still recognised; the bare-name lists remain a fallback for one imported some other way.
435
+ - **A general path-exclusion mechanism**: `--exclude` globs also match a directory name during a
436
+ directory walk, folding the built-in skip list (`__pycache__`, `node_modules`, hidden dirs, ...)
437
+ into the same mechanism instead of a separate hardcoded check.
438
+ - **`_FileRun` split** into `_CheckRun`, `_BaselineRun` and `_CoverageRun` (one per mode-group,
439
+ instead of one struct with fields only some modes populate), and `_check_path` and
440
+ `_baseline_path` share a `_read_checked` read-and-report-errors wrapper.
441
+ - **LVA007: duplicate/redundant typing.** A name annotated again with the type it already has, in
442
+ the same straight-line block; a warning at every level, an error at `suffocate`.
443
+ - **`--fix` infers more**: `not x` (always a real `bool`, unlike a comparison, which sqlalchemy's
444
+ own corpus data proves isn't safe to assume — it overloads `<`/`==` to build query expressions); a
445
+ table of builtins with a fixed, un-overloadable return type (`len`→`int`,
446
+ `isinstance`/`hasattr`/`callable`/`issubclass`→`bool`, `str`/`repr`/`chr`→`str`, `int`/`float`
447
+ →themselves, ...); and copying an already-known local's type for a plain `x = y` (from its own
448
+ annotation, an earlier fix in the same scope, or an annotated parameter) — a guessed source's type
449
+ copies too, marked just as guessed, so a chain of copies still converges in one `--fix` pass
450
+ instead of needing a second. Verified on the eighteen-codebase corpus below: fixed rose from
451
+ 12,104 to 13,608 of the same 94,523 found (12.8% → 14.4%), no crashes, nothing left to fix on a
452
+ second pass anywhere, and `requests`' own test suite (not just its compile check) passed
453
+ identically — 617 passed, 15 skipped, 1 xfailed — before and after `--fix --unsafe-fixes` on its
454
+ source.
455
+ - **`--fix` infers subscripts and attributes** of an already-typed local: `container[key]` (its
456
+ element type from a `list`, `dict` or homogeneous `tuple[T, ...]`; the same `list`/`str`/`bytes`
457
+ type back for a slice; nothing for a fixed-length heterogeneous tuple, since the element varies
458
+ with the index) and `obj.attr` (a class-level annotated attribute of a class defined in the same
459
+ module — not one only assigned in `__init__`, which would need dataflow across methods to see).
460
+ Both build on `_Scope.types`, so a guessed source's uncertainty carries through automatically, the
461
+ same as a plain copy. Re-verified on the corpus: no crashes, still converges in one `--fix` pass,
462
+ `requests`' test suite still passes identically, and fixed rose further (e.g. standard library
463
+ 4,431 → 4,450, mypy 1,939 → 1,996, django 2,408 → 2,414, sqlalchemy 830 → 848, pydantic 434 →
464
+ 447).
465
+ - **A permanent, pinned corpus.** By hand (`tests/corpus.py`/`corpus_fix.py`,
466
+ `--unsafe-fixes --all-scopes`), against eighteen real packages, to choose it — OpenCV's Python
467
+ bindings (the original idea) turned out to be a poor fit, since they're mostly thin C bindings,
468
+ not hand-annotated Python:
469
+
470
+ | Codebase | Version | Files | Left un-typed | Fixed | LVA006 @5 | LVA007 |
471
+ | ---------------- | ------- | ----: | ------------: | ---------: | --------: | -----: |
472
+ | standard library | 3.11.16 | 732 | 24,173 | 3,828 | 0 | 0 |
473
+ | mypy | 2.3.1 | 195 | 9,853 | 1,777 | 0 | 0 |
474
+ | pylint | 4.0.8 | 178 | 3,609 | 486 | 0 | 0 |
475
+ | libcst | 1.9.0 | 297 | 3,407 | 1,042 | 201 | 0 |
476
+ | uiautomator2 | 3.7.0 | 32 | 888 | 86 | 0 | 0 |
477
+ | requests | 2.34.2 | 19 | 372 | 45 | 0 | 0 |
478
+ | flask | 3.1.3 | 24 | 410 | 29 | 0 | 0 |
479
+ | click | 8.5.0 | 17 | 567 | 86 | 0 | 0 |
480
+ | praw | 8.0.3 | 89 | 602 | 114 | 0 | 0 |
481
+ | boto3 | 1.43.99 | 39 | 483 | 93 | 0 | 0 |
482
+ | django | 6.1.1 | 907 | 15,648 | 2,248 | 0 | 0 |
483
+ | pydantic | 2.13.5 | 105 | 2,998 | 367 | 0 | 0 |
484
+ | attrs | 26.1.0 | 13 | 336 | 60 | 0 | 0 |
485
+ | aiohttp | 3.14.3 | 55 | 1,590 | 217 | 0 | 0 |
486
+ | paramiko | 5.0.0 | 41 | 1,272 | 241 | 0 | 0 |
487
+ | scrapy | 2.19.0 | 179 | 1,814 | 389 | 1 | 0 |
488
+ | sqlalchemy | 2.0.54 | 257 | 12,556 | 643 | 7 | 0 |
489
+ | rich | 15.0.0 | 100 | 1,841 | 353 | 0 | 0 |
490
+ | **Total** | | | **82,419** | **12,104** | | |
491
+
492
+ No crashes on any of them, and `--unsafe-fixes` left nothing broken or nothing unfixed on a second
493
+ pass, on any of them; `--nesting`'s default (5) never fires on fourteen of the eighteen, and
494
+ LVA007 found nothing on any of them, at any nesting — strong evidence it isn't noisy
495
+ (`--nesting`'s default is still worth revisiting some day: `libcst`, deeply nested CST types, is
496
+ by far the most affected, `sqlalchemy` and `scrapy` are the only other two to hit it at all at the
497
+ default, and 3 already reported 124 times on sqlalchemy, 45 on mypy). Chose four to run
498
+ permanently in CI (see the Corpus job): **`requests`** (tiny, so a fast check; extremely stable
499
+ and widely known; the canonical "makes external API calls" library; unlike the dev tools,
500
+ representative of typical, lightly-typed real-world code), **`flask`** and **`django`** (two web
501
+ frameworks, more decorator/class-heavy than `requests`; `django` pinned to the 5.2 LTS, since 6.x
502
+ needs Python 3.12+) and **`sqlalchemy`** (an ORM, and the corpus most likely to exercise
503
+ `LVA006`). Each runs as its own Corpus (`package`) matrix job, from a new `corpus` dependency
504
+ group.
505
+
506
+ Also validated `requests` specifically: cloned `v2.34.2` (its source checkout, with its own test
507
+ suite, not just the installed wheel), ran `--fix --unsafe-fixes --all-scopes` on `src/requests/`,
508
+ and ran its own test suite before and after. Identical both times: 617 passed, 15 skipped, 1
509
+ xfailed — the inferred types changed nothing about its runtime behaviour. One found a real, if
510
+ inert, mistake in the "guessed" heuristic: `internetSettings = winreg.OpenKey(...)` (Windows-only,
511
+ guarded by `sys.platform == "win32"`, so untested by this run) got annotated
512
+ `internetSettings: winreg.OpenKey = ...` — `winreg.OpenKey` is a _function_, not a class, but its
513
+ PascalCase name (a Windows API convention, not Python's) fools the capitalised-name "constructs a
514
+ class" heuristic (`annotations._constructs`).
515
+
516
+ - **`--fix` infers `self.attr` and `str`/`bytes` method calls.** `classes` (used for `obj.attr`) now
517
+ also collects `self.x: T = ...` from anywhere in a method's body, not just class-level
518
+ annotations; a method whose first parameter is literally named `self` has it typed as its class
519
+ (`_owners`, by the method's `id()`, not by name — a same-named method on an unrelated class isn't
520
+ confused with it), so `self.attr` resolves the same way `obj.attr` already did. Separately, a
521
+ fixed table of `str`/`bytes` methods whose return type doesn't depend on their arguments (`strip`,
522
+ `split`, `startswith`, `encode`, `decode`, ...) makes `some_str.strip()` on an already-typed local
523
+ as certain as a builtin function call — not a guess, unlike an arbitrary method call, which stays
524
+ guessed. Re-verified on the corpus: no crashes, still converges in one `--fix` pass on all six
525
+ re-checked (standard library, mypy, `requests`, `flask`, `django`, `sqlalchemy`), and fixed rose
526
+ further still (e.g. mypy 1,996 → 2,074, sqlalchemy 848 → 1,039, `requests` 52 → 57).
419
527
 
420
528
  Next:
421
529
 
@@ -423,6 +531,48 @@ Next:
423
531
  CPython 3.10 one).
424
532
  2. Revisit the [disabled rules](#disabled-rules) as tools change (last checked 2026-09-22: COM812,
425
533
  one-line DOC201/DOC402 and `max-args` came back on; the rest can't go yet).
534
+ 3. **Raise `--fix`'s auto-fix rate further.** Attributes, subscripts, `self.attr` and `str`/`bytes`
535
+ method calls are done (see Done, above); what's left: a method call on an arbitrary class's
536
+ instance (`x = obj.method()`, unlike `str`/`bytes` isn't resolvable without following the
537
+ method's own return annotation, the same as `classes` already does for a field, extended to
538
+ methods) and `list`/`dict` methods whose return is the receiver's own element type (`list.pop`,
539
+ `dict.pop`, ...), which need the same element-type parsing `_subscripted` already does, just
540
+ reached from a method call instead of a subscript. `BinOp` (`a + b`, ~5% of the original sample)
541
+ would need operand types plus knowing the operator isn't overloaded to something else — riskier,
542
+ lower value, likely skip.
543
+ 4. **LVA008: a type that could narrow.** A warning at `constrict`, an error at `suffocate`: a
544
+ declared type that every value assigned to the name (across its lifetime, not just its first
545
+ binding) is consistent with a strictly narrower one, e.g. a `str` only ever assigned `"0"` or
546
+ `"1"` (could be `bool`), or a `float` only ever incremented, never divided (could be `int`).
547
+ Needs whole-variable value-flow analysis across every reassignment in a scope, not just a first
548
+ binding, which is a different (and much bigger) kind of check than `LVA001`–`LVA007`; wants its
549
+ own design pass (what counts as "consistent with" a type, how far to follow calls and mutation,
550
+ false-positive risk on a codebase this analysis can't fully see) before it's worth building.
551
+ 5. **LVA009: a reassignment that changes the type.** An error: `count: int = 0` later reassigned
552
+ `count = "done"` in the same scope. A real, common bug class (mypy already treats this as a type
553
+ error by default), but needs the same value-flow machinery as `LVA008` (infer every
554
+ reassignment's type with `annotations.inferred`, not just the first binding's), plus real
555
+ subtyping awareness to avoid noise `LVA008` doesn't have to worry about: a declared `X | None`
556
+ later assigned a plain `X` is normal Optional narrowing, not a bug, so the check needs to know
557
+ that's consistent rather than comparing annotation text like `LVA007` does; a name reused for
558
+ genuinely unrelated purposes (a sentinel, a generic helper handling more than one type by design)
559
+ is a real, if rarer, source of false positives to design around. Depends on `LVA008`'s design
560
+ work (same value-flow pass could likely serve both checks).
561
+ 6. **LVA010: a declared union only one branch ever uses.** A warning: `x: int | str = 0` where every
562
+ value ever assigned across `x`'s lifetime is consistent with only `int`, never `str` — the union
563
+ is wider than the code actually exercises, and could narrow to `int`. The complement of `LVA008`
564
+ (inferring a narrower type from values with no declared type to compare against) and `LVA009` (a
565
+ reassignment that breaks a declared type, not just widens what's already declared as a union);
566
+ shares the same value-flow machinery and design questions as both.
567
+ 7. **Show how each fix was inferred.** `annotations.inferred` now decides a fix through one of
568
+ several mechanisms (a literal, a container of literals, a same/cross-module function's declared
569
+ return type, a fixed-return builtin, a class it constructs, a copy of an already-typed local, a
570
+ subscript or an attribute of one), but `Offence.fix` keeps only the resulting annotation text,
571
+ not which one produced it. Surfacing that (`--diff`, or a verbose/explain mode) would help trust
572
+ and debug a fix, especially a guessed one. Needs every inference helper (`_scalar`, `_container`,
573
+ `_called`, `_subscripted`, the copy and attribute checks in `inferred` itself) to report a reason
574
+ alongside the type, not just the type — a real (if mechanical) change through most of
575
+ `annotations.py`'s inference path, not a one-line addition.
426
576
 
427
577
  After the first release (these need it on PyPI, or a published tag):
428
578