#!/usr/bin/env bash # Reject commit messages that break the attribution rules in # docs/agents/contributing.md, "AI Agent Attribution": # # - an attribution trailer (`Assisted-by:`, key matched case-insensitively) # carries the value `LLM` and nothing else, and appears at most once; # - no trailer names an assistant session, and no message links to one; # - no line is a generation byline naming the tool. # # All three are review blockers decided by the text alone, which is exactly the # kind of thing a human should not be spending a review round on. # # Two shapes from that section are left to the reviewer, each for a measured # reason rather than a guessed one: # # - a model named in a `Co-authored-by:` line needs a list of model names to # tell from a person, and a list either misses the model released next month # or fires on somebody's colleague. Most of the 771 `Co-authored-by:` lines # on main name a model, so the list would decide a lot of pull requests; # - a `Generated-by:` trailer has never appeared here at all, and a rule with # no subject is a rule nobody maintains. # # A check that fires on something legitimate gets switched off, and then it # guards nothing, so every rule above was run over the whole of main before it # was written down — 6062 commits at the time, 1300 rejected, every one of them # a true positive. # # Lines are read one at a time rather than through git's trailer parser, which # reads only the last paragraph. A bad trailer with a paragraph after it is # invisible to that parser and caught here. The cost is that a message quoting # a rejected form as an example is rejected too. # # Usage: hack/check-commit-trailers.sh e.g. origin/main..HEAD # # Every commit in the range is checked as written; nothing is required of a # commit, so a message with no trailer at all — a merge commit among them — # passes. Only a trailer that exists and is wrong fails. An empty range fails: # a check that examined nothing must not read as one that found nothing wrong. set -euo pipefail RANGE="${1:-}" if [ -z "$RANGE" ]; then echo "usage: hack/check-commit-trailers.sh " >&2 exit 2 fi # Hosts that serve transcripts rather than documentation. Of the three, only # claude.ai has turned up in a commit message here so far, in two of them; the # others are the same shape and cost nothing to carry. A vendor that also serves # docs and pricing from its main domain is left out on purpose: a link to a # manual is not a session, and a false red on a citation is a red nobody can # argue with. The general shape is covered by the trailer key instead (any # `*-Session:` key), which is how a session gets recorded in the first place. # # Matched as a URL only, and on a whole host: prose naming a host reads as # prose, and notchatgpt.com and chatgpt.com.evil belong to somebody else. The # closing class excludes a dot, so a bare host at the end of a sentence # ("see https://claude.ai.") is not matched — the cost of keeping the lookalike # hole shut. A real session link carries a path and matches on the slash. # Lowercase, because the line is lowercased before the match. The optional # leading group is userinfo, so https://user@claude.ai/x is still claude.ai; # the closing class excludes "@" so that https://chatgpt.com@somewhere.example/x, # whose actual host is somewhere.example, is not. SESSION_URL='https?://([^/@[:space:]]*@)?([a-z0-9-]+\\.)*(claude\\.ai|chatgpt\\.com|chat\\.openai\\.com)([^a-z0-9.@-]|$)' # Resolved up front rather than inside the `for` list: a command substitution # there is not a command `set -e` looks at, so an unresolvable range would walk # zero commits and report success. SHAS="$(git rev-list "$RANGE")" # An empty range is not a pass. The pre-push recipe in docs/agents/contributing.md # resolves origin/main..HEAD against the working directory's HEAD, so an agent # whose shell sits in a checkout at or behind the base resolves it to nothing # here, and an "OK" over zero commits is a green nobody can tell from a real one. # (A checkout on an unrelated branch resolves to that branch's commits instead, # not to nothing — a wrong-branch check this guard does not claim to catch.) The CI # path stays sound: it builds the range from the merge base and the head SHA of a # pull request, which GitHub refuses to open with no commits between the two. It # does go empty once the base branch has absorbed the head's commits, but only # after the PR has nothing left to merge, where a red trailer check is harmless. if [ -z "$SHAS" ]; then echo "check-commit-trailers: range '$RANGE' is empty; no commits were checked" >&2 exit 1 fi bad=0 for sha in $SHAS; do findings="$( git log -1 --format=%B "$sha" | awk -v url="$SESSION_URL" ' { line = $0 sub(/\r$/, "", line) # git folds an indented line into the value of the trailer above it, so # "Assisted-by: LLM" followed by " anything" is the value LLM anything. if (folds && line ~ /^[ \t]+[^ \t]/) { print " " line "\n continues the attribution trailer above; the value must be exactly LLM" next } folds = 0 if (match(line, /^[^:]+:/)) { key = tolower(substr(line, 1, RLENGTH - 1)) # git accepts a space before the separator and reads the key without # it, so "Assisted-by : x" is the same trailer as "Assisted-by: x". sub(/[ \t]+$/, "", key) val = substr(line, RLENGTH + 1) sub(/^[ \t]+/, "", val) sub(/[ \t]+$/, "", val) if (key == "assisted-by") { seen++ folds = 1 if (val != "LLM") print " " line "\n attribution trailer value must be exactly LLM" } if (key ~ /-session$/) print " " line "\n session trailer is not accepted" } # Lowercased for the match because a scheme and a host are # case-insensitive; the line is printed as written. if (tolower(line) ~ url) print " " line "\n links to an assistant session" # A generation byline, as opposed to an ordinary sentence: the first # letters on the line are a capitalised "Generated with", after any # emoji. That is 24 lines on main, all of them the same tool byline, # and no false positive — "Regenerated with cozyvalues-gen" and a # sentence wrapping onto "generated with ..." both keep their small g. if (line ~ /^[^A-Za-z]*Generated with /) print " " line "\n generation byline names the tool; use one Assisted-by: LLM trailer" } END { if (seen > 1) print " " seen " attribution trailers; at most one is accepted" } ' )" if [ -n "$findings" ]; then bad=$((bad + 1)) git log -1 --format='%h %s' "$sha" printf '%s\n' "$findings" fi done if [ "$bad" -gt 0 ]; then cat >&2 <<'EOF' Rejected commits above. Attribution discloses that a model took part; it does not say which one, and it never carries a link to the session. The accepted form is one line, next to Signed-off-by: Assisted-by: LLM Reword with `git rebase --interactive` and force-push the branch. See docs/agents/contributing.md, "AI Agent Attribution". EOF exit 1 fi echo "Commit trailers OK: $(git rev-list --count "$RANGE") commit(s) checked."