Files
hermes-agent/scripts/code_health/measure.py
Teknium 791dc63c05 fix(code-health): CRLF blobs measure like LF on Windows
Measurer.measure wrote each blob's decoded text into the temp tree with write_text's
default newline translation. On Windows that turns a CRLF blob into CR CR LF, so ruff
counted lines differently from the AST and comment tables: hits moved to <module>,
their `# health: allow` waivers were missed, and valid code blocked. Writing with
newline="" keeps the blob's own line endings. The test forces the Windows write
translation so it also runs on Linux.
2026-10-04 22:35:16 -07:00

200 lines
8.7 KiB
Python

"""Measure a set of files in one tree (a revision or the working tree)."""
from __future__ import annotations
import ast
import io
import tokenize
import json
import re
import tempfile
import warnings
from pathlib import Path
from scripts.code_health import py_rules, py_structure
from scripts.code_health.config import RULES, RULES_BY_ID, in_scope, rule_applies
from scripts.code_health.gitio import read_file
from scripts.code_health.model import FileMeasure, Unit
from scripts.code_health.ruff_runner import run_ruff
from scripts.code_health.ts_measure import measure_ts
_PATTERNS_FILE = Path("scripts/ci/profile_scope_patterns.json")
def _regex_rules(repo: Path) -> list[tuple[str, re.Pattern[str], re.Pattern[str] | None]]:
data = json.loads((repo / _PATTERNS_FILE).read_text(encoding="utf-8-sig"))
by_id = {p["id"]: p for p in data["patterns"]}
out = []
for rule in RULES:
if rule.source != "regex":
continue
pat = by_id[rule.pattern_id]
path_re = re.compile(pat["path_regex"]) if pat.get("path_regex") else None
out.append((rule.id, re.compile(pat["pattern_regex"]), path_re))
return out
def _own_complexity(fm: FileMeasure, cc_by_line: dict[int, int]) -> None:
"""Ruff's C901 for a function includes every nested def; subtract the direct children so
each function carries only its own branches (nested defs are separate units)."""
full = {q: cc_by_line[u.line] for q, u in fm.units.items() if u.line in cc_by_line}
own = dict(full)
for qual, unit in fm.units.items():
if unit.parent in own and qual in full:
own[unit.parent] -= full[qual]
for qual, value in own.items():
fm.units[qual].metrics["CC"] = value
def _python_comments(text: str) -> dict[int, str]:
comments: dict[int, str] = {}
try:
for tok in tokenize.generate_tokens(io.StringIO(text).readline):
if tok.type == tokenize.COMMENT:
comments[tok.start[0]] = comments.get(tok.start[0], "") + tok.string
except (tokenize.TokenError, SyntaxError):
pass # unparseable source has no AST findings to waive either
return comments
# Characters str.splitlines() breaks on; never blanked, so the blanked text keeps its line numbers.
_LINE_BREAKS = frozenset("\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029")
def _string_statements(tree: ast.Module):
"""Docstrings and other bare string statements: prose, never executed."""
for node in ast.walk(tree):
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)):
yield node
def _executable_lines(text: str, tree: ast.Module) -> list[str]:
"""``text.splitlines()`` with comments and string statements blanked out.
The profile regex rules describe operations (`env = os.environ.copy()`), so a comment or a
docstring that mentions one must not count as doing it. String literals inside code stay:
`os.getenv("DISCORD_TOKEN")` is matched by its argument.
"""
rows = [list(line) for line in io.StringIO(text).readlines()] # ast/tokenize line numbering
def blank(line: int, start: int, end: int | None = None) -> None:
row = rows[line - 1]
for col in range(start, len(row) if end is None else end):
if row[col] not in _LINE_BREAKS:
row[col] = " "
def char_col(line: int, byte_col: int) -> int: # ast columns are UTF-8 byte offsets
return len("".join(rows[line - 1]).encode("utf-8")[:byte_col].decode("utf-8", "ignore"))
try:
for tok in tokenize.generate_tokens(io.StringIO(text).readline):
if tok.type == tokenize.COMMENT:
blank(tok.start[0], tok.start[1], tok.end[1])
except (tokenize.TokenError, SyntaxError):
return text.splitlines() # fail closed: every line stays visible to the rules
for node in _string_statements(tree):
last = node.end_lineno or node.lineno
end = char_col(last, node.end_col_offset or 0)
if last == node.lineno:
blank(node.lineno, char_col(node.lineno, node.col_offset), end)
continue
blank(node.lineno, char_col(node.lineno, node.col_offset))
for line in range(node.lineno + 1, last):
blank(line, 0)
blank(last, 0, end)
return "".join("".join(row) for row in rows).splitlines()
class Measurer:
def __init__(self, repo: Path, ruff: list[str], known_env: set[str]) -> None:
self.repo = repo
self.ruff = ruff
self.ctx = py_rules.Ctx(known_env=known_env)
self.regex_rules = _regex_rules(repo)
def measure(self, tree: str | None, paths: list[str]) -> dict[str, FileMeasure]:
contents = {}
for path in paths:
if in_scope(path):
text = read_file(self.repo, tree, path)
if text is not None:
contents[path] = text
if tree is None:
return self._measure_in(self.repo, contents)
with tempfile.TemporaryDirectory(prefix="code-health-") as tmp:
root = Path(tmp)
for path, text in contents.items():
dest = root / path
dest.parent.mkdir(parents=True, exist_ok=True)
# newline="": the blob's own line endings, so ruff's line numbers match the
# AST's (Windows text mode would turn a CRLF blob into CR CR LF).
dest.write_text(text, encoding="utf-8", newline="")
return self._measure_in(root, contents)
def _measure_in(self, root: Path, contents: dict[str, str]) -> dict[str, FileMeasure]:
result = {path: FileMeasure(path, lines=text.splitlines()) for path, text in contents.items()}
py = sorted(p for p in contents if in_scope(p) == "py")
ts = sorted(p for p in contents if in_scope(p) == "ts")
ruff_out = run_ruff(self.ruff, root, py) if py else {}
for path in py:
self._python(result[path], contents[path], ruff_out.get(path))
ts_out = measure_ts(self.repo, root, ts) if ts else {}
for path in ts:
self._typescript(result[path], ts_out.get(path))
return result
def _python(self, fm: FileMeasure, text: str, ruff_file) -> None:
fm.metrics["FILE_LINES"] = len(fm.lines)
fm.comments = _python_comments(text)
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", SyntaxWarning)
tree = ast.parse(text)
rules_tree = py_rules.canonical_tree(ast.parse(text))
except SyntaxError as exc:
fm.error = f"does not parse: {exc.msg} (line {exc.lineno})"
return
scopes = py_structure.measure_structure(fm, tree)
if ruff_file is None or ruff_file.errors:
fm.error = "ruff could not measure it: " + "; ".join((ruff_file.errors if ruff_file else ["no result"])[:3])
else:
_own_complexity(fm, ruff_file.cc_by_line)
for code, row in ruff_file.hits:
if code in RULES_BY_ID and rule_applies(RULES_BY_ID[code], fm.path):
fm.add_hit(code, scopes.scope(row), row)
for rule_id, checker in py_rules.CHECKERS.items():
if rule_applies(RULES_BY_ID[rule_id], fm.path):
for row in sorted(set(checker(rules_tree, self.ctx))):
fm.add_hit(rule_id, scopes.scope(row), row)
self._regex(fm, scopes, text, tree)
def _regex(self, fm: FileMeasure, scopes, text: str, tree: ast.Module) -> None:
code_lines: list[str] | None = None
for rule_id, pattern, path_re in self.regex_rules:
if not rule_applies(RULES_BY_ID[rule_id], fm.path):
continue
if path_re and not path_re.search(fm.path):
continue
if code_lines is None:
code_lines = _executable_lines(text, tree)
for index, line in enumerate(code_lines, start=1):
if pattern.search(line):
fm.add_hit(rule_id, scopes.scope(index), index)
@staticmethod
def _typescript(fm: FileMeasure, data: dict | None) -> None:
fm.metrics["FILE_LINES"] = len(fm.lines)
if data is None or "error" in data:
fm.error = (data or {}).get("error", "the TypeScript measurer returned nothing for it")
return
fm.comments = {line: text for line, text in data.get("comments", [])}
for unit in data.get("units", []):
fm.units[unit["q"]] = Unit(
qualname=unit["q"],
line=unit["line"],
metrics={"CC": unit["cc"], "FUNC_LINES": unit["lines"], "NESTING": unit["nesting"]},
body_hash=unit["hash"],
end_line=unit.get("end"),
)