#!/usr/bin/env python3 """PostToolUse nudge for code comments: Write / Edit / MultiEdit. Lints ONLY the newly-added text against the "Code comments" contract in ~/.claude/writing.md, so legacy files are not re-flagged on every touch. Exit 0 = silent. Exit 2 = stderr goes back to Claude as feedback; the edit is already applied, so this is a revise-it nudge, not a block. Fails open on anything it cannot parse. """ import json import os import re import sys MAX_COMMENT_RUN = 3 # contract budget; past this you are teaching, not warning CODE_EXT = { ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".sol", ".sh", ".bash", ".zsh", ".c", ".h", ".cc", ".cpp", ".hpp", ".java", ".kt", ".rb", ".lua", ".zig", ".nix", ".swift", ".php", ".cs", ".scala", ".ex", } COMMENT_RE = re.compile(r"^\s*(//+|#+|/\*+|\*+/?|--|;;?)\s?(.*)$") # Only match syntax the language actually has. In a hash-comment language a `//` or `*` line is # almost always a string literal holding code for another language, and linting it flags text the # script is deleting rather than text it is adding. HASH_ONLY_EXT = {".py", ".sh", ".bash", ".zsh", ".rb", ".ex", ".nix"} HASH_RE = re.compile(r"^\s*(#+)\s?(.*)$") # Design rationale, alternatives, history: capped at 1-2 lines, belongs in docs. DOC_IN_SOURCE = [ (r"rejected alternative", "rejected alternatives belong in docs/ or an ADR"), (r"alternatives?\s+(considered|rejected|from)", "alternatives belong in docs/ or an ADR"), (r"\bit used to\b", "history belongs in the commit message"), (r"\bused to (say|be|read|return|live|call)\b", "history belongs in the commit message"), (r"\b(this|it|that) was\b.{0,50}\buntil\b", "history belongs in the commit message"), (r"\bpreviously[, ]+(this|it|we|the)\b", "history belongs in the commit message"), (r"\b(originally|historically)\b", "history belongs in the commit message"), (r"\bthe reason (we|this module|this file|it is here|this exists)\b", "rationale belongs in docs/ or an ADR"), (r"\bwe (chose|picked|went with|settled on)\b", "rationale belongs in docs/ or an ADR"), (r"\bwhy (it|this) (exists|is here|lives here)\b", "rationale belongs in docs/ or an ADR"), (r"\brather than (standing apart|widening)\b", "rationale belongs in docs/ or an ADR"), ] # A line that points at the doc is the fix the rule asks for, not a violation of it. CITES_DOC = re.compile(r"docs?/|\.md\b|\bADR[- ]?\d|\bsee `", re.I) # Deliberately narrow. `genuinely` was tried here and removed: "genuinely liquidatable", # "a genuinely DIVERGED node" and "genuinely 1:1" all mean actually-not-apparently, so flagging it # only rewrites correct prose. SUPERLATIVE = [ r"\bsingle most\b", r"\bmost consequential\b", r"\bthe (whole|entire) point\b", r"\bworth (money|noting)\b", ] def fail_open(msg=""): if msg and os.environ.get("COMMENT_LINT_DEBUG"): print(f"comment-lint: {msg}", file=sys.stderr) sys.exit(0) def added_text(tool_input): """Text this edit introduced, or None if there is nothing to lint.""" if "content" in tool_input: return tool_input["content"] if "new_string" in tool_input: return tool_input["new_string"] edits = tool_input.get("edits") if isinstance(edits, list): parts = [e.get("new_string", "") for e in edits if isinstance(e, dict)] return "\n".join(parts) if parts else None return None def comment_lines(text, ext=""): """[(index, body)] for lines that are wholly a comment.""" pattern = HASH_RE if ext in HASH_ONLY_EXT else COMMENT_RE out = [] for i, line in enumerate(text.splitlines()): m = pattern.match(line) if m and line.strip() not in ("*/", "/*"): out.append((i, m.group(2))) return out def longest_run(indices): best = run = 0 prev = None for i in indices: run = run + 1 if prev is not None and i == prev + 1 else 1 best = max(best, run) prev = i return best def lint(text, ext=""): """(problems, notes). Problems demand a revise; notes are advisory only.""" lines = comment_lines(text, ext) if not lines: return [], [] problems = [] notes = [] run = longest_run([i for i, _ in lines]) if run > MAX_COMMENT_RUN: notes.append( f"{run}-line comment block just written. Fine IF it explains code a reader " "would otherwise misread (subtle math, ordering, encoding, a line that looks " "wrong). Not fine if it argues for a design — that is a doc." ) blob = "\n".join(body for _, body in lines) lowered = blob.lower() prose = "\n".join(body for _, body in lines if not CITES_DOC.search(body)) seen = set() for pattern, advice in DOC_IN_SOURCE: m = re.search(pattern, prose.lower()) if m and advice not in seen: seen.add(advice) problems.append(f'"{m.group(0)}" in a comment — {advice}. Cite the path instead.') hits = [m.group(0) for p in SUPERLATIVE for m in [re.search(p, lowered)] if m] if hits: problems.append(f"Superlatives in comments: {', '.join(sorted(set(hits)))}. Cut them.") if "**" in blob: problems.append("Bold inside a comment. No emphasis in comments.") return problems, notes def main(): try: payload = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): fail_open("unparseable payload") if payload.get("tool_name") not in ("Write", "Edit", "MultiEdit"): fail_open() tool_input = payload.get("tool_input", {}) path = tool_input.get("file_path", "") if os.path.splitext(path)[1] not in CODE_EXT: fail_open(f"not a code file: {path}") text = added_text(tool_input) if not text: fail_open("no added text") problems, notes = lint(text, os.path.splitext(path)[1]) name = os.path.basename(path) if problems: lines = [f"Comment contract (~/.claude/writing.md) — {name}:", ""] lines += [f" - {p}" for p in problems + notes] lines += ["", "Trim what you just wrote, or say why it stays."] print("\n".join(lines), file=sys.stderr) sys.exit(2) if notes: # Advisory only: a long comment can be correct, so this reaches context # without forcing a revise cycle. print(json.dumps({ "suppressOutput": True, "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": f"Comment check ({name}): " + " ".join(notes), }, })) sys.exit(0) if __name__ == "__main__": main()