use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Layout {
PerPackage(Vec<String>),
Pooled,
}
pub const KINDS: [&str; 2] = ["changelog", "migrations"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub file: Option<String>,
pub message: String,
}
const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
pub fn discover_layout(root: &Path) -> Option<Layout> {
let dirs = fragment_dirs(root);
if dirs.is_empty() {
return None;
}
let mut containers: Vec<String> = dirs
.iter()
.filter(|segs| segs.len() == 3)
.map(|segs| segs[0].clone())
.collect();
containers.sort();
containers.dedup();
if containers.is_empty() {
return Some(Layout::Pooled);
}
Some(Layout::PerPackage(containers))
}
pub fn migrations_enforced(root: &Path) -> bool {
fragment_dirs(root)
.iter()
.any(|segs| segs.last().is_some_and(|last| last == "migrations.d"))
}
pub fn fragment_name_ok(name: &str) -> bool {
let Some(stem) = name.strip_suffix(".md") else {
return false;
};
let bytes = stem.as_bytes();
if bytes.len() < 12 {
return false;
}
let digit = |i: usize| bytes[i].is_ascii_digit();
let dated = (0..4).all(digit)
&& bytes[4] == b'-'
&& (5..7).all(digit)
&& bytes[7] == b'-'
&& (8..10).all(digit)
&& bytes[10] == b'-';
dated
&& bytes[11..]
.iter()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
}
pub fn has_skip_line(bodies: &str) -> bool {
const SKIP: &[u8] = b"skip-changelog:";
bodies.lines().any(|line| {
let bytes = line.as_bytes();
bytes.len() >= SKIP.len() && bytes[..SKIP.len()].eq_ignore_ascii_case(SKIP)
})
}
pub fn is_exempt(path: &str, pkg: &str) -> bool {
path.strip_prefix(pkg)
.and_then(|rest| rest.strip_prefix('/'))
.is_some_and(exempt_at_any_boundary)
}
pub fn changed_packages(changed: &[String]) -> Vec<String> {
let mut out: Vec<String> = changed
.iter()
.filter_map(|path| {
let segs: Vec<&str> = path.split('/').collect();
(segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
})
.collect();
out.sort();
out.dedup();
out
}
pub fn findings(
layout: &Layout,
migrations: bool,
changed: &[String],
added: &[String],
) -> Vec<Finding> {
let mut out: Vec<Finding> = malformed(layout, changed)
.into_iter()
.map(|path| Finding {
file: Some(path),
message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
.to_string(),
})
.collect();
match layout {
Layout::PerPackage(containers) => {
for pkg in changed_packages(changed) {
let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
if in_a_container && code_touched(changed, &pkg) {
for kind in missing_kinds(layout, added, Some(&pkg), migrations) {
out.push(owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind));
}
}
}
}
Layout::Pooled => {
if changed.iter().any(|path| !exempt_at_any_boundary(path)) {
for kind in missing_kinds(layout, added, None, migrations) {
out.push(owed("", &format!("{kind}.d"), kind));
}
}
}
}
out
}
pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
}
pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
Ok(lines(&out))
}
pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
let range = format!("{base}...HEAD");
let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
Ok(lines(&out))
}
fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
Finding {
file: None,
message: format!(
"{scope}changed public surface without adding a {kind} fragment. Add \
{dir}/YYYY-MM-DD-<slug>.md, or put a `skip-changelog: <reason>` line on any commit \
for a genuinely internal refactor. See docs/reference/checks/changelog."
),
}
}
struct Fragment {
pkg: Option<String>,
kind: &'static str,
name: String,
}
fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
let segs: Vec<&str> = path.split('/').collect();
let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
let kind = kind_of(segs[i])?;
if i + 2 != segs.len() {
return None;
}
let name = segs[i + 1].to_string();
match layout {
Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
Some(Fragment {
pkg: Some(format!("{}/{}", segs[0], segs[1])),
kind,
name,
})
}
Layout::PerPackage(_) => None,
Layout::Pooled => Some(Fragment {
pkg: None,
kind,
name,
}),
}
}
fn kind_of(segment: &str) -> Option<&'static str> {
let stem = segment.strip_suffix(".d")?;
KINDS.into_iter().find(|kind| *kind == stem)
}
fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
changed
.iter()
.filter(|path| {
fragment(path, layout)
.is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
})
.cloned()
.collect()
}
fn missing_kinds(
layout: &Layout,
added: &[String],
pkg: Option<&str>,
migrations: bool,
) -> Vec<&'static str> {
let present: Vec<&'static str> = added
.iter()
.filter_map(|path| fragment(path, layout))
.filter(|frag| fragment_name_ok(&frag.name) && frag.pkg.as_deref() == pkg)
.map(|frag| frag.kind)
.collect();
KINDS
.into_iter()
.filter(|kind| migrations || *kind != "migrations")
.filter(|kind| !present.contains(kind))
.collect()
}
fn code_touched(changed: &[String], pkg: &str) -> bool {
let prefix = format!("{pkg}/");
changed
.iter()
.any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
}
fn exempt_at_any_boundary(rel: &str) -> bool {
std::iter::once(rel)
.chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
.any(exempt_shape)
}
fn exempt_shape(rel: &str) -> bool {
matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
|| KINDS
.iter()
.any(|kind| rel.starts_with(&format!("{kind}.d/")))
|| rel.starts_with("e2e-attestations/")
|| (rel.contains('/')
&& matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
|| rel.ends_with("_test.py")
|| is_test_or_spec(rel)
}
fn is_test_or_spec(rel: &str) -> bool {
let name = rel.rsplit('/').next().unwrap_or(rel);
["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
.iter()
.any(|ext| {
name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
})
}
fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
let mut out = Vec::new();
scan(root, &[], &mut out);
out
}
fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
continue;
}
let mut segs = prefix.to_vec();
segs.push(name.clone());
if kind_of(&name).is_some() {
out.push(segs);
} else if segs.len() < 3 {
scan(&entry.path(), &segs, out);
}
}
}
fn git(repo: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
if !out.status.success() {
bail!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn lines(out: &str) -> Vec<String> {
out.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
}