1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
22pub enum ModuleBoundaryInputError {
23 #[error("a gate location requires a non-empty path")]
25 EmptyLocationPath,
26 #[error("a gate location requires a one-based line")]
28 ZeroLocationLine,
29 #[error("a gate location requires a one-based column")]
31 ZeroLocationColumn,
32 #[error("entity {ordinal} requires a non-empty label")]
34 EmptyEntityLabel {
35 ordinal: u32,
37 },
38 #[error("a component requires at least one member")]
40 EmptyComponentMembers,
41 #[error("component member ordinal {ordinal} is repeated")]
43 DuplicateComponentMember {
44 ordinal: u32,
46 },
47 #[error("a component requires at least one declared partition")]
49 EmptyComponentPartitions,
50 #[error("component partition ordinal {ordinal} is repeated")]
52 DuplicateComponentPartition {
53 ordinal: u32,
55 },
56 #[error("a component with {members} members cannot occupy {partitions} partitions")]
58 ComponentPartitionsExceedMembers {
59 members: usize,
61 partitions: usize,
63 },
64 #[error("an acyclic component cannot hold {members} members")]
66 AcyclicComponentHasMultipleMembers {
67 members: usize,
69 },
70 #[error("a misplaced symbol cannot prefer its declared partition {ordinal}")]
72 MisplacedSymbolPartitionsAreEqual {
73 ordinal: u32,
75 },
76 #[error("symbol {ordinal} has no outgoing selected edge to attribute")]
78 MisplacedSymbolHasNoOutgoingEdges {
79 ordinal: u32,
81 },
82 #[error("{foreign_edges} foreign edges exceed {total_outgoing_edges} outgoing edges")]
84 MisplacedSymbolForeignEdgesExceedTotal {
85 foreign_edges: u32,
87 total_outgoing_edges: u32,
89 },
90 #[error("a module-boundary input requires a non-empty target")]
92 EmptyInputTarget,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ModuleBoundaryFact {
98 subject: ModuleBoundaryEntity,
99 measurement: ModuleBoundaryMeasurement,
100}
101
102impl ModuleBoundaryFact {
103 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 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 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 pub fn subject(&self) -> &ModuleBoundaryEntity {
168 &self.subject
169 }
170
171 pub fn measurement(&self) -> &ModuleBoundaryMeasurement {
173 &self.measurement
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ModuleBoundaryInput {
180 target: Arc<str>,
181 facts: Box<[ModuleBoundaryFact]>,
182}
183
184impl ModuleBoundaryInput {
185 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 pub fn target(&self) -> &str {
199 &self.target
200 }
201
202 pub fn facts(&self) -> &[ModuleBoundaryFact] {
204 &self.facts
205 }
206}
207
208fn 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 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
263fn 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
281fn 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
322pub(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
337pub(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
351pub(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
373pub(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
395fn at_least_percent(part: u64, total: u64, percent: u8) -> bool {
397 part * 100 >= total * u64::from(percent)
398}
399
400fn below_percent(part: u64, total: u64, percent: u8) -> bool {
402 part * 100 < total * u64::from(percent)
403}
404
405pub 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
424fn 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}