Skip to content

Repair what the ratchet counts with NRA plans; type imports by when they run - #3170

Merged
trissim merged 12 commits into
mainfrom
ratchet/repair
Oct 25, 2026
Merged

trissim merged 12 commits into
mainfrom
ratchet/repair

Conversation

@trissim

@trissim trissim commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

The ratchet says which line got worse; this makes it also say how to fix it, as a Nominal Refactor Advisor plan you can preview or apply.

What it adds

tools/repairs.py

  • Repair (a DeclaredFamily) declares the located measure it removes (repairs = FunctionLocalImport).
  • It writes one NRA operation per occurrence it can remove, and keeps each other occurrence with the reason it stays.
  • NRA simulates the plan, so the diff shows before anything is written.
  • --apply goes through NRA's source-revision guards, then runs ruff's import sorter on the touched files. Black runs only on files Black already accepted.
python tools/repairs.py --root src --base origin/main --head HEAD   # the files whose count grew
python tools/repairs.py --root src --all --apply

ImportHoist moves a function-local import to its module's top. It refuses when a module-level import cycle runs through the import. It judges with every accepted move already in the load graph, because two local imports that name each other each pass alone and deadlock together. Other reasons it keeps an import: it is third-party, the module binds the name another way, the function rebinds the name, or it is not one plain line.

tools/module_layers.py: Import is now a family whose members say whether they run at module load:

  • LoadTimeImport
  • CallTimeImport
  • CheckerImport, for imports under if TYPE_CHECKING:

This also fixes a bug. The old visitor counted a function's decorators and default arguments as call-time, but those run when the def runs. Only the body is call-time now. Package.load_graph() gives the edges that load their target. Package.unreached uses the same ImportGraph.

tools/typing_names.py: TypingImports and TypingReference move out of debt_ratchet, so module_layers resolves if TYPE_CHECKING: by what the name is bound to, not by its spelling, without importing the ratchet (0.04 s vs 0.95 s). Moving them made the ratchet count their debt from zero, so it is removed instead of carried:

  • Untyped is the declared value for "bound to no typing declaration", replacing the None that dict.get returned.
  • raw_access_count asks self.value is Untyped instead of getattr(value, "__module__", None).
  • is_any lives on AnyReference in debt_ratchet, beside AnyName, the one measure that asks.
  • StringSubscript and AnyName lines are identical on all 1,014 tracked files, except the one moved Any site.

docs/layout/MODULE_LAYERS.md: regenerated on current main. The committed copy had drifted 13 units, because --check (debt-ratchet.yml:64) checks client imports and never compares the doc with the generator. The doc depends on the stack clients' branch heads, so a freshness gate would fail PRs whenever Toad pushes. The honest options are to regenerate it, or to stop committing a generated copy. This PR regenerates it; deleting it is a separate call.

Dependency: NRA joins the dev extra as a direct git reference. CI installs with pip, which ignores uv sources, and a bare name would resolve on PyPI to whoever registers it.

Evidence

  • tests/test_repairs.py: on a fixture package, the hoist moves the leaf import, keeps the import whose cycle runs through a module-level import, and the applied tree imports cleanly.
  • tests/test_module_layers_loads.py: covers module-level, decorator, default, TYPE_CHECKING, body and lambda-body imports. It fails on the old visitor (decorator and default came out call-time).
  • tests/test_module_layers_clients.py passes.
  • module_layers.py --check: 0 missing modules, 0 missing names. Generator output is unchanged by the Import family (diffed against main's generator).
  • On main, ImportHoist --all simulates 81 files changing. It leaves 160 imports for cycles, 42 that bind a name another way, 8 third-party, 4 that clash with another moved import, and 3 that are not one plain line. Move 225 function-local imports that nothing cycles back through to their module tops #3166 is that hoist applied.
  • ruff is clean on every changed .py file. The N815 on the NodeVisitor alias carries a reasoned noqa, following the repo's idiom: NodeVisitor dispatches on those names.

NRA: uv.lock takes 3fc2f084, which ships py.typed (OpenHCSDev/NominalRefactorAdvisor#21), so mypy checks repairs.py against NRA's types. The placement bug found on the way, OpenHCSDev/NominalRefactorAdvisor#20 (ensure_import stops at if TYPE_CHECKING:), is fixed in OpenHCSDev/NominalRefactorAdvisor#22. Until that lands, the ruff I001 step after apply repairs the order.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q8rVa3cPyXzpCKyR8XvM8W

trissim and others added 3 commits October 24, 2026 21:04
…hey run

tools/repairs.py: a Repair declares the located measure it removes and
writes Nominal Refactor Advisor operations for each occurrence it can
remove, tallying every one it keeps under the reason it stays. ImportHoist
repairs FunctionLocalImport: it moves a local import to its module's top
unless a module-level import cycle runs through it, judged with every
accepted move already added to the load graph.

tools/module_layers.py: Import is a family whose members say whether
they run at module load: LoadTimeImport, CallTimeImport, CheckerImport
(under TYPE_CHECKING). Only a function body is call-time; decorators,
defaults and annotations run where the def runs. Package.load_graph
gives the edges that load their target.

docs/layout/MODULE_LAYERS.md regenerated; it had drifted 13 units.
NRA joins the dev extra as a direct git reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trissim
trissim marked this pull request as ready for review October 25, 2026 01:05
Copilot AI balanced review requested due to automatic review settings October 25, 2026 01:05
@trissim
trissim enabled auto-merge October 25, 2026 01:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

debt-tools at 0f54df8, every finding:

grew OwnTypeTest:tools/typing_names.py +1

@trissim
trissim disabled auto-merge October 25, 2026 01:06
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

debt-src at 6951c22, every finding:

grew StringIdentity:tools/typing_names.py +1

trissim and others added 4 commits October 24, 2026 21:39
…wner

The debt-src and debt-tools growth on this branch, at its owners:

- ImportHoist.stays was a seven-arm if-chain returning sentences that left
  counted by string (StringSubscript at repairs.py:130). The reasons are now a
  HoistVerdict family in declaration order: each KeptImport member owns its
  holds() and why, Moves is the last member, and left is Counter[type[Kept]].
  Its locals become HoistedFile and LocalImport.
- target(path, qualname=None) named two targets with None as the switch:
  file_target and function_target.
- rebinds' match over node types (TypeSwitch, three arms) is a Rebinding
  NodeVisitor.
- module_layers wrapped a def's statements in ast.Expression to iterate them
  (a type error); visit_scope takes each scope's own body. Its TYPE_CHECKING
  test matched the spelling (StringIdentity +2) and any local name spelled so;
  it asks TypingReference.is_type_checking.
- TypingImports and TypingReference move from debt_ratchet to typing_names,
  so module_layers stays at 0.04 s to import instead of debt_ratchet's 0.95 s.
  Their Python 3.11 type_param guard is deleted: the package requires 3.14.

The full src simulation and its tallies are byte-identical before and after;
the generated MODULE_LAYERS.md is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8rVa3cPyXzpCKyR8XvM8W
Moving TypingImports into typing_names brought three sites into a new
file the ratchet counts from zero: None meaning "no typing declaration"
from dict.get, a getattr of "__module__" by literal name, and is_any's
typing.Any. Untyped is now the declared null value, raw_access_count asks
for it by identity, and AnyReference holds is_any in debt_ratchet next to
the measure that asks it. The unused typing_extensions import goes.

StringSubscript and AnyName lines are identical on every tracked file
except the moved Any site; MODULE_LAYERS.md is unchanged. uv.lock takes
NRA 3fc2f084, which ships py.typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8rVa3cPyXzpCKyR8XvM8W
trissim and others added 3 commits October 24, 2026 23:27
repairs.py imports debt_ratchet by its tools-directory name, as every
tool imports its siblings; the two tests spelling tools.debt_ratchet
loaded the same file as a second module, and the second registration
of each measure family member failed collection
(DuplicateRegistryKeyError on ReportExclude).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8rVa3cPyXzpCKyR8XvM8W
@trissim

trissim commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Sent back at 4b5e52e (commander review, recorded in Core).

debt-tools is red on real slop: OwnTypeTest:tools/typing_names.py +1.

  • class Untyped: has no members and is used as a bare marker. Readers check self.value is Untyped (typing_names.py:139) and is not Untyped (:110). That's IMPL-4, a half-finished family: the value can't say what it is, so every caller asks.
  • Fix: a small Resolution family whose base methods are raw_access_count() and declares(obj). Declared(value) returns 0 and value is obj; Untyped() returns 1 and False. Bindings store the members, and both identity checks are deleted.

Also:

What's good: ImportGraph replaces the copied reachability loop, the visitor counts def-time loads (decorators and defaults), and ImportHoist checks each move against the load graph with every other accepted move in it.

🤖 Generated with Claude Code

https://claude.ai/code/session_013ejYrMLnLWah8wCDDcb57V

trissim and others added 2 commits October 25, 2026 00:03
…d's identity

dc242bb traded main's getattr(value, "__module__") probe for
`self.value is Untyped`: a None check in a class costume, counted by
OwnTypeTest as a test of our own class (debt-tools: grew
OwnTypeTest:tools/typing_names.py +1). at_any_depth held the same probe as
`is not Untyped`. Both now ask TypingImports.declares, derived from the
typing_modules the class already declares; nothing tests Untyped any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8rVa3cPyXzpCKyR8XvM8W
@trissim
trissim merged commit 60f34a7 into main Oct 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants