1use std::path::Path;
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Layout {
13 PerPackage(Vec<String>),
15 Pooled,
17}
18
19pub const KINDS: [&str; 2] = ["changelog", "migrations"];
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Finding {
25 pub file: Option<String>,
27 pub message: String,
28}
29
30struct Facts {
39 layout: Option<Layout>,
40 bodies: String,
41 changed: Vec<String>,
42 added: Vec<String>,
43 migrations: bool,
44}
45
46fn facts(root: &Path, base: &str) -> Result<Facts> {
48 Ok(Facts {
49 layout: discover_layout(root),
50 bodies: commit_bodies(root, base)?,
51 changed: changed_files(root, base)?,
52 added: added_files(root, base)?,
53 migrations: migrations_enforced(root),
54 })
55}
56
57fn verdict(root: &Path, facts: &Facts) -> (i32, Vec<String>) {
61 let Some(layout) = &facts.layout else {
62 return (
63 0,
64 vec![format!(
65 "No fragment directories under `{}`; changelog check skipped.",
66 root.display()
67 )],
68 );
69 };
70 if has_skip_line(&facts.bodies) {
71 return (
72 0,
73 vec!["A `skip-changelog:` line is present; changelog check bypassed.".to_string()],
74 );
75 }
76 let found = findings(layout, facts.migrations, &facts.changed, &facts.added);
77 if found.is_empty() {
78 return (
79 0,
80 vec!["Every scope that changed public surface added its fragments.".to_string()],
81 );
82 }
83 (1, found.iter().map(annotation).collect())
84}
85
86pub fn run(root: &Path, base: &str) -> Result<i32> {
92 let (code, lines) = verdict(root, &facts(root, base)?);
93 println!("{}", lines.join("\n"));
94 Ok(code)
95}
96
97pub fn annotation(finding: &Finding) -> String {
100 match &finding.file {
101 Some(file) => format!("::error file={file}::{}", finding.message),
102 None => format!("::error::{}", finding.message),
103 }
104}
105
106const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
108
109pub fn discover_layout(root: &Path) -> Option<Layout> {
111 let dirs = fragment_dirs(root);
112 if dirs.is_empty() {
113 return None;
114 }
115 let mut containers: Vec<String> = dirs
116 .iter()
117 .filter(|segs| segs.len() == 3)
118 .map(|segs| segs[0].clone())
119 .collect();
120 containers.sort();
121 containers.dedup();
122 if containers.is_empty() {
123 return Some(Layout::Pooled);
124 }
125 Some(Layout::PerPackage(containers))
126}
127
128pub fn migrations_enforced(root: &Path) -> bool {
130 fragment_dirs(root)
131 .iter()
132 .any(|segs| segs.last().is_some_and(|last| last == "migrations.d"))
133}
134
135pub fn fragment_name_ok(name: &str) -> bool {
138 let Some(stem) = name.strip_suffix(".md") else {
139 return false;
140 };
141 let bytes = stem.as_bytes();
142 if bytes.len() < 12 {
143 return false;
144 }
145 let digit = |i: usize| bytes[i].is_ascii_digit();
146 let dated = (0..4).all(digit)
147 && bytes[4] == b'-'
148 && (5..7).all(digit)
149 && bytes[7] == b'-'
150 && (8..10).all(digit)
151 && bytes[10] == b'-';
152 dated
153 && bytes[11..]
154 .iter()
155 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
156}
157
158pub fn has_skip_line(bodies: &str) -> bool {
160 const SKIP: &[u8] = b"skip-changelog:";
161 bodies.lines().any(|line| {
162 let bytes = line.as_bytes();
163 bytes.len() >= SKIP.len() && bytes[..SKIP.len()].eq_ignore_ascii_case(SKIP)
164 })
165}
166
167pub fn is_exempt(path: &str, pkg: &str) -> bool {
169 path.strip_prefix(pkg)
170 .and_then(|rest| rest.strip_prefix('/'))
171 .is_some_and(exempt_at_any_boundary)
172}
173
174pub fn changed_packages(changed: &[String]) -> Vec<String> {
176 let mut out: Vec<String> = changed
177 .iter()
178 .filter_map(|path| {
179 let segs: Vec<&str> = path.split('/').collect();
180 (segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
181 })
182 .collect();
183 out.sort();
184 out.dedup();
185 out
186}
187
188pub fn findings(
191 layout: &Layout,
192 migrations: bool,
193 changed: &[String],
194 added: &[String],
195) -> Vec<Finding> {
196 let mut out: Vec<Finding> = malformed(layout, changed)
197 .into_iter()
198 .map(|path| Finding {
199 file: Some(path),
200 message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
201 lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
202 .to_string(),
203 })
204 .collect();
205
206 match layout {
207 Layout::PerPackage(containers) => {
208 for pkg in changed_packages(changed) {
209 let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
210 if in_a_container && code_touched(changed, &pkg) {
211 for kind in missing_kinds(layout, added, Some(&pkg), migrations) {
212 out.push(owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind));
213 }
214 }
215 }
216 }
217 Layout::Pooled => {
218 if changed.iter().any(|path| !exempt_at_any_boundary(path)) {
219 for kind in missing_kinds(layout, added, None, migrations) {
220 out.push(owed("", &format!("{kind}.d"), kind));
221 }
222 }
223 }
224 }
225 out
226}
227
228pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
230 git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
231}
232
233pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
235 let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
236 Ok(lines(&out))
237}
238
239pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
242 let range = format!("{base}...HEAD");
243 let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
244 Ok(lines(&out))
245}
246
247fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
248 Finding {
249 file: None,
250 message: format!(
251 "{scope}changed public surface without adding a {kind} fragment. Add \
252 {dir}/YYYY-MM-DD-<slug>.md, or put a `skip-changelog: <reason>` line on any commit \
253 for a genuinely internal refactor. See docs/reference/checks/changelog."
254 ),
255 }
256}
257
258struct Fragment {
260 pkg: Option<String>,
262 kind: &'static str,
263 name: String,
264}
265
266fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
267 let segs: Vec<&str> = path.split('/').collect();
268 let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
269 let kind = kind_of(segs[i])?;
270 if i + 2 != segs.len() {
271 return None;
272 }
273 let name = segs[i + 1].to_string();
274 match layout {
275 Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
276 Some(Fragment {
277 pkg: Some(format!("{}/{}", segs[0], segs[1])),
278 kind,
279 name,
280 })
281 }
282 Layout::PerPackage(_) => None,
283 Layout::Pooled => Some(Fragment {
284 pkg: None,
285 kind,
286 name,
287 }),
288 }
289}
290
291fn kind_of(segment: &str) -> Option<&'static str> {
292 let stem = segment.strip_suffix(".d")?;
293 KINDS.into_iter().find(|kind| *kind == stem)
294}
295
296fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
299 changed
300 .iter()
301 .filter(|path| {
302 fragment(path, layout)
303 .is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
304 })
305 .cloned()
306 .collect()
307}
308
309fn missing_kinds(
310 layout: &Layout,
311 added: &[String],
312 pkg: Option<&str>,
313 migrations: bool,
314) -> Vec<&'static str> {
315 let present: Vec<&'static str> = added
316 .iter()
317 .filter_map(|path| fragment(path, layout))
318 .filter(|frag| fragment_name_ok(&frag.name) && frag.pkg.as_deref() == pkg)
319 .map(|frag| frag.kind)
320 .collect();
321 KINDS
322 .into_iter()
323 .filter(|kind| migrations || *kind != "migrations")
324 .filter(|kind| !present.contains(kind))
325 .collect()
326}
327
328fn code_touched(changed: &[String], pkg: &str) -> bool {
329 let prefix = format!("{pkg}/");
330 changed
331 .iter()
332 .any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
333}
334
335fn exempt_at_any_boundary(rel: &str) -> bool {
336 std::iter::once(rel)
337 .chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
338 .any(exempt_shape)
339}
340
341fn exempt_shape(rel: &str) -> bool {
342 matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
343 || KINDS
344 .iter()
345 .any(|kind| rel.starts_with(&format!("{kind}.d/")))
346 || rel.starts_with("e2e-attestations/")
347 || (rel.contains('/')
348 && matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
349 || rel.ends_with("_test.py")
350 || is_test_or_spec(rel)
351}
352
353fn is_test_or_spec(rel: &str) -> bool {
354 let name = rel.rsplit('/').next().unwrap_or(rel);
355 ["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
356 .iter()
357 .any(|ext| {
358 name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
359 })
360}
361
362fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
365 let mut out = Vec::new();
366 scan(root, &[], &mut out);
367 out
368}
369
370fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
371 let Ok(entries) = std::fs::read_dir(dir) else {
372 return;
373 };
374 for entry in entries.flatten() {
375 if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
376 continue;
377 }
378 let name = entry.file_name().to_string_lossy().into_owned();
379 if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
380 continue;
381 }
382 let mut segs = prefix.to_vec();
383 segs.push(name.clone());
384 if kind_of(&name).is_some() {
385 out.push(segs);
386 } else if segs.len() < 3 {
387 scan(&entry.path(), &segs, out);
388 }
389 }
390}
391
392fn git(repo: &Path, args: &[&str]) -> Result<String> {
393 let out = Command::new("git")
394 .current_dir(repo)
395 .args(args)
396 .output()
397 .with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
398 if !out.status.success() {
399 bail!(
400 "`git {}` failed: {}",
401 args.join(" "),
402 String::from_utf8_lossy(&out.stderr).trim()
403 );
404 }
405 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
406}
407
408fn lines(out: &str) -> Vec<String> {
409 out.lines()
410 .filter(|line| !line.is_empty())
411 .map(str::to_string)
412 .collect()
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 fn facts_with(layout: Option<Layout>, bodies: &str, changed: &[&str], added: &[&str]) -> Facts {
420 Facts {
421 layout,
422 bodies: bodies.to_string(),
423 changed: changed.iter().map(|s| s.to_string()).collect(),
424 added: added.iter().map(|s| s.to_string()).collect(),
425 migrations: false,
426 }
427 }
428
429 #[test]
430 fn no_layout_skips_the_check_and_names_the_tree() {
431 let facts = facts_with(None, "", &["packages/parser/src/a.ts"], &[]);
432 let (code, lines) = verdict(Path::new("/repo"), &facts);
433 assert_eq!(code, 0);
434 assert_eq!(
435 lines,
436 vec!["No fragment directories under `/repo`; changelog check skipped."]
437 );
438 }
439
440 #[test]
441 fn a_skip_line_bypasses_a_tree_that_would_otherwise_owe_fragments() {
442 let owing = &["packages/parser/src/a.ts"];
443 let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
444 assert_eq!(
446 verdict(
447 Path::new("/repo"),
448 &facts_with(layout.clone(), "", owing, &[])
449 )
450 .0,
451 1
452 );
453 let (code, lines) = verdict(
454 Path::new("/repo"),
455 &facts_with(
456 layout,
457 "refactor: rename\n\nskip-changelog: no public surface",
458 owing,
459 &[],
460 ),
461 );
462 assert_eq!(code, 0);
463 assert_eq!(
464 lines,
465 vec!["A `skip-changelog:` line is present; changelog check bypassed."]
466 );
467 }
468
469 #[test]
470 fn a_scope_that_added_its_fragment_passes() {
471 let facts = facts_with(
472 Some(Layout::PerPackage(vec!["packages".to_string()])),
473 "",
474 &["packages/parser/src/a.ts"],
475 &["packages/parser/changelog.d/2026-10-05-change-a.md"],
476 );
477 let (code, lines) = verdict(Path::new("/repo"), &facts);
478 assert_eq!(code, 0);
479 assert_eq!(
480 lines,
481 vec!["Every scope that changed public surface added its fragments."]
482 );
483 }
484
485 #[test]
486 fn an_unpaid_scope_fails_with_one_annotation_per_finding() {
487 let facts = facts_with(
488 Some(Layout::PerPackage(vec!["packages".to_string()])),
489 "",
490 &["packages/parser/src/a.ts"],
491 &[],
492 );
493 let (code, lines) = verdict(Path::new("/repo"), &facts);
494 assert_eq!(code, 1);
495 let found = findings(
497 facts.layout.as_ref().unwrap(),
498 facts.migrations,
499 &facts.changed,
500 &facts.added,
501 );
502 assert_eq!(lines.len(), found.len());
503 assert!(lines.iter().all(|line| line.starts_with("::error")));
504 }
505
506 #[test]
507 fn a_finding_with_a_file_annotates_that_file() {
508 let finding = Finding {
509 file: Some("packages/rust/src/lib.rs".into()),
510 message: "changed public surface".into(),
511 };
512 assert_eq!(
513 annotation(&finding),
514 "::error file=packages/rust/src/lib.rs::changed public surface"
515 );
516 }
517
518 #[test]
519 fn a_finding_with_no_file_annotates_the_workflow() {
520 let finding = Finding {
521 file: None,
522 message: "changed public surface".into(),
523 };
524 assert_eq!(annotation(&finding), "::error::changed public surface");
525 }
526
527 #[test]
528 fn owed_names_the_scope_kind_and_fragment_directory() {
529 let finding = owed(
530 "packages/parser ",
531 "packages/parser/changelog.d",
532 "changelog",
533 );
534 assert_eq!(finding.file, None);
535 assert!(finding
536 .message
537 .contains("packages/parser changed public surface"));
538 assert!(finding
539 .message
540 .contains("packages/parser/changelog.d/YYYY-MM-DD-<slug>.md"));
541 assert!(finding.message.contains("skip-changelog: <reason>"));
542 }
543
544 #[test]
545 fn fragment_recognizes_only_a_package_fragment_at_the_expected_depth() {
546 let layout = Layout::PerPackage(vec!["packages".to_string()]);
547 let found = fragment("packages/parser/changelog.d/2026-09-26-change.md", &layout).unwrap();
548 assert_eq!(found.pkg.as_deref(), Some("packages/parser"));
549 assert_eq!(found.kind, "changelog");
550 assert_eq!(found.name, "2026-09-26-change.md");
551 assert!(fragment("other/parser/changelog.d/2026-09-26-change.md", &layout).is_none());
552 assert!(fragment("packages/parser/changelog.d/nested/change.md", &layout).is_none());
553 }
554
555 #[test]
556 fn kind_of_accepts_only_the_two_fragment_directories() {
557 assert_eq!(kind_of("changelog.d"), Some("changelog"));
558 assert_eq!(kind_of("migrations.d"), Some("migrations"));
559 assert_eq!(kind_of("notes.d"), None);
560 assert_eq!(kind_of("changelog"), None);
561 }
562
563 #[test]
564 fn malformed_reports_entries_but_not_fragment_readmes() {
565 let layout = Layout::Pooled;
566 let changed = vec![
567 "changelog.d/README.md".to_string(),
568 "changelog.d/2026-09-26-valid.md".to_string(),
569 "migrations.d/bad-name.md".to_string(),
570 "src/bad-name.md".to_string(),
571 ];
572 assert_eq!(
573 malformed(&layout, &changed),
574 vec!["migrations.d/bad-name.md"]
575 );
576 }
577
578 #[test]
579 fn missing_kinds_requires_added_valid_fragments_for_the_same_package() {
580 let layout = Layout::PerPackage(vec!["packages".to_string()]);
581 let added = vec![
582 "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
583 "packages/parser/migrations.d/bad-name.md".to_string(),
584 "packages/other/migrations.d/2026-09-26-change.md".to_string(),
585 ];
586 assert_eq!(
587 missing_kinds(&layout, &added, Some("packages/parser"), true),
588 vec!["migrations"]
589 );
590 assert!(missing_kinds(&layout, &added, Some("packages/parser"), false).is_empty());
591 }
592
593 #[test]
594 fn code_touched_ignores_fragments_and_tests_inside_the_package() {
595 let pkg = "packages/parser";
596 let exempt = vec![
597 "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
598 "packages/parser/tests/parser.rs".to_string(),
599 "packages/other/src/parser.rs".to_string(),
600 ];
601 assert!(!code_touched(&exempt, pkg));
602 assert!(code_touched(
603 &["packages/parser/src/parser.rs".to_string()],
604 pkg
605 ));
606 }
607
608 #[test]
609 fn exempt_at_any_boundary_checks_nested_test_and_fragment_paths() {
610 assert!(exempt_at_any_boundary("packages/parser/tests/parser.rs"));
611 assert!(exempt_at_any_boundary(
612 "packages/parser/changelog.d/2026-09-26-change.md"
613 ));
614 assert!(!exempt_at_any_boundary("packages/parser/src/parser.rs"));
615 }
616
617 #[test]
618 fn exempt_shape_recognizes_archives_attestations_and_test_paths() {
619 assert!(exempt_shape("CHANGELOG.md"));
620 assert!(exempt_shape("MIGRATIONS.md"));
621 assert!(exempt_shape("e2e-attestations/run.json"));
622 assert!(exempt_shape("tests/parser.rs"));
623 assert!(exempt_shape("src/parser_test.py"));
624 assert!(!exempt_shape("src/parser.rs"));
625 }
626
627 #[test]
628 fn is_test_or_spec_matches_supported_extensions_only() {
629 assert!(is_test_or_spec("src/parser.test.rs"));
630 assert!(is_test_or_spec("src/parser.spec.tsx"));
631 assert!(!is_test_or_spec("src/parser.test.txt"));
632 assert!(!is_test_or_spec("src/parser.rs"));
633 }
634
635 #[test]
636 fn fragment_dirs_finds_shallow_directories_and_skips_build_trees() {
637 let root = std::env::temp_dir().join(format!("tc-changelog-inline-{}", std::process::id()));
638 std::fs::create_dir_all(root.join("packages/parser/changelog.d")).unwrap();
639 std::fs::create_dir_all(root.join("target/debug/migrations.d")).unwrap();
640 std::fs::create_dir_all(root.join("packages/parser/deep/migrations.d")).unwrap();
641 let dirs = fragment_dirs(&root);
642 assert_eq!(
643 dirs,
644 vec![vec![
645 "packages".to_string(),
646 "parser".to_string(),
647 "changelog.d".to_string()
648 ]]
649 );
650 std::fs::remove_dir_all(root).unwrap();
651 }
652}