use std::path::{Path, PathBuf};
use fallow_config::WorkspaceInfo;
use fallow_core::results::AnalysisResults;
use rustc_hash::FxHashMap;
use super::relative_path;
use crate::codeowners::{self, CodeOwners, UNOWNED_LABEL};
pub enum OwnershipResolver {
Owner(CodeOwners),
Directory,
Package(PackageResolver),
}
pub struct PackageResolver {
workspaces: Vec<(PathBuf, String)>,
}
const ROOT_PACKAGE_LABEL: &str = "(root)";
impl PackageResolver {
pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
let mut ws: Vec<(PathBuf, String)> = workspaces
.iter()
.map(|w| {
let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
(rel.to_path_buf(), w.name.clone())
})
.collect();
ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
Self { workspaces: ws }
}
fn resolve(&self, rel_path: &Path) -> &str {
self.workspaces
.iter()
.find(|(root, _)| rel_path.starts_with(root))
.map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
}
}
impl OwnershipResolver {
pub fn resolve(&self, rel_path: &Path) -> String {
match self {
Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
Self::Directory => codeowners::directory_group(rel_path).to_string(),
Self::Package(pr) => pr.resolve(rel_path).to_string(),
}
}
pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
match self {
Self::Owner(co) => {
if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
(owner.to_string(), Some(rule.to_string()))
} else {
(UNOWNED_LABEL.to_string(), None)
}
}
Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
}
}
pub fn mode_label(&self) -> &'static str {
match self {
Self::Owner(_) => "owner",
Self::Directory => "directory",
Self::Package(_) => "package",
}
}
}
pub struct ResultGroup {
pub key: String,
pub results: AnalysisResults,
}
pub fn group_analysis_results(
results: &AnalysisResults,
root: &Path,
resolver: &OwnershipResolver,
) -> Vec<ResultGroup> {
let mut groups: FxHashMap<String, AnalysisResults> = FxHashMap::default();
let key_for = |path: &Path| -> String { resolver.resolve(relative_path(path, root)) };
for item in &results.unused_files {
groups
.entry(key_for(&item.path))
.or_default()
.unused_files
.push(item.clone());
}
for item in &results.unused_exports {
groups
.entry(key_for(&item.path))
.or_default()
.unused_exports
.push(item.clone());
}
for item in &results.unused_types {
groups
.entry(key_for(&item.path))
.or_default()
.unused_types
.push(item.clone());
}
for item in &results.unused_enum_members {
groups
.entry(key_for(&item.path))
.or_default()
.unused_enum_members
.push(item.clone());
}
for item in &results.unused_class_members {
groups
.entry(key_for(&item.path))
.or_default()
.unused_class_members
.push(item.clone());
}
for item in &results.unresolved_imports {
groups
.entry(key_for(&item.path))
.or_default()
.unresolved_imports
.push(item.clone());
}
for item in &results.unused_dependencies {
groups
.entry(key_for(&item.path))
.or_default()
.unused_dependencies
.push(item.clone());
}
for item in &results.unused_dev_dependencies {
groups
.entry(key_for(&item.path))
.or_default()
.unused_dev_dependencies
.push(item.clone());
}
for item in &results.unused_optional_dependencies {
groups
.entry(key_for(&item.path))
.or_default()
.unused_optional_dependencies
.push(item.clone());
}
for item in &results.type_only_dependencies {
groups
.entry(key_for(&item.path))
.or_default()
.type_only_dependencies
.push(item.clone());
}
for item in &results.test_only_dependencies {
groups
.entry(key_for(&item.path))
.or_default()
.test_only_dependencies
.push(item.clone());
}
for item in &results.unlisted_dependencies {
let key = item
.imported_from
.first()
.map_or_else(|| UNOWNED_LABEL.to_string(), |site| key_for(&site.path));
groups
.entry(key)
.or_default()
.unlisted_dependencies
.push(item.clone());
}
for item in &results.duplicate_exports {
let key = item
.locations
.first()
.map_or_else(|| UNOWNED_LABEL.to_string(), |loc| key_for(&loc.path));
groups
.entry(key)
.or_default()
.duplicate_exports
.push(item.clone());
}
for item in &results.circular_dependencies {
let key = item
.files
.first()
.map_or_else(|| UNOWNED_LABEL.to_string(), |f| key_for(f));
groups
.entry(key)
.or_default()
.circular_dependencies
.push(item.clone());
}
for item in &results.boundary_violations {
groups
.entry(key_for(&item.from_path))
.or_default()
.boundary_violations
.push(item.clone());
}
for item in &results.stale_suppressions {
groups
.entry(key_for(&item.path))
.or_default()
.stale_suppressions
.push(item.clone());
}
let mut sorted: Vec<_> = groups
.into_iter()
.map(|(key, results)| ResultGroup { key, results })
.collect();
sorted.sort_by(|a, b| {
let a_unowned = a.key == UNOWNED_LABEL;
let b_unowned = b.key == UNOWNED_LABEL;
match (a_unowned, b_unowned) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => b
.results
.total_issues()
.cmp(&a.results.total_issues())
.then_with(|| a.key.cmp(&b.key)),
}
});
sorted
}
pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
resolver.resolve(relative_path(path, root))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use fallow_core::results::*;
use super::*;
use crate::codeowners::CodeOwners;
fn root() -> PathBuf {
PathBuf::from("/root")
}
fn unused_file(path: &str) -> UnusedFile {
UnusedFile {
path: PathBuf::from(path),
}
}
fn unused_export(path: &str, name: &str) -> UnusedExport {
UnusedExport {
path: PathBuf::from(path),
export_name: name.to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
}
}
fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependency {
UnlistedDependency {
package_name: name.to_string(),
imported_from: sites,
}
}
fn import_site(path: &str) -> ImportSite {
ImportSite {
path: PathBuf::from(path),
line: 1,
col: 0,
}
}
#[test]
fn empty_results_returns_empty_vec() {
let results = AnalysisResults::default();
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert!(groups.is_empty());
}
#[test]
fn single_group_all_same_directory() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/a.ts"));
results.unused_files.push(unused_file("/root/src/b.ts"));
results
.unused_exports
.push(unused_export("/root/src/c.ts", "foo"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[0].results.unused_files.len(), 2);
assert_eq!(groups[0].results.unused_exports.len(), 1);
assert_eq!(groups[0].results.total_issues(), 3);
}
#[test]
fn multiple_groups_split_by_directory() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/a.ts"));
results.unused_files.push(unused_file("/root/lib/b.ts"));
results
.unused_exports
.push(unused_export("/root/src/c.ts", "bar"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 2);
let src_group = groups.iter().find(|g| g.key == "src").unwrap();
let lib_group = groups.iter().find(|g| g.key == "lib").unwrap();
assert_eq!(src_group.results.total_issues(), 2);
assert_eq!(lib_group.results.total_issues(), 1);
}
#[test]
fn sort_order_descending_by_total_issues() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/lib/a.ts"));
results.unused_files.push(unused_file("/root/src/a.ts"));
results.unused_files.push(unused_file("/root/src/b.ts"));
results
.unused_exports
.push(unused_export("/root/src/c.ts", "x"));
results.unused_files.push(unused_file("/root/test/a.ts"));
results.unused_files.push(unused_file("/root/test/b.ts"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[0].results.total_issues(), 3);
assert_eq!(groups[1].key, "test");
assert_eq!(groups[1].results.total_issues(), 2);
assert_eq!(groups[2].key, "lib");
assert_eq!(groups[2].results.total_issues(), 1);
}
#[test]
fn sort_order_alphabetical_tiebreaker() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/beta/a.ts"));
results.unused_files.push(unused_file("/root/alpha/a.ts"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].key, "alpha");
assert_eq!(groups[1].key, "beta");
}
#[test]
fn unowned_sorts_last_regardless_of_count() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/a.ts"));
results
.unlisted_dependencies
.push(unlisted_dep("pkg-a", vec![]));
results
.unlisted_dependencies
.push(unlisted_dep("pkg-b", vec![]));
results
.unlisted_dependencies
.push(unlisted_dep("pkg-c", vec![]));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[1].key, UNOWNED_LABEL);
assert_eq!(groups[1].results.total_issues(), 3);
}
#[test]
fn unlisted_dep_empty_imported_from_goes_to_unowned() {
let mut results = AnalysisResults::default();
results
.unlisted_dependencies
.push(unlisted_dep("missing-pkg", vec![]));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, UNOWNED_LABEL);
assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
}
#[test]
fn unlisted_dep_with_import_site_goes_to_directory() {
let mut results = AnalysisResults::default();
results.unlisted_dependencies.push(unlisted_dep(
"lodash",
vec![import_site("/root/src/util.ts")],
));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
}
#[test]
fn directory_mode_groups_by_first_path_component() {
let mut results = AnalysisResults::default();
results
.unused_files
.push(unused_file("/root/packages/ui/Button.ts"));
results
.unused_files
.push(unused_file("/root/packages/auth/login.ts"));
results
.unused_exports
.push(unused_export("/root/apps/web/index.ts", "main"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 2);
let pkgs = groups.iter().find(|g| g.key == "packages").unwrap();
let apps = groups.iter().find(|g| g.key == "apps").unwrap();
assert_eq!(pkgs.results.total_issues(), 2);
assert_eq!(apps.results.total_issues(), 1);
}
#[test]
fn owner_mode_groups_by_codeowners_owner() {
let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/app.ts"));
results.unused_files.push(unused_file("/root/README.md"));
let groups = group_analysis_results(&results, &root(), &resolver);
assert_eq!(groups.len(), 2);
let frontend = groups.iter().find(|g| g.key == "@frontend").unwrap();
let default = groups.iter().find(|g| g.key == "@default").unwrap();
assert_eq!(frontend.results.unused_files.len(), 1);
assert_eq!(default.results.unused_files.len(), 1);
}
#[test]
fn owner_mode_unmatched_goes_to_unowned() {
let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/README.md"));
let groups = group_analysis_results(&results, &root(), &resolver);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, UNOWNED_LABEL);
}
#[test]
fn boundary_violations_grouped_by_from_path() {
let mut results = AnalysisResults::default();
results.boundary_violations.push(BoundaryViolation {
from_path: PathBuf::from("/root/src/bad.ts"),
to_path: PathBuf::from("/root/lib/secret.ts"),
from_zone: "src".to_string(),
to_zone: "lib".to_string(),
import_specifier: "../lib/secret".to_string(),
line: 1,
col: 0,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[0].results.boundary_violations.len(), 1);
}
#[test]
fn circular_dep_empty_files_goes_to_unowned() {
let mut results = AnalysisResults::default();
results.circular_dependencies.push(CircularDependency {
files: vec![],
length: 0,
line: 0,
col: 0,
is_cross_package: false,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, UNOWNED_LABEL);
}
#[test]
fn circular_dep_uses_first_file() {
let mut results = AnalysisResults::default();
results.circular_dependencies.push(CircularDependency {
files: vec![
PathBuf::from("/root/src/a.ts"),
PathBuf::from("/root/lib/b.ts"),
],
length: 2,
line: 1,
col: 0,
is_cross_package: false,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "src");
}
#[test]
fn duplicate_exports_empty_locations_goes_to_unowned() {
let mut results = AnalysisResults::default();
results.duplicate_exports.push(DuplicateExport {
export_name: "dup".to_string(),
locations: vec![],
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, UNOWNED_LABEL);
}
#[test]
fn resolve_owner_returns_directory() {
let owner = resolve_owner(
Path::new("/root/src/file.ts"),
&root(),
&OwnershipResolver::Directory,
);
assert_eq!(owner, "src");
}
#[test]
fn resolve_owner_returns_codeowner() {
let co = CodeOwners::parse("/src/ @team\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
assert_eq!(owner, "@team");
}
#[test]
fn mode_label_owner() {
let co = CodeOwners::parse("").unwrap();
let resolver = OwnershipResolver::Owner(co);
assert_eq!(resolver.mode_label(), "owner");
}
#[test]
fn mode_label_directory() {
assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
}
#[test]
fn mode_label_package() {
let pr = PackageResolver { workspaces: vec![] };
assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
}
#[test]
fn package_resolver_matches_longest_prefix() {
let ws = vec![
fallow_config::WorkspaceInfo {
name: "packages/ui".to_string(),
root: PathBuf::from("/root/packages/ui"),
is_internal_dependency: false,
},
fallow_config::WorkspaceInfo {
name: "packages".to_string(),
root: PathBuf::from("/root/packages"),
is_internal_dependency: false,
},
];
let pr = PackageResolver::new(Path::new("/root"), &ws);
assert_eq!(
pr.resolve(Path::new("packages/ui/Button.ts")),
"packages/ui"
);
}
#[test]
fn package_resolver_root_fallback() {
let ws = vec![fallow_config::WorkspaceInfo {
name: "packages/ui".to_string(),
root: PathBuf::from("/root/packages/ui"),
is_internal_dependency: false,
}];
let pr = PackageResolver::new(Path::new("/root"), &ws);
assert_eq!(pr.resolve(Path::new("src/app.ts")), ROOT_PACKAGE_LABEL);
}
#[test]
fn package_mode_groups_by_workspace() {
let ws = vec![
fallow_config::WorkspaceInfo {
name: "ui".to_string(),
root: PathBuf::from("/root/packages/ui"),
is_internal_dependency: false,
},
fallow_config::WorkspaceInfo {
name: "auth".to_string(),
root: PathBuf::from("/root/packages/auth"),
is_internal_dependency: false,
},
];
let pr = PackageResolver::new(Path::new("/root"), &ws);
let resolver = OwnershipResolver::Package(pr);
let mut results = AnalysisResults::default();
results
.unused_files
.push(unused_file("/root/packages/ui/Button.ts"));
results
.unused_files
.push(unused_file("/root/packages/auth/login.ts"));
results.unused_files.push(unused_file("/root/src/main.ts"));
let groups = group_analysis_results(&results, &root(), &resolver);
assert_eq!(groups.len(), 3);
let ui_group = groups.iter().find(|g| g.key == "ui");
let auth_group = groups.iter().find(|g| g.key == "auth");
let root_group = groups.iter().find(|g| g.key == ROOT_PACKAGE_LABEL);
assert!(ui_group.is_some());
assert!(auth_group.is_some());
assert!(root_group.is_some());
}
#[test]
fn resolve_with_rule_directory_mode_no_rule() {
let (key, rule) = OwnershipResolver::Directory.resolve_with_rule(Path::new("src/file.ts"));
assert_eq!(key, "src");
assert!(rule.is_none());
}
#[test]
fn resolve_with_rule_owner_mode_with_match() {
let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
assert_eq!(key, "@frontend");
assert!(rule.is_some());
assert!(rule.unwrap().contains("src"));
}
#[test]
fn resolve_with_rule_owner_mode_no_match() {
let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let (key, rule) = resolver.resolve_with_rule(Path::new("docs/readme.md"));
assert_eq!(key, UNOWNED_LABEL);
assert!(rule.is_none());
}
#[test]
fn resolve_with_rule_package_mode_no_rule() {
let pr = PackageResolver { workspaces: vec![] };
let resolver = OwnershipResolver::Package(pr);
let (key, rule) = resolver.resolve_with_rule(Path::new("src/file.ts"));
assert_eq!(key, ROOT_PACKAGE_LABEL);
assert!(rule.is_none());
}
#[test]
fn group_unused_optional_deps() {
let mut results = AnalysisResults::default();
results.unused_optional_dependencies.push(UnusedDependency {
package_name: "fsevents".to_string(),
location: fallow_core::results::DependencyLocation::OptionalDependencies,
path: PathBuf::from("/root/package.json"),
line: 5,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].results.unused_optional_dependencies.len(), 1);
}
#[test]
fn group_type_only_deps() {
let mut results = AnalysisResults::default();
results
.type_only_dependencies
.push(fallow_core::results::TypeOnlyDependency {
package_name: "zod".to_string(),
path: PathBuf::from("/root/package.json"),
line: 8,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].results.type_only_dependencies.len(), 1);
}
#[test]
fn group_test_only_deps() {
let mut results = AnalysisResults::default();
results
.test_only_dependencies
.push(fallow_core::results::TestOnlyDependency {
package_name: "vitest".to_string(),
path: PathBuf::from("/root/package.json"),
line: 10,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].results.test_only_dependencies.len(), 1);
}
#[test]
fn group_unused_enum_members() {
let mut results = AnalysisResults::default();
results
.unused_enum_members
.push(fallow_core::results::UnusedMember {
path: PathBuf::from("/root/src/types.ts"),
parent_name: "Status".to_string(),
member_name: "Deprecated".to_string(),
kind: fallow_core::extract::MemberKind::EnumMember,
line: 5,
col: 0,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[0].results.unused_enum_members.len(), 1);
}
#[test]
fn group_unused_class_members() {
let mut results = AnalysisResults::default();
results
.unused_class_members
.push(fallow_core::results::UnusedMember {
path: PathBuf::from("/root/lib/service.ts"),
parent_name: "UserService".to_string(),
member_name: "legacyMethod".to_string(),
kind: fallow_core::extract::MemberKind::ClassMethod,
line: 42,
col: 0,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "lib");
assert_eq!(groups[0].results.unused_class_members.len(), 1);
}
#[test]
fn group_unresolved_imports() {
let mut results = AnalysisResults::default();
results
.unresolved_imports
.push(fallow_core::results::UnresolvedImport {
path: PathBuf::from("/root/src/app.ts"),
specifier: "./missing".to_string(),
line: 1,
col: 0,
specifier_col: 0,
});
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].key, "src");
assert_eq!(groups[0].results.unresolved_imports.len(), 1);
}
}