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)
91 && !exempt
92 && let Some(scope) = misshapen_scope(title)
93 {
94 findings.push(Finding {
95 class: "scope-shape",
96 line: 1,
97 detail: format!(
98 "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
99 landing::SCOPE_SHAPE
100 ),
101 });
102 }
103 let mut seen: std::collections::BTreeSet<(usize, String)> = std::collections::BTreeSet::new();
104 match ignored_paths(&args.target, &text) {
105 IgnoreJudgment::Repo(hits) => {
106 for (line, token) in hits {
107 findings.push(Finding {
108 class: "internal-path",
109 line,
110 detail: format!("{token} is git-ignored in {}", args.target),
111 });
112 seen.insert((line, token));
113 }
114 }
115 IgnoreJudgment::NoRepo => {
116 if !args.json {
117 out.warn(format!(
118 "{} is not a git repository; only the fixed .draft/ pattern was tested",
119 args.target
120 ));
121 }
122 }
123 }
124 findings.extend(
128 fixed_draft_hits(&text)
129 .into_iter()
130 .filter(|(line, fragment)| {
131 !seen
132 .iter()
133 .any(|(seen_line, token)| seen_line == line && token.contains(fragment))
134 })
135 .map(|(line, fragment)| Finding {
136 class: "internal-path",
137 line,
138 detail: format!("{fragment} references the internal .draft/ tree"),
139 }),
140 );
141 findings.sort_by_key(|finding| finding.line);
142
143 if exempt {
144 out.result_line("exempt: the release bot's request, by its title");
145 }
146 for finding in &findings {
147 out.result_line(format!(
148 "{}:{} {}",
149 finding.class, finding.line, finding.detail
150 ));
151 }
152 if findings.is_empty() {
153 out.result_line(format!("clean {}", args.kind.as_str()));
154 }
155 let count = findings.len();
156 out.emit(&Report {
157 schema: "rk.message/2",
158 kind: args.kind.as_str(),
159 exempt,
160 findings,
161 })?;
162
163 if args.check && count > 0 {
164 return Err(RkError::check_failed(
165 Diagnostic::new(
166 Reason::StateDrift,
167 format!(
168 "the {} carries {count} finding{}",
169 args.kind.as_str(),
170 if count == 1 { "" } else { "s" }
171 ),
172 )
173 .expected("no agent attribution, no reference to a git-ignored path, and a scope the title check admits")
174 .action("reword the text; the findings above name each line"),
175 ));
176 }
177 Ok(())
178}
179
180fn read_input(file: Option<&str>) -> Result<String, RkError> {
182 match file {
183 None | Some("-") => {
184 let mut text = String::new();
185 std::io::stdin().read_to_string(&mut text)?;
186 Ok(text)
187 }
188 Some(path) => Ok(std::fs::read_to_string(path)?),
189 }
190}
191
192fn bot_title(title: &str) -> bool {
196 let Some(rest) = title.strip_prefix("chore") else {
197 return false;
198 };
199 let rest = if rest.starts_with('(') {
200 let Some(rest) = ["(release)", "(master)", "(main)"]
201 .iter()
202 .find_map(|scope| rest.strip_prefix(scope))
203 else {
204 return false;
205 };
206 rest
207 } else {
208 rest
209 };
210 let Some(rest) = rest.strip_prefix(": ") else {
211 return false;
212 };
213 ["release", "v"].iter().any(|stem| {
214 rest.strip_suffix('\n')
215 .unwrap_or(rest)
216 .strip_prefix(stem)
217 .is_some_and(|tail| !tail.is_empty())
218 })
219}
220
221fn misshapen_scope(title: &str) -> Option<String> {
230 let (kind, rest) = title.split_once('(')?;
231 if kind.is_empty() || !kind.chars().all(|c| c.is_ascii_alphabetic()) {
232 return None;
233 }
234 let (scope, rest) = rest.split_once(')')?;
235 if !(rest.starts_with(':') || rest.starts_with("!:")) {
236 return None;
237 }
238 (!landing::scope_is_shaped(scope)).then(|| scope.to_owned())
239}
240
241fn attribution_hits(line: &str) -> Vec<String> {
248 let mut hits = Vec::new();
249 if [
251 "Generated with Claude",
252 "generated with Claude",
253 "Generated with [Claude",
254 "generated with [Claude",
255 ]
256 .iter()
257 .any(|variant| line.contains(variant))
258 {
259 hits.push("generated-with-claude attribution".to_owned());
260 }
261 if line.contains("🤖 Generated with") {
263 hits.push("robot generated-with attribution".to_owned());
264 }
265 let trailer = [
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 "co-authored-by:",
275 ]
276 .iter()
277 .filter_map(|variant| line.find(variant))
278 .min();
279 if let Some(at) = trailer {
280 let tail = &line[at..];
281 if ["Claude", "claude", "Copilot", "copilot", "Codex", "ChatGPT"]
282 .iter()
283 .any(|agent| tail.contains(agent))
284 {
285 hits.push("agent co-authored-by trailer".to_owned());
286 }
287 }
288 if line.contains("noreply@anthropic.com") {
290 hits.push("anthropic noreply address".to_owned());
291 }
292 hits
293}
294
295enum IgnoreJudgment {
297 Repo(Vec<(usize, String)>),
299 NoRepo,
301}
302
303fn ignored_paths(target: &Utf8Path, text: &str) -> IgnoreJudgment {
309 let candidates: Vec<(usize, String)> = text
310 .lines()
311 .enumerate()
312 .flat_map(|(index, line)| {
313 line.split_whitespace()
314 .filter_map(path_token)
315 .map(move |token| (index + 1, token))
316 })
317 .collect();
318 if candidates.is_empty() {
319 return IgnoreJudgment::Repo(Vec::new());
320 }
321 check_ignore(target, &candidates).map_or(IgnoreJudgment::NoRepo, IgnoreJudgment::Repo)
322}
323
324fn fixed_draft_hits(text: &str) -> Vec<(usize, String)> {
331 let mut hits = Vec::new();
332 for (index, line) in text.lines().enumerate() {
333 for (at, _) in line.match_indices(".draft/") {
334 let boundary = line[..at]
335 .chars()
336 .next_back()
337 .is_none_or(|c| !c.is_ascii_alphanumeric());
338 if !boundary {
339 continue;
340 }
341 let tail = &line[at..];
342 let end = tail.find(char::is_whitespace).unwrap_or(tail.len());
343 let fragment = tail[..end].trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
344 hits.push((index + 1, fragment.to_owned()));
345 }
346 }
347 hits
348}
349
350fn path_token(token: &str) -> Option<String> {
356 let token = token
357 .trim_start_matches(|c: char| "()[]<>`'\"".contains(c))
358 .trim_end_matches(|c: char| "()[]<>`'\".,;:".contains(c));
359 if token.contains("://") || token.starts_with('-') || token.contains('$') {
360 return None;
361 }
362 let (head, tail) = token.split_once('/')?;
363 if head.is_empty() || tail.is_empty() {
364 return None;
365 }
366 Some(token.to_owned())
367}
368
369fn check_ignore(target: &Utf8Path, candidates: &[(usize, String)]) -> Option<Vec<(usize, String)>> {
378 let mut command = std::process::Command::new(crate::probes::git_bin());
379 let mut child = command
380 .arg("-C")
381 .arg(target.as_std_path())
382 .args(["check-ignore", "--stdin", "-z"])
383 .stdin(std::process::Stdio::piped())
384 .stdout(std::process::Stdio::piped())
385 .stderr(std::process::Stdio::null())
386 .spawn()
387 .ok()?;
388 let writer = child.stdin.take().map(|mut stdin| {
389 let payload: Vec<u8> = candidates
390 .iter()
391 .flat_map(|(_, token)| token.as_bytes().iter().copied().chain([0]))
392 .collect();
393 std::thread::spawn(move || {
394 let _ = stdin.write_all(&payload);
395 })
396 });
397 let output = child.wait_with_output().ok()?;
398 if let Some(writer) = writer {
399 let _ = writer.join();
400 }
401 if !matches!(output.status.code(), Some(0 | 1)) {
404 return None;
405 }
406 let ignored: std::collections::BTreeSet<&[u8]> = output
407 .stdout
408 .split(|byte| *byte == 0)
409 .filter(|path| !path.is_empty())
410 .collect();
411 Some(
412 candidates
413 .iter()
414 .filter(|(_, token)| ignored.contains(token.as_bytes()))
415 .cloned()
416 .collect(),
417 )
418}
419
420#[must_use]
422pub fn guard_patterns() -> Vec<(&'static str, &'static str)> {
423 let mut class = "";
424 let mut patterns = Vec::new();
425 for line in GUARDS.lines() {
426 if let Some(named) = line.strip_prefix("# class: ") {
427 class = named;
428 } else if !line.starts_with('#') && !line.is_empty() {
429 patterns.push((class, line));
430 }
431 }
432 patterns
433}
434
435#[cfg(test)]
436mod tests {
437 use super::{
438 Finding, Report, attribution_hits, bot_title, fixed_draft_hits, guard_patterns, path_token,
439 };
440
441 #[test]
445 fn the_message_schema_snapshot_holds() {
446 let report = Report {
447 schema: "rk.message/2",
448 kind: "commit",
449 exempt: false,
450 findings: vec![
451 Finding {
452 class: "scope-shape",
453 line: 1,
454 detail: "the scope 'Specs Ugly' is outside [a-z0-9._/-]+: lowercase letters, digits, and _ . / -".into(),
455 },
456 Finding {
457 class: "internal-path",
458 line: 3,
459 detail: ".draft/plan.md is git-ignored in .".into(),
460 },
461 ],
462 };
463 assert_eq!(
464 serde_json::to_string(&report).expect("a report serializes"),
465 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 ."}]}"#
466 );
467 }
468
469 #[test]
473 fn the_guard_file_holds_the_patterns_the_matchers_implement() {
474 assert_eq!(
475 guard_patterns(),
476 [
477 ("attribution", r"[Gg]enerated with \[?Claude"),
478 ("attribution", "🤖 Generated with"),
479 (
480 "attribution",
481 r"[Cc]o-[Aa]uthored-[Bb]y:.*([Cc]laude|[Cc]opilot|Codex|ChatGPT)"
482 ),
483 ("attribution", r"noreply@anthropic\.com"),
484 ("internal-path", r"(^|[^A-Za-z0-9])\.draft/"),
485 ]
486 );
487 }
488
489 #[test]
490 fn the_attribution_matchers_cover_the_patterns() {
491 for line in [
492 "Generated with Claude Code",
493 "generated with [Claude Code](https://claude.com/claude-code)",
494 "🤖 Generated with tooling",
495 "Co-Authored-By: Claude <x@y>",
496 "co-authored-by: github-copilot",
497 "Co-authored-by: Codex",
498 "Co-Authored-By: ChatGPT",
499 "Signed noreply@anthropic.com",
500 ] {
501 assert!(!attribution_hits(line).is_empty(), "{line} must match");
502 }
503 for line in [
504 "Generated with release-plz",
505 "Co-authored-by: A Person <person@example.com>",
506 "the claude skill route",
507 "Co-authored-by: Autopilot Team",
508 "Xenerated with Claude",
509 "CO-AUTHORED-BY: Claude",
510 ] {
511 assert!(attribution_hits(line).is_empty(), "{line} must not match");
512 }
513 }
514
515 #[test]
518 fn a_multibyte_prefix_neither_panics_nor_hides_the_trailer() {
519 let line = format!("{} Co-Authored-By: Claude", "İ".repeat(40));
522 assert!(!attribution_hits(&line).is_empty());
523 assert!(attribution_hits(&format!("{} nothing here", "İ".repeat(40))).is_empty());
524 }
525
526 #[test]
529 fn the_fixed_pattern_matches_decorated_references_only_at_a_boundary() {
530 assert_eq!(
531 fixed_draft_hits(
532 "path=.draft/plan.md
533"
534 ),
535 vec![(1, ".draft/plan.md".to_owned())]
536 );
537 assert_eq!(
538 fixed_draft_hits(
539 "a [plan](.draft/plan.md) link
540"
541 ),
542 vec![(1, ".draft/plan.md".to_owned())]
543 );
544 assert_eq!(
545 fixed_draft_hits(
546 ".draft/x
547"
548 ),
549 vec![(1, ".draft/x".to_owned())]
550 );
551 assert!(
552 fixed_draft_hits(
553 "archived.draft/x
554"
555 )
556 .is_empty()
557 );
558 assert!(
559 fixed_draft_hits(
560 "no reference here
561"
562 )
563 .is_empty()
564 );
565 }
566
567 #[test]
570 fn the_bot_exemption_is_the_title_checks_bot_alternative() {
571 for title in [
572 "chore: release v0.2.6",
573 "chore(release): v0.3.0",
574 "chore(master): release 1.0.0",
575 "chore(main): v2",
576 ] {
577 assert!(bot_title(title), "{title} is the bot's");
578 }
579 for title in [
580 "chore: bump deps",
581 "chore(deps): release v1",
582 "feat(cli): release v1",
583 "chore(release): ",
584 "chore(release): v",
585 "chore:release v1",
586 ] {
587 assert!(!bot_title(title), "{title} is not the bot's");
588 }
589 }
590
591 #[test]
592 fn a_path_token_is_two_segments_without_url_flag_or_variable() {
593 assert_eq!(
594 path_token("(.draft/plan.md)"),
595 Some(".draft/plan.md".into())
596 );
597 assert_eq!(path_token("`src/main.rs`,"), Some("src/main.rs".into()));
598 assert_eq!(path_token("https://a.b/c"), None);
599 assert_eq!(path_token("--flag/value"), None);
600 assert_eq!(path_token("$HOME/x"), None);
601 assert_eq!(path_token("and/or"), Some("and/or".into()));
602 assert_eq!(path_token("word"), None);
603 assert_eq!(path_token("trailing/"), None);
604 }
605}