_Binder.visit_Global marked the name ambiguous in the module, so `global execute` in a
function hid `from subprocess import run as execute` and HX006 (missing timeout) never
fired on `execute(cmd)`; nonlocal did the same for an enclosing function's import. A
declaration selects the outer binding, it does not rebind it: declared names now resolve
in the scope they point at, and only an assignment (or import) through the declaration
rebinds that outer name.
Follow-up to the per-scope handle tracking (Adolanium §4): a repo-wide sweep
of the old vs new engine showed one lost hit,
tui_gateway/host_supervisor.py `_wait_for_exit(self, proc:
subprocess.Popen[str])` -> `proc.wait()`. The old module-wide map flagged it
only because another function happened to bind `proc` to a Popen; scoping
correctly treats a parameter as unknown, which also drops the case where the
type says exactly what it is.
A parameter annotated `subprocess.Popen[...]` (sync) or
`asyncio.subprocess.Process` (async) now starts as a handle of that kind;
unannotated parameters still clear the name.
unsupportedpastels M4 (review 5408300628 §2.3), each fixture a new file that
exited 1 at 96bb97a:
- HX001 matched any name starting with ".hermes", so
`Path.home() / ".hermes-profile-exports"` (hermes_cli/profiles.py) was a
"hardcoded Hermes home". It now matches only the exact `.hermes` path
component (`.hermes`, `.hermes/x`, `~/.hermes/...`).
- HX003 matched every string containing `ps aux` / `pgrep -f`, including
docstrings that explain why not to use them. Expr-statement string
constants (docstrings, bare string statements) are prose and skipped; real
command strings, assignments and f-string parts stay flagged.
- HX012 flagged `threading.Thread(target=copy_context().run, ...)`, the
context-preserving form the plan lists as a fix (agent/auxiliary_client.py
uses it). A target of `<ctx>.run` is accepted when ctx is
`contextvars.copy_context()` itself or a name this same scope binds only to
`copy_context()` (keyword or positional target). A ctx from another scope,
a rebound name, or any other target stays flagged.
andrexibiza's F9 (changed form, inline on py_rules.py:250): definition
traversal kept a def's decorators and defaults but dropped its parameter and
return annotations, which run at def time on 3.11-3.13 (the project floor is
3.11) unless the module has `from __future__ import annotations`; measured on
3.11 here, `def f(x: os.getenv("S"))` freezes the launch value in
__annotations__. Conversely, with the future import, a module/class
`x: os.getenv("PATH")` annotation is never evaluated but the whole AnnAssign
was walked, so it was charged as a capture.
Def annotations now count as import-time exprs unless annotations are
postponed; an AnnAssign contributes its target and value always and its
annotation only when not postponed. A nested def's annotations stay out
(they run when the outer function does).
Adolanium (comment 5983756525 §4) and JoaoMarcos44/andrexibiza: an annotated
`results: list = await asyncio.gather(..., return_exceptions=True)` was not
recognised as a gather result (only ast.Assign was), so a plain `: list`
annotation hid the exact pattern HX009 exists for. Conversely, unpacking a
zip/enumerate tainted every loop variable, so `isinstance(payload, Exception)`
over `zip(payloads, results)` or the `i` of `enumerate(results)` was flagged.
AnnAssign values now propagate results, and loop/comprehension targets are
projected positionally: each element of a zip follows its own argument, an
enumerate taints only its item. Non-tuple targets keep the old whole-target
behaviour.
JoaoMarcos44 (inline py_rules.py:442, review 5408210901; reproduced by
andrexibiza): after `proc.kill()` only an expression or plain-assignment
`proc.wait()` was recognised as reaped, so `return proc.wait()` and
`rc: int = proc.wait()` were flagged while the identical `rc = proc.wait()`
passed, and refactoring an accepted assignment into a return gained a blocker.
Return and AnnAssign values now count as the adjacent wait. The negative
controls stay flagged: no preceding kill, a different killed receiver,
communicate() after kill, and asyncio Process.wait() after kill; an explicit
finite timeout is still accepted. The assignment->return refactor is covered
through the real comparison path.
Adolanium (comment 5983756525 §4), corroborated by andrexibiza (F13 receiver
class) and helix4u: _process_handles() keyed handles by variable spelling for
the whole module, so an async `proc` in one function made a correctly bounded
sync `proc.communicate(timeout=1)` in another function fail HX006 (and a
`def stop(proc)` parameter inherited whatever `proc` meant elsewhere).
A plain-name handle now belongs to the scope that binds it: the innermost
scope binding the name decides (class bodies are invisible to methods,
closures see their enclosing function), and within it the latest binding
before the call, so reassignment (Popen -> asyncio process -> None)
re-classifies the receiver. Parameters and other bindings clear it. Attribute
handles (`self._proc`) stay shared by the methods of their class, keyed by
that class rather than by spelling across the module.
andrexibiza's F19 (review 5407834086, inline): _capture_lines() returned after
the first eager capture it met. The reverse stack reaches an existing dict
entry first, so inserting `"new": os.getenv("HOME"),` above
`"old": os.getenv("PATH"),` produced no new HX005 occurrence and the
comparison could not charge it.
Every independent capture is now emitted (dict entries, list items, defaults);
a capture nested inside another (`expanduser(getenv(...))`) is the same
occurrence and is not entered, and per-line duplicates still collapse in the
measurer. Deferred bodies stay excluded. Tested through the real comparison
path: added dict/list/default captures block, a lambda entry and unchanged
debt pass, a separate assignment blocks.
andrexibiza's F12 (changed form, review 5407834086): canonical_tree() pooled
every parameter, assignment and def in the module into one "rebound" set, so
`from subprocess import run as execute` stopped resolving everywhere as soon
as an unrelated function had a parameter or local named `execute`, and
`launch(cmd): return execute(cmd)` lost its HX006.
Bindings are now recorded per scope (module, class, function/lambda,
comprehension) and each read resolves through the scope chain the way Python
does: the innermost scope that binds the name decides, a scope that binds it
other than by one import makes it a plain local there, class bodies are not
visible to nested functions. Genuine shadowing in the consuming function (or
at module level) still stops resolution.
SIGKILL bounds a sync Popen.wait() that directly follows kill(), so it no
longer needs a timeout. communicate() after kill() and asyncio's
Process.wait() after kill() stay flagged: both read/close the pipes, and a
grandchild holding them blocks forever (measured: > 20 s with a background
`sleep`; the sync wait() returned in 0.00 s). The HX006 fix text now names
the reap pattern.
Fourth review round (F11-F18, N1-N4, kvnloo, Adolanium):
- Hits are old only where the base occurrence survives: a line the comment- and
whitespace-blind line diff keeps (blame semantics), or anything inside a unit whose
body is unchanged. An identical violation moved past unrelated code is new; removing
an allow comment or re-indenting is not. Hit fingerprints exclude comments (N4).
- Inline allows cover their own line; only a comment-only line covers the next (N3).
- Rules read a copy of the AST with import aliases resolved, so `sp.run`,
`from subprocess import run as execute`, aliased threading/asyncio/load_config are
seen (F12); deadline helpers must resolve to the real asyncio API, and
communicate()/wait() are judged only on receivers holding a spawned process (F13);
infinite deadlines are unbounded (F14).
- HERMES_* writes introduce a name too (N1); bool(os.environ[...]) is HX010 (N2);
HX009 needs the isinstance subject to come from gather(return_exceptions=True).
- Recursive self-references are normalised out of body hashes, so a recursive rename
keeps its cap, py and ts (F15). Decorators belong to their function's scope and
classes are matchable units, so their hits follow proven moves (F16).
- An over-cap file may not grow (F17, Teknium: strict improvement-only).
- A file the checker cannot parse or measure (ast/ruff/TS parse error) fails.
- scripts/check: --staged always lints an index snapshot (ignored files are invisible
to git status, not to guards) (F18); create the --prefix dir for Git for Windows
(F11); --only builds just the selected jobs, so it needs no ruff for non-ruff checks.
- CODEOWNERS covers scripts/check + scripts/code_health; frontend-only PRs skip the
Python-only lint jobs again and run only the ratchet.
Final review on #132646 (R1-R4), each reproduced on d03b089 first:
- Git blobs decode as utf-8-sig like working-tree reads, so a BOM no
longer makes one side unparseable: a new BOM file's debt is measured
(the hook used to let it through) and existing BOM debt is not new (R1).
- One walker, _eager(), yields what evaluating a node runs now: a
lambda/def contributes only its defaults and decorators, a generator
only its first iterable. HX005, HX006 and HX007 all use it, so a
coroutine defined inside `asyncio.timeout()` is not treated as bounded
and a returned sync loader is not a sync call in the coroutine (R2).
- `wait_for(fut=..., timeout=...)` is recognized like the positional
form (R3).
- HX005 also checks lambda defaults, `if`/`while` tests, `with` context
expressions, match guards and walrus bindings; a generator's body stays
deferred (R4).
Second-round review on #132646 (F1-F10), each reproduced on 2531fb2 first:
- Moves are matched one-to-one: unchanged units are reserved first, then
same-file body, then cross-file body only when the origin's name is gone,
then same name. A copy of unchanged debt is new debt; a function moved
over a same-name stub keeps its own cap (F1, F3).
- Every base hit is owned by exactly one head scope, so a file rename no
longer counts each old hit twice (F2).
- Ruff runs when either side has Python, so a delete-only .py diff with a
TS edit no longer crashes on a missing ruff result (F4).
- Deadlines must be finite: timeout=None, **{} and asyncio.timeout(None)
are no deadline; awaited calls inside `async with asyncio.timeout(x)`
are bounded (F5, F8).
- HX005 walks only what runs at import: the import-time branch of a
`__name__` guard (either operator), loops, match, try*, decorators,
class bases; a lambda value is deferred (F9).
- `health: allow` counts only in real comment tokens (Python tokenize, TS
comment trivia), never in string literals (F7).
- TS function lines are owned by the innermost function, so siblings on
one line give it up once (F10).
- The pre-commit hook runs `scripts/check --staged`: health compares the
index tree, and when the working tree differs from the index the other
checkers run on a checkout-index copy with a copied index, so commits are
judged on the staged bytes and unstaged work is never touched (F6).
Independent review of #132646 found five defects, each reproduced and fixed:
- Pattern hits from any vanished scope went into a shared (rule, text) pool, so deleting one
`except Exception:` paid for an unrelated new one elsewhere, and class scopes (never units)
always looked vanished. Hits now transfer only along a unit matched by body hash.
- A unit's prior is now looked up by body hash before qualname, so a TS anonymous callback
whose source-order ordinal shifted keeps its cap.
- The TS body hash included the function name; it is now parameters + body, so a rename keeps
its cap (as in Python).
- HX006 flagged `await asyncio.wait_for(proc.communicate(), timeout=...)`; calls passed to
wait_for/timeout are bounded. The fix text now names the asyncio form.
- scripts/check printed output only on failure, hiding advisories and comment allows; the
health report is now always shown.
Docs now say growth of a file already over 2,000 lines is a warning for now.
scripts/code_health measures the files a change touches on both sides
(base revision and head) and fails when:
- a function or file over target grows past its own value on the base
(CC 20, 300 own lines, nesting 6, file 2,000 lines);
- a new function or file is over target;
- a pattern rule gains a fingerprint (rule + enclosing function + line
text), so fixing one violation never buys another.
Code that moves keeps its cap: functions match by name-independent body
hash, pattern hits by (rule, line text) once their function has left the
old place. Nested functions are their own units, so a closure edit never
counts against the hook or function around it. Python CC is ruff's C901
minus the direct nested defs; TypeScript is parsed with the lockfile's
typescript (no type check).
Rules: swallowed exceptions (S110, BLE001, E722), correctness (F821/F823/
F811/F841, RUF006), hangs (ASYNC230/240, S113, subprocess/urlopen without
timeout, sync config I/O in async def, get_event_loop, gather results
checked against Exception), profile scope (hardcoded ~/.hermes, a new
HERMES_* env var, UnscopedSecretError -> os.getenv, import-time capture,
raw threading.Thread, P05/P06 from profile_scope_patterns.json), argv
process identity, bool() of an env string, and if/elif ladders.
Growth of a file already over 2,000 lines is advisory: replaying 300
merged PRs, a blocking file-size ratchet would have stopped 46 of them
before the 119 over-target files are split. Everything else blocks; the
same replay finds 18 PRs that would have needed a change, every finding
a real hit for its rule.
No baseline file: base is measured on the fly, so an improvement that
lands on main is the new cap at once and parallel PRs never conflict on
ratchet state. `# health: allow <RULE> -- <why>` is the reviewed escape.