Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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>"
        );
    }
}