Skip to main content

alint_rules/
changeset_requires_path.rs

1//! `changeset_requires_path` — the `<since>...HEAD` diff must ADD a
2//! file matching `add_glob:`. The "did you add a changelog entry?"
3//! gate (prettier `changelog_unreleased/`, cpython `Misc/NEWS.d/next/`,
4//! pnpm `.changeset/*.md`).
5//!
6//! Diff-scoped: `since:` (the base ref) is required — the assertion
7//! is about the *set of files a contribution adds*. An optional
8//! `when_changed:` gates the requirement on some other glob having
9//! changed (don't demand a changelog for a docs-only PR); with no
10//! gate, any non-empty changeset triggers it. Builds on the same
11//! `<since>...HEAD` three-dot (merge-base) diff as
12//! `scope_filter.changed_since:` / `alint check --changed`.
13//!
14//! Graceful no-op outside a git repo, when `git` is unavailable, or
15//! when nothing relevant changed. A `since:` that fails to resolve
16//! hard-fails with a shallow-clone hint (the user asked for a diff;
17//! a silently-empty range would mask the misconfiguration).
18//!
19//! Check-only — alint can't author the missing changelog entry.
20
21use std::slice;
22
23use alint_core::git::{
24    CommitRangeError, collect_changed_paths_checked, collect_changed_paths_filtered,
25};
26use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
27use serde::Deserialize;
28
29#[derive(Debug, Deserialize)]
30#[serde(deny_unknown_fields)]
31struct Options {
32    /// Glob; the diff must ADD (git status `A`) at least one path
33    /// matching it.
34    add_glob: String,
35    /// Optional gate: only require the add when some path matching
36    /// this glob changed (any status). Unset → any non-empty
37    /// changeset triggers the requirement.
38    #[serde(default)]
39    when_changed: Option<String>,
40    /// Base ref for the `<since>...HEAD` diff. The canonical
41    /// `{{env.X}}` interpolation is resolved at config load.
42    since: String,
43}
44
45#[derive(Debug)]
46pub struct ChangesetRequiresPathRule {
47    id: String,
48    level: Level,
49    policy_url: Option<String>,
50    message_override: Option<String>,
51    add_glob: String,
52    add_scope: Scope,
53    when_changed: Option<Scope>,
54    since: String,
55}
56
57impl Rule for ChangesetRequiresPathRule {
58    alint_core::rule_common_impl!();
59
60    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
61        let all_changed = match collect_changed_paths_checked(ctx.root, &self.since) {
62            Ok(Some(set)) => set,
63            Ok(None) => return Ok(Vec::new()), // not a git repo: silent
64            Err(CommitRangeError::BadRange { stderr }) => return Err(self.bad_range(&stderr)),
65        };
66
67        // Does the requirement apply? `when_changed` gates it; with
68        // no gate, any non-empty changeset triggers it.
69        let applies = match &self.when_changed {
70            None => !all_changed.is_empty(),
71            Some(scope) => all_changed.iter().any(|p| scope.matches(p, ctx.index)),
72        };
73        if !applies {
74            return Ok(Vec::new());
75        }
76
77        let added = match collect_changed_paths_filtered(ctx.root, &self.since, "A") {
78            Ok(Some(set)) => set,
79            Ok(None) => return Ok(Vec::new()),
80            Err(CommitRangeError::BadRange { stderr }) => return Err(self.bad_range(&stderr)),
81        };
82        if added.iter().any(|p| self.add_scope.matches(p, ctx.index)) {
83            return Ok(Vec::new());
84        }
85
86        let msg = self.message_override.clone().unwrap_or_else(|| {
87            let gate = if self.when_changed.is_some() {
88                " (its `when_changed` glob changed)"
89            } else {
90                ""
91            };
92            format!(
93                "the changeset `{}...HEAD`{gate} adds no file matching `{}`",
94                self.since, self.add_glob,
95            )
96        });
97        Ok(vec![Violation::new(msg)])
98    }
99}
100
101impl ChangesetRequiresPathRule {
102    fn bad_range(&self, stderr: &str) -> Error {
103        crate::commit_range::bad_diff_range(&self.id, &self.since, stderr)
104    }
105}
106
107pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
108    let opts: Options = spec
109        .deserialize_options()
110        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
111    if spec.fix.is_some() {
112        return Err(Error::rule_config(
113            &spec.id,
114            "changeset_requires_path has no fix op",
115        ));
116    }
117    if opts.add_glob.trim().is_empty() {
118        return Err(Error::rule_config(&spec.id, "`add_glob` must not be empty"));
119    }
120    if opts.since.trim().is_empty() {
121        return Err(Error::rule_config(
122            &spec.id,
123            "`since` must not be empty — `changeset_requires_path` is diff-scoped and needs a \
124             base ref (typically `since: \"{{env.ALINT_BASE_SHA | default('origin/main')}}\"`)",
125        ));
126    }
127    let add_scope = Scope::from_patterns(slice::from_ref(&opts.add_glob))
128        .map_err(|e| Error::rule_config(&spec.id, format!("invalid `add_glob`: {e}")))?;
129    let when_changed =
130        match &opts.when_changed {
131            None => None,
132            Some(g) if g.trim().is_empty() => {
133                return Err(Error::rule_config(
134                    &spec.id,
135                    "`when_changed` must not be empty (omit it to require the add on any change)",
136                ));
137            }
138            Some(g) => Some(Scope::from_patterns(slice::from_ref(g)).map_err(|e| {
139                Error::rule_config(&spec.id, format!("invalid `when_changed`: {e}"))
140            })?),
141        };
142
143    Ok(Box::new(ChangesetRequiresPathRule {
144        id: spec.id.clone(),
145        level: spec.level,
146        policy_url: spec.policy_url.clone(),
147        message_override: spec.message.clone(),
148        add_glob: opts.add_glob,
149        add_scope,
150        when_changed,
151        since: opts.since,
152    }))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn spec(toml: &str) -> RuleSpec {
160        let mut full = String::from(
161            "id = \"needs-changelog\"\nkind = \"changeset_requires_path\"\nlevel = \"error\"\n",
162        );
163        full.push_str(toml);
164        toml::from_str(&full).unwrap()
165    }
166
167    #[test]
168    fn build_accepts_minimal() {
169        assert!(
170            build(&spec(
171                "add_glob = \".changeset/*.md\"\nsince = \"origin/main\"\n"
172            ))
173            .is_ok()
174        );
175    }
176
177    #[test]
178    fn build_accepts_when_changed_gate() {
179        assert!(
180            build(&spec(
181                "add_glob = \".changeset/*.md\"\nwhen_changed = \"src/**\"\nsince = \"origin/main\"\n"
182            ))
183            .is_ok()
184        );
185    }
186
187    #[test]
188    fn build_requires_since() {
189        let err = build(&spec("add_glob = \".changeset/*.md\"\n")).unwrap_err();
190        // serde reports the missing required field.
191        assert!(err.to_string().contains("since"), "{err}");
192    }
193
194    #[test]
195    fn build_rejects_empty_add_glob() {
196        let err = build(&spec("add_glob = \"\"\nsince = \"origin/main\"\n")).unwrap_err();
197        assert!(err.to_string().contains("add_glob"), "{err}");
198    }
199
200    #[test]
201    fn build_rejects_fix() {
202        assert!(
203            build(&spec(
204                "add_glob = \"x\"\nsince = \"main\"\nfix = { file_create = { content = \"x\" } }\n"
205            ))
206            .is_err()
207        );
208    }
209}