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; the payload is the package roots whose
16    /// surface those fragments pay for, empty when the tree has no discoverable package root.
17    Pooled(Vec<String>),
18}
19
20/// The two fragment kinds, in the order the check reports them missing.
21pub const KINDS: [&str; 2] = ["changelog", "migrations"];
22
23/// One thing a pull request owes, ready to render as an annotation.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Finding {
26    /// The offending file, where the finding has one.
27    pub file: Option<String>,
28    pub message: String,
29}
30
31/// Everything the changelog verdict reads from the repository.
32///
33/// Gathered in one go rather than on demand. The lazy shape — skip the git calls when no
34/// fragment directory exists — cannot be split into a testable decision and an untestable
35/// seam, because the laziness *is* the branch. Every repository behaves identically either
36/// way: a repository with no fragment directories still reports "skipped", because the
37/// layout, not the diff, decides that. Only a path that is not a repository at all changes,
38/// and it now errors rather than reporting a clean skip.
39struct Facts {
40    layout: Option<Layout>,
41    bodies: String,
42    changed: Vec<String>,
43    added: Vec<String>,
44}
45
46/// Read the repository facts the verdict decides on.
47fn 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
56/// The exit code and the lines to print for gathered `facts`. The whole decision, with no
57/// repository in reach: `0` for a tree keeping no fragments, `0` for a bypass, `0` when every
58/// scope paid, and `1` with one annotation per scope that did not.
59fn 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
86/// Report every scope in `<base>...HEAD` that changed public surface without adding the
87/// fragments recording it. `0` when `root` keeps no fragment directories.
88///
89/// Branch-free on purpose: it reads the repository through [`facts`], so replacing its body
90/// whole has no unit-tier contract. [`verdict`] holds the decision and is tested directly.
91pub 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
97/// `finding` as the GitHub Actions annotation that reports it: file-scoped when the finding
98/// names a file, workflow-scoped when it does not.
99pub 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
106/// Directories the fragment walk never descends into.
107const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
108
109/// The layout `root` keeps its fragments in, or `None` when it keeps none.
110pub 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
128/// `true` when `name` is `YYYY-MM-DD-<slug>.md` — the UTC merge date, then lowercase letters,
129/// digits and hyphens.
130pub 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
151/// `true` when any line of `bodies` opens with `marker`, whatever its case.
152fn 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
159/// `true` when any line of `bodies` opens with `skip-changelog:` — the bypass.
160pub fn has_skip_line(bodies: &str) -> bool {
161    has_line_opening(bodies, b"skip-changelog:")
162}
163
164/// `true` when any line of `bodies` opens with `breaking:` — the signal that the pull request
165/// owes a migration fragment. Opt in: a breaking change whose commits carry no such line gets
166/// no migrations enforcement, which is the trade for the check needing no configuration.
167pub fn has_breaking_line(bodies: &str) -> bool {
168    has_line_opening(bodies, b"breaking:")
169}
170
171/// `true` when `path` sits under `pkg` and is not public surface.
172pub 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
178/// The package directories `changed` touches, unique and sorted.
179pub 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
192/// Everything the pull request owes: fragments whose names break the convention, then the
193/// fragments each changed scope is still missing.
194///
195/// `migrations` is whether the pull request marked a breaking change — see
196/// [`has_breaking_line`]. A migration fragment is required when it did, and allowed, never
197/// forbidden, when it did not.
198pub 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
209/// A finding per touched fragment whose filename breaks the convention.
210fn 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
222/// A finding per fragment a changed scope still owes. The per-package layout asks only a package
223/// sitting in a declared container; the pooled layout scopes public surface to its discovered roots.
224fn 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
240/// What each changed package under one of `containers` still owes. The fragment directory sits
241/// inside the package, so the directory names the package and the filename need not.
242fn 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
261/// What each changed package root still owes, when one pooled directory holds every package's
262/// fragments. Public surface is scoped to `roots`, so a path under none of them owes nothing; a
263/// tree with no discoverable root is one package at its own root, whose whole surface counts.
264fn 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
284/// The scopes a pooled tree answers for: each package root, or the tree itself when it has none.
285fn 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
292/// `true` when `changed` touches the public surface of pooled scope `root`.
293fn 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
300/// The name a fragment calls package `root` by: its own directory name.
301fn package_name(root: &str) -> &str {
302    root.rsplit('/').next().unwrap_or(root)
303}
304
305/// The bodies of every commit in `<base>..HEAD`, concatenated.
306pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
307    git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
308}
309
310/// Every path `<base>...HEAD` changed.
311pub 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
316/// The paths `<base>...HEAD` added. A fragment satisfies the check only when the pull request
317/// adds it, so the diff is filtered to additions.
318pub 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
324/// What a pull request missing a fragment of `kind` did, and the way out of owing it. A lookup
325/// with a default rather than a comparison, so the two kinds read side by side.
326fn 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
344/// What pooled scope `root` owes. One directory holds every package's fragments, so the fragment
345/// that pays names the package in its filename rather than by sitting inside it.
346fn 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
352/// The finding for a scope that owes a `kind` fragment, naming `fragment` as the file that pays.
353fn 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
364/// A fragment path, split into the scope that owns it, its kind, and its filename.
365struct Fragment {
366    /// The owning package, or `None` under the pooled layout.
367    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
387/// The scope owning a fragment whose kind directory sits at `segs[i]`: the package under the
388/// per-package layout, `Some(None)` under the pooled one. `None` where the layout places no
389/// fragment there at all, so a stray path is not read as an entry.
390fn 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
400/// `true` when `frag` pays for `scope`. The per-package layout reads the directory the fragment
401/// sits in, and `scope` is the package's path; the pooled layout reads the filename, and `scope`
402/// is the package's name. The filename rule cannot fire under the per-package layout, because
403/// that arm never consults the name.
404fn 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
411/// `true` when fragment `name` names package `pkg`: the slug after the date opens with `<pkg>-`.
412/// A pooled tree with no package root has no name to carry, so any well-formed name records it.
413///
414/// `name` has passed [`fragment_name_ok`], so the date prefix is the first 11 bytes and the rest
415/// is the slug.
416fn 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
431/// Touched fragment paths whose filenames break the convention. Each fragment directory carries
432/// a `README.md` describing that convention, which is not an entry.
433fn 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
497/// Every package root under `root`, as a root-relative path, sorted. A package is a directory
498/// inside a container directory — `packages/parser`, `crates/lexer` — holding one of the
499/// manifests [`crate::tiers::is_package_root`] reads, which is the same question every other
500/// check asks when it resolves a package root.
501fn 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
510/// The package roots directly under `root/container`, as root-relative paths.
511fn 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
520/// The names of `dir`'s visible subdirectories, skipping the build trees the walk never enters.
521fn 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
533/// Every fragment directory under `root`, as its root-relative segments. The convention puts a
534/// fragment directory at most two levels down, which bounds the walk.
535fn 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        // Same facts, with and without the trailer: the trailer is the only difference.
615        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        // Same facts, with and without the line: the line is the only difference.
645        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        // The requirement is an implication, not an equality: an unsolicited migration
671        // fragment is allowed.
672        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        // One line per finding, each already rendered as an annotation.
738        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        // `changelog.d/` is exempt from `code_touched`, so the only finding can be the name.
751        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        // In a container, but only its own fragment directory moved: nothing owed.
784        assert!(owed_for(&["packages/parser/changelog.d/2026-10-05-ok.md"]).is_empty());
785        // Code changed, but outside every declared container: nothing owed.
786        assert!(owed_for(&["internals/checks/src/a.py"]).is_empty());
787        // Both together is the only case that owes.
788        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    /// A tree under a directory unique to `slug` and this process, for a test that reads the
939    /// filesystem.
940    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        // A manifest at the root is not a package sitting inside a container directory.
961        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        // An unreadable or absent container is not a container of packages.
987        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        // The package name is a whole segment, so a hyphen has to follow it, and the name
1017        // carries a slug of its own as well as the package.
1018        assert!(!names_package("2026-09-21-parserdrop.md", Some("parser")));
1019        assert!(!names_package("2026-09-21-parser.md", Some("parser")));
1020        // A fragment is a markdown file.
1021        assert!(!names_package("2026-09-21-parser-x", Some("parser")));
1022        // No package root means no name for the fragment to carry.
1023        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        // Under the per-package layout the directory names the package, so a filename naming
1034        // another package cannot leave this one unpaid.
1035        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}