Skip to main content

pedant_core/gate/
verdict.rs

1//! Gate severity, the verdict record, source anchors, and structured evidence.
2
3use std::fmt;
4use std::sync::Arc;
5
6use serde::Serialize;
7
8use super::module_boundary::ModuleBoundaryInputError;
9
10/// Controls whether a gate verdict blocks CI, warns, or is purely informational.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum GateSeverity {
14    /// Blocks CI/publish with a non-zero exit code.
15    Deny,
16    /// Displayed but does not affect exit code.
17    Warn,
18    /// Logged for audit trail; no user-facing output by default.
19    Info,
20}
21
22impl fmt::Display for GateSeverity {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Deny => f.write_str("deny"),
26            Self::Warn => f.write_str("warn"),
27            Self::Info => f.write_str("info"),
28        }
29    }
30}
31
32/// What a location was derived from, which decides annotation precedence.
33///
34/// A file anchor is the whole-file fallback at line 1, column 1. A span that
35/// genuinely starts at 1:1 is indistinguishable from it by coordinates, so the
36/// derivation is carried rather than inferred.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum GateAnchor {
39    /// The declaration's own source span.
40    Span,
41    /// The file that holds the declaration, with no span of its own.
42    File,
43}
44
45/// A one-based, repository-relative source anchor carried by gate evidence.
46///
47/// The anchor kind is judgment-internal: it selects a component annotation and
48/// is not serialized, so the published location object keeps its exact bytes.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct GateLocation {
51    path: Arc<str>,
52    line: u32,
53    column: u32,
54    #[serde(skip)]
55    anchor: GateAnchor,
56}
57
58impl GateLocation {
59    /// Build a span location, rejecting an empty path and zero coordinates.
60    pub fn try_new(
61        path: impl Into<Arc<str>>,
62        line: u32,
63        column: u32,
64    ) -> Result<Self, ModuleBoundaryInputError> {
65        Self::anchored(path, line, column, GateAnchor::Span)
66    }
67
68    /// Build a whole-file location at line 1, column 1, rejecting an empty path.
69    pub fn try_new_file(path: impl Into<Arc<str>>) -> Result<Self, ModuleBoundaryInputError> {
70        Self::anchored(path, 1, 1, GateAnchor::File)
71    }
72
73    fn anchored(
74        path: impl Into<Arc<str>>,
75        line: u32,
76        column: u32,
77        anchor: GateAnchor,
78    ) -> Result<Self, ModuleBoundaryInputError> {
79        let path = path.into();
80        match (path.is_empty(), line, column) {
81            (true, _, _) => Err(ModuleBoundaryInputError::EmptyLocationPath),
82            (false, 0, _) => Err(ModuleBoundaryInputError::ZeroLocationLine),
83            (false, _, 0) => Err(ModuleBoundaryInputError::ZeroLocationColumn),
84            (false, _, _) => Ok(Self {
85                path,
86                line,
87                column,
88                anchor,
89            }),
90        }
91    }
92
93    /// The normalized, repository-relative source path.
94    pub fn path(&self) -> &str {
95        &self.path
96    }
97
98    /// The one-based line.
99    pub fn line(&self) -> u32 {
100        self.line
101    }
102
103    /// The one-based byte column.
104    pub fn column(&self) -> u32 {
105        self.column
106    }
107
108    /// Whether the coordinates came from a span or from the holding file.
109    pub fn anchor(&self) -> GateAnchor {
110        self.anchor
111    }
112}
113
114/// One graph-derived subject, member, or partition, identified without a graph.
115///
116/// The ordinal is the source node index copied as plain evidence. It keeps two
117/// equal qualified labels distinct, including when neither declaration has
118/// source bytes.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120pub struct ModuleBoundaryEntity {
121    ordinal: u32,
122    label: Arc<str>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    location: Option<GateLocation>,
125}
126
127impl ModuleBoundaryEntity {
128    /// Build an entity, rejecting an empty label.
129    pub fn try_new(
130        ordinal: u32,
131        label: impl Into<Arc<str>>,
132        location: Option<GateLocation>,
133    ) -> Result<Self, ModuleBoundaryInputError> {
134        let label = label.into();
135        match label.is_empty() {
136            true => Err(ModuleBoundaryInputError::EmptyEntityLabel { ordinal }),
137            false => Ok(Self {
138                ordinal,
139                label,
140                location,
141            }),
142        }
143    }
144
145    /// The target-local ordinal that disambiguates equal labels.
146    pub fn ordinal(&self) -> u32 {
147        self.ordinal
148    }
149
150    /// The container-qualified label, joined with `::`.
151    pub fn label(&self) -> &str {
152        &self.label
153    }
154
155    /// The source anchor, absent for a declaration without source bytes.
156    pub fn location(&self) -> Option<&GateLocation> {
157        self.location.as_ref()
158    }
159}
160
161/// What a module-boundary rule measured, retained exactly as it was judged.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
163#[serde(tag = "kind", rename_all = "snake_case")]
164pub enum ModuleBoundaryMeasurement {
165    /// One condensed component and the declared partitions it occupies.
166    Component {
167        /// Whether the component contains a cycle.
168        cyclic: bool,
169        /// Component members, in graph-node order.
170        members: Arc<[ModuleBoundaryEntity]>,
171        /// Declared partitions the members occupy, in graph-node order.
172        partitions: Arc<[ModuleBoundaryEntity]>,
173    },
174    /// One symbol whose outgoing edges favour another declared partition.
175    MisplacedSymbol {
176        /// The partition that declares the symbol.
177        declared_partition: ModuleBoundaryEntity,
178        /// The partition its outgoing edges favour.
179        candidate_partition: ModuleBoundaryEntity,
180        /// Selected outgoing edges that leave the declared partition.
181        foreign_edges: u32,
182        /// All selected outgoing edges of the symbol.
183        total_outgoing_edges: u32,
184    },
185    /// One module's split between internal and boundary-crossing traffic.
186    LowModuleCohesion {
187        /// Selected outgoing edges that stay inside the module.
188        internal_edges: u32,
189        /// Selected outgoing edges that leave the module.
190        boundary_edges: u32,
191    },
192}
193
194/// A module-boundary verdict's target, subject, and measurement.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
196pub struct ModuleBoundaryEvidence {
197    target: Arc<str>,
198    subject: ModuleBoundaryEntity,
199    measurement: ModuleBoundaryMeasurement,
200}
201
202impl ModuleBoundaryEvidence {
203    /// Share one input's target label into one triggering fact.
204    pub(super) fn new(
205        target: Arc<str>,
206        subject: ModuleBoundaryEntity,
207        measurement: ModuleBoundaryMeasurement,
208    ) -> Self {
209        Self {
210            target,
211            subject,
212            measurement,
213        }
214    }
215
216    /// The root target label the judged facts were scoped to.
217    pub fn target(&self) -> &str {
218        &self.target
219    }
220
221    /// The entity the verdict names.
222    pub fn subject(&self) -> &ModuleBoundaryEntity {
223        &self.subject
224    }
225
226    /// The counts that triggered the verdict.
227    pub fn measurement(&self) -> &ModuleBoundaryMeasurement {
228        &self.measurement
229    }
230}
231
232/// Structured evidence attached to a verdict, tagged by its judgment domain.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234#[serde(tag = "domain", content = "evidence", rename_all = "snake_case")]
235pub enum GateEvidence {
236    /// Evidence produced by a module-boundary rule.
237    ModuleBoundary(ModuleBoundaryEvidence),
238}
239
240/// Produced when a gate rule's predicate matches its input.
241#[derive(Debug, Serialize)]
242pub struct GateVerdict {
243    /// Kebab-case rule identifier (e.g., `"build-script-network"`).
244    pub rule: &'static str,
245    /// Effective severity after config overrides.
246    pub severity: GateSeverity,
247    /// Why this input is suspicious.
248    pub rationale: &'static str,
249    /// Structural detail, absent for capability and data-flow rules.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub evidence: Option<GateEvidence>,
252}