08195c1cd4
comms-lint.py and comment-lint.py lived only in ~/.claude, which is not a git repo. Contract prose lived in ~/.claude/CLAUDE.md. Neither survived a machine rebuild. Prose moves to claude-md/ fragments, imported via @name.md. Linters move to hooks/. link.sh and home.nix distribute both. settings.json wiring stays manual — it holds machine-local MCP/statusline config this repo must not own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaGR6ERGCfzWTtuv2rebGe
181 lines
6.6 KiB
Python
Executable File
181 lines
6.6 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/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 = 5 # past this you are teaching an agent that can already read the repo
|
|
|
|
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/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()
|