#!/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/rules/code-comments.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 # MUST stay in sync with the paths glob in claude-md/code-comments.md — a file # type in one and not the other either lints without the rule loaded, or loads # the rule and never lints. CODE_EXT = { ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".sol", ".sh", ".bash", ".zsh", ".rb", ".lua", ".zig", ".nix", ".ex", ".yaml", ".yml", ".toml", } 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", ".yaml", ".yml", ".toml"} HASH_RE = re.compile(r"^\s*(#+)\s?(.*)$") # Doc comments are a different genre and the budget does not apply: they # document a surface for callers, and tooling reads them — NatSpec ends up in # contract metadata. A two-star opener starts a doc block; a lone slash-star # does not. DOC_LINE_RE = re.compile(r"^\s*(///|//!)") DOC_BLOCK_OPEN_RE = re.compile(r"^\s*/\*\*") BLOCK_OPEN_RE = re.compile(r"^\s*/\*") BLOCK_CLOSE_RE = re.compile(r"\*/") # 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 non-doc comment.""" pattern = HASH_RE if ext in HASH_ONLY_EXT else COMMENT_RE hash_only = ext in HASH_ONLY_EXT out = [] in_doc_block = False in_block = False for i, line in enumerate(text.splitlines()): if not hash_only: if in_doc_block: if BLOCK_CLOSE_RE.search(line): in_doc_block = False continue if DOC_BLOCK_OPEN_RE.match(line): in_doc_block = not BLOCK_CLOSE_RE.search(line) continue if DOC_LINE_RE.match(line): continue if in_block: if BLOCK_CLOSE_RE.search(line): in_block = False else: out.append((i, line.strip())) continue # A plain block opener carries no per-line marker, so its body is # invisible to the regexes below unless tracked. if BLOCK_OPEN_RE.match(line) and not BLOCK_CLOSE_RE.search(line): in_block = True continue 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.") # Strip code spans first: a comment naming a doc-block opener or a glob # carries a literal double-star that is not emphasis. if "**" in re.sub(r"`[^`]*`", "", 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/rules/code-comments.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()