koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `tier1.no-hand-edit` — every auto-generated Tier 1 file
//! (`wiki/features/_index.md`, `wiki/decisions/_index.md`,
//! `wiki/_tags/*.md`, `wiki/health.md`) must round-trip through its
//! embedded `<!-- Checksum-of-body-below -->` header. Hand edits are
//! rejected so the wiki stays the source of truth.

use crate::check::{Check, Finding, FindingKind, Severity};
use koala_core::invariant::Context;
use koala_wiki::{verify_tier1_checksums, Tier1Status};

pub struct Tier1Integrity;

impl Check for Tier1Integrity {
    fn id(&self) -> &'static str {
        "tier1.no-hand-edit"
    }

    fn intent(&self) -> &'static str {
        "Auto-generated Tier 1 files must match the body checksum \
         embedded in their header — hand edits are forbidden, run \
         `koala-core wiki gen` to regenerate."
    }

    fn run(&self, ctx: &Context) -> Vec<Finding> {
        let mut out = Vec::new();
        for r in verify_tier1_checksums(ctx.root()) {
            if let Tier1Status::Tampered { expected, actual } = r.status {
                out.push(Finding {
                    check_id: self.id(),
                    file: r.path.clone(),
                    line: 0,
                    claim: format!(
                        "body checksum mismatch (expected sha256:{}, got sha256:{})",
                        short(&expected),
                        short(&actual)
                    ),
                    kind: FindingKind::Tier1Tampered { expected, actual },
                    severity: Severity::Hard,
                    fix_hint: Some(format!(
                        "this file is auto-generated; revert hand edits and run \
                         `koala-core wiki gen` to refresh `{}`",
                        r.path.display()
                    )),
                });
            }
        }
        out
    }
}

fn short(hex: &str) -> &str {
    hex.get(..12).unwrap_or(hex)
}

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

    fn write_feature(dir: &std::path::Path, name: &str, fm: &str) {
        let p = dir.join("wiki/features").join(name);
        fs::create_dir_all(p.parent().unwrap()).unwrap();
        fs::write(p, format!("---\n{fm}\n---\n\n# {name}\n")).unwrap();
    }

    #[test]
    fn clean_repo_no_findings() {
        let tmp = TempDir::new().unwrap();
        write_feature(tmp.path(), "x.md", "id: x\nstatus: done\ntags: [core]\n");
        gen(tmp.path()).unwrap();
        let ctx = Context::new(tmp.path().to_path_buf());
        let findings = Tier1Integrity.run(&ctx);
        assert!(findings.is_empty(), "{findings:?}");
    }

    #[test]
    fn hand_edit_blocks() {
        let tmp = TempDir::new().unwrap();
        write_feature(tmp.path(), "x.md", "id: x\nstatus: done\ntags: [core]\n");
        gen(tmp.path()).unwrap();
        let path = tmp.path().join("wiki/features/_index.md");
        let body = fs::read_to_string(&path).unwrap();
        fs::write(&path, format!("{body}\nUNAUTHORIZED\n")).unwrap();
        let ctx = Context::new(tmp.path().to_path_buf());
        let findings = Tier1Integrity.run(&ctx);
        assert!(
            findings
                .iter()
                .any(|f| matches!(f.kind, FindingKind::Tier1Tampered { .. })),
            "expected Tier1Tampered finding, got {findings:?}"
        );
    }
}