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.
102 lines
4.3 KiB
Python
102 lines
4.3 KiB
Python
"""Allow comments and the agent-facing report."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections import Counter
|
|
|
|
from scripts.code_health.config import ALLOW_SYNTAX, METRIC_FIX, RULES_BY_ID, TARGETS
|
|
from scripts.code_health.model import FileMeasure, Finding
|
|
|
|
_ALLOW = re.compile(r"health:\s*allow\s+([A-Za-z0-9_,\- ]+?)\s*(?:--|—|:)\s*(\S.*)$")
|
|
_ALLOW_BARE = re.compile(r"health:\s*allow\b")
|
|
|
|
|
|
def _allow_lines(finding: Finding, fm: FileMeasure) -> list[int]:
|
|
"""Where an allow for this finding may sit: its own line, or a comment-only line right
|
|
above it. A trailing allow on the previous statement waives only that statement."""
|
|
if finding.rule == "FILE_LINES":
|
|
return list(range(1, 6))
|
|
above = finding.line - 1
|
|
return [finding.line, above] if not fm.code_line(above) else [finding.line]
|
|
|
|
|
|
def apply_allows(findings: list[Finding], head: dict[str, FileMeasure]) -> None:
|
|
"""Mark findings whose line carries ``# health: allow RULE -- why`` (in a real comment) as allowed.
|
|
|
|
A bare allow without a reason does not count; the finding stays and says why.
|
|
"""
|
|
for finding in findings:
|
|
fm = head.get(finding.path)
|
|
if fm is None:
|
|
continue
|
|
for line_no in _allow_lines(finding, fm):
|
|
text = fm.comments.get(line_no, "")
|
|
match = _ALLOW.search(text)
|
|
if match and finding.rule in {r.strip() for r in match.group(1).split(",")}:
|
|
finding.allowed_reason = match.group(2).strip()
|
|
break
|
|
if _ALLOW_BARE.search(text) and finding.rule in text:
|
|
finding.detail += f" [allow comment ignored: it needs a reason, {ALLOW_SYNTAX}]"
|
|
|
|
|
|
def _fix_text(rule_id: str) -> str:
|
|
if rule_id in METRIC_FIX:
|
|
return METRIC_FIX[rule_id]
|
|
return RULES_BY_ID[rule_id].fix
|
|
|
|
|
|
def format_findings(findings: list[Finding]) -> str:
|
|
out: list[str] = []
|
|
for f in sorted(findings, key=lambda f: (f.path, f.line, f.rule)):
|
|
if f.allowed_reason is not None:
|
|
continue
|
|
tag = "" if f.blocking else " (advisory)"
|
|
out.append(f"{f.path}:{f.line} {f.rule}{tag} {f.scope}")
|
|
out.append(f" {f.detail}")
|
|
out.append(f" fix: {_fix_text(f.rule)}")
|
|
allowed = [f for f in findings if f.allowed_reason is not None]
|
|
if allowed:
|
|
out.append("")
|
|
out.append("Allowed by comment (reviewers: check the reasons):")
|
|
for f in allowed:
|
|
out.append(f" {f.path}:{f.line} {f.rule} {f.scope} -- {f.allowed_reason}")
|
|
return "\n".join(out)
|
|
|
|
|
|
def verdict(findings: list[Finding]) -> tuple[int, int]:
|
|
blocking = sum(1 for f in findings if f.blocking and f.allowed_reason is None)
|
|
advisory = sum(1 for f in findings if not f.blocking and f.allowed_reason is None)
|
|
return blocking, advisory
|
|
|
|
|
|
def summarize_tree(measures: dict[str, FileMeasure]) -> str:
|
|
"""Burn-down view of a whole tree: units over target and hits per rule."""
|
|
over: Counter[str] = Counter()
|
|
worst: dict[str, tuple[int, str]] = {}
|
|
hits: Counter[str] = Counter()
|
|
for path, fm in measures.items():
|
|
if fm.metrics.get("FILE_LINES", 0) > TARGETS["FILE_LINES"]:
|
|
over["FILE_LINES"] += 1
|
|
_track(worst, "FILE_LINES", fm.metrics["FILE_LINES"], path)
|
|
for unit in fm.units.values():
|
|
for metric, value in unit.metrics.items():
|
|
if value > TARGETS[metric]:
|
|
over[metric] += 1
|
|
_track(worst, metric, value, f"{path}::{unit.qualname}")
|
|
for hit, count in fm.hits.items():
|
|
hits[hit.rule] += count
|
|
lines = ["Over target (units that are baselined at their current value):"]
|
|
for metric, target in TARGETS.items():
|
|
value, where = worst.get(metric, (0, "-"))
|
|
lines.append(f" {metric:<11} target {target:<5} over: {over[metric]:<5} worst: {value} {where}")
|
|
lines.append("Pattern hits (existing ones are baselined; new ones fail):")
|
|
for rule_id, count in sorted(hits.items(), key=lambda kv: -kv[1]):
|
|
lines.append(f" {rule_id:<9} {count:>6} {RULES_BY_ID[rule_id].title}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _track(worst: dict[str, tuple[int, str]], metric: str, value: int, where: str) -> None:
|
|
if value > worst.get(metric, (0, ""))[0]:
|
|
worst[metric] = (value, where)
|