use corpora_core::{Authority, Diagnostic, Graph, Kind, Lifecycle};
use crate::Rule;
pub struct E3;
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) {
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()));
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() {
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()));
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:?}");
}
}