mirror of
https://github.com/harry7557558/spirula-studio.git
synced 2026-10-02 02:44:54 +08:00
285 lines
9.6 KiB
Python
285 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail when a comment block in an uncommitted change is over budget.
|
|
|
|
AGENTS.md ("Comments -- write fewer, and shorter") caps a file header at 10
|
|
lines and any other comment block at 3. Only blocks the working tree actually
|
|
touches are checked, so the standing debt in untouched files never blocks a
|
|
build; `--all` lists that debt for a deliberate cleanup pass.
|
|
|
|
Divider rules and section banners do not count toward a block: they are
|
|
structure, not prose.
|
|
|
|
Usage: python3 tools/check_comment_length.py [--all | --base REV] [--quiet]
|
|
|
|
--base REV checks every block a branch touched since REV, committed or not.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
HEADER_MAX = 10
|
|
BLOCK_MAX = 3
|
|
|
|
C_EXTS = {".h", ".hpp", ".c", ".cc", ".cpp", ".cu", ".cuh", ".slang"}
|
|
HASH_EXTS = {".py", ".sh", ".bash", ".cmake"}
|
|
|
|
# Vendored, generated, hand-run references, and dated design notes -- the same
|
|
# trees tools/check_comments.sh skips, for the same reasons.
|
|
EXCLUDED = (
|
|
"src/external/",
|
|
"src/generated/",
|
|
"src/instantiations/",
|
|
"reference/",
|
|
"docs/notes/",
|
|
"viewer/lib/",
|
|
"build/",
|
|
)
|
|
|
|
DIVIDER = re.compile(r"^[=\-*_~#+<>.!/ ]*$")
|
|
|
|
|
|
def run(args):
|
|
# Explicit utf-8: text=True decodes with the locale codec, and a diff
|
|
# touching i18n/catalog/ is full of bytes cp1252 has no character for.
|
|
p = subprocess.run(args, capture_output=True, encoding="utf-8",
|
|
errors="replace")
|
|
return p.stdout if p.returncode == 0 else None
|
|
|
|
|
|
def in_scope(path):
|
|
ext = os.path.splitext(path)[1]
|
|
if ext not in C_EXTS and ext not in HASH_EXTS:
|
|
return False
|
|
return not any(path.startswith(d) for d in EXCLUDED)
|
|
|
|
|
|
def strip_c_comments(lines):
|
|
"""Classify each line as code, comment text, or both.
|
|
|
|
Returns (kinds, texts): kinds[i] is "comment" when the line holds nothing
|
|
but comment, "code" when it holds any code, and "" when it is blank.
|
|
texts[i] is the comment text with its markers removed.
|
|
"""
|
|
kinds, texts = [], []
|
|
in_block = False
|
|
for line in lines:
|
|
code, text = [], []
|
|
i, n = 0, len(line)
|
|
# A bare `//` separates paragraphs of one block; it must not end it.
|
|
marked = in_block
|
|
while i < n:
|
|
two = line[i:i + 2]
|
|
if in_block:
|
|
if two == "*/":
|
|
in_block = False
|
|
i += 2
|
|
else:
|
|
text.append(line[i])
|
|
i += 1
|
|
elif two == "//":
|
|
text.append(line[i + 2:])
|
|
marked = True
|
|
i = n
|
|
elif two == "/*":
|
|
in_block = True
|
|
marked = True
|
|
i += 2
|
|
elif line[i] in "\"'":
|
|
quote = line[i]
|
|
code.append(quote)
|
|
i += 1
|
|
while i < n and line[i] != quote:
|
|
i += 2 if line[i] == "\\" else 1
|
|
i += 1
|
|
elif line[i] == "R" and line[i + 1:i + 2] == '"':
|
|
# Raw string: a // or /* inside it is payload, not a comment.
|
|
close = line.find('"', i + 2)
|
|
code.append(line[i:close + 1 if close >= 0 else n])
|
|
i = close + 1 if close >= 0 else n
|
|
else:
|
|
code.append(line[i])
|
|
i += 1
|
|
text = "".join(text)
|
|
if "".join(code).strip():
|
|
kinds.append("code")
|
|
elif marked:
|
|
kinds.append("comment")
|
|
else:
|
|
kinds.append("")
|
|
texts.append(text.strip(" \t*"))
|
|
return kinds, texts
|
|
|
|
|
|
def strip_hash_comments(lines):
|
|
kinds, texts = [], []
|
|
for n, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if n == 0 and stripped.startswith("#!"):
|
|
kinds.append("") # a shebang is not prose
|
|
texts.append("")
|
|
elif stripped.startswith("#"):
|
|
kinds.append("comment")
|
|
texts.append(stripped.lstrip("#").strip())
|
|
elif stripped:
|
|
kinds.append("code")
|
|
texts.append("")
|
|
else:
|
|
kinds.append("")
|
|
texts.append("")
|
|
return kinds, texts
|
|
|
|
|
|
def is_content(text):
|
|
return bool(text) and not DIVIDER.match(text)
|
|
|
|
|
|
def blocks(path, source):
|
|
"""Yield (start_line, end_line, content_line_count, budget) per block.
|
|
|
|
Line numbers are 1-based and inclusive.
|
|
"""
|
|
lines = source.splitlines()
|
|
ext = os.path.splitext(path)[1]
|
|
kinds, texts = (strip_c_comments if ext in C_EXTS else strip_hash_comments)(lines)
|
|
|
|
# The file header is the first block, and only while nothing but a
|
|
# shebang, an include guard or `#pragma once` has come before it.
|
|
preamble = re.compile(r"^\s*(#!|#\s*pragma\s+once|#\s*ifndef\b|#\s*define\b)")
|
|
header_open = True
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
if kinds[i] != "comment":
|
|
if kinds[i] == "code" and not preamble.match(lines[i]):
|
|
header_open = False
|
|
i += 1
|
|
continue
|
|
start = i
|
|
while i < len(lines) and kinds[i] == "comment":
|
|
i += 1
|
|
count = sum(1 for t in texts[start:i] if is_content(t))
|
|
yield start + 1, i, count, HEADER_MAX if header_open else BLOCK_MAX
|
|
header_open = False
|
|
|
|
|
|
def changed_lines(root, since="HEAD"):
|
|
"""Map path -> set of new-side line numbers, staged and unstaged alike."""
|
|
head = run(["git", "-C", root, "rev-parse", "--verify", since + "^{commit}"])
|
|
if head is None and since != "HEAD":
|
|
return None
|
|
base = head.strip() if head else "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
|
|
diff = run(["git", "-C", root, "diff", "--unified=0", "--no-color",
|
|
"--diff-filter=d", base])
|
|
if diff is None:
|
|
return None
|
|
|
|
touched, path = {}, None
|
|
for line in diff.splitlines():
|
|
if line.startswith("+++ b/"):
|
|
path = line[6:]
|
|
elif line.startswith("@@") and path:
|
|
m = re.search(r"\+(\d+)(?:,(\d+))?", line)
|
|
if m:
|
|
first = int(m.group(1))
|
|
span = int(m.group(2)) if m.group(2) else 1
|
|
touched.setdefault(path, set()).update(range(first, first + span))
|
|
|
|
others = run(["git", "-C", root, "ls-files", "--others", "--exclude-standard"])
|
|
for path in (others or "").splitlines():
|
|
if path:
|
|
touched[path] = None # whole file
|
|
return touched
|
|
|
|
|
|
def report(hits, root):
|
|
print("Comment blocks over budget:\n")
|
|
for path, start, end, count, budget in hits:
|
|
print(f" {path}:{start} {count} lines of prose (budget {budget})")
|
|
with open(os.path.join(root, path), encoding="utf-8",
|
|
errors="replace") as f:
|
|
shown = f.read().splitlines()[start - 1:end][:4]
|
|
for line in shown:
|
|
print(f" {line.strip()[:88]}")
|
|
if end - start + 1 > 4:
|
|
print(f" ... {end - start + 1 - 4} more")
|
|
print()
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
quiet = "--quiet" in args # say nothing when the tree is clean
|
|
scan_all = "--all" in args
|
|
since = "HEAD"
|
|
if "--base" in args:
|
|
at = args.index("--base")
|
|
if at + 1 >= len(args) or scan_all:
|
|
print("check_comment_length: --base takes a revision, and not with --all")
|
|
return 2
|
|
since = args.pop(at + 1)
|
|
args.pop(at)
|
|
unknown = [a for a in args if a not in ("--all", "--quiet")]
|
|
if unknown:
|
|
print(f"check_comment_length: unknown argument {unknown[0]}")
|
|
return 2
|
|
|
|
if os.environ.get("SS_SKIP_COMMENT_CHECK"):
|
|
return 0
|
|
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
root = run(["git", "-C", here, "rev-parse", "--show-toplevel"])
|
|
if root is None:
|
|
return 0 # no git, or not a checkout -- nothing to compare against
|
|
root = root.strip()
|
|
|
|
if scan_all:
|
|
listed = run(["git", "-C", root, "ls-files"]) or ""
|
|
targets = {p: None for p in listed.splitlines() if in_scope(p)}
|
|
else:
|
|
touched = changed_lines(root, since)
|
|
if touched is None:
|
|
if since != "HEAD":
|
|
print(f"check_comment_length: cannot diff against {since}")
|
|
return 2
|
|
return 0
|
|
targets = {p: v for p, v in touched.items() if in_scope(p)}
|
|
|
|
hits = []
|
|
for path, lineset in sorted(targets.items()):
|
|
full = os.path.join(root, path)
|
|
if not os.path.isfile(full):
|
|
continue
|
|
with open(full, encoding="utf-8", errors="replace") as f:
|
|
source = f.read()
|
|
for start, end, count, budget in blocks(path, source):
|
|
if count <= budget:
|
|
continue
|
|
if lineset is None or lineset & set(range(start, end + 1)):
|
|
hits.append((path, start, end, count, budget))
|
|
|
|
if not hits:
|
|
if scan_all:
|
|
print("OK: no comment block in the tree is over budget.")
|
|
elif not quiet:
|
|
scope = "the working tree" if since == "HEAD" else f"everything since {since}"
|
|
print(f"OK: comment lengths in {scope} are within budget.")
|
|
return 0
|
|
|
|
report(hits, root)
|
|
print(f"A file header gets {HEADER_MAX} lines of prose, any other comment "
|
|
f"block {BLOCK_MAX}")
|
|
print("(AGENTS.md, \"Comments -- write fewer, and shorter\"). Cut each block")
|
|
print("to the why -- the measured number, the footgun, the invariant the")
|
|
print("compiler cannot state -- or move it to docs/ and leave a pointer.")
|
|
if not scan_all:
|
|
print("")
|
|
print("`python3 tools/check_comment_length.py --all` lists the rest of the")
|
|
print("tree; SS_SKIP_COMMENT_CHECK=1 skips this check for one build.")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|