use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_config::{IgnoreDependencyMatcher, PackageJson, ResolvedConfig};
use crate::discover::FileId;
use crate::extract::ModuleInfo;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::results::{
DependencyLocation, DevDependencyInProduction, ImportSite, TestOnlyDependency,
TypeOnlyDependency, UnlistedDependency, UnresolvedImport, UnusedDependency,
};
use crate::suppress::{IssueKind, SuppressionContext};
use super::bundle_externalization::source_sets_packages_external;
use super::gitignored_targets::GitignoredTargets;
use super::package_json_utils::{find_dep_line_in_json, read_pkg_json_content};
use super::predicates::{
is_builtin_module, is_config_file, is_implicit_dependency, is_path_alias, is_virtual_module,
};
use super::{LineOffsetsMap, byte_offset_to_line_col};
use crate::plugins::{CompiledPathRule, ProvidedDependencyRule};
#[must_use]
pub fn matches_virtual_prefix(prefix: &str, spec: &str) -> bool {
spec.starts_with(prefix) || prefix.strip_suffix('/').is_some_and(|base| spec == base)
}
fn is_package_json_ignored(ws_pkg_path: &Path, config: &ResolvedConfig) -> bool {
is_package_json_ignored_under(ws_pkg_path, &config.root, &config.ignore_patterns)
}
fn is_package_json_ignored_under(
ws_pkg_path: &Path,
root: &Path,
ignore_patterns: &fallow_config::IgnorePatternSet,
) -> bool {
let relative = ws_pkg_path.strip_prefix(root).unwrap_or(ws_pkg_path);
ignore_patterns.is_match(relative)
}
pub struct DepCategoryConfig {
pub location: DependencyLocation,
pub check_implicit: bool,
pub check_known_tooling: bool,
pub check_plugin_tooling: bool,
pub plugin_tooling_needs_evidence: bool,
}
pub struct SharedDepSets<'a> {
pub plugin_referenced: &'a FxHashSet<&'a str>,
pub package_plugin_referenced: &'a FxHashSet<&'a str>,
pub plugin_tooling: &'a FxHashSet<&'a str>,
pub credited_plugin_tooling: &'a FxHashSet<&'a str>,
pub declared_packages: &'a FxHashSet<&'a str>,
pub project_root: &'a Path,
pub script_used: &'a FxHashSet<&'a str>,
pub ignore_deps: &'a IgnoreDependencyMatcher,
}
struct PeerDependencyResolver {
cache: FxHashMap<(PathBuf, String), Vec<InstalledPeer>>,
}
#[derive(Clone)]
struct InstalledPeer {
name: String,
optional: bool,
}
impl PeerDependencyResolver {
fn new() -> Self {
Self {
cache: FxHashMap::default(),
}
}
fn peer_dependency_closure<'b>(
&mut self,
package_root: &Path,
seeds: impl IntoIterator<Item = &'b str>,
listed: &FxHashSet<String>,
) -> FxHashMap<String, Vec<String>> {
let mut hosts_by_peer: FxHashMap<String, Vec<String>> = FxHashMap::default();
let mut expanded = FxHashSet::default();
let mut queue: Vec<String> = seeds.into_iter().map(str::to_string).collect();
while let Some(package_name) = queue.pop() {
if !expanded.insert(package_name.clone()) {
continue;
}
for peer in self.peer_dependencies_for(package_root, &package_name) {
if !peer.optional || listed.contains(peer.name.as_str()) {
queue.push(peer.name.clone());
}
hosts_by_peer
.entry(peer.name)
.or_default()
.push(package_name.clone());
}
}
hosts_by_peer
}
fn peer_dependencies_for(
&mut self,
package_root: &Path,
package_name: &str,
) -> Vec<InstalledPeer> {
let key = (package_root.to_path_buf(), package_name.to_string());
if let Some(cached) = self.cache.get(&key) {
return cached.clone();
}
let peer_dependencies: Vec<InstalledPeer> =
find_installed_package_json(package_root, package_name)
.and_then(|path| PackageJson::load(&path).ok())
.map(|pkg| {
pkg.peer_dependency_names()
.into_iter()
.map(|name| InstalledPeer {
optional: pkg.peer_dependency_is_optional(&name),
name,
})
.collect()
})
.unwrap_or_default();
self.cache.insert(key, peer_dependencies.clone());
peer_dependencies
}
}
pub fn peer_dependency_hosts(
graph: &ModuleGraph,
project_root: &Path,
workspace_roots: &[&Path],
ignore_patterns: &fallow_config::IgnorePatternSet,
package_name: &str,
) -> Vec<String> {
let root_declared = PackageJson::load(&project_root.join("package.json"))
.map(|pkg| pkg.all_dependency_names())
.unwrap_or_default();
let workspace_manifests: Vec<(&Path, Vec<String>)> = workspace_roots
.iter()
.copied()
.filter_map(|ws_root| {
let pkg_path = ws_root.join("package.json");
if is_package_json_ignored_under(&pkg_path, project_root, ignore_patterns) {
return None;
}
let pkg = PackageJson::load(&pkg_path).ok()?;
Some((ws_root, pkg.all_dependency_names()))
})
.collect();
let listed = closure_listed_names(
&root_declared,
workspace_manifests
.iter()
.flat_map(|(_, declared)| declared.iter()),
);
let workspace_roots: Vec<&Path> = workspace_manifests
.iter()
.map(|(ws_root, _)| *ws_root)
.collect();
let mut resolver = PeerDependencyResolver::new();
let mut hosts = resolver
.peer_dependency_closure(
project_root,
graph.package_usage.keys().map(String::as_str),
&listed,
)
.remove(package_name)
.unwrap_or_default();
let ownership = WorkspaceOwnershipIndex::new(graph, &workspace_roots);
let used_by_workspace = collect_workspace_used_packages(graph, &workspace_roots, &ownership);
for ws_root in &workspace_roots {
let Some(used) = used_by_workspace.get(ws_root) else {
continue;
};
if let Some(ws_hosts) = resolver
.peer_dependency_closure(ws_root, used.iter().copied(), &listed)
.remove(package_name)
{
hosts.extend(ws_hosts);
}
}
hosts.sort_unstable();
hosts.dedup();
hosts
}
fn find_installed_package_json(package_root: &Path, package_name: &str) -> Option<PathBuf> {
for base in package_root.ancestors() {
let candidate = node_modules_package_json(base, package_name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn node_modules_package_json(base: &Path, package_name: &str) -> PathBuf {
let mut path = base.join("node_modules");
for segment in package_name.split('/') {
path.push(segment);
}
path.join("package.json")
}
struct WorkspaceOwnershipIndex {
workspace_by_file: Vec<Option<usize>>,
ancestors_by_workspace: Vec<Vec<usize>>,
}
impl WorkspaceOwnershipIndex {
fn new(graph: &ModuleGraph, workspace_roots: &[&Path]) -> Self {
use rayon::prelude::*;
let by_root: FxHashMap<&Path, usize> = workspace_roots
.iter()
.enumerate()
.map(|(index, root)| (*root, index))
.collect();
let workspace_by_file = graph
.modules
.par_iter()
.map(|module| {
module
.path
.ancestors()
.find_map(|ancestor| by_root.get(ancestor).copied())
})
.collect();
let ancestors_by_workspace = workspace_roots
.iter()
.map(|root| {
root.ancestors()
.skip(1)
.filter_map(|ancestor| by_root.get(ancestor).copied())
.collect()
})
.collect();
Self {
workspace_by_file,
ancestors_by_workspace,
}
}
fn ancestors_of(&self, workspace_index: usize) -> &[usize] {
self.ancestors_by_workspace
.get(workspace_index)
.map_or(&[], Vec::as_slice)
}
fn workspace_index_for_file(&self, file_id: FileId) -> Option<usize> {
self.workspace_by_file
.get(file_id.0 as usize)
.copied()
.flatten()
}
fn root_for_file<'a>(&self, file_id: FileId, workspace_roots: &[&'a Path]) -> Option<&'a Path> {
self.workspace_index_for_file(file_id)
.and_then(|index| workspace_roots.get(index).copied())
}
}
struct WorkspaceManifest<'a> {
root: &'a Path,
name: String,
is_private: bool,
declared: FxHashSet<String>,
installed: FxHashSet<String>,
shipped: FxHashSet<String>,
externalizes_packages: bool,
script_programs: Vec<String>,
}
fn script_program_args(pkg: &PackageJson) -> Vec<String> {
pkg.scripts
.iter()
.flat_map(|scripts| scripts.values())
.flat_map(|script| crate::scripts::script_program_files(script))
.collect()
}
fn script_file_paths(manifest: &WorkspaceManifest<'_>) -> FxHashSet<PathBuf> {
manifest
.script_programs
.iter()
.map(|file| normalize_lexically(&resolve_script_file(&manifest.root.join(file))))
.collect()
}
fn resolve_script_file(base: &Path) -> PathBuf {
if base.is_file() {
return base.to_path_buf();
}
let with_extension = crate::discover::SOURCE_EXTENSIONS
.iter()
.find_map(|extension| {
let mut candidate = base.as_os_str().to_owned();
candidate.push(".");
candidate.push(extension);
let candidate = PathBuf::from(candidate);
candidate.is_file().then_some(candidate)
});
let index = || {
crate::discover::SOURCE_EXTENSIONS
.iter()
.find_map(|extension| {
let candidate = base.join(format!("index.{extension}"));
candidate.is_file().then_some(candidate)
})
};
with_extension
.or_else(index)
.unwrap_or_else(|| base.to_path_buf())
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other),
}
}
normalized
}
fn shipped_dependency_names(pkg: &PackageJson) -> FxHashSet<String> {
pkg.production_dependency_names()
.into_iter()
.chain(pkg.optional_dependency_names())
.chain(
pkg.peer_dependencies
.iter()
.flat_map(|peers| peers.keys().cloned()),
)
.collect()
}
fn read_workspace_manifests<'a>(
workspaces: &'a [fallow_config::WorkspaceInfo],
config: &ResolvedConfig,
) -> Vec<WorkspaceManifest<'a>> {
use rayon::prelude::*;
workspaces
.par_iter()
.filter_map(|workspace| {
let (_, _, pkg) = read_workspace_package(workspace, config)?;
Some(WorkspaceManifest {
root: workspace.root.as_path(),
name: pkg.name.clone().unwrap_or_else(|| workspace.name.clone()),
is_private: pkg.private == Some(true),
declared: pkg.all_dependency_names().into_iter().collect(),
installed: pkg
.production_dependency_names()
.into_iter()
.chain(pkg.dev_dependency_names())
.chain(pkg.optional_dependency_names())
.collect(),
shipped: shipped_dependency_names(&pkg),
externalizes_packages: pkg.scripts.as_ref().is_some_and(|scripts| {
scripts
.values()
.any(|script| crate::scripts::script_externalizes_packages(script))
}),
script_programs: script_program_args(&pkg),
})
})
.collect()
}
fn dependency_owning_workspace_roots<'a>(manifests: &[WorkspaceManifest<'a>]) -> Vec<&'a Path> {
manifests.iter().map(|manifest| manifest.root).collect()
}
fn collect_bundled_workspace_usage<'a>(
manifests: &[WorkspaceManifest<'a>],
workspace_used_packages: &FxHashMap<&'a Path, FxHashSet<&'a str>>,
externalizing: &FxHashSet<usize>,
) -> FxHashMap<&'a Path, FxHashSet<&'a str>> {
let private_by_name: FxHashMap<&str, usize> = manifests
.iter()
.enumerate()
.filter(|(_, manifest)| manifest.is_private)
.map(|(index, manifest)| (manifest.name.as_str(), index))
.collect();
if private_by_name.is_empty() {
return FxHashMap::default();
}
use rayon::prelude::*;
manifests
.par_iter()
.enumerate()
.filter(|(index, _)| !externalizing.contains(index))
.map(|(index, consumer)| {
let bundled = bundled_packages_for(
index,
consumer,
manifests,
&private_by_name,
workspace_used_packages,
);
(consumer.root, bundled)
})
.filter(|(_, bundled)| !bundled.is_empty())
.collect()
}
fn bundled_packages_for<'a>(
consumer_index: usize,
consumer: &WorkspaceManifest<'a>,
manifests: &[WorkspaceManifest<'a>],
private_by_name: &FxHashMap<&str, usize>,
workspace_used_packages: &FxHashMap<&'a Path, FxHashSet<&'a str>>,
) -> FxHashSet<&'a str> {
let private_siblings = |manifest: &WorkspaceManifest<'a>| -> Vec<usize> {
manifest
.declared
.iter()
.filter_map(|dep| private_by_name.get(dep.as_str()).copied())
.collect()
};
let mut bundled: FxHashSet<&str> = FxHashSet::default();
let mut visited: FxHashSet<usize> = FxHashSet::default();
visited.insert(consumer_index);
let mut pending = private_siblings(consumer);
while let Some(index) = pending.pop() {
if !visited.insert(index) {
continue;
}
let sibling = &manifests[index];
if let Some(imported) = workspace_used_packages.get(&sibling.root) {
bundled.extend(
imported
.iter()
.copied()
.filter(|package| sibling.shipped.contains(*package)),
);
}
pending.extend(private_siblings(sibling));
}
bundled
}
fn collect_externalizing_workspaces(
graph: &ModuleGraph,
manifests: &[WorkspaceManifest<'_>],
ownership: &WorkspaceOwnershipIndex,
) -> FxHashSet<usize> {
let mut externalizing: FxHashSet<usize> = manifests
.iter()
.enumerate()
.filter(|(_, manifest)| manifest.externalizes_packages)
.map(|(index, _)| index)
.collect();
let Some(file_ids) = graph.package_usage.get("esbuild") else {
return externalizing;
};
let candidates: FxHashSet<usize> = file_ids
.iter()
.filter_map(|id| ownership.workspace_index_for_file(*id))
.filter(|index| !externalizing.contains(index))
.collect();
if candidates.is_empty() {
return externalizing;
}
let build_files = collect_build_files(graph, manifests, ownership, &candidates);
let mut checked: FxHashSet<FileId> = FxHashSet::default();
for id in file_ids {
let Some(index) = ownership.workspace_index_for_file(*id) else {
continue;
};
if externalizing.contains(&index) || !checked.insert(*id) {
continue;
}
if !build_files.contains(id) {
continue;
}
let Some(module) = graph.modules.get(id.0 as usize) else {
continue;
};
if std::fs::read_to_string(&module.path)
.is_ok_and(|source| source_sets_packages_external(&source, &module.path))
{
externalizing.insert(index);
}
}
externalizing
}
fn collect_build_files(
graph: &ModuleGraph,
manifests: &[WorkspaceManifest<'_>],
ownership: &WorkspaceOwnershipIndex,
workspaces: &FxHashSet<usize>,
) -> FxHashSet<FileId> {
let script_files: FxHashMap<usize, FxHashSet<PathBuf>> = workspaces
.iter()
.filter_map(|index| {
let manifest = manifests.get(*index)?;
(!manifest.script_programs.is_empty()).then(|| (*index, script_file_paths(manifest)))
})
.collect();
if script_files.is_empty() {
return FxHashSet::default();
}
let mut pending: Vec<FileId> = graph
.modules
.iter()
.filter(|module| {
ownership
.workspace_index_for_file(module.file_id)
.and_then(|index| script_files.get(&index))
.is_some_and(|files| files.contains(&normalize_lexically(&module.path)))
})
.map(|module| module.file_id)
.collect();
let mut build_files: FxHashSet<FileId> = pending.iter().copied().collect();
while let Some(id) = pending.pop() {
let workspace = ownership.workspace_index_for_file(id);
for (target, symbols) in graph.outgoing_edge_symbols(id) {
let runs_target = symbols.is_empty()
|| symbols
.iter()
.any(|symbol| !symbol.is_type_only && symbol.loads_target());
if runs_target
&& ownership.workspace_index_for_file(target) == workspace
&& build_files.insert(target)
{
pending.push(target);
}
}
}
build_files
}
fn collect_workspace_used_packages<'a>(
graph: &'a ModuleGraph,
workspace_roots: &[&'a Path],
ownership: &WorkspaceOwnershipIndex,
) -> FxHashMap<&'a Path, FxHashSet<&'a str>> {
let mut by_ws: FxHashMap<&Path, FxHashSet<&str>> = workspace_roots
.iter()
.map(|root| (*root, FxHashSet::default()))
.collect();
for (package_name, file_ids) in &graph.package_usage {
for id in file_ids {
if let Some(ws_path) = ownership.root_for_file(*id, workspace_roots) {
by_ws
.entry(ws_path)
.or_default()
.insert(package_name.as_str());
}
}
}
by_ws
}
fn collect_ancestor_credited_packages<'a>(
graph: &'a ModuleGraph,
config: &ResolvedConfig,
manifests: &[WorkspaceManifest<'a>],
ownership: &WorkspaceOwnershipIndex,
) -> FxHashMap<&'a Path, FxHashSet<&'a str>> {
let mut credited: FxHashMap<&Path, FxHashSet<&str>> = FxHashMap::default();
for (package_name, file_ids) in &graph.package_usage {
for id in file_ids {
if let Some(ancestor) =
ancestor_satisfying_import(graph, config, manifests, ownership, package_name, *id)
{
credited
.entry(ancestor.root)
.or_default()
.insert(package_name.as_str());
}
}
}
credited
}
fn ancestor_satisfying_import<'m, 'a>(
graph: &ModuleGraph,
config: &ResolvedConfig,
manifests: &'m [WorkspaceManifest<'a>],
ownership: &WorkspaceOwnershipIndex,
package_name: &str,
id: FileId,
) -> Option<&'m WorkspaceManifest<'a>> {
let index = ownership.workspace_index_for_file(id)?;
let owner = manifests.get(index)?;
if owner.installed.contains(package_name) {
return None;
}
let module = graph.modules.get(id.0 as usize)?;
if !accepts_ancestor_declaration(owner.is_private, module, config) {
return None;
}
ownership
.ancestors_of(index)
.iter()
.filter_map(|ancestor| manifests.get(*ancestor))
.find(|ancestor| ancestor.installed.contains(package_name))
}
fn collect_root_credited_packages<'a>(
graph: &'a ModuleGraph,
manifests: &[WorkspaceManifest<'_>],
ownership: &WorkspaceOwnershipIndex,
) -> FxHashSet<&'a str> {
graph
.package_usage
.iter()
.filter(|(package_name, file_ids)| {
file_ids.iter().any(|id| {
workspace_chain_installer(manifests, ownership, package_name, *id).is_none()
})
})
.map(|(package_name, _)| package_name.as_str())
.collect()
}
fn collect_nearer_manifest_credit<'a>(
graph: &'a ModuleGraph,
manifests: &[WorkspaceManifest<'_>],
ownership: &WorkspaceOwnershipIndex,
) -> FxHashMap<&'a str, Vec<PathBuf>> {
let mut credit: FxHashMap<&str, Vec<PathBuf>> = FxHashMap::default();
for (package_name, file_ids) in &graph.package_usage {
let mut roots: Vec<PathBuf> = file_ids
.iter()
.filter_map(|id| workspace_chain_installer(manifests, ownership, package_name, *id))
.map(Path::to_path_buf)
.collect();
if roots.is_empty() {
continue;
}
roots.sort();
roots.dedup();
credit.insert(package_name.as_str(), roots);
}
credit
}
fn workspace_chain_installer<'a>(
manifests: &[WorkspaceManifest<'a>],
ownership: &WorkspaceOwnershipIndex,
package_name: &str,
id: FileId,
) -> Option<&'a Path> {
let index = ownership.workspace_index_for_file(id)?;
std::iter::once(index)
.chain(ownership.ancestors_of(index).iter().copied())
.filter_map(|workspace| manifests.get(workspace))
.find(|manifest| manifest.installed.contains(package_name))
.map(|manifest| manifest.root)
}
struct RootImporters<'a> {
graph: &'a ModuleGraph,
manifests: Vec<WorkspaceManifest<'a>>,
ownership: WorkspaceOwnershipIndex,
}
impl<'a> RootImporters<'a> {
fn new(
graph: &'a ModuleGraph,
config: &ResolvedConfig,
workspaces: &'a [fallow_config::WorkspaceInfo],
) -> Self {
let manifests = read_workspace_manifests(workspaces, config);
let ownership =
WorkspaceOwnershipIndex::new(graph, &dependency_owning_workspace_roots(&manifests));
Self {
graph,
manifests,
ownership,
}
}
fn of(&self, package_name: &str) -> Vec<FileId> {
self.filter(self.graph.package_usage.get(package_name), package_name)
}
fn type_only_of(&self, package_name: &str) -> Vec<FileId> {
self.filter(
self.graph.type_only_package_usage.get(package_name),
package_name,
)
}
fn filter(&self, file_ids: Option<&Vec<FileId>>, package_name: &str) -> Vec<FileId> {
file_ids
.into_iter()
.flatten()
.copied()
.filter(|id| {
workspace_chain_installer(&self.manifests, &self.ownership, package_name, *id)
.is_none()
})
.collect()
}
}
fn shared_dep_sets<'a>(
plugin_referenced: &'a FxHashSet<&'a str>,
package_plugin_referenced: &'a FxHashSet<&'a str>,
plugin_tooling: &'a PluginToolingSets<'a>,
script_used: &'a FxHashSet<&'a str>,
ignore_deps: &'a IgnoreDependencyMatcher,
project_root: &'a Path,
) -> SharedDepSets<'a> {
SharedDepSets {
project_root,
plugin_referenced,
package_plugin_referenced,
plugin_tooling: &plugin_tooling.declared,
credited_plugin_tooling: &plugin_tooling.credited,
declared_packages: &plugin_tooling.declared_packages,
script_used,
ignore_deps,
}
}
#[derive(Default)]
struct PluginToolingSets<'a> {
declared: FxHashSet<&'a str>,
credited: FxHashSet<&'a str>,
declared_packages: FxHashSet<&'a str>,
}
pub struct UnusedCategoryInput<'a> {
pub dep_names: Vec<String>,
pub category: &'a DepCategoryConfig,
pub shared: &'a SharedDepSets<'a>,
pub is_used: &'a dyn Fn(&str) -> bool,
pub used_in_workspaces: &'a dyn Fn(&str) -> Vec<PathBuf>,
pub pkg_path: &'a Path,
pub pkg_content: Option<&'a str>,
}
pub fn collect_unused_for_category(input: UnusedCategoryInput<'_>) -> Vec<UnusedDependency> {
input
.dep_names
.into_iter()
.filter(|dep| !input.shared.ignore_deps.is_declared_ignored(dep))
.filter(|dep| !(input.is_used)(dep))
.filter(|dep| !input.shared.script_used.contains(dep.as_str()))
.filter(|dep| !input.category.check_implicit || !is_implicit_dependency(dep))
.filter(|dep| {
!input.category.check_known_tooling
|| !is_credited_known_tooling(
dep,
input.shared,
input.is_used,
input.pkg_path,
input.pkg_content,
)
})
.filter(|dep| {
let tooling = if input.category.plugin_tooling_needs_evidence {
input.shared.credited_plugin_tooling
} else {
input.shared.plugin_tooling
};
!input.category.check_plugin_tooling || !tooling.contains(dep.as_str())
})
.filter(|dep| !input.shared.plugin_referenced.contains(dep.as_str()))
.filter(|dep| {
!input
.shared
.package_plugin_referenced
.contains(dep.as_str())
})
.map(|dep| {
let line = input
.pkg_content
.map_or(1, |c| find_dep_line_in_json(c, &dep));
let used_in_workspaces = (input.used_in_workspaces)(&dep);
UnusedDependency {
package_name: dep,
location: input.category.location.clone(),
path: input.pkg_path.to_path_buf(),
line,
used_in_workspaces,
declared_and_imported_in: Vec::new(),
}
})
.collect()
}
fn is_credited_known_tooling(
dep: &str,
shared: &SharedDepSets<'_>,
is_used: &dyn Fn(&str) -> bool,
pkg_path: &Path,
pkg_content: Option<&str>,
) -> bool {
if crate::plugins::is_ambient_types_package(dep) {
return true;
}
if let Some(target) = crate::plugins::types_package_target(dep) {
return shared.declared_packages.contains(target.as_str()) || is_used(&target);
}
if let Some(config) = crate::plugins::cli_tooling_config_patterns(dep) {
let manifest =
pkg_content.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok());
return cli_tool_own_config(
dep,
config,
shared.project_root,
pkg_path,
manifest.as_ref(),
)
.is_some();
}
crate::plugins::is_known_tooling_dependency(dep)
}
pub fn cli_tool_own_config(
dep: &str,
config: &[String],
project_root: &Path,
pkg_path: &Path,
manifest: Option<&serde_json::Value>,
) -> Option<PathBuf> {
if manifest.is_some_and(|manifest| manifest.get(dep).is_some()) {
return Some(pkg_path.to_path_buf());
}
if config.is_empty() {
return None;
}
let package_root = pkg_path.parent().unwrap_or(project_root);
let roots: &[&Path] = if package_root == project_root {
&[project_root]
} else {
&[package_root, project_root]
};
crate::plugins::registry::find_config_file(config.iter().map(String::as_str), roots)
}
fn collect_package_workspace_usage(
graph: &ModuleGraph,
workspace_roots: &[&Path],
ownership: &WorkspaceOwnershipIndex,
) -> FxHashMap<String, Vec<PathBuf>> {
let mut usage: FxHashMap<String, Vec<PathBuf>> = FxHashMap::default();
for (package_name, file_ids) in &graph.package_usage {
for id in file_ids {
let Some(ws_root) = ownership.root_for_file(*id, workspace_roots) else {
continue;
};
usage
.entry(package_name.clone())
.or_default()
.push(ws_root.to_path_buf());
}
}
for roots in usage.values_mut() {
roots.sort();
roots.dedup();
}
usage
}
fn used_in_other_workspaces(
package_workspace_usage: &FxHashMap<String, Vec<PathBuf>>,
dep: &str,
declaring_workspace_root: &Path,
) -> Vec<PathBuf> {
package_workspace_usage
.get(dep)
.map_or_else(Vec::new, |roots| {
roots
.iter()
.filter(|root| root.as_path() != declaring_workspace_root)
.cloned()
.collect()
})
}
const fn prod_category() -> DepCategoryConfig {
DepCategoryConfig {
location: DependencyLocation::Dependencies,
check_implicit: true,
check_known_tooling: false,
check_plugin_tooling: true,
plugin_tooling_needs_evidence: false,
}
}
const fn dev_category() -> DepCategoryConfig {
DepCategoryConfig {
location: DependencyLocation::DevDependencies,
check_implicit: false,
check_known_tooling: true,
check_plugin_tooling: true,
plugin_tooling_needs_evidence: true,
}
}
const fn optional_category() -> DepCategoryConfig {
DepCategoryConfig {
location: DependencyLocation::OptionalDependencies,
check_implicit: true,
check_known_tooling: false,
check_plugin_tooling: false,
plugin_tooling_needs_evidence: false,
}
}
fn own_peer_dependency_names(pkg: &PackageJson) -> FxHashSet<&str> {
pkg.peer_dependencies
.as_ref()
.into_iter()
.flat_map(|deps| deps.keys().map(String::as_str))
.collect()
}
fn package_referenced_dependencies_by_path(
plugin_result: &crate::plugins::AggregatedPluginResult,
) -> FxHashMap<PathBuf, FxHashSet<&str>> {
let mut by_path: FxHashMap<PathBuf, FxHashSet<&str>> = FxHashMap::default();
for (pkg_path, dep) in &plugin_result.package_referenced_dependencies {
by_path
.entry(pkg_path.clone())
.or_default()
.insert(dep.as_str());
}
by_path
}
fn plugin_referenced_set(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> FxHashSet<&str> {
plugin_result
.map(|pr| {
pr.referenced_dependencies
.iter()
.map(String::as_str)
.collect()
})
.unwrap_or_default()
}
fn plugin_tooling_set(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> FxHashSet<&str> {
plugin_result
.map(|pr| pr.tooling_dependencies.iter().map(String::as_str).collect())
.unwrap_or_default()
}
fn plugin_tooling_sets<'a>(
plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
root_declared: &'a [String],
) -> PluginToolingSets<'a> {
let Some(plugin_result) = plugin_result else {
return PluginToolingSets {
declared_packages: root_declared.iter().map(String::as_str).collect(),
..PluginToolingSets::default()
};
};
let declared_packages = plugin_result
.dependency_binaries
.declared_packages()
.iter()
.map(String::as_str)
.chain(root_declared.iter().map(String::as_str))
.collect();
let credited = plugin_result
.plugin_tooling
.iter()
.filter(|entry| {
entry
.evidence(&plugin_result.script_used_packages)
.is_some()
})
.flat_map(|entry| entry.dependencies.iter().map(String::as_str))
.collect();
PluginToolingSets {
declared: plugin_tooling_set(Some(plugin_result)),
credited,
declared_packages,
}
}
fn script_used_set(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> FxHashSet<&str> {
plugin_result
.map(|pr| pr.script_used_packages.iter().map(String::as_str).collect())
.unwrap_or_default()
}
pub fn find_unused_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
workspaces: &[fallow_config::WorkspaceInfo],
) -> (
Vec<UnusedDependency>,
Vec<UnusedDependency>,
Vec<UnusedDependency>,
) {
let root_declared = pkg.all_dependency_names();
let scan =
build_unused_dependency_scan(graph, config, plugin_result, workspaces, &root_declared);
let shared = scan.root_shared(config);
let linked_workspaces = fallow_config::link_only_workspace_dependencies(
&config.root,
&config.ignore_patterns,
workspaces,
);
let (mut unused_deps, mut unused_dev_deps, mut unused_optional_deps) =
collect_root_unused_dependencies(pkg, config, &shared, &scan.usage, &linked_workspaces);
let root_flagged = root_flagged_dependencies(
&unused_deps,
&unused_dev_deps,
&unused_optional_deps,
&scan.usage.used_packages,
);
let inputs = scan.workspace_inputs(config, &root_flagged);
append_workspace_unused_dependencies(
workspaces,
&inputs,
&mut unused_deps,
&mut unused_dev_deps,
&mut unused_optional_deps,
);
(unused_deps, unused_dev_deps, unused_optional_deps)
}
struct UnusedDependencyScan<'a> {
plugin_referenced: FxHashSet<&'a str>,
plugin_tooling: PluginToolingSets<'a>,
script_used: FxHashSet<&'a str>,
package_referenced: FxHashMap<PathBuf, FxHashSet<&'a str>>,
empty_package_referenced: FxHashSet<&'a str>,
ignore_deps: &'a IgnoreDependencyMatcher,
usage: DependencyUsageIndices<'a>,
}
impl<'a> UnusedDependencyScan<'a> {
fn root_shared(&'a self, config: &'a ResolvedConfig) -> SharedDepSets<'a> {
shared_dep_sets(
&self.plugin_referenced,
self.package_referenced
.get(&config.root.join("package.json"))
.unwrap_or(&self.empty_package_referenced),
&self.plugin_tooling,
&self.script_used,
self.ignore_deps,
&config.root,
)
}
fn workspace_inputs(
&'a self,
config: &'a ResolvedConfig,
root_flagged: &'a FxHashSet<String>,
) -> WorkspaceUnusedDependencyInputs<'a> {
WorkspaceUnusedDependencyInputs {
config,
package_referenced: &self.package_referenced,
empty_package_referenced: &self.empty_package_referenced,
plugin_referenced: &self.plugin_referenced,
plugin_tooling: &self.plugin_tooling,
script_used: &self.script_used,
ignore_deps: self.ignore_deps,
workspace_used_packages: &self.usage.workspace_used_packages,
bundled_workspace_usage: &self.usage.bundled_workspace_usage,
ancestor_credited_packages: &self.usage.ancestor_credited_packages,
package_workspace_usage: &self.usage.package_workspace_usage,
root_flagged,
closure_listed: &self.usage.closure_listed,
}
}
}
fn build_unused_dependency_scan<'a>(
graph: &'a ModuleGraph,
config: &'a ResolvedConfig,
plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
workspaces: &'a [fallow_config::WorkspaceInfo],
root_declared: &'a [String],
) -> UnusedDependencyScan<'a> {
UnusedDependencyScan {
plugin_referenced: plugin_referenced_set(plugin_result),
plugin_tooling: plugin_tooling_sets(plugin_result, root_declared),
script_used: script_used_set(plugin_result),
package_referenced: plugin_result
.map(package_referenced_dependencies_by_path)
.unwrap_or_default(),
empty_package_referenced: FxHashSet::default(),
ignore_deps: &config.ignore_dependencies,
usage: collect_dependency_usage_indices(graph, config, workspaces, root_declared),
}
}
fn append_workspace_unused_dependencies(
workspaces: &[fallow_config::WorkspaceInfo],
inputs: &WorkspaceUnusedDependencyInputs<'_>,
unused_deps: &mut Vec<UnusedDependency>,
unused_dev_deps: &mut Vec<UnusedDependency>,
unused_optional_deps: &mut Vec<UnusedDependency>,
) {
for (prod, dev, optional) in collect_workspaces_unused_dependencies(workspaces, inputs) {
unused_deps.extend(prod);
unused_dev_deps.extend(dev);
unused_optional_deps.extend(optional);
}
}
type UnusedDependencyTriple = (
Vec<UnusedDependency>,
Vec<UnusedDependency>,
Vec<UnusedDependency>,
);
struct DependencyUsageIndices<'a> {
used_packages: FxHashSet<&'a str>,
root_credited_packages: FxHashSet<&'a str>,
nearer_manifest_credit: FxHashMap<&'a str, Vec<PathBuf>>,
package_workspace_usage: FxHashMap<String, Vec<PathBuf>>,
workspace_used_packages: FxHashMap<&'a Path, FxHashSet<&'a str>>,
bundled_workspace_usage: FxHashMap<&'a Path, FxHashSet<&'a str>>,
ancestor_credited_packages: FxHashMap<&'a Path, FxHashSet<&'a str>>,
root_peer_used: FxHashMap<String, Vec<String>>,
closure_listed: FxHashSet<String>,
}
fn closure_listed_names<'a>(
root_declared: &'a [String],
workspace_declared: impl IntoIterator<Item = &'a String>,
) -> FxHashSet<String> {
root_declared
.iter()
.chain(workspace_declared)
.cloned()
.collect()
}
fn collect_dependency_usage_indices<'a>(
graph: &'a ModuleGraph,
config: &ResolvedConfig,
workspaces: &'a [fallow_config::WorkspaceInfo],
root_declared: &[String],
) -> DependencyUsageIndices<'a> {
let used_packages: FxHashSet<&str> = graph.package_usage.keys().map(String::as_str).collect();
let manifests = read_workspace_manifests(workspaces, config);
let closure_listed = closure_listed_names(
root_declared,
manifests
.iter()
.flat_map(|manifest| manifest.declared.iter()),
);
let root_peer_used = PeerDependencyResolver::new().peer_dependency_closure(
&config.root,
used_packages.iter().copied(),
&closure_listed,
);
let workspace_roots = dependency_owning_workspace_roots(&manifests);
let ownership = WorkspaceOwnershipIndex::new(graph, &workspace_roots);
let workspace_used_packages =
collect_workspace_used_packages(graph, &workspace_roots, &ownership);
let externalizing = collect_externalizing_workspaces(graph, &manifests, &ownership);
let bundled_workspace_usage =
collect_bundled_workspace_usage(&manifests, &workspace_used_packages, &externalizing);
let ancestor_credited_packages =
collect_ancestor_credited_packages(graph, config, &manifests, &ownership);
let root_credited_packages = collect_root_credited_packages(graph, &manifests, &ownership);
let nearer_manifest_credit = collect_nearer_manifest_credit(graph, &manifests, &ownership);
DependencyUsageIndices {
root_credited_packages,
nearer_manifest_credit,
package_workspace_usage: collect_package_workspace_usage(
graph,
&workspace_roots,
&ownership,
),
workspace_used_packages,
bundled_workspace_usage,
ancestor_credited_packages,
used_packages,
root_peer_used,
closure_listed,
}
}
fn collect_root_unused_dependencies(
pkg: &PackageJson,
config: &ResolvedConfig,
shared: &SharedDepSets<'_>,
usage: &DependencyUsageIndices<'_>,
linked_workspaces: &FxHashSet<String>,
) -> UnusedDependencyTriple {
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let is_used_globally = |dep: &str| {
usage.root_credited_packages.contains(dep)
|| usage.root_peer_used.contains_key(dep)
|| linked_workspaces.contains(dep)
};
let mut triple = collect_root_unused_categories(
pkg,
shared,
&is_used_globally,
&root_pkg_path,
root_pkg_content.as_deref(),
);
let (prod, dev, optional) = &mut triple;
for dep in prod
.iter_mut()
.chain(dev.iter_mut())
.chain(optional.iter_mut())
{
if let Some(roots) = usage.nearer_manifest_credit.get(dep.package_name.as_str()) {
dep.declared_and_imported_in.clone_from(roots);
}
}
triple
}
fn root_flagged_dependencies(
unused_deps: &[UnusedDependency],
unused_dev_deps: &[UnusedDependency],
unused_optional_deps: &[UnusedDependency],
used_packages: &FxHashSet<&str>,
) -> FxHashSet<String> {
unused_deps
.iter()
.chain(unused_dev_deps)
.chain(unused_optional_deps)
.filter(|d| !used_packages.contains(d.package_name.as_str()))
.map(|d| d.package_name.clone())
.collect()
}
fn collect_root_unused_categories(
pkg: &PackageJson,
shared: &SharedDepSets<'_>,
is_used_globally: &dyn Fn(&str) -> bool,
root_pkg_path: &Path,
root_pkg_content: Option<&str>,
) -> UnusedDependencyTriple {
let no_workspace_context = |_dep: &str| Vec::new();
let category =
|dep_names: Vec<String>, category: &DepCategoryConfig, is_used: &dyn Fn(&str) -> bool| {
collect_unused_for_category(UnusedCategoryInput {
dep_names,
category,
shared,
is_used,
used_in_workspaces: &no_workspace_context,
pkg_path: root_pkg_path,
pkg_content: root_pkg_content,
})
};
let own_peers = own_peer_dependency_names(pkg);
let is_dev_used = |dep: &str| own_peers.contains(dep) || is_used_globally(dep);
let unused_deps = category(
pkg.production_dependency_names(),
&prod_category(),
is_used_globally,
);
let unused_dev_deps = category(pkg.dev_dependency_names(), &dev_category(), &is_dev_used);
let unused_optional_deps = category(
pkg.optional_dependency_names(),
&optional_category(),
is_used_globally,
);
(unused_deps, unused_dev_deps, unused_optional_deps)
}
fn collect_workspaces_unused_dependencies(
workspaces: &[fallow_config::WorkspaceInfo],
inputs: &WorkspaceUnusedDependencyInputs<'_>,
) -> Vec<UnusedDependencyTriple> {
use rayon::prelude::*;
workspaces
.par_iter()
.map(|ws| collect_workspace_unused_dependencies(ws, inputs))
.collect()
}
struct WorkspaceUnusedDependencyInputs<'a> {
config: &'a ResolvedConfig,
package_referenced: &'a FxHashMap<PathBuf, FxHashSet<&'a str>>,
empty_package_referenced: &'a FxHashSet<&'a str>,
plugin_referenced: &'a FxHashSet<&'a str>,
plugin_tooling: &'a PluginToolingSets<'a>,
script_used: &'a FxHashSet<&'a str>,
ignore_deps: &'a IgnoreDependencyMatcher,
workspace_used_packages: &'a FxHashMap<&'a Path, FxHashSet<&'a str>>,
bundled_workspace_usage: &'a FxHashMap<&'a Path, FxHashSet<&'a str>>,
ancestor_credited_packages: &'a FxHashMap<&'a Path, FxHashSet<&'a str>>,
package_workspace_usage: &'a FxHashMap<String, Vec<PathBuf>>,
root_flagged: &'a FxHashSet<String>,
closure_listed: &'a FxHashSet<String>,
}
fn collect_workspace_unused_dependencies<'a>(
ws: &'a fallow_config::WorkspaceInfo,
inputs: &WorkspaceUnusedDependencyInputs<'a>,
) -> (
Vec<UnusedDependency>,
Vec<UnusedDependency>,
Vec<UnusedDependency>,
) {
let Some((ws_pkg_path, ws_pkg_content, ws_pkg)) = read_workspace_package(ws, inputs.config)
else {
return (Vec::new(), Vec::new(), Vec::new());
};
let ws_package_referenced = inputs
.package_referenced
.get(&ws_pkg_path)
.unwrap_or(inputs.empty_package_referenced);
let ws_shared = shared_dep_sets(
inputs.plugin_referenced,
ws_package_referenced,
inputs.plugin_tooling,
inputs.script_used,
inputs.ignore_deps,
&inputs.config.root,
);
let ws_root = ws.root.as_path();
let ancestor_credited = inputs.ancestor_credited_packages.get(&ws_root);
let ws_used_packages: FxHashSet<&str> = inputs
.workspace_used_packages
.get(&ws_root)
.into_iter()
.chain(ancestor_credited)
.flatten()
.copied()
.collect();
let ws_peer_used = PeerDependencyResolver::new().peer_dependency_closure(
ws_root,
ws_used_packages.iter().copied(),
inputs.closure_listed,
);
let usage = workspace_dependency_usage(
ws_root,
ws_peer_used,
inputs.package_workspace_usage,
inputs.bundled_workspace_usage.get(&ws_root),
ancestor_credited,
inputs.root_flagged,
);
collect_workspace_unused_categories(&ws_pkg, &ws_shared, &usage, &ws_pkg_path, &ws_pkg_content)
}
fn read_workspace_package(
ws: &fallow_config::WorkspaceInfo,
config: &ResolvedConfig,
) -> Option<(PathBuf, String, PackageJson)> {
let ws_pkg_path = ws.root.join("package.json");
if is_package_json_ignored(&ws_pkg_path, config) {
return None;
}
let ws_pkg_content = std::fs::read_to_string(&ws_pkg_path).ok()?;
let ws_pkg = serde_json::from_str::<PackageJson>(&ws_pkg_content).ok()?;
Some((ws_pkg_path, ws_pkg_content, ws_pkg))
}
struct WorkspaceDependencyUsage<'a> {
ws_root: &'a Path,
ws_peer_used: FxHashMap<String, Vec<String>>,
bundled_used: Option<&'a FxHashSet<&'a str>>,
ancestor_credited: Option<&'a FxHashSet<&'a str>>,
package_workspace_usage: &'a FxHashMap<String, Vec<PathBuf>>,
root_flagged: &'a FxHashSet<String>,
}
impl WorkspaceDependencyUsage<'_> {
fn is_used_in_workspace(&self, dep: &str) -> bool {
self.root_flagged.contains(dep)
|| self.ws_peer_used.contains_key(dep)
|| self
.bundled_used
.is_some_and(|bundled| bundled.contains(dep))
|| self
.ancestor_credited
.is_some_and(|credited| credited.contains(dep))
|| self
.package_workspace_usage
.get(dep)
.is_some_and(|roots| roots.iter().any(|root| root == self.ws_root))
}
fn used_in_other_workspaces(&self, dep: &str) -> Vec<PathBuf> {
used_in_other_workspaces(self.package_workspace_usage, dep, self.ws_root)
}
}
fn workspace_dependency_usage<'a>(
ws_root: &'a Path,
ws_peer_used: FxHashMap<String, Vec<String>>,
package_workspace_usage: &'a FxHashMap<String, Vec<PathBuf>>,
bundled_used: Option<&'a FxHashSet<&'a str>>,
ancestor_credited: Option<&'a FxHashSet<&'a str>>,
root_flagged: &'a FxHashSet<String>,
) -> WorkspaceDependencyUsage<'a> {
WorkspaceDependencyUsage {
ws_root,
ws_peer_used,
bundled_used,
ancestor_credited,
package_workspace_usage,
root_flagged,
}
}
fn collect_workspace_unused_categories(
ws_pkg: &PackageJson,
ws_shared: &SharedDepSets<'_>,
usage: &WorkspaceDependencyUsage<'_>,
ws_pkg_path: &Path,
ws_pkg_content: &str,
) -> (
Vec<UnusedDependency>,
Vec<UnusedDependency>,
Vec<UnusedDependency>,
) {
let is_used_in_workspace = |dep: &str| usage.is_used_in_workspace(dep);
let used_in_workspaces = |dep: &str| usage.used_in_other_workspaces(dep);
let own_peers = own_peer_dependency_names(ws_pkg);
let is_dev_used = |dep: &str| own_peers.contains(dep) || is_used_in_workspace(dep);
let prod = collect_unused_for_category(UnusedCategoryInput {
dep_names: ws_pkg.production_dependency_names(),
category: &prod_category(),
shared: ws_shared,
is_used: &is_used_in_workspace,
used_in_workspaces: &used_in_workspaces,
pkg_path: ws_pkg_path,
pkg_content: Some(ws_pkg_content),
});
let dev = collect_unused_for_category(UnusedCategoryInput {
dep_names: ws_pkg.dev_dependency_names(),
category: &dev_category(),
shared: ws_shared,
is_used: &is_dev_used,
used_in_workspaces: &used_in_workspaces,
pkg_path: ws_pkg_path,
pkg_content: Some(ws_pkg_content),
});
let optional = collect_unused_for_category(UnusedCategoryInput {
dep_names: ws_pkg.optional_dependency_names(),
category: &optional_category(),
shared: ws_shared,
is_used: &is_used_in_workspace,
used_in_workspaces: &used_in_workspaces,
pkg_path: ws_pkg_path,
pkg_content: Some(ws_pkg_content),
});
(prod, dev, optional)
}
pub fn find_type_only_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<TypeOnlyDependency> {
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
let root_importers = RootImporters::new(graph, config, workspaces);
let mut type_only_deps = Vec::new();
for dep in pkg.production_dependency_names() {
if workspace_names.contains(dep.as_str()) {
continue;
}
if config.ignore_dependencies.is_ignored(&dep) {
continue;
}
let total_count = root_importers.of(&dep).len();
let type_only_count = root_importers.type_only_of(&dep).len();
if total_count > 0 && type_only_count == total_count {
let line = root_pkg_content
.as_deref()
.map_or(1, |c| find_dep_line_in_json(c, &dep));
type_only_deps.push(TypeOnlyDependency {
package_name: dep,
path: root_pkg_path.clone(),
line,
});
}
}
type_only_deps
}
fn production_exclude_globset() -> Option<&'static globset::GlobSet> {
static SET: OnceLock<Option<globset::GlobSet>> = OnceLock::new();
SET.get_or_init(|| {
let mut builder = globset::GlobSetBuilder::new();
for pattern in crate::discover::PRODUCTION_EXCLUDE_PATTERNS {
if let Ok(glob) = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
{
builder.add(glob);
}
}
builder.build().ok()
})
.as_ref()
}
fn is_production_module(module: &crate::graph::ModuleNode, config: &ResolvedConfig) -> bool {
if !module.is_runtime_reachable() || is_config_file(&module.path) {
return false;
}
let relative = module
.path
.strip_prefix(&config.root)
.unwrap_or(&module.path);
!production_exclude_globset().is_some_and(|test_globs| test_globs.is_match(relative))
}
fn accepts_ancestor_declaration(
owner_is_private: bool,
module: &crate::graph::ModuleNode,
config: &ResolvedConfig,
) -> bool {
owner_is_private || !is_production_module(module, config)
}
fn dependency_is_test_only(
dep: &str,
graph: &ModuleGraph,
config: &ResolvedConfig,
test_globs: &globset::GlobSet,
root_importers: &RootImporters<'_>,
) -> bool {
let file_ids = root_importers.of(dep);
if file_ids.is_empty() {
return false;
}
let total_count = file_ids.len();
let type_only_count = root_importers.type_only_of(dep).len();
if type_only_count == total_count {
return false;
}
file_ids.iter().all(|id| {
graph.modules.get(id.0 as usize).is_some_and(|module| {
let relative = module
.path
.strip_prefix(&config.root)
.unwrap_or(&module.path);
test_globs.is_match(relative) || is_config_file(&module.path)
})
})
}
pub fn find_test_only_dependencies(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<TestOnlyDependency> {
let Some(test_globs) = production_exclude_globset() else {
return Vec::new();
};
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
let ignore_deps = &config.ignore_dependencies;
let root_importers = RootImporters::new(graph, config, workspaces);
let mut test_only_deps = Vec::new();
for dep in pkg.production_dependency_names() {
if workspace_names.contains(dep.as_str()) {
continue;
}
if ignore_deps.is_ignored(&dep) {
continue;
}
if dependency_is_test_only(&dep, graph, config, test_globs, &root_importers) {
let line = root_pkg_content
.as_deref()
.map_or(1, |c| find_dep_line_in_json(c, &dep));
test_only_deps.push(TestOnlyDependency {
package_name: dep,
path: root_pkg_path.clone(),
line,
});
}
}
test_only_deps
}
fn dependency_has_prod_value_import(
dep: &str,
graph: &ModuleGraph,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
) -> bool {
let Some(file_ids) = graph.package_usage.get(dep) else {
return false;
};
let mut per_file: FxHashMap<FileId, (u32, u32)> = FxHashMap::default();
for id in file_ids {
per_file.entry(*id).or_default().0 += 1;
}
let build_time_ids = graph
.type_only_package_usage
.get(dep)
.into_iter()
.chain(graph.asset_package_usage.get(dep))
.flatten();
for id in build_time_ids {
per_file.entry(*id).or_default().1 += 1;
}
per_file.iter().any(|(id, (total, build_time))| {
if total <= build_time {
return false;
}
graph.modules.get(id.0 as usize).is_some_and(|module| {
is_production_module(module, config)
&& !workspaces
.iter()
.any(|ws| module.path.starts_with(&ws.root))
})
})
}
pub fn find_dev_dependencies_in_production(
graph: &ModuleGraph,
pkg: &PackageJson,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> Vec<DevDependencyInProduction> {
if production_exclude_globset().is_none() {
return Vec::new();
}
let root_pkg_path = config.root.join("package.json");
let root_pkg_content = read_pkg_json_content(&root_pkg_path);
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
let ignore_deps = &config.ignore_dependencies;
let prod_names = pkg.production_dependency_names();
let optional_names = pkg.optional_dependency_names();
let runtime_provided: FxHashSet<&str> = prod_names
.iter()
.chain(optional_names.iter())
.map(String::as_str)
.chain(own_peer_dependency_names(pkg))
.collect();
let plugin_tooling = plugin_tooling_set(plugin_result);
let mut findings = Vec::new();
for dep in pkg.dev_dependency_names() {
if workspace_names.contains(dep.as_str()) {
continue;
}
if ignore_deps.is_ignored(&dep) {
continue;
}
if runtime_provided.contains(dep.as_str()) {
continue;
}
if crate::plugins::is_known_tooling_dependency(&dep) {
continue;
}
if plugin_tooling.contains(dep.as_str()) {
continue;
}
if dependency_has_prod_value_import(&dep, graph, config, workspaces) {
let line = root_pkg_content
.as_deref()
.map_or(1, |c| find_dep_line_in_json(c, &dep));
findings.push(DevDependencyInProduction {
package_name: dep,
path: root_pkg_path.clone(),
line,
});
}
}
findings
}
fn types_package_name(package_name: &str) -> String {
package_name.strip_prefix('@').map_or_else(
|| format!("@types/{package_name}"),
|scoped| format!("@types/{}", scoped.replacen('/', "__", 1)),
)
}
fn relative_module_path(module_path: &Path, root: &Path) -> String {
module_path
.strip_prefix(root)
.unwrap_or(module_path)
.to_string_lossy()
.into_owned()
}
struct CompiledProvidedDependencyRule<'a> {
rule: &'a ProvidedDependencyRule,
path_matcher: CompiledPathRule,
}
fn compile_provided_dependency_rules(
rules: &[ProvidedDependencyRule],
) -> Vec<CompiledProvidedDependencyRule<'_>> {
rules
.iter()
.filter_map(|rule| {
CompiledPathRule::for_used_export_rule(&rule.path, "provided dependency")
.map(|path_matcher| CompiledProvidedDependencyRule { rule, path_matcher })
})
.collect()
}
fn import_is_provided(
rules: &[CompiledProvidedDependencyRule<'_>],
relative_path: &str,
source_specifier: &str,
) -> bool {
rules.iter().any(|rule| {
rule.path_matcher.matches(relative_path) && rule.rule.covers_specifier(source_specifier)
})
}
fn package_has_file_scoped_provider(rules: &[ProvidedDependencyRule], package_name: &str) -> bool {
rules
.iter()
.any(|rule| rule.may_cover_package(package_name))
}
fn find_unprovided_import_location(
import_spans_by_file: &FxHashMap<FileId, Vec<(&str, &str, u32)>>,
line_offsets_by_file: &LineOffsetsMap<'_>,
provided_rules: &[CompiledProvidedDependencyRule<'_>],
relative_path: &str,
file_id: FileId,
package_name: &str,
) -> Option<(u32, u32)> {
import_spans_by_file.get(&file_id).and_then(|spans| {
spans
.iter()
.filter(|(name, _, _)| *name == package_name)
.find(|(_, source, _)| {
!is_builtin_module(source)
&& !import_is_provided(provided_rules, relative_path, source)
})
.or_else(|| {
spans.iter().find(|(name, source, _)| {
*name == package_name
&& !import_is_provided(provided_rules, relative_path, source)
})
})
.map(|(_, _, span_start)| {
byte_offset_to_line_col(line_offsets_by_file, file_id, *span_start)
})
})
}
fn package_imports_are_all_builtin(
import_spans_by_file: &FxHashMap<FileId, Vec<(&str, &str, u32)>>,
file_id: FileId,
package_name: &str,
) -> bool {
let Some(imports) = import_spans_by_file.get(&file_id) else {
return false;
};
let mut saw_package = false;
for (name, source, _) in imports {
if *name == package_name {
saw_package = true;
if !is_builtin_module(source) {
return false;
}
}
}
saw_package
}
fn package_imports_are_all_npm_scheme(
import_spans_by_file: &FxHashMap<FileId, Vec<(&str, &str, u32)>>,
file_id: FileId,
package_name: &str,
) -> bool {
let Some(imports) = import_spans_by_file.get(&file_id) else {
return false;
};
let mut saw_package = false;
for (name, source, _) in imports {
if *name == package_name {
saw_package = true;
if !source.starts_with("npm:") {
return false;
}
}
}
saw_package
}
struct WorkspaceDependencies {
root: PathBuf,
deps: FxHashSet<String>,
is_private: bool,
}
fn workspace_dependency_map(
workspaces: &[fallow_config::WorkspaceInfo],
config: &ResolvedConfig,
) -> Vec<WorkspaceDependencies> {
let ambient_workspace_names: FxHashSet<String> = workspaces
.iter()
.filter(|ws| fallow_config::dir_has_deno_json(&ws.root))
.map(|ws| ws.name.clone())
.collect();
let mut ws_dep_map = Vec::new();
for ws in workspaces {
let ws_pkg_path = ws.root.join("package.json");
if ws_pkg_path.is_file() && is_package_json_ignored(&ws_pkg_path, config) {
continue;
}
let Ok(Some((_name, ws_pkg, _deps))) =
fallow_config::load_member_package_manifest(&ws.root)
else {
continue;
};
let mut ws_deps: FxHashSet<String> = ws_pkg.all_dependency_names().into_iter().collect();
ws_deps.insert(ws.name.clone());
if fallow_config::dir_has_deno_json(&ws.root) {
ws_deps.extend(ambient_workspace_names.iter().cloned());
}
ws_dep_map.push(WorkspaceDependencies {
root: ws.root.clone(),
deps: ws_deps,
is_private: ws_pkg.private == Some(true),
});
}
ws_dep_map
}
fn import_spans_by_file<'a>(
resolved_modules: &'a [ResolvedModule],
modules: &'a [ModuleInfo],
) -> FxHashMap<FileId, Vec<(&'a str, &'a str, u32)>> {
let mut import_spans_by_file: FxHashMap<FileId, Vec<(&str, &str, u32)>> = FxHashMap::default();
for rm in resolved_modules {
for edge in rm.all_resolved_source_edges() {
if let Some(name) = edge.target().package_usage_name() {
import_spans_by_file.entry(rm.file_id).or_default().push((
name,
edge.source_specifier(),
edge.span().start,
));
}
}
}
for module in modules {
for (package_name, span_start) in &module.package_resolve_sites {
import_spans_by_file
.entry(module.file_id)
.or_default()
.push((package_name.as_str(), package_name.as_str(), *span_start));
}
}
import_spans_by_file
}
#[derive(Clone, Copy)]
pub struct UnlistedDependencyInput<'a> {
pub graph: &'a ModuleGraph,
pub pkg: &'a PackageJson,
pub config: &'a ResolvedConfig,
pub workspaces: &'a [fallow_config::WorkspaceInfo],
pub plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
pub resolved_modules: &'a [ResolvedModule],
pub modules: &'a [ModuleInfo],
pub line_offsets_by_file: &'a LineOffsetsMap<'a>,
}
pub fn find_unlisted_dependencies(input: UnlistedDependencyInput<'_>) -> Vec<UnlistedDependency> {
let parts = build_unlisted_dependency_context_parts(&input);
let workspace_roots: Vec<&Path> = parts
.ws_dep_map
.iter()
.map(|ws| ws.root.as_path())
.collect();
let workspace_ownership = WorkspaceOwnershipIndex::new(input.graph, &workspace_roots);
let ctx = UnlistedDependencyContext {
graph: input.graph,
config: input.config,
all_deps: &parts.all_deps,
ws_dep_map: &parts.ws_dep_map,
virtual_prefixes: &parts.virtual_prefixes,
virtual_suffixes: &parts.virtual_suffixes,
plugin_tooling: &parts.plugin_tooling,
provided_dependency_rules: parts.provided_dependency_rules,
compiled_provided_dependency_rules: &parts.compiled_provided_dependency_rules,
import_spans_by_file: &parts.import_spans_by_file,
ignore_deps: parts.ignore_deps,
line_offsets_by_file: input.line_offsets_by_file,
workspace_ownership: &workspace_ownership,
};
collect_unlisted_dependencies(&ctx)
}
struct UnlistedDependencyContextParts<'a> {
all_deps: FxHashSet<String>,
ws_dep_map: Vec<WorkspaceDependencies>,
virtual_prefixes: Vec<&'a str>,
virtual_suffixes: Vec<&'a str>,
plugin_tooling: FxHashSet<&'a str>,
provided_dependency_rules: &'a [ProvidedDependencyRule],
compiled_provided_dependency_rules: Vec<CompiledProvidedDependencyRule<'a>>,
import_spans_by_file: FxHashMap<FileId, Vec<(&'a str, &'a str, u32)>>,
ignore_deps: &'a IgnoreDependencyMatcher,
}
struct UnlistedDependencyPluginParts<'a> {
virtual_prefixes: Vec<&'a str>,
virtual_suffixes: Vec<&'a str>,
plugin_tooling: FxHashSet<&'a str>,
provided_dependency_rules: &'a [ProvidedDependencyRule],
compiled_provided_dependency_rules: Vec<CompiledProvidedDependencyRule<'a>>,
}
fn build_unlisted_dependency_plugin_parts(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> UnlistedDependencyPluginParts<'_> {
let virtual_prefixes = plugin_result
.map(|pr| {
pr.virtual_module_prefixes
.iter()
.map(String::as_str)
.collect()
})
.unwrap_or_default();
let virtual_suffixes = plugin_result
.map(|pr| {
pr.virtual_package_suffixes
.iter()
.map(String::as_str)
.collect()
})
.unwrap_or_default();
let plugin_tooling = plugin_result
.map(|pr| pr.tooling_dependencies.iter().map(String::as_str).collect())
.unwrap_or_default();
let provided_dependency_rules: &[ProvidedDependencyRule] =
plugin_result.map_or(&[], |pr| pr.provided_dependencies.as_slice());
let compiled_provided_dependency_rules =
compile_provided_dependency_rules(provided_dependency_rules);
UnlistedDependencyPluginParts {
virtual_prefixes,
virtual_suffixes,
plugin_tooling,
provided_dependency_rules,
compiled_provided_dependency_rules,
}
}
fn build_unlisted_dependency_context_parts<'a>(
input: &UnlistedDependencyInput<'a>,
) -> UnlistedDependencyContextParts<'a> {
let mut all_deps: FxHashSet<String> = input.pkg.all_dependency_names().into_iter().collect();
if let Some(root_name) = &input.pkg.name {
all_deps.insert(root_name.clone());
}
if fallow_config::dir_has_deno_json(&input.config.root) {
all_deps.extend(
input
.workspaces
.iter()
.filter(|ws| fallow_config::dir_has_deno_json(&ws.root))
.map(|ws| ws.name.clone()),
);
}
let ws_dep_map = workspace_dependency_map(input.workspaces, input.config);
let plugin_parts = build_unlisted_dependency_plugin_parts(input.plugin_result);
let import_spans_by_file = import_spans_by_file(input.resolved_modules, input.modules);
let ignore_deps = &input.config.ignore_dependencies;
UnlistedDependencyContextParts {
all_deps,
ws_dep_map,
virtual_prefixes: plugin_parts.virtual_prefixes,
virtual_suffixes: plugin_parts.virtual_suffixes,
plugin_tooling: plugin_parts.plugin_tooling,
provided_dependency_rules: plugin_parts.provided_dependency_rules,
compiled_provided_dependency_rules: plugin_parts.compiled_provided_dependency_rules,
import_spans_by_file,
ignore_deps,
}
}
fn collect_unlisted_dependencies(ctx: &UnlistedDependencyContext<'_>) -> Vec<UnlistedDependency> {
let mut unlisted: FxHashMap<String, Vec<ImportSite>> = FxHashMap::default();
for (package_name, file_ids) in &ctx.graph.package_usage {
if should_skip_unlisted_package(package_name, ctx) {
continue;
}
let mut unlisted_sites = collect_unlisted_import_sites(package_name, file_ids, ctx);
if !unlisted_sites.is_empty() {
unlisted_sites.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
unlisted_sites.dedup_by(|a, b| a.path == b.path);
unlisted.insert(package_name.clone(), unlisted_sites);
}
}
unlisted
.into_iter()
.map(|(name, sites)| UnlistedDependency {
package_name: name,
imported_from: sites,
})
.collect()
}
struct UnlistedDependencyContext<'a> {
graph: &'a ModuleGraph,
config: &'a ResolvedConfig,
all_deps: &'a FxHashSet<String>,
ws_dep_map: &'a [WorkspaceDependencies],
virtual_prefixes: &'a [&'a str],
virtual_suffixes: &'a [&'a str],
plugin_tooling: &'a FxHashSet<&'a str>,
provided_dependency_rules: &'a [ProvidedDependencyRule],
compiled_provided_dependency_rules: &'a [CompiledProvidedDependencyRule<'a>],
import_spans_by_file: &'a FxHashMap<FileId, Vec<(&'a str, &'a str, u32)>>,
ignore_deps: &'a IgnoreDependencyMatcher,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
workspace_ownership: &'a WorkspaceOwnershipIndex,
}
fn should_skip_unlisted_package(package_name: &str, ctx: &UnlistedDependencyContext<'_>) -> bool {
((package_name != "bun" && is_builtin_module(package_name)) || is_path_alias(package_name))
|| is_virtual_module(package_name)
|| ctx.ignore_deps.is_ignored(package_name)
|| (ctx.plugin_tooling.contains(package_name)
&& !package_has_file_scoped_provider(ctx.provided_dependency_rules, package_name))
|| ctx
.virtual_prefixes
.iter()
.any(|prefix| matches_virtual_prefix(prefix, package_name))
|| ctx
.virtual_suffixes
.iter()
.any(|suffix| package_name.ends_with(suffix))
}
fn collect_unlisted_import_sites(
package_name: &str,
file_ids: &[FileId],
ctx: &UnlistedDependencyContext<'_>,
) -> Vec<ImportSite> {
file_ids
.iter()
.filter_map(|id| collect_unlisted_import_site(package_name, *id, ctx))
.collect()
}
fn collect_unlisted_import_site(
package_name: &str,
id: FileId,
ctx: &UnlistedDependencyContext<'_>,
) -> Option<ImportSite> {
let module = ctx.graph.modules.get(id.0 as usize)?;
if package_name == "bun"
&& package_imports_are_all_builtin(ctx.import_spans_by_file, id, package_name)
{
return None;
}
if package_imports_are_all_npm_scheme(ctx.import_spans_by_file, id, package_name) {
return None;
}
if import_is_declared(package_name, id, module, ctx) {
return None;
}
let relative_path = relative_module_path(&module.path, &ctx.config.root);
let (line, col) = find_unprovided_import_location(
ctx.import_spans_by_file,
ctx.line_offsets_by_file,
ctx.compiled_provided_dependency_rules,
&relative_path,
id,
package_name,
)?;
Some(ImportSite {
path: module.path.clone(),
line,
col,
})
}
fn import_is_declared(
package_name: &str,
id: FileId,
module: &crate::graph::ModuleNode,
ctx: &UnlistedDependencyContext<'_>,
) -> bool {
manifest_chain_declares(
package_name,
id,
ctx.ws_dep_map,
ctx.workspace_ownership,
ctx.all_deps,
|owner_is_private| accepts_ancestor_declaration(owner_is_private, module, ctx.config),
)
}
fn manifest_chain_declares(
package_name: &str,
id: FileId,
ws_dep_map: &[WorkspaceDependencies],
ownership: &WorkspaceOwnershipIndex,
root_deps: &FxHashSet<String>,
accepts_ancestor: impl FnOnce(bool) -> bool,
) -> bool {
let types_name = types_package_name(package_name);
let declares =
|deps: &FxHashSet<String>| deps.contains(package_name) || deps.contains(&types_name);
let Some((index, owner)) = ownership
.workspace_index_for_file(id)
.and_then(|index| ws_dep_map.get(index).map(|owner| (index, owner)))
else {
return declares(root_deps);
};
if declares(&owner.deps) {
return true;
}
if !accepts_ancestor(owner.is_private) {
return false;
}
ownership
.ancestors_of(index)
.iter()
.filter_map(|ancestor| ws_dep_map.get(*ancestor))
.any(|ancestor| declares(&ancestor.deps))
|| declares(root_deps)
}
struct UnresolvedImportFilters<'a> {
config: &'a ResolvedConfig,
virtual_prefixes: &'a [&'a str],
generated_patterns: &'a [&'a str],
generated_type_prefixes: &'a [&'a str],
}
fn unresolved_spec_is_silenced(
spec: &str,
is_type_only: bool,
filters: &UnresolvedImportFilters<'_>,
) -> bool {
if is_builtin_module(spec) || is_virtual_module(spec) {
return true;
}
if filters
.virtual_prefixes
.iter()
.any(|prefix| matches_virtual_prefix(prefix, spec))
{
return true;
}
if !filters.generated_patterns.is_empty() {
let bare = spec
.strip_suffix(".js")
.or_else(|| spec.strip_suffix(".ts"))
.unwrap_or(spec);
if filters
.generated_patterns
.iter()
.any(|pat| bare.ends_with(pat))
{
return true;
}
}
if is_type_only
&& filters
.generated_type_prefixes
.iter()
.any(|prefix| spec.starts_with(prefix))
{
return true;
}
let normalized_spec = spec.strip_prefix("./").unwrap_or(spec);
filters
.config
.ignore_unresolved_imports
.iter()
.any(|matcher| matcher.is_match(spec) || matcher.is_match(normalized_spec))
}
fn unresolved_import_location(
edge: &crate::resolve::ResolvedSourceEdge<'_>,
file_id: FileId,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (u32, u32, u32) {
let (line, col) = byte_offset_to_line_col(line_offsets_by_file, file_id, edge.span().start);
let source_span = edge.source_span();
if source_span.end > source_span.start {
let (specifier_line, specifier_col) =
byte_offset_to_line_col(line_offsets_by_file, file_id, source_span.start);
if specifier_line == line {
(line, col, specifier_col)
} else {
(specifier_line, specifier_col, specifier_col)
}
} else {
(line, col, col)
}
}
fn unresolved_import_suppressed(
suppressions: &SuppressionContext<'_>,
edge: &crate::resolve::ResolvedSourceEdge<'_>,
file_id: FileId,
anchor_line: u32,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> bool {
let statement_span = edge.statement_span();
let statement_line = if statement_span.end > statement_span.start {
byte_offset_to_line_col(line_offsets_by_file, file_id, statement_span.start).0
} else {
byte_offset_to_line_col(line_offsets_by_file, file_id, edge.span().start).0
};
let first = statement_line.min(anchor_line);
let last = statement_line.max(anchor_line);
(first..=last)
.any(|line| suppressions.is_suppressed(file_id, line, IssueKind::UnresolvedImport))
}
#[expect(
clippy::too_many_arguments,
reason = "each analysis input stays a separate borrowed argument"
)]
pub fn find_unresolved_imports(
resolved_modules: &[ResolvedModule],
config: &ResolvedConfig,
suppressions: &SuppressionContext<'_>,
virtual_prefixes: &[&str],
generated_patterns: &[&str],
generated_type_prefixes: &[&str],
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnresolvedImport> {
let filters = UnresolvedImportFilters {
config,
virtual_prefixes,
generated_patterns,
generated_type_prefixes,
};
let mut gitignored = GitignoredTargets::new(&config.root);
let mut unresolved = Vec::new();
for module in resolved_modules {
let mut reported_specs: FxHashSet<String> = FxHashSet::default();
for edge in module.all_resolved_source_edges() {
let crate::resolve::ResolveResult::Unresolvable(spec) = edge.target() else {
continue;
};
if reported_specs.contains(spec.as_str()) {
continue;
}
if unresolved_spec_is_silenced(spec, edge.is_type_only(), &filters) {
continue;
}
if gitignored.ignores_missing_target(&module.path, spec) {
continue;
}
if module
.missing_export_targets
.iter()
.find(|target| target.specifier == *spec)
.is_some_and(|target| gitignored.ignores_missing_paths(&target.paths))
{
continue;
}
let (line, col, specifier_col) =
unresolved_import_location(&edge, module.file_id, line_offsets_by_file);
reported_specs.insert(spec.clone());
if unresolved_import_suppressed(
suppressions,
&edge,
module.file_id,
line,
line_offsets_by_file,
) {
continue;
}
unresolved.push(UnresolvedImport {
path: module.path.clone(),
specifier: spec.clone(),
line,
col,
specifier_col,
});
}
}
unresolved
}
#[cfg(test)]
#[path = "unused_deps_tests/mod.rs"]
mod tests;