Skip to main content

koala_drift/checks/
feature_acceptance_complete.rs

1//! `feature.acceptance-complete` — a feature whose frontmatter claims
2//! `status: done` (or implemented / shipped) must not leave any unchecked
3//! `- [ ]` item in its `## Acceptance criteria` section. A "done" feature
4//! with an open acceptance box is the checkboxes-vs-reality drift this
5//! check catches.
6//!
7//! This is the live replacement for the former
8//! `.review/round-20/behavior-acceptance-fully-met.md` snapshot artifact:
9//! that artifact froze a grep-and-count of every acceptance line and so
10//! went TAMPERED on any PR that touched one. Recomputing against the
11//! current wiki instead of hashing a snapshot is exactly drift's job
12//! (see ADR-0017).
13
14use crate::check::{Check, Finding, FindingKind, Severity};
15use crate::scan::{list_feature_files, rel, tagged_lines};
16use koala_core::invariant::Context;
17use std::fs;
18
19const DONE_STATUSES: &[&str] = &["done", "implemented", "shipped"];
20const ACCEPTANCE_HEADING_PREFIX: &str = "Acceptance criteria";
21const UNCHECKED_PREFIX: &str = "- [ ]";
22
23pub struct FeatureAcceptanceComplete;
24
25impl Check for FeatureAcceptanceComplete {
26    fn id(&self) -> &'static str {
27        "feature.acceptance-complete"
28    }
29
30    fn intent(&self) -> &'static str {
31        "A feature whose frontmatter claims status=done must have every \
32         item in its `## Acceptance criteria` section checked off — no \
33         unchecked `- [ ]` boxes left open."
34    }
35
36    fn run(&self, ctx: &Context) -> Vec<Finding> {
37        let mut out = Vec::new();
38        for feature in list_feature_files(ctx.root()) {
39            let Ok(content) = fs::read_to_string(&feature) else {
40                continue;
41            };
42            if !is_done(&content) {
43                continue;
44            }
45            let display = rel(&feature, ctx.root());
46            for line in tagged_lines(&content) {
47                if line.in_fence {
48                    continue;
49                }
50                if !line
51                    .section
52                    .map(|s| s.starts_with(ACCEPTANCE_HEADING_PREFIX))
53                    .unwrap_or(false)
54                {
55                    continue;
56                }
57                if line.text.trim_start().starts_with(UNCHECKED_PREFIX) {
58                    out.push(Finding {
59                        check_id: self.id(),
60                        file: display.clone(),
61                        line: line.line_no,
62                        claim: line.text.trim().to_string(),
63                        kind: FindingKind::AcceptanceItemUnchecked,
64                        severity: Severity::Hard,
65                        fix_hint: Some(
66                            "this feature is `status: done` but the acceptance \
67                             item is still unchecked; either finish it and tick \
68                             `- [x]`, or set the feature's status back to \
69                             `in-progress`"
70                                .to_string(),
71                        ),
72                    });
73                }
74            }
75        }
76        out
77    }
78}
79
80/// True when the feature's frontmatter `status:` is one of the done-like
81/// states. Mirrors the frontmatter slice used by `feature.status-done-has-impl`.
82fn is_done(content: &str) -> bool {
83    let Some(rest) = content.strip_prefix("---\n") else {
84        return false;
85    };
86    let Some(end) = rest.find("\n---\n") else {
87        return false;
88    };
89    for line in rest[..end].lines() {
90        if let Some((k, v)) = line.split_once(':') {
91            if k.trim() == "status" {
92                let status = v.trim();
93                return DONE_STATUSES.iter().any(|s| s.eq_ignore_ascii_case(status));
94            }
95        }
96    }
97    false
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::fs;
104    use tempfile::TempDir;
105
106    fn write_feature(tmp: &TempDir, name: &str, body: &str) {
107        fs::create_dir_all(tmp.path().join("wiki/features")).unwrap();
108        fs::write(tmp.path().join("wiki/features").join(name), body).unwrap();
109    }
110
111    const DONE_ALL_CHECKED: &str = "\
112---
113id: x
114status: done
115owner: crates/x/
116---
117
118# Feature: x
119
120## Acceptance criteria
121
122- [x] first thing
123  → crates/x/src/lib.rs#first
124- [x] second thing
125";
126
127    const DONE_ONE_OPEN: &str = "\
128---
129id: x
130status: done
131owner: crates/x/
132---
133
134# Feature: x
135
136## Acceptance criteria
137
138- [x] first thing
139- [ ] second thing not finished
140";
141
142    #[test]
143    fn done_feature_with_all_boxes_checked_passes() {
144        let tmp = TempDir::new().unwrap();
145        write_feature(&tmp, "x.md", DONE_ALL_CHECKED);
146        let ctx = Context::new(tmp.path().to_path_buf());
147        assert!(FeatureAcceptanceComplete.run(&ctx).is_empty());
148    }
149
150    #[test]
151    fn done_feature_with_open_box_blocks() {
152        let tmp = TempDir::new().unwrap();
153        write_feature(&tmp, "x.md", DONE_ONE_OPEN);
154        let ctx = Context::new(tmp.path().to_path_buf());
155        let f = FeatureAcceptanceComplete.run(&ctx);
156        assert_eq!(f.len(), 1, "the single open box should be flagged");
157        assert_eq!(f[0].severity, Severity::Hard);
158        assert_eq!(f[0].kind, FindingKind::AcceptanceItemUnchecked);
159        assert!(f[0].claim.contains("second thing not finished"));
160    }
161
162    #[test]
163    fn in_progress_feature_with_open_box_is_skipped() {
164        let tmp = TempDir::new().unwrap();
165        write_feature(
166            &tmp,
167            "x.md",
168            &DONE_ONE_OPEN.replace("status: done", "status: in-progress"),
169        );
170        let ctx = Context::new(tmp.path().to_path_buf());
171        assert!(
172            FeatureAcceptanceComplete.run(&ctx).is_empty(),
173            "non-done features may legitimately have open boxes"
174        );
175    }
176
177    #[test]
178    fn unchecked_box_outside_acceptance_section_is_ignored() {
179        let tmp = TempDir::new().unwrap();
180        write_feature(
181            &tmp,
182            "x.md",
183            "\
184---
185id: x
186status: done
187---
188
189# Feature: x
190
191## Non-goals
192
193- [ ] this is a roadmap idea, not an acceptance item
194
195## Acceptance criteria
196
197- [x] done
198",
199        );
200        let ctx = Context::new(tmp.path().to_path_buf());
201        assert!(
202            FeatureAcceptanceComplete.run(&ctx).is_empty(),
203            "only the Acceptance criteria section is scanned"
204        );
205    }
206
207    #[test]
208    fn unchecked_box_inside_fence_is_ignored() {
209        let tmp = TempDir::new().unwrap();
210        write_feature(
211            &tmp,
212            "x.md",
213            "\
214---
215id: x
216status: done
217---
218
219# Feature: x
220
221## Acceptance criteria
222
223- [x] real item
224```text
225- [ ] this is example output, not a real box
226```
227",
228        );
229        let ctx = Context::new(tmp.path().to_path_buf());
230        assert!(
231            FeatureAcceptanceComplete.run(&ctx).is_empty(),
232            "fenced code blocks are not acceptance items"
233        );
234    }
235}