Skip to main content

rto_graph/
authorship.rs

1//! Who wrote the change under review, read from its commit trailers — so a
2//! reviewer can say when it is reviewing its own output (issue #649, part 3).
3//!
4//! A model reviewing code it wrote is the weakest possible reviewer: it shares
5//! the blind spot that produced the defect. The authorship is already recorded,
6//! because commits made by an agent harness carry a trailer:
7//!
8//! ```text
9//! Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10//! ```
11//!
12//! This module is the pure half — parse the trailers, decide whether one names
13//! the model that is about to review. Reading a commit range and printing a
14//! warning belong to the binary, where git and the engine already are.
15//!
16//! # Warn, never refuse
17//!
18//! Settled by the owner on 27 Aug 2026, and the reason matters for what is here:
19//! a same-model review is weakened, not worthless, and refusing would trade it
20//! for **no** review on exactly the machine most likely to have one model
21//! installed. So nothing in this module returns an error or a veto — the widest
22//! answer it gives is "these two names denote the same model", and the caller's
23//! only move is to say so.
24//!
25//! A commit with no `Co-Authored-By` — a human wrote it — yields no trailer, so
26//! no match, so no warning, and the review proceeds exactly as it did before.
27//! Human-authored changes are never harder to review than machine-authored ones,
28//! which was the thing to avoid.
29//!
30//! # Does the trailer name the model, or the harness that ran it?
31//!
32//! **The trailer is read as naming a model, and the comparison is model-to-model
33//! only.** That is a decision about what can be *compared*, not a claim about
34//! what harnesses write.
35//!
36//! The registry side is unambiguous: `ModelTask::Review` — named rather than
37//! linked, because the registry is behind `models` and this module is not —
38//! resolves to a model, never to a harness. So a model-to-harness comparison is
39//! a category error whichever way the trailer happens to be written, and there
40//! is no rule that could rescue it. The trailer side, by contrast, is free text
41//! with no schema — a harness may write its product name (`Claude Code`,
42//! `Cursor`, `Aider`) instead of the weights it ran, and nothing in the string
43//! says which it did. Any rule that *decided* "this one is a harness" would be
44//! guessing at the one thing the format does not record.
45//!
46//! So [`names_same_model`] attempts an identity match against the model name and
47//! gives up silently when it fails. A trailer naming a harness normalises onto no
48//! registry model, does not match, and produces nothing.
49//!
50//! ## Why a mismatch is the benign direction
51//!
52//! The two ways to be wrong are not symmetric.
53//!
54//! A **false negative** — a same-model review that goes unwarned — costs exactly
55//! what today costs, because today there is no warning at all. It is a feature
56//! that did not fire, not a regression.
57//!
58//! A **false positive** — warning that the reviewer wrote the change when it
59//! demonstrably did not — is worse than it looks, because the alternative rule
60//! that would "never miss" is *warn whenever any AI trailer is present*. On this
61//! repository, whose commits are Claude-authored and whose local reviewer is a
62//! Qwen GGUF, that rule fires on every single run while being false every single
63//! time. A warning that is always on is a warning nobody reads, and it would take
64//! the true ones down with it.
65//!
66//! Hence exact identity or silence. [`identity_tokens`] absorbs the spelling
67//! differences that are certainly not identity differences — case, the separators
68//! between a family and its size, and a bracketed qualifier like `(1M context)`,
69//! which describes how a model was *configured* rather than which model it is —
70//! and nothing else.
71
72/// One `Co-Authored-By` trailer.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CoAuthor {
75    /// The display name, exactly as the trailer wrote it — `Claude Opus 5 (1M
76    /// context)`. Kept verbatim so a warning can quote what it actually read
77    /// rather than a normalised form the reader would not find in `git log`.
78    pub name: String,
79    /// The address inside the angle brackets, or the empty string when the
80    /// trailer carried none.
81    pub email: String,
82}
83
84/// Every `Co-Authored-By` trailer in one commit message, in the order it appears.
85///
86/// Deliberately not a full RFC-822-ish trailer parser: this reads the one key it
87/// needs, case-insensitively, from any line of the message. Git's own trailer
88/// rules require the block to be at the end and unbroken, and a message that
89/// slightly violates them still records who wrote the code — which is the fact
90/// wanted here, not a syntactic verdict about the commit.
91///
92/// Duplicates are kept. Two identical trailers are a fact about the message, and
93/// the caller de-duplicates across a range where that is what it wants.
94#[must_use]
95pub fn co_authors(message: &str) -> Vec<CoAuthor> {
96    const KEY: &str = "co-authored-by:";
97    let mut out = Vec::new();
98    for line in message.lines() {
99        let line = line.trim();
100        let Some(head) = line.get(..KEY.len()) else {
101            continue;
102        };
103        if !head.eq_ignore_ascii_case(KEY) {
104            continue;
105        }
106        let value = line[KEY.len()..].trim();
107        // The address is the bracketed tail, if there is one. A trailer with no
108        // `<...>` is still an authorship claim and is kept with an empty email
109        // rather than dropped — the name is the half this module compares.
110        let (name, email) = match value.rfind('<') {
111            Some(at) => {
112                let email = value[at + 1..].trim_end().trim_end_matches('>');
113                (value[..at].trim(), email.trim())
114            }
115            None => (value, ""),
116        };
117        if name.is_empty() && email.is_empty() {
118            continue;
119        }
120        out.push(CoAuthor {
121            name: name.to_owned(),
122            email: email.to_owned(),
123        });
124    }
125    out
126}
127
128/// Normalise a model or trailer name to the tokens that identify it.
129///
130/// Lowercased and split on **every non-alphanumeric character**, so
131/// `claude-opus-5`, `claude_opus_5`, `claude.opus.5` and `Claude Opus 5` all
132/// reach the same answer; and with any bracketed span dropped — `(…)` **and**
133/// `[…]` alike, since a qualifier is written both ways. `(1M context)` says how a
134/// model was *configured*, not which model it is, and two runs of one model at
135/// two context sizes are the same weights with the same blind spot.
136///
137/// Nothing else is removed, and nothing is added. This absorbs spelling, never
138/// meaning — see the module docs for why the rule stops here.
139#[must_use]
140pub fn identity_tokens(name: &str) -> Vec<String> {
141    let mut out = Vec::new();
142    let mut current = String::new();
143    let mut depth = 0usize;
144    for ch in name.chars() {
145        match ch {
146            '(' | '[' => {
147                depth += 1;
148                continue;
149            }
150            ')' | ']' => {
151                depth = depth.saturating_sub(1);
152                continue;
153            }
154            _ => {}
155        }
156        if depth > 0 {
157            continue;
158        }
159        if ch.is_alphanumeric() {
160            current.extend(ch.to_lowercase());
161        } else if !current.is_empty() {
162            out.push(std::mem::take(&mut current));
163        }
164    }
165    if !current.is_empty() {
166        out.push(current);
167    }
168    out
169}
170
171/// Whether a trailer's `name` denotes the same model as `model`.
172///
173/// Exact equality of the [`identity_tokens`] sequence, and nothing looser. A
174/// trailer that names a harness, a person, or a different model returns `false`,
175/// which the caller renders as silence — see the module docs for why that is the
176/// direction to fail in.
177#[must_use]
178pub fn names_same_model(name: &str, model: &str) -> bool {
179    let left = identity_tokens(name);
180    // An empty token list matches nothing, including another empty one: two names
181    // that normalise to nothing are not evidence that they are the same model.
182    !left.is_empty() && left == identity_tokens(model)
183}
184
185/// How much of a commit range the reviewing model wrote — see
186/// [`reviewers_own_work`].
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct OwnWork {
189    /// How many of the messages carry at least one matching trailer.
190    ///
191    /// Counted separately from [`OwnWork::names`] because the two answer
192    /// different questions and are easy to conflate into a sentence that is
193    /// quietly false: one model spelled two ways across nine commits is two names
194    /// and nine commits, and a warning that says "2 commits" would be wrong about
195    /// the only number a reader would act on.
196    pub commits: usize,
197    /// The matching trailer names as written, de-duplicated, first-seen order —
198    /// so a warning quotes what `git log` would show rather than a normalised
199    /// form the reader could not search for.
200    pub names: Vec<String>,
201}
202
203impl OwnWork {
204    /// Whether the reviewing model wrote any of the range.
205    #[must_use]
206    pub fn is_empty(&self) -> bool {
207        self.commits == 0
208    }
209}
210
211/// Which commits in a range `model` co-authored, by its trailers.
212///
213/// A default [`OwnWork`] means no commit in the range was written by this model,
214/// which is the ordinary case and is never an error: see the module docs for why
215/// silence is the direction this fails in.
216#[must_use]
217pub fn reviewers_own_work(messages: &[String], model: &str) -> OwnWork {
218    let mut out = OwnWork::default();
219    for message in messages {
220        let mut matched = false;
221        for author in co_authors(message) {
222            if !names_same_model(&author.name, model) {
223                continue;
224            }
225            matched = true;
226            if !out.names.contains(&author.name) {
227                out.names.push(author.name);
228            }
229        }
230        out.commits += usize::from(matched);
231    }
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{co_authors, identity_tokens, names_same_model, reviewers_own_work};
238
239    #[test]
240    fn a_trailer_is_read_from_a_real_commit_message() {
241        let message = "feat(review): do the thing\n\
242                       \n\
243                       A body paragraph.\n\
244                       \n\
245                       Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>\n\
246                       Claude-Session: https://example.invalid/s\n";
247        let authors = co_authors(message);
248        assert_eq!(authors.len(), 1);
249        assert_eq!(authors[0].name, "Claude Opus 5 (1M context)");
250        assert_eq!(authors[0].email, "noreply@anthropic.com");
251    }
252
253    #[test]
254    fn the_key_is_matched_case_insensitively_and_a_missing_address_is_kept() {
255        let authors = co_authors("x\n\nco-authored-by: qwen3-8b\nCO-AUTHORED-BY: A B <a@b>\n");
256        assert_eq!(authors.len(), 2);
257        assert_eq!(authors[0].name, "qwen3-8b");
258        assert_eq!(
259            authors[0].email, "",
260            "a trailer with no address is still an authorship claim"
261        );
262        assert_eq!(authors[1].email, "a@b");
263    }
264
265    #[test]
266    fn a_human_commit_carries_no_trailer_and_therefore_no_match() {
267        // The case that must never become a reason to refuse: a human-authored
268        // commit yields nothing, so nothing is warned about and the review
269        // proceeds exactly as it did before.
270        let message = "fix: correct the off-by-one\n\nNoticed while reading.\n";
271        assert!(co_authors(message).is_empty());
272        assert!(
273            reviewers_own_work(&[message.to_owned()], "qwen3-8b").is_empty(),
274            "a human-authored commit must never be harder to review"
275        );
276    }
277
278    #[test]
279    fn spelling_differences_that_are_not_identity_differences_are_absorbed() {
280        assert_eq!(
281            identity_tokens("Claude Opus 5 (1M context)"),
282            ["claude", "opus", "5"]
283        );
284        assert_eq!(identity_tokens("claude-opus-5"), ["claude", "opus", "5"]);
285        assert_eq!(identity_tokens("qwen3.8-27b"), ["qwen3", "8", "27b"]);
286        assert!(names_same_model(
287            "Claude Opus 5 (1M context)",
288            "claude-opus-5"
289        ));
290        assert!(
291            names_same_model("QWEN3_8B", "qwen3-8b"),
292            "case and separator are spelling, not identity"
293        );
294    }
295
296    /// The context qualifier is dropped **because it is a configuration, not an
297    /// identity**: the same weights at two window sizes carry the same blind spot,
298    /// which is the whole reason the warning exists.
299    ///
300    /// Both bracket forms, and every separator — the doc comment claims all of
301    /// this, and a claim about normalisation that nothing checks is the
302    /// contract-drift class this reviewer exists to find. Caught as a suppressed
303    /// finding in review of #649, where the docs said "parenthesised" and
304    /// "`-`, `_`, `.` and `/`" while the code took `[…]` and every
305    /// non-alphanumeric character.
306    #[test]
307    fn a_bracketed_qualifier_does_not_make_it_a_different_model() {
308        assert!(names_same_model(
309            "Claude Opus 5 (1M context)",
310            "Claude Opus 5"
311        ));
312        assert!(names_same_model(
313            "Claude Opus 5 (200K context)",
314            "claude opus 5"
315        ));
316        assert!(
317            names_same_model("Claude Opus 5 [1M context]", "claude-opus-5"),
318            "square brackets are dropped too, exactly as the doc comment says"
319        );
320        assert_eq!(
321            identity_tokens("Claude Opus 5 [1M context]"),
322            ["claude", "opus", "5"]
323        );
324        // Every non-alphanumeric character separates, not only the four the doc
325        // comment used to list.
326        for spelling in [
327            "claude-opus-5",
328            "claude_opus_5",
329            "claude.opus.5",
330            "claude/opus/5",
331            "claude:opus:5",
332            "claude+opus+5",
333        ] {
334            assert_eq!(
335                identity_tokens(spelling),
336                ["claude", "opus", "5"],
337                "{spelling} must normalise like every other spelling"
338            );
339        }
340    }
341
342    /// A harness name normalises onto no model name, so it does not match, so
343    /// nothing is printed. This is the mismatch case being benign by
344    /// construction rather than by intention.
345    #[test]
346    fn a_harness_name_matches_no_model_and_is_therefore_silent() {
347        for harness in ["Claude Code", "Cursor", "Aider", "GitHub Copilot"] {
348            assert!(
349                !names_same_model(harness, "claude-opus-5"),
350                "{harness} names a harness, and a harness is not a model"
351            );
352            assert!(!names_same_model(harness, "qwen3-8b"));
353        }
354    }
355
356    /// The realistic negative on this repository, asserted so the feature's
357    /// *silence* here is a measured fact rather than an assumption: commits are
358    /// Claude-authored and the local reviewer is a Qwen GGUF, so nothing fires.
359    #[test]
360    fn a_different_model_is_never_reported_as_the_same_one() {
361        let message = "x\n\nCo-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>\n";
362        assert!(reviewers_own_work(&[message.to_owned()], "qwen3-8b").is_empty());
363        assert!(reviewers_own_work(&[message.to_owned()], "qwen3.8-27b").is_empty());
364    }
365
366    /// The two counts are separate because a sentence that conflates them is
367    /// quietly false: one model spelled two ways over three commits is **two**
368    /// names and **three** commits, and the commit count is the number a reader
369    /// would act on.
370    #[test]
371    fn distinct_names_and_matching_commits_are_counted_separately() {
372        let one = "a\n\nCo-Authored-By: Qwen3 8B <x@y>\n".to_owned();
373        let two = "b\n\nCo-Authored-By: Qwen3 8B <x@y>\n".to_owned();
374        let three = "c\n\nCo-Authored-By: qwen3-8b <x@y>\n".to_owned();
375        let human = "d\n\nnobody else\n".to_owned();
376        let hit = reviewers_own_work(&[one, two, three, human], "qwen3-8b");
377        assert_eq!(
378            hit.names,
379            vec!["Qwen3 8B".to_owned(), "qwen3-8b".to_owned()],
380            "quoted as written, de-duplicated, first-seen order"
381        );
382        assert_eq!(
383            hit.commits, 3,
384            "three commits matched; the fourth is human-authored"
385        );
386    }
387
388    /// One commit naming the model twice is still one commit.
389    #[test]
390    fn a_commit_is_counted_once_however_many_trailers_it_carries() {
391        let message = "a\n\nCo-Authored-By: Qwen3 8B <x@y>\nCo-Authored-By: qwen3-8b <x@y>\n";
392        let hit = reviewers_own_work(&[message.to_owned()], "qwen3-8b");
393        assert_eq!(hit.commits, 1);
394        assert_eq!(hit.names.len(), 2);
395    }
396
397    #[test]
398    fn a_name_that_normalises_to_nothing_matches_nothing() {
399        assert!(
400            !names_same_model("", ""),
401            "two names that say nothing are not evidence of the same model"
402        );
403        assert!(!names_same_model("(1M context)", ""));
404    }
405}