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