crates/gunnar-policy/src/trailer.rs
//! Machine-attribution lines in a commit message: the needles the
//! [`AiTrailerGate`](crate::gates::AiTrailerGate) refuses.
//!
//! # What counts, and why it is so narrow
//!
//! A line counts when, after trimming surrounding whitespace and ignoring
//! ASCII case, it is one of:
//!
//! * a `Co-Authored-By:` trailer whose value names `claude` or `anthropic`
//! (which covers `noreply@anthropic.com`);
//! * a `Generated with Claude…` / `Generated with [Claude…` line, optionally
//! led by the robot emoji that tooling puts in front of it.
//!
//! The match is **anchored at the start of the line**. That is the difference
//! from `cargo xtask trailer-guard`'s substring match, and it is deliberate:
//! that tool only *reports*, so a false positive costs a glance, while this
//! matcher *refuses a push*, so a false positive costs somebody a history
//! rewrite for having written a sentence. A commit message that mentions the
//! tool in prose — "switch the review bot to Claude", "no Co-Authored-By
//! trailers, ever", "the notes were generated with claude's help" in the
//! middle of a paragraph — is not a trailer and must pass. The xtask's own
//! false-positive corpus is replayed against this matcher in
//! `tests/ai_trailers.rs`, so the two cannot drift apart silently on the
//! sentences that matter.
/// The robot emoji tooling writes in front of its "Generated with" line.
const ROBOT: &str = "\u{1f916}";
/// One offending line: its 1-based number in the message, and the line itself
/// as written (trimmed), for the refusal text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttributionLine {
/// 1-based line number within the commit message.
pub line: usize,
/// The line, trimmed, exactly as the author wrote it.
pub text: String,
}
/// `true` if `line` is a machine-attribution line. See the module docs.
pub fn is_ai_attribution(line: &str) -> bool {
let lower = line.trim().to_lowercase();
if let Some(value) = lower.strip_prefix("co-authored-by:") {
return value.contains("claude") || value.contains("anthropic");
}
let rest = lower
.strip_prefix(ROBOT)
.map(str::trim_start)
.unwrap_or(&lower);
rest.starts_with("generated with claude") || rest.starts_with("generated with [claude")
}
/// Every machine-attribution line in a commit **message** (not the header
/// block — the caller hands over the message only).
///
/// Invalid UTF-8 is read lossily: a needle is ASCII (bar the emoji, which is
/// only an optional prefix), so replacement characters elsewhere cannot hide
/// or invent one.
pub fn ai_attribution_lines(message: &[u8]) -> Vec<AttributionLine> {
String::from_utf8_lossy(message)
.lines()
.enumerate()
.filter(|(_, line)| is_ai_attribution(line))
.map(|(index, line)| AttributionLine {
line: index + 1,
text: line.trim().to_owned(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_trailer_shapes_are_found() {
for hit in [
"Co-Authored-By: Claude <noreply@anthropic.com>",
"co-authored-by: claude opus <noreply@anthropic.com>",
"CO-AUTHORED-BY: CLAUDE",
"Co-authored-by: Some Bot <noreply@anthropic.com>",
"Co-Authored-By: Anthropic",
" Co-Authored-By: Claude ",
"\u{1f916} Generated with [Claude Code](https://claude.com/claude-code)",
"Generated with Claude Code",
"generated with [claude code]",
] {
assert!(is_ai_attribution(hit), "missed {hit:?}");
}
}
#[test]
fn prose_and_human_coauthors_are_not_hits() {
for ok in [
"Co-Authored-By: Anna Svensson <anna@x14.se>",
"No Co-Authored-By trailers, ever.",
"Switch the review bot to Claude and bump the Anthropic SDK.",
"The summary was generated with Claude in mind, see notes.",
"This verb reports trailers; it never rewrites history.",
"noreply@anthropic.com is the address the tooling uses",
] {
assert!(!is_ai_attribution(ok), "false positive on {ok:?}");
}
}
#[test]
fn line_numbers_are_one_based_within_the_message() {
let found = ai_attribution_lines(
b"Subject\n\nBody.\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].line, 5);
assert_eq!(
found[0].text,
"Co-Authored-By: Claude <noreply@anthropic.com>"
);
}
}