* 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.
68 lines
2.2 KiB
Python
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))
|