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