Skip to main content

corpora_rules/
e3.rs

1//! E3 — don't depend on stale canon. With the schema-v0 refinement: a *typed* edge to a
2//! superseded atom is an error; a bare prose mention is only a warning.
3
4use corpora_core::{Authority, Diagnostic, Graph, Kind, Lifecycle};
5
6use crate::Rule;
7
8pub struct E3;
9
10/// Explainer and handoff records are non-normative pedagogy/snapshots — schema-v0 §4 marks
11/// them E3-exempt, so a stale citation in them is not an error.
12fn 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                // Any typed edge to a superseded atom is an error. Offer a migration target
31                // only when the chain resolves to one — a citation into an ambiguous or
32                // cyclic chain still errors, it just can't be told where to go.
33                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        // A current canon doc depends_on the superseded D1 → E3 error.
87        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        // C and D both supersede B → B is ambiguous (no safe terminal). A citation to B
129        // must still error, just without a migration target.
130        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        // The citation is a body *link* to D1, not a front-matter edge.
172        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}