corpora_rules/
supersession.rs1use 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 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 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}