1use std::io::Read as _;
27use std::io::Write as _;
28
29use camino::Utf8Path;
30use serde::Serialize;
31
32use crate::cli::message::{MessageArgs, MessageKind};
33use crate::diagnostic::{Diagnostic, Reason};
34use crate::error::RkError;
35use crate::landing;
36use crate::output::Output;
37
38static GUARDS: &str = include_str!("../../blocks/message-guards");
40
41#[derive(Debug, Serialize)]
43pub(crate) struct Finding {
44 pub(crate) class: &'static str,
46 pub(crate) line: usize,
48 pub(crate) detail: String,
50}
51
52#[derive(Debug, Serialize)]
54struct Report {
55 schema: &'static str,
57 kind: &'static str,
59 exempt: bool,
61 findings: Vec<Finding>,
63}
64
65pub fn run(args: &MessageArgs) -> Result<(), RkError> {
73 let text = read_input(args.file.as_deref().map(camino::Utf8Path::as_str))?;
74 let out = Output::new(args.json);
75
76 let title = match args.kind {
77 MessageKind::Commit | MessageKind::Title => text.lines().next().unwrap_or(""),
78 MessageKind::Body => args.title.as_deref().unwrap_or(""),
79 };
80 let exempt = bot_title(title);
81 let judgment = Judgment {
82 exempt,
83 scope_required: scope_required(&args.target),
84 };
85
86 let (findings, no_repo) = judge(&text, args.kind, &args.target, title, judgment);
87 if no_repo && !args.json {
88 out.warn(format!(
89 "{} is not a git repository; only the fixed .draft/ pattern was tested",
90 args.target
91 ));
92 }
93
94 if exempt {
95 out.result_line("exempt: the release bot's request, by its title");
96 }
97 for finding in &findings {
98 out.result_line(format!(
99 "{}:{} {}",
100 finding.class, finding.line, finding.detail
101 ));
102 }
103 if findings.is_empty() {
104 out.result_line(format!("clean {}", args.kind.as_str()));
105 }
106 let count = findings.len();
107 out.emit(&Report {
108 schema: "rk.message/3",
109 kind: args.kind.as_str(),
110 exempt,
111 findings,
112 })?;
113
114 if args.check && count > 0 {
115 return Err(RkError::check_failed(
116 Diagnostic::new(
117 Reason::StateDrift,
118 format!(
119 "the {} carries {count} finding{}",
120 args.kind.as_str(),
121 if count == 1 { "" } else { "s" }
122 ),
123 )
124 .expected("no agent attribution, no reference to a git-ignored path, and a scope the title check admits")
125 .action("reword the text; the findings above name each line"),
126 ));
127 }
128 Ok(())
129}
130
131#[derive(Debug, Clone, Copy)]
133pub(crate) struct Judgment {
134 pub(crate) exempt: bool,
136 pub(crate) scope_required: bool,
139}
140
141fn scope_required(target: &Utf8Path) -> bool {
148 let mode = crate::landing::manifest::load(target)
149 .ok()
150 .flatten()
151 .map(|record| record.profile.release.mode)
152 .or_else(|| {
153 crate::config::load(target.as_std_path())
154 .ok()
155 .flatten()
156 .and_then(|config| config.profile.release.mode)
157 });
158 mode.is_some_and(|mode| mode != crate::profile::ReleaseMode::None)
159}
160
161pub(crate) fn judge(
169 text: &str,
170 kind: MessageKind,
171 target: &Utf8Path,
172 title: &str,
173 judgment: Judgment,
174) -> (Vec<Finding>, bool) {
175 let exempt = judgment.exempt;
176 let mut findings = Vec::new();
177 let mut no_repo = false;
178 for (index, line) in text.lines().enumerate() {
179 if !exempt {
180 findings.extend(attribution_hits(line).into_iter().map(|detail| Finding {
181 class: "attribution",
182 line: index + 1,
183 detail,
184 }));
185 }
186 }
187 if matches!(kind, MessageKind::Commit | MessageKind::Title)
191 && !exempt
192 && let Some(scope) = misshapen_scope(title)
193 {
194 findings.push(Finding {
195 class: "scope-shape",
196 line: 1,
197 detail: format!(
198 "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
199 landing::SCOPE_SHAPE
200 ),
201 });
202 }
203 if matches!(kind, MessageKind::Commit | MessageKind::Title)
204 && !exempt
205 && judgment.scope_required
206 && let Some(kind) = unscoped_type(title)
207 {
208 findings.push(Finding {
209 class: "scope-missing",
210 line: 1,
211 detail: format!(
212 "the subject names no scope after '{kind}', and this target names a release, so every subject reads <type>(<scope>): <description>"
213 ),
214 });
215 }
216 let mut seen: std::collections::BTreeSet<(usize, String)> = std::collections::BTreeSet::new();
217 match ignored_paths(target, text) {
218 IgnoreJudgment::Repo(hits) => {
219 for (line, token) in hits {
220 findings.push(Finding {
221 class: "internal-path",
222 line,
223 detail: format!("{token} is git-ignored in {target}"),
224 });
225 seen.insert((line, token));
226 }
227 }
228 IgnoreJudgment::NoRepo => no_repo = true,
229 }
230 findings.extend(
234 fixed_draft_hits(text)
235 .into_iter()
236 .filter(|(line, fragment)| {
237 !seen
238 .iter()
239 .any(|(seen_line, token)| seen_line == line && token.contains(fragment))
240 })
241 .map(|(line, fragment)| Finding {
242 class: "internal-path",
243 line,
244 detail: format!("{fragment} references the internal .draft/ tree"),
245 }),
246 );
247 findings.sort_by_key(|finding| finding.line);
248 (findings, no_repo)
249}
250
251pub(crate) fn exempt_title(title: &str) -> bool {
253 bot_title(title)
254}
255
256fn read_input(file: Option<&str>) -> Result<String, RkError> {
258 match file {
259 None | Some("-") => {
260 let mut text = String::new();
261 std::io::stdin().read_to_string(&mut text)?;
262 Ok(text)
263 }
264 Some(path) => Ok(std::fs::read_to_string(path)?),
265 }
266}
267
268fn bot_title(title: &str) -> bool {
272 let Some(rest) = title.strip_prefix("chore") else {
273 return false;
274 };
275 let rest = if rest.starts_with('(') {
276 let Some(rest) = ["(release)", "(master)", "(main)"]
277 .iter()
278 .find_map(|scope| rest.strip_prefix(scope))
279 else {
280 return false;
281 };
282 rest
283 } else {
284 rest
285 };
286 let Some(rest) = rest.strip_prefix(": ") else {
287 return false;
288 };
289 ["release", "v"].iter().any(|stem| {
290 rest.strip_suffix('\n')
291 .unwrap_or(rest)
292 .strip_prefix(stem)
293 .is_some_and(|tail| !tail.is_empty())
294 })
295}
296
297fn misshapen_scope(title: &str) -> Option<String> {
306 let (kind, rest) = title.split_once('(')?;
307 if kind.is_empty() || !kind.chars().all(|c| c.is_ascii_alphabetic()) {
308 return None;
309 }
310 let (scope, rest) = rest.split_once(')')?;
311 if !(rest.starts_with(':') || rest.starts_with("!:")) {
312 return None;
313 }
314 (!landing::scope_is_shaped(scope)).then(|| scope.to_owned())
315}
316
317fn unscoped_type(title: &str) -> Option<&str> {
322 let (head, _) = title.split_once(':')?;
323 let kind = head.strip_suffix('!').unwrap_or(head);
324 (!kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphabetic())).then_some(kind)
325}
326
327fn attribution_hits(line: &str) -> Vec<String> {
334 let mut hits = Vec::new();
335 if [
337 "Generated with Claude",
338 "generated with Claude",
339 "Generated with [Claude",
340 "generated with [Claude",
341 ]
342 .iter()
343 .any(|variant| line.contains(variant))
344 {
345 hits.push("generated-with-claude attribution".to_owned());
346 }
347 if line.contains("🤖 Generated with") {
349 hits.push("robot generated-with attribution".to_owned());
350 }
351 let trailer = [
353 "Co-Authored-By:",
354 "Co-Authored-by:",
355 "Co-authored-By:",
356 "Co-authored-by:",
357 "co-Authored-By:",
358 "co-Authored-by:",
359 "co-authored-By:",
360 "co-authored-by:",
361 ]
362 .iter()
363 .filter_map(|variant| line.find(variant))
364 .min();
365 if let Some(at) = trailer {
366 let tail = &line[at..];
367 if ["Claude", "claude", "Copilot", "copilot", "Codex", "ChatGPT"]
368 .iter()
369 .any(|agent| tail.contains(agent))
370 {
371 hits.push("agent co-authored-by trailer".to_owned());
372 }
373 }
374 if line.contains("noreply@anthropic.com") {
376 hits.push("anthropic noreply address".to_owned());
377 }
378 hits
379}
380
381enum IgnoreJudgment {
383 Repo(Vec<(usize, String)>),
385 NoRepo,
387}
388
389fn ignored_paths(target: &Utf8Path, text: &str) -> IgnoreJudgment {
395 let candidates: Vec<(usize, String)> = text
396 .lines()
397 .enumerate()
398 .flat_map(|(index, line)| {
399 line.split_whitespace()
400 .filter_map(path_token)
401 .map(move |token| (index + 1, token))
402 })
403 .collect();
404 if candidates.is_empty() {
405 return IgnoreJudgment::Repo(Vec::new());
406 }
407 check_ignore(target, &candidates).map_or(IgnoreJudgment::NoRepo, IgnoreJudgment::Repo)
408}
409
410fn fixed_draft_hits(text: &str) -> Vec<(usize, String)> {
417 let mut hits = Vec::new();
418 for (index, line) in text.lines().enumerate() {
419 for (at, _) in line.match_indices(".draft/") {
420 let boundary = line[..at]
421 .chars()
422 .next_back()
423 .is_none_or(|c| !c.is_ascii_alphanumeric());
424 if !boundary {
425 continue;
426 }
427 let tail = &line[at..];
428 let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
429 let fragment = tail[..end].trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
430 hits.push((index + 1, fragment.to_owned()));
431 }
432 }
433 hits
434}
435
436fn path_token(token: &str) -> Option<String> {
442 let token = token
443 .trim_start_matches(|c: char| "()[]<>`'\"".contains(c))
444 .trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
445 if token.contains("://") || token.starts_with('-') || token.contains('$') {
446 return None;
447 }
448 let (head, tail) = token.split_once('/')?;
449 if head.is_empty() || tail.is_empty() {
450 return None;
451 }
452 Some(token.to_owned())
453}
454
455fn check_ignore(target: &Utf8Path, candidates: &[(usize, String)]) -> Option<Vec<(usize, String)>> {
464 let mut command = std::process::Command::new(crate::probes::git_bin());
465 let mut child = command
466 .arg("-C")
467 .arg(target.as_std_path())
468 .args(["check-ignore", "--stdin", "-z"])
469 .stdin(std::process::Stdio::piped())
470 .stdout(std::process::Stdio::piped())
471 .stderr(std::process::Stdio::null())
472 .spawn()
473 .ok()?;
474 let writer = child.stdin.take().map(|mut stdin| {
475 let payload: Vec<u8> = candidates
476 .iter()
477 .flat_map(|(_, token)| token.as_bytes().iter().copied().chain([0]))
478 .collect();
479 std::thread::spawn(move || {
480 let _ = stdin.write_all(&payload);
481 })
482 });
483 let output = child.wait_with_output().ok()?;
484 if let Some(writer) = writer {
485 let _ = writer.join();
486 }
487 if !matches!(output.status.code(), Some(0 | 1)) {
490 return None;
491 }
492 let ignored: std::collections::BTreeSet<&[u8]> = output
493 .stdout
494 .split(|byte| *byte == 0)
495 .filter(|path| !path.is_empty())
496 .collect();
497 Some(
498 candidates
499 .iter()
500 .filter(|(_, token)| ignored.contains(token.as_bytes()))
501 .cloned()
502 .collect(),
503 )
504}
505
506#[must_use]
508pub fn guard_patterns() -> Vec<(&'static str, &'static str)> {
509 let mut class = "";
510 let mut patterns = Vec::new();
511 for line in GUARDS.lines() {
512 if let Some(named) = line.strip_prefix("# class: ") {
513 class = named;
514 } else if !line.starts_with('#') && !line.is_empty() {
515 patterns.push((class, line));
516 }
517 }
518 patterns
519}
520
521#[cfg(test)]
522mod tests {
523 use super::{
524 Finding, Judgment, Report, attribution_hits, bot_title, fixed_draft_hits, guard_patterns,
525 judge, path_token, unscoped_type,
526 };
527
528 #[test]
533 fn the_message_schema_snapshot_holds() {
534 let report = Report {
535 schema: "rk.message/3",
536 kind: "commit",
537 exempt: false,
538 findings: vec![
539 Finding {
540 class: "scope-shape",
541 line: 1,
542 detail: "the scope 'Specs Ugly' is outside [a-z0-9._/-]+: lowercase letters, digits, and _ . / -".into(),
543 },
544 Finding {
545 class: "internal-path",
546 line: 3,
547 detail: ".draft/plan.md is git-ignored in .".into(),
548 },
549 ],
550 };
551 assert_eq!(
552 serde_json::to_string(&report).expect("a report serializes"),
553 r#"{"schema":"rk.message/3","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 ."}]}"#
554 );
555 }
556
557 #[test]
561 fn the_guard_file_holds_the_patterns_the_matchers_implement() {
562 assert_eq!(
563 guard_patterns(),
564 [
565 ("attribution", r"[Gg]enerated with \[?Claude"),
566 ("attribution", "🤖 Generated with"),
567 (
568 "attribution",
569 r"[Cc]o-[Aa]uthored-[Bb]y:.*([Cc]laude|[Cc]opilot|Codex|ChatGPT)"
570 ),
571 ("attribution", r"noreply@anthropic\.com"),
572 ("internal-path", r"(^|[^A-Za-z0-9])\.draft/"),
573 ]
574 );
575 }
576
577 #[test]
578 fn the_attribution_matchers_cover_the_patterns() {
579 for line in [
580 "Generated with Claude Code",
581 "generated with [Claude Code](https://claude.com/claude-code)",
582 "🤖 Generated with tooling",
583 "Co-Authored-By: Claude <x@y>",
584 "co-authored-by: github-copilot",
585 "Co-authored-by: Codex",
586 "Co-Authored-By: ChatGPT",
587 "Signed noreply@anthropic.com",
588 ] {
589 assert!(!attribution_hits(line).is_empty(), "{line} must match");
590 }
591 for line in [
592 "Generated with release-plz",
593 "Co-authored-by: A Person <person@example.com>",
594 "the claude skill route",
595 "Co-authored-by: Autopilot Team",
596 "Xenerated with Claude",
597 "CO-AUTHORED-BY: Claude",
598 ] {
599 assert!(attribution_hits(line).is_empty(), "{line} must not match");
600 }
601 }
602
603 #[test]
606 fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
607 let line = format!("{} Co-Authored-By: Claude", "İ".repeat(40));
610 assert!(!attribution_hits(&line).is_empty());
611 assert!(attribution_hits(&format!("{} nothing here", "İ".repeat(40))).is_empty());
612 }
613
614 #[test]
617 fn the_fixed_pattern_matches_decorated_references_only_at_a_boundary() {
618 assert_eq!(
619 fixed_draft_hits(
620 "path=.draft/plan.md
621"
622 ),
623 vec![(1, ".draft/plan.md".to_owned())]
624 );
625 assert_eq!(
626 fixed_draft_hits(
627 "a [plan](.draft/plan.md) link
628"
629 ),
630 vec![(1, ".draft/plan.md".to_owned())]
631 );
632 assert_eq!(
633 fixed_draft_hits(
634 ".draft/x
635"
636 ),
637 vec![(1, ".draft/x".to_owned())]
638 );
639 assert!(
640 fixed_draft_hits(
641 "archived.draft/x
642"
643 )
644 .is_empty()
645 );
646 assert!(
647 fixed_draft_hits(
648 "no reference here
649"
650 )
651 .is_empty()
652 );
653 }
654
655 #[test]
658 fn the_bot_exemption_is_the_title_checks_bot_alternative() {
659 for title in [
660 "chore: release v0.2.6",
661 "chore(release): v0.3.0",
662 "chore(master): release 1.0.0",
663 "chore(main): v2",
664 ] {
665 assert!(bot_title(title), "{title} is the bot's");
666 }
667 for title in [
668 "chore: bump deps",
669 "chore(deps): release v1",
670 "feat(cli): release v1",
671 "chore(release): ",
672 "chore(release): v",
673 "chore:release v1",
674 ] {
675 assert!(!bot_title(title), "{title} is not the bot's");
676 }
677 }
678
679 #[test]
680 fn a_path_token_is_two_segments_without_url_flag_or_variable() {
681 assert_eq!(
682 path_token("(.draft/plan.md)"),
683 Some(".draft/plan.md".into())
684 );
685 assert_eq!(path_token("`src/main.rs`,"), Some("src/main.rs".into()));
686 assert_eq!(path_token("https://a.b/c"), None);
687 assert_eq!(path_token("--flag/value"), None);
688 assert_eq!(path_token("$HOME/x"), None);
689 assert_eq!(path_token("and/or"), Some("and/or".into()));
690 assert_eq!(path_token("word"), None);
691 assert_eq!(path_token("trailing/"), None);
692 }
693
694 #[test]
701 fn workflow_only_a_header_with_no_scope_passes_only_without_a_release() {
702 let dir = tempfile::tempdir().expect("a scratch dir exists");
703 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8");
704 let judged = |text: &str, scope_required: bool| {
705 let title = text.lines().next().unwrap_or("");
706 judge(
707 text,
708 crate::cli::message::MessageKind::Commit,
709 target,
710 title,
711 Judgment {
712 exempt: bot_title(title),
713 scope_required,
714 },
715 )
716 .0
717 };
718 assert!(judged("fix: drop the stale path\n", false).is_empty());
719 let refused = judged("fix: drop the stale path\n", true);
720 assert_eq!(refused.len(), 1, "{refused:?}");
721 assert_eq!(refused[0].class, "scope-missing");
722 assert!(refused[0].detail.contains("names no scope"), "{refused:?}");
723 assert!(judged("fix(paths): drop the stale path\n", true).is_empty());
724 assert!(judged("chore: release v1.2.0\n", true).is_empty());
725 assert_eq!(unscoped_type("feat!: break it"), Some("feat"));
726 assert_eq!(unscoped_type("feat(x): add it"), None);
727 assert_eq!(unscoped_type("Merge branch 'x' into master"), None);
728 assert_eq!(unscoped_type("fixup! feat(x): add it"), None);
729 assert_eq!(
731 judged("fix(Bad Scope): mend\n", false)[0].class,
732 "scope-shape"
733 );
734 }
735}