use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use syn::spanned::Spanned;
use syn::visit::Visit;
use crate::finding::{Finding, FindingId};
use crate::functions::walk_functions;
use crate::ingest::{CrateInfo, Workspace};
use crate::rules::boundaries::{
self, BoundaryConfig, BoundaryConfigError, MODULE_BOUNDARY_VIOLATION_RULE, ModuleBoundaryRule,
};
use crate::rules::complexity::WorkspaceComplexity;
use crate::rules::pattern::{CodeScope, Contraindication, Evidence, EvidenceLocation};
use crate::rules::slop_text::{CommentSpan, extract_comments};
pub const FUNCTIONAL_CORE_COMPLEXITY_THRESHOLD: u32 = 10;
pub const INTERFACE_SEGREGATION_METHOD_THRESHOLD: usize = 5;
pub const COHESION_ITEM_THRESHOLD: usize = 3;
pub const LAW_OF_DEMETER_CHAIN_THRESHOLD: usize = 3;
pub const API_EVOLVABILITY_MIN_FIELDS: usize = 2;
pub const MISU_MIN_OPTION_FIELDS: usize = 2;
pub const MISU_MIN_CONSTRUCTION_SITES: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DesignPrinciple {
Cohesion,
OpenClosed,
InterfaceSegregation,
DependencyInversion,
TellDontAsk,
LawOfDemeter,
Kiss,
Yagni,
FunctionalCoreImperativeShell,
MakeIllegalStatesUnrepresentable,
ParseDontValidate,
Composition,
ApiEvolvability,
StructuredConcurrency,
BoundedResources,
UnsafeContainment,
}
impl DesignPrinciple {
pub const fn slug(self) -> &'static str {
match self {
Self::Cohesion => "cohesion",
Self::OpenClosed => "open-closed",
Self::InterfaceSegregation => "interface-segregation",
Self::DependencyInversion => "dependency-inversion",
Self::TellDontAsk => "tell-dont-ask",
Self::LawOfDemeter => "law-of-demeter",
Self::Kiss => "kiss",
Self::Yagni => "yagni",
Self::FunctionalCoreImperativeShell => "functional-core-imperative-shell",
Self::MakeIllegalStatesUnrepresentable => "make-illegal-states-unrepresentable",
Self::ParseDontValidate => "parse-dont-validate",
Self::Composition => "composition",
Self::ApiEvolvability => "api-evolvability",
Self::StructuredConcurrency => "structured-concurrency",
Self::BoundedResources => "bounded-resources",
Self::UnsafeContainment => "unsafe-containment",
}
}
}
impl std::fmt::Display for DesignPrinciple {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.slug())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MissingEvidence {
pub description: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DesignAlternative {
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct PrincipleHeuristicId(String);
impl PrincipleHeuristicId {
pub fn as_str(&self) -> &str {
&self.0
}
fn compute(
principle: DesignPrinciple,
scope: &CodeScope,
evidence_identities: &[String],
) -> Self {
let mut modules = scope.modules.clone();
modules.sort();
let mut identities = evidence_identities.to_vec();
identities.sort();
identities.dedup();
let normalized = format!(
"{}|{}|{}|{}",
principle.slug(),
scope.krate,
modules.join(","),
identities.join(",")
);
Self(format!(
"principle:{}:{}",
principle.slug(),
crate::finding::fnv1a_hex(&normalized)
))
}
}
impl std::fmt::Display for PrincipleHeuristicId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PrincipleHeuristic {
pub id: PrincipleHeuristicId,
pub principle: DesignPrinciple,
pub scope: CodeScope,
pub evidence: Vec<Evidence>,
pub interpretation: String,
pub contraindications: Vec<Contraindication>,
pub missing_evidence: Vec<MissingEvidence>,
pub alternatives: Vec<DesignAlternative>,
pub related_findings: Vec<FindingId>,
}
fn single_module_scope(krate: &CrateInfo, module: impl Into<String>) -> CodeScope {
CodeScope {
krate: krate.name.clone(),
modules: vec![module.into()],
}
}
fn item_location(file: &Path, item_path: &str) -> EvidenceLocation {
EvidenceLocation {
file: file.to_path_buf(),
item_path: Some(item_path.to_string()),
}
}
fn item_context(krate: &CrateInfo, file: &Path, item_path: &str) -> (CodeScope, EvidenceLocation) {
(
single_module_scope(krate, item_path),
item_location(file, item_path),
)
}
macro_rules! principle_heuristic {
(
principle: $principle:expr,
$scope:expr,
$evidence_identities:expr,
{
evidence: $evidence:expr,
interpretation: $interpretation:expr,
contraindications: $contraindications:expr,
missing_evidence: $missing_evidence:expr,
alternatives: $alternatives:expr,
related_findings: $related_findings:expr $(,)?
}
) => {{
let principle = $principle;
let scope = $scope;
let evidence_identities = $evidence_identities;
PrincipleHeuristic {
id: PrincipleHeuristicId::compute(principle, &scope, &evidence_identities),
principle,
scope,
evidence: $evidence,
interpretation: $interpretation,
contraindications: $contraindications,
missing_evidence: $missing_evidence,
alternatives: $alternatives,
related_findings: $related_findings,
}
}};
}
pub fn analyze_workspace(
workspace: &Workspace,
complexity: &WorkspaceComplexity,
boundary_config: Option<&BoundaryConfig>,
) -> Result<Vec<PrincipleHeuristic>, BoundaryConfigError> {
let mut heuristics = functional_core_imperative_shell_candidates(workspace, complexity);
heuristics.extend(interface_segregation_candidates(workspace));
heuristics.extend(dependency_inversion_candidates(workspace, boundary_config)?);
heuristics.extend(cohesion_candidates(workspace, complexity));
heuristics.extend(law_of_demeter_candidates(workspace));
heuristics.extend(bounded_resources_candidates(workspace));
heuristics.extend(parse_dont_validate_candidates(workspace));
heuristics.extend(api_evolvability_candidates(workspace));
heuristics.extend(unsafe_containment_candidates(workspace));
heuristics.extend(make_illegal_states_unrepresentable_candidates(workspace));
Ok(heuristics)
}
fn for_each_parsed_file<'a>(
workspace: &'a Workspace,
mut visit: impl FnMut(&'a CrateInfo, &'a Path, &str, &syn::File),
) {
for krate in &workspace.crates {
for source in &krate.source_files {
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
visit(krate, &source.path, &text, &ast);
}
}
}
fn functional_core_imperative_shell_candidates(
workspace: &Workspace,
complexity: &WorkspaceComplexity,
) -> Vec<PrincipleHeuristic> {
let cyclomatic_by_function = cyclomatic_by_function_map(complexity);
let mut heuristics = Vec::new();
for_each_parsed_file(workspace, |krate, file, _text, ast| {
walk_functions(ast, |site| {
let Some(&cyclomatic) =
cyclomatic_by_function.get(&(file.to_path_buf(), site.qualified_name.clone()))
else {
return;
};
if cyclomatic < FUNCTIONAL_CORE_COMPLEXITY_THRESHOLD {
return;
}
let io_hits = io_call_hits(site.block);
if io_hits.is_empty() {
return;
}
heuristics.push(build_functional_core_imperative_shell_heuristic(
krate,
file,
&site.qualified_name,
cyclomatic,
&io_hits,
));
});
});
heuristics
}
fn cyclomatic_by_function_map(
complexity: &WorkspaceComplexity,
) -> BTreeMap<(PathBuf, String), u32> {
let mut map = BTreeMap::new();
for info in &complexity.functions {
map.insert(
(info.file.clone(), info.qualified_name.clone()),
info.cyclomatic,
);
}
map
}
const IO_PATH_PREFIX_PAIRS: &[(&str, &str)] = &[
("std", "fs"),
("std", "env"),
("std", "process"),
("std", "io"),
];
const IO_METHOD_NAMES: &[&str] = &[
"read_to_string",
"read_to_end",
"write_all",
"read_line",
"flush",
];
fn path_segment_strings(path: &syn::Path) -> Vec<String> {
path.segments.iter().map(|s| s.ident.to_string()).collect()
}
fn path_contains_consecutive_pair(path: &syn::Path, pairs: &[(&str, &str)]) -> bool {
path_segment_strings(path)
.windows(2)
.any(|pair| pairs.iter().any(|(a, b)| pair[0] == *a && pair[1] == *b))
}
fn path_ends_with(path: &syn::Path, name: &str) -> bool {
path.segments.last().is_some_and(|s| s.ident == name)
}
fn path_matches_io_prefix(path: &syn::Path) -> bool {
path_contains_consecutive_pair(path, IO_PATH_PREFIX_PAIRS)
}
fn io_call_hits(block: &syn::Block) -> Vec<String> {
use quote::ToTokens;
struct Finder {
hits: Vec<String>,
}
impl<'ast> Visit<'ast> for Finder {
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if let syn::Expr::Path(expr_path) = node.func.as_ref()
&& path_matches_io_prefix(&expr_path.path)
{
self.hits.push(node.func.to_token_stream().to_string());
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
let name = node.method.to_string();
if IO_METHOD_NAMES.contains(&name.as_str()) {
self.hits.push(format!(".{name}(...)"));
}
syn::visit::visit_expr_method_call(self, node);
}
}
let mut finder = Finder { hits: Vec::new() };
finder.visit_block(block);
finder.hits
}
fn build_functional_core_imperative_shell_heuristic(
krate: &CrateInfo,
file: &Path,
item_path: &str,
cyclomatic: u32,
io_hits: &[String],
) -> PrincipleHeuristic {
let (scope, location) = item_context(krate, file, item_path);
let structural = Evidence {
description: format!(
"`{item_path}` calls at least one I/O-/environment-/process-shaped operation: {}.",
io_hits.join(", ")
),
locations: vec![location.clone()],
};
let measured = Evidence {
description: format!(
"`{item_path}` has a cyclomatic complexity of {cyclomatic}, at or above the \
{FUNCTIONAL_CORE_COMPLEXITY_THRESHOLD} threshold this heuristic treats as \
non-trivial branching — an independently computed metric from \
`judge::rules::complexity`, not a second reading of the I/O call pattern above."
),
locations: vec![location],
};
let evidence_identities = vec![item_path.to_string()];
principle_heuristic! {
principle: DesignPrinciple::FunctionalCoreImperativeShell,
scope,
evidence_identities,
{
evidence: vec![structural, measured],
interpretation: "This function combines I/O/environment/process operations with \
non-trivial branching complexity in one place. Separating the deterministic \
computation from the I/O shell could make the computation independently testable."
.to_string(),
contraindications: vec![
Contraindication {
description: "A thin orchestration function that mostly sequences I/O calls \
with light glue logic may not benefit from further splitting."
.to_string(),
},
Contraindication {
description: "If the branching complexity comes from error handling around the \
I/O itself (not separate business logic), separating core from shell may \
not apply."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether the branching logic is genuinely independent business logic \
(vs. I/O-specific error handling) is not distinguished by this heuristic."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the function as-is.".to_string(),
},
DesignAlternative {
description: "Extract the non-I/O computation into a pure, \
independently-testable function; keep I/O calls in a thin wrapper."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
struct TraitDeclaration {
name: String,
method_count: usize,
location: EvidenceLocation,
}
struct TraitImplementation {
trait_name: String,
self_type: String,
overridden_methods: std::collections::BTreeSet<String>,
location: EvidenceLocation,
}
fn interface_segregation_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for krate in &workspace.crates {
let (traits, impls) = collect_trait_declarations_and_impls(krate);
for trait_decl in &traits {
if trait_decl.method_count < INTERFACE_SEGREGATION_METHOD_THRESHOLD {
continue;
}
if let Some((first, second)) = find_disjoint_impl_pair(trait_decl, &impls) {
heuristics.push(build_interface_segregation_heuristic(
krate, trait_decl, first, second,
));
}
}
}
heuristics
}
fn collect_trait_declarations_and_impls(
krate: &CrateInfo,
) -> (Vec<TraitDeclaration>, Vec<TraitImplementation>) {
use quote::ToTokens;
struct Collector {
file: PathBuf,
traits: Vec<TraitDeclaration>,
impls: Vec<TraitImplementation>,
}
impl<'ast> Visit<'ast> for Collector {
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
let method_count = node
.items
.iter()
.filter(|item| matches!(item, syn::TraitItem::Fn(_)))
.count();
self.traits.push(TraitDeclaration {
name: node.ident.to_string(),
method_count,
location: EvidenceLocation {
file: self.file.clone(),
item_path: Some(node.ident.to_string()),
},
});
syn::visit::visit_item_trait(self, node);
}
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
if let Some((_, path, _)) = &node.trait_
&& let Some(segment) = path.segments.last()
{
let trait_name = segment.ident.to_string();
let self_type = node.self_ty.to_token_stream().to_string();
let overridden_methods = node
.items
.iter()
.filter_map(|item| match item {
syn::ImplItem::Fn(method) => Some(method.sig.ident.to_string()),
_ => None,
})
.collect();
self.impls.push(TraitImplementation {
trait_name: trait_name.clone(),
location: EvidenceLocation {
file: self.file.clone(),
item_path: Some(format!("<{self_type} as {trait_name}>")),
},
self_type,
overridden_methods,
});
}
syn::visit::visit_item_impl(self, node);
}
}
let mut traits = Vec::new();
let mut impls = Vec::new();
for source in &krate.source_files {
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
let mut collector = Collector {
file: source.path.clone(),
traits: Vec::new(),
impls: Vec::new(),
};
collector.visit_file(&ast);
traits.extend(collector.traits);
impls.extend(collector.impls);
}
(traits, impls)
}
fn find_disjoint_impl_pair<'a>(
trait_decl: &TraitDeclaration,
impls: &'a [TraitImplementation],
) -> Option<(&'a TraitImplementation, &'a TraitImplementation)> {
let mut candidates: Vec<&TraitImplementation> = impls
.iter()
.filter(|imp| imp.trait_name == trait_decl.name && !imp.overridden_methods.is_empty())
.collect();
candidates
.sort_by(|a, b| (&a.self_type, &a.location.file).cmp(&(&b.self_type, &b.location.file)));
candidates.iter().enumerate().find_map(|(i, first)| {
candidates[i + 1..]
.iter()
.find(|second| {
first
.overridden_methods
.is_disjoint(&second.overridden_methods)
})
.map(|second| (*first, *second))
})
}
fn build_interface_segregation_heuristic(
krate: &CrateInfo,
trait_decl: &TraitDeclaration,
first: &TraitImplementation,
second: &TraitImplementation,
) -> PrincipleHeuristic {
let scope = single_module_scope(krate, trait_decl.name.clone());
let structural = Evidence {
description: format!(
"`{}` declares {} methods, at or above the {INTERFACE_SEGREGATION_METHOD_THRESHOLD} \
threshold this heuristic treats as a large trait.",
trait_decl.name, trait_decl.method_count
),
locations: vec![trait_decl.location.clone()],
};
let usage = Evidence {
description: format!(
"In this crate, `{}` overrides {{{}}} and `{}` overrides {{{}}} of `{}` — two \
implementors whose overridden method sets share no method name.",
first.self_type,
sorted_joined(&first.overridden_methods),
second.self_type,
sorted_joined(&second.overridden_methods),
trait_decl.name
),
locations: vec![first.location.clone(), second.location.clone()],
};
let evidence_identities = vec![
trait_decl.name.clone(),
first.self_type.clone(),
second.self_type.clone(),
];
principle_heuristic! {
principle: DesignPrinciple::InterfaceSegregation,
scope,
evidence_identities,
{
evidence: vec![structural, usage],
interpretation: format!(
"This trait has {} methods, and its implementors in this crate split into \
non-overlapping groups by which methods they override. That may indicate the \
trait actually models more than one consumer-facing interface.",
trait_decl.method_count
),
contraindications: vec![
Contraindication {
description: "A trait with many default-implemented convenience methods on top \
of a small required core is a common, intentional design — not \
automatically evidence of multiple interfaces."
.to_string(),
},
Contraindication {
description: "Only 2 implementors may be too few to establish a real usage \
pattern, rather than incidental non-overlap."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether callers actually depend on the trait through the full \
interface or only through one of the observed subsets is not checked here \
(would need cross-crate consumer analysis)."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the trait as-is.".to_string(),
},
DesignAlternative {
description: "Split into two or more smaller traits along the observed method \
groups, potentially with a supertrait for shared methods if any exist."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
fn sorted_joined(methods: &std::collections::BTreeSet<String>) -> String {
methods.iter().cloned().collect::<Vec<_>>().join(", ")
}
fn module_path_for_file(crate_root: &Path, file_path: &Path) -> Option<String> {
let relative = file_path.strip_prefix(crate_root).ok()?;
let mut components: Vec<String> = relative
.components()
.filter_map(|component| match component {
std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
_ => None,
})
.collect();
if components.first().map(String::as_str) != Some("src") {
return None;
}
components.remove(0);
if components.is_empty() {
return None;
}
if components.len() == 1 && matches!(components[0].as_str(), "lib.rs" | "main.rs") {
return Some(String::new());
}
if components.first().map(String::as_str) == Some("bin") {
return Some(String::new());
}
let last = components.last().cloned()?;
if last == "mod.rs" {
components.pop();
} else {
let stem = last.strip_suffix(".rs")?;
let stem = stem.to_string();
*components.last_mut().expect("just checked non-empty") = stem;
}
Some(components.join("::"))
}
fn module_path_under(module_path: &str, prefix: &str) -> bool {
module_path == prefix || module_path.starts_with(&format!("{prefix}::"))
}
struct LeakedSignature {
item_path: String,
leaked_type: String,
forbidden: String,
location: EvidenceLocation,
}
fn unwrap_reference_type_path(ty: &syn::Type) -> Option<&syn::TypePath> {
let inner = match ty {
syn::Type::Reference(reference) => reference.elem.as_ref(),
other => other,
};
match inner {
syn::Type::Path(type_path) => Some(type_path),
_ => None,
}
}
fn leaked_type_in(ty: &syn::Type, forbidden: &[String]) -> Option<(String, String)> {
use quote::ToTokens;
let type_path = unwrap_reference_type_path(ty)?;
let segments = path_segment_strings(&type_path.path);
if segments.first().map(String::as_str) != Some("crate") {
return None;
}
let rest = &segments[1..];
forbidden.iter().find_map(|target| {
let target_segments: Vec<&str> = target.split("::").collect();
let is_match = rest.len() >= target_segments.len()
&& rest
.iter()
.zip(target_segments.iter())
.all(|(segment, target_segment)| segment == target_segment);
is_match.then(|| (ty.to_token_stream().to_string(), target.clone()))
})
}
fn check_signature(
hits: &mut Vec<LeakedSignature>,
item_path: &str,
sig: &syn::Signature,
file: &Path,
forbidden: &[String],
) {
let mut types: Vec<&syn::Type> = sig
.inputs
.iter()
.filter_map(|arg| match arg {
syn::FnArg::Typed(pat_type) => Some(pat_type.ty.as_ref()),
syn::FnArg::Receiver(_) => None,
})
.collect();
if let syn::ReturnType::Type(_, ty) = &sig.output {
types.push(ty.as_ref());
}
for ty in types {
if let Some((leaked_type, forbidden)) = leaked_type_in(ty, forbidden) {
hits.push(LeakedSignature {
item_path: item_path.to_string(),
leaked_type,
forbidden,
location: item_location(file, item_path),
});
}
}
}
fn leaked_signatures(
krate: &CrateInfo,
from_module: &str,
forbidden: &[String],
) -> Vec<LeakedSignature> {
let mut leaks = Vec::new();
for source in &krate.source_files {
let Some(module_path) = module_path_for_file(&krate.root, &source.path) else {
continue;
};
if !module_path_under(&module_path, from_module) {
continue;
}
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
walk_functions(&ast, |site| {
if !matches!(site.vis, Some(syn::Visibility::Public(_))) {
return;
}
check_signature(
&mut leaks,
&site.qualified_name,
site.sig,
&source.path,
forbidden,
);
});
}
leaks
}
fn dependency_inversion_candidates(
workspace: &Workspace,
boundary_config: Option<&BoundaryConfig>,
) -> Result<Vec<PrincipleHeuristic>, BoundaryConfigError> {
let Some(config) = boundary_config else {
return Ok(Vec::new());
};
if config.module_boundaries.is_empty() {
return Ok(Vec::new());
}
let boundaries = boundaries::evaluate(workspace, config)?;
let mut heuristics = Vec::new();
for rule in &config.module_boundaries {
let Some(krate) = workspace.crates.iter().find(|k| k.name == rule.krate) else {
continue;
};
let prefix = format!("{} [direct]:", rule.name);
let related_findings: Vec<&Finding> = boundaries
.findings
.iter()
.filter(|finding| {
finding.rule == MODULE_BOUNDARY_VIOLATION_RULE
&& finding.location.item_path.starts_with(&prefix)
})
.collect();
if related_findings.is_empty() {
continue;
}
let leaks = leaked_signatures(krate, &rule.from, &rule.forbidden);
if leaks.is_empty() {
continue;
}
heuristics.push(build_dependency_inversion_heuristic(
krate,
rule,
&related_findings,
&leaks,
));
}
Ok(heuristics)
}
fn build_dependency_inversion_heuristic(
krate: &CrateInfo,
rule: &ModuleBoundaryRule,
related_findings: &[&Finding],
leaks: &[LeakedSignature],
) -> PrincipleHeuristic {
let scope = single_module_scope(krate, rule.from.clone());
let call_level = Evidence {
description: format!(
"`{}` already has {} `module-boundary-violation` finding(s) for `{}` -> {{{}}}, \
recorded independently by `judge::rules::boundaries::evaluate`.",
rule.name,
related_findings.len(),
rule.from,
rule.forbidden.join(", "),
),
locations: related_findings
.iter()
.map(|finding| EvidenceLocation {
file: finding.location.file.clone(),
item_path: Some(finding.location.item_path.clone()),
})
.collect(),
};
let leak_descriptions: Vec<String> = leaks
.iter()
.map(|leak| {
format!(
"`{}` in `{}` names `{}` (matches forbidden module `{}`)",
leak.item_path, rule.from, leak.leaked_type, leak.forbidden
)
})
.collect();
let signature_leak = Evidence {
description: format!(
"In `{}`, {} public function signature(s) name a type whose path begins with \
`crate::<forbidden module>`: {}.",
rule.from,
leaks.len(),
leak_descriptions.join("; "),
),
locations: leaks.iter().map(|leak| leak.location.clone()).collect(),
};
let mut evidence_identities = vec![rule.name.clone()];
evidence_identities.extend(leaks.iter().map(|leak| leak.item_path.clone()));
evidence_identities.extend(related_findings.iter().map(|f| f.id.as_str().to_string()));
principle_heuristic! {
principle: DesignPrinciple::DependencyInversion,
scope,
evidence_identities,
{
evidence: vec![call_level, signature_leak],
interpretation: "This module boundary is both crossed at the call level and has \
infrastructure types leaking into public signatures of the domain-tagged module. \
Introducing a port/trait at the boundary could decouple the domain module from the \
concrete infrastructure type."
.to_string(),
contraindications: vec![
Contraindication {
description: "A small, stable, unlikely-to-change infrastructure type (e.g. a \
newtype wrapper) may not justify the indirection of a port/trait."
.to_string(),
},
Contraindication {
description: "If the module boundary itself is new/experimental configuration, \
the violations may reflect an intentional transition period rather than a \
design flaw."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether the leaked type is actually varied/swapped in practice (the \
core justification for dependency inversion) is not checked — only that a \
public signature names it."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the module boundary as-is.".to_string(),
},
DesignAlternative {
description: "Introduce a trait/port owned by the domain module, implement it \
for the infrastructure type, and change the public signature to use the \
trait object/generic instead."
.to_string(),
},
],
related_findings: related_findings.iter().map(|f| f.id.clone()).collect(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EffectCategory {
IoOperations,
TerminalOutput,
ComplexComputation,
}
impl EffectCategory {
fn label(self) -> &'static str {
match self {
Self::IoOperations => "I/O operations",
Self::TerminalOutput => "terminal output",
Self::ComplexComputation => "complex computation",
}
}
}
const TERMINAL_OUTPUT_MACROS: &[&str] = &["println", "eprintln", "print", "eprint"];
const WRITE_MACROS: &[&str] = &["write", "writeln"];
const TERMINAL_STREAM_MARKERS: &[&str] = &["stdout", "stderr", "Stdout", "Stderr"];
fn terminal_output_hits(block: &syn::Block) -> Vec<String> {
struct Finder {
hits: Vec<String>,
}
impl<'ast> Visit<'ast> for Finder {
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if let Some(name) = node.path.get_ident().map(ToString::to_string) {
let is_terminal_output = TERMINAL_OUTPUT_MACROS.contains(&name.as_str())
|| (WRITE_MACROS.contains(&name.as_str())
&& TERMINAL_STREAM_MARKERS
.iter()
.any(|marker| node.tokens.to_string().contains(marker)));
if is_terminal_output {
self.hits.push(format!("{name}!(...)"));
}
}
syn::visit::visit_macro(self, node);
}
}
let mut finder = Finder { hits: Vec::new() };
finder.visit_block(block);
finder.hits
}
struct CategoryHit {
category: EffectCategory,
detail: String,
}
struct FileItem {
name: String,
location: EvidenceLocation,
categories: Vec<CategoryHit>,
}
struct DeclaredItemCollector {
file: PathBuf,
items: Vec<FileItem>,
}
impl DeclaredItemCollector {
fn push(&mut self, name: String) {
self.items.push(FileItem {
location: item_location(&self.file, &name),
name,
categories: Vec::new(),
});
}
fn push_if_public(&mut self, vis: &syn::Visibility, ident: &syn::Ident) {
if matches!(vis, syn::Visibility::Public(_)) {
self.push(ident.to_string());
}
}
}
impl<'ast> Visit<'ast> for DeclaredItemCollector {
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
self.push_if_public(&node.vis, &node.ident);
syn::visit::visit_item_struct(self, node);
}
fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
self.push_if_public(&node.vis, &node.ident);
syn::visit::visit_item_enum(self, node);
}
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
self.push_if_public(&node.vis, &node.ident);
syn::visit::visit_item_trait(self, node);
}
}
fn collect_file_items(
ast: &syn::File,
file: &Path,
cyclomatic_by_function: &BTreeMap<(PathBuf, String), u32>,
) -> Vec<FileItem> {
let mut items = Vec::new();
walk_functions(ast, |site| {
let Some(vis) = site.vis else {
return;
};
if !matches!(vis, syn::Visibility::Public(_)) {
return;
}
let mut categories = Vec::new();
let io_hits = io_call_hits(site.block);
if !io_hits.is_empty() {
categories.push(CategoryHit {
category: EffectCategory::IoOperations,
detail: io_hits.join(", "),
});
}
let output_hits = terminal_output_hits(site.block);
if !output_hits.is_empty() {
categories.push(CategoryHit {
category: EffectCategory::TerminalOutput,
detail: output_hits.join(", "),
});
}
if let Some(&cyclomatic) =
cyclomatic_by_function.get(&(file.to_path_buf(), site.qualified_name.clone()))
&& cyclomatic >= FUNCTIONAL_CORE_COMPLEXITY_THRESHOLD
{
categories.push(CategoryHit {
category: EffectCategory::ComplexComputation,
detail: format!("cyclomatic complexity {cyclomatic}"),
});
}
items.push(FileItem {
location: item_location(file, &site.qualified_name),
name: site.qualified_name,
categories,
});
});
let mut declared = DeclaredItemCollector {
file: file.to_path_buf(),
items: Vec::new(),
};
declared.visit_file(ast);
items.extend(declared.items);
items
}
fn first_differing_category_pair(items: &[FileItem]) -> Option<(usize, usize)> {
for i in 0..items.len() {
for j in (i + 1)..items.len() {
let differs = items[i].categories.iter().any(|hit_i| {
items[j]
.categories
.iter()
.any(|hit_j| hit_i.category != hit_j.category)
});
if differs {
return Some((i, j));
}
}
}
None
}
fn cohesion_candidates(
workspace: &Workspace,
complexity: &WorkspaceComplexity,
) -> Vec<PrincipleHeuristic> {
let cyclomatic_by_function = cyclomatic_by_function_map(complexity);
let mut heuristics = Vec::new();
for_each_parsed_file(workspace, |krate, file, _text, ast| {
let items = collect_file_items(ast, file, &cyclomatic_by_function);
if items.len() < COHESION_ITEM_THRESHOLD {
return;
}
if first_differing_category_pair(&items).is_some() {
heuristics.push(build_cohesion_heuristic(krate, file, &items));
}
});
heuristics
}
fn build_cohesion_heuristic(
krate: &CrateInfo,
file: &Path,
items: &[FileItem],
) -> PrincipleHeuristic {
let module =
module_path_for_file(&krate.root, file).unwrap_or_else(|| file.display().to_string());
let scope = single_module_scope(krate, module);
let item_names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
let structural = Evidence {
description: format!(
"This file declares {} public top-level items, at or above the \
{COHESION_ITEM_THRESHOLD} threshold this heuristic treats as several public items \
in one file: {}.",
items.len(),
item_names.join(", "),
),
locations: items.iter().map(|item| item.location.clone()).collect(),
};
let categorized: Vec<&FileItem> = items
.iter()
.filter(|item| !item.categories.is_empty())
.collect();
let category_descriptions: Vec<String> = categorized
.iter()
.map(|item| {
let hits: Vec<String> = item
.categories
.iter()
.map(|hit| format!("{} ({})", hit.category.label(), hit.detail))
.collect();
format!("`{}` shows {}", item.name, hits.join(" and "))
})
.collect();
let category_evidence = Evidence {
description: format!(
"At least two of these items show a different effect category from each other, \
independently of one another: {}.",
category_descriptions.join("; "),
),
locations: categorized
.iter()
.map(|item| item.location.clone())
.collect(),
};
let evidence_identities: Vec<String> = items.iter().map(|item| item.name.clone()).collect();
principle_heuristic! {
principle: DesignPrinciple::Cohesion,
scope,
evidence_identities,
{
evidence: vec![structural, category_evidence],
interpretation: "This file defines several public items, and at least two of them \
exhibit different effect categories (I/O, terminal output, complex computation) \
independently of each other. That may indicate the file bundles more than one \
responsibility."
.to_string(),
contraindications: vec![
Contraindication {
description: "A module deliberately organized as a small orchestration/facade \
layer may legitimately touch several effect kinds by design — that's its \
job, not a cohesion problem."
.to_string(),
},
Contraindication {
description: "Three or more public items in one file is extremely common in \
Rust and not inherently a signal on its own without the category-diversity \
evidence."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether these items are actually called together/interdependently \
(true coupling) or are just co-located is not checked here — only that they \
exist in the same file with different effect signatures."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the file as-is.".to_string(),
},
DesignAlternative {
description: "Split the file along the observed effect-category boundaries \
into separate modules, each with a narrower responsibility."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
struct ChainHit {
length: usize,
rendered: String,
intermediates: Vec<String>,
}
fn chain_base_is_excluded(expr: &syn::Expr) -> bool {
let mut cursor = expr;
loop {
match cursor {
syn::Expr::Reference(reference) => cursor = reference.expr.as_ref(),
syn::Expr::Paren(paren) => cursor = paren.expr.as_ref(),
_ => break,
}
}
match cursor {
syn::Expr::Path(path) => path.path.is_ident("self"),
syn::Expr::Field(field) => {
matches!(field.base.as_ref(), syn::Expr::Path(base) if base.path.is_ident("self"))
}
syn::Expr::Call(call) => {
let syn::Expr::Path(func_path) = call.func.as_ref() else {
return false;
};
let segments = path_segment_strings(&func_path.path);
if segments.first().map(String::as_str) == Some("Self") {
return true;
}
segments.len() >= 2
&& matches!(
segments.last().map(String::as_str),
Some("new" | "default" | "builder")
)
}
_ => false,
}
}
fn has_intermediate_let(block: &syn::Block, intermediates: &[String]) -> bool {
use quote::ToTokens;
block.stmts.iter().any(|stmt| {
if let syn::Stmt::Local(local) = stmt
&& let Some(init) = &local.init
{
intermediates.contains(&init.expr.to_token_stream().to_string())
} else {
false
}
})
}
fn law_of_demeter_chain_hits(block: &syn::Block) -> Vec<ChainHit> {
use quote::ToTokens;
struct Finder<'ast> {
consumed: std::collections::HashSet<*const syn::ExprMethodCall>,
block_stack: Vec<&'ast syn::Block>,
hits: Vec<ChainHit>,
}
impl<'ast> Visit<'ast> for Finder<'ast> {
fn visit_block(&mut self, node: &'ast syn::Block) {
self.block_stack.push(node);
syn::visit::visit_block(self, node);
self.block_stack.pop();
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
if self
.consumed
.contains(&(node as *const syn::ExprMethodCall))
{
syn::visit::visit_expr_method_call(self, node);
return;
}
let mut length = 1;
let mut intermediates = Vec::new();
let mut cursor: &syn::Expr = node.receiver.as_ref();
while let syn::Expr::MethodCall(inner) = cursor {
self.consumed.insert(inner as *const syn::ExprMethodCall);
length += 1;
intermediates.push(cursor.to_token_stream().to_string());
cursor = inner.receiver.as_ref();
}
if length >= LAW_OF_DEMETER_CHAIN_THRESHOLD
&& !chain_base_is_excluded(cursor)
&& !self
.block_stack
.last()
.is_some_and(|block| has_intermediate_let(block, &intermediates))
{
self.hits.push(ChainHit {
length,
rendered: node.to_token_stream().to_string(),
intermediates,
});
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
let mut finder = Finder {
consumed: std::collections::HashSet::new(),
block_stack: Vec::new(),
hits: Vec::new(),
};
finder.visit_block(block);
finder.hits
}
fn site_level_candidates<H>(
workspace: &Workspace,
hits_in_block: impl Fn(&syn::Block) -> Vec<H>,
build: impl Fn(&CrateInfo, &Path, &str, &H) -> PrincipleHeuristic,
) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for_each_parsed_file(workspace, |krate, file, _text, ast| {
walk_functions(ast, |site| {
for hit in hits_in_block(site.block) {
heuristics.push(build(krate, file, &site.qualified_name, &hit));
}
});
});
heuristics
}
fn law_of_demeter_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
site_level_candidates(
workspace,
law_of_demeter_chain_hits,
build_law_of_demeter_heuristic,
)
}
fn build_law_of_demeter_heuristic(
krate: &CrateInfo,
file: &Path,
item_path: &str,
hit: &ChainHit,
) -> PrincipleHeuristic {
let (scope, location) = item_context(krate, file, item_path);
let structural = Evidence {
description: format!(
"`{item_path}` contains a method chain with {} chained calls in one unbroken \
expression, at or above the {LAW_OF_DEMETER_CHAIN_THRESHOLD}-call threshold this \
heuristic treats as a long reach: `{}`.",
hit.length, hit.rendered
),
locations: vec![location.clone()],
};
let corroborating = Evidence {
description: if hit.intermediates.is_empty() {
"This chain has no intermediate step besides its base receiver to look for in a \
sibling `let` binding."
.to_string()
} else {
format!(
"No sibling `let` binding elsewhere in the same block captures any of this \
chain's intermediate results ({}) — the chain was not already broken into \
readable steps.",
hit.intermediates.join(", ")
)
},
locations: vec![location],
};
let evidence_identities = vec![item_path.to_string(), hit.rendered.clone()];
principle_heuristic! {
principle: DesignPrinciple::LawOfDemeter,
scope,
evidence_identities,
{
evidence: vec![structural, corroborating],
interpretation: format!(
"In the examined function, this expression reaches through {} chained method calls \
in a single unbroken step, without an intermediate binding that would suggest the \
steps were deliberately made readable. That may indicate the caller depends on \
more of an intermediate object's own interface than its immediate collaborator.",
hit.length
),
contraindications: vec![
Contraindication {
description: "A chain over a well-known, stable interface designed for chaining \
(iterator adaptors, string/path builders) is idiomatic Rust and not itself \
evidence of reaching through unrelated internals."
.to_string(),
},
Contraindication {
description: "If every type in the chain belongs to the same module or is a \
thin wrapper around the previous step's own concern, the chain may not cross \
any real object boundary."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether the intermediate types in the chain belong to unrelated \
ownership boundaries (the actual Demeter concern) or are closely related \
collaborators is not checked here — only that the chain is long and unbroken."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the expression as-is.".to_string(),
},
DesignAlternative {
description: "Introduce intermediate `let` bindings, or a method on the \
immediate collaborator that performs the deeper step internally (Tell, \
Don't Ask), so the caller depends on one interface instead of several \
chained ones."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
struct LoopHit {
line: usize,
}
fn path_matches_process_exit(path: &syn::Path) -> bool {
path_contains_consecutive_pair(path, &[("process", "exit")])
}
fn loop_has_any_exit(body: &syn::Block) -> bool {
struct Finder {
found: bool,
}
impl<'ast> Visit<'ast> for Finder {
fn visit_expr_break(&mut self, node: &'ast syn::ExprBreak) {
self.found = true;
syn::visit::visit_expr_break(self, node);
}
fn visit_expr_return(&mut self, node: &'ast syn::ExprReturn) {
self.found = true;
syn::visit::visit_expr_return(self, node);
}
fn visit_expr_try(&mut self, node: &'ast syn::ExprTry) {
self.found = true;
syn::visit::visit_expr_try(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if node.path.is_ident("panic") {
self.found = true;
}
syn::visit::visit_macro(self, node);
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if let syn::Expr::Path(path) = node.func.as_ref()
&& path_matches_process_exit(&path.path)
{
self.found = true;
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_closure(&mut self, _node: &'ast syn::ExprClosure) {}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
let mut finder = Finder { found: false };
finder.visit_block(body);
finder.found
}
fn bounded_resources_loop_hits(block: &syn::Block) -> Vec<LoopHit> {
struct Finder {
hits: Vec<LoopHit>,
}
impl<'ast> Visit<'ast> for Finder {
fn visit_expr_loop(&mut self, node: &'ast syn::ExprLoop) {
if !loop_has_any_exit(&node.body) {
self.hits.push(LoopHit {
line: node.loop_token.span().start().line,
});
}
syn::visit::visit_expr_loop(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
let mut finder = Finder { hits: Vec::new() };
finder.visit_block(block);
finder.hits
}
fn bounded_resources_loop_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
site_level_candidates(
workspace,
bounded_resources_loop_hits,
build_bounded_resources_loop_heuristic,
)
}
fn build_bounded_resources_loop_heuristic(
krate: &CrateInfo,
file: &Path,
item_path: &str,
hit: &LoopHit,
) -> PrincipleHeuristic {
let (scope, location) = item_context(krate, file, item_path);
let structural = Evidence {
description: format!(
"`{item_path}` has a `loop {{ ... }}` at line {} with no `break` anywhere in its \
own lexical body (not counting a nested closure or a locally defined nested `fn`).",
hit.line
),
locations: vec![location.clone()],
};
let corroborating = Evidence {
description: "The same loop also has no `return`, `?`, `panic!`, or \
`std::process::exit` anywhere in its own lexical body — no visible exit path at \
all, not just an absent `break`."
.to_string(),
locations: vec![location],
};
let evidence_identities = vec![item_path.to_string(), hit.line.to_string()];
principle_heuristic! {
principle: DesignPrinciple::BoundedResources,
scope,
evidence_identities,
{
evidence: vec![structural, corroborating],
interpretation: "In the examined function, this `loop` has no visible exit — no \
`break`, `return`, `?`, `panic!`, or `std::process::exit` — anywhere in its own \
lexical body. That may indicate the loop's termination depends on something this \
per-function, syntax-only check cannot see, or that the loop is genuinely \
unbounded."
.to_string(),
contraindications: vec![
Contraindication {
description: "A loop meant to run for the process's entire lifetime (an event \
loop, a server accept loop) is deliberately unbounded — that's its job, not \
a defect."
.to_string(),
},
Contraindication {
description: "This is a Fast-Tier syntax proxy, not a termination proof: an \
exit driven by a called function's own control flow (e.g. a helper that \
itself calls `std::process::exit`) would not be seen here."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether this loop is an intentional long-running loop versus a \
genuine bug is not distinguished here — that depends on non-observable intent. \
A `break` reached only via a labeled block/loop from further out, or an \
unlabeled `break` belonging only to a nested inner loop (still conservatively \
counted as this loop's own exit), would not be recognized correctly by this \
check."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the loop as-is.".to_string(),
},
DesignAlternative {
description: "Make the loop's bound or termination condition explicit — a \
`while`/`for` with a visible bound, an explicit `break` condition, or a \
documented comment explaining why the loop is intentionally unbounded."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
struct RecursionHit {
call_count: usize,
first_call_rendered: String,
first_call_line: usize,
param_names: Vec<String>,
}
fn expr_references_any(expr: &syn::Expr, names: &[String]) -> bool {
struct Finder<'a> {
names: &'a [String],
found: bool,
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
if let Some(ident) = node.path.get_ident() {
let name = ident.to_string();
if self.names.contains(&name) {
self.found = true;
}
}
syn::visit::visit_expr_path(self, node);
}
}
let mut finder = Finder {
names,
found: false,
};
finder.visit_expr(expr);
finder.found
}
fn direct_recursive_calls(block: &syn::Block, name: &str) -> Vec<(usize, String)> {
use quote::ToTokens;
struct Finder<'a> {
name: &'a str,
hits: Vec<(usize, String)>,
}
impl Finder<'_> {
fn record(&mut self, line: usize, rendered: String) {
self.hits.push((line, rendered));
}
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if let syn::Expr::Path(path) = node.func.as_ref()
&& path_ends_with(&path.path, self.name)
{
self.record(node.span().start().line, node.to_token_stream().to_string());
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
if node.method == self.name {
self.record(node.span().start().line, node.to_token_stream().to_string());
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
let mut finder = Finder {
name,
hits: Vec::new(),
};
finder.visit_block(block);
finder.hits.sort_by_key(|(line, _)| *line);
finder.hits
}
fn param_names(sig: &syn::Signature) -> Vec<String> {
typed_params(sig)
.into_iter()
.map(|(name, _)| name)
.collect()
}
fn has_parameter_guard_before(block: &syn::Block, params: &[String], before_line: usize) -> bool {
struct Finder<'a> {
params: &'a [String],
before_line: usize,
found: bool,
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
if node.if_token.span().start().line < self.before_line
&& expr_references_any(&node.cond, self.params)
{
self.found = true;
}
syn::visit::visit_expr_if(self, node);
}
fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
if node.match_token.span().start().line < self.before_line
&& expr_references_any(&node.expr, self.params)
{
self.found = true;
}
syn::visit::visit_expr_match(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
let mut finder = Finder {
params,
before_line,
found: false,
};
finder.visit_block(block);
finder.found
}
fn bounded_resources_recursion_hit(
name: &str,
sig: &syn::Signature,
block: &syn::Block,
) -> Option<RecursionHit> {
let calls = direct_recursive_calls(block, name);
let (first_call_line, first_call_rendered) = calls.first()?.clone();
let params = param_names(sig);
if has_parameter_guard_before(block, ¶ms, first_call_line) {
return None;
}
Some(RecursionHit {
call_count: calls.len(),
first_call_rendered,
first_call_line,
param_names: params,
})
}
fn bounded_resources_recursion_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for_each_parsed_file(workspace, |krate, file, _text, ast| {
let mut hits: Vec<(String, RecursionHit)> = Vec::new();
walk_functions(ast, |site| {
if site.vis.is_none() {
return;
}
let name = site.sig.ident.to_string();
if let Some(hit) = bounded_resources_recursion_hit(&name, site.sig, site.block) {
hits.push((site.qualified_name.clone(), hit));
}
});
for (item_path, hit) in hits {
heuristics.push(build_bounded_resources_recursion_heuristic(
krate, file, &item_path, &hit,
));
}
});
heuristics
}
fn build_bounded_resources_recursion_heuristic(
krate: &CrateInfo,
file: &Path,
item_path: &str,
hit: &RecursionHit,
) -> PrincipleHeuristic {
let (scope, location) = item_context(krate, file, item_path);
let structural = Evidence {
description: format!(
"`{item_path}` calls itself directly by name at least once (line {}: `{}`, {} \
recursive call site(s) total).",
hit.first_call_line, hit.first_call_rendered, hit.call_count
),
locations: vec![location.clone()],
};
let corroborating = Evidence {
description: if hit.param_names.is_empty() {
format!(
"`{item_path}` takes no parameters, so no parameter-derived guard is possible \
before the recursive call at line {}.",
hit.first_call_line
)
} else {
format!(
"No `if`/`match` in `{item_path}` references one of its own parameters ({}) at \
a line before the recursive call at line {} — no visible parameter-derived \
guard precedes it.",
hit.param_names.join(", "),
hit.first_call_line
)
},
locations: vec![location],
};
let evidence_identities = vec![item_path.to_string()];
principle_heuristic! {
principle: DesignPrinciple::BoundedResources,
scope,
evidence_identities,
{
evidence: vec![structural, corroborating],
interpretation: "In the examined function, direct self-recursion occurs with no \
parameter-referencing `if`/`match` visible before the recursive call. That may \
indicate the recursion has no syntactically visible base case, though this \
per-function, syntax-only check cannot rule out a guard expressed another way."
.to_string(),
contraindications: vec![
Contraindication {
description: "A base case guarded by a helper function's return value, a field \
access reached indirectly rather than a bare parameter reference, or a \
guard expressed via an early `?`/error return would not be recognized by \
this check."
.to_string(),
},
Contraindication {
description: "Mutual/indirect recursion through another function is out of \
scope for this per-file, name-based check — a real base case reached that \
way looks identical to no base case at all here."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether the recursion is actually bounded by something this check \
can't see (a helper's return value, a field access rather than a bare \
parameter reference, or an externally enforced call-depth limit) is not \
checked here — only that no parameter-referencing conditional textually \
precedes the first recursive call."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the function as-is.".to_string(),
},
DesignAlternative {
description: "Add an explicit guard on a parameter (or a value derived from \
one) before the recursive call, or convert the recursion to an explicitly \
bounded iterative loop."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
fn bounded_resources_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
let mut heuristics = bounded_resources_loop_candidates(workspace);
heuristics.extend(bounded_resources_recursion_candidates(workspace));
heuristics
}
const PRIMITIVE_SCALAR_IDENTS: &[&str] = &[
"str", "String", "bool", "char", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16",
"u32", "u64", "u128", "usize", "f32", "f64",
];
fn primitive_scalar_kind(ty: &syn::Type) -> Option<String> {
let type_path = unwrap_reference_type_path(ty)?;
let ident = type_path.path.segments.last()?.ident.to_string();
if !PRIMITIVE_SCALAR_IDENTS.contains(&ident.as_str()) {
return None;
}
Some(if ident == "String" {
"str".to_string()
} else {
ident
})
}
fn return_scalar_kind(output: &syn::ReturnType) -> Option<String> {
let syn::ReturnType::Type(_, ty) = output else {
return None;
};
if let Some(kind) = primitive_scalar_kind(ty) {
return Some(kind);
}
let syn::Type::Path(type_path) = ty.as_ref() else {
return None;
};
let segment = type_path.path.segments.last()?;
if segment.ident != "Result" && segment.ident != "Option" {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
return None;
};
let first_type = args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(inner) => Some(inner),
_ => None,
})?;
primitive_scalar_kind(first_type)
}
fn typed_params(sig: &syn::Signature) -> Vec<(String, &syn::Type)> {
sig.inputs
.iter()
.filter_map(|arg| match arg {
syn::FnArg::Typed(pat_type) => match pat_type.pat.as_ref() {
syn::Pat::Ident(pat_ident) => {
Some((pat_ident.ident.to_string(), pat_type.ty.as_ref()))
}
_ => None,
},
syn::FnArg::Receiver(_) => None,
})
.collect()
}
struct ReturnOrPanicFinder {
found: bool,
}
impl<'ast> Visit<'ast> for ReturnOrPanicFinder {
fn visit_expr_return(&mut self, node: &'ast syn::ExprReturn) {
self.found = true;
syn::visit::visit_expr_return(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if node.path.is_ident("panic") {
self.found = true;
}
syn::visit::visit_macro(self, node);
}
fn visit_expr_closure(&mut self, _node: &'ast syn::ExprClosure) {}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
fn block_has_return_or_panic(block: &syn::Block) -> bool {
let mut finder = ReturnOrPanicFinder { found: false };
finder.visit_block(block);
finder.found
}
fn expr_has_return_or_panic(expr: &syn::Expr) -> bool {
let mut finder = ReturnOrPanicFinder { found: false };
finder.visit_expr(expr);
finder.found
}
fn path_is_assert_like(path: &syn::Path) -> bool {
const ASSERT_MACROS: &[&str] = &[
"assert",
"assert_eq",
"assert_ne",
"debug_assert",
"debug_assert_eq",
"debug_assert_ne",
];
path.get_ident()
.is_some_and(|ident| ASSERT_MACROS.contains(&ident.to_string().as_str()))
}
fn macro_tokens_reference_param(macro_call: &syn::Macro, param: &str) -> bool {
macro_call
.tokens
.to_string()
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|token| token == param)
}
struct ValidationGuard {
line: usize,
rendered: String,
}
fn validation_guard_hit(block: &syn::Block, param: &str) -> Option<ValidationGuard> {
use quote::ToTokens;
struct Finder<'a> {
param: &'a str,
hit: Option<ValidationGuard>,
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
if self.hit.is_none()
&& expr_references_any(&node.cond, std::slice::from_ref(&self.param.to_string()))
&& block_has_return_or_panic(&node.then_branch)
{
self.hit = Some(ValidationGuard {
line: node.if_token.span().start().line,
rendered: node.cond.to_token_stream().to_string(),
});
}
syn::visit::visit_expr_if(self, node);
}
fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
if self.hit.is_none()
&& expr_references_any(&node.expr, std::slice::from_ref(&self.param.to_string()))
&& node
.arms
.iter()
.any(|arm| expr_has_return_or_panic(&arm.body))
{
self.hit = Some(ValidationGuard {
line: node.match_token.span().start().line,
rendered: node.expr.to_token_stream().to_string(),
});
}
syn::visit::visit_expr_match(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if self.hit.is_none()
&& path_is_assert_like(&node.path)
&& macro_tokens_reference_param(node, self.param)
{
self.hit = Some(ValidationGuard {
line: node.path.span().start().line,
rendered: node.tokens.to_string(),
});
}
syn::visit::visit_macro(self, node);
}
fn visit_item_fn(&mut self, _node: &'ast syn::ItemFn) {}
}
let mut finder = Finder { param, hit: None };
finder.visit_block(block);
finder.hit
}
struct ParamGuardHit {
param_name: String,
param_kind: String,
return_kind: String,
guard_line: usize,
guard_rendered: String,
}
fn parse_dont_validate_signal1(sig: &syn::Signature, block: &syn::Block) -> Option<ParamGuardHit> {
let return_kind = return_scalar_kind(&sig.output)?;
for (name, ty) in typed_params(sig) {
let param_kind = primitive_scalar_kind(ty)?;
if let Some(guard) = validation_guard_hit(block, &name) {
return Some(ParamGuardHit {
param_name: name,
param_kind,
return_kind: return_kind.clone(),
guard_line: guard.line,
guard_rendered: guard.rendered,
});
}
}
None
}
fn parse_dont_validate_guard_kinds(sig: &syn::Signature, block: &syn::Block) -> Vec<String> {
typed_params(sig)
.into_iter()
.filter_map(|(name, ty)| {
let kind = primitive_scalar_kind(ty)?;
validation_guard_hit(block, &name).map(|_| kind)
})
.collect()
}
struct GuardedParam {
qualified_name: String,
param_kind: String,
}
fn parse_dont_validate_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for krate in &workspace.crates {
let guarded = collect_guarded_params(krate);
heuristics.extend(parse_dont_validate_heuristics_for_crate(krate, &guarded));
}
heuristics
}
fn collect_guarded_params(krate: &CrateInfo) -> Vec<GuardedParam> {
let mut guarded: Vec<GuardedParam> = Vec::new();
for source in &krate.source_files {
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
walk_functions(&ast, |site| {
for param_kind in parse_dont_validate_guard_kinds(site.sig, site.block) {
guarded.push(GuardedParam {
qualified_name: site.qualified_name.clone(),
param_kind,
});
}
});
}
guarded
}
fn parse_dont_validate_heuristics_for_crate(
krate: &CrateInfo,
guarded: &[GuardedParam],
) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for source in &krate.source_files {
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
walk_functions(&ast, |site| {
if !matches!(site.vis, Some(syn::Visibility::Public(_))) {
return;
}
let Some(hit) = parse_dont_validate_signal1(site.sig, site.block) else {
return;
};
let mut siblings: Vec<String> = guarded
.iter()
.filter(|g| {
g.param_kind == hit.param_kind && g.qualified_name != site.qualified_name
})
.map(|g| g.qualified_name.clone())
.collect();
siblings.sort();
siblings.dedup();
if siblings.is_empty() {
return;
}
heuristics.push(build_parse_dont_validate_heuristic(
krate,
&source.path,
&site.qualified_name,
&hit,
&siblings,
));
});
}
heuristics
}
fn build_parse_dont_validate_heuristic(
krate: &CrateInfo,
file: &Path,
item_path: &str,
hit: &ParamGuardHit,
siblings: &[String],
) -> PrincipleHeuristic {
let (scope, location) = item_context(krate, file, item_path);
let structural = Evidence {
description: format!(
"`{item_path}` takes a `{}`-shaped parameter `{}` guarded by a validation-shaped \
check at line {} (`{}`), and its own return type is still a `{}`-shaped value \
rather than a distinct named type.",
hit.param_kind, hit.param_name, hit.guard_line, hit.guard_rendered, hit.return_kind
),
locations: vec![location.clone()],
};
let corroborating = Evidence {
description: format!(
"Elsewhere in crate `{}`, {} other function(s) independently guard a parameter of \
the same `{}` kind with a similarly shaped validation check, instead of one shared \
parse step: {}.",
krate.name,
siblings.len(),
hit.param_kind,
siblings.join(", ")
),
locations: vec![location],
};
let evidence_identities = vec![item_path.to_string(), hit.param_name.clone()];
principle_heuristic! {
principle: DesignPrinciple::ParseDontValidate,
scope,
evidence_identities,
{
evidence: vec![structural, corroborating],
interpretation: format!(
"In the examined function, parameter `{}` is checked at the boundary but the \
function hands back the same loosely-typed `{}` shape, and a similarly shaped \
check recurs on the same primitive kind elsewhere in the crate. That combination \
may suggest the validation could be centralized into a single parse step that \
returns a more precisely typed value, rather than repeated at each call site.",
hit.param_name, hit.param_kind
),
contraindications: vec![
Contraindication {
description: "A validation predicate meant to stay a reusable, general-purpose \
yes/no check (e.g. a small `is_valid`/`looks_like` helper called from several \
unrelated contexts) is a reasonable design on its own and not necessarily \
evidence that a boundary parse step is missing."
.to_string(),
},
Contraindication {
description: "The sibling functions this heuristic points to may validate \
different, unrelated properties of the same primitive kind (e.g. one checks \
length, another checks character set) rather than duplicating the same check \
— this detector only compares primitive kind, not what the check actually \
verifies."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether the sibling functions this heuristic points to actually \
duplicate the same semantic check, and whether a caller of the examined function \
re-validates the value it gets back, are not checked here — only that a \
similarly shaped guard recurs on the same primitive kind somewhere else in the \
crate."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the function as-is.".to_string(),
},
DesignAlternative {
description: "Introduce a newtype/struct that performs the validation once in a \
constructor (or a `TryFrom`/`FromStr` impl) and carries the result, so \
downstream callers and the sibling functions this heuristic points to can \
depend on a value whose shape already guarantees the check passed."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
fn struct_has_non_exhaustive(attrs: &[syn::Attribute]) -> bool {
attrs
.iter()
.any(|attr| attr.path().is_ident("non_exhaustive"))
}
fn field_count(fields: &syn::Fields) -> usize {
match fields {
syn::Fields::Named(named) => named.named.len(),
syn::Fields::Unnamed(unnamed) => unnamed.unnamed.len(),
syn::Fields::Unit => 0,
}
}
fn all_fields_public(fields: &syn::Fields) -> bool {
let vis_iter: Box<dyn Iterator<Item = &syn::Visibility>> = match fields {
syn::Fields::Named(named) => Box::new(named.named.iter().map(|f| &f.vis)),
syn::Fields::Unnamed(unnamed) => Box::new(unnamed.unnamed.iter().map(|f| &f.vis)),
syn::Fields::Unit => Box::new(std::iter::empty()),
};
vis_iter
.into_iter()
.all(|vis| matches!(vis, syn::Visibility::Public(_)))
}
struct EvolvableStructCandidate {
name: String,
field_count: usize,
location: EvidenceLocation,
}
struct EvolvableStructCollector {
file: PathBuf,
candidates: Vec<EvolvableStructCandidate>,
}
impl<'ast> Visit<'ast> for EvolvableStructCollector {
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
if matches!(node.vis, syn::Visibility::Public(_))
&& !struct_has_non_exhaustive(&node.attrs)
&& all_fields_public(&node.fields)
{
let count = field_count(&node.fields);
if count >= API_EVOLVABILITY_MIN_FIELDS {
self.candidates.push(EvolvableStructCandidate {
name: node.ident.to_string(),
field_count: count,
location: item_location(&self.file, &node.ident.to_string()),
});
}
}
syn::visit::visit_item_struct(self, node);
}
}
struct StructConstructionSite {
type_name: String,
line: usize,
location: EvidenceLocation,
}
struct StructConstructionCollector {
file: PathBuf,
sites: Vec<StructConstructionSite>,
}
impl<'ast> Visit<'ast> for StructConstructionCollector {
fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) {
if !node.fields.is_empty()
&& let Some(segment) = node.path.segments.last()
{
self.sites.push(StructConstructionSite {
type_name: segment.ident.to_string(),
line: node.span().start().line,
location: EvidenceLocation {
file: self.file.clone(),
item_path: None,
},
});
}
syn::visit::visit_expr_struct(self, node);
}
}
fn collect_crate_wide_pair<A, B>(
krate: &CrateInfo,
collect: impl Fn(PathBuf, &syn::File) -> (Vec<A>, Vec<B>),
) -> (Vec<A>, Vec<B>) {
let mut candidates = Vec::new();
let mut sites = Vec::new();
for source in &krate.source_files {
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
let (file_candidates, file_sites) = collect(source.path.clone(), &ast);
candidates.extend(file_candidates);
sites.extend(file_sites);
}
(candidates, sites)
}
fn api_evolvability_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for krate in &workspace.crates {
let (candidates, mut construction_sites): (
Vec<EvolvableStructCandidate>,
Vec<StructConstructionSite>,
) = collect_crate_wide_pair(krate, |file, ast| {
let mut struct_collector = EvolvableStructCollector {
file: file.clone(),
candidates: Vec::new(),
};
struct_collector.visit_file(ast);
let mut construction_collector = StructConstructionCollector {
file,
sites: Vec::new(),
};
construction_collector.visit_file(ast);
(struct_collector.candidates, construction_collector.sites)
});
construction_sites
.sort_by(|a, b| (&a.location.file, a.line).cmp(&(&b.location.file, b.line)));
for candidate in &candidates {
let Some(site) = construction_sites
.iter()
.find(|site| site.type_name == candidate.name)
else {
continue;
};
heuristics.push(build_api_evolvability_heuristic(krate, candidate, site));
}
}
heuristics
}
fn build_api_evolvability_heuristic(
krate: &CrateInfo,
candidate: &EvolvableStructCandidate,
site: &StructConstructionSite,
) -> PrincipleHeuristic {
let scope = single_module_scope(krate, candidate.name.clone());
let structural = Evidence {
description: format!(
"`{}` has {} all-public fields and no `#[non_exhaustive]` attribute, at or above \
the {API_EVOLVABILITY_MIN_FIELDS}-field threshold this heuristic treats as an \
evolvability concern.",
candidate.name, candidate.field_count
),
locations: vec![candidate.location.clone()],
};
let usage = Evidence {
description: format!(
"`{}` is constructed via field-literal syntax at {}:{} in the same crate, with at \
least one explicit field rather than only a `..` spread — evidence a caller \
already depends on this struct's current field set.",
candidate.name,
site.location.file.display(),
site.line,
),
locations: vec![site.location.clone()],
};
let evidence_identities = vec![
candidate.name.clone(),
site.location.file.display().to_string(),
site.line.to_string(),
];
principle_heuristic! {
principle: DesignPrinciple::ApiEvolvability,
scope,
evidence_identities,
{
evidence: vec![structural, usage],
interpretation: format!(
"`{}` exposes every field as `pub` with no `#[non_exhaustive]` attribute, and at \
least one construction site elsewhere in the crate already relies on its exact \
field set via field-literal syntax. Adding a field later would break that \
construction site, either forcing a coordinated update or a semver-major bump for \
any external caller doing the same.",
candidate.name
),
contraindications: vec![
Contraindication {
description: "A small, stable data-transfer struct that is unlikely to ever \
gain a field may not benefit from the added friction of \
`#[non_exhaustive]` or a constructor function."
.to_string(),
},
Contraindication {
description: "If every construction site is internal to this crate (never \
exposed to external callers), adding a field later is a local, coordinated \
change rather than a semver hazard."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether this struct is actually part of the crate's external public \
API (re-exported, reachable by downstream crates) or only an internal \
implementation detail is not checked here — only that a field-literal \
construction site exists somewhere in the same crate."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the struct as-is.".to_string(),
},
DesignAlternative {
description: "Add `#[non_exhaustive]` and a constructor function or builder, so \
a future field can be added without breaking existing field-literal \
construction sites."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
struct PubUnsafeFnSite {
qualified_name: String,
location: EvidenceLocation,
line: usize,
has_safety_doc_section: bool,
}
struct SafetyWrapperSite {
qualified_name: String,
location: EvidenceLocation,
line: usize,
}
fn has_adjacent_safety_comment(comments: &[CommentSpan], unsafe_start_line: usize) -> bool {
comments.iter().any(|comment| {
comment.text.contains("SAFETY:")
&& (comment.end_line + 1 == unsafe_start_line
|| comment.start_line == unsafe_start_line
|| comment.start_line == unsafe_start_line + 1)
})
}
struct SafetyCommentedUnsafeBlockFinder<'a> {
comments: &'a [CommentSpan],
first_line: Option<usize>,
}
impl<'ast> Visit<'ast> for SafetyCommentedUnsafeBlockFinder<'_> {
fn visit_expr_unsafe(&mut self, node: &'ast syn::ExprUnsafe) {
if self.first_line.is_none() {
let start_line = node.span().start().line;
if has_adjacent_safety_comment(self.comments, start_line) {
self.first_line = Some(start_line);
}
}
syn::visit::visit_expr_unsafe(self, node);
}
}
fn has_safety_doc_section(attrs: &[syn::Attribute]) -> bool {
let joined: Vec<String> = attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.filter_map(|attr| match &attr.meta {
syn::Meta::NameValue(name_value) => match &name_value.value {
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(text),
..
}) => Some(text.value()),
_ => None,
},
_ => None,
})
.collect();
joined.join(" ").contains("# Safety")
}
fn unsafe_containment_file_sites(
file: &Path,
ast: &syn::File,
comments: &[CommentSpan],
) -> (Vec<PubUnsafeFnSite>, Vec<SafetyWrapperSite>) {
let mut pub_unsafe_fns = Vec::new();
let mut safety_wrappers = Vec::new();
walk_functions(ast, |site| {
if matches!(site.vis, Some(syn::Visibility::Public(_))) && site.sig.unsafety.is_some() {
pub_unsafe_fns.push(PubUnsafeFnSite {
qualified_name: site.qualified_name.clone(),
location: item_location(file, &site.qualified_name),
line: site.span.start().line,
has_safety_doc_section: has_safety_doc_section(site.attrs),
});
return;
}
let mut finder = SafetyCommentedUnsafeBlockFinder {
comments,
first_line: None,
};
finder.visit_block(site.block);
if let Some(line) = finder.first_line {
safety_wrappers.push(SafetyWrapperSite {
qualified_name: site.qualified_name.clone(),
location: item_location(file, &site.qualified_name),
line,
});
}
});
(pub_unsafe_fns, safety_wrappers)
}
fn unsafe_containment_candidates(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for_each_parsed_file(workspace, |krate, file, text, ast| {
let comments = extract_comments(text);
let (pub_unsafe_fns, safety_wrappers) = unsafe_containment_file_sites(file, ast, &comments);
if safety_wrappers.is_empty() {
return;
}
for candidate in &pub_unsafe_fns {
if candidate.has_safety_doc_section {
continue;
}
let Some(wrapper) = safety_wrappers.first() else {
continue;
};
heuristics.push(build_unsafe_containment_heuristic(
krate, file, candidate, wrapper,
));
}
});
heuristics
}
fn build_unsafe_containment_heuristic(
krate: &CrateInfo,
file: &Path,
candidate: &PubUnsafeFnSite,
wrapper: &SafetyWrapperSite,
) -> PrincipleHeuristic {
let scope = single_module_scope(krate, candidate.qualified_name.clone());
let structural = Evidence {
description: format!(
"`{}` is declared `pub unsafe fn` at {}:{}, pushing its safety invariants onto \
every external caller instead of the crate upholding them internally behind a safe \
wrapper.",
candidate.qualified_name,
file.display(),
candidate.line,
),
locations: vec![candidate.location.clone()],
};
let contrast = Evidence {
description: format!(
"The same file already wraps an `unsafe {{ .. }}` block in a `// SAFETY:` comment \
inside `{}` at {}:{} — the module demonstrably knows how to encapsulate unsafe code \
behind a documented internal wrapper, yet `{}` exposes raw unsafe capability \
instead.",
wrapper.qualified_name,
file.display(),
wrapper.line,
candidate.qualified_name,
),
locations: vec![wrapper.location.clone()],
};
let evidence_identities = vec![
candidate.qualified_name.clone(),
candidate.line.to_string(),
wrapper.qualified_name.clone(),
wrapper.line.to_string(),
];
principle_heuristic! {
principle: DesignPrinciple::UnsafeContainment,
scope,
evidence_identities,
{
evidence: vec![structural, contrast],
interpretation: format!(
"`{}` is `pub unsafe fn` with no `# Safety` section in its own doc comment, so \
callers are asked to uphold invariants that are never written down — while the \
same file already shows, in `{}`, that the module can encapsulate unsafe code \
behind a `// SAFETY:`-documented internal wrapper instead of exposing it at the \
public boundary.",
candidate.qualified_name, wrapper.qualified_name,
),
contraindications: vec![
Contraindication {
description: "A low-level primitive whose whole purpose is to expose an unsafe \
capability (an FFI binding, a `no_std` allocator entry point, a SIMD \
intrinsic wrapper) may have no safe encapsulation to offer — the caller \
genuinely must uphold the invariant themselves."
.to_string(),
},
Contraindication {
description: "If every caller of this function is internal to the crate (never \
part of its external public API), the party upholding the invariant may \
already be the same team that wrote it, with the invariant understood out of \
band rather than documented in rustdoc."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether the safety invariant is documented somewhere other than a `# \
Safety` doc-comment section (a module-level doc, an external design doc, a plain \
code comment above the declaration) is not checked here — only the `pub unsafe \
fn`'s own doc attribute."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the function `pub unsafe fn` as-is.".to_string(),
},
DesignAlternative {
description: "Wrap the unsafe capability behind a safe `pub fn` that upholds the \
invariant internally, the way the file's existing `// SAFETY:`-commented \
wrapper already does elsewhere, or add a `# Safety` section documenting \
exactly what the caller must guarantee."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
fn is_option_type(ty: &syn::Type) -> bool {
match ty {
syn::Type::Path(type_path) if type_path.qself.is_none() => type_path
.path
.segments
.last()
.is_some_and(|segment| segment.ident == "Option"),
_ => false,
}
}
struct MisuStructCandidate {
name: String,
option_fields: Vec<String>,
location: EvidenceLocation,
}
struct MisuStructCollector {
file: PathBuf,
candidates: Vec<MisuStructCandidate>,
}
impl<'ast> Visit<'ast> for MisuStructCollector {
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
if let syn::Fields::Named(named) = &node.fields {
let option_fields: Vec<String> = named
.named
.iter()
.filter_map(|field| {
let ident = field.ident.as_ref()?;
is_option_type(&field.ty).then(|| ident.to_string())
})
.collect();
if option_fields.len() >= MISU_MIN_OPTION_FIELDS {
self.candidates.push(MisuStructCandidate {
name: node.ident.to_string(),
option_fields,
location: item_location(&self.file, &node.ident.to_string()),
});
}
}
syn::visit::visit_item_struct(self, node);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldSetting {
SomeShaped,
NoneShaped,
Ambiguous,
}
fn field_setting(expr: &syn::Expr) -> FieldSetting {
match expr {
syn::Expr::Call(call) => match &*call.func {
syn::Expr::Path(path) if path_ends_with(&path.path, "Some") => FieldSetting::SomeShaped,
_ => FieldSetting::Ambiguous,
},
syn::Expr::Path(path) if path_ends_with(&path.path, "None") => FieldSetting::NoneShaped,
_ => FieldSetting::Ambiguous,
}
}
struct MisuConstructionSite {
type_name: String,
line: usize,
location: EvidenceLocation,
field_settings: BTreeMap<String, FieldSetting>,
}
struct MisuConstructionCollector {
file: PathBuf,
sites: Vec<MisuConstructionSite>,
}
impl<'ast> Visit<'ast> for MisuConstructionCollector {
fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) {
if let Some(segment) = node.path.segments.last() {
let mut field_settings = BTreeMap::new();
for field_value in &node.fields {
if let syn::Member::Named(ident) = &field_value.member {
let setting = field_setting(&field_value.expr);
if setting != FieldSetting::Ambiguous {
field_settings.insert(ident.to_string(), setting);
}
}
}
self.sites.push(MisuConstructionSite {
type_name: segment.ident.to_string(),
line: node.span().start().line,
location: EvidenceLocation {
file: self.file.clone(),
item_path: None,
},
field_settings,
});
}
syn::visit::visit_expr_struct(self, node);
}
}
fn make_illegal_states_unrepresentable_candidates(
workspace: &Workspace,
) -> Vec<PrincipleHeuristic> {
let mut heuristics = Vec::new();
for krate in &workspace.crates {
let (candidates, construction_sites) =
collect_crate_wide_pair(krate, collect_misu_candidates_and_sites);
for candidate in &candidates {
let Some(usable_sites) = misu_usable_sites(candidate, &construction_sites) else {
continue;
};
heuristics.push(build_misu_heuristic(krate, candidate, &usable_sites));
}
}
heuristics
}
fn collect_misu_candidates_and_sites(
file: PathBuf,
ast: &syn::File,
) -> (Vec<MisuStructCandidate>, Vec<MisuConstructionSite>) {
let mut struct_collector = MisuStructCollector {
file: file.clone(),
candidates: Vec::new(),
};
struct_collector.visit_file(ast);
let mut construction_collector = MisuConstructionCollector {
file,
sites: Vec::new(),
};
construction_collector.visit_file(ast);
(struct_collector.candidates, construction_collector.sites)
}
fn misu_usable_sites<'a>(
candidate: &MisuStructCandidate,
construction_sites: &'a [MisuConstructionSite],
) -> Option<Vec<&'a MisuConstructionSite>> {
let mut usable_sites: Vec<&MisuConstructionSite> = Vec::new();
let mut violates_exclusivity = false;
for site in construction_sites
.iter()
.filter(|site| site.type_name == candidate.name)
{
let determinable_count = candidate
.option_fields
.iter()
.filter(|field| site.field_settings.contains_key(*field))
.count();
if determinable_count == 0 {
continue;
}
let some_count = candidate
.option_fields
.iter()
.filter(|field| site.field_settings.get(*field) == Some(&FieldSetting::SomeShaped))
.count();
if some_count > 1 {
violates_exclusivity = true;
}
usable_sites.push(site);
}
if violates_exclusivity || usable_sites.len() < MISU_MIN_CONSTRUCTION_SITES {
return None;
}
usable_sites.sort_by(|a, b| (&a.location.file, a.line).cmp(&(&b.location.file, b.line)));
Some(usable_sites)
}
fn build_misu_heuristic(
krate: &CrateInfo,
candidate: &MisuStructCandidate,
usable_sites: &[&MisuConstructionSite],
) -> PrincipleHeuristic {
let scope = single_module_scope(krate, candidate.name.clone());
let structural = Evidence {
description: format!(
"`{}` has {} `Option<T>` fields: {}.",
candidate.name,
candidate.option_fields.len(),
candidate.option_fields.join(", "),
),
locations: vec![candidate.location.clone()],
};
let site_refs: Vec<String> = usable_sites
.iter()
.map(|site| format!("{}:{}", site.location.file.display(), site.line))
.collect();
let usage = Evidence {
description: format!(
"Observed across {} construction sites ({}), no site sets more than one of `{}`'s \
candidate `Option<T>` fields to `Some(..)` at the same time — every determinable \
construction site treats them as mutually exclusive.",
usable_sites.len(),
site_refs.join(", "),
candidate.name,
),
locations: usable_sites
.iter()
.map(|site| site.location.clone())
.collect(),
};
let mut evidence_identities = vec![candidate.name.clone()];
evidence_identities.extend(site_refs.iter().cloned());
principle_heuristic! {
principle: DesignPrinciple::MakeIllegalStatesUnrepresentable,
scope,
evidence_identities,
{
evidence: vec![structural, usage],
interpretation: format!(
"`{}` declares {} `Option<T>` fields ({}), and every construction site in the crate \
that determinably sets any of them sets at most one to `Some(..)` at a time. That \
correlation suggests the fields might be better modeled as a single `enum` with one \
named variant per case, so the \"at most one set\" pattern becomes a property the \
compiler enforces rather than a convention every construction site happens to \
follow so far.",
candidate.name,
candidate.option_fields.len(),
candidate.option_fields.join(", "),
),
contraindications: vec![
Contraindication {
description: "Fields that are genuinely independent optional overrides — where \
any combination, including several set at once, is a valid and meaningful \
state — would show the same observed shape without the fields actually \
being illegal-state prone."
.to_string(),
},
Contraindication {
description: "A combination this heuristic never observed set together may still \
have a real, if rare, valid meaning that the crate's current construction \
sites simply haven't exercised yet."
.to_string(),
},
],
missing_evidence: vec![MissingEvidence {
description: "Whether setting more than one of these fields at once would actually \
be an illegal state, or is instead a legitimate combination the examined \
construction sites simply haven't exercised, is not checked here — only that no \
observed site exercises it."
.to_string(),
}],
alternatives: vec![
DesignAlternative {
description: "Keep the fields as separate `Option<T>`s.".to_string(),
},
DesignAlternative {
description: "Replace the fields with a single `enum` whose variants name each \
mutually exclusive case, so the \"at most one set\" invariant is enforced by \
the type system instead of by every construction site's discipline."
.to_string(),
},
],
related_findings: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ingest::{SourceFile, SourceKind};
use crate::test_util::TempDir;
fn workspace_with_crate(root: PathBuf, files: Vec<PathBuf>) -> Workspace {
Workspace {
root: root.clone(),
crates: vec![CrateInfo {
name: "fixture".to_string(),
version: "0.1.0".to_string(),
manifest_path: root.join("Cargo.toml"),
root,
source_files: files
.into_iter()
.map(|path| SourceFile {
path,
kind: SourceKind::Authored,
})
.collect(),
entry_points: Vec::new(),
dependencies: Vec::new(),
}],
}
}
fn analyze(workspace: &Workspace) -> Vec<PrincipleHeuristic> {
analyze_with_boundary_config(workspace, None)
}
fn analyze_with_boundary_config(
workspace: &Workspace,
boundary_config: Option<&BoundaryConfig>,
) -> Vec<PrincipleHeuristic> {
let source_files = workspace
.crates
.iter()
.flat_map(|krate| krate.source_files.iter());
let complexity = crate::rules::complexity::analyze_workspace(source_files, false);
analyze_workspace(workspace, &complexity, boundary_config).unwrap()
}
const NINE_IFS: &str = "
if total > 0 { total += 1; }
if total > 1 { total += 1; }
if total > 2 { total += 1; }
if total > 3 { total += 1; }
if total > 4 { total += 1; }
if total > 5 { total += 1; }
if total > 6 { total += 1; }
if total > 7 { total += 1; }
if total > 8 { total += 1; }
";
#[test]
fn io_call_plus_high_complexity_produces_one_heuristic() {
let dir = TempDir::new("principle-io-plus-complexity");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"pub fn read_and_branch(path: &str) -> i32 {{\n\
let contents = std::fs::read_to_string(path).unwrap();\n\
let mut total = contents.len() as i32;\n\
{NINE_IFS}\n\
total\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert_eq!(heuristics.len(), 1);
let heuristic = &heuristics[0];
assert_eq!(
heuristic.principle,
DesignPrinciple::FunctionalCoreImperativeShell
);
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn io_call_with_low_complexity_produces_no_heuristic() {
let dir = TempDir::new("principle-io-low-complexity");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn read_simple(path: &str) -> String {\n\
std::fs::read_to_string(path).unwrap()\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
#[test]
fn high_complexity_without_io_call_produces_no_heuristic() {
let dir = TempDir::new("principle-complexity-no-io");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"pub fn branch_only(mut total: i32) -> i32 {{\n\
{NINE_IFS}\n\
total\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
const WIDE_TRAIT: &str = "
pub trait Wide {
fn a(&self) { let _ = 1; }
fn b(&self) { let _ = 1; }
fn c(&self) { let _ = 1; }
fn d(&self) { let _ = 1; }
fn e(&self) { let _ = 1; }
}
";
#[test]
fn wide_trait_with_disjoint_impls_produces_one_heuristic() {
let dir = TempDir::new("principle-interface-segregation-disjoint");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"{WIDE_TRAIT}\n\
pub struct Left;\n\
impl Wide for Left {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
}}\n\
pub struct Right;\n\
impl Wide for Right {{\n\
\x20 fn c(&self) {{}}\n\
\x20 fn d(&self) {{}}\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert_eq!(heuristics.len(), 1);
let heuristic = &heuristics[0];
assert_eq!(heuristic.principle, DesignPrinciple::InterfaceSegregation);
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn wide_trait_with_single_impl_produces_no_heuristic() {
let dir = TempDir::new("principle-interface-segregation-single-impl");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"{WIDE_TRAIT}\n\
pub struct Left;\n\
impl Wide for Left {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
#[test]
fn wide_trait_with_overlapping_impls_produces_no_heuristic() {
let dir = TempDir::new("principle-interface-segregation-overlap");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"{WIDE_TRAIT}\n\
pub struct Left;\n\
impl Wide for Left {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
}}\n\
pub struct Right;\n\
impl Wide for Right {{\n\
\x20 fn b(&self) {{}}\n\
\x20 fn c(&self) {{}}\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
#[test]
fn narrow_trait_with_disjoint_impls_produces_no_heuristic() {
let dir = TempDir::new("principle-interface-segregation-narrow");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub trait Narrow {\n\
\x20 fn a(&self) { let _ = 1; }\n\
\x20 fn b(&self) { let _ = 1; }\n\
\x20 fn c(&self) { let _ = 1; }\n\
\x20 fn d(&self) { let _ = 1; }\n\
}\n\
pub struct Left;\n\
impl Narrow for Left {\n\
\x20 fn a(&self) {}\n\
}\n\
pub struct Right;\n\
impl Narrow for Right {\n\
\x20 fn b(&self) {}\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
#[test]
fn both_rules_can_report_candidates_in_the_same_workspace() {
let dir = TempDir::new("principle-both-rules-together");
let io_file = dir.join("shell.rs");
std::fs::write(
&io_file,
format!(
"pub fn read_and_branch(path: &str) -> i32 {{\n\
let contents = std::fs::read_to_string(path).unwrap();\n\
let mut total = contents.len() as i32;\n\
{NINE_IFS}\n\
total\n\
}}\n"
),
)
.unwrap();
let trait_file = dir.join("wide.rs");
std::fs::write(
&trait_file,
format!(
"{WIDE_TRAIT}\n\
pub struct Left;\n\
impl Wide for Left {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
}}\n\
pub struct Right;\n\
impl Wide for Right {{\n\
\x20 fn c(&self) {{}}\n\
\x20 fn d(&self) {{}}\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![io_file, trait_file]);
let heuristics = analyze(&workspace);
let principles: Vec<DesignPrinciple> = heuristics.iter().map(|h| h.principle).collect();
assert!(principles.contains(&DesignPrinciple::FunctionalCoreImperativeShell));
assert!(principles.contains(&DesignPrinciple::InterfaceSegregation));
assert_eq!(heuristics.len(), 2);
}
#[test]
fn generated_wording_never_claims_a_violation() {
const FORBIDDEN: &[&str] = &[
"verletzt",
"muss",
"falsch aufgebaut",
"violates",
"must",
"is broken",
"best practice not followed",
"is bad",
];
let dir = TempDir::new("principle-golden-wording");
std::fs::create_dir_all(dir.join("fixture/src/domain")).unwrap();
std::fs::write(
dir.join("fixture/Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(
dir.join("fixture/src/lib.rs"),
"pub mod domain;\npub mod infra;\n",
)
.unwrap();
std::fs::write(
dir.join("fixture/src/shell.rs"),
format!(
"pub fn read_and_branch(path: &str) -> i32 {{\n\
let contents = std::fs::read_to_string(path).unwrap();\n\
let mut total = contents.len() as i32;\n\
{NINE_IFS}\n\
total\n\
}}\n"
),
)
.unwrap();
std::fs::write(
dir.join("fixture/src/wide.rs"),
format!(
"{WIDE_TRAIT}\n\
pub struct Left;\n\
impl Wide for Left {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
}}\n\
pub struct Right;\n\
impl Wide for Right {{\n\
\x20 fn c(&self) {{}}\n\
\x20 fn d(&self) {{}}\n\
}}\n"
),
)
.unwrap();
std::fs::write(
dir.join("fixture/src/domain/mod.rs"),
"pub fn run() {\n crate::infra::read_file();\n}\n\n\
pub fn build() -> crate::infra::Client {\n todo!()\n}\n",
)
.unwrap();
std::fs::write(
dir.join("fixture/src/infra.rs"),
"pub fn read_file() {}\npub struct Client;\n",
)
.unwrap();
std::fs::write(
dir.join("fixture/src/cohesion.rs"),
format!(
"pub fn read_config(path: &str) -> String {{\n\
\x20 std::fs::read_to_string(path).unwrap()\n\
}}\n\
pub fn compute(mut total: i32) -> i32 {{\n\
{NINE_IFS}\n\
\x20 total\n\
}}\n\
pub struct Marker;\n"
),
)
.unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"fixture\"]\nresolver = \"2\"\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-infra",
"fixture",
"domain",
&["infra"],
)],
..Default::default()
};
let heuristics = analyze_with_boundary_config(&workspace, Some(&config));
assert!(
!heuristics.is_empty(),
"fixture must produce a heuristic to check"
);
let principles: Vec<DesignPrinciple> = heuristics.iter().map(|h| h.principle).collect();
assert!(
principles.contains(&DesignPrinciple::FunctionalCoreImperativeShell)
&& principles.contains(&DesignPrinciple::InterfaceSegregation)
&& principles.contains(&DesignPrinciple::DependencyInversion)
&& principles.contains(&DesignPrinciple::Cohesion),
"fixture must exercise all four rules' wording: {principles:?}"
);
for heuristic in &heuristics {
let mut texts = vec![heuristic.interpretation.clone()];
texts.extend(heuristic.evidence.iter().map(|e| e.description.clone()));
texts.extend(
heuristic
.contraindications
.iter()
.map(|c| c.description.clone()),
);
texts.extend(
heuristic
.missing_evidence
.iter()
.map(|m| m.description.clone()),
);
texts.extend(heuristic.alternatives.iter().map(|a| a.description.clone()));
for text in texts {
let lower = text.to_lowercase();
for forbidden in FORBIDDEN {
assert!(
!lower.contains(forbidden),
"forbidden wording {forbidden:?} found in {text:?}"
);
}
}
}
}
fn module_boundary_rule(
name: &str,
krate: &str,
from: &str,
forbidden: &[&str],
) -> ModuleBoundaryRule {
ModuleBoundaryRule {
name: name.to_string(),
krate: krate.to_string(),
from: from.to_string(),
forbidden: forbidden.iter().map(|s| s.to_string()).collect(),
reach: None,
}
}
fn dependency_inversion_workspace(
dir: &TempDir,
domain_body: &str,
infra_body: &str,
other_body: Option<&str>,
) -> Workspace {
std::fs::create_dir_all(dir.join("fixture/src/domain")).unwrap();
std::fs::write(
dir.join("fixture/Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
let mut lib_rs = "pub mod domain;\npub mod infra;\n".to_string();
if other_body.is_some() {
lib_rs.push_str("pub mod other;\n");
}
std::fs::write(dir.join("fixture/src/lib.rs"), lib_rs).unwrap();
std::fs::write(dir.join("fixture/src/domain/mod.rs"), domain_body).unwrap();
std::fs::write(dir.join("fixture/src/infra.rs"), infra_body).unwrap();
if let Some(other_body) = other_body {
std::fs::write(dir.join("fixture/src/other.rs"), other_body).unwrap();
}
std::fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"fixture\"]\nresolver = \"2\"\n",
)
.unwrap();
crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap()
}
fn dependency_inversion_heuristics(
heuristics: &[PrincipleHeuristic],
) -> Vec<&PrincipleHeuristic> {
heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::DependencyInversion)
.collect()
}
#[test]
fn call_violation_plus_signature_leak_produces_one_heuristic() {
let dir = TempDir::new("principle-dependency-inversion-both-signals");
let workspace = dependency_inversion_workspace(
&dir,
"pub fn run() {\n crate::infra::read_file();\n}\n\n\
pub fn build() -> crate::infra::Client {\n todo!()\n}\n",
"pub fn read_file() {}\npub struct Client;\n",
None,
);
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-infra",
"fixture",
"domain",
&["infra"],
)],
..Default::default()
};
let heuristics = analyze_with_boundary_config(&workspace, Some(&config));
let dependency_inversion = dependency_inversion_heuristics(&heuristics);
assert_eq!(dependency_inversion.len(), 1);
let heuristic = dependency_inversion[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(!heuristic.related_findings.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn call_violation_without_signature_leak_produces_no_heuristic() {
let dir = TempDir::new("principle-dependency-inversion-call-only");
let workspace = dependency_inversion_workspace(
&dir,
"pub fn run() {\n crate::infra::read_file();\n}\n",
"pub fn read_file() {}\npub struct Client;\n",
None,
);
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-infra",
"fixture",
"domain",
&["infra"],
)],
..Default::default()
};
let heuristics = analyze_with_boundary_config(&workspace, Some(&config));
assert!(dependency_inversion_heuristics(&heuristics).is_empty());
}
#[test]
fn signature_leak_outside_the_configured_module_boundary_produces_no_heuristic() {
let dir = TempDir::new("principle-dependency-inversion-out-of-scope");
let workspace = dependency_inversion_workspace(
&dir,
"pub fn run() -> i32 {\n 42\n}\n",
"pub fn read_file() {}\npub struct Client;\n",
Some("pub fn leaked() -> crate::infra::Client {\n todo!()\n}\n"),
);
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-infra",
"fixture",
"domain",
&["infra"],
)],
..Default::default()
};
let heuristics = analyze_with_boundary_config(&workspace, Some(&config));
assert!(dependency_inversion_heuristics(&heuristics).is_empty());
}
#[test]
fn no_module_boundary_config_produces_no_heuristic() {
let dir = TempDir::new("principle-dependency-inversion-no-config");
let workspace = dependency_inversion_workspace(
&dir,
"pub fn run() {\n crate::infra::read_file();\n}\n\n\
pub fn build() -> crate::infra::Client {\n todo!()\n}\n",
"pub fn read_file() {}\npub struct Client;\n",
None,
);
let heuristics = analyze_with_boundary_config(&workspace, None);
assert!(dependency_inversion_heuristics(&heuristics).is_empty());
let empty_config = BoundaryConfig::default();
let heuristics = analyze_with_boundary_config(&workspace, Some(&empty_config));
assert!(dependency_inversion_heuristics(&heuristics).is_empty());
}
fn cohesion_heuristics(heuristics: &[PrincipleHeuristic]) -> Vec<&PrincipleHeuristic> {
heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::Cohesion)
.collect()
}
#[test]
fn mixed_categories_across_items_produces_one_heuristic() {
let dir = TempDir::new("principle-cohesion-mixed-categories");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"pub fn read_file(path: &str) -> String {{\n\
\x20 std::fs::read_to_string(path).unwrap()\n\
}}\n\
pub fn compute(mut total: i32) -> i32 {{\n\
{NINE_IFS}\n\
\x20 total\n\
}}\n\
pub struct Marker;\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let cohesion = cohesion_heuristics(&heuristics);
assert_eq!(cohesion.len(), 1);
let heuristic = cohesion[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn same_category_across_all_items_produces_no_heuristic() {
let dir = TempDir::new("principle-cohesion-single-category");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"pub fn compute_a(mut total: i32) -> i32 {{\n{NINE_IFS}\n\x20 total\n}}\n\
pub fn compute_b(mut total: i32) -> i32 {{\n{NINE_IFS}\n\x20 total\n}}\n\
pub fn compute_c(mut total: i32) -> i32 {{\n{NINE_IFS}\n\x20 total\n}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
#[test]
fn below_item_threshold_produces_no_heuristic() {
let dir = TempDir::new("principle-cohesion-below-threshold");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"pub fn read_file(path: &str) -> String {{\n\
\x20 std::fs::read_to_string(path).unwrap()\n\
}}\n\
pub fn compute(mut total: i32) -> i32 {{\n\
{NINE_IFS}\n\
\x20 total\n\
}}\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze(&workspace).is_empty());
}
#[test]
fn single_item_mixing_categories_produces_no_cohesion_heuristic() {
let dir = TempDir::new("principle-cohesion-single-item-mix");
let file = dir.join("lib.rs");
std::fs::write(
&file,
format!(
"pub fn read_and_branch(path: &str) -> i32 {{\n\
\x20 let contents = std::fs::read_to_string(path).unwrap();\n\
\x20 let mut total = contents.len() as i32;\n\
{NINE_IFS}\n\
\x20 total\n\
}}\n\
pub struct Marker;\n\
pub struct OtherMarker;\n"
),
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let principles: Vec<DesignPrinciple> = heuristics.iter().map(|h| h.principle).collect();
assert!(principles.contains(&DesignPrinciple::FunctionalCoreImperativeShell));
assert!(cohesion_heuristics(&heuristics).is_empty());
}
fn write_multi_crate_manifest(dir: &TempDir, members: &[&str]) {
let members_toml = members
.iter()
.map(|m| format!("\"{m}\""))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
dir.join("Cargo.toml"),
format!("[workspace]\nmembers = [{members_toml}]\nresolver = \"2\"\n"),
)
.unwrap();
}
fn write_crate_member(dir: &TempDir, name: &str, lib_rs: &str) {
std::fs::create_dir_all(dir.join(name).join("src")).unwrap();
std::fs::write(
dir.join(name).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.unwrap();
std::fs::write(dir.join(name).join("src/lib.rs"), lib_rs).unwrap();
}
fn write_domain_infra_crate_member(
dir: &TempDir,
name: &str,
domain_body: &str,
infra_body: &str,
) {
std::fs::create_dir_all(dir.join(name).join("src/domain")).unwrap();
std::fs::write(
dir.join(name).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.unwrap();
std::fs::write(
dir.join(name).join("src/lib.rs"),
"pub mod domain;\npub mod infra;\n",
)
.unwrap();
std::fs::write(dir.join(name).join("src/domain/mod.rs"), domain_body).unwrap();
std::fs::write(dir.join(name).join("src/infra.rs"), infra_body).unwrap();
}
#[test]
fn functional_core_signal_is_scoped_to_the_crate_that_has_it() {
let dir = TempDir::new("principle-fcis-multi-crate");
write_multi_crate_manifest(&dir, &["crate_a", "crate_b"]);
write_crate_member(
&dir,
"crate_a",
&format!(
"pub fn read_and_branch(path: &str) -> i32 {{\n\
\x20 let contents = std::fs::read_to_string(path).unwrap();\n\
\x20 let mut total = contents.len() as i32;\n\
{NINE_IFS}\n\
\x20 total\n\
}}\n"
),
);
write_crate_member(
&dir,
"crate_b",
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let heuristics = analyze(&workspace);
let fcis: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::FunctionalCoreImperativeShell)
.collect();
assert_eq!(fcis.len(), 1);
assert_eq!(fcis[0].scope.krate, "crate_a");
}
#[test]
fn thin_orchestrator_sequencing_io_without_branching_produces_no_fcis_heuristic() {
let dir = TempDir::new("principle-fcis-orchestrator");
write_multi_crate_manifest(&dir, &["orchestrator", "other"]);
write_crate_member(
&dir,
"orchestrator",
"pub fn run_pipeline(path: &str) -> std::io::Result<()> {\n\
\x20 let _a = std::fs::read_to_string(path)?;\n\
\x20 let _b = std::fs::read_to_string(path)?;\n\
\x20 std::fs::write(path, \"done\")?;\n\
\x20 Ok(())\n\
}\n",
);
write_crate_member(&dir, "other", "pub fn noop() {}\n");
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::FunctionalCoreImperativeShell)
);
}
#[test]
fn interface_segregation_signal_is_scoped_to_the_crate_that_has_it() {
let dir = TempDir::new("principle-interface-segregation-multi-crate");
write_multi_crate_manifest(&dir, &["crate_a", "crate_b"]);
write_crate_member(
&dir,
"crate_a",
&format!(
"{WIDE_TRAIT}\n\
pub struct Left;\n\
impl Wide for Left {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
}}\n\
pub struct Right;\n\
impl Wide for Right {{\n\
\x20 fn c(&self) {{}}\n\
\x20 fn d(&self) {{}}\n\
}}\n"
),
);
write_crate_member(
&dir,
"crate_b",
"pub trait Wide {\n\
\x20 fn x(&self) { let _ = 1; }\n\
\x20 fn y(&self) { let _ = 1; }\n\
}\n\
pub struct Left;\n\
impl Wide for Left {\n\
\x20 fn x(&self) {}\n\
}\n\
pub struct Right;\n\
impl Wide for Right {\n\
\x20 fn y(&self) {}\n\
}\n",
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let heuristics = analyze(&workspace);
let interface_segregation: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::InterfaceSegregation)
.collect();
assert_eq!(interface_segregation.len(), 1);
assert_eq!(interface_segregation[0].scope.krate, "crate_a");
}
#[test]
fn full_adapters_implementing_the_whole_interface_produce_no_interface_segregation_heuristic() {
let dir = TempDir::new("principle-interface-segregation-full-adapters");
write_multi_crate_manifest(&dir, &["adapters", "other"]);
write_crate_member(
&dir,
"adapters",
&format!(
"{WIDE_TRAIT}\n\
pub struct AdapterOne;\n\
impl Wide for AdapterOne {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
\x20 fn c(&self) {{}}\n\
\x20 fn d(&self) {{}}\n\
\x20 fn e(&self) {{}}\n\
}}\n\
pub struct AdapterTwo;\n\
impl Wide for AdapterTwo {{\n\
\x20 fn a(&self) {{}}\n\
\x20 fn b(&self) {{}}\n\
\x20 fn c(&self) {{}}\n\
\x20 fn d(&self) {{}}\n\
\x20 fn e(&self) {{}}\n\
}}\n"
),
);
write_crate_member(&dir, "other", "pub fn noop() {}\n");
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::InterfaceSegregation)
);
}
#[test]
fn dependency_inversion_signal_is_scoped_to_the_configured_crate() {
let dir = TempDir::new("principle-dependency-inversion-multi-crate");
write_multi_crate_manifest(&dir, &["crate_a", "crate_b"]);
write_domain_infra_crate_member(
&dir,
"crate_a",
"pub fn run() {\n crate::infra::read_file();\n}\n\n\
pub fn build() -> crate::infra::Client {\n todo!()\n}\n",
"pub fn read_file() {}\npub struct Client;\n",
);
write_crate_member(
&dir,
"crate_b",
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-infra",
"crate_a",
"domain",
&["infra"],
)],
..Default::default()
};
let heuristics = analyze_with_boundary_config(&workspace, Some(&config));
let dependency_inversion = dependency_inversion_heuristics(&heuristics);
assert_eq!(dependency_inversion.len(), 1);
assert_eq!(dependency_inversion[0].scope.krate, "crate_a");
}
#[test]
fn orchestrator_calling_infra_without_leaking_its_types_produces_no_dependency_inversion_heuristic()
{
let dir = TempDir::new("principle-dependency-inversion-orchestrator");
write_multi_crate_manifest(&dir, &["crate_a", "crate_b"]);
write_domain_infra_crate_member(
&dir,
"crate_a",
"pub fn run() -> bool {\n \
crate::infra::read_file();\n \
crate::infra::write_file();\n \
true\n}\n",
"pub fn read_file() {}\npub fn write_file() {}\npub struct Client;\n",
);
write_crate_member(
&dir,
"crate_b",
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let config = BoundaryConfig {
module_boundaries: vec![module_boundary_rule(
"domain-no-infra",
"crate_a",
"domain",
&["infra"],
)],
..Default::default()
};
let heuristics = analyze_with_boundary_config(&workspace, Some(&config));
assert!(dependency_inversion_heuristics(&heuristics).is_empty());
}
#[test]
fn cohesion_signal_is_scoped_to_the_crate_that_has_it() {
let dir = TempDir::new("principle-cohesion-multi-crate");
write_multi_crate_manifest(&dir, &["crate_a", "crate_b"]);
write_crate_member(
&dir,
"crate_a",
&format!(
"pub fn read_file(path: &str) -> String {{\n\
\x20 std::fs::read_to_string(path).unwrap()\n\
}}\n\
pub fn compute(mut total: i32) -> i32 {{\n\
{NINE_IFS}\n\
\x20 total\n\
}}\n\
pub struct Marker;\n"
),
);
write_crate_member(
&dir,
"crate_b",
&format!(
"pub fn compute_a(mut total: i32) -> i32 {{\n{NINE_IFS}\n\x20 total\n}}\n\
pub fn compute_b(mut total: i32) -> i32 {{\n{NINE_IFS}\n\x20 total\n}}\n\
pub fn compute_c(mut total: i32) -> i32 {{\n{NINE_IFS}\n\x20 total\n}}\n"
),
);
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let heuristics = analyze(&workspace);
let cohesion: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::Cohesion)
.collect();
assert_eq!(cohesion.len(), 1);
assert_eq!(cohesion[0].scope.krate, "crate_a");
}
#[test]
fn orchestrator_module_bundling_delegate_calls_produces_no_cohesion_heuristic() {
let dir = TempDir::new("principle-cohesion-orchestrator");
write_multi_crate_manifest(&dir, &["facade", "other"]);
write_crate_member(
&dir,
"facade",
"pub fn step_one() -> i32 {\n 1\n}\n\
pub fn step_two() -> i32 {\n 2\n}\n\
pub fn step_three() -> i32 {\n 3\n}\n\
pub fn run_all() -> i32 {\n step_one() + step_two() + step_three()\n}\n",
);
write_crate_member(&dir, "other", "pub fn noop() {}\n");
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::Cohesion)
);
}
#[test]
fn law_of_demeter_unbroken_three_call_chain_produces_one_heuristic() {
let dir = TempDir::new("principle-demeter-chain");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn reach(collector: &Collector) -> i32 {\n\
\x20 collector.repository().connection().timeout()\n\
}\n\
pub struct Collector;\n\
pub struct Repository;\n\
pub struct Connection;\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let demeter: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::LawOfDemeter)
.collect();
assert_eq!(demeter.len(), 1);
let heuristic = demeter[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn law_of_demeter_chain_with_sibling_intermediate_let_produces_no_heuristic() {
let dir = TempDir::new("principle-demeter-chain-let");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn reach(collector: &Collector) -> i32 {\n\
\x20 let _cached = collector.repository().connection();\n\
\x20 collector.repository().connection().timeout()\n\
}\n\
pub struct Collector;\n\
pub struct Repository;\n\
pub struct Connection;\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::LawOfDemeter)
);
}
#[test]
fn law_of_demeter_chain_starting_from_self_field_produces_no_heuristic() {
let dir = TempDir::new("principle-demeter-chain-self");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Widget {\n\
\x20 inner: Inner,\n\
}\n\
impl Widget {\n\
\x20 pub fn reach(&self) -> i32 {\n\
\x20\x20\x20 self.inner.repository().connection().timeout()\n\
\x20 }\n\
}\n\
pub struct Inner;\n\
pub struct Repository;\n\
pub struct Connection;\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::LawOfDemeter)
);
}
#[test]
fn bounded_resources_loop_without_any_exit_produces_one_heuristic() {
let dir = TempDir::new("principle-bounded-resources-loop");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn spin(counter: &mut i32) {\n\
\x20 loop {\n\
\x20\x20\x20 *counter += 1;\n\
\x20 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let bounded: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::BoundedResources)
.collect();
assert_eq!(bounded.len(), 1);
let heuristic = bounded[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn bounded_resources_loop_with_break_produces_no_heuristic() {
let dir = TempDir::new("principle-bounded-resources-loop-break");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn spin(counter: &mut i32) -> i32 {\n\
\x20 loop {\n\
\x20\x20\x20 *counter += 1;\n\
\x20\x20\x20 if *counter > 3 {\n\
\x20\x20\x20\x20\x20 break;\n\
\x20\x20\x20 }\n\
\x20 }\n\
\x20 *counter\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::BoundedResources)
);
}
#[test]
fn bounded_resources_direct_recursion_without_guard_produces_one_heuristic() {
let dir = TempDir::new("principle-bounded-resources-recursion");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn countdown(n: i32) -> i32 {\n\
\x20 countdown(n - 1)\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let bounded: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::BoundedResources)
.collect();
assert_eq!(bounded.len(), 1);
let heuristic = bounded[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn bounded_resources_direct_recursion_with_parameter_guard_produces_no_heuristic() {
let dir = TempDir::new("principle-bounded-resources-recursion-guard");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn countdown(n: i32) -> i32 {\n\
\x20 if n <= 0 {\n\
\x20\x20\x20 return 0;\n\
\x20 }\n\
\x20 countdown(n - 1)\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::BoundedResources)
);
}
#[test]
fn parse_dont_validate_with_crate_wide_duplication_produces_one_heuristic() {
let dir = TempDir::new("principle-parse-dont-validate");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn validate_email(s: &str) -> bool {\n\
\x20 if s.is_empty() {\n\
\x20\x20\x20 return false;\n\
\x20 }\n\
\x20 true\n\
}\n\
\n\
fn check_username(u: &str) -> bool {\n\
\x20 if u.is_empty() {\n\
\x20\x20\x20 return false;\n\
\x20 }\n\
\x20 true\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let parse_dont_validate: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::ParseDontValidate)
.collect();
assert_eq!(parse_dont_validate.len(), 1);
let heuristic = parse_dont_validate[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn parse_dont_validate_without_crate_wide_duplication_produces_no_heuristic() {
let dir = TempDir::new("principle-parse-dont-validate-no-corroboration");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn validate_email(s: &str) -> bool {\n\
\x20 if s.is_empty() {\n\
\x20\x20\x20 return false;\n\
\x20 }\n\
\x20 true\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::ParseDontValidate)
);
}
#[test]
fn parse_dont_validate_that_parses_into_a_newtype_produces_no_heuristic() {
let dir = TempDir::new("principle-parse-dont-validate-newtype");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Email(String);\n\
\n\
pub fn parse_email(s: &str) -> Result<Email, String> {\n\
\x20 if s.is_empty() {\n\
\x20\x20\x20 return Err(\"empty\".to_string());\n\
\x20 }\n\
\x20 Ok(Email(s.to_string()))\n\
}\n\
\n\
fn check_username(u: &str) -> bool {\n\
\x20 if u.is_empty() {\n\
\x20\x20\x20 return false;\n\
\x20 }\n\
\x20 true\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::ParseDontValidate)
);
}
#[test]
fn api_evolvability_with_construction_site_produces_one_heuristic() {
let dir = TempDir::new("principle-api-evolvability");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Point {\n\
\x20 pub x: i32,\n\
\x20 pub y: i32,\n\
}\n\
\n\
pub fn origin_shifted() -> Point {\n\
\x20 Point { x: 1, y: 2 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let api_evolvability: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::ApiEvolvability)
.collect();
assert_eq!(api_evolvability.len(), 1);
let heuristic = api_evolvability[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn api_evolvability_without_construction_site_produces_no_heuristic() {
let dir = TempDir::new("principle-api-evolvability-no-construction");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"#[derive(Default)]\n\
pub struct Point {\n\
\x20 pub x: i32,\n\
\x20 pub y: i32,\n\
}\n\
\n\
pub fn origin() -> Point {\n\
\x20 Point { ..Default::default() }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::ApiEvolvability)
);
}
#[test]
fn api_evolvability_with_non_exhaustive_produces_no_heuristic() {
let dir = TempDir::new("principle-api-evolvability-non-exhaustive");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"#[non_exhaustive]\n\
pub struct Point {\n\
\x20 pub x: i32,\n\
\x20 pub y: i32,\n\
}\n\
\n\
pub fn origin_shifted() -> Point {\n\
\x20 Point { x: 1, y: 2 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::ApiEvolvability)
);
}
#[test]
fn api_evolvability_with_private_field_produces_no_heuristic() {
let dir = TempDir::new("principle-api-evolvability-private-field");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Point {\n\
\x20 pub x: i32,\n\
\x20 y: i32,\n\
}\n\
\n\
impl Point {\n\
\x20 pub fn shifted() -> Point {\n\
\x20\x20\x20 Point { x: 1, y: 2 }\n\
\x20 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::ApiEvolvability)
);
}
#[test]
fn undocumented_pub_unsafe_fn_with_safety_wrapper_produces_one_heuristic() {
let dir = TempDir::new("principle-unsafe-containment-undocumented");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub unsafe fn raw_read(ptr: *const u8) -> u8 {\n\
\x20 unsafe { *ptr }\n\
}\n\
\n\
pub fn safe_read(ptr: *const u8) -> u8 {\n\
\x20 // SAFETY: caller guarantees ptr is valid and aligned\n\
\x20 unsafe { *ptr }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let unsafe_containment: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::UnsafeContainment)
.collect();
assert_eq!(unsafe_containment.len(), 1);
let heuristic = unsafe_containment[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert!(!heuristic.evidence[1].locations.is_empty());
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn documented_pub_unsafe_fn_with_safety_wrapper_produces_no_heuristic() {
let dir = TempDir::new("principle-unsafe-containment-documented");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"/// Reads a raw pointer.\n\
///\n\
/// # Safety\n\
///\n\
/// `ptr` must be valid and aligned.\n\
pub unsafe fn raw_read(ptr: *const u8) -> u8 {\n\
\x20 unsafe { *ptr }\n\
}\n\
\n\
pub fn safe_read(ptr: *const u8) -> u8 {\n\
\x20 // SAFETY: caller guarantees ptr is valid and aligned\n\
\x20 unsafe { *ptr }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::UnsafeContainment)
);
}
#[test]
fn undocumented_pub_unsafe_fn_without_safety_wrapper_produces_no_heuristic() {
let dir = TempDir::new("principle-unsafe-containment-no-wrapper");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub unsafe fn raw_read(ptr: *const u8) -> u8 {\n\
\x20 unsafe { *ptr }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::UnsafeContainment)
);
}
#[test]
fn misu_with_mutually_exclusive_construction_sites_produces_one_heuristic() {
let dir = TempDir::new("principle-misu-mutually-exclusive");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Shipping {\n\
\x20 pub express: Option<String>,\n\
\x20 pub pickup: Option<String>,\n\
}\n\
\n\
pub fn via_express(courier: String) -> Shipping {\n\
\x20 Shipping { express: Some(courier), pickup: None }\n\
}\n\
\n\
pub fn via_pickup(location: String) -> Shipping {\n\
\x20 Shipping { express: None, pickup: Some(location) }\n\
}\n\
\n\
pub fn unspecified() -> Shipping {\n\
\x20 Shipping { express: None, pickup: None }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
let misu: Vec<&PrincipleHeuristic> = heuristics
.iter()
.filter(|h| h.principle == DesignPrinciple::MakeIllegalStatesUnrepresentable)
.collect();
assert_eq!(misu.len(), 1);
let heuristic = misu[0];
assert_eq!(heuristic.scope.krate, "fixture");
assert_eq!(heuristic.evidence.len(), 2);
assert!(!heuristic.evidence[0].locations.is_empty());
assert_eq!(heuristic.evidence[1].locations.len(), 3);
assert!(heuristic.contraindications.len() >= 2);
assert!(heuristic.alternatives.len() >= 2);
assert!(!heuristic.missing_evidence.is_empty());
}
#[test]
fn misu_with_simultaneous_construction_site_produces_no_heuristic() {
let dir = TempDir::new("principle-misu-simultaneous");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Shipping {\n\
\x20 pub express: Option<String>,\n\
\x20 pub pickup: Option<String>,\n\
}\n\
\n\
pub fn via_express(courier: String) -> Shipping {\n\
\x20 Shipping { express: Some(courier), pickup: None }\n\
}\n\
\n\
pub fn via_pickup(location: String) -> Shipping {\n\
\x20 Shipping { express: None, pickup: Some(location) }\n\
}\n\
\n\
pub fn via_both(courier: String, location: String) -> Shipping {\n\
\x20 Shipping { express: Some(courier), pickup: Some(location) }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::MakeIllegalStatesUnrepresentable)
);
}
#[test]
fn misu_with_one_construction_site_produces_no_heuristic() {
let dir = TempDir::new("principle-misu-one-site");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Shipping {\n\
\x20 pub express: Option<String>,\n\
\x20 pub pickup: Option<String>,\n\
}\n\
\n\
pub fn via_express(courier: String) -> Shipping {\n\
\x20 Shipping { express: Some(courier), pickup: None }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::MakeIllegalStatesUnrepresentable)
);
}
#[test]
fn misu_with_single_option_field_produces_no_heuristic() {
let dir = TempDir::new("principle-misu-single-field");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Shipping {\n\
\x20 pub express: Option<String>,\n\
\x20 pub carrier: String,\n\
}\n\
\n\
pub fn via_express(courier: String) -> Shipping {\n\
\x20 Shipping { express: Some(courier.clone()), carrier: courier }\n\
}\n\
\n\
pub fn without_express(courier: String) -> Shipping {\n\
\x20 Shipping { express: None, carrier: courier }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let heuristics = analyze(&workspace);
assert!(
heuristics
.iter()
.all(|h| h.principle != DesignPrinciple::MakeIllegalStatesUnrepresentable)
);
}
}