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