Skip to main content

pedant_core/gate/
module_boundary.rs

1//! Graph-neutral structural inputs and the pure module-boundary evaluator.
2//!
3//! The composition owner projects its topology into validated facts; this
4//! module judges them. Every decision is exact integer comparison over the
5//! counts a fact carries, so the same facts always produce the same verdicts.
6
7use std::collections::BTreeSet;
8use std::sync::Arc;
9
10use thiserror::Error;
11
12use crate::check_config::{GateConfig, ModuleBoundaryConfig};
13
14use super::catalog::{MODULE_BOUNDARY_RULES, ModuleBoundaryRule, resolve_severity};
15use super::verdict::{
16    GateAnchor, GateEvidence, GateLocation, GateSeverity, GateVerdict, ModuleBoundaryEntity,
17    ModuleBoundaryEvidence, ModuleBoundaryMeasurement,
18};
19
20/// Why a graph-neutral module-boundary value was refused.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
22pub enum ModuleBoundaryInputError {
23    /// A location was written without a source path.
24    #[error("a gate location requires a non-empty path")]
25    EmptyLocationPath,
26    /// A location line was zero; lines are one-based.
27    #[error("a gate location requires a one-based line")]
28    ZeroLocationLine,
29    /// A location column was zero; columns are one-based.
30    #[error("a gate location requires a one-based column")]
31    ZeroLocationColumn,
32    /// An entity was written without a qualified label.
33    #[error("entity {ordinal} requires a non-empty label")]
34    EmptyEntityLabel {
35        /// The ordinal of the rejected entity.
36        ordinal: u32,
37    },
38    /// A component listed no member.
39    #[error("a component requires at least one member")]
40    EmptyComponentMembers,
41    /// Two component members shared one ordinal.
42    #[error("component member ordinal {ordinal} is repeated")]
43    DuplicateComponentMember {
44        /// The repeated member ordinal.
45        ordinal: u32,
46    },
47    /// A component listed no declared partition.
48    #[error("a component requires at least one declared partition")]
49    EmptyComponentPartitions,
50    /// Two component partitions shared one ordinal.
51    #[error("component partition ordinal {ordinal} is repeated")]
52    DuplicateComponentPartition {
53        /// The repeated partition ordinal.
54        ordinal: u32,
55    },
56    /// More partitions were listed than the component has members.
57    #[error("a component with {members} members cannot occupy {partitions} partitions")]
58    ComponentPartitionsExceedMembers {
59        /// Members the component listed.
60        members: usize,
61        /// Partitions the component listed.
62        partitions: usize,
63    },
64    /// A component with several members was reported acyclic.
65    #[error("an acyclic component cannot hold {members} members")]
66    AcyclicComponentHasMultipleMembers {
67        /// Members the component listed.
68        members: usize,
69    },
70    /// A misplacement named one partition as both declared and candidate.
71    #[error("a misplaced symbol cannot prefer its declared partition {ordinal}")]
72    MisplacedSymbolPartitionsAreEqual {
73        /// The ordinal named twice.
74        ordinal: u32,
75    },
76    /// A misplacement was reported for a symbol with no outgoing edge.
77    #[error("symbol {ordinal} has no outgoing selected edge to attribute")]
78    MisplacedSymbolHasNoOutgoingEdges {
79        /// The ordinal of the rejected symbol.
80        ordinal: u32,
81    },
82    /// More foreign edges were counted than the symbol has outgoing edges.
83    #[error("{foreign_edges} foreign edges exceed {total_outgoing_edges} outgoing edges")]
84    MisplacedSymbolForeignEdgesExceedTotal {
85        /// Foreign edges counted.
86        foreign_edges: u32,
87        /// All outgoing edges counted.
88        total_outgoing_edges: u32,
89    },
90    /// An input was written without a root target label.
91    #[error("a module-boundary input requires a non-empty target")]
92    EmptyInputTarget,
93}
94
95/// One validated structural observation about a single root target.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ModuleBoundaryFact {
98    subject: ModuleBoundaryEntity,
99    measurement: ModuleBoundaryMeasurement,
100}
101
102impl ModuleBoundaryFact {
103    /// Build a component fact, deriving its subject from the first member.
104    pub fn component(
105        cyclic: bool,
106        members: Arc<[ModuleBoundaryEntity]>,
107        partitions: Arc<[ModuleBoundaryEntity]>,
108    ) -> Result<Self, ModuleBoundaryInputError> {
109        let first = validate_component(cyclic, &members, &partitions)?;
110        let subject = component_subject(first, &members)?;
111        Ok(Self {
112            subject,
113            measurement: ModuleBoundaryMeasurement::Component {
114                cyclic,
115                members,
116                partitions,
117            },
118        })
119    }
120
121    /// Build a misplacement fact whose subject is the symbol itself.
122    pub fn misplaced_symbol(
123        symbol: ModuleBoundaryEntity,
124        declared_partition: ModuleBoundaryEntity,
125        candidate_partition: ModuleBoundaryEntity,
126        foreign_edges: u32,
127        total_outgoing_edges: u32,
128    ) -> Result<Self, ModuleBoundaryInputError> {
129        validate_misplacement(
130            &symbol,
131            &declared_partition,
132            &candidate_partition,
133            foreign_edges,
134            total_outgoing_edges,
135        )?;
136        Ok(Self {
137            subject: symbol,
138            measurement: ModuleBoundaryMeasurement::MisplacedSymbol {
139                declared_partition,
140                candidate_partition,
141                foreign_edges,
142                total_outgoing_edges,
143            },
144        })
145    }
146
147    /// Build a cohesion fact whose subject is the module itself.
148    ///
149    /// Infallible, unlike its siblings: a module's two edge counts are
150    /// independent, so no pair contradicts the other. Two zeros are accepted
151    /// too, because an isolated module is a valid observation.
152    pub fn low_module_cohesion(
153        partition: ModuleBoundaryEntity,
154        internal_edges: u32,
155        boundary_edges: u32,
156    ) -> Self {
157        Self {
158            subject: partition,
159            measurement: ModuleBoundaryMeasurement::LowModuleCohesion {
160                internal_edges,
161                boundary_edges,
162            },
163        }
164    }
165
166    /// The entity a verdict built from this fact names.
167    pub fn subject(&self) -> &ModuleBoundaryEntity {
168        &self.subject
169    }
170
171    /// The counts a rule compares against its thresholds.
172    pub fn measurement(&self) -> &ModuleBoundaryMeasurement {
173        &self.measurement
174    }
175}
176
177/// Every validated fact of one root target, with the label they are scoped to.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ModuleBoundaryInput {
180    target: Arc<str>,
181    facts: Box<[ModuleBoundaryFact]>,
182}
183
184impl ModuleBoundaryInput {
185    /// Scope already validated facts to one non-empty root target label.
186    pub fn try_new(
187        target: impl Into<Arc<str>>,
188        facts: Box<[ModuleBoundaryFact]>,
189    ) -> Result<Self, ModuleBoundaryInputError> {
190        let target = target.into();
191        match target.is_empty() {
192            true => Err(ModuleBoundaryInputError::EmptyInputTarget),
193            false => Ok(Self { target, facts }),
194        }
195    }
196
197    /// The root target label every emitted verdict repeats.
198    pub fn target(&self) -> &str {
199        &self.target
200    }
201
202    /// The validated facts, in projection order.
203    pub fn facts(&self) -> &[ModuleBoundaryFact] {
204        &self.facts
205    }
206}
207
208/// Check component self-consistency, returning the member the subject derives from.
209fn validate_component<'m>(
210    cyclic: bool,
211    members: &'m [ModuleBoundaryEntity],
212    partitions: &[ModuleBoundaryEntity],
213) -> Result<&'m ModuleBoundaryEntity, ModuleBoundaryInputError> {
214    let first = members
215        .first()
216        .ok_or(ModuleBoundaryInputError::EmptyComponentMembers)?;
217    require_unique_ordinals(members, |ordinal| {
218        ModuleBoundaryInputError::DuplicateComponentMember { ordinal }
219    })?;
220    if partitions.is_empty() {
221        return Err(ModuleBoundaryInputError::EmptyComponentPartitions);
222    }
223    require_unique_ordinals(partitions, |ordinal| {
224        ModuleBoundaryInputError::DuplicateComponentPartition { ordinal }
225    })?;
226    if partitions.len() > members.len() {
227        return Err(ModuleBoundaryInputError::ComponentPartitionsExceedMembers {
228            members: members.len(),
229            partitions: partitions.len(),
230        });
231    }
232    if !cyclic && members.len() > 1 {
233        return Err(
234            ModuleBoundaryInputError::AcyclicComponentHasMultipleMembers {
235                members: members.len(),
236            },
237        );
238    }
239    Ok(first)
240}
241
242fn require_unique_ordinals(
243    entities: &[ModuleBoundaryEntity],
244    repeated: impl Fn(u32) -> ModuleBoundaryInputError,
245) -> Result<(), ModuleBoundaryInputError> {
246    // A one-element slice repeats nothing, and an acyclic project produces one
247    // single-member component per node up to the node ceiling, so the set is
248    // built only where duplication is possible.
249    if entities.len() < 2 {
250        return Ok(());
251    }
252    let mut seen = BTreeSet::new();
253    match entities
254        .iter()
255        .map(ModuleBoundaryEntity::ordinal)
256        .find(|ordinal| !seen.insert(*ordinal))
257    {
258        Some(ordinal) => Err(repeated(ordinal)),
259        None => Ok(()),
260    }
261}
262
263/// Name the component after its first member and annotate it with the first
264/// member in node order that carries a span, then the first that carries a
265/// file, then no location.
266///
267/// The annotation never changes the subject's first-member ordinal or label.
268fn component_subject(
269    first: &ModuleBoundaryEntity,
270    members: &[ModuleBoundaryEntity],
271) -> Result<ModuleBoundaryEntity, ModuleBoundaryInputError> {
272    let annotation = first_anchored(members, GateAnchor::Span)
273        .or_else(|| first_anchored(members, GateAnchor::File));
274    ModuleBoundaryEntity::try_new(
275        first.ordinal(),
276        format!("scc::{}", first.label()),
277        annotation,
278    )
279}
280
281/// The first member location in node order with the requested derivation.
282fn first_anchored(members: &[ModuleBoundaryEntity], anchor: GateAnchor) -> Option<GateLocation> {
283    members
284        .iter()
285        .filter_map(ModuleBoundaryEntity::location)
286        .find(|location| location.anchor() == anchor)
287        .cloned()
288}
289
290fn validate_misplacement(
291    symbol: &ModuleBoundaryEntity,
292    declared_partition: &ModuleBoundaryEntity,
293    candidate_partition: &ModuleBoundaryEntity,
294    foreign_edges: u32,
295    total_outgoing_edges: u32,
296) -> Result<(), ModuleBoundaryInputError> {
297    if declared_partition.ordinal() == candidate_partition.ordinal() {
298        return Err(
299            ModuleBoundaryInputError::MisplacedSymbolPartitionsAreEqual {
300                ordinal: declared_partition.ordinal(),
301            },
302        );
303    }
304    if total_outgoing_edges == 0 {
305        return Err(
306            ModuleBoundaryInputError::MisplacedSymbolHasNoOutgoingEdges {
307                ordinal: symbol.ordinal(),
308            },
309        );
310    }
311    if foreign_edges > total_outgoing_edges {
312        return Err(
313            ModuleBoundaryInputError::MisplacedSymbolForeignEdgesExceedTotal {
314                foreign_edges,
315                total_outgoing_edges,
316            },
317        );
318    }
319    Ok(())
320}
321
322/// A cyclic component with more members than the configured limit.
323pub(super) fn is_large_component(
324    measurement: &ModuleBoundaryMeasurement,
325    config: &ModuleBoundaryConfig,
326) -> bool {
327    match measurement {
328        ModuleBoundaryMeasurement::Component {
329            cyclic: true,
330            members,
331            ..
332        } => members.len() as u64 > u64::from(config.max_scc_members()),
333        _ => false,
334    }
335}
336
337/// A cycle whose members occupy more than one declared partition.
338///
339/// Numeric policy cannot change this answer, so it reads no configuration.
340pub(super) fn is_boundary_crossing_component(measurement: &ModuleBoundaryMeasurement) -> bool {
341    match measurement {
342        ModuleBoundaryMeasurement::Component {
343            cyclic: true,
344            partitions,
345            ..
346        } => partitions.len() > 1,
347        _ => false,
348    }
349}
350
351/// A symbol whose foreign-edge share meets both misplacement thresholds.
352pub(super) fn is_misplaced_symbol(
353    measurement: &ModuleBoundaryMeasurement,
354    config: &ModuleBoundaryConfig,
355) -> bool {
356    match measurement {
357        ModuleBoundaryMeasurement::MisplacedSymbol {
358            foreign_edges,
359            total_outgoing_edges,
360            ..
361        } => {
362            *foreign_edges >= config.misplaced_symbol_min_foreign_edges()
363                && at_least_percent(
364                    u64::from(*foreign_edges),
365                    u64::from(*total_outgoing_edges),
366                    config.misplaced_symbol_min_affinity_percent(),
367                )
368        }
369        _ => false,
370    }
371}
372
373/// A module with enough selected traffic whose internal share is too small.
374pub(super) fn is_low_cohesion_module(
375    measurement: &ModuleBoundaryMeasurement,
376    config: &ModuleBoundaryConfig,
377) -> bool {
378    match measurement {
379        ModuleBoundaryMeasurement::LowModuleCohesion {
380            internal_edges,
381            boundary_edges,
382        } => {
383            let total = u64::from(*internal_edges) + u64::from(*boundary_edges);
384            total >= u64::from(config.low_module_cohesion_min_outgoing_edges())
385                && below_percent(
386                    u64::from(*internal_edges),
387                    total,
388                    config.low_module_cohesion_min_percent(),
389                )
390        }
391        _ => false,
392    }
393}
394
395/// `part / total >= percent / 100`, by exact cross multiplication.
396fn at_least_percent(part: u64, total: u64, percent: u8) -> bool {
397    part * 100 >= total * u64::from(percent)
398}
399
400/// `part / total < percent / 100`, by exact cross multiplication.
401fn below_percent(part: u64, total: u64, percent: u8) -> bool {
402    part * 100 < total * u64::from(percent)
403}
404
405/// Judge one target's validated facts against local module-boundary policy.
406///
407/// Pure and infallible: rules run in catalog order, input order is retained
408/// within one rule, and every emitted verdict carries the exact row that
409/// triggered it.
410pub fn evaluate_module_boundary_rules(
411    input: &ModuleBoundaryInput,
412    config: &GateConfig,
413) -> Box<[GateVerdict]> {
414    if !config.enabled || !config.module_boundary.enabled() {
415        return Box::new([]);
416    }
417
418    MODULE_BOUNDARY_RULES
419        .iter()
420        .flat_map(|rule| rule_verdicts(rule, input, &config.module_boundary))
421        .collect()
422}
423
424/// One rule's verdicts, streamed so the evaluation allocates once in total.
425fn rule_verdicts<'i>(
426    rule: &'static ModuleBoundaryRule,
427    input: &'i ModuleBoundaryInput,
428    config: &'i ModuleBoundaryConfig,
429) -> impl Iterator<Item = GateVerdict> + 'i {
430    resolve_severity(rule.name, rule.default_severity, config.rule_overrides())
431        .into_iter()
432        .flat_map(move |severity| {
433            input
434                .facts()
435                .iter()
436                .filter(move |fact| (rule.predicate)(fact.measurement(), config))
437                .map(move |fact| verdict(rule, severity, &input.target, fact))
438        })
439}
440
441fn verdict(
442    rule: &'static ModuleBoundaryRule,
443    severity: GateSeverity,
444    target: &Arc<str>,
445    fact: &ModuleBoundaryFact,
446) -> GateVerdict {
447    let evidence = ModuleBoundaryEvidence::new(
448        Arc::clone(target),
449        fact.subject().clone(),
450        fact.measurement().clone(),
451    );
452    rule.verdict(severity, Some(GateEvidence::ModuleBoundary(evidence)))
453}