koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `adr.dormancy-advisory` — accepted ADRs with zero inbound references
//! anywhere in `wiki/` get an advisory finding (never blocks). The
//! signal feeds the quarterly pruning sprint (see ADR-0010).

use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::{list_adr_files, list_all_wiki_md, rel};
use koala_core::invariant::Context;
use koala_core::wiki::{extract_frontmatter, parse_yaml_frontmatter};
use regex::Regex;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;

pub struct AdrDormancy;

fn adr_pattern() -> &'static Regex {
    static R: OnceLock<Regex> = OnceLock::new();
    // See `feature_adr_refs::adr_pattern` for the word-boundary rationale.
    R.get_or_init(|| Regex::new(r"\bADR-(\d{4})\b").expect("static regex compiles"))
}

impl Check for AdrDormancy {
    fn id(&self) -> &'static str {
        "adr.dormancy-advisory"
    }

    fn intent(&self) -> &'static str {
        "Accepted ADRs that nothing in the wiki references any more get an \
         advisory ping for the next pruning sprint."
    }

    fn run(&self, ctx: &Context) -> Vec<Finding> {
        let adrs = list_adr_files(ctx.root());
        if adrs.is_empty() {
            return Vec::new();
        }

        // Count inbound references per ADR id, scanning every other md file
        // under wiki/. We don't strip code fences here — example output that
        // mentions ADR-NNNN still counts as engagement; the signal is "is
        // anyone talking about this ADR at all".
        let mut inbound: std::collections::HashMap<String, usize> =
            std::collections::HashMap::new();
        let all = list_all_wiki_md(ctx.root());
        let adr_paths: std::collections::HashSet<&Path> =
            adrs.iter().map(|p| p.as_path()).collect();
        for path in &all {
            if adr_paths.contains(path.as_path()) {
                continue;
            }
            let Ok(content) = fs::read_to_string(path) else {
                continue;
            };
            let mut seen_in_file: std::collections::HashSet<String> =
                std::collections::HashSet::new();
            for caps in adr_pattern().captures_iter(&content) {
                let id = caps.get(1).expect("group 1").as_str().to_string();
                seen_in_file.insert(id);
            }
            for id in seen_in_file {
                *inbound.entry(id).or_default() += 1;
            }
        }

        let mut out = Vec::new();
        for path in adrs {
            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
                continue;
            };
            let id = &name[..4];
            let Ok(content) = fs::read_to_string(&path) else {
                continue;
            };
            let fm = extract_frontmatter(&content).unwrap_or("");
            let fields = parse_yaml_frontmatter(fm);
            let status = fields
                .get("status")
                .map(String::as_str)
                .unwrap_or("unknown");
            if status != "accepted" {
                continue;
            }
            if inbound.get(id).copied().unwrap_or(0) > 0 {
                continue;
            }
            out.push(Finding {
                check_id: self.id(),
                file: rel(&path, ctx.root()),
                line: 1,
                claim: format!("ADR-{id} accepted with no inbound wiki references"),
                kind: FindingKind::AdrDormant,
                severity: Severity::Advisory,
                fix_hint: Some(
                    "next pruning sprint: confirm still load-bearing or supersede".to_string(),
                ),
            });
        }
        out
    }
}