koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `feature.adr-ref-resolves` — every `ADR-NNNN` mentioned in a feature
//! file (outside fenced code blocks) must point to an existing decision
//! file. References to a `superseded` ADR fail with a hint to use the
//! superseder (see ADR-0008).

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

pub struct FeatureAdrRefs;

#[derive(Debug)]
struct AdrRecord {
    status: String,
    superseded_by: Option<String>,
}

fn adr_pattern() -> &'static Regex {
    static R: OnceLock<Regex> = OnceLock::new();
    // Word boundaries prevent silent absorption of `ADR-99999` (5-digit
    // typo) into `ADR-9999` and similar — exactly the kind of "silent
    // typo" failure mode that drift detection exists to flag, not hide.
    R.get_or_init(|| Regex::new(r"\bADR-(\d{4})\b").expect("static regex compiles"))
}

impl Check for FeatureAdrRefs {
    fn id(&self) -> &'static str {
        "feature.adr-ref-resolves"
    }

    fn intent(&self) -> &'static str {
        "ADR references in feature docs resolve to an existing decision \
         file and are not superseded."
    }

    fn run(&self, ctx: &Context) -> Vec<Finding> {
        let adr_index = build_adr_index(ctx.root());
        let mut out = Vec::new();
        for feature_path in list_feature_files(ctx.root()) {
            let Ok(content) = fs::read_to_string(&feature_path) else {
                continue;
            };
            let display = rel(&feature_path, ctx.root());
            for line in tagged_lines(&content) {
                if line.in_fence {
                    continue;
                }
                let mut seen_on_line: Vec<&str> = Vec::new();
                for m in adr_pattern().captures_iter(line.text) {
                    let id = m.get(1).expect("group 1 always present").as_str();
                    if seen_on_line.contains(&id) {
                        continue;
                    }
                    seen_on_line.push(id);

                    match adr_index.get(id) {
                        None => out.push(Finding {
                            check_id: self.id(),
                            file: display.clone(),
                            line: line.line_no,
                            claim: line.text.trim().to_string(),
                            kind: FindingKind::AdrRefDangling,
                            severity: Severity::Hard,
                            fix_hint: Some(format!(
                                "no `wiki/decisions/{id}-*.md` exists; remove \
                                 the reference or land the ADR first"
                            )),
                        }),
                        Some(rec) if rec.status == "superseded" => {
                            let superseder = rec.superseded_by.clone();
                            let hint = match superseder.as_deref() {
                                Some(s) => format!(
                                    "ADR-{id} is superseded by ADR-{s}; update \
                                     the reference"
                                ),
                                None => format!(
                                    "ADR-{id} is superseded; update the \
                                     reference to its superseder"
                                ),
                            };
                            out.push(Finding {
                                check_id: self.id(),
                                file: display.clone(),
                                line: line.line_no,
                                claim: line.text.trim().to_string(),
                                kind: FindingKind::AdrRefSuperseded { superseder },
                                severity: Severity::Hard,
                                fix_hint: Some(hint),
                            });
                        }
                        Some(_) => {}
                    }
                }
            }
        }
        out
    }
}

fn build_adr_index(root: &Path) -> HashMap<String, AdrRecord> {
    let mut out = HashMap::new();
    for path in list_adr_files(root) {
        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        let id = name[..4].to_string();
        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")
            .cloned()
            .unwrap_or_else(|| "unknown".to_string());
        let superseded_by = fields
            .get("superseded-by")
            .or_else(|| fields.get("superseded_by"))
            .cloned();
        out.insert(
            id,
            AdrRecord {
                status,
                superseded_by,
            },
        );
    }
    out
}