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.
78 lines
2.4 KiB
Python
78 lines
2.4 KiB
Python
"""Data shapes shared by the measurers and the comparison."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
|
|
MODULE_SCOPE = "<module>"
|
|
|
|
|
|
@dataclass
|
|
class Unit:
|
|
"""One function (Python def, TS function/method/arrow) and its metrics."""
|
|
|
|
qualname: str
|
|
line: int
|
|
metrics: dict[str, int]
|
|
body_hash: str
|
|
parent: str | None = None # nearest enclosing function (nested defs are their own units)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Hit:
|
|
"""A pattern-rule fingerprint: never a line number, so edits above don't move it."""
|
|
|
|
rule: str
|
|
scope: str
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class FileMeasure:
|
|
path: str
|
|
metrics: dict[str, int] = field(default_factory=dict)
|
|
units: dict[str, Unit] = field(default_factory=dict)
|
|
hits: Counter[Hit] = field(default_factory=Counter)
|
|
# Line numbers of each hit occurrence, for reporting only.
|
|
hit_lines: dict[Hit, list[int]] = field(default_factory=dict)
|
|
# 1-based line -> source text, for messages.
|
|
lines: list[str] = field(default_factory=list)
|
|
# 1-based line -> text of the real comment tokens on it (allow directives live only here,
|
|
# so a string literal that happens to say "health: allow" waives nothing).
|
|
comments: dict[int, str] = field(default_factory=dict)
|
|
# Why this file could not be measured (unparseable, measurer failure): a measurement gap
|
|
# must fail the check, never read as "no findings".
|
|
error: str | None = None
|
|
|
|
def code_line(self, line_no: int) -> str:
|
|
"""The line's code with its comment removed and whitespace normalised: the identity of
|
|
a violation, so editing or dropping a comment (an allow included) never makes old debt
|
|
look new."""
|
|
line = self.source_line(line_no)
|
|
comment = self.comments.get(line_no)
|
|
if comment and comment in line:
|
|
line = line[: line.rindex(comment)]
|
|
return " ".join(line.split())
|
|
|
|
def add_hit(self, rule: str, scope: str, line_no: int) -> None:
|
|
hit = Hit(rule, scope, self.code_line(line_no))
|
|
self.hits[hit] += 1
|
|
self.hit_lines.setdefault(hit, []).append(line_no)
|
|
|
|
def source_line(self, line_no: int) -> str:
|
|
if 1 <= line_no <= len(self.lines):
|
|
return self.lines[line_no - 1]
|
|
return ""
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
path: str
|
|
rule: str
|
|
scope: str
|
|
line: int
|
|
detail: str
|
|
blocking: bool = True
|
|
allowed_reason: str | None = None
|