hermes-agent/scripts/lints/no_shadowed_test_definition...

107 lines
3.3 KiB
Python

"""No test module may define the same name twice in one scope.
Python keeps only the last definition, so a duplicate silently deletes
the first one. When the shadowed name is a test, its coverage disappears
with no error and no skip; when it is an ``autouse`` fixture, the whole
module quietly switches to the newer one's isolation rules. Both have
happened here (see the original module docstring in git history:
``tests/test_no_shadowed_test_definitions.py``).
Migrated from that test to the lint registry: same AST scan, but
per-file ``::error`` annotations instead of one aggregated assert.
"""
from __future__ import annotations
import ast
from pathlib import Path
from lints import REPO_ROOT, Finding, Lint
TESTS_ROOT = REPO_ROOT / "tests"
# Decorators that legitimately repeat a name in one scope.
REDEFINING_DECORATORS = ("overload", "setter", "getter", "deleter", "register")
# Throwaway callbacks conventionally named `_` are not shadowing bugs.
ALLOWED_REPEATS = {"_"}
def _decorator_names(node: ast.AST) -> list[str]:
out = []
for dec in getattr(node, "decorator_list", []):
try:
out.append(ast.unparse(dec))
except Exception: # pragma: no cover - defensive
pass
return out
def duplicates_in(body, scope: str) -> list[tuple[int, str]]:
"""``(lineno, message)`` for every shadowing definition in a scope."""
seen: dict[str, int] = {}
problems: list[tuple[int, str]] = []
for node in body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if node.name in ALLOWED_REPEATS:
continue
if any(
marker in dec
for dec in _decorator_names(node)
for marker in REDEFINING_DECORATORS
):
seen[node.name] = node.lineno
continue
if node.name in seen:
problems.append(
(
node.lineno,
f"{scope}.{node.name}() shadows the definition at line "
f"{seen[node.name]} — Python keeps only the last one, so "
"the earlier test/fixture silently disappears",
)
)
seen[node.name] = node.lineno
return problems
def scan_file(path: Path) -> list[tuple[int, str]]:
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError): # pragma: no cover
return []
problems = duplicates_in(tree.body, "<module>")
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
problems += duplicates_in(node.body, node.name)
return problems
def check() -> list[Finding]:
findings: list[Finding] = []
for path in sorted(TESTS_ROOT.rglob("test_*.py")):
rel = path.relative_to(REPO_ROOT).as_posix()
for lineno, message in scan_file(path):
findings.append(
Finding(
lint_id="no-shadowed-test-definitions",
path=rel,
line=lineno,
message=message,
)
)
return findings
LINT = Lint(
id="no-shadowed-test-definitions",
description=(
"duplicate def names in a test module scope silently delete the "
"earlier test or fixture."
),
severity="blocking",
autofix=False,
check=check,
)