Skip to main content

corpora_rules/
gate_b.rs

1//! Gate B — every pinned `code_revision` must resolve in the repository. The git lookup is
2//! injected via a [`RevisionOracle`], so the rule stays pure-testable. It distinguishes an
3//! intentional no-op and a repo-less corpus (skip) from a real misconfiguration such as a
4//! missing `git` binary, which is surfaced as a warning instead of failing open silently.
5
6use corpora_core::{Diagnostic, DocPath, Graph, OracleStatus, RevisionOracle};
7
8use crate::Rule;
9
10pub struct GateB {
11    oracle: Box<dyn RevisionOracle>,
12}
13
14impl GateB {
15    pub fn new(oracle: Box<dyn RevisionOracle>) -> Self {
16        GateB { oracle }
17    }
18}
19
20impl Rule for GateB {
21    fn code(&self) -> &'static str {
22        "GATE_B"
23    }
24
25    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
26        match self.oracle.status() {
27            // Only the explicitly-disabled NullOracle fails open silently.
28            OracleStatus::Disabled => return,
29            // Backend wanted but unusable (e.g. git missing): make the gap visible rather
30            // than passing every pin unchecked.
31            OracleStatus::Unavailable => {
32                out.push(Diagnostic::warn(
33                    "GATE_B",
34                    &DocPath("(repository)".into()),
35                    "git is unavailable; code_revision pins are unverified".to_string(),
36                ));
37                return;
38            }
39            // No repository here (e.g. a mistyped --repo). Silence would hide unverified
40            // pins, so warn when there are any; stay quiet only if nothing needs verifying.
41            OracleStatus::NoRepo => {
42                let pins = g.records().filter(|r| r.facet.code_revision().is_some()).count();
43                if pins > 0 {
44                    out.push(Diagnostic::warn(
45                        "GATE_B",
46                        &DocPath("(repository)".into()),
47                        format!("no git repository found; {pins} code_revision pin(s) unverified"),
48                    ));
49                }
50                return;
51            }
52            OracleStatus::Ready => {}
53        }
54
55        for r in g.records() {
56            if let Some(rev) = r.facet.code_revision() {
57                if self.oracle.resolve(rev).is_none() {
58                    out.push(Diagnostic::error(
59                        "GATE_B",
60                        &r.path,
61                        format!("code_revision {} does not resolve in the repository", rev.0),
62                    ));
63                }
64            }
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use corpora_core::*;
73    use std::sync::Arc;
74
75    /// Configurable stand-in: pick a status and whether `resolve` succeeds.
76    struct FakeOracle {
77        status: OracleStatus,
78        resolves: bool,
79    }
80    impl RevisionOracle for FakeOracle {
81        fn status(&self) -> OracleStatus {
82            self.status
83        }
84        fn resolve(&self, _r: &Rev) -> Option<Rev> {
85            self.resolves.then(|| Rev("resolved".into()))
86        }
87    }
88
89    fn current_record(rev: &str) -> Record {
90        Record::minimal(
91            Some(Id("C1".into())),
92            DocPath("c.md".into()),
93            Kind::Current,
94            Lifecycle::Current,
95            Authority::Descriptive,
96            Facet::Current {
97                implementation: Impl::Implemented,
98                code_revision: Rev(rev.into()),
99                source_revision: None,
100            },
101        )
102    }
103
104    fn run(oracle: Box<dyn RevisionOracle>, records: Vec<Record>) -> Vec<Diagnostic> {
105        let g = Graph::build(records.into_iter().map(Arc::new).collect()).0;
106        let mut out = Vec::new();
107        GateB::new(oracle).check(&g, &mut out);
108        out
109    }
110
111    #[test]
112    fn unresolvable_revision_flagged() {
113        let oracle = Box::new(FakeOracle { status: OracleStatus::Ready, resolves: false });
114        let out = run(oracle, vec![current_record("deadbeef")]);
115        assert_eq!(out.len(), 1);
116        assert_eq!(out[0].code, "GATE_B");
117        assert_eq!(out[0].severity, Severity::Error);
118    }
119
120    #[test]
121    fn resolvable_revision_passes() {
122        let oracle = Box::new(FakeOracle { status: OracleStatus::Ready, resolves: true });
123        let out = run(oracle, vec![current_record("cafef00d")]);
124        assert!(out.is_empty(), "{out:?}");
125    }
126
127    #[test]
128    fn disabled_skips_silently_even_with_pins() {
129        assert!(run(Box::new(NullOracle), vec![current_record("deadbeef")]).is_empty());
130    }
131
132    #[test]
133    fn no_repo_warns_when_there_are_pins() {
134        let oracle = Box::new(FakeOracle { status: OracleStatus::NoRepo, resolves: false });
135        let out = run(oracle, vec![current_record("deadbeef")]);
136        assert_eq!(out.len(), 1);
137        assert_eq!(out[0].severity, Severity::Warning);
138    }
139
140    #[test]
141    fn no_repo_silent_without_pins() {
142        let narrative = Record::minimal(
143            Some(Id("D1".into())),
144            DocPath("d1.md".into()),
145            Kind::Decision,
146            Lifecycle::Current,
147            Authority::Normative,
148            Facet::Narrative,
149        );
150        let oracle = Box::new(FakeOracle { status: OracleStatus::NoRepo, resolves: false });
151        assert!(run(oracle, vec![narrative]).is_empty());
152    }
153
154    #[test]
155    fn unavailable_git_warns() {
156        let oracle = Box::new(FakeOracle { status: OracleStatus::Unavailable, resolves: false });
157        let out = run(oracle, vec![current_record("deadbeef")]);
158        assert_eq!(out.len(), 1);
159        assert_eq!(out[0].severity, Severity::Warning);
160    }
161}