Skip to main content

testing_conventions/
changelog.rs

1//! Changelog rule: a pull request that changes a package's public surface adds a fragment
2//! recording it. The layout is discovered from where the fragment directories sit, so a
3//! consumer declares nothing.
4
5use std::path::Path;
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10/// Where a repository keeps its fragments.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Layout {
13    /// One fragment directory per package; the payload is the directories holding the packages.
14    PerPackage(Vec<String>),
15    /// One fragment directory for the whole repository.
16    Pooled,
17}
18
19/// The two fragment kinds, in the order the check reports them missing.
20pub const KINDS: [&str; 2] = ["changelog", "migrations"];
21
22/// One thing a pull request owes, ready to render as an annotation.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Finding {
25    /// The offending file, where the finding has one.
26    pub file: Option<String>,
27    pub message: String,
28}
29
30/// Everything the changelog verdict reads from the repository.
31///
32/// Gathered in one go rather than on demand. The lazy shape — skip the git calls when no
33/// fragment directory exists — cannot be split into a testable decision and an untestable
34/// seam, because the laziness *is* the branch. Every repository behaves identically either
35/// way: a repository with no fragment directories still reports "skipped", because the
36/// layout, not the diff, decides that. Only a path that is not a repository at all changes,
37/// and it now errors rather than reporting a clean skip.
38struct Facts {
39    layout: Option<Layout>,
40    bodies: String,
41    changed: Vec<String>,
42    added: Vec<String>,
43}
44
45/// Read the repository facts the verdict decides on.
46fn facts(root: &Path, base: &str) -> Result<Facts> {
47    Ok(Facts {
48        layout: discover_layout(root),
49        bodies: commit_bodies(root, base)?,
50        changed: changed_files(root, base)?,
51        added: added_files(root, base)?,
52    })
53}
54
55/// The exit code and the lines to print for gathered `facts`. The whole decision, with no
56/// repository in reach: `0` for a tree keeping no fragments, `0` for a bypass, `0` when every
57/// scope paid, and `1` with one annotation per scope that did not.
58fn verdict(root: &Path, facts: &Facts) -> (i32, Vec<String>) {
59    let Some(layout) = &facts.layout else {
60        return (
61            0,
62            vec![format!(
63                "No fragment directories under `{}`; changelog check skipped.",
64                root.display()
65            )],
66        );
67    };
68    if has_skip_line(&facts.bodies) {
69        return (
70            0,
71            vec!["A `skip-changelog:` line is present; changelog check bypassed.".to_string()],
72        );
73    }
74    let breaking = has_breaking_line(&facts.bodies);
75    let found = findings(layout, breaking, &facts.changed, &facts.added);
76    if found.is_empty() {
77        return (
78            0,
79            vec!["Every scope that changed public surface added its fragments.".to_string()],
80        );
81    }
82    (1, found.iter().map(annotation).collect())
83}
84
85/// Report every scope in `<base>...HEAD` that changed public surface without adding the
86/// fragments recording it. `0` when `root` keeps no fragment directories.
87///
88/// Branch-free on purpose: it reads the repository through [`facts`], so replacing its body
89/// whole has no unit-tier contract. [`verdict`] holds the decision and is tested directly.
90pub fn run(root: &Path, base: &str) -> Result<i32> {
91    let (code, lines) = verdict(root, &facts(root, base)?);
92    println!("{}", lines.join("\n"));
93    Ok(code)
94}
95
96/// `finding` as the GitHub Actions annotation that reports it: file-scoped when the finding
97/// names a file, workflow-scoped when it does not.
98pub fn annotation(finding: &Finding) -> String {
99    match &finding.file {
100        Some(file) => format!("::error file={file}::{}", finding.message),
101        None => format!("::error::{}", finding.message),
102    }
103}
104
105/// Directories the fragment walk never descends into.
106const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
107
108/// The layout `root` keeps its fragments in, or `None` when it keeps none.
109pub fn discover_layout(root: &Path) -> Option<Layout> {
110    let dirs = fragment_dirs(root);
111    if dirs.is_empty() {
112        return None;
113    }
114    let mut containers: Vec<String> = dirs
115        .iter()
116        .filter(|segs| segs.len() == 3)
117        .map(|segs| segs[0].clone())
118        .collect();
119    containers.sort();
120    containers.dedup();
121    if containers.is_empty() {
122        return Some(Layout::Pooled);
123    }
124    Some(Layout::PerPackage(containers))
125}
126
127/// `true` when `name` is `YYYY-MM-DD-<slug>.md` — the UTC merge date, then lowercase letters,
128/// digits and hyphens.
129pub fn fragment_name_ok(name: &str) -> bool {
130    let Some(stem) = name.strip_suffix(".md") else {
131        return false;
132    };
133    let bytes = stem.as_bytes();
134    if bytes.len() < 12 {
135        return false;
136    }
137    let digit = |i: usize| bytes[i].is_ascii_digit();
138    let dated = (0..4).all(digit)
139        && bytes[4] == b'-'
140        && (5..7).all(digit)
141        && bytes[7] == b'-'
142        && (8..10).all(digit)
143        && bytes[10] == b'-';
144    dated
145        && bytes[11..]
146            .iter()
147            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
148}
149
150/// `true` when any line of `bodies` opens with `marker`, whatever its case.
151fn has_line_opening(bodies: &str, marker: &[u8]) -> bool {
152    bodies.lines().any(|line| {
153        let bytes = line.as_bytes();
154        bytes.len() >= marker.len() && bytes[..marker.len()].eq_ignore_ascii_case(marker)
155    })
156}
157
158/// `true` when any line of `bodies` opens with `skip-changelog:` — the bypass.
159pub fn has_skip_line(bodies: &str) -> bool {
160    has_line_opening(bodies, b"skip-changelog:")
161}
162
163/// `true` when any line of `bodies` opens with `breaking:` — the signal that the pull request
164/// owes a migration fragment. Opt in: a breaking change whose commits carry no such line gets
165/// no migrations enforcement, which is the trade for the check needing no configuration.
166pub fn has_breaking_line(bodies: &str) -> bool {
167    has_line_opening(bodies, b"breaking:")
168}
169
170/// `true` when `path` sits under `pkg` and is not public surface.
171pub fn is_exempt(path: &str, pkg: &str) -> bool {
172    path.strip_prefix(pkg)
173        .and_then(|rest| rest.strip_prefix('/'))
174        .is_some_and(exempt_at_any_boundary)
175}
176
177/// The package directories `changed` touches, unique and sorted.
178pub fn changed_packages(changed: &[String]) -> Vec<String> {
179    let mut out: Vec<String> = changed
180        .iter()
181        .filter_map(|path| {
182            let segs: Vec<&str> = path.split('/').collect();
183            (segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
184        })
185        .collect();
186    out.sort();
187    out.dedup();
188    out
189}
190
191/// Everything the pull request owes: fragments whose names break the convention, then the
192/// fragments each changed scope is still missing.
193///
194/// `migrations` is whether the pull request marked a breaking change — see
195/// [`has_breaking_line`]. A migration fragment is required when it did, and allowed, never
196/// forbidden, when it did not.
197pub fn findings(
198    layout: &Layout,
199    migrations: bool,
200    changed: &[String],
201    added: &[String],
202) -> Vec<Finding> {
203    let mut out = malformed_findings(layout, changed);
204    out.extend(owed_findings(layout, migrations, changed, added));
205    out
206}
207
208/// A finding per touched fragment whose filename breaks the convention.
209fn malformed_findings(layout: &Layout, changed: &[String]) -> Vec<Finding> {
210    malformed(layout, changed)
211        .into_iter()
212        .map(|path| Finding {
213            file: Some(path),
214            message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
215                      lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
216                .to_string(),
217        })
218        .collect()
219}
220
221/// A finding per fragment a changed scope still owes. Under the per-package layout a scope owes
222/// only where it sits in a declared container and the change touched its code.
223fn owed_findings(
224    layout: &Layout,
225    migrations: bool,
226    changed: &[String],
227    added: &[String],
228) -> Vec<Finding> {
229    let mut out = Vec::new();
230    match layout {
231        Layout::PerPackage(containers) => {
232            for pkg in changed_packages(changed) {
233                let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
234                if in_a_container && code_touched(changed, &pkg) {
235                    out.extend(package_owed(layout, migrations, added, &pkg));
236                }
237            }
238        }
239        Layout::Pooled => {
240            if changed.iter().any(|path| !exempt_at_any_boundary(path)) {
241                for kind in missing_kinds(layout, added, None, migrations) {
242                    out.push(owed("", &format!("{kind}.d"), kind));
243                }
244            }
245        }
246    }
247    out
248}
249
250/// A finding per fragment the package `pkg` still owes, each naming the directory it belongs in.
251fn package_owed(layout: &Layout, migrations: bool, added: &[String], pkg: &str) -> Vec<Finding> {
252    missing_kinds(layout, added, Some(pkg), migrations)
253        .into_iter()
254        .map(|kind| owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind))
255        .collect()
256}
257
258/// The bodies of every commit in `<base>..HEAD`, concatenated.
259pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
260    git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
261}
262
263/// Every path `<base>...HEAD` changed.
264pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
265    let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
266    Ok(lines(&out))
267}
268
269/// The paths `<base>...HEAD` added. A fragment satisfies the check only when the pull request
270/// adds it, so the diff is filtered to additions.
271pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
272    let range = format!("{base}...HEAD");
273    let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
274    Ok(lines(&out))
275}
276
277/// What a pull request missing a fragment of `kind` did, and the way out of owing it. A lookup
278/// with a default rather than a comparison, so the two kinds read side by side.
279fn phrasing(kind: &str) -> (&'static str, &'static str) {
280    match kind {
281        "migrations" => (
282            "marked a breaking change",
283            "drop the `breaking:` line if the change is not breaking",
284        ),
285        _ => (
286            "changed public surface",
287            "put a `skip-changelog: <reason>` line on any commit for a genuinely internal \
288             refactor",
289        ),
290    }
291}
292
293fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
294    let (did, way_out) = phrasing(kind);
295    Finding {
296        file: None,
297        message: format!(
298            "{scope}{did} without adding a {kind} fragment. Add {dir}/YYYY-MM-DD-<slug>.md, or \
299             {way_out}. See docs/reference/checks/changelog."
300        ),
301    }
302}
303
304/// A fragment path, split into the scope that owns it, its kind, and its filename.
305struct Fragment {
306    /// The owning package, or `None` under the pooled layout.
307    pkg: Option<String>,
308    kind: &'static str,
309    name: String,
310}
311
312fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
313    let segs: Vec<&str> = path.split('/').collect();
314    let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
315    let kind = kind_of(segs[i])?;
316    if i + 2 != segs.len() {
317        return None;
318    }
319    let pkg = fragment_scope(&segs, i, layout)?;
320    Some(Fragment {
321        pkg,
322        kind,
323        name: segs[i + 1].to_string(),
324    })
325}
326
327/// The scope owning a fragment whose kind directory sits at `segs[i]`: the package under the
328/// per-package layout, `Some(None)` under the pooled one. `None` where the layout places no
329/// fragment there at all, so a stray path is not read as an entry.
330fn fragment_scope(segs: &[&str], i: usize, layout: &Layout) -> Option<Option<String>> {
331    match layout {
332        Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
333            Some(Some(format!("{}/{}", segs[0], segs[1])))
334        }
335        Layout::PerPackage(_) => None,
336        Layout::Pooled => Some(None),
337    }
338}
339
340fn kind_of(segment: &str) -> Option<&'static str> {
341    let stem = segment.strip_suffix(".d")?;
342    KINDS.into_iter().find(|kind| *kind == stem)
343}
344
345/// Touched fragment paths whose filenames break the convention. Each fragment directory carries
346/// a `README.md` describing that convention, which is not an entry.
347fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
348    changed
349        .iter()
350        .filter(|path| {
351            fragment(path, layout)
352                .is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
353        })
354        .cloned()
355        .collect()
356}
357
358fn missing_kinds(
359    layout: &Layout,
360    added: &[String],
361    pkg: Option<&str>,
362    migrations: bool,
363) -> Vec<&'static str> {
364    let present: Vec<&'static str> = added
365        .iter()
366        .filter_map(|path| fragment(path, layout))
367        .filter(|frag| fragment_name_ok(&frag.name) && frag.pkg.as_deref() == pkg)
368        .map(|frag| frag.kind)
369        .collect();
370    KINDS
371        .into_iter()
372        .filter(|kind| migrations || *kind != "migrations")
373        .filter(|kind| !present.contains(kind))
374        .collect()
375}
376
377fn code_touched(changed: &[String], pkg: &str) -> bool {
378    let prefix = format!("{pkg}/");
379    changed
380        .iter()
381        .any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
382}
383
384fn exempt_at_any_boundary(rel: &str) -> bool {
385    std::iter::once(rel)
386        .chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
387        .any(exempt_shape)
388}
389
390fn exempt_shape(rel: &str) -> bool {
391    matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
392        || KINDS
393            .iter()
394            .any(|kind| rel.starts_with(&format!("{kind}.d/")))
395        || rel.starts_with("e2e-attestations/")
396        || (rel.contains('/')
397            && matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
398        || rel.ends_with("_test.py")
399        || is_test_or_spec(rel)
400}
401
402fn is_test_or_spec(rel: &str) -> bool {
403    let name = rel.rsplit('/').next().unwrap_or(rel);
404    ["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
405        .iter()
406        .any(|ext| {
407            name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
408        })
409}
410
411/// Every fragment directory under `root`, as its root-relative segments. The convention puts a
412/// fragment directory at most two levels down, which bounds the walk.
413fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
414    let mut out = Vec::new();
415    scan(root, &[], &mut out);
416    out
417}
418
419fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
420    let Ok(entries) = std::fs::read_dir(dir) else {
421        return;
422    };
423    for entry in entries.flatten() {
424        if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
425            continue;
426        }
427        let name = entry.file_name().to_string_lossy().into_owned();
428        if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
429            continue;
430        }
431        let mut segs = prefix.to_vec();
432        segs.push(name.clone());
433        if kind_of(&name).is_some() {
434            out.push(segs);
435        } else if segs.len() < 3 {
436            scan(&entry.path(), &segs, out);
437        }
438    }
439}
440
441fn git(repo: &Path, args: &[&str]) -> Result<String> {
442    let out = Command::new("git")
443        .current_dir(repo)
444        .args(args)
445        .output()
446        .with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
447    if !out.status.success() {
448        bail!(
449            "`git {}` failed: {}",
450            args.join(" "),
451            String::from_utf8_lossy(&out.stderr).trim()
452        );
453    }
454    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
455}
456
457fn lines(out: &str) -> Vec<String> {
458    out.lines()
459        .filter(|line| !line.is_empty())
460        .map(str::to_string)
461        .collect()
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    fn facts_with(layout: Option<Layout>, bodies: &str, changed: &[&str], added: &[&str]) -> Facts {
469        Facts {
470            layout,
471            bodies: bodies.to_string(),
472            changed: changed.iter().map(|s| s.to_string()).collect(),
473            added: added.iter().map(|s| s.to_string()).collect(),
474        }
475    }
476
477    #[test]
478    fn no_layout_skips_the_check_and_names_the_tree() {
479        let facts = facts_with(None, "", &["packages/parser/src/a.ts"], &[]);
480        let (code, lines) = verdict(Path::new("/repo"), &facts);
481        assert_eq!(code, 0);
482        assert_eq!(
483            lines,
484            vec!["No fragment directories under `/repo`; changelog check skipped."]
485        );
486    }
487
488    #[test]
489    fn a_skip_line_bypasses_a_tree_that_would_otherwise_owe_fragments() {
490        let owing = &["packages/parser/src/a.ts"];
491        let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
492        // Same facts, with and without the trailer: the trailer is the only difference.
493        assert_eq!(
494            verdict(
495                Path::new("/repo"),
496                &facts_with(layout.clone(), "", owing, &[])
497            )
498            .0,
499            1
500        );
501        let (code, lines) = verdict(
502            Path::new("/repo"),
503            &facts_with(
504                layout,
505                "refactor: rename\n\nskip-changelog: no public surface",
506                owing,
507                &[],
508            ),
509        );
510        assert_eq!(code, 0);
511        assert_eq!(
512            lines,
513            vec!["A `skip-changelog:` line is present; changelog check bypassed."]
514        );
515    }
516
517    #[test]
518    fn a_breaking_line_makes_a_migrations_fragment_required() {
519        let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
520        let changed = &["packages/parser/src/a.ts"];
521        let paid = &["packages/parser/changelog.d/2026-10-06-change-a.md"];
522        // Same facts, with and without the line: the line is the only difference.
523        assert_eq!(
524            verdict(
525                Path::new("/repo"),
526                &facts_with(layout.clone(), "", changed, paid)
527            )
528            .0,
529            0
530        );
531        let (code, lines) = verdict(
532            Path::new("/repo"),
533            &facts_with(
534                layout,
535                "feat!: drop the flag\n\nbreaking: the flag is gone",
536                changed,
537                paid,
538            ),
539        );
540        assert_eq!(code, 1);
541        assert_eq!(lines.len(), 1);
542        assert!(lines[0].contains("migrations fragment"));
543        assert!(lines[0].contains("breaking change"));
544    }
545
546    #[test]
547    fn a_migrations_fragment_no_breaking_line_asked_for_is_allowed() {
548        // The requirement is an implication, not an equality: an unsolicited migration
549        // fragment is allowed.
550        let facts = facts_with(
551            Some(Layout::PerPackage(vec!["packages".to_string()])),
552            "",
553            &["packages/parser/src/a.ts"],
554            &[
555                "packages/parser/changelog.d/2026-10-06-change-a.md",
556                "packages/parser/migrations.d/2026-10-06-change-a.md",
557            ],
558        );
559        assert_eq!(verdict(Path::new("/repo"), &facts).0, 0);
560    }
561
562    #[test]
563    fn a_breaking_line_is_read_only_at_the_opening_of_a_line() {
564        assert!(has_breaking_line("breaking: the flag is gone"));
565        assert!(has_breaking_line(
566            "feat!: drop it\n\nBREAKING: the flag is gone"
567        ));
568        assert!(!has_breaking_line(
569            "fix: a comment saying this is not breaking: really"
570        ));
571        assert!(!has_breaking_line("breaking the flag"));
572        assert!(!has_breaking_line(""));
573    }
574
575    #[test]
576    fn phrasing_tells_a_breaking_change_apart_from_a_surface_change() {
577        assert_eq!(
578            phrasing("migrations"),
579            (
580                "marked a breaking change",
581                "drop the `breaking:` line if the change is not breaking"
582            )
583        );
584        let (did, way_out) = phrasing("changelog");
585        assert_eq!(did, "changed public surface");
586        assert!(way_out.contains("skip-changelog: <reason>"));
587    }
588
589    #[test]
590    fn a_scope_that_added_its_fragment_passes() {
591        let facts = facts_with(
592            Some(Layout::PerPackage(vec!["packages".to_string()])),
593            "",
594            &["packages/parser/src/a.ts"],
595            &["packages/parser/changelog.d/2026-10-05-change-a.md"],
596        );
597        let (code, lines) = verdict(Path::new("/repo"), &facts);
598        assert_eq!(code, 0);
599        assert_eq!(
600            lines,
601            vec!["Every scope that changed public surface added its fragments."]
602        );
603    }
604
605    #[test]
606    fn an_unpaid_scope_fails_with_one_annotation_per_finding() {
607        let facts = facts_with(
608            Some(Layout::PerPackage(vec!["packages".to_string()])),
609            "",
610            &["packages/parser/src/a.ts"],
611            &[],
612        );
613        let (code, lines) = verdict(Path::new("/repo"), &facts);
614        assert_eq!(code, 1);
615        // One line per finding, each already rendered as an annotation.
616        let found = findings(
617            facts.layout.as_ref().unwrap(),
618            has_breaking_line(&facts.bodies),
619            &facts.changed,
620            &facts.added,
621        );
622        assert_eq!(lines.len(), found.len());
623        assert!(lines.iter().all(|line| line.starts_with("::error")));
624    }
625
626    #[test]
627    fn a_malformed_fragment_name_is_a_finding_even_where_nothing_is_owed() {
628        // `changelog.d/` is exempt from `code_touched`, so the only finding can be the name.
629        let facts = facts_with(
630            Some(Layout::PerPackage(vec!["packages".to_string()])),
631            "",
632            &["packages/parser/changelog.d/Nope.md"],
633            &[],
634        );
635        let found = findings(
636            facts.layout.as_ref().unwrap(),
637            facts.migrations,
638            &facts.changed,
639            &facts.added,
640        );
641        assert_eq!(found.len(), 1);
642        assert_eq!(
643            found[0].file.as_deref(),
644            Some("packages/parser/changelog.d/Nope.md")
645        );
646        assert!(found[0].message.starts_with("fragment filenames are"));
647    }
648
649    #[test]
650    fn a_scope_owes_only_where_it_is_in_a_container_and_its_code_changed() {
651        let containers = Some(Layout::PerPackage(vec!["packages".to_string()]));
652        let owed_for = |changed: &[&str]| {
653            let facts = facts_with(containers.clone(), "", changed, &[]);
654            findings(
655                facts.layout.as_ref().unwrap(),
656                facts.migrations,
657                &facts.changed,
658                &facts.added,
659            )
660        };
661        // In a container, but only its own fragment directory moved: nothing owed.
662        assert!(owed_for(&["packages/parser/changelog.d/2026-10-05-ok.md"]).is_empty());
663        // Code changed, but outside every declared container: nothing owed.
664        assert!(owed_for(&["internals/checks/src/a.py"]).is_empty());
665        // Both together is the only case that owes.
666        assert_eq!(owed_for(&["packages/parser/src/a.ts"]).len(), 1);
667    }
668
669    #[test]
670    fn a_finding_with_a_file_annotates_that_file() {
671        let finding = Finding {
672            file: Some("packages/rust/src/lib.rs".into()),
673            message: "changed public surface".into(),
674        };
675        assert_eq!(
676            annotation(&finding),
677            "::error file=packages/rust/src/lib.rs::changed public surface"
678        );
679    }
680
681    #[test]
682    fn a_finding_with_no_file_annotates_the_workflow() {
683        let finding = Finding {
684            file: None,
685            message: "changed public surface".into(),
686        };
687        assert_eq!(annotation(&finding), "::error::changed public surface");
688    }
689
690    #[test]
691    fn owed_names_the_scope_kind_and_fragment_directory() {
692        let finding = owed(
693            "packages/parser ",
694            "packages/parser/changelog.d",
695            "changelog",
696        );
697        assert_eq!(finding.file, None);
698        assert!(finding
699            .message
700            .contains("packages/parser changed public surface"));
701        assert!(finding
702            .message
703            .contains("packages/parser/changelog.d/YYYY-MM-DD-<slug>.md"));
704        assert!(finding.message.contains("skip-changelog: <reason>"));
705    }
706
707    #[test]
708    fn fragment_recognizes_only_a_package_fragment_at_the_expected_depth() {
709        let layout = Layout::PerPackage(vec!["packages".to_string()]);
710        let found = fragment("packages/parser/changelog.d/2026-09-26-change.md", &layout).unwrap();
711        assert_eq!(found.pkg.as_deref(), Some("packages/parser"));
712        assert_eq!(found.kind, "changelog");
713        assert_eq!(found.name, "2026-09-26-change.md");
714        assert!(fragment("other/parser/changelog.d/2026-09-26-change.md", &layout).is_none());
715        assert!(fragment("packages/parser/changelog.d/nested/change.md", &layout).is_none());
716    }
717
718    #[test]
719    fn kind_of_accepts_only_the_two_fragment_directories() {
720        assert_eq!(kind_of("changelog.d"), Some("changelog"));
721        assert_eq!(kind_of("migrations.d"), Some("migrations"));
722        assert_eq!(kind_of("notes.d"), None);
723        assert_eq!(kind_of("changelog"), None);
724    }
725
726    #[test]
727    fn malformed_reports_entries_but_not_fragment_readmes() {
728        let layout = Layout::Pooled;
729        let changed = vec![
730            "changelog.d/README.md".to_string(),
731            "changelog.d/2026-09-26-valid.md".to_string(),
732            "migrations.d/bad-name.md".to_string(),
733            "src/bad-name.md".to_string(),
734        ];
735        assert_eq!(
736            malformed(&layout, &changed),
737            vec!["migrations.d/bad-name.md"]
738        );
739    }
740
741    #[test]
742    fn missing_kinds_requires_added_valid_fragments_for_the_same_package() {
743        let layout = Layout::PerPackage(vec!["packages".to_string()]);
744        let added = vec![
745            "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
746            "packages/parser/migrations.d/bad-name.md".to_string(),
747            "packages/other/migrations.d/2026-09-26-change.md".to_string(),
748        ];
749        assert_eq!(
750            missing_kinds(&layout, &added, Some("packages/parser"), true),
751            vec!["migrations"]
752        );
753        assert!(missing_kinds(&layout, &added, Some("packages/parser"), false).is_empty());
754    }
755
756    #[test]
757    fn code_touched_ignores_fragments_and_tests_inside_the_package() {
758        let pkg = "packages/parser";
759        let exempt = vec![
760            "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
761            "packages/parser/tests/parser.rs".to_string(),
762            "packages/other/src/parser.rs".to_string(),
763        ];
764        assert!(!code_touched(&exempt, pkg));
765        assert!(code_touched(
766            &["packages/parser/src/parser.rs".to_string()],
767            pkg
768        ));
769    }
770
771    #[test]
772    fn exempt_at_any_boundary_checks_nested_test_and_fragment_paths() {
773        assert!(exempt_at_any_boundary("packages/parser/tests/parser.rs"));
774        assert!(exempt_at_any_boundary(
775            "packages/parser/changelog.d/2026-09-26-change.md"
776        ));
777        assert!(!exempt_at_any_boundary("packages/parser/src/parser.rs"));
778    }
779
780    #[test]
781    fn exempt_shape_recognizes_archives_attestations_and_test_paths() {
782        assert!(exempt_shape("CHANGELOG.md"));
783        assert!(exempt_shape("MIGRATIONS.md"));
784        assert!(exempt_shape("e2e-attestations/run.json"));
785        assert!(exempt_shape("tests/parser.rs"));
786        assert!(exempt_shape("src/parser_test.py"));
787        assert!(!exempt_shape("src/parser.rs"));
788    }
789
790    #[test]
791    fn is_test_or_spec_matches_supported_extensions_only() {
792        assert!(is_test_or_spec("src/parser.test.rs"));
793        assert!(is_test_or_spec("src/parser.spec.tsx"));
794        assert!(!is_test_or_spec("src/parser.test.txt"));
795        assert!(!is_test_or_spec("src/parser.rs"));
796    }
797
798    #[test]
799    fn fragment_dirs_finds_shallow_directories_and_skips_build_trees() {
800        let root = std::env::temp_dir().join(format!("tc-changelog-inline-{}", std::process::id()));
801        std::fs::create_dir_all(root.join("packages/parser/changelog.d")).unwrap();
802        std::fs::create_dir_all(root.join("target/debug/migrations.d")).unwrap();
803        std::fs::create_dir_all(root.join("packages/parser/deep/migrations.d")).unwrap();
804        let dirs = fragment_dirs(&root);
805        assert_eq!(
806            dirs,
807            vec![vec![
808                "packages".to_string(),
809                "parser".to_string(),
810                "changelog.d".to_string()
811            ]]
812        );
813        std::fs::remove_dir_all(root).unwrap();
814    }
815}