koala_drift/checks/
feature_acceptance_complete.rs1use 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
80fn 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}