corpora-rules 0.1.0

Validation rule pack (E3, held-decision, schema) for the corpora docs validator.
Documentation
//! Pairs each record's declared lifecycle with the supersession state the graph resolved:
//! a `superseded` lifecycle must actually have a successor, and a record that *is*
//! superseded must declare it. Catches lifecycle drift the graph build can't (it only sees
//! edges, not the lifecycle field).

use corpora_core::{Diagnostic, Graph, Lifecycle};

use crate::Rule;

pub struct SupersessionIntegrity;

impl Rule for SupersessionIntegrity {
    fn code(&self) -> &'static str {
        "SUPERSESSION"
    }

    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
        for r in g.records() {
            let Some(id) = &r.id else { continue };
            match (r.lifecycle, g.is_superseded(id)) {
                (Lifecycle::Superseded, false) => out.push(Diagnostic::error(
                    "SUPERSESSION",
                    &r.path,
                    format!("{} has lifecycle=superseded but nothing supersedes it", id.0),
                )),
                (life, true) if life != Lifecycle::Superseded => {
                    // `None` here covers both ambiguous and cyclic chains.
                    let succ = g
                        .effective_successor(id)
                        .map(|s| s.0.as_str())
                        .unwrap_or("(unresolved)");
                    out.push(Diagnostic::error(
                        "SUPERSESSION",
                        &r.path,
                        format!("{} is superseded (by {succ}) but lifecycle is {life:?}", id.0),
                    ));
                }
                _ => {}
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use corpora_core::*;
    use std::sync::Arc;

    fn narrative(id: &str, lifecycle: Lifecycle, supersedes: &[&str]) -> Record {
        let mut r = Record::minimal(
            Some(Id(id.into())),
            DocPath(format!("{id}.md")),
            Kind::Decision,
            lifecycle,
            Authority::Normative,
            Facet::Decision(DecisionFacet {
                status: Status::Accepted,
                date: Date("2026-06-21".into()),
                implementation: None,
                fork: None,
                realized_by: vec![],
            }),
        );
        r.edges.supersedes = supersedes.iter().map(|s| Id((*s).into())).collect();
        r
    }

    fn run(records: Vec<Record>) -> Vec<Diagnostic> {
        let g = Graph::build(records.into_iter().map(Arc::new).collect()).0;
        let mut out = Vec::new();
        SupersessionIntegrity.check(&g, &mut out);
        out
    }

    #[test]
    fn superseded_lifecycle_without_successor_flagged() {
        let out = run(vec![narrative("D1", Lifecycle::Superseded, &[])]);
        assert_eq!(out.len(), 1, "{out:?}");
    }

    #[test]
    fn superseded_record_with_wrong_lifecycle_flagged() {
        // D2 supersedes D1, but D1 still claims lifecycle=current.
        let out = run(vec![
            narrative("D1", Lifecycle::Current, &[]),
            narrative("D2", Lifecycle::Current, &["D1"]),
        ]);
        assert!(out.iter().any(|d| d.message.contains("D1 is superseded")), "{out:?}");
    }

    #[test]
    fn consistent_pair_passes() {
        let out = run(vec![
            narrative("D1", Lifecycle::Superseded, &[]),
            narrative("D2", Lifecycle::Current, &["D1"]),
        ]);
        assert!(out.is_empty(), "{out:?}");
    }
}