Fourth review round (F11-F18, N1-N4, kvnloo, Adolanium): - Hits are old only where the base occurrence survives: a line the comment- and whitespace-blind line diff keeps (blame semantics), or anything inside a unit whose body is unchanged. An identical violation moved past unrelated code is new; removing an allow comment or re-indenting is not. Hit fingerprints exclude comments (N4). - Inline allows cover their own line; only a comment-only line covers the next (N3). - Rules read a copy of the AST with import aliases resolved, so `sp.run`, `from subprocess import run as execute`, aliased threading/asyncio/load_config are seen (F12); deadline helpers must resolve to the real asyncio API, and communicate()/wait() are judged only on receivers holding a spawned process (F13); infinite deadlines are unbounded (F14). - HERMES_* writes introduce a name too (N1); bool(os.environ[...]) is HX010 (N2); HX009 needs the isinstance subject to come from gather(return_exceptions=True). - Recursive self-references are normalised out of body hashes, so a recursive rename keeps its cap, py and ts (F15). Decorators belong to their function's scope and classes are matchable units, so their hits follow proven moves (F16). - An over-cap file may not grow (F17, Teknium: strict improvement-only). - A file the checker cannot parse or measure (ast/ruff/TS parse error) fails. - scripts/check: --staged always lints an index snapshot (ignored files are invisible to git status, not to guards) (F18); create the --prefix dir for Git for Windows (F11); --only builds just the selected jobs, so it needs no ruff for non-ruff checks. - CODEOWNERS covers scripts/check + scripts/code_health; frontend-only PRs skip the Python-only lint jobs again and run only the ratchet.
164 lines
6.4 KiB
Python
164 lines
6.4 KiB
Python
"""Python structure: functions (qualname, length, nesting, body hash) and scope lookup.
|
|
|
|
Cyclomatic complexity comes from ruff (``ruff_runner``) so the number matches what
|
|
``ruff check --select C901`` prints; everything else is measured here from the AST.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import hashlib
|
|
from bisect import bisect_right
|
|
from dataclasses import dataclass
|
|
|
|
from scripts.code_health.model import MODULE_SCOPE, FileMeasure, Unit
|
|
|
|
_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
_BLOCKS = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.With, ast.AsyncWith, ast.Match)
|
|
_TRY_STAR = getattr(ast, "TryStar", None)
|
|
if _TRY_STAR is not None:
|
|
_BLOCKS = (*_BLOCKS, _TRY_STAR)
|
|
|
|
|
|
@dataclass
|
|
class Span:
|
|
start: int
|
|
end: int
|
|
qualname: str
|
|
|
|
|
|
def _child_blocks(node: ast.AST):
|
|
"""Statement lists directly owned by a compound statement."""
|
|
for name in ("body", "orelse", "finalbody"):
|
|
yield getattr(node, name, None) or []
|
|
for handler in getattr(node, "handlers", None) or []:
|
|
yield handler.body
|
|
for case in getattr(node, "cases", None) or []:
|
|
yield case.body
|
|
|
|
|
|
def nesting_depth(stmts: list[ast.stmt], depth: int = 0) -> int:
|
|
deepest = depth
|
|
for stmt in stmts:
|
|
if isinstance(stmt, (*_FUNCS, ast.ClassDef)) or not isinstance(stmt, _BLOCKS):
|
|
continue
|
|
for index, block in enumerate(_child_blocks(stmt)):
|
|
# `elif` is an If alone in orelse: same visual depth as its `if`.
|
|
is_elif = (
|
|
isinstance(stmt, ast.If)
|
|
and index == 1
|
|
and len(block) == 1
|
|
and isinstance(block[0], ast.If)
|
|
)
|
|
deepest = max(deepest, nesting_depth(block, depth if is_elif else depth + 1))
|
|
return deepest
|
|
|
|
|
|
def body_hash(node: ast.AST) -> str:
|
|
"""Name-independent hash, so a function moved or renamed unchanged keeps its cap.
|
|
|
|
The declared name is dropped, and so are the body's references to it (``name(...)``,
|
|
``self.name(...)``, ``cls.name(...)``), unless the body rebinds that name itself: a
|
|
recursive function renamed together with its self-call is still the same code.
|
|
"""
|
|
name = getattr(node, "name", "")
|
|
dumped = ast.dump(node, annotate_fields=False).replace(repr(name), "''", 1)
|
|
if name and f"Name({name!r}, Store())" not in dumped and f"arg({name!r}" not in dumped:
|
|
dumped = dumped.replace(f"Name({name!r}, Load())", "Name('', Load())")
|
|
for receiver in ("self", "cls"):
|
|
dumped = dumped.replace(f"Attribute(Name({receiver!r}, Load()), {name!r}, Load())",
|
|
f"Attribute(Name({receiver!r}, Load()), '', Load())")
|
|
return hashlib.sha1(dumped.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _first_line(node: ast.AST) -> int:
|
|
"""A def/class starts at its first decorator: decorator code belongs to it (and moves
|
|
with it), not to the module."""
|
|
return min([node.lineno, *(d.lineno for d in getattr(node, "decorator_list", []))])
|
|
|
|
|
|
class _UnitCollector(ast.NodeVisitor):
|
|
def __init__(self) -> None:
|
|
self.stack: list[str] = []
|
|
self.units: dict[str, Unit] = {}
|
|
self.spans: list[Span] = []
|
|
self.seen: set[str] = set()
|
|
self.funcs: list[str] = []
|
|
self.total_lines: dict[str, int] = {}
|
|
|
|
def _qualname(self, name: str) -> str:
|
|
"""Dotted path without ``<locals>``; a repeated name (property setter, conditional
|
|
def) gets ``#2``, ``#3`` in source order."""
|
|
base = ".".join([*self.stack, name])
|
|
qual, n = base, 1
|
|
while qual in self.seen:
|
|
n += 1
|
|
qual = f"{base}#{n}"
|
|
self.seen.add(qual)
|
|
return qual
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
qual = self._qualname(node.name)
|
|
# A class has no size metrics of its own, but it is a unit so that a renamed or moved
|
|
# class keeps the violations in its body (decorators, attributes) one-to-one.
|
|
self.units[qual] = Unit(qualname=qual, line=node.lineno, metrics={},
|
|
body_hash=body_hash(node), parent=self.funcs[-1] if self.funcs else None)
|
|
self.spans.append(Span(_first_line(node), node.end_lineno or node.lineno, qual))
|
|
self.stack.append(qual.rsplit(".", 1)[-1])
|
|
self.generic_visit(node)
|
|
self.stack.pop()
|
|
|
|
def _visit_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
qual = self._qualname(node.name)
|
|
end = node.end_lineno or node.lineno
|
|
first = _first_line(node)
|
|
self.total_lines[qual] = end - first + 1
|
|
self.units[qual] = Unit(
|
|
qualname=qual,
|
|
line=node.lineno,
|
|
metrics={"FUNC_LINES": end - first + 1, "NESTING": nesting_depth(node.body)},
|
|
body_hash=body_hash(node),
|
|
parent=self.funcs[-1] if self.funcs else None,
|
|
)
|
|
self.spans.append(Span(first, end, qual))
|
|
self.stack.append(qual.rsplit(".", 1)[-1])
|
|
self.funcs.append(qual)
|
|
self.generic_visit(node)
|
|
self.funcs.pop()
|
|
self.stack.pop()
|
|
|
|
visit_FunctionDef = _visit_func
|
|
visit_AsyncFunctionDef = _visit_func
|
|
|
|
|
|
class ScopeIndex:
|
|
"""Innermost enclosing def/class qualname for a line."""
|
|
|
|
def __init__(self, spans: list[Span]) -> None:
|
|
self.spans = sorted(spans, key=lambda s: (s.start, -s.end))
|
|
self.starts = [s.start for s in self.spans]
|
|
|
|
def scope(self, line: int) -> str:
|
|
best = MODULE_SCOPE
|
|
best_size = None
|
|
for span in self.spans[: bisect_right(self.starts, line)]:
|
|
if span.start <= line <= span.end:
|
|
size = span.end - span.start
|
|
if best_size is None or size <= best_size:
|
|
best, best_size = span.qualname, size
|
|
return best
|
|
|
|
|
|
def measure_structure(fm: FileMeasure, tree: ast.Module) -> ScopeIndex:
|
|
collector = _UnitCollector()
|
|
collector.visit(tree)
|
|
# FUNC_LINES is a function's OWN lines: a nested def is its own unit, so editing a closure
|
|
# never counts against the function that encloses it.
|
|
for unit in collector.units.values():
|
|
if unit.parent is not None and unit.qualname in collector.total_lines:
|
|
parent = collector.units[unit.parent]
|
|
parent.metrics["FUNC_LINES"] -= collector.total_lines[unit.qualname]
|
|
fm.units = collector.units
|
|
fm.metrics["FILE_LINES"] = len(fm.lines)
|
|
return ScopeIndex(collector.spans)
|