Skip to main content

corpora_rules/
supersession.rs

1//! Pairs each record's declared lifecycle with the supersession state the graph resolved:
2//! a `superseded` lifecycle must actually have a successor, and a record that *is*
3//! superseded must declare it. Catches lifecycle drift the graph build can't (it only sees
4//! edges, not the lifecycle field).
5
6use corpora_core::{Diagnostic, Graph, Lifecycle};
7
8use crate::Rule;
9
10pub struct SupersessionIntegrity;
11
12impl Rule for SupersessionIntegrity {
13    fn code(&self) -> &'static str {
14        "SUPERSESSION"
15    }
16
17    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
18        for r in g.records() {
19            let Some(id) = &r.id else { continue };
20            match (r.lifecycle, g.is_superseded(id)) {
21                (Lifecycle::Superseded, false) => out.push(Diagnostic::error(
22                    "SUPERSESSION",
23                    &r.path,
24                    format!("{} has lifecycle=superseded but nothing supersedes it", id.0),
25                )),
26                (life, true) if life != Lifecycle::Superseded => {
27                    // `None` here covers both ambiguous and cyclic chains.
28                    let succ = g
29                        .effective_successor(id)
30                        .map(|s| s.0.as_str())
31                        .unwrap_or("(unresolved)");
32                    out.push(Diagnostic::error(
33                        "SUPERSESSION",
34                        &r.path,
35                        format!("{} is superseded (by {succ}) but lifecycle is {life:?}", id.0),
36                    ));
37                }
38                _ => {}
39            }
40        }
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    use corpora_core::*;
48    use std::sync::Arc;
49
50    fn narrative(id: &str, lifecycle: Lifecycle, supersedes: &[&str]) -> Record {
51        let mut r = Record::minimal(
52            Some(Id(id.into())),
53            DocPath(format!("{id}.md")),
54            Kind::Decision,
55            lifecycle,
56            Authority::Normative,
57            Facet::Decision(DecisionFacet {
58                status: Status::Accepted,
59                date: Date("2026-06-21".into()),
60                implementation: None,
61                fork: None,
62                realized_by: vec![],
63            }),
64        );
65        r.edges.supersedes = supersedes.iter().map(|s| Id((*s).into())).collect();
66        r
67    }
68
69    fn run(records: Vec<Record>) -> Vec<Diagnostic> {
70        let g = Graph::build(records.into_iter().map(Arc::new).collect()).0;
71        let mut out = Vec::new();
72        SupersessionIntegrity.check(&g, &mut out);
73        out
74    }
75
76    #[test]
77    fn superseded_lifecycle_without_successor_flagged() {
78        let out = run(vec![narrative("D1", Lifecycle::Superseded, &[])]);
79        assert_eq!(out.len(), 1, "{out:?}");
80    }
81
82    #[test]
83    fn superseded_record_with_wrong_lifecycle_flagged() {
84        // D2 supersedes D1, but D1 still claims lifecycle=current.
85        let out = run(vec![
86            narrative("D1", Lifecycle::Current, &[]),
87            narrative("D2", Lifecycle::Current, &["D1"]),
88        ]);
89        assert!(out.iter().any(|d| d.message.contains("D1 is superseded")), "{out:?}");
90    }
91
92    #[test]
93    fn consistent_pair_passes() {
94        let out = run(vec![
95            narrative("D1", Lifecycle::Superseded, &[]),
96            narrative("D2", Lifecycle::Current, &["D1"]),
97        ]);
98        assert!(out.is_empty(), "{out:?}");
99    }
100}