* 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.
175 lines
10 KiB
Python
175 lines
10 KiB
Python
"""Targets, file scope and the rule table: the one place a rule's id, scope and fix text live.
|
|
|
|
A rule's ``fix`` is the text the agent reads at the moment it trips the rule, so it names the
|
|
repo-specific remedy rather than restating the rule.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from fnmatch import fnmatch
|
|
|
|
# Master switch, honoured everywhere the ratchet runs (CI, `scripts/check`, the git hooks). It is
|
|
# read from main, never from the branch under test, so a PR cannot relax its own check: CI reads
|
|
# the merge commit's first parent; local runs read `origin/main` (falling back to the merge-base).
|
|
# A flip reaches an open PR on its next CI run (a re-run reuses the old merge commit). "blocking": findings fail;
|
|
# "advisory": findings print, the run passes; "off": the ratchet is skipped. Flip it with a
|
|
# one-line commit to main; a single rule can instead be demoted with ``blocking=False`` below.
|
|
ENFORCEMENT = "blocking"
|
|
|
|
# Per-unit targets. A unit already over its target keeps its own current value as its cap
|
|
# (measured on the base revision) and may only go down; new units must meet the target.
|
|
TARGETS = {
|
|
"CC": 20, # cyclomatic complexity per function (ruff C901 numbers for Python)
|
|
"FUNC_LINES": 300,
|
|
"NESTING": 6, # nested control-flow blocks inside one function
|
|
"FILE_LINES": 2000,
|
|
}
|
|
|
|
METRIC_FIX = {
|
|
"CC": "split it: extract phases into a topical sibling (`<stem>_<topic>.py`), or replace a"
|
|
" branch ladder with a dict/table -> handler",
|
|
"FUNC_LINES": "extract phases into named helpers or a topical sibling module",
|
|
"NESTING": "return early, or extract the inner block into a helper",
|
|
"FILE_LINES": "offset the growth in the same PR: move an existing function or class into a"
|
|
" topical sibling `<stem>_<topic>.py` (moved code keeps its cap; see root AGENTS.md), or put"
|
|
" new tests in a new `test_<stem>_<topic>.py`; new behaviour never goes into a facade",
|
|
"MEASURE": "the file must parse and measure (a file that cannot be measured cannot be"
|
|
" judged); fix the syntax, or report the measurer bug",
|
|
}
|
|
|
|
PY_EXCLUDE = ("website/*", "*/node_modules/*", "node_modules/*")
|
|
TS_EXCLUDE = PY_EXCLUDE + ("*.d.ts", "*/dist/*", "*/build/*", "*/generated/*", "*.generated.ts")
|
|
TESTS = ("tests/*", "*/tests/*", "tests-js/*", "*/conftest.py")
|
|
# One-shot processes that serve a single profile (CI/release scripts, skill scripts run as a
|
|
# child with a scoped env, installers): the profile-scope rules don't apply to them.
|
|
SINGLE_PROFILE = TESTS + ("scripts/*", "skills/*", "optional-skills/*", "evals/*", "setup.py")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Rule:
|
|
id: str
|
|
title: str
|
|
fix: str
|
|
source: str # "ruff" | "ast" | "regex"
|
|
exclude: tuple[str, ...] = TESTS
|
|
blocking: bool = True
|
|
include: tuple[str, ...] = () # when set, the rule applies only to these paths
|
|
pattern_id: str = "" # regex rules: id in scripts/ci/profile_scope_patterns.json
|
|
|
|
|
|
# Heuristic rules print as warnings until a frozen replay (`python -m scripts.code_health.replay`)
|
|
# shows most of their hits on merged PRs are real problems; flip a rule to blocking with that
|
|
# evidence in the PR. Reviewers' samples put these between ~12% and ~60% precision.
|
|
WARN_UNTIL_REPLAYED = False
|
|
|
|
RULES: tuple[Rule, ...] = (
|
|
# Swallowed exceptions: the top statically-catchable bug cause in the fix sample.
|
|
Rule("S110", "try/except/pass", "log it or narrow the except; a silent pass hides the"
|
|
" failure the next bug report is about", "ruff"),
|
|
# ruff (pinned, --ignore-noqa) passes a handler that re-raises or logs the traceback; a
|
|
# message-only log such as `logger.warning("...: %s", e)` still counts as swallowed.
|
|
Rule("BLE001", "blind `except Exception`", "catch the exceptions this code can actually"
|
|
" raise; a deliberate catch-all boundary must re-raise or log the traceback"
|
|
" (`logger.exception(...)` or `exc_info=True`; `logger.warning(..., e)` is not enough)."
|
|
" `# noqa` is ignored here: waive with `# health: allow BLE001 -- <why>`", "ruff"),
|
|
Rule("E722", "bare `except:`", "catch a named exception (bare except also swallows"
|
|
" KeyboardInterrupt/SystemExit)", "ruff"),
|
|
# Correctness basics.
|
|
# tui_gateway method modules get the server's names injected (`bind_module(globals(), ...)`).
|
|
Rule("F821", "undefined name", "import or define it", "ruff", exclude=("tui_gateway/*",)),
|
|
Rule("F823", "local used before assignment", "assign before use or declare global",
|
|
"ruff", exclude=()),
|
|
Rule("F811", "redefinition of unused name", "remove the shadowed definition", "ruff",
|
|
exclude=()),
|
|
Rule("F841", "unused local variable", "delete it or use it", "ruff", exclude=()),
|
|
Rule("RUF006", "dangling asyncio task", "keep a reference to the task (it can be garbage"
|
|
" collected mid-run) and handle its exception", "ruff"),
|
|
# Hangs.
|
|
Rule("ASYNC230", "blocking open() in async def", "`await asyncio.to_thread(...)`", "ruff"),
|
|
Rule("ASYNC240", "blocking path call in async def", "`await asyncio.to_thread(...)`",
|
|
"ruff"),
|
|
Rule("S113", "HTTP request without timeout", "pass `timeout=`", "ruff"),
|
|
Rule("HX006", "subprocess/urlopen without timeout", "pass `timeout=`; an asyncio"
|
|
" `proc.communicate()` goes inside `asyncio.wait_for(..., timeout=...)` (a child or"
|
|
" socket that never answers hangs forever); after `proc.kill()` reap with `proc.wait()`,"
|
|
" never a bare `communicate()` (it blocks while a grandchild holds the pipe)", "ast"),
|
|
Rule("HX007", "sync config I/O inside async def", "`await asyncio.to_thread(load_config)`"
|
|
" or read it before entering the event loop", "ast"),
|
|
Rule("HX008", "asyncio.get_event_loop()", "`asyncio.get_running_loop()` inside a"
|
|
" coroutine; `asyncio.run()` at an entry point", "ast"),
|
|
Rule("HX009", "isinstance(r, Exception) on gather results", "check `BaseException`:"
|
|
" `gather(return_exceptions=True)` also returns CancelledError", "ast"),
|
|
# Profile scope (one process serves many profiles).
|
|
Rule("HX001", "hardcoded Hermes home", "`get_hermes_home()` for paths,"
|
|
" `display_hermes_home()` for user-facing text (`hermes_constants`); a path that"
|
|
" deliberately lives under the user's home, not the profile (e.g. the profiles root),"
|
|
" takes `# health: allow HX001 -- <why>`", "ast",
|
|
blocking=WARN_UNTIL_REPLAYED,
|
|
exclude=SINGLE_PROFILE + ("hermes_constants.py",)),
|
|
Rule("HX002", "new HERMES_* environment variable", "behavioural settings go in"
|
|
" config.yaml, secrets through the secret scope; `.env` is for credentials only",
|
|
"ast", exclude=SINGLE_PROFILE),
|
|
Rule("HX004", "UnscopedSecretError falls back to os.getenv", "bind the owning profile's"
|
|
" secret scope at the spawn site; never read the launch profile's environment",
|
|
"ast", exclude=SINGLE_PROFILE),
|
|
Rule("HX005", "home/config/env captured at import time", "resolve it at call time"
|
|
" (`get_hermes_home()`, `load_config()`) or key the slot by `hermes_home_key()`;"
|
|
" module constants hold the launch profile's value", "ast",
|
|
exclude=SINGLE_PROFILE),
|
|
Rule("HX012", "raw threading.Thread", "`spawn_context_thread(...)` so the thread keeps"
|
|
" the caller's profile scope (ContextVars do not cross a bare Thread)", "ast",
|
|
blocking=WARN_UNTIL_REPLAYED,
|
|
exclude=SINGLE_PROFILE),
|
|
Rule("PS-P05", "child env built from os.environ", "`served_profile_child_env()`; the"
|
|
" child otherwise inherits the launch profile's home and secrets", "regex",
|
|
exclude=SINGLE_PROFILE, blocking=WARN_UNTIL_REPLAYED, pattern_id="P05"),
|
|
Rule("PS-P06", "raw platform credential getenv", "read it through the profile's secret"
|
|
" scope (`get_secret`)", "regex", exclude=SINGLE_PROFILE,
|
|
blocking=WARN_UNTIL_REPLAYED, pattern_id="P06"),
|
|
# Process identity.
|
|
Rule("HX003", "process identity from argv substrings", "use"
|
|
" `gateway.status.looks_like_gateway_command_line` /"
|
|
" `hermes_cli.update_cmd._hermes_holder_subcommand` and match full cmdlines", "ast",
|
|
blocking=WARN_UNTIL_REPLAYED),
|
|
# Config truthiness.
|
|
Rule("HX010", "bool() of a config/env string", "`bool(\"false\")` is True. Is it set?"
|
|
" `os.getenv(\"X\") is not None` (or `!= \"\"` to treat empty as unset). A boolean"
|
|
" flag? `is_truthy_value(...)`, which is False for any non-flag string such as a key",
|
|
"ast", blocking=WARN_UNTIL_REPLAYED),
|
|
# Structure.
|
|
Rule("HX011", "if/elif ladder on one name", "use a dict/table -> handler (`_SLASH_DISPATCH`"
|
|
" is the shape)", "ast", exclude=()),
|
|
# Tests: a fixed delay is a race against the runner's load (the usual flake).
|
|
Rule("HX013", "wall-clock sleep in a test", "wait for the condition, not the clock: an"
|
|
" Event the code under test sets (`threading.Event` / `asyncio.Event`, `.wait(timeout=)`),"
|
|
" or poll with `tests.waiting.wait_until(predicate, timeout=...)` (`async_wait_until` in a"
|
|
" coroutine; a poll loop's interval under a deadline or a `range(n)` bound passes); a stand-in that must hang"
|
|
" blocks on an Event the test sets at teardown."
|
|
" `sleep(0)` to yield is fine. A delay that IS the behaviour under test takes"
|
|
" `# health: allow HX013 -- <why>`", "ast", include=TESTS, exclude=()),
|
|
)
|
|
|
|
RULES_BY_ID = {rule.id: rule for rule in RULES}
|
|
RUFF_CODES = tuple(rule.id for rule in RULES if rule.source == "ruff")
|
|
ALLOW_SYNTAX = "# health: allow <RULE> -- <why>"
|
|
|
|
|
|
def _matches(path: str, globs: tuple[str, ...]) -> bool:
|
|
return any(fnmatch(path, glob) for glob in globs)
|
|
|
|
|
|
def rule_applies(rule: Rule, path: str) -> bool:
|
|
if rule.include and not _matches(path, rule.include):
|
|
return False
|
|
return not _matches(path, rule.exclude)
|
|
|
|
|
|
def in_scope(path: str) -> str | None:
|
|
"""Language of a tracked path the engine measures, or None."""
|
|
if path.endswith(".py"):
|
|
return None if _matches(path, PY_EXCLUDE) else "py"
|
|
if path.endswith((".ts", ".tsx", ".mts", ".cts")):
|
|
return None if _matches(path, TS_EXCLUDE) else "ts"
|
|
return None
|