mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
ruff B905 flagged zip(comments, spans) without strict=. spans is built by a comprehension over comments, so the two can only ever be the same length; strict=True can never fire and records that, while a future edit that breaks the pairing now fails loudly instead of silently truncating the shorter side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
507 lines
18 KiB
Python
507 lines
18 KiB
Python
"""Lint: source files may carry only *useful* comments, so code stays readable.
|
|
|
|
A comment is VALID only when it is one of:
|
|
|
|
* a **file-header** block (leading comment lines explaining the file),
|
|
* a **doc comment** directly above a class / function / method / rule,
|
|
* a **tool directive** (`noqa`, `nocheck`, `type: ignore`, `shellcheck`,
|
|
`rubocop:`, `stylelint-disable`, `pragma`, language annotations, ...),
|
|
* a **marked exception** -- a mid-code comment that flags a real trip-wire
|
|
(warning, pitfall, deliberate non-idiomatic choice) whose text starts with
|
|
one of the exception markers (`Exception`, ...),
|
|
* a **rationale** -- `rationale: <topic>; <one line>`, required by another lint
|
|
that will not accept the construct without a written justification. Today the
|
|
only such lint is the async one, so the form is `rationale: async; ...`.
|
|
* a **key doc** -- a SINGLE line directly above a key in ``defaults/*.yml``,
|
|
``vars/*.yml`` or anything under ``group_vars/``. Those files are the
|
|
parameter list of a role or an inventory group, so one line above the key
|
|
documents that variable the way a docstring documents a function. Two or more
|
|
lines are a block again and get flagged: the carve-out buys a doc line, not a
|
|
paragraph, and it holds nowhere else -- ``tasks/`` and ``meta/`` keep the
|
|
no-narration rule.
|
|
|
|
Mid-code comments are ONLY allowed as exceptions. Plain narration, banners,
|
|
restating the next line, or neutral `Note:`-style info carry no warning and so
|
|
get flagged for deletion -- headers and doc comments above defs are the place
|
|
for explanation, the code body is only interrupted for genuine exceptions.
|
|
|
|
Checks ``.py .sh .yml/.yaml .rb .php .css`` plus ``.j2`` templates: the ``.j2``
|
|
suffix is stripped so the underlying language's comments are linted (``foo.yml.j2``
|
|
-> ``.yml`` rules, ``foo.php.j2`` -> ``.php`` rules, anything else -> ``#``), and
|
|
Jinja ``{# #}`` comments are checked on top. Python is parsed with ``tokenize``
|
|
(string-aware, exact). Python additionally flags bare string-expression
|
|
statements (``\"\"\"...\"\"\"`` / ``'''...'''`` / plain literals) outside
|
|
docstring position: a string floating in a body is a comment in disguise and
|
|
runs through the same validity rules. Docstrings (first statement of a
|
|
module/class/function) and PEP 224 attribute docstrings (string directly
|
|
after an assignment) stay untouched. The line-based languages flag full-line
|
|
``#`` comments plus ``/* */`` blocks; trailing inline comments are
|
|
intentionally OUT of scope (reliably distinguishing them from ``#`` inside a
|
|
string needs a full lexer per language, and false positives would make the
|
|
linter unusable).
|
|
|
|
SCOPE: every tracked file of a checked type, plus untracked new ones. The
|
|
policy holds for the whole repository, not only for the files a change happens
|
|
to touch.
|
|
|
|
A whole file opts out with a ``# nocheck: comments-valid`` marker in its first
|
|
30 lines (any comment prefix, e.g. ``{# nocheck: comments-valid #}`` for Jinja),
|
|
for vendored/upstream config templates whose inline docs are all intentional.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import io
|
|
import re
|
|
import subprocess
|
|
import tokenize
|
|
import unittest
|
|
from typing import TYPE_CHECKING
|
|
|
|
from utils.annotations.suppress import is_suppressed_in_head
|
|
from utils.cache.files import read_text
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_FILE_NOCHECK_RULE = "comments-valid"
|
|
|
|
_MARKERS = (
|
|
"todo",
|
|
"fixme",
|
|
"hack",
|
|
"xxx",
|
|
"warn",
|
|
"attention",
|
|
"danger",
|
|
"exception",
|
|
"sec",
|
|
"safety",
|
|
"deprecated",
|
|
"limitation",
|
|
"rationale",
|
|
)
|
|
_MARKER_RE = re.compile(rf"^[\s*/#>-]*(?:{'|'.join(_MARKERS)})\b", re.IGNORECASE)
|
|
|
|
_DIRECTIVE_RE = re.compile(
|
|
r"\b("
|
|
r"noqa|nocheck|nosec|bandit|type:\s*ignore|type:\s*\w|pragma|pylint|mypy|ruff"
|
|
r"|flake8|pyright|isort|fmt:\s*(?:on|off)|yapf|coding[:=]|shellcheck|yamllint"
|
|
r"|hadolint"
|
|
r"|rubocop|frozen_string_literal|sourcery|phpcs|phpstan|psalm|phan"
|
|
r"|codingStandardsIgnore|stylelint|eslint|prettier|jscpd|checkov|tflint"
|
|
r"|ansible-lint|markdownlint|shfmt|depend(?:abot|s)|renovate"
|
|
r")\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
_DEF_RE = {
|
|
".py": re.compile(r"^\s*(?:@|(?:async\s+)?def\s|class\s)"),
|
|
".rb": re.compile(r"^\s*(?:def\s|class\s|module\s|[A-Z_]+\s*=|attr_)"),
|
|
".sh": re.compile(r"^\s*(?:function\s+[\w-]+|[\w-]+\s*\(\)\s*\{?)"),
|
|
".php": re.compile(
|
|
r"^\s*(?:(?:final|abstract|public|private|protected|static|readonly|\s)*"
|
|
r"function\s|(?:final|abstract\s)*class\s|interface\s|trait\s|enum\s|namespace\s)"
|
|
),
|
|
".css": re.compile(r"^\s*[.#:@a-zA-Z\[&*].*\{\s*$"),
|
|
}
|
|
|
|
_KEY_DOC_PATH_RE = re.compile(
|
|
r"(?:^|/)(?:(?:defaults|vars)/[^/]+|group_vars/.+)\.ya?ml$"
|
|
)
|
|
_KEY_DOC_RE = re.compile(r"^\s*(?:-\s*)?[\w.\-\"']+\s*:")
|
|
|
|
_HEADER_SKIP = re.compile(r"^\s*(?:#!|<\?php|---|\.\.\.|/\*|\*|\*/|//|#|\{#)")
|
|
_PROLOGUE = re.compile(
|
|
r"^\s*(?:set |set\b|shopt|IFS=|export |readonly |umask |source |\. /|use strict"
|
|
r"|use \w|package |from __future__ |require |require_relative )"
|
|
)
|
|
|
|
|
|
def _py_comments(text: str):
|
|
try:
|
|
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
|
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
return None
|
|
return [
|
|
(t.start[0], t.string.lstrip("#").strip())
|
|
for t in toks
|
|
if t.type == tokenize.COMMENT
|
|
]
|
|
|
|
|
|
def _py_stray_strings(text: str):
|
|
"""Bare string-expression statements outside docstring position.
|
|
|
|
A triple-quoted (or plain) string literal floating in a body is a
|
|
comment in disguise and is validated like any other comment. Allowed
|
|
positions: first statement of a module/class/function (docstring) and
|
|
directly after an assignment (PEP 224 attribute docstring).
|
|
"""
|
|
try:
|
|
tree = ast.parse(text)
|
|
except SyntaxError:
|
|
return []
|
|
|
|
def _is_str_expr(stmt) -> bool:
|
|
return (
|
|
isinstance(stmt, ast.Expr)
|
|
and isinstance(stmt.value, ast.Constant)
|
|
and isinstance(stmt.value.value, str)
|
|
)
|
|
|
|
allowed: set[int] = set()
|
|
for node in ast.walk(tree):
|
|
for field in ("body", "orelse", "finalbody"):
|
|
body = getattr(node, field, None)
|
|
if not isinstance(body, list):
|
|
continue
|
|
for idx, stmt in enumerate(body):
|
|
if not _is_str_expr(stmt):
|
|
continue
|
|
is_docstring = (
|
|
field == "body"
|
|
and idx == 0
|
|
and isinstance(
|
|
node,
|
|
(
|
|
ast.Module,
|
|
ast.ClassDef,
|
|
ast.FunctionDef,
|
|
ast.AsyncFunctionDef,
|
|
),
|
|
)
|
|
)
|
|
after_assign = (
|
|
field == "body"
|
|
and idx > 0
|
|
and isinstance(node, (ast.Module, ast.ClassDef))
|
|
and isinstance(body[idx - 1], (ast.Assign, ast.AnnAssign))
|
|
)
|
|
if is_docstring or after_assign:
|
|
allowed.add(id(stmt))
|
|
|
|
out = []
|
|
for node in ast.walk(tree):
|
|
if _is_str_expr(node) and id(node) not in allowed:
|
|
first = " ".join(node.value.value.strip().split())
|
|
end = node.end_lineno or node.lineno
|
|
out.append((node.lineno, first, set(range(node.lineno, end + 1))))
|
|
return out
|
|
|
|
|
|
def _hash_comments(lines):
|
|
out = []
|
|
for i, line in enumerate(lines, 1):
|
|
s = line.lstrip()
|
|
if s.startswith("#") and not s.startswith("#!"):
|
|
out.append((i, s[1:].strip(), {i}))
|
|
return out
|
|
|
|
|
|
def _php_comments(lines):
|
|
out = []
|
|
in_block = False
|
|
block_start = 0
|
|
block_lines: set[int] = set()
|
|
block_body: list[str] = []
|
|
for i, line in enumerate(lines, 1):
|
|
if in_block:
|
|
block_lines.add(i)
|
|
end = line.find("*/")
|
|
block_body.append(line if end < 0 else line[:end])
|
|
if end >= 0:
|
|
out.append(
|
|
(block_start, " ".join(block_body).strip(" *"), set(block_lines))
|
|
)
|
|
in_block = False
|
|
continue
|
|
s = line.lstrip()
|
|
start = line.find("/*")
|
|
if start >= 0 and "*/" not in line[start:]:
|
|
in_block = True
|
|
block_start = i
|
|
block_lines = {i}
|
|
block_body = [line[start + 2 :]]
|
|
continue
|
|
if start >= 0:
|
|
end = line.find("*/", start)
|
|
out.append((i, line[start + 2 : end].strip(), {i}))
|
|
continue
|
|
if s.startswith("//"):
|
|
out.append((i, s[2:].strip(), {i}))
|
|
elif s.startswith("#") and not s.startswith("#["):
|
|
out.append((i, s[1:].strip(), {i}))
|
|
return out
|
|
|
|
|
|
def _css_comments(lines):
|
|
out = []
|
|
in_block = False
|
|
block_start = 0
|
|
block_lines: set[int] = set()
|
|
block_body: list[str] = []
|
|
for i, line in enumerate(lines, 1):
|
|
cursor = 0
|
|
while True:
|
|
if in_block:
|
|
block_lines.add(i)
|
|
end = line.find("*/", cursor)
|
|
if end < 0:
|
|
block_body.append(line[cursor:])
|
|
break
|
|
block_body.append(line[cursor:end])
|
|
out.append(
|
|
(block_start, " ".join(block_body).strip(" *"), set(block_lines))
|
|
)
|
|
in_block = False
|
|
cursor = end + 2
|
|
continue
|
|
start = line.find("/*", cursor)
|
|
if start < 0:
|
|
break
|
|
end = line.find("*/", start + 2)
|
|
if end < 0:
|
|
in_block = True
|
|
block_start = i
|
|
block_lines = {i}
|
|
block_body = [line[start + 2 :]]
|
|
break
|
|
out.append((i, line[start + 2 : end].strip(), {i}))
|
|
cursor = end + 2
|
|
return out
|
|
|
|
|
|
def _jinja_comments(lines):
|
|
out = []
|
|
in_block = False
|
|
block_start = 0
|
|
block_lines: set[int] = set()
|
|
block_body: list[str] = []
|
|
for i, line in enumerate(lines, 1):
|
|
cursor = 0
|
|
while True:
|
|
if in_block:
|
|
block_lines.add(i)
|
|
end = line.find("#}", cursor)
|
|
if end < 0:
|
|
block_body.append(line[cursor:])
|
|
break
|
|
block_body.append(line[cursor:end])
|
|
out.append(
|
|
(block_start, " ".join(block_body).strip(" #"), set(block_lines))
|
|
)
|
|
in_block = False
|
|
cursor = end + 2
|
|
continue
|
|
start = line.find("{#", cursor)
|
|
if start < 0:
|
|
break
|
|
end = line.find("#}", start + 2)
|
|
if end < 0:
|
|
in_block = True
|
|
block_start = i
|
|
block_lines = {i}
|
|
block_body = [line[start + 2 :]]
|
|
break
|
|
out.append((i, line[start + 2 : end].strip(" #"), {i}))
|
|
cursor = end + 2
|
|
return out
|
|
|
|
|
|
def _drop_nested(comments):
|
|
"""Drop comments that another comment already contains.
|
|
|
|
Two collectors run over a ``.j2`` template, so the closing ``#}`` of a
|
|
Jinja block is also a standalone ``#`` comment to the line-based one.
|
|
Reporting that fragment invites a fix that deletes one delimiter and
|
|
leaves the other, which comments out the rest of the file while every
|
|
parser still sees valid syntax.
|
|
"""
|
|
spans = [set(c[2]) for c in comments]
|
|
return [
|
|
c
|
|
for c, span in zip(comments, spans, strict=True)
|
|
if not any(span < other for other in spans)
|
|
]
|
|
|
|
|
|
def _header_end(lines) -> int:
|
|
end = 0
|
|
for i, line in enumerate(lines, 1):
|
|
s = line.strip()
|
|
if not s or _HEADER_SKIP.match(line) or _PROLOGUE.match(line):
|
|
end = i
|
|
continue
|
|
break
|
|
return end
|
|
|
|
|
|
def _next_code_matches(start_lineno, lines, comment_lines, pat) -> bool:
|
|
for idx in range(start_lineno, len(lines)):
|
|
n = idx + 1
|
|
if n in comment_lines:
|
|
continue
|
|
if not lines[idx].strip():
|
|
continue
|
|
return bool(pat.match(lines[idx]))
|
|
return False
|
|
|
|
|
|
def _next_code_is_def(start_lineno, lines, comment_lines, ext) -> bool:
|
|
pat = _DEF_RE.get(ext)
|
|
if pat is None:
|
|
return False
|
|
return _next_code_matches(start_lineno, lines, comment_lines, pat)
|
|
|
|
|
|
def allows_key_doc(path: Path) -> bool:
|
|
"""True where a one-line comment above a key documents that variable.
|
|
|
|
Ansible's variable files are the parameter list of a role or an
|
|
inventory group; a single line above the key is that parameter's doc,
|
|
the same carve-out a docstring gets above a function.
|
|
"""
|
|
return bool(_KEY_DOC_PATH_RE.search(path.as_posix()))
|
|
|
|
|
|
def _is_full_line(lineno, lines) -> bool:
|
|
if 1 <= lineno <= len(lines):
|
|
return lines[lineno - 1].lstrip().startswith(("#", "//", "/*", "*", "{#"))
|
|
return False
|
|
|
|
|
|
def _blocks(comments, lines):
|
|
"""Group consecutive FULL-LINE comments into blocks; trailing comments are
|
|
singletons. A multi-line warning keeps its marker on the first line only, so
|
|
the whole contiguous block inherits the first line's validity."""
|
|
blocks: list[list] = []
|
|
for lineno, body, span in comments:
|
|
start, end = min(span), max(span)
|
|
full = _is_full_line(start, lines)
|
|
if full and blocks and blocks[-1][-1][3] and start == blocks[-1][-1][2] + 1:
|
|
blocks[-1].append((lineno, body, end, full))
|
|
else:
|
|
blocks.append([(lineno, body, end, full)])
|
|
return blocks
|
|
|
|
|
|
def find_invalid_comments(path: Path):
|
|
try:
|
|
text = read_text(str(path))
|
|
except (OSError, UnicodeDecodeError):
|
|
return []
|
|
lines = text.splitlines()
|
|
if is_suppressed_in_head(lines, _FILE_NOCHECK_RULE):
|
|
return []
|
|
ext = path.suffix
|
|
underlying = path.with_suffix("").suffix if ext == ".j2" else ext
|
|
|
|
if underlying == ".py":
|
|
py = _py_comments(text)
|
|
if py is None:
|
|
return []
|
|
comments = sorted(
|
|
[(ln, body, {ln}) for ln, body in py] + _py_stray_strings(text),
|
|
key=lambda c: c[0],
|
|
)
|
|
elif underlying in (".yml", ".yaml", ".sh", ".rb") or path.name == "Dockerfile":
|
|
comments = _hash_comments(lines)
|
|
elif underlying == ".php":
|
|
comments = _php_comments(lines)
|
|
elif underlying == ".css":
|
|
comments = _css_comments(lines)
|
|
elif ext == ".j2":
|
|
comments = _hash_comments(lines)
|
|
else:
|
|
return []
|
|
|
|
if ext == ".j2":
|
|
comments = _drop_nested(
|
|
sorted(comments + _jinja_comments(lines), key=lambda c: c[0])
|
|
)
|
|
|
|
header_end = _header_end(lines)
|
|
comment_lines = {ln for c in comments for ln in c[2]}
|
|
key_doc_file = allows_key_doc(path)
|
|
invalid = []
|
|
for block in _blocks(comments, lines):
|
|
first_body = block[0][1]
|
|
block_marker = bool(_MARKER_RE.match(first_body))
|
|
block_def = _next_code_is_def(block[-1][2], lines, comment_lines, underlying)
|
|
if not block_def and key_doc_file and len(block) == 1:
|
|
block_def = _next_code_matches(
|
|
block[-1][2], lines, comment_lines, _KEY_DOC_RE
|
|
)
|
|
for lineno, body, _end, _full in block:
|
|
if not body:
|
|
continue
|
|
if _DIRECTIVE_RE.search(body) or block_marker:
|
|
continue
|
|
if lineno <= header_end or block_def:
|
|
continue
|
|
invalid.append((lineno, body))
|
|
return invalid
|
|
|
|
|
|
_EXTS = (".py", ".sh", ".yml", ".yaml", ".rb", ".php", ".css", ".j2")
|
|
|
|
|
|
def _git_lines(args: list[str]) -> list[str]:
|
|
"""Run a git command and return its stdout lines.
|
|
|
|
safe.directory='*' is required because the make-test container mounts the
|
|
repo as a different uid; bare git would abort with dubious-ownership and the
|
|
linter would silently scan zero files.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-c", "safe.directory=*", *args],
|
|
cwd=str(PROJECT_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return []
|
|
return result.stdout.splitlines()
|
|
|
|
|
|
def _all_targets():
|
|
"""Every tracked file of a checked type, plus untracked new ones."""
|
|
rel = set(_git_lines(["ls-files"]))
|
|
rel |= set(_git_lines(["ls-files", "--others", "--exclude-standard"]))
|
|
targets = []
|
|
for r in sorted(rel):
|
|
if r.endswith(_EXTS) or r.rsplit("/", 1)[-1] == "Dockerfile":
|
|
path = PROJECT_ROOT / r
|
|
if path.is_file():
|
|
targets.append(path)
|
|
return targets
|
|
|
|
|
|
class TestCommentsValid(unittest.TestCase):
|
|
def test_only_valid_comments(self) -> None:
|
|
offenders = []
|
|
for path in _all_targets():
|
|
for lineno, body in find_invalid_comments(path):
|
|
rel = path.relative_to(PROJECT_ROOT).as_posix()
|
|
offenders.append(f"{rel}:{lineno}: {body[:70]}")
|
|
if offenders:
|
|
shown = "\n".join(sorted(offenders)[:200])
|
|
self.fail(
|
|
f"{len(offenders)} invalid comment(s). A comment is allowed only as a "
|
|
"file header, a doc comment directly above a class/function/rule, a "
|
|
"tool directive (noqa/nocheck/...), or a mid-code EXCEPTION that flags "
|
|
"a real trip-wire (starts with Exception/...). Everything else is narration: DELETE it "
|
|
"(move real explanation into the file header or a doc comment):\n"
|
|
+ shown
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|