hermes-agent/scripts/lints/__init__.py

160 lines
5.8 KiB
Python

"""Project-wide lint registry.
A *lint* is a named repo-hygiene check with two explicit axes:
- ``severity`` — ``blocking`` findings fail the PR gate; ``advisory``
findings only surface as warnings in the CI review comment.
- ``autofix`` — the lint ships a ``fix()`` that produces deterministic
edits, applied by the autofix bot on push to main.
The severity policy (one rule, generalized from how eslint already works
in this repo):
1. blocking + not fixable -> fails the PR gate
2. blocking + fixable -> advisory at PR time; the autofix bot
fixes it on merge to main
3. advisory -> warning, never fails anything
4. ``network=True`` -> always advisory on PRs (registry/API
blips must never block a merge); the
enforcement point is the autofix pass
on main
Lints live as modules in this directory, one per file, each exposing a
top-level ``LINT``. Discovery is automatic — there is no per-lint CI
wiring to forget (the class of bug where a checker script exists but no
workflow runs it).
Run them with ``python scripts/lints/run.py`` (see ``--help``).
"""
from __future__ import annotations
import importlib.util
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
REPO_ROOT = Path(__file__).resolve().parents[2]
_SEVERITIES = ("blocking", "advisory")
# Modules in this directory that are not lints.
_NON_LINT_MODULES = {"__init__.py", "run.py"}
@dataclass(frozen=True)
class Finding:
"""One lint violation.
``fixable`` is per-finding: a lint with ``autofix=True`` may still
emit findings its fixer cannot resolve (those gate like unfixable
ones when the lint is blocking).
"""
lint_id: str
message: str
path: str = "" # repo-relative; empty for repo-wide findings
line: int | None = None
fixable: bool = False
def location(self) -> str:
if not self.path:
return "(repo)"
return f"{self.path}:{self.line}" if self.line else self.path
@dataclass(frozen=True)
class Lint:
"""A registered lint.
``check`` returns the current findings; ``fix`` (when ``autofix``)
edits files in place and returns the repo-relative paths it changed.
``fix_touches`` declares the path globs a fix may modify — the
autofix workflow's patch guard is derived from the union of these,
so a fixer that writes outside its declared globs is rejected before
anything reaches the bot branch.
"""
id: str
description: str
severity: str
autofix: bool
check: Callable[[], list[Finding]]
fix: Callable[[], list[str]] | None = None
network: bool = False
fix_touches: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.severity not in _SEVERITIES:
raise ValueError(
f"lint {self.id!r}: severity must be one of {_SEVERITIES}, "
f"got {self.severity!r}"
)
if self.autofix and self.fix is None:
raise ValueError(f"lint {self.id!r}: autofix=True requires a fix callable")
if self.autofix and not self.fix_touches:
raise ValueError(
f"lint {self.id!r}: autofix=True requires fix_touches globs "
"(the autofix patch guard is derived from them)"
)
if not self.autofix and self.fix is not None:
raise ValueError(f"lint {self.id!r}: fix provided but autofix=False")
def _ensure_importable() -> None:
"""Make ``import lints`` work regardless of how the caller started.
Lint modules import their base types as ``from lints import ...``,
so the ``scripts/`` directory must be on ``sys.path`` before any
lint module executes.
"""
scripts_dir = str(Path(__file__).resolve().parents[1])
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
def discover(directory: Path | None = None) -> list[Lint]:
"""Load every lint module and return the registered lints.
A module in the lint directory that fails to import or does not
define ``LINT`` is a hard error, never a skip — a lint that silently
drops out of the run is exactly the failure mode this registry
exists to prevent.
"""
_ensure_importable()
directory = Path(directory) if directory else Path(__file__).parent
lints: list[Lint] = []
for path in sorted(directory.glob("*.py")):
if path.name in _NON_LINT_MODULES or path.name.startswith("_"):
continue
module_name = f"_hermes_lint_{path.stem}"
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load lint module {path}")
module = importlib.util.module_from_spec(spec)
# Register before exec: @dataclass resolves string annotations by
# looking the module up in sys.modules — an unregistered module
# crashes _is_type with AttributeError on None.
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
lint = getattr(module, "LINT", None)
if lint is None:
raise RuntimeError(f"lint module {path} defines no top-level LINT")
if not isinstance(lint, Lint):
raise RuntimeError(f"lint module {path}: LINT is not a Lint instance")
lints.append(lint)
seen: dict[str, int] = {}
for lint in lints:
seen[lint.id] = seen.get(lint.id, 0) + 1
dupes = [lint_id for lint_id, n in seen.items() if n > 1]
if dupes:
raise RuntimeError(f"duplicate lint ids: {dupes}")
return lints