corpora-rules 0.1.0

Validation rule pack (E3, held-decision, schema) for the corpora docs validator.
Documentation
//! E3 — don't depend on stale canon. With the schema-v0 refinement: a *typed* edge to a
//! superseded atom is an error; a bare prose mention is only a warning.

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

use crate::Rule;

pub struct E3;

/// Explainer and handoff records are non-normative pedagogy/snapshots — schema-v0 §4 marks
/// them E3-exempt, so a stale citation in them is not an error.
fn e3_exempt(kind: Kind) -> bool {
    matches!(kind, Kind::Explainer | Kind::Handoff)
}

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

    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
        for r in g.records().filter(|r| {
            r.lifecycle == Lifecycle::Current
                && r.authority != Authority::Historical
                && !e3_exempt(r.kind)
        }) {
            let Some(id) = &r.id else { continue };

            for c in g.structured_citations(id) {
                // Any typed edge to a superseded atom is an error. Offer a migration target
                // only when the chain resolves to one — a citation into an ambiguous or
                // cyclic chain still errors, it just can't be told where to go.
                if g.is_superseded(&c.target) {
                    let msg = match g.effective_successor(&c.target) {
                        Some(s) => {
                            format!("{} cites superseded {} → migrate to {}", id.0, c.target.0, s.0)
                        }
                        None => format!(
                            "{} cites superseded {} (no safe migration target: its successor chain is ambiguous or cyclic)",
                            id.0, c.target.0
                        ),
                    };
                    out.push(Diagnostic::error("E3", &r.path, msg));
                }
            }

            for m in g.bare_mentions(id) {
                if g.is_superseded(&m) {
                    out.push(Diagnostic::warn(
                        "E3.mention",
                        &r.path,
                        format!("{} mentions superseded {} in prose", id.0, m.0),
                    ));
                }
            }
        }
    }
}

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

    #[test]
    fn flags_structured_citation_to_superseded() {
        let d1 = Record::minimal(
            Some(Id("D1".into())),
            DocPath("d1.md".into()),
            Kind::Decision,
            Lifecycle::Superseded,
            Authority::Normative,
            Facet::Narrative,
        );
        let mut d2 = Record::minimal(
            Some(Id("D2".into())),
            DocPath("d2.md".into()),
            Kind::Decision,
            Lifecycle::Current,
            Authority::Normative,
            Facet::Narrative,
        );
        d2.edges.supersedes.push(Id("D1".into()));

        // A current canon doc depends_on the superseded D1 → E3 error.
        let mut arch = Record::minimal(
            Some(Id("ARCH".into())),
            DocPath("arch.md".into()),
            Kind::Architecture,
            Lifecycle::Current,
            Authority::Normative,
            Facet::Canon { implementation: None, code_revision: None },
        );
        arch.edges.depends_on.push(Id("D1".into()));

        let (g, _) = Graph::build(vec![Arc::new(d1), Arc::new(d2), Arc::new(arch)]);
        let mut out = Vec::new();
        E3.check(&g, &mut out);

        assert_eq!(out.len(), 1);
        assert_eq!(out[0].code, "E3");
        assert_eq!(out[0].severity, Severity::Error);
        assert!(out[0].message.contains("migrate to D2"));
    }

    fn decision(id: &str, status: Status, supersedes: &[&str]) -> Record {
        let mut r = Record::minimal(
            Some(Id(id.into())),
            DocPath(format!("{id}.md")),
            Kind::Decision,
            Lifecycle::Superseded,
            Authority::Normative,
            Facet::Decision(DecisionFacet {
                status,
                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
    }

    #[test]
    fn flags_citation_into_ambiguous_chain_without_a_target() {
        // C and D both supersede B → B is ambiguous (no safe terminal). A citation to B
        // must still error, just without a migration target.
        let b = decision("B", Status::Superseded, &[]);
        let c = decision("C", Status::Accepted, &["B"]);
        let d = decision("D", Status::Accepted, &["B"]);
        let mut arch = Record::minimal(
            Some(Id("ARCH".into())),
            DocPath("arch.md".into()),
            Kind::Architecture,
            Lifecycle::Current,
            Authority::Normative,
            Facet::Canon { implementation: None, code_revision: None },
        );
        arch.edges.depends_on.push(Id("B".into()));

        let (g, _) = Graph::build(vec![Arc::new(b), Arc::new(c), Arc::new(d), Arc::new(arch)]);
        let mut out = Vec::new();
        E3.check(&g, &mut out);

        let e3: Vec<_> = out.iter().filter(|x| x.code == "E3").collect();
        assert_eq!(e3.len(), 1, "{out:?}");
        assert!(e3[0].message.contains("no safe migration target"), "{}", e3[0].message);
    }

    #[test]
    fn flags_link_to_superseded_atom() {
        let d1 = Record::minimal(
            Some(Id("D1".into())),
            DocPath("d1.md".into()),
            Kind::Decision,
            Lifecycle::Superseded,
            Authority::Normative,
            Facet::Narrative,
        );
        let mut d2 = Record::minimal(
            Some(Id("D2".into())),
            DocPath("d2.md".into()),
            Kind::Decision,
            Lifecycle::Current,
            Authority::Normative,
            Facet::Narrative,
        );
        d2.edges.supersedes.push(Id("D1".into()));
        // The citation is a body *link* to D1, not a front-matter edge.
        let mut arch = Record::minimal(
            Some(Id("ARCH".into())),
            DocPath("arch.md".into()),
            Kind::Architecture,
            Lifecycle::Current,
            Authority::Normative,
            Facet::Canon { implementation: None, code_revision: None },
        );
        arch.body.link_refs.push(Id("D1".into()));

        let (g, _) = Graph::build(vec![Arc::new(d1), Arc::new(d2), Arc::new(arch)]);
        let mut out = Vec::new();
        E3.check(&g, &mut out);
        assert_eq!(out.iter().filter(|x| x.code == "E3").count(), 1, "{out:?}");
    }
}