koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `feature.acceptance-complete` — a feature whose frontmatter claims
//! `status: done` (or implemented / shipped) must not leave any unchecked
//! `- [ ]` item in its `## Acceptance criteria` section. A "done" feature
//! with an open acceptance box is the checkboxes-vs-reality drift this
//! check catches.
//!
//! This is the live replacement for the former
//! `.review/round-20/behavior-acceptance-fully-met.md` snapshot artifact:
//! that artifact froze a grep-and-count of every acceptance line and so
//! went TAMPERED on any PR that touched one. Recomputing against the
//! current wiki instead of hashing a snapshot is exactly drift's job
//! (see ADR-0017).

use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::{list_feature_files, rel, tagged_lines};
use koala_core::invariant::Context;
use std::fs;

const DONE_STATUSES: &[&str] = &["done", "implemented", "shipped"];
const ACCEPTANCE_HEADING_PREFIX: &str = "Acceptance criteria";
const UNCHECKED_PREFIX: &str = "- [ ]";

pub struct FeatureAcceptanceComplete;

impl Check for FeatureAcceptanceComplete {
    fn id(&self) -> &'static str {
        "feature.acceptance-complete"
    }

    fn intent(&self) -> &'static str {
        "A feature whose frontmatter claims status=done must have every \
         item in its `## Acceptance criteria` section checked off — no \
         unchecked `- [ ]` boxes left open."
    }

    fn run(&self, ctx: &Context) -> Vec<Finding> {
        let mut out = Vec::new();
        for feature in list_feature_files(ctx.root()) {
            let Ok(content) = fs::read_to_string(&feature) else {
                continue;
            };
            if !is_done(&content) {
                continue;
            }
            let display = rel(&feature, ctx.root());
            for line in tagged_lines(&content) {
                if line.in_fence {
                    continue;
                }
                if !line
                    .section
                    .map(|s| s.starts_with(ACCEPTANCE_HEADING_PREFIX))
                    .unwrap_or(false)
                {
                    continue;
                }
                if line.text.trim_start().starts_with(UNCHECKED_PREFIX) {
                    out.push(Finding {
                        check_id: self.id(),
                        file: display.clone(),
                        line: line.line_no,
                        claim: line.text.trim().to_string(),
                        kind: FindingKind::AcceptanceItemUnchecked,
                        severity: Severity::Hard,
                        fix_hint: Some(
                            "this feature is `status: done` but the acceptance \
                             item is still unchecked; either finish it and tick \
                             `- [x]`, or set the feature's status back to \
                             `in-progress`"
                                .to_string(),
                        ),
                    });
                }
            }
        }
        out
    }
}

/// True when the feature's frontmatter `status:` is one of the done-like
/// states. Mirrors the frontmatter slice used by `feature.status-done-has-impl`.
fn is_done(content: &str) -> bool {
    let Some(rest) = content.strip_prefix("---\n") else {
        return false;
    };
    let Some(end) = rest.find("\n---\n") else {
        return false;
    };
    for line in rest[..end].lines() {
        if let Some((k, v)) = line.split_once(':') {
            if k.trim() == "status" {
                let status = v.trim();
                return DONE_STATUSES.iter().any(|s| s.eq_ignore_ascii_case(status));
            }
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write_feature(tmp: &TempDir, name: &str, body: &str) {
        fs::create_dir_all(tmp.path().join("wiki/features")).unwrap();
        fs::write(tmp.path().join("wiki/features").join(name), body).unwrap();
    }

    const DONE_ALL_CHECKED: &str = "\
---
id: x
status: done
owner: crates/x/
---

# Feature: x

## Acceptance criteria

- [x] first thing
  → crates/x/src/lib.rs#first
- [x] second thing
";

    const DONE_ONE_OPEN: &str = "\
---
id: x
status: done
owner: crates/x/
---

# Feature: x

## Acceptance criteria

- [x] first thing
- [ ] second thing not finished
";

    #[test]
    fn done_feature_with_all_boxes_checked_passes() {
        let tmp = TempDir::new().unwrap();
        write_feature(&tmp, "x.md", DONE_ALL_CHECKED);
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(FeatureAcceptanceComplete.run(&ctx).is_empty());
    }

    #[test]
    fn done_feature_with_open_box_blocks() {
        let tmp = TempDir::new().unwrap();
        write_feature(&tmp, "x.md", DONE_ONE_OPEN);
        let ctx = Context::new(tmp.path().to_path_buf());
        let f = FeatureAcceptanceComplete.run(&ctx);
        assert_eq!(f.len(), 1, "the single open box should be flagged");
        assert_eq!(f[0].severity, Severity::Hard);
        assert_eq!(f[0].kind, FindingKind::AcceptanceItemUnchecked);
        assert!(f[0].claim.contains("second thing not finished"));
    }

    #[test]
    fn in_progress_feature_with_open_box_is_skipped() {
        let tmp = TempDir::new().unwrap();
        write_feature(
            &tmp,
            "x.md",
            &DONE_ONE_OPEN.replace("status: done", "status: in-progress"),
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(
            FeatureAcceptanceComplete.run(&ctx).is_empty(),
            "non-done features may legitimately have open boxes"
        );
    }

    #[test]
    fn unchecked_box_outside_acceptance_section_is_ignored() {
        let tmp = TempDir::new().unwrap();
        write_feature(
            &tmp,
            "x.md",
            "\
---
id: x
status: done
---

# Feature: x

## Non-goals

- [ ] this is a roadmap idea, not an acceptance item

## Acceptance criteria

- [x] done
",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(
            FeatureAcceptanceComplete.run(&ctx).is_empty(),
            "only the Acceptance criteria section is scanned"
        );
    }

    #[test]
    fn unchecked_box_inside_fence_is_ignored() {
        let tmp = TempDir::new().unwrap();
        write_feature(
            &tmp,
            "x.md",
            "\
---
id: x
status: done
---

# Feature: x

## Acceptance criteria

- [x] real item
```text
- [ ] this is example output, not a real box
```
",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(
            FeatureAcceptanceComplete.run(&ctx).is_empty(),
            "fenced code blocks are not acceptance items"
        );
    }
}