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(Vec<String>),
18}
19
20pub const KINDS: [&str; 2] = ["changelog", "migrations"];
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Finding {
26 pub file: Option<String>,
28 pub message: String,
29}
30
31struct Facts {
40 layout: Option<Layout>,
41 bodies: String,
42 changed: Vec<String>,
43 added: Vec<String>,
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 })
54}
55
56fn verdict(root: &Path, facts: &Facts) -> (i32, Vec<String>) {
60 let Some(layout) = &facts.layout else {
61 return (
62 0,
63 vec![format!(
64 "No fragment directories under `{}`; changelog check skipped.",
65 root.display()
66 )],
67 );
68 };
69 if has_skip_line(&facts.bodies) {
70 return (
71 0,
72 vec!["A `skip-changelog:` line is present; changelog check bypassed.".to_string()],
73 );
74 }
75 let breaking = has_breaking_line(&facts.bodies);
76 let found = findings(layout, breaking, &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(package_roots(root)));
124 }
125 Some(Layout::PerPackage(containers))
126}
127
128pub fn fragment_name_ok(name: &str) -> bool {
131 let Some(stem) = name.strip_suffix(".md") else {
132 return false;
133 };
134 let bytes = stem.as_bytes();
135 if bytes.len() < 12 {
136 return false;
137 }
138 let digit = |i: usize| bytes[i].is_ascii_digit();
139 let dated = (0..4).all(digit)
140 && bytes[4] == b'-'
141 && (5..7).all(digit)
142 && bytes[7] == b'-'
143 && (8..10).all(digit)
144 && bytes[10] == b'-';
145 dated
146 && bytes[11..]
147 .iter()
148 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
149}
150
151fn has_line_opening(bodies: &str, marker: &[u8]) -> bool {
153 bodies.lines().any(|line| {
154 let bytes = line.as_bytes();
155 bytes.len() >= marker.len() && bytes[..marker.len()].eq_ignore_ascii_case(marker)
156 })
157}
158
159pub fn has_skip_line(bodies: &str) -> bool {
161 has_line_opening(bodies, b"skip-changelog:")
162}
163
164pub fn has_breaking_line(bodies: &str) -> bool {
168 has_line_opening(bodies, b"breaking:")
169}
170
171pub fn is_exempt(path: &str, pkg: &str) -> bool {
173 path.strip_prefix(pkg)
174 .and_then(|rest| rest.strip_prefix('/'))
175 .is_some_and(exempt_at_any_boundary)
176}
177
178pub fn changed_packages(changed: &[String]) -> Vec<String> {
180 let mut out: Vec<String> = changed
181 .iter()
182 .filter_map(|path| {
183 let segs: Vec<&str> = path.split('/').collect();
184 (segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
185 })
186 .collect();
187 out.sort();
188 out.dedup();
189 out
190}
191
192pub fn findings(
199 layout: &Layout,
200 migrations: bool,
201 changed: &[String],
202 added: &[String],
203) -> Vec<Finding> {
204 let mut out = malformed_findings(layout, changed);
205 out.extend(owed_findings(layout, migrations, changed, added));
206 out
207}
208
209fn malformed_findings(layout: &Layout, changed: &[String]) -> Vec<Finding> {
211 malformed(layout, changed)
212 .into_iter()
213 .map(|path| Finding {
214 file: Some(path),
215 message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
216 lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
217 .to_string(),
218 })
219 .collect()
220}
221
222fn owed_findings(
225 layout: &Layout,
226 migrations: bool,
227 changed: &[String],
228 added: &[String],
229) -> Vec<Finding> {
230 let mut out = Vec::new();
231 out.extend(match layout {
232 Layout::PerPackage(containers) => {
233 per_package_findings(layout, containers, migrations, changed, added)
234 }
235 Layout::Pooled(roots) => pooled_findings(layout, roots, migrations, changed, added),
236 });
237 out
238}
239
240fn per_package_findings(
243 layout: &Layout,
244 containers: &[String],
245 migrations: bool,
246 changed: &[String],
247 added: &[String],
248) -> Vec<Finding> {
249 let mut out = Vec::new();
250 for pkg in changed_packages(changed) {
251 let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
252 if in_a_container && code_touched(changed, &pkg) {
253 for kind in missing_kinds(layout, added, Some(&pkg), migrations) {
254 out.push(owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind));
255 }
256 }
257 }
258 out
259}
260
261fn pooled_findings(
265 layout: &Layout,
266 roots: &[String],
267 migrations: bool,
268 changed: &[String],
269 added: &[String],
270) -> Vec<Finding> {
271 let mut out = Vec::new();
272 for root in pooled_scopes(roots) {
273 if !pooled_surface_touched(changed, root) {
274 continue;
275 }
276 let pkg = root.map(package_name);
277 for kind in missing_kinds(layout, added, pkg, migrations) {
278 out.push(owed_pooled(root, pkg, kind));
279 }
280 }
281 out
282}
283
284fn pooled_scopes(roots: &[String]) -> Vec<Option<&str>> {
286 if roots.is_empty() {
287 return vec![None];
288 }
289 roots.iter().map(|root| Some(root.as_str())).collect()
290}
291
292fn pooled_surface_touched(changed: &[String], root: Option<&str>) -> bool {
294 match root {
295 Some(root) => code_touched(changed, root),
296 None => changed.iter().any(|path| !exempt_at_any_boundary(path)),
297 }
298}
299
300fn package_name(root: &str) -> &str {
302 root.rsplit('/').next().unwrap_or(root)
303}
304
305pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
307 git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
308}
309
310pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
312 let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
313 Ok(lines(&out))
314}
315
316pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
319 let range = format!("{base}...HEAD");
320 let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
321 Ok(lines(&out))
322}
323
324fn phrasing(kind: &str) -> (&'static str, &'static str) {
327 match kind {
328 "migrations" => (
329 "marked a breaking change",
330 "drop the `breaking:` line if the change is not breaking",
331 ),
332 _ => (
333 "changed public surface",
334 "put a `skip-changelog: <reason>` line on any commit for a genuinely internal \
335 refactor",
336 ),
337 }
338}
339
340fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
341 named(scope, &format!("{dir}/YYYY-MM-DD-<slug>.md"), kind)
342}
343
344fn owed_pooled(root: Option<&str>, pkg: Option<&str>, kind: &str) -> Finding {
347 let scope = root.map_or_else(String::new, |root| format!("{root} "));
348 let slug = pkg.map_or_else(|| "<slug>".to_string(), |pkg| format!("{pkg}-<slug>"));
349 named(&scope, &format!("{kind}.d/YYYY-MM-DD-{slug}.md"), kind)
350}
351
352fn named(scope: &str, fragment: &str, kind: &str) -> Finding {
354 let (did, way_out) = phrasing(kind);
355 Finding {
356 file: None,
357 message: format!(
358 "{scope}{did} without adding a {kind} fragment. Add {fragment}, or {way_out}. See \
359 docs/reference/checks/changelog."
360 ),
361 }
362}
363
364struct Fragment {
366 pkg: Option<String>,
368 kind: &'static str,
369 name: String,
370}
371
372fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
373 let segs: Vec<&str> = path.split('/').collect();
374 let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
375 let kind = kind_of(segs[i])?;
376 if i + 2 != segs.len() {
377 return None;
378 }
379 let pkg = fragment_scope(&segs, i, layout)?;
380 Some(Fragment {
381 pkg,
382 kind,
383 name: segs[i + 1].to_string(),
384 })
385}
386
387fn fragment_scope(segs: &[&str], i: usize, layout: &Layout) -> Option<Option<String>> {
391 match layout {
392 Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
393 Some(Some(format!("{}/{}", segs[0], segs[1])))
394 }
395 Layout::PerPackage(_) => None,
396 Layout::Pooled(_) => Some(None),
397 }
398}
399
400fn records(layout: &Layout, frag: &Fragment, scope: Option<&str>) -> bool {
405 match layout {
406 Layout::PerPackage(_) => frag.pkg.as_deref() == scope,
407 Layout::Pooled(_) => names_package(&frag.name, scope),
408 }
409}
410
411fn names_package(name: &str, pkg: Option<&str>) -> bool {
417 let Some(pkg) = pkg else {
418 return true;
419 };
420 name.strip_suffix(".md")
421 .and_then(|stem| stem.get(11..))
422 .and_then(|slug| slug.strip_prefix(pkg))
423 .is_some_and(|rest| rest.starts_with('-'))
424}
425
426fn kind_of(segment: &str) -> Option<&'static str> {
427 let stem = segment.strip_suffix(".d")?;
428 KINDS.into_iter().find(|kind| *kind == stem)
429}
430
431fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
434 changed
435 .iter()
436 .filter(|path| {
437 fragment(path, layout)
438 .is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
439 })
440 .cloned()
441 .collect()
442}
443
444fn missing_kinds(
445 layout: &Layout,
446 added: &[String],
447 scope: Option<&str>,
448 migrations: bool,
449) -> Vec<&'static str> {
450 let present: Vec<&'static str> = added
451 .iter()
452 .filter_map(|path| fragment(path, layout))
453 .filter(|frag| fragment_name_ok(&frag.name) && records(layout, frag, scope))
454 .map(|frag| frag.kind)
455 .collect();
456 KINDS
457 .into_iter()
458 .filter(|kind| migrations || *kind != "migrations")
459 .filter(|kind| !present.contains(kind))
460 .collect()
461}
462
463fn code_touched(changed: &[String], pkg: &str) -> bool {
464 let prefix = format!("{pkg}/");
465 changed
466 .iter()
467 .any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
468}
469
470fn exempt_at_any_boundary(rel: &str) -> bool {
471 std::iter::once(rel)
472 .chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
473 .any(exempt_shape)
474}
475
476fn exempt_shape(rel: &str) -> bool {
477 matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
478 || KINDS
479 .iter()
480 .any(|kind| rel.starts_with(&format!("{kind}.d/")))
481 || rel.starts_with("e2e-attestations/")
482 || (rel.contains('/')
483 && matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
484 || rel.ends_with("_test.py")
485 || is_test_or_spec(rel)
486}
487
488fn is_test_or_spec(rel: &str) -> bool {
489 let name = rel.rsplit('/').next().unwrap_or(rel);
490 ["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
491 .iter()
492 .any(|ext| {
493 name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
494 })
495}
496
497fn package_roots(root: &Path) -> Vec<String> {
502 let mut out: Vec<String> = child_dirs(root)
503 .iter()
504 .flat_map(|container| packages_in(root, container))
505 .collect();
506 out.sort();
507 out
508}
509
510fn packages_in(root: &Path, container: &str) -> Vec<String> {
512 let dir = root.join(container);
513 child_dirs(&dir)
514 .into_iter()
515 .filter(|name| crate::tiers::is_package_root(&dir.join(name)))
516 .map(|name| format!("{container}/{name}"))
517 .collect()
518}
519
520fn child_dirs(dir: &Path) -> Vec<String> {
522 let Ok(entries) = std::fs::read_dir(dir) else {
523 return Vec::new();
524 };
525 entries
526 .flatten()
527 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
528 .map(|entry| entry.file_name().to_string_lossy().into_owned())
529 .filter(|name| !name.starts_with('.') && !SKIPPED_DIRS.contains(&name.as_str()))
530 .collect()
531}
532
533fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
536 let mut out = Vec::new();
537 scan(root, &[], &mut out);
538 out
539}
540
541fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
542 let Ok(entries) = std::fs::read_dir(dir) else {
543 return;
544 };
545 for entry in entries.flatten() {
546 if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
547 continue;
548 }
549 let name = entry.file_name().to_string_lossy().into_owned();
550 if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
551 continue;
552 }
553 let mut segs = prefix.to_vec();
554 segs.push(name.clone());
555 if kind_of(&name).is_some() {
556 out.push(segs);
557 } else if segs.len() < 3 {
558 scan(&entry.path(), &segs, out);
559 }
560 }
561}
562
563fn git(repo: &Path, args: &[&str]) -> Result<String> {
564 let out = Command::new("git")
565 .current_dir(repo)
566 .args(args)
567 .output()
568 .with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
569 if !out.status.success() {
570 bail!(
571 "`git {}` failed: {}",
572 args.join(" "),
573 String::from_utf8_lossy(&out.stderr).trim()
574 );
575 }
576 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
577}
578
579fn lines(out: &str) -> Vec<String> {
580 out.lines()
581 .filter(|line| !line.is_empty())
582 .map(str::to_string)
583 .collect()
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn facts_with(layout: Option<Layout>, bodies: &str, changed: &[&str], added: &[&str]) -> Facts {
591 Facts {
592 layout,
593 bodies: bodies.to_string(),
594 changed: changed.iter().map(|s| s.to_string()).collect(),
595 added: added.iter().map(|s| s.to_string()).collect(),
596 }
597 }
598
599 #[test]
600 fn no_layout_skips_the_check_and_names_the_tree() {
601 let facts = facts_with(None, "", &["packages/parser/src/a.ts"], &[]);
602 let (code, lines) = verdict(Path::new("/repo"), &facts);
603 assert_eq!(code, 0);
604 assert_eq!(
605 lines,
606 vec!["No fragment directories under `/repo`; changelog check skipped."]
607 );
608 }
609
610 #[test]
611 fn a_skip_line_bypasses_a_tree_that_would_otherwise_owe_fragments() {
612 let owing = &["packages/parser/src/a.ts"];
613 let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
614 assert_eq!(
616 verdict(
617 Path::new("/repo"),
618 &facts_with(layout.clone(), "", owing, &[])
619 )
620 .0,
621 1
622 );
623 let (code, lines) = verdict(
624 Path::new("/repo"),
625 &facts_with(
626 layout,
627 "refactor: rename\n\nskip-changelog: no public surface",
628 owing,
629 &[],
630 ),
631 );
632 assert_eq!(code, 0);
633 assert_eq!(
634 lines,
635 vec!["A `skip-changelog:` line is present; changelog check bypassed."]
636 );
637 }
638
639 #[test]
640 fn a_breaking_line_makes_a_migrations_fragment_required() {
641 let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
642 let changed = &["packages/parser/src/a.ts"];
643 let paid = &["packages/parser/changelog.d/2026-10-06-change-a.md"];
644 assert_eq!(
646 verdict(
647 Path::new("/repo"),
648 &facts_with(layout.clone(), "", changed, paid)
649 )
650 .0,
651 0
652 );
653 let (code, lines) = verdict(
654 Path::new("/repo"),
655 &facts_with(
656 layout,
657 "feat!: drop the flag\n\nbreaking: the flag is gone",
658 changed,
659 paid,
660 ),
661 );
662 assert_eq!(code, 1);
663 assert_eq!(lines.len(), 1);
664 assert!(lines[0].contains("migrations fragment"));
665 assert!(lines[0].contains("breaking change"));
666 }
667
668 #[test]
669 fn a_migrations_fragment_no_breaking_line_asked_for_is_allowed() {
670 let facts = facts_with(
673 Some(Layout::PerPackage(vec!["packages".to_string()])),
674 "",
675 &["packages/parser/src/a.ts"],
676 &[
677 "packages/parser/changelog.d/2026-10-06-change-a.md",
678 "packages/parser/migrations.d/2026-10-06-change-a.md",
679 ],
680 );
681 assert_eq!(verdict(Path::new("/repo"), &facts).0, 0);
682 }
683
684 #[test]
685 fn a_breaking_line_is_read_only_at_the_opening_of_a_line() {
686 assert!(has_breaking_line("breaking: the flag is gone"));
687 assert!(has_breaking_line(
688 "feat!: drop it\n\nBREAKING: the flag is gone"
689 ));
690 assert!(!has_breaking_line(
691 "fix: a comment saying this is not breaking: really"
692 ));
693 assert!(!has_breaking_line("breaking the flag"));
694 assert!(!has_breaking_line(""));
695 }
696
697 #[test]
698 fn phrasing_tells_a_breaking_change_apart_from_a_surface_change() {
699 assert_eq!(
700 phrasing("migrations"),
701 (
702 "marked a breaking change",
703 "drop the `breaking:` line if the change is not breaking"
704 )
705 );
706 let (did, way_out) = phrasing("changelog");
707 assert_eq!(did, "changed public surface");
708 assert!(way_out.contains("skip-changelog: <reason>"));
709 }
710
711 #[test]
712 fn a_scope_that_added_its_fragment_passes() {
713 let facts = facts_with(
714 Some(Layout::PerPackage(vec!["packages".to_string()])),
715 "",
716 &["packages/parser/src/a.ts"],
717 &["packages/parser/changelog.d/2026-10-05-change-a.md"],
718 );
719 let (code, lines) = verdict(Path::new("/repo"), &facts);
720 assert_eq!(code, 0);
721 assert_eq!(
722 lines,
723 vec!["Every scope that changed public surface added its fragments."]
724 );
725 }
726
727 #[test]
728 fn an_unpaid_scope_fails_with_one_annotation_per_finding() {
729 let facts = facts_with(
730 Some(Layout::PerPackage(vec!["packages".to_string()])),
731 "",
732 &["packages/parser/src/a.ts"],
733 &[],
734 );
735 let (code, lines) = verdict(Path::new("/repo"), &facts);
736 assert_eq!(code, 1);
737 let found = findings(
739 facts.layout.as_ref().unwrap(),
740 has_breaking_line(&facts.bodies),
741 &facts.changed,
742 &facts.added,
743 );
744 assert_eq!(lines.len(), found.len());
745 assert!(lines.iter().all(|line| line.starts_with("::error")));
746 }
747
748 #[test]
749 fn a_malformed_fragment_name_is_a_finding_even_where_nothing_is_owed() {
750 let facts = facts_with(
752 Some(Layout::PerPackage(vec!["packages".to_string()])),
753 "",
754 &["packages/parser/changelog.d/Nope.md"],
755 &[],
756 );
757 let found = findings(
758 facts.layout.as_ref().unwrap(),
759 has_breaking_line(&facts.bodies),
760 &facts.changed,
761 &facts.added,
762 );
763 assert_eq!(found.len(), 1);
764 assert_eq!(
765 found[0].file.as_deref(),
766 Some("packages/parser/changelog.d/Nope.md")
767 );
768 assert!(found[0].message.starts_with("fragment filenames are"));
769 }
770
771 #[test]
772 fn a_scope_owes_only_where_it_is_in_a_container_and_its_code_changed() {
773 let containers = Some(Layout::PerPackage(vec!["packages".to_string()]));
774 let owed_for = |changed: &[&str]| {
775 let facts = facts_with(containers.clone(), "", changed, &[]);
776 findings(
777 facts.layout.as_ref().unwrap(),
778 has_breaking_line(&facts.bodies),
779 &facts.changed,
780 &facts.added,
781 )
782 };
783 assert!(owed_for(&["packages/parser/changelog.d/2026-10-05-ok.md"]).is_empty());
785 assert!(owed_for(&["internals/checks/src/a.py"]).is_empty());
787 assert_eq!(owed_for(&["packages/parser/src/a.ts"]).len(), 1);
789 }
790
791 #[test]
792 fn a_finding_with_a_file_annotates_that_file() {
793 let finding = Finding {
794 file: Some("packages/rust/src/lib.rs".into()),
795 message: "changed public surface".into(),
796 };
797 assert_eq!(
798 annotation(&finding),
799 "::error file=packages/rust/src/lib.rs::changed public surface"
800 );
801 }
802
803 #[test]
804 fn a_finding_with_no_file_annotates_the_workflow() {
805 let finding = Finding {
806 file: None,
807 message: "changed public surface".into(),
808 };
809 assert_eq!(annotation(&finding), "::error::changed public surface");
810 }
811
812 #[test]
813 fn owed_names_the_scope_kind_and_fragment_directory() {
814 let finding = owed(
815 "packages/parser ",
816 "packages/parser/changelog.d",
817 "changelog",
818 );
819 assert_eq!(finding.file, None);
820 assert!(finding
821 .message
822 .contains("packages/parser changed public surface"));
823 assert!(finding
824 .message
825 .contains("packages/parser/changelog.d/YYYY-MM-DD-<slug>.md"));
826 assert!(finding.message.contains("skip-changelog: <reason>"));
827 }
828
829 #[test]
830 fn fragment_recognizes_only_a_package_fragment_at_the_expected_depth() {
831 let layout = Layout::PerPackage(vec!["packages".to_string()]);
832 let found = fragment("packages/parser/changelog.d/2026-09-26-change.md", &layout).unwrap();
833 assert_eq!(found.pkg.as_deref(), Some("packages/parser"));
834 assert_eq!(found.kind, "changelog");
835 assert_eq!(found.name, "2026-09-26-change.md");
836 assert!(fragment("other/parser/changelog.d/2026-09-26-change.md", &layout).is_none());
837 assert!(fragment("packages/parser/changelog.d/nested/change.md", &layout).is_none());
838 }
839
840 #[test]
841 fn kind_of_accepts_only_the_two_fragment_directories() {
842 assert_eq!(kind_of("changelog.d"), Some("changelog"));
843 assert_eq!(kind_of("migrations.d"), Some("migrations"));
844 assert_eq!(kind_of("notes.d"), None);
845 assert_eq!(kind_of("changelog"), None);
846 }
847
848 #[test]
849 fn malformed_reports_entries_but_not_fragment_readmes() {
850 let layout = Layout::Pooled(Vec::new());
851 let changed = vec![
852 "changelog.d/README.md".to_string(),
853 "changelog.d/2026-09-26-valid.md".to_string(),
854 "migrations.d/bad-name.md".to_string(),
855 "src/bad-name.md".to_string(),
856 ];
857 assert_eq!(
858 malformed(&layout, &changed),
859 vec!["migrations.d/bad-name.md"]
860 );
861 }
862
863 #[test]
864 fn missing_kinds_requires_added_valid_fragments_for_the_same_package() {
865 let layout = Layout::PerPackage(vec!["packages".to_string()]);
866 let added = vec![
867 "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
868 "packages/parser/migrations.d/bad-name.md".to_string(),
869 "packages/other/migrations.d/2026-09-26-change.md".to_string(),
870 ];
871 assert_eq!(
872 missing_kinds(&layout, &added, Some("packages/parser"), true),
873 vec!["migrations"]
874 );
875 assert!(missing_kinds(&layout, &added, Some("packages/parser"), false).is_empty());
876 }
877
878 #[test]
879 fn code_touched_ignores_fragments_and_tests_inside_the_package() {
880 let pkg = "packages/parser";
881 let exempt = vec![
882 "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
883 "packages/parser/tests/parser.rs".to_string(),
884 "packages/other/src/parser.rs".to_string(),
885 ];
886 assert!(!code_touched(&exempt, pkg));
887 assert!(code_touched(
888 &["packages/parser/src/parser.rs".to_string()],
889 pkg
890 ));
891 }
892
893 #[test]
894 fn exempt_at_any_boundary_checks_nested_test_and_fragment_paths() {
895 assert!(exempt_at_any_boundary("packages/parser/tests/parser.rs"));
896 assert!(exempt_at_any_boundary(
897 "packages/parser/changelog.d/2026-09-26-change.md"
898 ));
899 assert!(!exempt_at_any_boundary("packages/parser/src/parser.rs"));
900 }
901
902 #[test]
903 fn exempt_shape_recognizes_archives_attestations_and_test_paths() {
904 assert!(exempt_shape("CHANGELOG.md"));
905 assert!(exempt_shape("MIGRATIONS.md"));
906 assert!(exempt_shape("e2e-attestations/run.json"));
907 assert!(exempt_shape("tests/parser.rs"));
908 assert!(exempt_shape("src/parser_test.py"));
909 assert!(!exempt_shape("src/parser.rs"));
910 }
911
912 #[test]
913 fn is_test_or_spec_matches_supported_extensions_only() {
914 assert!(is_test_or_spec("src/parser.test.rs"));
915 assert!(is_test_or_spec("src/parser.spec.tsx"));
916 assert!(!is_test_or_spec("src/parser.test.txt"));
917 assert!(!is_test_or_spec("src/parser.rs"));
918 }
919
920 #[test]
921 fn fragment_dirs_finds_shallow_directories_and_skips_build_trees() {
922 let root = std::env::temp_dir().join(format!("tc-changelog-inline-{}", std::process::id()));
923 std::fs::create_dir_all(root.join("packages/parser/changelog.d")).unwrap();
924 std::fs::create_dir_all(root.join("target/debug/migrations.d")).unwrap();
925 std::fs::create_dir_all(root.join("packages/parser/deep/migrations.d")).unwrap();
926 let dirs = fragment_dirs(&root);
927 assert_eq!(
928 dirs,
929 vec![vec![
930 "packages".to_string(),
931 "parser".to_string(),
932 "changelog.d".to_string()
933 ]]
934 );
935 std::fs::remove_dir_all(root).unwrap();
936 }
937
938 fn tree(slug: &str) -> std::path::PathBuf {
941 let root =
942 std::env::temp_dir().join(format!("tc-changelog-inline-{slug}-{}", std::process::id()));
943 std::fs::create_dir_all(&root).unwrap();
944 root
945 }
946
947 fn touch(root: &std::path::Path, rel: &str) {
948 let path = root.join(rel);
949 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
950 std::fs::write(path, "").unwrap();
951 }
952
953 #[test]
954 fn package_roots_are_the_manifest_holding_directories_inside_a_container() {
955 let root = tree("roots");
956 touch(&root, "packages/parser/package.json");
957 touch(&root, "crates/lexer/Cargo.toml");
958 touch(&root, "services/engine/pyproject.toml");
959 touch(&root, "docs/guide.md");
960 touch(&root, "package.json");
962 assert_eq!(
963 package_roots(&root),
964 vec![
965 "crates/lexer".to_string(),
966 "packages/parser".to_string(),
967 "services/engine".to_string(),
968 ]
969 );
970 std::fs::remove_dir_all(root).unwrap();
971 }
972
973 #[test]
974 fn the_package_walk_skips_dependency_and_build_directories() {
975 let root = tree("skips");
976 touch(&root, "node_modules/left-pad/package.json");
977 touch(&root, "target/debug/Cargo.toml");
978 touch(&root, ".git/modules/x/package.json");
979 touch(&root, "packages/node_modules/package.json");
980 assert_eq!(package_roots(&root), Vec::<String>::new());
981 std::fs::remove_dir_all(root).unwrap();
982 }
983
984 #[test]
985 fn a_directory_the_walk_cannot_read_holds_no_packages() {
986 let root = tree("unreadable");
988 let missing = root.join("absent");
989 assert_eq!(child_dirs(&missing), Vec::<String>::new());
990 assert_eq!(packages_in(&missing, "packages"), Vec::<String>::new());
991 std::fs::remove_dir_all(root).unwrap();
992 }
993
994 #[test]
995 fn a_pooled_layout_carries_the_package_roots_it_discovered() {
996 let root = tree("discover");
997 std::fs::create_dir_all(root.join("docs/changelog.d")).unwrap();
998 touch(&root, "packages/parser/package.json");
999 assert_eq!(
1000 discover_layout(&root),
1001 Some(Layout::Pooled(vec!["packages/parser".to_string()]))
1002 );
1003 std::fs::remove_dir_all(root).unwrap();
1004 }
1005
1006 #[test]
1007 fn names_package_reads_the_slug_after_the_date() {
1008 assert!(names_package(
1009 "2026-09-21-parser-drop-the-flag.md",
1010 Some("parser")
1011 ));
1012 assert!(!names_package(
1013 "2026-09-21-emitter-drop-the-flag.md",
1014 Some("parser")
1015 ));
1016 assert!(!names_package("2026-09-21-parserdrop.md", Some("parser")));
1019 assert!(!names_package("2026-09-21-parser.md", Some("parser")));
1020 assert!(!names_package("2026-09-21-parser-x", Some("parser")));
1022 assert!(names_package("2026-09-21-anything.md", None));
1024 }
1025
1026 #[test]
1027 fn records_reads_the_directory_per_package_and_the_name_when_pooled() {
1028 let frag = Fragment {
1029 pkg: Some("packages/parser".to_string()),
1030 kind: "changelog",
1031 name: "2026-09-21-emitter-drop-the-flag.md".to_string(),
1032 };
1033 let per_package = Layout::PerPackage(vec!["packages".to_string()]);
1036 assert!(records(&per_package, &frag, Some("packages/parser")));
1037 assert!(!records(&per_package, &frag, Some("packages/emitter")));
1038 let pooled = Layout::Pooled(vec!["packages/parser".to_string()]);
1039 assert!(!records(&pooled, &frag, Some("parser")));
1040 assert!(records(&pooled, &frag, Some("emitter")));
1041 }
1042
1043 #[test]
1044 fn pooled_scopes_fall_back_to_the_whole_tree() {
1045 let roots = vec!["packages/parser".to_string(), "crates/lexer".to_string()];
1046 assert_eq!(
1047 pooled_scopes(&roots),
1048 vec![Some("packages/parser"), Some("crates/lexer")]
1049 );
1050 assert_eq!(pooled_scopes(&[]), vec![None]);
1051 }
1052
1053 #[test]
1054 fn pooled_surface_is_the_package_root_or_every_non_exempt_path() {
1055 let changed = vec!["packages/parser/src/lex.py".to_string()];
1056 assert!(pooled_surface_touched(&changed, Some("packages/parser")));
1057 assert!(!pooled_surface_touched(&changed, Some("packages/emitter")));
1058 assert!(pooled_surface_touched(&changed, None));
1059 let exempt = vec!["packages/parser/src/lex_test.py".to_string()];
1060 assert!(!pooled_surface_touched(&exempt, None));
1061 }
1062
1063 #[test]
1064 fn package_name_is_the_last_segment_of_the_root() {
1065 assert_eq!(package_name("packages/parser"), "parser");
1066 assert_eq!(package_name("parser"), "parser");
1067 }
1068
1069 #[test]
1070 fn owed_pooled_names_the_package_in_the_fragment_it_asks_for() {
1071 let with_root = owed_pooled(Some("packages/parser"), Some("parser"), "migrations");
1072 assert_eq!(with_root.file, None);
1073 assert!(with_root.message.starts_with("packages/parser marked"));
1074 assert!(with_root
1075 .message
1076 .contains("migrations.d/YYYY-MM-DD-parser-<slug>.md"));
1077 let whole_tree = owed_pooled(None, None, "changelog");
1078 assert!(whole_tree.message.starts_with("changed public surface"));
1079 assert!(whole_tree
1080 .message
1081 .contains("changelog.d/YYYY-MM-DD-<slug>.md"));
1082 }
1083
1084 #[test]
1085 fn per_package_findings_asks_a_package_in_a_container_that_changed_code() {
1086 let layout = Layout::PerPackage(vec!["packages".to_string()]);
1087 let containers = vec!["packages".to_string()];
1088 let changed = vec!["packages/parser/src/lex.py".to_string()];
1089 assert_eq!(
1090 per_package_findings(&layout, &containers, false, &changed, &[]).len(),
1091 1
1092 );
1093 }
1094
1095 #[test]
1096 fn per_package_findings_skips_a_package_outside_every_container() {
1097 let layout = Layout::PerPackage(vec!["packages".to_string()]);
1098 let containers = vec!["packages".to_string()];
1099 let changed = vec!["vendor/parser/src/lex.py".to_string()];
1100 assert_eq!(
1101 per_package_findings(&layout, &containers, false, &changed, &[]),
1102 vec![]
1103 );
1104 }
1105
1106 #[test]
1107 fn per_package_findings_skips_a_package_whose_changed_paths_are_all_exempt() {
1108 let layout = Layout::PerPackage(vec!["packages".to_string()]);
1109 let containers = vec!["packages".to_string()];
1110 let changed = vec!["packages/parser/src/lex_test.py".to_string()];
1111 assert_eq!(
1112 per_package_findings(&layout, &containers, false, &changed, &[]),
1113 vec![]
1114 );
1115 }
1116
1117 #[test]
1118 fn pooled_findings_scope_each_package_root_separately() {
1119 let layout = Layout::Pooled(vec![
1120 "packages/emitter".to_string(),
1121 "packages/parser".to_string(),
1122 ]);
1123 let changed = vec![
1124 "packages/parser/src/lex.py".to_string(),
1125 "packages/emitter/src/emit.py".to_string(),
1126 "README.md".to_string(),
1127 ];
1128 let added = vec!["docs/changelog.d/2026-09-21-parser-drop-the-flag.md".to_string()];
1129 let found = findings(&layout, false, &changed, &added);
1130 assert_eq!(found.len(), 1);
1131 assert!(found[0].message.starts_with("packages/emitter changed"));
1132 }
1133}