Commit Graph

12 Commits

Author SHA1 Message Date
Teknium
16fba82ce3 fix(code-health): global/nonlocal no longer hide an import alias
_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.
2026-10-04 22:35:16 -07:00
Teknium
8a0e27697c fix(code-health): a parameter annotated as a process is an HX006 handle
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.
2026-10-04 21:29:39 -07:00
Teknium
66cd3efacc fix(code-health): fix texts for HX010, HX001 and BLE001 say what is accepted
unsupportedpastels M4 (review 5408300628 §2.3) and m7: the printed fixes would
break code or did not match the engine.

- HX010 led with `is_truthy_value`, which returns False for every real key,
  while most hits are "is it set" checks (`bool(os.getenv("..._API_KEY"))`).
  It now offers `is not None` / `!= ""` for presence and `is_truthy_value`
  only for boolean flags.
- HX001 only said `get_hermes_home()`, which would move a path that lives
  under the user's home on purpose (the profiles root); such a path takes an
  allow with a reason.
- BLE001: the ratchet runs ruff with --ignore-noqa, so `# noqa: BLE001`
  waives nothing, and a message-only `logger.warning(..., e)` still fails.
  Verified with pinned ruff 0.15.10 through the real comparison path
  (test_ble001_fix_text_matches_the_engine): logger.exception, any log call
  with exc_info=True, and re-raise pass; `% e` logging and `# noqa` block;
  `# health: allow BLE001 -- why` waives.

Text only: no rule's blocking flag or ENFORCEMENT changed.
2026-10-04 21:29:39 -07:00
Teknium
b1b1f0694c fix(code-health): HX001/HX003/HX012 stop rejecting correct code (M4)
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.
2026-10-04 21:29:39 -07:00
Teknium
6ca1395ee0 fix(code-health): NESTING counts else: + indented if as nesting
JoaoMarcos44 (inline py_structure.py:49, review 5408439923; reproduced by
andrexibiza): nesting_depth() treated any If that sits alone in an orelse as
an `elif`, but `else:\n    if b:` produces the same AST. Seven physical
`else: if` levels measured 1 (adding an inert `pass` made them measure 7), so
the gate depended on an irrelevant statement rather than on indentation.

A real `elif` If node starts at the `elif` keyword, i.e. at its `if`'s
column; an `if` nested under `else:` must be indented deeper (a compound
statement cannot follow `else:` on the same line). Only the former keeps the
depth. Seven physical levels now measure 7 with or without the inert pass
and block; a genuine seven-arm elif chain stays at 1 and passes.
2026-10-04 21:29:39 -07:00
Teknium
ae60f7ccc1 fix(code-health): recursive-rename normalization honours nested scopes (F15)
andrexibiza's F15 (changed form, inline on py_rules.py:250): body_hash()
disabled self-reference normalization whenever the textual dump contained
`arg('<name>'` or `Name('<name>', Store())` anywhere in the subtree, so an
unrelated nested `def identity(legacy)` made a pure recursive rename of
`legacy` lose its cap (FUNC_LINES 302 > target 300 as new code). Even with
the check removed, the string replace would also have blanked the nested
function's own `return legacy`, which refers to its parameter.

References are now resolved lexically: only bindings in the function's own
scope (params, assignments, nested def/class names, imports, global/nonlocal,
except/match captures) disable normalization, and a nested def/lambda/class/
comprehension that binds the name keeps its own reads. The matched Name /
Attribute nodes are blanked in place for the dump and restored. Controls: a
rename that leaves the old self-call is different code, an own-scope rebinding
is not a self-reference, a closure's call to its enclosing function is.
2026-10-04 21:29:39 -07:00
Teknium
eb872c9ebb fix(code-health): HX005 evaluates annotations when the interpreter does (F9)
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).
2026-10-04 21:29:39 -07:00
Teknium
9b6b48a881 fix(code-health): HX009 follows annotated results and projects loop positions
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.
2026-10-04 21:29:39 -07:00
Teknium
5b5b4ebf66 fix(code-health): HX006 post-kill exemption covers returned and annotated waits
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.
2026-10-04 21:29:39 -07:00
Teknium
592a02fd13 fix(code-health): track HX006 process handles per lexical scope
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.
2026-10-04 21:29:39 -07:00
Teknium
a52a7f829b fix(code-health): report every independent import-time capture (F19)
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.
2026-10-04 21:29:39 -07:00
Teknium
2cd844f114 fix(code-health): resolve import aliases per lexical scope (F12)
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.
2026-10-04 21:29:39 -07:00