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)]
38struct Finding {
39 class: &'static str,
41 line: usize,
43 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 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 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 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
179fn 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
191fn 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
220fn 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
240fn attribution_hits(line: &str) -> Vec<String> {
247 let mut hits = Vec::new();
248 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 if line.contains("🤖 Generated with") {
262 hits.push("robot generated-with attribution".to_owned());
263 }
264 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 if line.contains("noreply@anthropic.com") {
289 hits.push("anthropic noreply address".to_owned());
290 }
291 hits
292}
293
294enum IgnoreJudgment {
296 Repo(Vec<(usize, String)>),
298 NoRepo,
300}
301
302fn 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
323fn 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
349fn 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
368fn 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 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#[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 #[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 #[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 #[test]
519 fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
520 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 #[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 #[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}