Skip to main content

pma_core/
class.rs

1//! Maintenance class of a task: what an agent may take, and which paths a
2//! correct change touches. The class is predicted at dispatch from the signal
3//! type and the item's tags; the paths a run actually changed are checked
4//! against `scope` afterwards, so a misprediction cannot widen the scope.
5
6/// Classes from `docs/dev/design-review.md`, "Classes of maintenance work".
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Class {
9    /// A. Mechanical: accepted by the existing suite, confined to manifests.
10    Mechanical,
11    /// A-. Mechanical but privileged: a workflow runs with repository tokens,
12    /// so this is never unattended whatever its complexity.
13    Privileged,
14    /// B. Specified: acceptance is a test, scope is what the task names.
15    Specified,
16    /// C. Judgment: no automatic acceptance. Reserved for the `propose`
17    /// approval mode; nothing produces it yet.
18    Judgment,
19    /// D. Never dispatched: releases, credentials, licences, and work the
20    /// user marked `#manual`.
21    Never,
22}
23
24/// Paths no class but A- may touch, whatever class was predicted. A workflow
25/// runs with repository tokens and CI validates the changed workflow rather
26/// than checking it, so the green-CI gate is worth least exactly where the
27/// change is most dangerous.
28pub const PRIVILEGED: [&str; 4] = [".github/**", "LICENSE", "COPYING", "**/.netrc"];
29
30/// Files `pma` writes itself. No class may change one: an agent is told not
31/// to, and publishing marks the item done after the rebase. Keeping the file out of
32/// every scope is what lets that edit be admitted without granting an agent
33/// access to it.
34pub const OWNED: [&str; 1] = ["TODO.md"];
35
36/// Manifests and lock files a dependency update may touch. One list covers
37/// every ecosystem, so a project needs no per-class setting.
38const MANIFESTS: [&str; 10] = [
39    "Cargo.toml",
40    "Cargo.lock",
41    "go.mod",
42    "go.sum",
43    "pyproject.toml",
44    "uv.lock",
45    "requirements*.txt",
46    "package.json",
47    "package-lock.json",
48    "*.lock",
49];
50
51impl Class {
52    const ALL: [(&'static str, Class); 5] = [
53        ("A", Class::Mechanical),
54        ("A-", Class::Privileged),
55        ("B", Class::Specified),
56        ("C", Class::Judgment),
57        ("D", Class::Never),
58    ];
59
60    pub fn name(self) -> &'static str {
61        Self::ALL
62            .iter()
63            .find(|(_, c)| *c == self)
64            .map_or("", |(n, _)| n)
65    }
66
67    pub fn parse(s: &str) -> Option<Class> {
68        Self::ALL.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
69    }
70
71    /// The class of a task, from its key and the tags it carries on origin.
72    /// `#manual` wins over everything, including a signal, so marking a task
73    /// always takes it out of dispatch. An unclassified item is B: the
74    /// conservative choice, since B is never unattended without a test that
75    /// discriminates the base from the candidate.
76    pub fn of(key: &str, tags: &[String]) -> Class {
77        if tags.iter().any(|t| t == "manual") {
78            return Class::Never;
79        }
80        match key {
81            "deps" => Class::Mechanical,
82            _ => Class::Specified,
83        }
84    }
85
86    /// Globs a correct change may touch, or an empty list when the class
87    /// states no bound. B names files the task alone knows, so it has none;
88    /// the privileged paths still apply.
89    pub fn scope(self) -> Vec<String> {
90        match self {
91            Class::Mechanical => MANIFESTS.iter().map(|s| (*s).into()).collect(),
92            Class::Privileged => vec![".github/**".into()],
93            _ => Vec::new(),
94        }
95    }
96
97    /// Paths the run changed that its class does not permit. Privileged paths
98    /// are judged against the paths actually changed, not against the class
99    /// predicted at dispatch, so a task misread as mechanical cannot edit a
100    /// workflow.
101    pub fn violations<'a>(self, scope: &[String], paths: &'a [String]) -> Vec<&'a str> {
102        paths
103            .iter()
104            .filter(|p| {
105                if OWNED.iter().any(|g| crate::scan::glob_match(g, p.as_str())) {
106                    return true;
107                }
108                let privileged = PRIVILEGED
109                    .iter()
110                    .any(|g| crate::scan::glob_match(g, p.as_str()));
111                if privileged {
112                    return self != Class::Privileged;
113                }
114                !scope.is_empty() && !scope.iter().any(|g| crate::scan::glob_match(g, p.as_str()))
115            })
116            .map(String::as_str)
117            .collect()
118    }
119
120    /// Whether an agent may be dispatched to this class at all.
121    pub fn dispatchable(self) -> bool {
122        self != Class::Never
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn tags(list: &[&str]) -> Vec<String> {
131        list.iter().map(|s| (*s).to_string()).collect()
132    }
133
134    #[test]
135    fn signals_and_tags_decide_the_class() {
136        assert_eq!(Class::of("deps", &[]), Class::Mechanical);
137        assert_eq!(Class::of("ci", &[]), Class::Specified);
138        assert_eq!(Class::of("fix the parser", &[]), Class::Specified);
139        assert_eq!(
140            Class::of("fix the parser", &tags(["bug"].as_ref())),
141            Class::Specified
142        );
143        // `#agent` marks eligibility, not class.
144        assert_eq!(
145            Class::of("fix it", &tags(["agent"].as_ref())),
146            Class::Specified
147        );
148    }
149
150    #[test]
151    fn manual_is_never_dispatched_even_as_a_signal() {
152        assert_eq!(Class::of("deps", &tags(["manual"].as_ref())), Class::Never);
153        assert_eq!(Class::of("ci", &tags(["manual"].as_ref())), Class::Never);
154        assert!(!Class::Never.dispatchable());
155        assert!(Class::Mechanical.dispatchable());
156    }
157
158    #[test]
159    fn only_mechanical_classes_bound_their_paths() {
160        assert!(
161            Class::Mechanical
162                .scope()
163                .contains(&"Cargo.lock".to_string())
164        );
165        assert_eq!(Class::Privileged.scope(), [".github/**"]);
166        assert!(
167            Class::Specified.scope().is_empty(),
168            "the task names its files"
169        );
170    }
171
172    fn paths(list: &[&str]) -> Vec<String> {
173        list.iter().map(|s| (*s).to_string()).collect()
174    }
175
176    #[test]
177    fn a_bounded_class_refuses_paths_outside_its_globs() {
178        let a = Class::Mechanical;
179        let scope = a.scope();
180        assert!(
181            a.violations(&scope, &paths(&["Cargo.lock", "sub/Cargo.toml", "uv.lock"]))
182                .is_empty()
183        );
184        assert_eq!(
185            a.violations(&scope, &paths(&["Cargo.lock", "src/main.rs"])),
186            ["src/main.rs"]
187        );
188    }
189
190    #[test]
191    fn an_unbounded_class_still_refuses_privileged_paths() {
192        let b = Class::Specified;
193        assert!(b.scope().is_empty());
194        assert!(
195            b.violations(&[], &paths(&["src/main.rs", "tests/cli.rs"]))
196                .is_empty()
197        );
198        assert_eq!(
199            b.violations(
200                &[],
201                &paths(&[".github/workflows/ci.yml", "LICENSE", "a/.netrc"])
202            ),
203            [".github/workflows/ci.yml", "LICENSE", "a/.netrc"]
204        );
205    }
206
207    /// A deps task that edits a workflow is refused although `.github/**` is
208    /// nowhere in its scope, and although its class says nothing about it.
209    #[test]
210    fn a_misclassified_workflow_edit_cannot_pass() {
211        let a = Class::Mechanical;
212        assert_eq!(
213            a.violations(&a.scope(), &paths(&[".github/workflows/ci.yml"])),
214            [".github/workflows/ci.yml"]
215        );
216        // Only A- may, and then nothing else.
217        let p = Class::Privileged;
218        assert!(
219            p.violations(&p.scope(), &paths(&[".github/workflows/ci.yml"]))
220                .is_empty()
221        );
222        assert_eq!(
223            p.violations(&p.scope(), &paths(&["src/main.rs"])),
224            ["src/main.rs"]
225        );
226    }
227
228    /// `pma` marks the item done itself, after the rebase. An agent that
229    /// edits the file is caught at review, whatever its class.
230    #[test]
231    fn no_class_may_change_a_file_pma_writes() {
232        for class in [Class::Mechanical, Class::Privileged, Class::Specified] {
233            assert_eq!(
234                class.violations(&class.scope(), &paths(&["TODO.md"])),
235                ["TODO.md"],
236                "{class:?}"
237            );
238        }
239    }
240
241    #[test]
242    fn names_round_trip() {
243        for (name, class) in Class::ALL {
244            assert_eq!(Class::parse(name), Some(class));
245            assert_eq!(class.name(), name);
246        }
247        assert_eq!(Class::parse("A+"), None);
248    }
249}