a1f95a1161
Replaces public-comms.md and code-comments.md (749 words, ~25 rules, almost all prohibitions, no examples) with a single writing.md that leads with worked examples. The rule is selection, not compression: keep output short by cutting whole ideas that don't change what the reader does next, then write what survives as plain sentences. Not by dropping articles or writing fragments, which Anthropic's Fable 5 guide calls out as the wrong lever. Prompt style leaks into output style, so the file is written in the voice it asks for. Drops the BLUF ask-line rule entirely: everyone on a PR already knows who reviews and who merges, and read literally it produced openers like "Ask: reviewers please merge". Links the contract to ~/.agents/AGENTS.md, which Codex had nothing in at all, and to the nix module for the NixOS machine.
181 lines
6.5 KiB
Python
Executable File
181 lines
6.5 KiB
Python
Executable File
#!/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()
|