1use corpora_core::{Authority, Diagnostic, Graph, Kind, Lifecycle};
5
6use crate::Rule;
7
8pub struct E3;
9
10fn e3_exempt(kind: Kind) -> bool {
13 matches!(kind, Kind::Explainer | Kind::Handoff)
14}
15
16impl Rule for E3 {
17 fn code(&self) -> &'static str {
18 "E3"
19 }
20
21 fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
22 for r in g.records().filter(|r| {
23 r.lifecycle == Lifecycle::Current
24 && r.authority != Authority::Historical
25 && !e3_exempt(r.kind)
26 }) {
27 let Some(id) = &r.id else { continue };
28
29 for c in g.structured_citations(id) {
30 if g.is_superseded(&c.target) {
34 let msg = match g.effective_successor(&c.target) {
35 Some(s) => {
36 format!("{} cites superseded {} → migrate to {}", id.0, c.target.0, s.0)
37 }
38 None => format!(
39 "{} cites superseded {} (no safe migration target: its successor chain is ambiguous or cyclic)",
40 id.0, c.target.0
41 ),
42 };
43 out.push(Diagnostic::error("E3", &r.path, msg));
44 }
45 }
46
47 for m in g.bare_mentions(id) {
48 if g.is_superseded(&m) {
49 out.push(Diagnostic::warn(
50 "E3.mention",
51 &r.path,
52 format!("{} mentions superseded {} in prose", id.0, m.0),
53 ));
54 }
55 }
56 }
57 }
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63 use corpora_core::*;
64 use std::sync::Arc;
65
66 #[test]
67 fn flags_structured_citation_to_superseded() {
68 let d1 = Record::minimal(
69 Some(Id("D1".into())),
70 DocPath("d1.md".into()),
71 Kind::Decision,
72 Lifecycle::Superseded,
73 Authority::Normative,
74 Facet::Narrative,
75 );
76 let mut d2 = Record::minimal(
77 Some(Id("D2".into())),
78 DocPath("d2.md".into()),
79 Kind::Decision,
80 Lifecycle::Current,
81 Authority::Normative,
82 Facet::Narrative,
83 );
84 d2.edges.supersedes.push(Id("D1".into()));
85
86 let mut arch = Record::minimal(
88 Some(Id("ARCH".into())),
89 DocPath("arch.md".into()),
90 Kind::Architecture,
91 Lifecycle::Current,
92 Authority::Normative,
93 Facet::Canon { implementation: None, code_revision: None },
94 );
95 arch.edges.depends_on.push(Id("D1".into()));
96
97 let (g, _) = Graph::build(vec![Arc::new(d1), Arc::new(d2), Arc::new(arch)]);
98 let mut out = Vec::new();
99 E3.check(&g, &mut out);
100
101 assert_eq!(out.len(), 1);
102 assert_eq!(out[0].code, "E3");
103 assert_eq!(out[0].severity, Severity::Error);
104 assert!(out[0].message.contains("migrate to D2"));
105 }
106
107 fn decision(id: &str, status: Status, supersedes: &[&str]) -> Record {
108 let mut r = Record::minimal(
109 Some(Id(id.into())),
110 DocPath(format!("{id}.md")),
111 Kind::Decision,
112 Lifecycle::Superseded,
113 Authority::Normative,
114 Facet::Decision(DecisionFacet {
115 status,
116 date: Date("2026-06-21".into()),
117 implementation: None,
118 fork: None,
119 realized_by: vec![],
120 }),
121 );
122 r.edges.supersedes = supersedes.iter().map(|s| Id((*s).into())).collect();
123 r
124 }
125
126 #[test]
127 fn flags_citation_into_ambiguous_chain_without_a_target() {
128 let b = decision("B", Status::Superseded, &[]);
131 let c = decision("C", Status::Accepted, &["B"]);
132 let d = decision("D", Status::Accepted, &["B"]);
133 let mut arch = Record::minimal(
134 Some(Id("ARCH".into())),
135 DocPath("arch.md".into()),
136 Kind::Architecture,
137 Lifecycle::Current,
138 Authority::Normative,
139 Facet::Canon { implementation: None, code_revision: None },
140 );
141 arch.edges.depends_on.push(Id("B".into()));
142
143 let (g, _) = Graph::build(vec![Arc::new(b), Arc::new(c), Arc::new(d), Arc::new(arch)]);
144 let mut out = Vec::new();
145 E3.check(&g, &mut out);
146
147 let e3: Vec<_> = out.iter().filter(|x| x.code == "E3").collect();
148 assert_eq!(e3.len(), 1, "{out:?}");
149 assert!(e3[0].message.contains("no safe migration target"), "{}", e3[0].message);
150 }
151
152 #[test]
153 fn flags_link_to_superseded_atom() {
154 let d1 = Record::minimal(
155 Some(Id("D1".into())),
156 DocPath("d1.md".into()),
157 Kind::Decision,
158 Lifecycle::Superseded,
159 Authority::Normative,
160 Facet::Narrative,
161 );
162 let mut d2 = Record::minimal(
163 Some(Id("D2".into())),
164 DocPath("d2.md".into()),
165 Kind::Decision,
166 Lifecycle::Current,
167 Authority::Normative,
168 Facet::Narrative,
169 );
170 d2.edges.supersedes.push(Id("D1".into()));
171 let mut arch = Record::minimal(
173 Some(Id("ARCH".into())),
174 DocPath("arch.md".into()),
175 Kind::Architecture,
176 Lifecycle::Current,
177 Authority::Normative,
178 Facet::Canon { implementation: None, code_revision: None },
179 );
180 arch.body.link_refs.push(Id("D1".into()));
181
182 let (g, _) = Graph::build(vec![Arc::new(d1), Arc::new(d2), Arc::new(arch)]);
183 let mut out = Vec::new();
184 E3.check(&g, &mut out);
185 assert_eq!(out.iter().filter(|x| x.code == "E3").count(), 1, "{out:?}");
186 }
187}