koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `adr.graph-clean` — the ADR supersede graph must be clean: no cycles,
//! no dangling `supersedes` / `superseded-by` targets, no status/pointer
//! mismatch, no asymmetric bidirectional links. This recomputes
//! `koala_adr::validate` against the current `wiki/decisions/` on every run.
//!
//! This is the live drift equivalent of recording `koala-core adr validate`
//! output as a reviewer artifact. That artifact's stdout embeds a running
//! ADR count (`✓ N ADR(s) — graph clean.`), so it went TAMPERED on any PR
//! that added an ADR — even though nobody touched the artifact (issue #23).
//! Per ADR-0017's criterion ("does re-running this command change output as
//! the repo evolves? → it's a current-state assertion, it belongs in drift,
//! not a hash-frozen artifact"), graph-cleanliness is recomputed here.
//! See ADR-0018.

use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::rel;
use koala_adr::{validate, Issue, Registry};
use koala_core::invariant::Context;
use std::path::PathBuf;

pub struct AdrGraphClean;

impl Check for AdrGraphClean {
    fn id(&self) -> &'static str {
        "adr.graph-clean"
    }

    fn intent(&self) -> &'static str {
        "The ADR supersede graph must stay clean — no cycles, dangling \
         supersede targets, status/pointer mismatches, or asymmetric links. \
         Recomputed live so adding an ADR never false-flags (issue #23)."
    }

    fn run(&self, ctx: &Context) -> Vec<Finding> {
        // A registry that won't even load (malformed frontmatter) is the
        // job of `adr_frontmatter_valid` invariant / parser; here we simply
        // have nothing to validate.
        let Ok(reg) = Registry::load(ctx.root()) else {
            return Vec::new();
        };

        validate(&reg)
            .into_iter()
            .map(|issue| {
                let anchor = issue_anchor(&issue);
                let file = anchor
                    .and_then(|id| {
                        reg.entries()
                            .iter()
                            .find(|e| e.frontmatter.id == id)
                            .map(|e| e.relative.clone())
                    })
                    // No on-disk file (e.g. a dangling *target*): point at the
                    // decisions index so the finding still resolves to a place.
                    .unwrap_or_else(|| index_path(ctx.root()));
                Finding {
                    check_id: self.id(),
                    file,
                    line: 1,
                    claim: issue.to_string(),
                    kind: FindingKind::AdrGraphUnclean,
                    severity: Severity::Hard,
                    fix_hint: Some(fix_hint(&issue)),
                }
            })
            .collect()
    }
}

/// The ADR id whose own file is the natural home for the finding: the
/// `from` side of every issue (the ADR carrying the bad pointer/status).
fn issue_anchor(issue: &Issue) -> Option<u32> {
    match issue {
        Issue::DanglingSupersededBy { from, .. }
        | Issue::DanglingSupersedes { from, .. }
        | Issue::SupersededWithoutPointer { from }
        | Issue::PointerWithoutSupersededStatus { from }
        | Issue::AsymmetricLink { from, .. } => Some(*from),
        // The cycle's first node is as good an entry point as any.
        Issue::Cycle(chain) => chain.first().copied(),
    }
}

fn fix_hint(issue: &Issue) -> String {
    match issue {
        Issue::Cycle(_) => {
            "break the supersede cycle — an ADR cannot transitively supersede itself".to_string()
        }
        Issue::DanglingSupersededBy { target, .. } => {
            format!("`superseded-by` points at ADR-{target:04}, which doesn't exist; fix the id or create it")
        }
        Issue::DanglingSupersedes { target, .. } => {
            format!("`supersedes` lists ADR-{target:04}, which doesn't exist; fix the id")
        }
        Issue::SupersededWithoutPointer { .. } => {
            "status is `superseded` but `superseded-by` is missing; add the pointer".to_string()
        }
        Issue::PointerWithoutSupersededStatus { .. } => {
            "has `superseded-by` but status isn't `superseded`; set status or drop the pointer"
                .to_string()
        }
        Issue::AsymmetricLink { from, to } => {
            format!(
                "ADR-{from:04} and ADR-{to:04} disagree; make `supersedes`/`superseded-by` mutual"
            )
        }
    }
}

fn index_path(root: &std::path::Path) -> PathBuf {
    rel(&root.join("wiki/decisions/_index.md"), root)
}

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

    /// Write one ADR file with the given frontmatter knobs.
    fn write_adr(
        tmp: &TempDir,
        id: u32,
        status: &str,
        supersedes: &[u32],
        superseded_by: Option<u32>,
    ) {
        let dir = tmp.path().join("wiki/decisions");
        fs::create_dir_all(&dir).unwrap();
        let mut body = String::from("---\n");
        body.push_str(&format!("id: {id:04}\n"));
        body.push_str(&format!("title: t{id}\n"));
        body.push_str(&format!("status: {status}\n"));
        body.push_str("date: 2026-01-01\n");
        if !supersedes.is_empty() {
            let s: Vec<String> = supersedes.iter().map(|i| format!("{i:04}")).collect();
            body.push_str(&format!("supersedes: [{}]\n", s.join(", ")));
        }
        if let Some(by) = superseded_by {
            body.push_str(&format!("superseded-by: {by:04}\n"));
        }
        body.push_str("---\n\nbody\n");
        fs::write(dir.join(format!("{id:04}-x.md")), body).unwrap();
    }

    #[test]
    fn clean_chain_produces_no_findings() {
        let tmp = TempDir::new().unwrap();
        write_adr(&tmp, 1, "superseded", &[], Some(2));
        write_adr(&tmp, 2, "accepted", &[1], None);
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(AdrGraphClean.run(&ctx).is_empty());
    }

    #[test]
    fn empty_decisions_dir_is_clean() {
        let tmp = TempDir::new().unwrap();
        fs::create_dir_all(tmp.path().join("wiki/decisions")).unwrap();
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(AdrGraphClean.run(&ctx).is_empty());
    }

    #[test]
    fn adding_an_unrelated_adr_never_flags() {
        // The whole point of issue #23: a clean graph stays clean no matter
        // how many ADRs exist — count is never part of the signal.
        let tmp = TempDir::new().unwrap();
        for id in 1..=11 {
            write_adr(&tmp, id, "accepted", &[], None);
        }
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(AdrGraphClean.run(&ctx).is_empty());
    }

    #[test]
    fn dangling_superseded_by_blocks_and_anchors_to_source() {
        let tmp = TempDir::new().unwrap();
        write_adr(&tmp, 1, "superseded", &[], Some(99));
        let ctx = Context::new(tmp.path().to_path_buf());
        let f = AdrGraphClean.run(&ctx);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].severity, Severity::Hard);
        assert_eq!(f[0].kind, FindingKind::AdrGraphUnclean);
        // Anchored to ADR-0001's own file, not the index.
        assert!(f[0].file.to_string_lossy().contains("0001"));
    }

    #[test]
    fn supersede_cycle_blocks() {
        let tmp = TempDir::new().unwrap();
        write_adr(&tmp, 1, "superseded", &[2], Some(2));
        write_adr(&tmp, 2, "superseded", &[1], Some(1));
        let ctx = Context::new(tmp.path().to_path_buf());
        let f = AdrGraphClean.run(&ctx);
        assert!(f.iter().all(|x| x.severity == Severity::Hard));
        assert!(f.iter().any(|x| x.claim.contains("cycle")));
    }
}