Skip to main content

release_kit/commands/
message.rs

1//! `rk message`: the content guards over a commit message, a title, or a
2//! request body.
3//!
4//! Two classes of finding are declared in `blocks/message-guards`:
5//! attribution an agent left in the text, and a reference to a path the
6//! target repository ignores — an internal artifact that would leak into
7//! the permanent record. The release bot's request is exempt from the
8//! attribution class alone, recognized by exactly the title shape the
9//! landed title check admits for the bot; the ignored-path class always
10//! runs. The guard file owns the patterns; the matchers here implement
11//! them by hand — the binary carries no regex engine — and a unit test
12//! pins the file to the set the matchers cover, so a pattern edit and its
13//! matcher move together.
14//!
15//! A third class is a shape rather than a forbidden pattern, so it lives
16//! here and not in the guard file: a subject whose scope falls outside
17//! [`landing::SCOPE_SHAPE`]. The landed `conventional-pre-commit` hook
18//! requires a scope but takes no pattern for it, so without this class a
19//! scope the forge's title check rejects passes at the desk.
20
21use std::io::Read as _;
22use std::io::Write as _;
23
24use camino::Utf8Path;
25use serde::Serialize;
26
27use crate::cli::message::{MessageArgs, MessageKind};
28use crate::diagnostic::{Diagnostic, Reason};
29use crate::error::RkError;
30use crate::landing;
31use crate::output::Output;
32
33/// The guard patterns, verbatim: `blocks/message-guards`.
34static GUARDS: &str = include_str!("../../blocks/message-guards");
35
36/// One finding.
37#[derive(Debug, Serialize)]
38struct Finding {
39    /// `attribution`, `internal-path`, or `scope-shape`.
40    class: &'static str,
41    /// The 1-based line the finding sits on.
42    line: usize,
43    /// What matched.
44    detail: String,
45}
46
47/// The machine form of a report.
48#[derive(Debug, Serialize)]
49struct Report {
50    /// The shape version of this document.
51    schema: &'static str,
52    /// What the text was judged as.
53    kind: &'static str,
54    /// Whether the bot exemption applied to the attribution class.
55    exempt: bool,
56    /// Every finding, in line order.
57    findings: Vec<Finding>,
58}
59
60/// Judge the text and report; exit 1 under `--check` when a finding stands.
61///
62/// # Errors
63///
64/// Returns [`RkError::CheckFailed`] under `--check` when any finding
65/// stands, [`RkError::Io`] when the input cannot be read, and an error
66/// when the report cannot serialize.
67pub fn run(args: &MessageArgs) -> Result<(), RkError> {
68    let text = read_input(args.file.as_deref().map(camino::Utf8Path::as_str))?;
69    let out = Output::new(args.json);
70
71    let title = match args.kind {
72        MessageKind::Commit | MessageKind::Title => text.lines().next().unwrap_or(""),
73        MessageKind::Body => args.title.as_deref().unwrap_or(""),
74    };
75    let exempt = bot_title(title);
76
77    let mut findings = Vec::new();
78    for (index, line) in text.lines().enumerate() {
79        if !exempt {
80            findings.extend(attribution_hits(line).into_iter().map(|detail| Finding {
81                class: "attribution",
82                line: index + 1,
83                detail,
84            }));
85        }
86    }
87    // The subject is line 1 of a commit message and of a title; a body's
88    // title is context passed beside it, and its findings would carry no
89    // line the reader can open.
90    if matches!(args.kind, MessageKind::Commit | MessageKind::Title)
91        && !exempt
92        && let Some(scope) = misshapen_scope(title)
93    {
94        findings.push(Finding {
95            class: "scope-shape",
96            line: 1,
97            detail: format!(
98                "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
99                landing::SCOPE_SHAPE
100            ),
101        });
102    }
103    let mut seen: std::collections::BTreeSet<(usize, String)> = std::collections::BTreeSet::new();
104    match ignored_paths(&args.target, &text) {
105        IgnoreJudgment::Repo(hits) => {
106            for (line, token) in hits {
107                findings.push(Finding {
108                    class: "internal-path",
109                    line,
110                    detail: format!("{token} is git-ignored in {}", args.target),
111                });
112                seen.insert((line, token));
113            }
114        }
115        IgnoreJudgment::NoRepo => {
116            if !args.json {
117                out.warn(format!(
118                    "{} is not a git repository; only the fixed .draft/ pattern was tested",
119                    args.target
120                ));
121            }
122        }
123    }
124    // A fixed-pattern fragment already inside a reported token on the
125    // same line — `nested/.draft/plan.md` carrying `.draft/plan.md` — is
126    // the same reference, not a second finding.
127    findings.extend(
128        fixed_draft_hits(&text)
129            .into_iter()
130            .filter(|(line, fragment)| {
131                !seen
132                    .iter()
133                    .any(|(seen_line, token)| seen_line == line && token.contains(fragment))
134            })
135            .map(|(line, fragment)| Finding {
136                class: "internal-path",
137                line,
138                detail: format!("{fragment} references the internal .draft/ tree"),
139            }),
140    );
141    findings.sort_by_key(|finding| finding.line);
142
143    if exempt {
144        out.result_line("exempt: the release bot's request, by its title");
145    }
146    for finding in &findings {
147        out.result_line(format!(
148            "{}:{} {}",
149            finding.class, finding.line, finding.detail
150        ));
151    }
152    if findings.is_empty() {
153        out.result_line(format!("clean {}", args.kind.as_str()));
154    }
155    let count = findings.len();
156    out.emit(&Report {
157        schema: "rk.message/2",
158        kind: args.kind.as_str(),
159        exempt,
160        findings,
161    })?;
162
163    if args.check && count > 0 {
164        return Err(RkError::check_failed(
165            Diagnostic::new(
166                Reason::StateDrift,
167                format!(
168                    "the {} carries {count} finding{}",
169                    args.kind.as_str(),
170                    if count == 1 { "" } else { "s" }
171                ),
172            )
173            .expected("no agent attribution, no reference to a git-ignored path, and a scope the title check admits")
174            .action("reword the text; the findings above name each line"),
175        ));
176    }
177    Ok(())
178}
179
180/// The text: stdin for `-` or no file, the file otherwise.
181fn read_input(file: Option<&str>) -> Result<String, RkError> {
182    match file {
183        None | Some("-") => {
184            let mut text = String::new();
185            std::io::stdin().read_to_string(&mut text)?;
186            Ok(text)
187        }
188        Some(path) => Ok(std::fs::read_to_string(path)?),
189    }
190}
191
192/// Whether the title is the release bot's, by exactly the shape the
193/// landed title check admits for it:
194/// `^chore(\((release|master|main)\))?: (release|v).+$`.
195fn bot_title(title: &str) -> bool {
196    let Some(rest) = title.strip_prefix("chore") else {
197        return false;
198    };
199    let rest = if rest.starts_with('(') {
200        let Some(rest) = ["(release)", "(master)", "(main)"]
201            .iter()
202            .find_map(|scope| rest.strip_prefix(scope))
203        else {
204            return false;
205        };
206        rest
207    } else {
208        rest
209    };
210    let Some(rest) = rest.strip_prefix(": ") else {
211        return false;
212    };
213    ["release", "v"].iter().any(|stem| {
214        rest.strip_suffix('\n')
215            .unwrap_or(rest)
216            .strip_prefix(stem)
217            .is_some_and(|tail| !tail.is_empty())
218    })
219}
220
221/// The subject's scope where it falls outside [`landing::SCOPE_SHAPE`],
222/// or `None`.
223///
224/// The scope is what sits between the parentheses of a `type(scope):`
225/// subject, with the breaking `!` outside them. A subject carrying no
226/// scope at all returns `None`: requiring one is the landed
227/// `conventional-pre-commit` hook's job, and this class judges the shape
228/// of a scope that is there.
229fn misshapen_scope(title: &str) -> Option<String> {
230    let (kind, rest) = title.split_once('(')?;
231    if kind.is_empty() || !kind.chars().all(|c| c.is_ascii_alphabetic()) {
232        return None;
233    }
234    let (scope, rest) = rest.split_once(')')?;
235    if !(rest.starts_with(':') || rest.starts_with("!:")) {
236        return None;
237    }
238    (!landing::scope_is_shaped(scope)).then(|| scope.to_owned())
239}
240
241/// Every attribution match on one line, as the matched fragment.
242///
243/// Each matcher implements one pattern of the guard file's attribution
244/// class by hand, exactly — each bracketed case class expands to its
245/// literal variants, never to a broader case-insensitive search — and
246/// [`guard_patterns`] with its test holds the two in step.
247fn attribution_hits(line: &str) -> Vec<String> {
248    let mut hits = Vec::new();
249    // [Gg]enerated with \[?Claude
250    if [
251        "Generated with Claude",
252        "generated with Claude",
253        "Generated with [Claude",
254        "generated with [Claude",
255    ]
256    .iter()
257    .any(|variant| line.contains(variant))
258    {
259        hits.push("generated-with-claude attribution".to_owned());
260    }
261    // 🤖 Generated with
262    if line.contains("🤖 Generated with") {
263        hits.push("robot generated-with attribution".to_owned());
264    }
265    // [Cc]o-[Aa]uthored-[Bb]y:.*([Cc]laude|[Cc]opilot|Codex|ChatGPT)
266    let trailer = [
267        "Co-Authored-By:",
268        "Co-Authored-by:",
269        "Co-authored-By:",
270        "Co-authored-by:",
271        "co-Authored-By:",
272        "co-Authored-by:",
273        "co-authored-By:",
274        "co-authored-by:",
275    ]
276    .iter()
277    .filter_map(|variant| line.find(variant))
278    .min();
279    if let Some(at) = trailer {
280        let tail = &line[at..];
281        if ["Claude", "claude", "Copilot", "copilot", "Codex", "ChatGPT"]
282            .iter()
283            .any(|agent| tail.contains(agent))
284        {
285            hits.push("agent co-authored-by trailer".to_owned());
286        }
287    }
288    // noreply@anthropic\.com
289    if line.contains("noreply@anthropic.com") {
290        hits.push("anthropic noreply address".to_owned());
291    }
292    hits
293}
294
295/// What the ignored-path check could determine.
296enum IgnoreJudgment {
297    /// The target is a repository; these `(line, token)` pairs are ignored.
298    Repo(Vec<(usize, String)>),
299    /// No repository at the target; only the fixed pattern judged.
300    NoRepo,
301}
302
303/// The path-shaped tokens of the text the target's ignore rules reject,
304/// degraded to no repository judgment where the target is none. The
305/// fixed `.draft/` pattern is not here: it runs unconditionally in
306/// [`fixed_draft_hits`], so a decorated reference no clean token carries
307/// still answers, repository or not.
308fn ignored_paths(target: &Utf8Path, text: &str) -> IgnoreJudgment {
309    let candidates: Vec<(usize, String)> = text
310        .lines()
311        .enumerate()
312        .flat_map(|(index, line)| {
313            line.split_whitespace()
314                .filter_map(path_token)
315                .map(move |token| (index + 1, token))
316        })
317        .collect();
318    if candidates.is_empty() {
319        return IgnoreJudgment::Repo(Vec::new());
320    }
321    check_ignore(target, &candidates).map_or(IgnoreJudgment::NoRepo, IgnoreJudgment::Repo)
322}
323
324/// Every `(line, fragment)` the fixed internal-path pattern matches:
325/// `(^|[^A-Za-z0-9])\.draft/`, implemented exactly — a `.draft/` whose
326/// preceding character, where one exists, is not ASCII-alphanumeric —
327/// with the fragment read forward to the surrounding whitespace and
328/// trimmed of trailing wrappers, so a decorated reference like
329/// `path=.draft/plan.md` or a markdown link still answers.
330fn fixed_draft_hits(text: &str) -> Vec<(usize, String)> {
331    let mut hits = Vec::new();
332    for (index, line) in text.lines().enumerate() {
333        for (at, _) in line.match_indices(".draft/") {
334            let boundary = line[..at]
335                .chars()
336                .next_back()
337                .is_none_or(|c| !c.is_ascii_alphanumeric());
338            if !boundary {
339                continue;
340            }
341            let tail = &line[at..];
342            let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
343            let fragment = tail[..end].trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
344            hits.push((index + 1, fragment.to_owned()));
345        }
346    }
347    hits
348}
349
350/// A whitespace token reduced to its path candidate: wrapping brackets
351/// and quotes trimmed from both edges, sentence punctuation only from the
352/// end — a leading dot is part of a hidden path — URLs, flags, and
353/// variables skipped, and only a token of two or more segments kept,
354/// because a bare word is prose, not a reference.
355fn path_token(token: &str) -> Option<String> {
356    let token = token
357        .trim_start_matches(|c: char| "()[]<>`'\"".contains(c))
358        .trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
359    if token.contains("://") || token.starts_with('-') || token.contains('$') {
360        return None;
361    }
362    let (head, tail) = token.split_once('/')?;
363    if head.is_empty() || tail.is_empty() {
364        return None;
365    }
366    Some(token.to_owned())
367}
368
369/// The candidates the target's git ignores, or `None` where the target is
370/// not a repository the check could consult.
371///
372/// `-z` on both sides: input and output are NUL-delimited, so a non-ASCII
373/// path comes back verbatim rather than `core.quotePath`-escaped and the
374/// byte comparison holds. The writer is its own thread, because git may
375/// fill its stdout pipe while this process is still writing stdin — the
376/// buffering deadlock its documentation assigns the caller.
377fn check_ignore(target: &Utf8Path, candidates: &[(usize, String)]) -> Option<Vec<(usize, String)>> {
378    let mut command = std::process::Command::new(crate::probes::git_bin());
379    let mut child = command
380        .arg("-C")
381        .arg(target.as_std_path())
382        .args(["check-ignore", "--stdin", "-z"])
383        .stdin(std::process::Stdio::piped())
384        .stdout(std::process::Stdio::piped())
385        .stderr(std::process::Stdio::null())
386        .spawn()
387        .ok()?;
388    let writer = child.stdin.take().map(|mut stdin| {
389        let payload: Vec<u8> = candidates
390            .iter()
391            .flat_map(|(_, token)| token.as_bytes().iter().copied().chain([0]))
392            .collect();
393        std::thread::spawn(move || {
394            let _ = stdin.write_all(&payload);
395        })
396    });
397    let output = child.wait_with_output().ok()?;
398    if let Some(writer) = writer {
399        let _ = writer.join();
400    }
401    // 0: some input is ignored; 1: none is. Anything else — 128 for a
402    // missing repository above all — is a target the check cannot judge.
403    if !matches!(output.status.code(), Some(0 | 1)) {
404        return None;
405    }
406    let ignored: std::collections::BTreeSet<&[u8]> = output
407        .stdout
408        .split(|byte| *byte == 0)
409        .filter(|path| !path.is_empty())
410        .collect();
411    Some(
412        candidates
413            .iter()
414            .filter(|(_, token)| ignored.contains(token.as_bytes()))
415            .cloned()
416            .collect(),
417    )
418}
419
420/// The guard file's `(class, pattern)` lines, in order.
421#[must_use]
422pub fn guard_patterns() -> Vec<(&'static str, &'static str)> {
423    let mut class = "";
424    let mut patterns = Vec::new();
425    for line in GUARDS.lines() {
426        if let Some(named) = line.strip_prefix("# class: ") {
427            class = named;
428        } else if !line.starts_with('#') && !line.is_empty() {
429            patterns.push((class, line));
430        }
431    }
432    patterns
433}
434
435#[cfg(test)]
436mod tests {
437    use super::{
438        Finding, Report, attribution_hits, bot_title, fixed_draft_hits, guard_patterns, path_token,
439    };
440
441    /// The complete `rk.message/2` shape, held by snapshot: the class
442    /// vocabulary is part of the contract, so the third class moved the
443    /// version rather than arriving unannounced.
444    #[test]
445    fn the_message_schema_snapshot_holds() {
446        let report = Report {
447            schema: "rk.message/2",
448            kind: "commit",
449            exempt: false,
450            findings: vec![
451                Finding {
452                    class: "scope-shape",
453                    line: 1,
454                    detail: "the scope 'Specs Ugly' is outside [a-z0-9._/-]+: lowercase letters, digits, and _ . / -".into(),
455                },
456                Finding {
457                    class: "internal-path",
458                    line: 3,
459                    detail: ".draft/plan.md is git-ignored in .".into(),
460                },
461            ],
462        };
463        assert_eq!(
464            serde_json::to_string(&report).expect("a report serializes"),
465            r#"{"schema":"rk.message/2","kind":"commit","exempt":false,"findings":[{"class":"scope-shape","line":1,"detail":"the scope 'Specs Ugly' is outside [a-z0-9._/-]+: lowercase letters, digits, and _ . / -"},{"class":"internal-path","line":3,"detail":".draft/plan.md is git-ignored in ."}]}"#
466        );
467    }
468
469    /// The guard file and the hand matchers move together: this is the
470    /// exact pattern set the matchers above implement, so an edit to
471    /// `blocks/message-guards` fails here until the matcher follows.
472    #[test]
473    fn the_guard_file_holds_the_patterns_the_matchers_implement() {
474        assert_eq!(
475            guard_patterns(),
476            [
477                ("attribution", r"[Gg]enerated with \[?Claude"),
478                ("attribution", "🤖 Generated with"),
479                (
480                    "attribution",
481                    r"[Cc]o-[Aa]uthored-[Bb]y:.*([Cc]laude|[Cc]opilot|Codex|ChatGPT)"
482                ),
483                ("attribution", r"noreply@anthropic\.com"),
484                ("internal-path", r"(^|[^A-Za-z0-9])\.draft/"),
485            ]
486        );
487    }
488
489    #[test]
490    fn the_attribution_matchers_cover_the_patterns() {
491        for line in [
492            "Generated with Claude Code",
493            "generated with [Claude Code](https://claude.com/claude-code)",
494            "🤖 Generated with tooling",
495            "Co-Authored-By: Claude <x@y>",
496            "co-authored-by: github-copilot",
497            "Co-authored-by: Codex",
498            "Co-Authored-By: ChatGPT",
499            "Signed noreply@anthropic.com",
500        ] {
501            assert!(!attribution_hits(line).is_empty(), "{line} must match");
502        }
503        for line in [
504            "Generated with release-plz",
505            "Co-authored-by: A Person <person@example.com>",
506            "the claude skill route",
507            "Co-authored-by: Autopilot Team",
508            "Xenerated with Claude",
509            "CO-AUTHORED-BY: Claude",
510        ] {
511            assert!(attribution_hits(line).is_empty(), "{line} must not match");
512        }
513    }
514
515    /// Case transformation never feeds an offset back into the original:
516    /// multibyte text before a trailer must match without panicking.
517    #[test]
518    fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
519        // Enough expanding characters that a lowercased offset would
520        // fall past the original string's end, not merely drift.
521        let line = format!("{} Co-Authored-By: Claude", "İ".repeat(40));
522        assert!(!attribution_hits(&line).is_empty());
523        assert!(attribution_hits(&format!("{} nothing here", "İ".repeat(40))).is_empty());
524    }
525
526    /// The fixed pattern is `(^|[^A-Za-z0-9])\.draft/`, exactly: a
527    /// decorated reference answers, an alphanumeric-adjacent one does not.
528    #[test]
529    fn the_fixed_pattern_matches_decorated_references_only_at_a_boundary() {
530        assert_eq!(
531            fixed_draft_hits(
532                "path=.draft/plan.md
533"
534            ),
535            vec![(1, ".draft/plan.md".to_owned())]
536        );
537        assert_eq!(
538            fixed_draft_hits(
539                "a [plan](.draft/plan.md) link
540"
541            ),
542            vec![(1, ".draft/plan.md".to_owned())]
543        );
544        assert_eq!(
545            fixed_draft_hits(
546                ".draft/x
547"
548            ),
549            vec![(1, ".draft/x".to_owned())]
550        );
551        assert!(
552            fixed_draft_hits(
553                "archived.draft/x
554"
555            )
556            .is_empty()
557        );
558        assert!(
559            fixed_draft_hits(
560                "no reference here
561"
562            )
563            .is_empty()
564        );
565    }
566
567    /// Exactly the landed title check's bot alternative:
568    /// `^chore(\((release|master|main)\))?: (release|v).+$`.
569    #[test]
570    fn the_bot_exemption_is_the_title_checks_bot_alternative() {
571        for title in [
572            "chore: release v0.2.6",
573            "chore(release): v0.3.0",
574            "chore(master): release 1.0.0",
575            "chore(main): v2",
576        ] {
577            assert!(bot_title(title), "{title} is the bot's");
578        }
579        for title in [
580            "chore: bump deps",
581            "chore(deps): release v1",
582            "feat(cli): release v1",
583            "chore(release): ",
584            "chore(release): v",
585            "chore:release v1",
586        ] {
587            assert!(!bot_title(title), "{title} is not the bot's");
588        }
589    }
590
591    #[test]
592    fn a_path_token_is_two_segments_without_url_flag_or_variable() {
593        assert_eq!(
594            path_token("(.draft/plan.md)"),
595            Some(".draft/plan.md".into())
596        );
597        assert_eq!(path_token("`src/main.rs`,"), Some("src/main.rs".into()));
598        assert_eq!(path_token("https://a.b/c"), None);
599        assert_eq!(path_token("--flag/value"), None);
600        assert_eq!(path_token("$HOME/x"), None);
601        assert_eq!(path_token("and/or"), Some("and/or".into()));
602        assert_eq!(path_token("word"), None);
603        assert_eq!(path_token("trailing/"), None);
604    }
605}