1use 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
33static GUARDS: &str = include_str!("../../blocks/message-guards");
35
36#[derive(Debug, Serialize)]
38pub(crate) struct Finding {
39 pub(crate) class: &'static str,
41 pub(crate) line: usize,
43 pub(crate) detail: String,
45}
46
47#[derive(Debug, Serialize)]
49struct Report {
50 schema: &'static str,
52 kind: &'static str,
54 exempt: bool,
56 findings: Vec<Finding>,
58}
59
60pub 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
122pub(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 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 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
198pub(crate) fn exempt_title(title: &str) -> bool {
200 bot_title(title)
201}
202
203fn 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
215fn 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
244fn 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
264fn attribution_hits(line: &str) -> Vec<String> {
271 let mut hits = Vec::new();
272 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 if line.contains("🤖 Generated with") {
286 hits.push("robot generated-with attribution".to_owned());
287 }
288 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 if line.contains("noreply@anthropic.com") {
313 hits.push("anthropic noreply address".to_owned());
314 }
315 hits
316}
317
318enum IgnoreJudgment {
320 Repo(Vec<(usize, String)>),
322 NoRepo,
324}
325
326fn 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
347fn 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
373fn 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
392fn 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 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#[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 #[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 #[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 #[test]
541 fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
542 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 #[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 #[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}