Files
openswarm/linter/checks/no_underscore_names.py
T

149 lines
5.7 KiB
Python

"""Ban leading-underscore names that dead-code tooling silently skips.
Pylance's reportUnusedVariable, ruff's dummy-variable-rgx (F841/ARG0xx), and
vulture all treat a leading underscore as "intentionally private/unused" and
stop reporting it -- which makes ``_name`` a blind spot for dead-code detection.
This check bans the prefix so nothing can hide behind it.
Exempt: dunders (``__init__`` and friends, i.e. ``__x__``) and the bare ``_``
throwaway (``for _ in ...`` / ``a, _ = unpack()``). Everything else starting
with ``_`` is flagged, including name-mangled ``__x``.
Covers function/method names, arguments (incl. lambda), class names, variable
bindings (assignments, annotations, walrus, loop/with/except targets, tuple
unpacking), instance/class attribute writes (``self._x = ...``), and import
aliases. Scoped to ``backend/`` Python, like checks/classes.py.
"""
from __future__ import annotations
import ast
from collections.abc import Callable
from pathlib import Path
from . import is_excepted, is_excluded, is_lintignored
RULE = "no-underscore-names"
Report = Callable[[str, int, int, str], None]
def _is_dunder(name: str) -> bool:
"""True for ``__x__`` style names that Python requires (``__init__`` etc.)."""
return len(name) > 4 and name.startswith("__") and name.endswith("__")
def _flagged(name: str) -> bool:
if name == "_" or not name.startswith("_"):
return False
return not _is_dunder(name)
def _report_targets(target: ast.AST, report: Report) -> None:
"""Walk an assignment/loop target down to the names it binds."""
if isinstance(target, ast.Name):
report(target.id, target.lineno, target.col_offset, "variable")
elif isinstance(target, ast.Attribute):
# self._x = ... / cls._x = ...: the bound name is the attribute itself.
report(target.attr, target.end_lineno or target.lineno, _attr_col(target), "attribute")
elif isinstance(target, ast.Starred):
_report_targets(target.value, report)
elif isinstance(target, (ast.Tuple, ast.List)):
for elt in target.elts:
_report_targets(elt, report)
# ast.Subscript (``d["_x"] = ...``) is not a name binding and is skipped.
def _attr_col(node: ast.Attribute) -> int:
"""Best-effort column for the attribute name (after the dot)."""
# value.end_col_offset points just past ``value``; +1 skips the dot.
end = getattr(node.value, "end_col_offset", None)
return (end + 1) if end is not None else node.col_offset
def _args(a: ast.arguments) -> list[ast.arg | None]:
return [*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg]
def _check_tree(tree: ast.AST, rel: str) -> list[str]:
errors: list[str] = []
seen: set[tuple[int, int, str]] = set()
def report(name: str, lineno: int, col: int, kind: str) -> None:
if not _flagged(name):
return
key = (lineno, col, name)
if key in seen:
return
seen.add(key)
errors.append(
f"{rel}:{lineno}:{col + 1}: error: "
f"[{RULE}] {kind} '{name}' has a leading underscore"
)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
report(node.name, node.lineno, node.col_offset, "function")
for arg in _args(node.args):
if arg is not None:
report(arg.arg, arg.lineno, arg.col_offset, "argument")
elif isinstance(node, ast.Lambda):
for arg in _args(node.args):
if arg is not None:
report(arg.arg, arg.lineno, arg.col_offset, "argument")
elif isinstance(node, ast.ClassDef):
report(node.name, node.lineno, node.col_offset, "class")
elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for t in targets:
_report_targets(t, report)
elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
_report_targets(node.target, report)
elif isinstance(node, (ast.With, ast.AsyncWith)):
for item in node.items:
if item.optional_vars is not None:
_report_targets(item.optional_vars, report)
elif isinstance(node, ast.ExceptHandler) and node.name:
report(node.name, node.lineno, node.col_offset, "exception")
elif isinstance(node, ast.Import):
for alias in node.names:
report(
alias.asname or alias.name.split(".")[0],
node.lineno, node.col_offset, "import",
)
elif isinstance(node, ast.ImportFrom):
for alias in node.names:
if alias.name != "*":
report(alias.asname or alias.name, node.lineno, node.col_offset, "import")
return errors
def run_underscore_check(
root: Path,
exceptions: dict[str, list[str]],
excludes: list[str],
ignores: dict[Path, set[str]] | None = None,
) -> list[str]:
"""Flag leading-underscore names in backend Python files."""
errors: list[str] = []
backend = root / "backend"
if not backend.is_dir():
return errors
for pyfile in sorted(backend.rglob("*.py")):
if is_excluded(pyfile, root, excludes):
continue
rel = str(pyfile.relative_to(root))
if is_excepted(rel, RULE, exceptions):
continue
if ignores and is_lintignored(pyfile, root, RULE, ignores):
continue
try:
tree = ast.parse(pyfile.read_text(), filename=rel)
except (OSError, SyntaxError):
continue
errors.extend(_check_tree(tree, rel))
return errors