Files
hermes-agent/scripts/code_health/py_ast.py
Teknium 74f77ab9b0 Code health blocks new wall-clock sleeps in tests (HX013), adds a shared wait_until helper (#136644)
* feat(code-health): HX013 blocks new wall-clock sleeps in tests

A fixed time.sleep()/asyncio.sleep() is a bet that the code under test finishes
in time; on a loaded runner it loses and the test flakes. Main has ~1,470 of
them in tests/ and nothing stopped new ones (HX rules exclude tests).

HX013 runs only on test paths (Rule.include) and flags time/asyncio/anyio/trio
sleeps with a delay that is not a literal 0, plus a fresh threading.Event().wait(x).
Existing sleeps are grandfathered by the ratchet's base-relative compare; moved
code keeps its debt; '# health: allow HX013 -- <why>' waives a deliberate delay.

The fix text points at tests/waiting.py (wait_until / async_wait_until), a shared
poll-with-deadline helper replacing the ~60 private copies across the suite.

* fix(code-health): HX013 passes the interval of a poll under a deadline

The frozen replay (PRs merged 2026-10-08..09) hit three private poll helpers
(while not done and monotonic() < end: sleep(x)): that is the compliant shape the
fix text recommends, not a fixed delay. A while loop that reads a clock and can
end on a non-clock condition now exempts its sleep; a loop that only waits out
the clock, or polls with no deadline, still counts. tests/waiting.py needs no
waiver any more; tests/AGENTS.md names the rule and the helper.

* fix(code-health): HX013 passes a round-bounded poll loop

The second frozen window (2026-10-06..07) hit 'for _ in range(200): await
asyncio.sleep(0.01); if idle: return', a poll bounded by its round count: load
stretches it but cannot make it return early. A for-range loop whose body can
leave on a non-clock condition now exempts its sleep; a range loop of fixed
delays, or a poll over arbitrary items, still counts.

* fix(code-health): HX013 exempts only a real poll's own interval

Review fixes for the HX013 poll-loop exemption, plus a file split:

- Split: the HX013 checker moves to scripts/code_health/py_rules_sleep.py,
  and the AST helpers it shares with py_rules (Ctx, _FUNCS, _dotted,
  _call_name, _deferred_parts, _eager) move to a leaf module py_ast.py.
  py_rules.py drops from 1,034 to 877 lines (928 on main); CHECKERS still
  registers HX013.
- Clock-only conditions: only a test operand that reads something other
  than the clock counts as a condition. Combined clock checks
  (`monotonic() < a and monotonic() < b`), `not`/constant operands, and
  locals assigned only from clock arithmetic (`now < end`) no longer
  exempt the sleep. `and`/`or`/`not` follow their logic, so `while not
  done() or monotonic() < end` still waits out the clock.
- Loop ownership: a `break` ends only the loop whose body holds it (a
  nested loop's break does not count for the outer loop; one in the
  nested loop's `else:` does). Nested defs, lambdas and classes are
  skipped. A sleep in a loop's `else:`, `while` test or `for` iterable is
  not that loop's interval.
- Retry polls: a `try` body's return/break counts as conditional when the
  body calls something that can raise (`try: return probe() except
  OSError: sleep`). A body that only sleeps still counts as a fixed delay.

On origin/main this flags 2 more grandfathered sleeps and exempts 12 retry
polls the old rule missed, so the count goes from 923 to 913.
2026-10-11 11:27:22 -07:00

68 lines
2.2 KiB
Python

"""AST helpers shared by the HX checkers (``py_rules`` and its ``py_rules_*`` siblings).
A leaf module: it imports nothing from the engine, so every checker module can use it without a
cycle.
"""
from __future__ import annotations
import ast
from collections.abc import Iterator
from dataclasses import dataclass, field
_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
@dataclass
class Ctx:
known_env: set[str] = field(default_factory=set)
def _dotted(node: ast.AST) -> str:
"""``os.environ.get`` for an Attribute chain, ``""`` for anything else."""
parts: list[str] = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
if isinstance(node, ast.Call):
inner = _dotted(node.func)
return ".".join([f"{inner}()", *reversed(parts)]) if inner else ""
return ""
def _call_name(call: ast.Call) -> str:
return _dotted(call.func)
def _deferred_parts(node: ast.AST) -> list[ast.AST] | None:
"""For a node whose evaluation defers part of itself, the parts that run NOW; else None.
A lambda/def runs only its defaults and decorators when evaluated; a generator expression
runs only its first iterable (the element, conditions and later loops run on consumption).
Comprehensions and class bodies run immediately, so they are not deferred.
"""
if isinstance(node, ast.Lambda):
return [d for d in (*node.args.defaults, *node.args.kw_defaults) if d is not None]
if isinstance(node, _FUNCS):
defaults = [d for d in (*node.args.defaults, *node.args.kw_defaults) if d is not None]
return [*node.decorator_list, *defaults]
if isinstance(node, ast.GeneratorExp):
return [node.generators[0].iter]
return None
def _eager(node: ast.AST) -> Iterator[ast.AST]:
"""Every node evaluated when ``node`` is evaluated (or a statement executes), root included."""
stack = [node]
while stack:
current = stack.pop()
parts = _deferred_parts(current)
if parts is not None:
stack.extend(parts)
continue
yield current
stack.extend(ast.iter_child_nodes(current))