Files
luxusandluxus 421e84b676 ci(writing): enforce house style in CI
Commits, changelog bullets, and comments this diff opened
had no length gate, so the next cut could ship another
design review. Fail the PR at the caps in docs/writing.md.
2026-08-31 14:49:57 +00:00

559 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""Length and shape gates for commits, CHANGELOG.md, and Rust comments.
See docs/writing.md. If this fails: shorten. Do not add `writing-ok` unless
the extra lines are a SAFETY/lifetime trap.
1. Each commit: `type(scope): summary`, ≤72 chars, no trailing period,
no `and` / semicolon, body ≤ 200 words, no Co-Authored-By.
2. Newest CHANGELOG section: ≤160 lines. Older sections are not counted.
3. Opened `//` : fail at 6 lines. Opened `//!` / `///`: fail at 24.
4. Metaphor / field-report / soak phrasing fails in all three.
A comment is opened if its lines are in the diff, or it sits above an item
(`fn` / `struct` / …) whose body this diff changed. Other comments in the
file are ignored.
`// SAFETY:` is exempt. `writing-ok:` on the line above the block, or on
its first line, with a reason. Vendor trees skipped. No cargo.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
CHANGELOG_FAIL = 160
LINE_COMMENT_FAIL = 6
DOC_COMMENT_FAIL = 24
SUBJECT_FAIL = 72
BODY_WORDS_FAIL = 200
ROOT = Path(__file__).resolve().parents[2]
COMMIT_TYPES = (
"feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "security"
)
CONV = re.compile(
rf"^({'|'.join(COMMIT_TYPES)})\(([A-Za-z0-9_./+-]+)\): (.+)$"
)
SKIP_SUBJECT = re.compile(r"^(Merge |Revert )")
ITEM_START = re.compile(
r"^(pub(\([^)]*\))?\s+)?(async\s+)?(unsafe\s+)?"
r"(fn|struct|enum|impl|trait|type|const|static|mod)\b"
)
# Shared across commits, changelog, comments. Keep this list the stories we
# actually shipped, not a vibe classifier.
STORY = (
(re.compile(r"rolled dice", re.I), "metaphor"),
(re.compile(r"\bon the floor\b", re.I), "metaphor"),
(re.compile(r"\blied\b", re.I), "metaphor"),
(re.compile(r"\bwore\b", re.I), "metaphor"),
(re.compile(r"first section written", re.I), "meta"),
(re.compile(r"Field 20\d\d"), "field report"),
(re.compile(r"cured by reconnecting", re.I), "field report"),
(re.compile(r"\bponytail:"), "lab nickname"),
(re.compile(r"\b\d+\s*-?\s*minute(?:s)?\s+soak\b", re.I), "field measurement"),
)
def story_hits(text: str) -> list[str]:
found = []
for rx, label in STORY:
if rx.search(text):
found.append(label)
return found
def newest_changelog_section(text: str) -> tuple[int, str, str]:
"""Return (line_count, heading, section_text) of the first `## v*` section."""
lines = text.splitlines()
starts = [i for i, line in enumerate(lines) if re.match(r"^## v\d", line)]
if not starts:
return 0, "", ""
a = starts[0]
b = starts[1] if len(starts) > 1 else len(lines)
return b - a, lines[a], "\n".join(lines[a:b])
def newest_changelog_len(text: str) -> tuple[int, str]:
n, heading, _ = newest_changelog_section(text)
return n, heading
def iter_comment_blocks(lines: list[str]):
"""Yield (kind, start, end) half-open. kind is 'line' (`//`) or 'doc' (`//!`/`///`)."""
i = 0
n = len(lines)
while i < n:
stripped = lines[i].lstrip()
if stripped.startswith("//!") or stripped.startswith("///"):
start = i
while i < n:
s = lines[i].lstrip()
if not lines[i].strip():
nxt = lines[i + 1].lstrip() if i + 1 < n else ""
if nxt.startswith("//!") or nxt.startswith("///"):
i += 1
continue
break
if s.startswith("//!") or s.startswith("///"):
i += 1
continue
break
yield "doc", start, i
continue
if stripped.startswith("//"):
start = i
while i < n:
s = lines[i].lstrip()
if s.startswith("//") and not s.startswith("//!") and not s.startswith("///"):
i += 1
continue
break
yield "line", start, i
continue
i += 1
def _waived(lines: list[str], start: int) -> bool:
first = lines[start].lstrip()
if re.match(r"^//(/|!)?\s*SAFETY:", first):
return True
if "writing-ok:" in first:
return True
j = start - 1
while j >= 0 and not lines[j].strip():
j -= 1
if j >= 0 and "writing-ok:" in lines[j]:
return True
return False
def file_level_doc(lines: list[str]) -> tuple[int, int] | None:
"""The leading `//!` block, skipping inner attributes and blanks before it."""
i = 0
n = len(lines)
while i < n:
s = lines[i].lstrip()
if not lines[i].strip() or (s.startswith("#![") and not s.startswith("//!")):
i += 1
continue
break
if i < n and lines[i].lstrip().startswith("//!"):
for kind, start, end in iter_comment_blocks(lines[i:]):
if kind == "doc" and start == 0:
return i, i + (end - start)
break
return None
def comment_above_item(lines: list[str], item_idx: int) -> tuple[int, int] | None:
"""Comment block immediately above an item, allowing attrs and blanks between."""
j = item_idx - 1
while j >= 0 and (not lines[j].strip() or lines[j].lstrip().startswith("#[")):
j -= 1
if j < 0:
return None
s = lines[j].lstrip()
if not (s.startswith("//") or s.startswith("///") or s.startswith("//!")):
return None
for kind, start, end in iter_comment_blocks(lines):
if start <= j < end:
return start, end
return None
def opened_comment_spans(
lines: list[str], touched: set[int] | None
) -> set[tuple[int, int]]:
"""Start/end of comment blocks this diff opened. None = every block."""
blocks = list(iter_comment_blocks(lines))
if touched is None:
return {(start, end) for _, start, end in blocks}
opened: set[tuple[int, int]] = set()
header = file_level_doc(lines)
for kind, start, end in blocks:
span = set(range(start + 1, end + 1))
if not span.isdisjoint(touched):
opened.add((start, end))
for lineno in touched:
i = lineno - 1
if i < 0 or i >= len(lines):
continue
k = i
found = None
while k >= 0:
if ITEM_START.match(lines[k].lstrip()) and not lines[k].lstrip().startswith("//"):
found = k
break
k -= 1
if found is None:
continue
attached = comment_above_item(lines, found)
if attached:
opened.add(attached)
# A module header is opened only when the header itself moved, not when
# some function in the file did. New files add every line, so they count.
if header is not None:
hspan = set(range(header[0] + 1, header[1] + 1))
if hspan.isdisjoint(touched):
opened.discard(header)
return opened
def check_blocks(
path: str,
lines: list[str],
touched: set[int] | None,
file_touched: bool = True,
) -> list[str]:
"""Line numbers in `touched` are 1-based. None means every block.
`file_touched` is accepted for call-site compatibility; opening is decided
per comment, not per file.
"""
del file_touched
errors = []
opened = opened_comment_spans(lines, touched)
kinds = {(start, end): kind for kind, start, end in iter_comment_blocks(lines)}
for start, end in sorted(opened):
if _waived(lines, start):
continue
kind = kinds.get((start, end), "line")
length = end - start
limit = DOC_COMMENT_FAIL if kind == "doc" else LINE_COMMENT_FAIL
lead = lines[start].strip()[:80]
which = "//! / ///" if kind == "doc" else "//"
if length >= limit:
errors.append(
f"{path}:{start + 1}: {which} block is {length} lines "
f"(fail at {limit}). Rewrite what you opened: {lead}"
)
text = "\n".join(lines[start:end])
for label in story_hits(text):
errors.append(
f"{path}:{start + 1}: {which} block is a {label}. "
f"Put the incident on the PR. Rewrite what you opened: {lead}"
)
return errors
def check_commit(subject: str, body: str, sha: str = "") -> list[str]:
loc = f"commit {sha[:12]} " if sha else "commit "
errors = []
if SKIP_SUBJECT.match(subject):
return errors
if len(subject) > SUBJECT_FAIL:
errors.append(
f"{loc}subject is {len(subject)} chars (fail at {SUBJECT_FAIL}): {subject!r}"
)
if subject.endswith("."):
errors.append(f"{loc}subject has a trailing period: {subject!r}")
m = CONV.match(subject)
if not m:
errors.append(
f"{loc}subject must be `type(scope): summary` "
f"(types: {', '.join(COMMIT_TYPES)}): {subject!r}"
)
else:
summary = m.group(3)
if summary.startswith("The "):
errors.append(
f"{loc}subject starts with `The`. Use type(scope) and an imperative "
f"verb: {subject!r}"
)
if " and " in summary or ";" in summary:
errors.append(
f"{loc}subject joins two changes (`and` / `;`). Split the commit "
f"or name one theme: {subject!r}"
)
if re.search(r"^Co-Authored-By:", body, re.M | re.I):
errors.append(f"{loc}has Co-Authored-By. Credit a co-author in prose.")
words = len(body.split())
if words > BODY_WORDS_FAIL:
errors.append(
f"{loc}body is {words} words (fail at {BODY_WORDS_FAIL}). "
"Investigation goes on the PR."
)
for label in story_hits(subject + "\n" + body):
errors.append(
f"{loc}is a {label}. Field logs and metaphors go on the PR, not the commit."
)
return errors
def parse_diff_changed_lines(diff: str) -> dict[str, set[int]]:
files: dict[str, set[int]] = defaultdict(set)
path = None
new_line = 0
for line in diff.splitlines():
if line.startswith("diff --git "):
path = None
m = re.search(r" b/(.+)$", line)
if m:
path = m.group(1)
continue
if line.startswith("+++ "):
rest = line[4:]
if rest.startswith("b/"):
path = rest[2:]
continue
if line.startswith("@@"):
m = re.search(r"\+(\d+)(?:,(\d+))?", line)
if not m:
continue
new_line = int(m.group(1))
continue
if line.startswith("+") and not line.startswith("+++"):
if path:
files[path].add(new_line)
new_line += 1
continue
if line.startswith("-") and not line.startswith("---"):
continue
if line.startswith("\\"):
continue
return files
def git_merge_base() -> str | None:
env_base = os.environ.get("WRITING_BASE") or os.environ.get("GITHUB_BASE_REF")
candidates = []
if env_base:
candidates.append(env_base)
candidates.append(f"origin/{env_base}")
candidates.extend(["main", "origin/main", "unom/main"])
for ref in candidates:
try:
base = subprocess.check_output(
["git", "merge-base", "HEAD", ref],
cwd=ROOT,
text=True,
stderr=subprocess.DEVNULL,
).strip()
if base:
return base
except subprocess.CalledProcessError:
continue
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD^"],
cwd=ROOT,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except subprocess.CalledProcessError:
return None
def changed_rs_lines(base: str) -> dict[str, set[int]]:
diff = subprocess.check_output(
["git", "diff", "-U0", f"{base}...HEAD", "--", "*.rs", ":!**/vendor/**"],
cwd=ROOT,
text=True,
)
return parse_diff_changed_lines(diff)
def commits_since(base: str) -> list[tuple[str, str, str]]:
raw = subprocess.check_output(
["git", "log", "-z", "--format=%H%x1f%s%x1f%b", f"{base}..HEAD"],
cwd=ROOT,
text=True,
)
out = []
for rec in raw.split("\0"):
if not rec.strip():
continue
parts = rec.split("\x1f", 2)
if len(parts) != 3:
continue
sha, subject, body = parts
out.append((sha.strip(), subject.strip(), body.strip()))
return out
def check_repo() -> list[str]:
errors: list[str] = []
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
n, heading, section = newest_changelog_section(changelog)
if n >= CHANGELOG_FAIL:
errors.append(
f"CHANGELOG.md: newest section {heading!r} is {n} lines "
f"(fail at {CHANGELOG_FAIL}). Shorten; do not edit older sections."
)
for label in story_hits(section):
errors.append(
f"CHANGELOG.md: newest section {heading!r} is a {label}. "
"Two sentences per bullet; stories go on the PR."
)
base = git_merge_base()
if base:
for sha, subject, body in commits_since(base):
errors.extend(check_commit(subject, body, sha=sha))
changed: dict[str, set[int]] = defaultdict(set)
if base:
for rel, lines_touched in changed_rs_lines(base).items():
changed[rel].update(lines_touched)
try:
wt = subprocess.check_output(
["git", "diff", "-U0", "HEAD", "--", "*.rs", ":!**/vendor/**"],
cwd=ROOT,
text=True,
)
for rel, lines_touched in parse_diff_changed_lines(wt).items():
changed[rel].update(lines_touched)
except subprocess.CalledProcessError:
pass
for rel, lines_touched in sorted(changed.items()):
if "/vendor/" in rel.split("/"):
continue
path = ROOT / rel
if not path.is_file():
continue
text = path.read_text(encoding="utf-8", errors="replace")
errors.extend(check_blocks(rel, text.splitlines(), lines_touched))
return errors
def self_test() -> int:
fails = 0
def expect(label: str, cond: bool) -> None:
nonlocal fails
if not cond:
print(f"FAIL: {label}", file=sys.stderr)
fails += 1
short = "## v1.0.0\n\n### Fixed\n- **Foo.** Bar.\n\n## v0.9.0\n"
n, h = newest_changelog_len(short)
expect("short changelog counted", n < CHANGELOG_FAIL and h == "## v1.0.0")
long = "## v1.0.0\n" + ("x\n" * CHANGELOG_FAIL) + "## v0.9.0\n"
n, _ = newest_changelog_len(long)
expect("long changelog counted", n >= CHANGELOG_FAIL)
story_cl, _, sec = newest_changelog_section(
"## v1.0.0\n- clients rolled dice\n\n## v0.9.0\n"
)
expect("changelog story", "metaphor" in story_hits(sec) and story_cl == 3)
five = ["// a"] * 5 + ["fn x() {}"]
expect("five // pass", check_blocks("t.rs", five, {1, 2, 3, 4, 5}) == [])
six = ["// a"] * 6 + ["fn x() {}"]
err = check_blocks("t.rs", six, {1, 2, 3, 4, 5, 6})
expect("six // fail", len(err) >= 1 and any("fail at 6" in e for e in err))
untouched = check_blocks("t.rs", six, {8}, file_touched=False)
expect("untouched six // pass", untouched == [])
# Touching the function body opens the comment above it.
leftover = ["// leftover field report"] * 6 + ["fn foo() {", " let x = 1;", "}"]
err = check_blocks("t.rs", leftover, {8})
expect(
"opened function comment",
any("fail at 6" in e and "Rewrite what you opened" in e for e in err),
)
short_above = ["// why 250 ms"] + ["fn foo() {", " let x = 1;", "}"]
expect("short attached comment", check_blocks("t.rs", short_above, {3}) == [])
safety = ["// SAFETY: the pointer is aligned"] + ["// still"] * 6 + ["unsafe {}"]
expect("SAFETY exempt", check_blocks("t.rs", safety, {1}) == [])
waived = ["// writing-ok: generation vs session"] + ["// x"] * 6 + ["fn y() {}"]
expect("writing-ok above", check_blocks("t.rs", waived, {2}) == [])
field = ["// Field 2026-08-28, iPad Pro: froze", "fn foo() {", " x();", "}"]
err = check_blocks("t.rs", field, {3})
expect("field report opened", any("field report" in e for e in err))
doc_ok = ["//! m"] * 23 + ["pub fn z() {}"]
expect("23 //! pass", check_blocks("t.rs", doc_ok, None) == [])
doc_bad = ["//! m"] * 24 + ["pub fn z() {}"]
err = check_blocks("t.rs", doc_bad, None)
expect("24 //! fail", any("fail at 24" in e for e in err))
# A long module header is not opened by editing a function 30 lines down.
err = check_blocks("t.rs", doc_bad, {30})
expect("body edit skips header", err == [])
err = check_blocks("t.rs", doc_bad, {1})
expect("header edit is opened", any("fail at 24" in e for e in err))
header_and_fn = ["//! m"] * 24 + ["pub fn z() {", " let x = 1;", "}"]
err = check_blocks("t.rs", header_and_fn, {26})
expect("fn body does not open //!", err == [])
diff = """diff --git a/src/lib.rs b/src/lib.rs
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -10,0 +11,2 @@
+// one
+// two
"""
parsed = parse_diff_changed_lines(diff)
expect("diff lines", parsed.get("src/lib.rs") == {11, 12})
good = "fix(host/hyprland): keep topology restore across pipeline retries"
expect("good commit", check_commit(good, "The restore was dropped.\n") == [])
plot = "The retry loop stops eating the restore that re-lights the desk"
err = check_commit(plot, "")
expect("plot subject", any("type(scope)" in e for e in err))
err = check_commit("fix(host): keep the restore.", "")
expect("trailing period", any("trailing period" in e for e in err))
err = check_commit("fix(host): keep foo and skip bar", "")
expect("and in subject", any("`and`" in e for e in err))
long_subj = "fix(host): " + "x" * SUBJECT_FAIL
err = check_commit(long_subj, "")
expect("long subject", any("chars" in e for e in err))
err = check_commit("fix(host): keep it", "Co-Authored-By: x <x@y>")
expect("trailer", any("Co-Authored-By" in e for e in err))
err = check_commit("fix(host): keep it", " ".join(["word"] * (BODY_WORDS_FAIL + 1)))
expect("long body", any("words" in e for e in err))
conv_the = "fix(host): The retry loop stops eating the restore"
err = check_commit(conv_the, "")
expect("The-subject", any("starts with `The`" in e for e in err))
if fails:
print(f"{fails} self-test failure(s)", file=sys.stderr)
return 1
print("check-writing self-test ok")
return 0
def main(argv: list[str]) -> int:
if argv[1:] == ["--self-test"]:
return self_test()
os.chdir(ROOT)
errors = check_repo()
if errors:
for e in errors:
print(f"::error::{e}")
print(
"docs/writing.md: shorten the commit, the changelog bullet, or the "
"comment you opened. Do not add writing-ok unless the extra lines "
"are a SAFETY/lifetime trap.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))