use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::components::ComponentMap;
use crate::config::{Config, Listed};
use crate::lang::Target;
use crate::scan::{Dep, slash};
use crate::scope::Scope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RuleId {
Dependency,
Privacy,
Cycle,
External,
Metric,
Orphan,
Isolation,
Required,
File,
Symbol,
UnusedSuppression,
SuppressionReason,
}
impl RuleId {
pub fn as_str(self) -> &'static str {
match self {
RuleId::Dependency => "dependency",
RuleId::Privacy => "privacy",
RuleId::Cycle => "cycle",
RuleId::External => "external",
RuleId::Metric => "metric",
RuleId::Orphan => "orphan",
RuleId::Isolation => "isolation",
RuleId::Required => "required",
RuleId::File => "file",
RuleId::Symbol => "symbol",
RuleId::UnusedSuppression => "unused-suppression",
RuleId::SuppressionReason => "suppression-reason",
}
}
pub const SUPPRESSIBLE: [RuleId; 5] = [
RuleId::Dependency,
RuleId::Privacy,
RuleId::External,
RuleId::Isolation,
RuleId::Symbol,
];
pub fn suppressible(s: &str) -> Option<Self> {
Self::SUPPRESSIBLE.into_iter().find(|r| r.as_str() == s)
}
}
fn default_rule() -> RuleId {
RuleId::Dependency
}
#[derive(Debug, Clone, Serialize)]
pub struct Violation {
pub rule: RuleId,
pub file: String,
pub line: usize,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub because: Option<String>,
pub from: String,
pub to: String,
pub target: String,
pub specifier: String,
#[serde(skip)]
pub evidence: Vec<(String, usize)>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Key {
#[serde(default = "default_rule")]
pub rule: RuleId,
pub file: String,
pub target: String,
pub from: String,
pub to: String,
}
impl Violation {
pub fn key(&self) -> Key {
Key {
rule: self.rule,
file: self.file.clone(),
target: self.target.clone(),
from: self.from.clone(),
to: self.to.clone(),
}
}
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(Debug, Default, Serialize)]
pub struct Stats {
pub files: usize,
pub internal_deps: usize,
pub external_deps: usize,
pub unmapped_files: usize,
pub suppressed: usize,
#[serde(skip_serializing_if = "is_zero")]
pub indexed: usize,
#[serde(skip_serializing_if = "is_zero")]
pub from_cache: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub uncovered: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub limited_to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub new_dependencies: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SymbolRef {
pub file: std::path::PathBuf,
pub line: usize,
pub specifier: String,
pub name: String,
pub target: Target,
}
#[derive(Debug, Clone)]
pub struct Edge {
pub from: String,
pub to: String,
pub file: String,
pub line: usize,
}
pub fn evaluate(
config: &Config,
map: &ComponentMap,
scope: &Scope,
deps: &[Dep],
) -> (Vec<Violation>, Vec<Edge>, Stats) {
let mut stats = Stats::default();
let mut found = Vec::new();
let mut edges = Vec::new();
let mut unmapped = BTreeSet::new();
for dep in deps {
let file = slash(&dep.file);
let counted = scope.contains(map, &file);
let target = match &dep.target {
Target::Internal(path) => slash(path),
Target::External(module) => {
stats.external_deps += usize::from(counted);
if let Some(from) = map.component_of(&file)
&& let Some(problem) = map.external_problem(from, module)
{
found.push(Violation {
rule: RuleId::External,
evidence: vec![(file.clone(), dep.line)],
file,
line: dep.line,
message: format!("{from} -> external {module} {}", problem.what),
because: problem
.because
.or_else(|| config.reason(from))
.map(str::to_string),
from: from.to_string(),
to: String::new(),
target: module.clone(),
specifier: dep.specifier.clone(),
});
}
continue;
}
};
stats.internal_deps += usize::from(counted);
let Some(from) = map.component_of(&file) else {
if counted {
unmapped.insert(file);
}
continue;
};
let Some(to) = map.component_of(&target) else {
continue;
};
if from == to {
if let (Some(here), Some(there)) = (map.instance_of(&file), map.instance_of(&target))
&& here != there
{
found.push(Violation {
rule: RuleId::Isolation,
evidence: vec![(file.clone(), dep.line)],
file,
line: dep.line,
message: format!(
"{here} -> {there}: instances of {from} must not import each other (imports {})",
dep.specifier
),
because: config.components[from]
.because
.clone()
.or_else(|| config.reason(from).map(str::to_string)),
from: from.to_string(),
to: to.to_string(),
target,
specifier: dep.specifier.clone(),
});
}
continue;
}
edges.push(Edge {
from: from.to_string(),
to: to.to_string(),
file: file.clone(),
line: dep.line,
});
let importers = config.components[to].allow_importers.as_deref();
let (rule, message, because) = if !config.allows(from, to) {
let how = if config.forbids(from, to) {
"is forbidden"
} else {
"is not allowed"
};
(
RuleId::Dependency,
format!("{from} -> {to} {how} (imports {})", dep.specifier),
config
.forbid_reason(from, to)
.or_else(|| config.reason(from)),
)
} else if importers.is_some_and(|list| !list.iter().any(|c| c == from)) {
(
RuleId::Dependency,
format!(
"{from} -> {to}: {to} may be imported only from {} (imports {})",
importers.unwrap_or_default().join(", "),
dep.specifier
),
config.components[to].because.as_deref(),
)
} else if !map.is_public(to, &target) {
(
RuleId::Privacy,
format!(
"{from} -> {to}: {target} is not in the public API of {to} (imports {})",
dep.specifier
),
config.components[to].because.as_deref(),
)
} else {
continue;
};
found.push(Violation {
rule,
evidence: vec![(file.clone(), dep.line)],
file,
line: dep.line,
message,
because: because.map(str::to_string),
from: from.to_string(),
to: to.to_string(),
target,
specifier: dep.specifier.clone(),
});
}
stats.unmapped_files = unmapped.len();
(found, edges, stats)
}
pub fn new_dependencies(edges: &[Edge], changed: impl Fn(&str, usize) -> bool) -> Vec<String> {
let mut all_changed: BTreeMap<(&str, &str), bool> = BTreeMap::new();
for e in edges {
let entry = all_changed.entry((&e.from, &e.to)).or_insert(true);
*entry &= changed(&e.file, e.line);
}
all_changed
.into_iter()
.filter(|(_, new)| *new)
.map(|((from, to), _)| format!("{from} -> {to}"))
.collect()
}
pub fn cycles(config: &Config, edges: &[Edge]) -> Vec<Violation> {
let covered: Vec<&Edge> = edges
.iter()
.filter(|e| config.no_cycles.covers(&e.from) && config.no_cycles.covers(&e.to))
.collect();
let mut graph: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for e in &covered {
graph.entry(&e.from).or_default().insert(&e.to);
graph.entry(&e.to).or_default();
}
let mut groups: Vec<BTreeSet<&str>> = strongly_connected(&graph)
.into_iter()
.filter(|g| g.len() > 1)
.map(|g| g.into_iter().collect())
.collect();
groups.sort();
groups
.into_iter()
.map(|members| {
let start = *members.first().expect("non-empty group");
let cycle = shortest_cycle(&graph, &members, start);
let first = covered
.iter()
.filter(|e| e.from == cycle[0] && e.to == cycle[1])
.min_by(|x, y| (&x.file, x.line).cmp(&(&y.file, y.line)))
.expect("cycle edge exists");
let path = cycle.join(" -> ");
let names: Vec<&str> = members.iter().copied().collect();
let message = if members.len() == cycle.len() - 1 {
format!("component dependency cycle: {path}")
} else {
format!(
"component dependency cycle among {} components: {} (shortest: {path})",
members.len(),
names.join(", ")
)
};
let mut evidence: Vec<(String, usize)> = covered
.iter()
.filter(|e| members.contains(e.from.as_str()) && members.contains(e.to.as_str()))
.map(|e| (e.file.clone(), e.line))
.collect();
evidence.sort();
Violation {
rule: RuleId::Cycle,
file: first.file.clone(),
line: first.line,
message,
because: None,
from: names.join(","),
to: String::new(),
target: String::new(),
specifier: String::new(),
evidence,
}
})
.collect()
}
fn strongly_connected<'a>(graph: &BTreeMap<&'a str, BTreeSet<&'a str>>) -> Vec<Vec<&'a str>> {
#[derive(Default)]
struct State<'a> {
index: BTreeMap<&'a str, usize>,
low: BTreeMap<&'a str, usize>,
stack: Vec<&'a str>,
on_stack: BTreeSet<&'a str>,
groups: Vec<Vec<&'a str>>,
}
fn visit<'a>(v: &'a str, graph: &BTreeMap<&'a str, BTreeSet<&'a str>>, s: &mut State<'a>) {
let i = s.index.len();
s.index.insert(v, i);
s.low.insert(v, i);
s.stack.push(v);
s.on_stack.insert(v);
for &w in &graph[v] {
if !s.index.contains_key(w) {
visit(w, graph, s);
let low = s.low[v].min(s.low[w]);
s.low.insert(v, low);
} else if s.on_stack.contains(w) {
let low = s.low[v].min(s.index[w]);
s.low.insert(v, low);
}
}
if s.low[v] == s.index[v] {
let mut group = Vec::new();
while let Some(w) = s.stack.pop() {
s.on_stack.remove(w);
group.push(w);
if w == v {
break;
}
}
s.groups.push(group);
}
}
let mut state = State::default();
for &v in graph.keys() {
if !state.index.contains_key(v) {
visit(v, graph, &mut state);
}
}
state.groups
}
fn shortest_cycle<'a>(
graph: &BTreeMap<&'a str, BTreeSet<&'a str>>,
members: &BTreeSet<&'a str>,
start: &'a str,
) -> Vec<&'a str> {
let mut prev: BTreeMap<&str, &str> = BTreeMap::new();
let mut queue = VecDeque::from([start]);
while let Some(v) = queue.pop_front() {
for &w in &graph[v] {
if !members.contains(w) {
continue;
}
if w == start {
let mut path = vec![v];
let mut cur = v;
while cur != start {
cur = prev[cur];
path.push(cur);
}
path.reverse();
path.push(start);
return path;
}
if w != start && !prev.contains_key(w) {
prev.insert(w, v);
queue.push_back(w);
}
}
}
unreachable!("a strongly connected group has a cycle through every member")
}
pub fn metrics(config: &Config, edges: &[Edge]) -> Vec<Violation> {
let mut out: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut incoming: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for e in edges {
out.entry(&e.from).or_default().insert(&e.to);
incoming.entry(&e.to).or_default().insert(&e.from);
}
let mut found = Vec::new();
for (name, rule) in &config.rules {
let Some(targets) = out.get(name.as_str()) else {
continue;
};
let fan_out = targets.len();
let fan_in = incoming.get(name.as_str()).map_or(0, BTreeSet::len);
let instability = fan_out as f64 / (fan_in + fan_out) as f64;
let mut evidence: Vec<(String, usize)> = edges
.iter()
.filter(|e| e.from == *name)
.map(|e| (e.file.clone(), e.line))
.collect();
evidence.sort();
let (file, line) = evidence[0].clone();
let list = targets.iter().copied().collect::<Vec<_>>().join(", ");
let mut over = |limit: &str, message: String| {
found.push(Violation {
rule: RuleId::Metric,
file: file.clone(),
line,
message,
because: rule.because.clone(),
from: name.clone(),
to: String::new(),
target: limit.to_string(),
specifier: String::new(),
evidence: evidence.clone(),
});
};
if let Some(max) = rule.max_fan_out
&& fan_out > max
{
over(
"max_fan_out",
format!(
"{name} depends on {fan_out} components, more than max_fan_out {max}: {list}"
),
);
}
if let Some(max) = rule.max_instability
&& instability > max
{
over(
"max_instability",
format!(
"{name} has instability {instability:.2} ({fan_out} out, {fan_in} in), above max_instability {max}"
),
);
}
}
found
}
#[derive(Default)]
struct Unit {
files: BTreeSet<String>,
components: BTreeSet<String>,
external: BTreeSet<String>,
}
pub fn required(
root: &Path,
config: &Config,
map: &ComponentMap,
deps: &[Dep],
paths: &[String],
all_files: &[String],
) -> Vec<Violation> {
let parent = |file: &str| file.rsplit_once('/').map_or("", |(dir, _)| dir).to_string();
let wanted = |from: &str| {
config.rules.get(from).is_some_and(|r| {
!(r.must_depend_on.is_empty()
&& r.must_use_external.is_empty()
&& r.requires_file.is_empty()
&& r.requires_directive.is_empty())
})
};
let mut units: BTreeMap<(String, String), Unit> = BTreeMap::new();
for file in paths {
if let Some(component) = map.component_of(file).filter(|c| wanted(c)) {
units
.entry((component.to_string(), parent(file)))
.or_default()
.files
.insert(file.clone());
}
}
for dep in deps {
let file = slash(&dep.file);
let Some(component) = map.component_of(&file) else {
continue;
};
let Some(unit) = units.get_mut(&(component.to_string(), parent(&file))) else {
continue;
};
match &dep.target {
Target::Internal(target) => {
if let Some(to) = map.component_of(&slash(target)) {
unit.components.insert(to.to_string());
}
}
Target::External(module) => {
unit.external.insert(module.clone());
}
}
}
let mut dir_files: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for file in all_files {
let (dir, name) = file.rsplit_once('/').unwrap_or(("", file));
dir_files.entry(dir).or_default().push(name);
}
let mut found = Vec::new();
for ((component, dir), unit) in &units {
let rule = &config.rules[component];
let first = unit.files.first().expect("a unit has files").clone();
let shown = if dir.is_empty() { "." } else { dir };
let mut missing = |target: String, message: String, wanted: &Listed| {
found.push(Violation {
rule: RuleId::Required,
file: first.clone(),
line: 1,
message,
because: wanted
.because()
.or(rule.because.as_deref())
.map(str::to_string),
from: component.clone(),
to: String::new(),
target,
specifier: String::new(),
evidence: vec![(first.clone(), 1)],
});
};
for wanted in &rule.must_depend_on {
if !unit.components.contains(wanted.name()) {
missing(
format!("import {}", wanted.name()),
format!(
"{shown} ({component}) must import {}, and no file in it does",
wanted.name()
),
wanted,
);
}
}
for wanted in &rule.must_use_external {
let used = ComponentMap::external_matches(wanted.name(), unit.external.iter());
if !used {
missing(
format!("external {}", wanted.name()),
format!(
"{shown} ({component}) must use the external module {}, and no file in it does",
wanted.name()
),
wanted,
);
}
}
for wanted in &rule.requires_file {
let names = dir_files.get(dir.as_str()).map_or(&[][..], Vec::as_slice);
let present = globset::Glob::new(wanted.name())
.map(|g| g.compile_matcher())
.is_ok_and(|m| names.iter().any(|n| m.is_match(n)));
if !present {
missing(
format!("file {}", wanted.name()),
format!(
"{shown} ({component}) must contain a file {}",
wanted.name()
),
wanted,
);
}
}
for wanted in &rule.requires_directive {
let Ok(pattern) = regex::Regex::new(wanted.name()) else {
continue;
};
let present = unit.files.iter().any(|file| {
std::fs::read_to_string(root.join(file)).is_ok_and(|text| pattern.is_match(&text))
});
if !present {
missing(
format!("directive {}", wanted.name()),
format!(
"{shown} ({component}) must contain a comment or text matching `{}`, and no file in it does",
wanted.name()
),
wanted,
);
}
}
}
found
}
pub fn symbols(config: &Config, map: &ComponentMap, uses: &[SymbolRef]) -> Vec<Violation> {
let mut found = Vec::new();
for used in uses {
let file = slash(&used.file);
let Some(from) = map.component_of(&file) else {
continue;
};
let mut report = |message: String, target: String, to: &str, because: Option<&str>| {
found.push(Violation {
rule: RuleId::Symbol,
evidence: vec![(file.clone(), used.line)],
file: file.clone(),
line: used.line,
message,
because: because.map(str::to_string),
from: from.to_string(),
to: to.to_string(),
target,
specifier: used.specifier.clone(),
});
};
if let Some(rule) = config.rules.get(from) {
for listed in &rule.forbid_symbol {
if names_symbol(listed.name(), &used.specifier, &used.name) {
report(
format!("{from} must not use {}", listed.name()),
listed.name().to_string(),
"",
listed.because().or(rule.because.as_deref()),
);
}
}
}
if let Target::Internal(path) = &used.target
&& let Some(to) = map.component_of(&slash(path))
&& to != from
&& let Some(public) = &config.components[to].public_symbols
&& !public.contains(&used.name)
{
report(
format!(
"{from} -> {to}: {} is not in the public symbols of {to} ({})",
used.name,
public.join(", ")
),
format!("{}.{}", used.specifier, used.name),
to,
config.components[to].because.as_deref(),
);
}
}
found
}
fn names_symbol(pattern: &str, specifier: &str, name: &str) -> bool {
let Some((qualifier, wanted)) = pattern.rsplit_once('.') else {
return false;
};
wanted == name
&& (qualifier == specifier
|| specifier
.strip_suffix(qualifier)
.is_some_and(|rest| rest.ends_with('/')))
}
pub fn forbidden_files(config: &Config, map: &ComponentMap, files: &[String]) -> Vec<Violation> {
let mut found = Vec::new();
for (component, rule) in &config.rules {
if rule.forbid_file.is_empty() {
continue;
}
let matchers: Vec<(&Listed, globset::GlobMatcher)> = rule
.forbid_file
.iter()
.filter_map(|l| Some((l, globset::Glob::new(l.name()).ok()?.compile_matcher())))
.collect();
for file in files {
if map.component_of(file) != Some(component.as_str()) {
continue;
}
let name = file.rsplit_once('/').map_or(file.as_str(), |(_, n)| n);
let Some((listed, _)) = matchers
.iter()
.find(|(_, m)| m.is_match(name) || m.is_match(file))
else {
continue;
};
found.push(Violation {
rule: RuleId::File,
file: file.clone(),
line: 1,
message: format!(
"{component} must not contain {file} (forbid_file {})",
listed.name()
),
because: listed
.because()
.or(rule.because.as_deref())
.map(str::to_string),
from: component.clone(),
to: String::new(),
target: file.clone(),
specifier: String::new(),
evidence: vec![(file.clone(), 1)],
});
}
}
found
}
pub fn orphans(config: &Config, map: &ComponentMap, files: &[String]) -> Vec<Violation> {
if !config.strict {
return Vec::new();
}
files
.iter()
.filter(|f| map.component_of(f).is_none() && !map.is_unowned(f))
.map(|file| Violation {
rule: RuleId::Orphan,
file: file.clone(),
line: 1,
message: "file belongs to no component (strict mode)".to_string(),
because: None,
from: String::new(),
to: String::new(),
target: file.clone(),
specifier: String::new(),
evidence: vec![(file.clone(), 1)],
})
.collect()
}
pub fn dedupe(violations: Vec<Violation>) -> Vec<Violation> {
let mut unique: BTreeMap<(Key, usize), Violation> = BTreeMap::new();
for v in violations {
unique.entry((v.key(), v.line)).or_insert(v);
}
unique.into_values().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dedupe_keeps_every_line_and_drops_repeats() {
let at = |line: usize| Violation {
rule: RuleId::Symbol,
file: "f".into(),
line,
message: String::new(),
because: None,
from: "a".into(),
to: String::new(),
target: "db.Client".into(),
specifier: String::new(),
evidence: Vec::new(),
};
let lines: Vec<usize> = dedupe(vec![at(9), at(3), at(9)])
.iter()
.map(|v| v.line)
.collect();
assert_eq!(lines, [3, 9]);
}
fn edge(from: &str, to: &str) -> Edge {
Edge {
from: from.into(),
to: to.into(),
file: format!("{from}/x"),
line: 1,
}
}
fn config(no_cycles: &str) -> Config {
serde_norway::from_str(&format!(
"version: 1
components:
a: {{ in: a }}
b: {{ in: b }}
c: {{ in: c }}
d: {{ in: d }}
no_cycles: {no_cycles}
"
))
.unwrap()
}
#[test]
fn reports_each_cycle_group_once_with_shortest_path() {
let edges = [
edge("a", "b"),
edge("b", "c"),
edge("c", "a"),
edge("b", "a"),
edge("c", "d"),
];
let found = cycles(&config("true"), &edges);
assert_eq!(found.len(), 1);
assert_eq!(
found[0].message,
"component dependency cycle among 3 components: a, b, c (shortest: a -> b -> a)"
);
assert_eq!(found[0].from, "a,b,c");
assert_eq!(found[0].evidence.len(), 4);
}
fn limits(rule: &str) -> Config {
serde_norway::from_str(&format!(
"version: 1
components:
a: {{ in: a }}
b: {{ in: b }}
c: {{ in: c }}
rules:
a: {{ {rule} }}
"
))
.unwrap()
}
#[test]
fn metrics_report_fan_out_and_instability() {
let edges = [edge("a", "b"), edge("a", "c"), edge("b", "a")];
let found = metrics(&limits("max_fan_out: 1, max_instability: 0.5"), &edges);
let targets: Vec<_> = found.iter().map(|v| v.target.as_str()).collect();
assert_eq!(targets, ["max_fan_out", "max_instability"]);
assert!(found[0].message.contains("depends on 2 components"));
assert!(found[1].message.contains("instability 0.67"));
assert!(metrics(&limits("max_fan_out: 2, max_instability: 0.7"), &edges).is_empty());
}
#[test]
fn a_dependency_is_new_only_when_all_its_imports_changed() {
let edges = [
Edge {
line: 1,
..edge("a", "b")
},
Edge {
line: 2,
..edge("a", "b")
},
Edge {
line: 3,
..edge("a", "c")
},
];
let got = new_dependencies(&edges, |_, line| line != 1);
assert_eq!(got, ["a -> c"]);
}
#[test]
fn orphans_only_in_strict_mode() {
let yaml = |strict: bool| -> Config {
serde_norway::from_str(&format!(
"version: 1\ncomponents:\n a: {{ in: a }}\nstrict: {strict}\n"
))
.unwrap()
};
let files = ["a/x.go".to_string(), "b/y.go".to_string()];
let strict = yaml(true);
let map = ComponentMap::new(&strict).unwrap();
let found = orphans(&strict, &map, &files);
assert_eq!(found.len(), 1);
assert_eq!(found[0].file, "b/y.go");
assert!(orphans(&yaml(false), &map, &files).is_empty());
}
#[test]
fn simple_cycle_and_scoping() {
let edges = [edge("a", "b"), edge("b", "c"), edge("c", "a")];
let found = cycles(&config("true"), &edges);
assert_eq!(
found[0].message,
"component dependency cycle: a -> b -> c -> a"
);
assert!(cycles(&config("[a, b]"), &edges).is_empty());
assert!(cycles(&config("false"), &edges).is_empty());
}
}