diff --git a/.claude/hooks/md-no-hardwrap.py b/.claude/hooks/md-no-hardwrap.py new file mode 100755 index 000000000..ad8e99819 --- /dev/null +++ b/.claude/hooks/md-no-hardwrap.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: keep hardwrapped prose out of markdown and out of GitHub. + +A markdown renderer collapses a soft line break into a space, so a paragraph +wrapped at ~80 columns renders identically to the same paragraph on one line. +The wrap is not free, though: it breaks narrow viewports, it mangles list and +table rendering, and it turns a one-word edit into a diff that reflows the +whole block. Prose therefore stays on a single line per paragraph, and the +renderer decides where it wraps. + +The rule is easy to state and easy to forget, because an ~80-column wrap is +correct in the places a coding agent spends most of its time (Go comments, +commit message bodies), and the reflex leaks from there into markdown files +and GitHub bodies. This hook is the mechanical check that the reflex slips +past. It guards two surfaces: + + Write / Edit / MultiEdit markdown content headed for a file + Bash a `gh` command publishing a body to GitHub + +Two design rules keep it from becoming a nuisance, which matters more than +catching every last wrap -- a guard that blocks legitimate work gets switched +off, and then it guards nothing: + + * Only NEW wraps are refused. The hook compares the file as it stands + against the file as it would land, and reports only what the edit adds. + Editing one word of a legacy document that is wrapped throughout, or + rewriting a vendored chart README verbatim, therefore stays possible. + + * Anything ambiguous is allowed. A body assembled at runtime by the shell, + an unsplittable command line, a path that is not a regular file, a + document the hook cannot read -- all pass. The hook only ever blocks what + it is certain about. + +Line breaks are rejected only inside prose: a paragraph, a list item, or a +blockquote spanning more than one line. Everywhere else a break carries +meaning and is left alone -- code blocks (fenced and indented), tables, +headings, front matter, HTML, link reference definitions, footnotes, badge +stacks, and explicit hard breaks (two trailing spaces, or a trailing +backslash), which render as
and cannot be reflowed without changing the +output. + +Agent hook protocol: a JSON payload on stdin, exit 0 to allow the tool call, +exit 2 to block it with an explanation on stderr. +""" + +from __future__ import annotations + +import difflib +import json +import os +import re +import shlex +import stat +import sys +from typing import NamedTuple + +MARKDOWN_SUFFIXES = (".md", ".markdown", ".mdx") + +# Paths whose prose the repo does not own. Vendored upstream charts are +# regenerated verbatim by `make update` and AGENTS.md forbids editing them; +# published changelogs are historical records; *.md.gotmpl is a Go template, +# not a document. Reflowing any of them is the wrong instruction to give. +OUT_OF_SCOPE = ( + re.compile(r"packages/[^/]+/[^/]+/charts/"), + re.compile(r"/?docs/changelogs/"), + re.compile(r"/vendor/"), +) + +# Refuse to read a body file larger than this. A body is prose; anything of +# this size is not, and a hook must not stall the agent chewing through it. +MAX_BODY_FILE_BYTES = 1 << 20 # 1 MiB + +# Above this, diffing before against after costs more than the check is worth. +# The hook then cannot tell an added wrap from an inherited one, and allows. +MAX_DIFF_CHARS = 400_000 + +# `gh` subcommands that publish a body. `gh pr view`, `gh pr list` and friends +# are absent by design: they read, they do not publish. +GH_PUBLISHING_SUBCOMMANDS = { + ("pr", "create"), + ("pr", "edit"), + ("pr", "comment"), + ("pr", "review"), + ("issue", "create"), + ("issue", "edit"), + ("issue", "comment"), + ("release", "create"), + ("release", "edit"), + ("api",), +} +GH_NOUNS = {"pr", "issue", "release"} +GH_ACTIONS = {"create", "edit", "comment", "review"} + +# Flag meanings are subcommand-dependent: -F is --body-file for `gh pr`, but +# --raw-field for `gh api`. Getting this backwards silently drops the body. +BODY_FLAGS = ("--body", "-b", "--notes", "-n") +BODY_FILE_FLAGS = ("--body-file", "-F", "--notes-file") +API_FIELD_FLAGS = ("-f", "--field", "-F", "--raw-field") + + +class Violation(NamedTuple): + start_line: int # 1-based, first line of the offending block + end_line: int # 1-based, last line of the offending block + text: str # the block, joined with " | " for the message + + +_RE_HEADING = re.compile(r"^#{1,6}\s") +_RE_LIST_MARKER = re.compile(r"^\s*([-*+]|\d+[.)])\s") +_RE_BLOCKQUOTE = re.compile(r"^\s*>") +_RE_TABLE_ROW = re.compile(r"^\s*\|") +# A table delimiter row is dashes, colons, pipes and spaces, with at least one +# pipe. The pipe is what tells it apart from a `---` horizontal rule or setext +# underline, and it is what makes a pipe-less GFM table (`a | b`) detectable. +_RE_TABLE_DELIMITER = re.compile(r"^\s*:?-{1,}:?(\s*\|\s*:?-{1,}:?)+\s*$") +_RE_SETEXT_UNDERLINE = re.compile(r"^\s*(=+|-+)\s*$") +_RE_ADMONITION_OPENER = re.compile(r"^\s*(\?\?\?|!!!)") +# Any line opening with a tag is HTML, whether or not it also carries content: +# `Test output` and `
all green
` are two lines of +# a
block, not a wrapped paragraph, and joining them would be wrong. +_RE_HTML_BLOCK = re.compile(r"^\s*\s*$") +_RE_FENCE = re.compile(r"^\s*(```|~~~)") +_RE_HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$") +_RE_FRONT_MATTER = re.compile(r"^\s*(---|\+\+\+)\s*$") +# `[label]: https://…` and `[^note]: text` -- one definition per line is the +# only way to write these, so a run of them is not a wrapped paragraph. +_RE_LINK_REF_DEF = re.compile(r"^\s*\[[^\]]+\]:\s") +# `: definition` under a term. +_RE_DEFINITION = re.compile(r"^\s*:\s") +# Indented code: four spaces or a tab, outside a list. +_RE_INDENTED_CODE = re.compile(r"^( |\t)") +# A README shield stack is one badge per line by convention; collapsing it +# would be wrong. A paragraph mixing badges with prose is still wrapped prose. +_RE_BADGE_ROW = re.compile(r"^\s*\[!\[") +_RE_IMAGE_ROW = re.compile(r"^\s*!\[") +# Heredoc opener: `< bool: + return bool(_RE_BADGE_ROW.match(line) or _RE_IMAGE_ROW.match(line)) + + +def ends_with_hard_break(line: str) -> bool: + """True iff the line ends in an explicit
: two spaces, or a backslash. + + Such a break renders as a line break. Reflowing it onto one line would + change the output, so it is a deliberate break and not a wrap. + """ + return line.endswith(" ") or line.rstrip(" ").endswith("\\") + + +def is_structure(line: str) -> bool: + """True iff the line is markdown structure rather than prose.""" + return any( + rx.match(line) + for rx in ( + _RE_HEADING, + _RE_BLOCKQUOTE, + _RE_TABLE_ROW, + _RE_TABLE_DELIMITER, + _RE_SETEXT_UNDERLINE, + _RE_ADMONITION_OPENER, + _RE_HTML_BLOCK, + _RE_HR, + _RE_LINK_REF_DEF, + _RE_DEFINITION, + ) + ) + + +def wrapped_groups(block: list[tuple[int, str]]) -> list[list[tuple[int, str]]]: + """Split a block into logical lines, and return those spanning >1 physical line. + + An explicit hard break ends a logical line. Everything up to the next hard + break (or the end of the block) is one logical line, and if it occupies + more than one physical line, the author wrapped it. + """ + groups: list[list[tuple[int, str]]] = [] + current: list[tuple[int, str]] = [] + + for numbered in block: + current.append(numbered) + if ends_with_hard_break(numbered[1]): + groups.append(current) + current = [] + if current: + groups.append(current) + + return [g for g in groups if len(g) > 1] + + +def find_violations(content: str) -> list[Violation]: + """Return every prose block in content that spans more than one line.""" + violations: list[Violation] = [] + lines = content.split("\n") + + def flush(block: list[tuple[int, str]], strip_quote: bool = False) -> None: + if len(block) < 2: + return + # A stack of badges or images is one per line by convention. + if all(is_badge_or_image_row(text) for _, text in block): + return + for group in wrapped_groups(block): + violations.append( + Violation( + start_line=group[0][0], + end_line=group[-1][0], + text=" | ".join( + (t.lstrip("> ").strip() if strip_quote else t.strip()) + for _, t in group + ), + ) + ) + + in_fence = False + fence_marker = "" + in_html_comment = False + # A leading `---` opens front matter only if a matching delimiter closes it. + # Otherwise it is a horizontal rule, and treating it as an unterminated + # front-matter block would swallow the whole document unchecked. + front_matter_marker = ( + lines[0].strip() if lines and _RE_FRONT_MATTER.match(lines[0]) else "" + ) + in_front_matter = bool(front_matter_marker) and any( + line.strip() == front_matter_marker for line in lines[1:] + ) + in_table = False + + block: list[tuple[int, str]] = [] + block_kind: str | None = None # "para" | "list" | "quote" + + for idx, raw in enumerate(lines, start=1): + if in_front_matter: + if idx > 1 and raw.strip() == front_matter_marker: + in_front_matter = False + continue + + if in_html_comment: + if _RE_HTML_COMMENT_CLOSE.search(raw): + in_html_comment = False + continue + if _RE_HTML_COMMENT_OPEN.match(raw): + flush(block, block_kind == "quote") + block, block_kind = [], None + if not _RE_HTML_COMMENT_CLOSE.search(raw): + in_html_comment = True + continue + + fence = _RE_FENCE.match(raw) + if fence: + flush(block, block_kind == "quote") + block, block_kind = [], None + if not in_fence: + in_fence, fence_marker = True, fence.group(1) + elif fence_marker in raw: + in_fence, fence_marker = False, "" + continue + if in_fence: + continue + + if not raw.strip(): + flush(block, block_kind == "quote") + block, block_kind, in_table = [], None, False + continue + + # A delimiter row proves the line above it was a table header, not the + # first line of a paragraph. This is what makes a pipe-less GFM table + # (`a | b` over `--- | ---`) readable as a table. + if _RE_TABLE_DELIMITER.match(raw): + if block_kind == "para" and block: + block.pop() + flush(block, False) + block, block_kind, in_table = [], None, True + continue + if in_table: + if "|" in raw: + continue + in_table = False + + if _RE_BLOCKQUOTE.match(raw): + if block_kind != "quote": + flush(block, block_kind == "quote") + block, block_kind = [], "quote" + # `>` on its own separates paragraphs inside the quote. + if not raw.lstrip("> ").strip(): + flush(block, True) + block = [] + else: + block.append((idx, raw)) + continue + + if _RE_LIST_MARKER.match(raw): + flush(block, block_kind == "quote") + block, block_kind = [(idx, raw)], "list" + continue + + if is_structure(raw): + flush(block, block_kind == "quote") + block, block_kind = [], None + continue + + # An indented line directly under a list item is that item's lazy + # continuation -- the wrapped-bullet case. Outside a list, and with no + # paragraph open, it is an indented code block. + if _RE_INDENTED_CODE.match(raw) and block_kind != "list" and not block: + continue + + if block_kind == "list": + block.append((idx, raw)) # lazy continuation of the item + continue + + if block_kind != "para": + flush(block, block_kind == "quote") + block, block_kind = [], "para" + block.append((idx, raw)) + + flush(block, block_kind == "quote") + return violations + + +def out_of_scope(path: str) -> bool: + """True iff the repo does not own this file's prose.""" + normalised = path.replace(os.sep, "/") + if normalised.endswith(".gotmpl"): + return True + return any(rx.search(normalised) for rx in OUT_OF_SCOPE) + + +def is_markdown(path: str) -> bool: + return path.lower().endswith(MARKDOWN_SUFFIXES) + + +def read_text_file(path: str) -> str | None: + """Read a regular file, or None if it is missing, huge, or not a file. + + A fifo or a character device would block forever, and the agent with it. + """ + try: + expanded = os.path.expanduser(path) + info = os.stat(expanded) + if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BODY_FILE_BYTES: + return None + with open(expanded, encoding="utf-8") as fh: + return fh.read() + except (OSError, UnicodeDecodeError): + return None + + +def added_violations(before: str | None, after: str) -> list[Violation]: + """Return the violations whose line breaks THIS edit introduced. + + Legacy documents are wrapped throughout, and demanding that an agent reflow + a file it only meant to touch one word of is how a hook gets switched off. + But "is this violation new" survives neither of the obvious tests: comparing + the violations' text refuses a typo fix inside a paragraph that was already + wrapped, and counting them lets a file be rewritten as entirely fresh + hardwrapped prose so long as the total does not rise. + + The question that does hold up is about the break, not the block: a soft + break is this edit's doing only when BOTH lines it joins are lines the edit + wrote. Touching one line of a pair that was already wrapped leaves the break + where it was; writing both lines is what creates it. + """ + found = find_violations(after) + if before is None or not found: + return found + + before_lines = before.split("\n") + after_lines = after.split("\n") + + # Lines of `after` that came through the edit untouched, 1-based. + untouched: set[int] = set() + matcher = difflib.SequenceMatcher(None, before_lines, after_lines, autojunk=False) + for tag, _, _, start_line, end_line in matcher.get_opcodes(): + if tag == "equal": + untouched.update(range(start_line + 1, end_line + 1)) + + def edit_wrote_this_break(violation: Violation) -> bool: + return any( + number not in untouched and number + 1 not in untouched + for number in range(violation.start_line, violation.end_line) + ) + + return [v for v in found if edit_wrote_this_break(v)] + + +def apply_edits(original: str, tool_input: dict) -> str | None: + """Apply an Edit/MultiEdit payload to the file's current text.""" + edits: list[dict] = [] + if isinstance(tool_input.get("edits"), list): + edits = [e for e in tool_input["edits"] if isinstance(e, dict)] + elif isinstance(tool_input.get("new_string"), str): + edits = [tool_input] + + result = original + for edit in edits: + old = edit.get("old_string") + new = edit.get("new_string") + if not isinstance(old, str) or not isinstance(new, str) or not old: + return None # an empty old_string would prepend, not replace + if old not in result: + return None # cannot reproduce the edit; do not guess + if edit.get("replace_all"): + result = result.replace(old, new) + else: + result = result.replace(old, new, 1) + return result + + +def gh_subcommand(args: list[str]) -> tuple[str, ...] | None: + """Return the (noun, action) a `gh` invocation names, or None. + + Position alone cannot find the subcommand: a global flag may precede it, + and `gh -R owner/repo pr comment` would be read as the subcommand + `owner/repo`, letting the body through. Match on the token's value. + """ + noun: str | None = None + for token in args: + if noun is None: + if token == "api": + return ("api",) + if token in GH_NOUNS: + noun = token + elif token in GH_ACTIONS: + return (noun, token) + return None + + +HEREDOC_MARKER = "__COZY_HEREDOC_{}__" +_RE_HEREDOC_MARKER = re.compile(r"__COZY_HEREDOC_(\d+)__") + + +def split_off_heredocs(command: str) -> tuple[str, list[str]]: + """Split a command line into (command with heredocs marked, their bodies). + + A heredoc body is data, and shlex would tokenise it into nonsense. Lift the + bodies out and leave a marker where each opener stood, so that the body can + later be traced back to the command that opened it -- a heredoc feeding + `kubectl apply -f -` is not a GitHub body just because a `gh` call shares + the line. + + Every line is scanned for an opener, not only the first: a long `gh` call is + routinely spread over backslash-continued lines, the heredoc opening on the + last of them. + """ + kept: list[str] = [] + bodies: list[str] = [] + body: list[str] = [] + pending: list[str] = [] + + for line in command.split("\n"): + if pending: + if line.strip() == pending[0]: + bodies.append("\n".join(body)) + body, pending = [], pending[1:] + else: + body.append(line) + continue + + openers = list(_RE_HEREDOC.finditer(line)) + if openers: + marked = line + for offset, opener in enumerate(openers): + index = len(bodies) + len(pending) + offset + marked = marked.replace( + opener.group(0), HEREDOC_MARKER.format(index), 1 + ) + kept.append(marked) + pending.extend(m.group(2) for m in openers) + else: + kept.append(line) + + if body: # unterminated heredoc: take what we have + bodies.append("\n".join(body)) + + return "\n".join(kept), bodies + + +def stdin_redirect(args: list[str]) -> str | None: + """Return the file a `< path` redirect feeds this segment's stdin from.""" + for index, arg in enumerate(args): + if arg == "<" and index + 1 < len(args): + return args[index + 1] + if arg.startswith("<") and len(arg) > 1 and not arg.startswith("<<"): + return arg[1:] + return None + + +def owned_heredocs(args: list[str], bodies: list[str]) -> list[str]: + """Return the heredoc bodies opened by this segment, in order.""" + owned: list[str] = [] + for arg in args: + for match in _RE_HEREDOC_MARKER.finditer(arg): + index = int(match.group(1)) + if index < len(bodies): + owned.append(bodies[index]) + return owned + + +def newlines_to_separators(command: str) -> str: + """Turn command-separating newlines into `;`, leaving quoted ones alone. + + shlex treats a newline as plain whitespace, so a script whose second line + is the `gh` call lexes into one segment beginning with the FIRST line's + command -- and the `gh` invocation is never recognised as one. A newline + inside a quoted string belongs to the body and must survive; a + backslash-continued newline joins one command, so it is not a separator. + """ + out: list[str] = [] + quote: str | None = None + index = 0 + + while index < len(command): + char = command[index] + + if quote: + if char == "\\" and quote == '"' and index + 1 < len(command): + out.append(char) + out.append(command[index + 1]) + index += 2 + continue + if char == quote: + quote = None + out.append(char) + index += 1 + continue + + if char in ("'", '"'): + quote = char + out.append(char) + index += 1 + continue + + if char == "\\" and index + 1 < len(command) and command[index + 1] == "\n": + out.append(" ") # line continuation: one command, not two + index += 2 + continue + + if char == "\n": + out.append(" ; ") + index += 1 + continue + + out.append(char) + index += 1 + + return "".join(out) + + +def shell_assembled(value: str) -> bool: + """True iff the shell, not the agent, decides what this argument says. + + `--body "$(cat < list[str]: + """Return the prose fields of a JSON document (`gh api --input` payload). + + A JSON heredoc is not markdown: its braces and quoted keys are not a + wrapped paragraph. Only the human-facing values inside it are prose. + """ + try: + data = json.loads(text) + except (json.JSONDecodeError, ValueError): + return [] + + found: list[str] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key in ("body", "text") and isinstance(value, str): + found.append(value) + else: + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(data) + return found + + +def segment_bodies(args: list[str], is_api: bool) -> tuple[list[str], bool]: + """Return (bodies, wants_stdin) for one `gh` segment's arguments.""" + bodies: list[str] = [] + wants_stdin = False + + idx = 0 + while idx < len(args): + arg = args[idx] + nxt = args[idx + 1] if idx + 1 < len(args) else None + + if is_api: + # For `gh api`, -F is --raw-field, not --body-file. + if arg in API_FIELD_FLAGS and nxt is not None: + if nxt.startswith("body=") or nxt.startswith("text="): + value = nxt.split("=", 1)[1] + if value.startswith("@"): + content = read_text_file(value[1:]) + if content is not None: + bodies.append(content) + else: + bodies.append(value) + idx += 2 + continue + if arg == "--input" and nxt is not None: + if nxt == "-": + wants_stdin = True + else: + content = read_text_file(nxt) + if content is not None: + bodies.extend(json_bodies(content)) + idx += 2 + continue + idx += 1 + continue + + if arg in BODY_FLAGS and nxt is not None: + if not shell_assembled(nxt): + bodies.append(nxt) + idx += 2 + continue + if arg.startswith(("--body=", "--notes=")): + value = arg.split("=", 1)[1] + if not shell_assembled(value): + bodies.append(value) + idx += 1 + continue + if arg in BODY_FILE_FLAGS and nxt is not None: + if nxt == "-": + wants_stdin = True + elif not out_of_scope(nxt): + content = read_text_file(nxt) + if content is not None: + bodies.append(content) + idx += 2 + continue + if arg.startswith(("--body-file=", "--notes-file=")): + value = arg.split("=", 1)[1] + if value == "-": + wants_stdin = True + elif not out_of_scope(value): + content = read_text_file(value) + if content is not None: + bodies.append(content) + idx += 1 + continue + idx += 1 + + return bodies, wants_stdin + + +def gh_bodies(command: str) -> list[str]: + """Return every body a `gh` command line would publish to GitHub.""" + if "gh" not in command: + return [] + + stripped, heredocs = split_off_heredocs(command) + stripped = newlines_to_separators(stripped) + + # punctuation_chars makes the lexer return `;`, `&&`, `|`, `(` and friends + # as tokens of their own. Without it `git push; gh pr create …` lexes as + # `push;` plus a `gh` that never starts a segment, and the body it + # publishes is never seen. + try: + lexer = shlex.shlex(stripped, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) + except ValueError: # unbalanced quotes: not ours to judge + return [] + + # A command line may chain several commands; only `gh` segments matter. + separators = (";", "&", "&&", "||", "|", "(", ")", "{", "}", "\n") + segments: list[list[str]] = [[]] + for token in tokens: + if token in separators: + segments.append([]) + else: + segments[-1].append(token) + + bodies: list[str] = [] + for segment in segments: + # An environment prefix (`GH_TOKEN=… gh …`) would otherwise hide the + # `gh` behind a token that is not a command name. + while segment and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", segment[0]): + segment = segment[1:] + if not segment or os.path.basename(segment[0]) != "gh": + continue + + subcommand = gh_subcommand(segment[1:]) + if subcommand not in GH_PUBLISHING_SUBCOMMANDS: + continue + + is_api = subcommand == ("api",) + found, wants_stdin = segment_bodies(segment[1:], is_api) + bodies.extend(found) + + # A heredoc THIS segment opened is the body it publishes -- as JSON for + # `gh api`, as markdown otherwise. A heredoc opened by another command + # on the same line (a commit message, a YAML manifest) belongs to that + # command, and blocking on it would be a false positive of exactly the + # kind that gets a hook switched off. + owned = owned_heredocs(segment[1:], heredocs) + for heredoc in owned: + bodies.extend(json_bodies(heredoc) if is_api else [heredoc]) + + # `--body-file -` / `--input -` take the body from stdin; a `< file` + # redirect says which file that is. Without following it the body + # publishes unread. + if wants_stdin and not owned: + source = stdin_redirect(segment[1:]) + if source and not out_of_scope(source): + content = read_text_file(source) + if content is not None: + bodies.extend(json_bodies(content) if is_api else [content]) + + return bodies + + +def truncate(text: str, limit: int = 160) -> str: + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def report(subject: str, violations: list[Violation]) -> None: + lines = [ + "BLOCKED: hardwrapped prose.", + "AGENTS.md rule: one continuous line per paragraph — the renderer wraps to viewer width.", + "Reflow each block below onto a single line, then retry.", + "", + f"Subject: {subject}", + ] + for v in violations[:5]: + lines.append(f" - lines {v.start_line}-{v.end_line}: {truncate(v.text)}") + if len(violations) > 5: + lines.append(f" - … and {len(violations) - 5} more") + print("\n".join(lines), file=sys.stderr) + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, UnicodeDecodeError): + return 0 + if not isinstance(payload, dict): + return 0 + + tool_name = payload.get("tool_name", "") + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + return 0 + + violations: list[Violation] = [] + + if tool_name in ("Write", "Edit", "MultiEdit"): + path = tool_input.get("file_path") + if not isinstance(path, str) or not is_markdown(path) or out_of_scope(path): + return 0 + + subject = path + before = read_text_file(path) + + if tool_name == "Write": + after = tool_input.get("content") + if not isinstance(after, str): + return 0 + else: + if before is None: + return 0 # cannot see the file, cannot judge the hunk + after = apply_edits(before, tool_input) + if after is None: + return 0 + + violations = added_violations(before, after) + + elif tool_name == "Bash": + command = tool_input.get("command") + if not isinstance(command, str): + return 0 + subject = "gh command body" + for body in gh_bodies(command): + violations.extend(find_violations(body)) + + else: + return 0 + + if not violations: + return 0 + + report(str(subject), violations) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..395314de6 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/md-no-hardwrap.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 529052948..4d15bd0bd 100644 --- a/.gitignore +++ b/.gitignore @@ -83,7 +83,17 @@ tmp/ # build revision marker (generated by make image-packages) packages/core/platform/.build-revision -.claude/ + +# Agent tooling. The md-no-hardwrap hook and its registration are shared with +# every contributor, so they are tracked by name; personal agent state (local +# settings overrides, worktrees, session data) stays out of git. Put personal +# overrides in .claude/settings.local.json -- .claude/settings.json is tracked +# and git will overwrite it on pull. +.claude/* +!.claude/hooks/ +.claude/hooks/* +!.claude/hooks/md-no-hardwrap.py +!.claude/settings.json # Credentials and runtime state minted by the backup/restore demo scripts # under examples/backups/*/. Files are written by 00-helpers.sh + diff --git a/hack/md-no-hardwrap.bats b/hack/md-no-hardwrap.bats new file mode 100644 index 000000000..667b43bf8 --- /dev/null +++ b/hack/md-no-hardwrap.bats @@ -0,0 +1,667 @@ +#!/usr/bin/env bats +# Unit test: the md-no-hardwrap agent hook. +# +# The hook is a PreToolUse guard for AI coding agents. It refuses two classes +# of write: +# +# 1. Write/Edit/MultiEdit whose resulting markdown contains a prose +# paragraph, list item or blockquote spanning more than one line. +# 2. Bash invocations of `gh` that publish such a body to GitHub. +# +# Markdown renderers collapse a soft line break into a space, so a paragraph +# hardwrapped at ~80 columns gains nothing and loses on narrow viewports and +# in diffs, where a one-word edit reflows the whole block. Everything that is +# not prose -- code blocks, tables, headings, front matter, link reference +# definitions, explicit hard breaks -- keeps its line breaks, because there a +# break carries meaning. +# +# Two properties matter more than coverage of exotic markdown, and most of +# the cases below defend them: +# +# * A false positive is worse than a miss. A hook that blocks legitimate +# work gets switched off, and then it guards nothing. +# * The single most common wrap -- a bullet the agent broke across two +# lines -- must be caught. It is the whole reason the hook exists. +# +# The hook speaks the agent hook protocol: a JSON payload on stdin, exit 0 to +# allow, exit 2 to block with an explanation on stderr. + +REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)" +HOOK="$REPO_ROOT/.claude/hooks/md-no-hardwrap.py" + +# The hook is written in python3, and so is the payload builder below. Other +# hack/*.bats deliberately avoid a python3 dependency; a test for a python3 +# hook cannot. Fail loudly when the interpreter is missing rather than passing +# vacuously -- a green suite that ran nothing is worse than a red one. +have_python3() { + command -v python3 >/dev/null 2>&1 && return 0 + echo "python3 is required to exercise .claude/hooks/md-no-hardwrap.py" >&2 + return 1 +} + +# Feed a JSON payload to the hook and echo its exit code. Never fails the +# calling test on a non-zero exit -- the exit code IS the assertion subject. +hook_rc() { + local rc=0 + printf '%s' "$1" | python3 "$HOOK" >/dev/null 2>&1 || rc=$? + printf '%s' "$rc" +} + +# Payload builders. python3 does the JSON encoding so that newlines, quotes +# and backslashes survive the trip intact. +write_payload() { + python3 -c 'import json,sys; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":sys.argv[1],"content":sys.argv[2]}}))' "$1" "$2" +} + +edit_payload() { + python3 -c 'import json,sys; print(json.dumps({"tool_name":"Edit","tool_input":{"file_path":sys.argv[1],"old_string":sys.argv[2],"new_string":sys.argv[3]}}))' "$1" "$2" "$3" +} + +bash_payload() { + python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$1" +} + +@test "hook is executable" { + [ -x "$HOOK" ] +} + +############################################################################### +# Markdown: what must be blocked # +############################################################################### + +@test "markdown: multi-line prose paragraph is blocked" { + have_python3 + payload="$(write_payload /tmp/doc.md 'A paragraph that the agent +hardwrapped at eighty columns because of a reflex.')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "markdown: bullet wrapped onto a second line is blocked" { + have_python3 + # The commonest wrap of all, and the one the hook exists for. A lazy + # continuation line belongs to the bullet above it, not to a new paragraph. + payload="$(write_payload /tmp/doc.md '- a bullet the agent wrapped + at eighty columns +- a bullet that stays on one line')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "markdown: ordered list item wrapped onto a second line is blocked" { + have_python3 + payload="$(write_payload /tmp/doc.md '1. an ordered item the agent wrapped + onto a second line')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "markdown: wrapped blockquote is blocked" { + have_python3 + payload="$(write_payload /tmp/doc.md '> a quoted paragraph the agent +> wrapped across two lines')" + [ "$(hook_rc "$payload")" = "2" ] +} + +############################################################################### +# Markdown: what must NOT be blocked # +############################################################################### + +@test "markdown: single-line paragraphs pass" { + have_python3 + payload="$(write_payload /tmp/doc.md '# Title + +One continuous line per paragraph, however long it happens to run. + +Another paragraph, also a single line.')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: fenced code block keeps its line breaks" { + have_python3 + payload="$(write_payload /tmp/doc.md 'Intro line. + +```bash +echo one +echo two +```')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: indented code block keeps its line breaks" { + have_python3 + payload="$(write_payload /tmp/doc.md 'Intro line. + + apiVersion: v1 + kind: ConfigMap + metadata: + name: demo')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: explicit hard break with two trailing spaces is preserved" { + have_python3 + # Two trailing spaces render as
. Reflowing that onto one line would + # change the rendered output, so it is a deliberate break, not a wrap. + payload="$(printf '%s' '{"tool_name":"Write","tool_input":{"file_path":"/tmp/doc.md","content":"Line one, ending in a hard break. \nLine two, its own rendered line.\n"}}')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: explicit hard break with a trailing backslash is preserved" { + have_python3 + payload="$(printf '%s' '{"tool_name":"Write","tool_input":{"file_path":"/tmp/doc.md","content":"Line one, ending in a backslash break.\\\\\nLine two, its own rendered line.\n"}}')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: adjacent link reference definitions keep one per line" { + have_python3 + payload="$(write_payload /tmp/doc.md '[upstream]: https://example.com/upstream +[downstream]: https://example.com/downstream')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: footnote definitions keep one per line" { + have_python3 + payload="$(write_payload /tmp/doc.md '[^one]: The first footnote. +[^two]: The second footnote.')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: lists and tables keep their line breaks" { + have_python3 + payload="$(write_payload /tmp/doc.md '- first bullet +- second bullet + +| a | b | +| --- | --- | +| 1 | 2 |')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: pipe-less table keeps its line breaks" { + have_python3 + payload="$(write_payload /tmp/doc.md 'a | b +--- | --- +1 | 2')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: badge stack keeps one shield per line" { + have_python3 + payload="$(write_payload /tmp/README.md '[![Build](https://example.com/b.svg)](https://example.com/build) +[![License](https://example.com/l.svg)](https://example.com/license)')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: the repo's own README passes the hook" { + have_python3 + # README.md uses a two-space hard break. A hook that cannot write the repo's + # front page is a hook that gets switched off. + payload="$(python3 -c 'import json,sys; p=sys.argv[1]; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":p,"content":open(p,encoding="utf-8").read()}}))' "$REPO_ROOT/README.md")" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: AGENTS.md passes the hook it documents" { + have_python3 + payload="$(python3 -c 'import json,sys; p=sys.argv[1]; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":p,"content":open(p,encoding="utf-8").read()}}))' "$REPO_ROOT/AGENTS.md")" + [ "$(hook_rc "$payload")" = "0" ] +} + +############################################################################### +# Markdown: Edit hunks are judged against the file, not in isolation # +############################################################################### + +@test "markdown: Edit hunk introducing a wrap is blocked" { + have_python3 + tmp="$(mktemp -d)" + printf 'Existing single line.\n\nold\n' > "$tmp/doc.md" + payload="$(edit_payload "$tmp/doc.md" 'old' 'A fresh paragraph that got +wrapped across two lines.')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +} + +@test "markdown: Edit hunk landing inside a code fence is allowed" { + have_python3 + # The hunk alone looks like a wrapped paragraph. Only the surrounding file + # shows it is YAML inside a fence -- so the file is what must be judged. + tmp="$(mktemp -d)" + printf 'Intro line.\n\n```yaml\nPLACEHOLDER\n```\n' > "$tmp/doc.md" + payload="$(edit_payload "$tmp/doc.md" 'PLACEHOLDER' 'apiVersion: v1 +kind: ConfigMap +metadata: + name: demo')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "0" ] +} + +@test "markdown: fixing a typo inside a legacy wrapped paragraph is allowed" { + have_python3 + # The paragraph stays as wrapped as it was -- the edit adds no wrap. Keying + # violations by their text would see the changed wording as a brand-new + # violation and refuse a one-word fix. + tmp="$(mktemp -d)" + printf 'A legacy paragraph with teh typo\nwrapped long ago.\n' > "$tmp/doc.md" + payload="$(edit_payload "$tmp/doc.md" 'teh' 'the')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "0" ] +} + +@test "markdown: adding a wrap to an already-wrapped file is still blocked" { + have_python3 + tmp="$(mktemp -d)" + printf 'A legacy paragraph that was\nwrapped long ago.\n\nTARGET\n' > "$tmp/doc.md" + payload="$(edit_payload "$tmp/doc.md" 'TARGET' 'A brand new paragraph that the +agent wrapped as well.')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +} + +@test "markdown: Edit preserving a pre-existing wrap elsewhere is allowed" { + have_python3 + # Legacy files are full of wrapped paragraphs. Editing one word in such a + # file must not require reflowing the rest of it. + tmp="$(mktemp -d)" + printf 'A legacy paragraph that was\nwrapped long ago.\n\nTARGET\n' > "$tmp/doc.md" + payload="$(edit_payload "$tmp/doc.md" 'TARGET' 'A replacement on one line.')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "0" ] +} + +############################################################################### +# Markdown: paths the repo does not own # +############################################################################### + +@test "markdown: vendored upstream chart docs are out of scope" { + have_python3 + # AGENTS.md forbids editing vendored charts; `make update` regenerates them + # verbatim from upstream. Flagging their wraps would demand a forbidden fix. + payload="$(write_payload "$REPO_ROOT/packages/system/cert-manager/charts/cert-manager/README.md" 'A vendored paragraph +wrapped by its upstream author.')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: published changelogs are out of scope" { + have_python3 + payload="$(write_payload "$REPO_ROOT/docs/changelogs/v1.5.0.md" 'A released changelog entry +wrapped when it was published.')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "non-markdown files are none of the hook's business" { + have_python3 + payload="$(write_payload /tmp/main.go 'package main + +// A Go comment wrapped at eighty columns, which is correct style +// for Go and must not be blocked by a markdown rule. +func main() {}')" + [ "$(hook_rc "$payload")" = "0" ] +} + +############################################################################### +# gh: bodies that must be blocked # +############################################################################### + +@test "gh: PR body with a wrapped paragraph is blocked" { + have_python3 + payload="$(bash_payload 'gh pr create --title "fix(x): y" --body "This body was +hardwrapped by the agent."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: --body= equals form is blocked" { + have_python3 + payload="$(bash_payload 'gh issue comment 42 --body="A body that was +wrapped across two lines."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: global flag before the subcommand does not hide the body" { + have_python3 + # `gh -R owner/repo pr comment` puts the repo between `gh` and the + # subcommand. Reading the subcommand as "the first non-flag argument" picks + # up the repo instead, and the body sails through unchecked. + payload="$(bash_payload 'gh -R cozystack/cozystack pr comment 123 --body "First line of the body +and its wrapped continuation."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: environment prefix does not hide the body" { + have_python3 + payload="$(bash_payload 'GH_TOKEN=secret gh issue comment 1 --body "First line of the body +and its wrapped continuation."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: subshell does not hide the body" { + have_python3 + payload="$(bash_payload '(gh issue comment 1 --body "First line of the body +and its wrapped continuation.")')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: chained command after && is still inspected" { + have_python3 + payload="$(bash_payload 'git push && gh pr create --title "t" --body "First line of the body +and its wrapped continuation."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: chained command after a semicolon with no space is still inspected" { + have_python3 + # `push;` is one shlex token unless the lexer treats `;` as punctuation, and + # then the gh segment that follows is never recognised as one. + payload="$(bash_payload 'git push; gh pr create --title "t" --body "First line of the body +and its wrapped continuation."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: body survives a redirect on the same command" { + have_python3 + payload="$(bash_payload 'gh pr create --title "t" --body "First line of the body +and its wrapped continuation." > /dev/null')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: heredoc body with a wrapped paragraph is blocked" { + have_python3 + payload="$(bash_payload 'gh pr create --body-file - < "$tmp/body.md" + payload="$(bash_payload "gh pr create --body-file $tmp/body.md")" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +} + +@test "gh: api field body with a wrapped paragraph is blocked" { + have_python3 + payload="$(bash_payload 'gh api --method POST /repos/o/r/issues/1/comments -f body="A review reply that was +wrapped across two lines."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: api raw field body with a wrapped paragraph is blocked" { + have_python3 + # For `gh api`, -F means --raw-field, not --body-file. Reading it as a file + # name drops the body on the floor and the wrap ships. + payload="$(bash_payload 'gh api --method POST /repos/o/r/issues/1/comments -F body="A review reply that was +wrapped across two lines."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +############################################################################### +# gh: bodies and commands that must NOT be blocked # +############################################################################### + +@test "gh: single-line PR body passes" { + have_python3 + payload="$(bash_payload 'gh pr create --title "fix(x): y" --body "One continuous line, as the renderer expects."')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "gh: read-only subcommands are never blocked" { + have_python3 + payload="$(bash_payload 'gh pr view 42 --json body')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "gh: release notes from a published changelog are not blocked" { + have_python3 + # hack/upload-releasenotes.sh publishes docs/changelogs/.md, which + # is historical and wrapped. Blocking it would break the release process. + payload="$(bash_payload "gh release edit v1.5.0 --notes-file $REPO_ROOT/docs/changelogs/v1.5.0.md")" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "gh: api JSON heredoc is not read as markdown" { + have_python3 + # `gh api --input -` takes JSON, not markdown. Its braces and quoted keys + # are not a hardwrapped paragraph. + # + # The JSON is indented so that no line of it starts with `}`: cozytest.sh + # converts each @test into a shell function with awk, and a bare `}` in the + # first column ends that function early, silently truncating the test. + payload="$(bash_payload 'gh api --method POST /repos/o/r/pulls/1/reviews --input - < /tmp/notes.txt')" + [ "$(hook_rc "$payload")" = "0" ] +} + +############################################################################### +# Robustness: the hook must never hang, crash, or block what it cannot read # +############################################################################### + +@test "unparseable payload fails open" { + have_python3 + rc=0 + printf 'not json at all' | python3 "$HOOK" >/dev/null 2>&1 || rc=$? + [ "$rc" = "0" ] +} + +@test "gh: --body-file on a fifo does not hang" { + have_python3 + # An unopened fifo blocks forever on read. The hook must refuse to read + # anything that is not a regular file rather than stall the agent. + tmp="$(mktemp -d)" + mkfifo "$tmp/fifo" + payload="$(bash_payload "gh pr create --body-file $tmp/fifo")" + rc=0 + printf '%s' "$payload" | python3 "$HOOK" >/dev/null 2>&1 || rc=$? + rm -rf "$tmp" + [ "$rc" = "0" ] +} + +@test "gh: unbalanced quotes fail open" { + have_python3 + payload="$(bash_payload 'gh pr create --body "unterminated')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "block message names the rule and the offending lines" { + have_python3 + payload="$(write_payload /tmp/doc.md 'A paragraph that the agent +hardwrapped at eighty columns.')" + msg="$(printf '%s' "$payload" | python3 "$HOOK" 2>&1 >/dev/null || true)" + printf '%s' "$msg" | grep -q 'one continuous line per paragraph' + printf '%s' "$msg" | grep -q '/tmp/doc.md' +} + +############################################################################### +# The question is always "did this edit ADD the line break", never "is there # +# a wrap somewhere in the file". These cases pin that distinction down. # +############################################################################### + +@test "markdown: rewriting a file as entirely wrapped prose is blocked" { + have_python3 + # Ties the violation count of the legacy file, so any count-based test waves + # it through -- while every wrap in it is brand new. + tmp="$(mktemp -d)" + printf 'A legacy paragraph that was\nwrapped long ago.\n' > "$tmp/doc.md" + payload="$(write_payload "$tmp/doc.md" 'A completely new paragraph that the +agent has just hardwrapped.')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +} + +@test "markdown: reflowing one paragraph while wrapping another is blocked" { + have_python3 + tmp="$(mktemp -d)" + printf 'One wrapped paragraph\nfrom long ago.\n\nSECOND\n' > "$tmp/doc.md" + payload="$(edit_payload "$tmp/doc.md" 'SECOND' 'A second paragraph the agent +has now wrapped as well.')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +} + +@test "markdown: rewriting a legacy file verbatim is allowed" { + have_python3 + tmp="$(mktemp -d)" + printf 'A legacy paragraph that was\nwrapped long ago.\n' > "$tmp/doc.md" + payload="$(write_payload "$tmp/doc.md" 'A legacy paragraph that was +wrapped long ago.')" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "0" ] +} + +@test "gh: a publishing command on the second line of a script is inspected" { + have_python3 + # A newline is whitespace to shlex, so without explicit handling the gh call + # folds into the previous command's segment and is never seen. + payload="$(bash_payload 'git status +gh pr create --title "t" --body "A body paragraph the agent +hardwrapped across two lines."')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: backslash-continued command with a heredoc body is inspected" { + have_python3 + payload="$(bash_payload 'gh pr create \ + --title "t" \ + --body-file - < +Test output +
all green
+
"')" + [ "$(hook_rc "$payload")" = "0" ] +} + +@test "markdown: a leading horizontal rule does not swallow the document" { + have_python3 + # `---` on line 1 opens front matter only if a closing delimiter follows. + payload="$(write_payload /tmp/doc.md '--- + +A paragraph the agent +hardwrapped after a horizontal rule.')" + [ "$(hook_rc "$payload")" = "2" ] +} + +@test "gh: body redirected onto stdin from a file is inspected" { + have_python3 + # `--body-file -` says the body arrives on stdin, and `< body.md` says where + # stdin comes from. Recording the first without following the second lets the + # body publish unread. + tmp="$(mktemp -d)" + printf 'A body paragraph the agent\nhardwrapped across two lines.\n' > "$tmp/body.md" + payload="$(bash_payload "gh pr create --title t --body-file - < $tmp/body.md")" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +} + +@test "gh: api input redirected onto stdin from a file is inspected" { + have_python3 + tmp="$(mktemp -d)" + printf '{"body": "A review body that was\\nwrapped across two lines."}\n' > "$tmp/review.json" + payload="$(bash_payload "gh api --method POST /repos/o/r/pulls/1/reviews --input - < $tmp/review.json")" + rc="$(hook_rc "$payload")" + rm -rf "$tmp" + [ "$rc" = "2" ] +}