160 lines
5.8 KiB
Python
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
|