Files
hermes-agent/scripts/code_health/config.py
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

152 lines
8.4 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. Read from the BASE revision, so a PR cannot relax its own check, and honoured
# everywhere the ratchet runs (CI, `scripts/check`, the git hooks). "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": "split along `<stem>_<topic>` (facade + siblings, see root AGENTS.md); new"
" behaviour goes in a new or topical sibling, never appended to 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
pattern_id: str = "" # regex rules: id in scripts/ci/profile_scope_patterns.json
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",
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",
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, pattern_id="P05"),
Rule("PS-P06", "raw platform credential getenv", "read it through the profile's secret"
" scope (`get_secret`)", "regex", exclude=SINGLE_PROFILE, 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"),
# 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"),
# Structure.
Rule("HX011", "if/elif ladder on one name", "use a dict/table -> handler (`_SLASH_DISPATCH`"
" is the shape)", "ast", 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:
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