1use 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
26static GUARDS: &str = include_str!("../../blocks/message-guards");
28
29#[derive(Debug, Serialize)]
31struct Finding {
32 class: &'static str,
34 line: usize,
36 detail: String,
38}
39
40#[derive(Debug, Serialize)]
42struct Report {
43 schema: &'static str,
45 kind: &'static str,
47 exempt: bool,
49 findings: Vec<Finding>,
51}
52
53pub 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 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
157fn 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
169fn 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
198fn attribution_hits(line: &str) -> Vec<String> {
205 let mut hits = Vec::new();
206 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 if line.contains("🤖 Generated with") {
220 hits.push("robot generated-with attribution".to_owned());
221 }
222 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 if line.contains("noreply@anthropic.com") {
247 hits.push("anthropic noreply address".to_owned());
248 }
249 hits
250}
251
252enum IgnoreJudgment {
254 Repo(Vec<(usize, String)>),
256 NoRepo,
258}
259
260fn 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
281fn 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
307fn 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
326fn 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 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#[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 #[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 #[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 #[test]
468 fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
469 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 #[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 #[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}