use fallow_config::{
CompiledIgnoreDependencyOverrideRule, PackageJson, PnpmOverrideData, ResolvedConfig,
WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceInfo, append_workspace_diagnostics,
override_misconfig_reason as parser_misconfig_reason, parse_bun_package_json_resolutions,
parse_npm_package_json_overrides, parse_pnpm_package_json_overrides,
parse_pnpm_workspace_overrides, record_workspace_diagnostics, yaml,
};
use fallow_types::results::{
DependencyOverrideMisconfigReason, DependencyOverrideSource, MisconfiguredDependencyOverride,
UnusedDependencyOverride,
};
use rustc_hash::FxHashSet;
use super::package_manager::{
DeclaredPackageManager, PackageJsonOverrideReads, PackageManagerKind,
pnpm_workspace_overrides_ignored,
};
const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
const PNPM_LOCK_FILE: &str = "pnpm-lock.yaml";
const NPM_LOCK_FILE: &str = "package-lock.json";
const NPM_SHRINKWRAP_FILE: &str = "npm-shrinkwrap.json";
const BUN_LOCK_FILE: &str = "bun.lock";
const BUN_LOCKB_FILE: &str = "bun.lockb";
const YARN_LOCK_FILE: &str = "yarn.lock";
const NODE_MODULES_SEGMENT: &str = "node_modules/";
const ROOT_PACKAGE_JSON: &str = "package.json";
const OVERRIDES_KEY: &str = "overrides";
const SOURCE_LABEL_YAML: &str = "pnpm-workspace.yaml";
const SOURCE_LABEL_JSON: &str = "package.json";
const HINT_MAY_BE_TRANSITIVE_PNPM: &str =
"may target a transitive dependency; pnpm install --frozen-lockfile is the ground truth";
const HINT_MAY_BE_TRANSITIVE_BUN: &str =
"may target a transitive dependency; bun install --frozen-lockfile is the ground truth";
const HINT_MAY_BE_TRANSITIVE_BUN_RESOLUTIONS: &str = "declared under `resolutions`, which bun applies as an alias of `overrides`; may target a transitive dependency; bun install --frozen-lockfile is the ground truth";
const HINT_MAY_BE_TRANSITIVE_NPM: &str =
"may target a transitive dependency; npm ci is the ground truth";
const HINT_OVERRIDES_IGNORED_BY_YARN: &str =
"yarn does not apply `overrides`; declare the pin under `resolutions` instead";
const PNPM_ENV_IMPORTER_SECTIONS: &[&str] = &["packageManagerDependencies", "configDependencies"];
const LOCKFILE_DEPENDENCY_SECTIONS: &[&str] = &[
"dependencies",
"optionalDependencies",
"devDependencies",
"peerDependencies",
];
pub struct PnpmOverrideState {
workspace_yaml_data: PnpmOverrideData,
package_json_data: PnpmOverrideData,
npm_package_json_data: PnpmOverrideData,
bun_resolutions_data: PnpmOverrideData,
declared_packages: FxHashSet<String>,
lockfile_packages: FxHashSet<String>,
unreadable_lockfiles: Vec<UnreadableLockfile>,
transitive_hint: &'static str,
}
#[must_use]
pub fn gather_pnpm_override_state(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Option<PnpmOverrideState> {
let yaml_path = config.root.join(PNPM_WORKSPACE_FILE);
let mut workspace_yaml_data = std::fs::read_to_string(&yaml_path)
.ok()
.map(|yaml_source| {
parse_pnpm_workspace_overrides(&yaml_source).unwrap_or_else(|error| {
super::unused_catalog::report_malformed_pnpm_workspace_yaml(
&config.root,
&yaml_path,
error,
);
PnpmOverrideData::default()
})
})
.unwrap_or_default();
let root_pkg_path = config.root.join(ROOT_PACKAGE_JSON);
let root_pkg_source = std::fs::read_to_string(&root_pkg_path).ok();
let root_manifest: Option<serde_json::Value> = root_pkg_source
.as_deref()
.and_then(|source| serde_json::from_str(source).ok());
let declared = root_manifest
.as_ref()
.and_then(DeclaredPackageManager::from_manifest);
let declared_manager = declared.map(|declared| declared.kind);
if !workspace_yaml_data.entries.is_empty()
&& let Some(cause) = root_manifest
.as_ref()
.and_then(pnpm_workspace_overrides_ignored)
{
record_override_diagnostics(
config,
vec![WorkspaceDiagnostic::new(
&config.root,
yaml_path,
WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored { cause },
)],
);
workspace_yaml_data = PnpmOverrideData::default();
}
let reads_pnpm_overrides = PackageJsonOverrideReads::for_pnpm_major(
declared.and_then(DeclaredPackageManager::pnpm_major),
)
.pnpm_overrides;
let package_json_data = root_pkg_source
.as_deref()
.filter(|_| reads_pnpm_overrides)
.map(parse_pnpm_package_json_overrides)
.unwrap_or_default();
let npm_package_json_data = root_pkg_source
.as_deref()
.map(parse_npm_package_json_overrides)
.unwrap_or_default();
let manifest_declares_overrides = root_manifest
.as_ref()
.is_some_and(|manifest| manifest.get(OVERRIDES_KEY).is_some());
let uses_bun = uses_bun(declared_manager, &config.root);
let parsed_bun_resolutions = root_pkg_source
.as_deref()
.filter(|_| uses_bun)
.map(parse_bun_package_json_resolutions)
.unwrap_or_default();
let bun_resolutions_shadowed =
manifest_declares_overrides && !parsed_bun_resolutions.entries.is_empty();
if bun_resolutions_shadowed {
record_override_diagnostics(
config,
vec![WorkspaceDiagnostic::new(
&config.root,
root_pkg_path,
WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
)],
);
}
let bun_resolutions_data = if manifest_declares_overrides {
PnpmOverrideData::default()
} else {
parsed_bun_resolutions
};
if workspace_yaml_data.entries.is_empty()
&& package_json_data.entries.is_empty()
&& npm_package_json_data.entries.is_empty()
&& bun_resolutions_data.entries.is_empty()
{
return None;
}
let declared_packages = collect_declared_packages(config, workspaces);
let lockfile_resolution = collect_lockfile_packages(config, declared_manager);
Some(PnpmOverrideState {
workspace_yaml_data,
package_json_data,
npm_package_json_data,
bun_resolutions_data,
declared_packages,
lockfile_packages: lockfile_resolution.packages,
unreadable_lockfiles: lockfile_resolution.unreadable_lockfiles,
transitive_hint: lockfile_resolution.transitive_hint,
})
}
fn collect_declared_packages(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> FxHashSet<String> {
let mut paths = Vec::with_capacity(workspaces.len() + 1);
paths.push(config.root.join(ROOT_PACKAGE_JSON));
for ws in workspaces {
paths.push(ws.root.join(ROOT_PACKAGE_JSON));
}
let mut set: FxHashSet<String> = FxHashSet::default();
for pkg_path in &paths {
let Ok(raw_source) = std::fs::read_to_string(pkg_path) else {
continue;
};
let Ok(pkg) = serde_json::from_str::<PackageJson>(&raw_source) else {
continue;
};
for deps in [
pkg.dependencies.as_ref(),
pkg.dev_dependencies.as_ref(),
pkg.peer_dependencies.as_ref(),
pkg.optional_dependencies.as_ref(),
]
.into_iter()
.flatten()
{
for name in deps.keys() {
set.insert(name.clone());
}
}
}
set
}
struct LockfileResolution {
packages: FxHashSet<String>,
unreadable_lockfiles: Vec<UnreadableLockfile>,
transitive_hint: &'static str,
}
#[derive(Clone, Copy)]
enum UnreadableLockfile {
BunBinary,
BunText,
Pnpm,
Npm(&'static str),
}
fn collect_lockfile_packages(
config: &ResolvedConfig,
declared_manager: Option<PackageManagerKind>,
) -> LockfileResolution {
let mut packages = FxHashSet::default();
let has_pnpm_lock = config.root.join(PNPM_LOCK_FILE).exists();
let pnpm_lock_parsed = if let Ok(raw_source) =
std::fs::read_to_string(config.root.join(PNPM_LOCK_FILE))
&& let Some(pnpm_packages) = collect_pnpm_lock_packages(&raw_source)
{
packages.extend(pnpm_packages);
true
} else {
false
};
let mut has_npm_lock = false;
let mut npm_lock_parsed = false;
let mut unparseable_npm_locks = Vec::new();
for npm_lock_file in [NPM_LOCK_FILE, NPM_SHRINKWRAP_FILE] {
let npm_lock_path = config.root.join(npm_lock_file);
if !npm_lock_path.exists() {
continue;
}
has_npm_lock = true;
if let Some(npm_packages) = std::fs::read_to_string(&npm_lock_path)
.ok()
.and_then(|raw_source| collect_npm_lock_packages(&raw_source))
{
packages.extend(npm_packages);
npm_lock_parsed = true;
} else {
unparseable_npm_locks.push(npm_lock_file);
}
}
let has_bun_lock = config.root.join(BUN_LOCK_FILE).exists();
let bun_lock_parsed = if let Ok(raw_source) =
std::fs::read_to_string(config.root.join(BUN_LOCK_FILE))
&& let Some(bun_packages) = collect_bun_lock_packages(&raw_source)
{
packages.extend(bun_packages);
true
} else {
false
};
let has_bun_lockb = config.root.join(BUN_LOCKB_FILE).exists();
let has_yarn_lock = config.root.join(YARN_LOCK_FILE).exists();
let transitive_hint = match declared_manager {
Some(PackageManagerKind::Bun) => HINT_MAY_BE_TRANSITIVE_BUN,
Some(PackageManagerKind::Npm) => HINT_MAY_BE_TRANSITIVE_NPM,
Some(PackageManagerKind::Yarn) => HINT_OVERRIDES_IGNORED_BY_YARN,
None if has_pnpm_lock => HINT_MAY_BE_TRANSITIVE_PNPM,
None if has_bun_lock || has_bun_lockb => HINT_MAY_BE_TRANSITIVE_BUN,
None if has_npm_lock => HINT_MAY_BE_TRANSITIVE_NPM,
None if has_yarn_lock => HINT_OVERRIDES_IGNORED_BY_YARN,
Some(PackageManagerKind::Pnpm) | None => HINT_MAY_BE_TRANSITIVE_PNPM,
};
let mut unreadable_lockfiles = Vec::new();
if !pnpm_lock_parsed && !npm_lock_parsed && !bun_lock_parsed {
if has_pnpm_lock {
unreadable_lockfiles.push(UnreadableLockfile::Pnpm);
}
unreadable_lockfiles.extend(
unparseable_npm_locks
.into_iter()
.map(UnreadableLockfile::Npm),
);
if has_bun_lock {
unreadable_lockfiles.push(UnreadableLockfile::BunText);
} else if has_bun_lockb {
unreadable_lockfiles.push(UnreadableLockfile::BunBinary);
}
}
LockfileResolution {
packages,
unreadable_lockfiles,
transitive_hint,
}
}
fn uses_bun(declared_manager: Option<PackageManagerKind>, root: &std::path::Path) -> bool {
match declared_manager {
Some(PackageManagerKind::Bun) => true,
Some(_) => false,
None => root.join(BUN_LOCK_FILE).exists() || root.join(BUN_LOCKB_FILE).exists(),
}
}
fn collect_bun_lock_packages(source: &str) -> Option<FxHashSet<String>> {
let Ok(value) = fallow_config::jsonc::parse_to_value::<serde_json::Value>(source) else {
return None;
};
if !value.is_object() {
return None;
}
let mut packages = FxHashSet::default();
if let Some(mapping) = value.get("packages").and_then(serde_json::Value::as_object) {
for entry in mapping.values() {
let Some(specifier) = entry
.as_array()
.and_then(|tuple| tuple.first())
.and_then(serde_json::Value::as_str)
else {
continue;
};
if let Some(package_name) = package_name_from_lock_key(specifier) {
packages.insert(package_name);
}
}
}
collect_json_dependency_map_names(&value, &mut packages);
Some(packages)
}
fn collect_npm_lock_packages(source: &str) -> Option<FxHashSet<String>> {
let value = serde_json::from_str::<serde_json::Value>(source).ok()?;
let mut packages = FxHashSet::default();
if let Some(mapping) = value.get("packages").and_then(serde_json::Value::as_object) {
for key in mapping.keys() {
if let Some(idx) = key.rfind(NODE_MODULES_SEGMENT) {
let name = &key[idx + NODE_MODULES_SEGMENT.len()..];
if !name.is_empty() {
packages.insert(name.to_string());
}
}
}
}
collect_json_dependency_map_names(&value, &mut packages);
Some(packages)
}
fn collect_json_dependency_map_names(value: &serde_json::Value, packages: &mut FxHashSet<String>) {
match value {
serde_json::Value::Object(mapping) => {
for (key, child) in mapping {
if LOCKFILE_DEPENDENCY_SECTIONS.contains(&key.as_str())
&& let Some(dependencies) = child.as_object()
{
for package_name in dependencies.keys() {
packages.insert(package_name.clone());
}
}
collect_json_dependency_map_names(child, packages);
}
}
serde_json::Value::Array(items) => {
for item in items {
collect_json_dependency_map_names(item, packages);
}
}
_ => {}
}
}
fn collect_pnpm_lock_packages(source: &str) -> Option<FxHashSet<String>> {
let documents = yaml::parse_documents(source).ok()?;
let mut packages = FxHashSet::default();
for document in documents.iter().map(yaml::YamlDocument::root) {
if !is_pnpm_env_lock_document(document) {
collect_pnpm_lock_document_packages(document, &mut packages);
}
}
Some(packages)
}
fn collect_pnpm_lock_document_packages(
document: yaml::YamlNode<'_>,
packages: &mut FxHashSet<String>,
) {
let Some(root) = document.as_mapping() else {
return;
};
for section in ["packages", "snapshots"] {
let Some(mapping) = root.get(section).and_then(yaml::YamlNode::as_mapping) else {
continue;
};
for key in mapping.str_keys() {
if let Some(package_name) = package_name_from_lock_key(key) {
packages.insert(package_name);
}
}
}
collect_dependency_map_names(document, packages);
}
fn is_pnpm_env_lock_document(document: yaml::YamlNode<'_>) -> bool {
let Some(importers) = document
.get("importers")
.and_then(yaml::YamlNode::as_mapping)
else {
return false;
};
!importers.is_empty()
&& importers.values().all(|importer| {
importer.as_mapping().is_some_and(|sections| {
!sections.is_empty()
&& sections.keys().all(|key| {
key.as_str()
.is_some_and(|name| PNPM_ENV_IMPORTER_SECTIONS.contains(&name))
})
})
})
}
fn collect_dependency_map_names(value: yaml::YamlNode<'_>, packages: &mut FxHashSet<String>) {
let mut pending = vec![value];
while let Some(value) = pending.pop() {
if let Some(mapping) = value.as_mapping() {
for (key, child) in mapping.iter() {
if key
.as_str()
.is_some_and(|name| LOCKFILE_DEPENDENCY_SECTIONS.contains(&name))
&& let Some(dependencies) = child.as_mapping()
{
for package_name in dependencies.str_keys() {
packages.insert(package_name.to_string());
}
}
pending.push(child);
}
} else if let Some(items) = value.as_sequence() {
pending.extend(items);
}
}
}
fn package_name_from_lock_key(raw_key: &str) -> Option<String> {
let key = raw_key.trim().trim_start_matches('/');
if key.is_empty() {
return None;
}
if key.starts_with('@') {
let scope_end = key.find('/')?;
let package_segment = &key[scope_end + 1..];
let name_end = package_segment
.find(['@', '/', '('])
.unwrap_or(package_segment.len());
if name_end == 0 {
return None;
}
return Some(key[..scope_end + 1 + name_end].to_string());
}
let name_end = key.find(['@', '/', '(']).unwrap_or(key.len());
if name_end == 0 {
return None;
}
Some(key[..name_end].to_string())
}
fn report_override_resolution_skipped(config: &ResolvedConfig, unreadable: &[UnreadableLockfile]) {
let diagnostics = unreadable
.iter()
.map(|lockfile| {
let (path, kind) = match lockfile {
UnreadableLockfile::BunBinary => (
config.root.join(ROOT_PACKAGE_JSON),
WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
),
UnreadableLockfile::BunText => (
config.root.join(BUN_LOCK_FILE),
WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
),
UnreadableLockfile::Pnpm => (
config.root.join(PNPM_LOCK_FILE),
WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
),
UnreadableLockfile::Npm(file) => (
config.root.join(file),
WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
),
};
WorkspaceDiagnostic::new(&config.root, path, kind)
})
.collect();
record_override_diagnostics(config, diagnostics);
}
fn record_override_diagnostics(config: &ResolvedConfig, diagnostics: Vec<WorkspaceDiagnostic>) {
if should_emit_override_warning(config) {
record_workspace_diagnostics(&config.root, diagnostics);
} else {
append_workspace_diagnostics(&config.root, diagnostics);
}
}
fn should_emit_override_warning(config: &ResolvedConfig) -> bool {
!config.analysis_snapshot.is_base()
}
#[must_use]
pub fn find_unused_dependency_overrides(
state: &PnpmOverrideState,
config: &ResolvedConfig,
) -> Vec<UnusedDependencyOverride> {
if !state.unreadable_lockfiles.is_empty() {
report_override_resolution_skipped(config, &state.unreadable_lockfiles);
return Vec::new();
}
let mut findings = Vec::new();
let yaml_path = config.root.join(PNPM_WORKSPACE_FILE);
let json_path = config.root.join(ROOT_PACKAGE_JSON);
collect_unused_from_source(&mut UnusedOverrideSourceInput {
data: &state.workspace_yaml_data,
source: DependencyOverrideSource::PnpmWorkspaceYaml,
source_path: &yaml_path,
declared: &state.declared_packages,
resolved: &state.lockfile_packages,
hint: state.transitive_hint,
ignore_rules: &config.compiled_ignore_dependency_overrides,
findings: &mut findings,
});
collect_unused_from_source(&mut UnusedOverrideSourceInput {
data: &state.package_json_data,
source: DependencyOverrideSource::PnpmPackageJson,
source_path: &json_path,
declared: &state.declared_packages,
resolved: &state.lockfile_packages,
hint: state.transitive_hint,
ignore_rules: &config.compiled_ignore_dependency_overrides,
findings: &mut findings,
});
collect_unused_from_source(&mut UnusedOverrideSourceInput {
data: &state.npm_package_json_data,
source: DependencyOverrideSource::PnpmPackageJson,
source_path: &json_path,
declared: &state.declared_packages,
resolved: &state.lockfile_packages,
hint: state.transitive_hint,
ignore_rules: &config.compiled_ignore_dependency_overrides,
findings: &mut findings,
});
collect_unused_from_source(&mut UnusedOverrideSourceInput {
data: &state.bun_resolutions_data,
source: DependencyOverrideSource::PnpmPackageJson,
source_path: &json_path,
declared: &state.declared_packages,
resolved: &state.lockfile_packages,
hint: HINT_MAY_BE_TRANSITIVE_BUN_RESOLUTIONS,
ignore_rules: &config.compiled_ignore_dependency_overrides,
findings: &mut findings,
});
findings
}
struct UnusedOverrideSourceInput<'a> {
data: &'a PnpmOverrideData,
source: DependencyOverrideSource,
source_path: &'a std::path::Path,
declared: &'a FxHashSet<String>,
resolved: &'a FxHashSet<String>,
hint: &'static str,
ignore_rules: &'a [CompiledIgnoreDependencyOverrideRule],
findings: &'a mut Vec<UnusedDependencyOverride>,
}
fn collect_unused_from_source(input: &mut UnusedOverrideSourceInput<'_>) {
for entry in &input.data.entries {
let Some(parsed) = entry.parsed_key.as_ref() else {
continue;
};
let Some(value) = entry.raw_value.as_ref() else {
continue;
};
if !fallow_config::is_valid_override_value(value) {
continue;
}
if value.starts_with('$') {
continue;
}
let target_declared = input.declared.contains(&parsed.target_package);
let target_resolved = input.resolved.contains(&parsed.target_package);
let parent_declared = parsed
.parent_package
.as_ref()
.is_some_and(|p| input.declared.contains(p));
let parent_resolved = parsed
.parent_package
.as_ref()
.is_some_and(|p| input.resolved.contains(p));
if target_declared || target_resolved || parent_declared || parent_resolved {
continue;
}
let source_label = source_label_for(input.source);
if input
.ignore_rules
.iter()
.any(|rule| rule.matches(&parsed.target_package, source_label))
{
continue;
}
let hint = Some(input.hint.to_string());
input.findings.push(UnusedDependencyOverride {
raw_key: entry.raw_key.clone(),
target_package: parsed.target_package.clone(),
parent_package: parsed.parent_package.clone(),
version_constraint: parsed.target_version_selector.clone(),
version_range: value.clone(),
source: input.source,
path: input.source_path.to_path_buf(),
line: entry.line,
hint,
});
}
}
#[must_use]
pub fn find_misconfigured_dependency_overrides(
state: &PnpmOverrideState,
config: &ResolvedConfig,
) -> Vec<MisconfiguredDependencyOverride> {
let mut findings = Vec::new();
let yaml_path = config.root.join(PNPM_WORKSPACE_FILE);
let json_path = config.root.join(ROOT_PACKAGE_JSON);
collect_misconfigured_from_source(
&state.workspace_yaml_data,
DependencyOverrideSource::PnpmWorkspaceYaml,
&yaml_path,
&config.compiled_ignore_dependency_overrides,
&mut findings,
);
collect_misconfigured_from_source(
&state.package_json_data,
DependencyOverrideSource::PnpmPackageJson,
&json_path,
&config.compiled_ignore_dependency_overrides,
&mut findings,
);
collect_misconfigured_from_source(
&state.npm_package_json_data,
DependencyOverrideSource::PnpmPackageJson,
&json_path,
&config.compiled_ignore_dependency_overrides,
&mut findings,
);
collect_misconfigured_from_source(
&state.bun_resolutions_data,
DependencyOverrideSource::PnpmPackageJson,
&json_path,
&config.compiled_ignore_dependency_overrides,
&mut findings,
);
findings
}
fn collect_misconfigured_from_source(
data: &PnpmOverrideData,
source: DependencyOverrideSource,
source_path: &std::path::Path,
ignore_rules: &[CompiledIgnoreDependencyOverrideRule],
findings: &mut Vec<MisconfiguredDependencyOverride>,
) {
for entry in &data.entries {
let Some(reason) = parser_misconfig_reason(entry) else {
continue;
};
let target_for_ignore = entry
.parsed_key
.as_ref()
.map_or(entry.raw_key.as_str(), |p| p.target_package.as_str());
let source_label = source_label_for(source);
if ignore_rules
.iter()
.any(|rule| rule.matches(target_for_ignore, source_label))
{
continue;
}
let target_package = entry.parsed_key.as_ref().map(|p| p.target_package.clone());
findings.push(MisconfiguredDependencyOverride {
raw_key: entry.raw_key.clone(),
target_package,
raw_value: entry.raw_value.clone().unwrap_or_default(),
reason: map_misconfig_reason(reason),
source,
path: source_path.to_path_buf(),
line: entry.line,
});
}
}
const fn map_misconfig_reason(
reason: fallow_config::MisconfigReason,
) -> DependencyOverrideMisconfigReason {
match reason {
fallow_config::MisconfigReason::UnparsableKey => {
DependencyOverrideMisconfigReason::UnparsableKey
}
fallow_config::MisconfigReason::EmptyValue => DependencyOverrideMisconfigReason::EmptyValue,
}
}
const fn source_label_for(source: DependencyOverrideSource) -> &'static str {
match source {
DependencyOverrideSource::PnpmWorkspaceYaml => SOURCE_LABEL_YAML,
DependencyOverrideSource::PnpmPackageJson => SOURCE_LABEL_JSON,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_key_bare_package_with_version() {
assert_eq!(
package_name_from_lock_key("react@18.3.1"),
Some("react".to_string())
);
}
#[test]
fn lock_key_scoped_package_with_version() {
assert_eq!(
package_name_from_lock_key("@types/react@18.2.0"),
Some("@types/react".to_string())
);
}
#[test]
fn lock_key_scoped_package_with_peer_suffix() {
assert_eq!(
package_name_from_lock_key("@scope/pkg@1.0.0(peer@2.0.0)"),
Some("@scope/pkg".to_string())
);
}
#[test]
fn lock_key_pnpm6_leading_slash() {
assert_eq!(
package_name_from_lock_key("/react@18.3.1"),
Some("react".to_string())
);
}
#[test]
fn lock_key_pnpm6_leading_slash_scoped() {
assert_eq!(
package_name_from_lock_key("/@types/react@18.2.0"),
Some("@types/react".to_string())
);
}
#[test]
fn lock_key_no_version() {
assert_eq!(
package_name_from_lock_key("react"),
Some("react".to_string())
);
assert_eq!(
package_name_from_lock_key("@scope/pkg"),
Some("@scope/pkg".to_string())
);
}
#[test]
fn lock_key_npm_alias() {
assert_eq!(
package_name_from_lock_key("debug@npm:obug@^1.0.2"),
Some("debug".to_string())
);
}
#[test]
fn lock_key_paren_only_suffix() {
assert_eq!(
package_name_from_lock_key("react(peer@2)"),
Some("react".to_string())
);
}
#[test]
fn lock_key_whitespace_is_trimmed() {
assert_eq!(
package_name_from_lock_key(" react@1.0.0 "),
Some("react".to_string())
);
}
#[test]
fn lock_key_empty_returns_none() {
assert_eq!(package_name_from_lock_key(""), None);
assert_eq!(package_name_from_lock_key(" "), None);
assert_eq!(package_name_from_lock_key("/"), None);
}
#[test]
fn lock_key_malformed_scope_returns_none() {
assert_eq!(package_name_from_lock_key("@scope"), None);
assert_eq!(package_name_from_lock_key("@scope/"), None);
}
#[test]
fn collect_lock_packages_handles_lockfile_v9_shape() {
let source = "lockfileVersion: '9.0'\n\
\n\
importers:\n \
.:\n \
dependencies:\n \
react:\n specifier: ^18.0.0\n version: 18.3.1\n\
\n\
packages:\n \
react@18.3.1:\n resolution: {integrity: sha512-r}\n \
postcss@8.5.10:\n resolution: {integrity: sha512-p}\n\
\n\
snapshots:\n \
react@18.3.1:\n dependencies:\n loose-envify: 1.4.0\n \
postcss@8.5.10: {}\n \
loose-envify@1.4.0: {}\n";
let packages = collect_pnpm_lock_packages(source).expect("lockfile parses");
assert!(packages.contains("react"));
assert!(packages.contains("postcss"));
assert!(packages.contains("loose-envify"));
}
const PNPM_LOCK_TWO_DOCUMENTS: &str = "---\n\
lockfileVersion: '9.0'\n\
\n\
importers:\n\
\n .:\n configDependencies: {}\n packageManagerDependencies:\n \
pnpm:\n specifier: 12.6.0\n version: 12.6.0\n\
\n\
packages:\n\
\n '@pnpm/exe.darwin-arm64@12.6.0':\n resolution: {integrity: sha512-r}\n\
\n pnpm@12.6.0:\n resolution: {integrity: sha512-p}\n\
\n\
snapshots:\n\
\n '@pnpm/exe.darwin-arm64@12.6.0':\n optional: true\n\
\n pnpm@12.6.0:\n optionalDependencies:\n '@pnpm/exe.darwin-arm64': 12.6.0\n\
\n\
---\n\
lockfileVersion: '9.0'\n\
\n\
overrides:\n undici-types: 6.23.0\n\
\n\
importers:\n\
\n .:\n devDependencies:\n '@types/node':\n \
specifier: 22.20.4\n version: 22.20.4\n\
\n\
packages:\n\
\n '@types/node@22.20.4':\n resolution: {integrity: sha512-n}\n\
\n undici-types@6.23.0:\n resolution: {integrity: sha512-u}\n\
\n\
snapshots:\n\
\n '@types/node@22.20.4':\n dependencies:\n undici-types: 6.23.0\n\
\n undici-types@6.23.0: {}\n";
#[test]
fn collect_lock_packages_reads_project_document_of_two_document_lockfile() {
let packages =
collect_pnpm_lock_packages(PNPM_LOCK_TWO_DOCUMENTS).expect("lockfile parses");
assert!(packages.contains("undici-types"), "got {packages:?}");
assert!(packages.contains("@types/node"), "got {packages:?}");
}
#[test]
fn collect_lock_packages_skips_package_manager_document() {
let packages =
collect_pnpm_lock_packages(PNPM_LOCK_TWO_DOCUMENTS).expect("lockfile parses");
assert!(packages.contains("undici-types"), "got {packages:?}");
assert!(!packages.contains("pnpm"), "got {packages:?}");
assert!(
!packages.contains("@pnpm/exe.darwin-arm64"),
"got {packages:?}"
);
}
#[test]
fn collect_lock_packages_malformed_returns_none() {
assert!(collect_pnpm_lock_packages("lockfileVersion: '9.0\n this: [[[").is_none());
}
#[test]
fn collect_lock_packages_with_merge_conflict_markers_returns_none() {
assert!(collect_pnpm_lock_packages(&pnpm_lock_merge_conflict()).is_none());
}
#[test]
fn collect_lock_packages_empty_yields_empty() {
assert!(
collect_pnpm_lock_packages("")
.expect("an empty lockfile parses")
.is_empty()
);
}
fn pnpm_lock_merge_conflict() -> String {
let (ours, separator, theirs) = ("<".repeat(7), "=".repeat(7), ">".repeat(7));
format!(
"lockfileVersion: '9.0'
importers:
.:
dependencies:
express:
{ours} HEAD
specifier: ^4.21.2
version: 4.21.2
{separator}
specifier: ^4.21.1
version: 4.21.1
{theirs} feature-branch
packages:
express@4.21.2:
resolution: {{integrity: sha512-x}}
path-to-regexp@0.1.12:
resolution: {{integrity: sha512-y}}
"
)
}
#[test]
fn collect_lock_packages_survives_deep_nesting() {
const DEPTH: usize = 1_000_000;
let source = format!(
"importers:\n .:\n dependencies:\n react: {{}}\nx: {}{}\n",
"[".repeat(DEPTH),
"]".repeat(DEPTH)
);
let packages = collect_pnpm_lock_packages(&source).expect("lockfile parses");
assert!(packages.contains("react"));
}
const BUN_LOCK_REAL_SHAPE: &str = r#"{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-repro",
"devDependencies": {
"happy-dom": "^20.10.6",
},
},
},
"overrides": {
"ws": "^8.21.0",
},
"packages": {
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2"],
"happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hl"],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt"],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-20"],
}
}
"#;
#[test]
fn collect_bun_lock_packages_real_shape() {
let packages = collect_bun_lock_packages(BUN_LOCK_REAL_SHAPE).expect("bun.lock parses");
for name in [
"happy-dom",
"ws",
"@types/whatwg-mimetype",
"whatwg-mimetype",
] {
assert!(packages.contains(name), "missing {name}: {packages:?}");
}
assert!(
!packages.contains("bun-repro"),
"workspace name is not a resolved package"
);
}
#[test]
fn collect_bun_lock_packages_override_keys_are_not_credited_as_resolved() {
let source = r#"{
"lockfileVersion": 1,
"workspaces": { "": { "name": "x" } },
"overrides": { "left-pad": "^1.3.0" },
"packages": {}
}"#;
let packages = collect_bun_lock_packages(source).expect("parses");
assert!(!packages.contains("left-pad"), "got {packages:?}");
}
#[test]
fn collect_bun_lock_packages_nested_tree_path_uses_specifier_name() {
let source = r#"{
"lockfileVersion": 1,
"workspaces": { "": { "name": "x", "dependencies": { "parent-pkg": "^1.0.0" } } },
"packages": {
"parent-pkg": ["parent-pkg@1.0.0", "", { "dependencies": { "shared-dep": "^1.0.0" } }, "sha512-a"],
"parent-pkg/shared-dep": ["shared-dep@1.2.3", "", {}, "sha512-b"],
}
}"#;
let packages = collect_bun_lock_packages(source).expect("parses");
assert!(packages.contains("shared-dep"), "got {packages:?}");
assert!(packages.contains("parent-pkg"), "got {packages:?}");
}
#[test]
fn collect_bun_lock_packages_malformed_returns_none() {
assert!(collect_bun_lock_packages("not json {{{").is_none());
assert!(collect_bun_lock_packages("").is_none());
}
const BUN_LOCKB_PLACEHOLDER: &[u8] = b"\x00binary lockfile\x01\x02";
fn write_bun_manifest(root: &std::path::Path, overrides: Option<&str>) {
let overrides_field = overrides
.map(|value| format!(",\n \"overrides\": {value}"))
.unwrap_or_default();
std::fs::write(
root.join(ROOT_PACKAGE_JSON),
format!(
r#"{{
"name": "issue-2358-bun-lockb",
"private": true,
"packageManager": "bun@1.3.2",
"devDependencies": {{ "happy-dom": "^20.10.6" }}{overrides_field}
}}"#
),
)
.expect("write package.json");
}
fn resolve_config(root: &std::path::Path) -> ResolvedConfig {
fallow_config::FallowConfig::default().resolve(
root.to_path_buf(),
fallow_config::OutputFormat::Human,
1,
true,
true,
None,
)
}
fn run_unused_override_detector(
config: &ResolvedConfig,
) -> Option<Vec<UnusedDependencyOverride>> {
let state = gather_pnpm_override_state(config, &[])?;
Some(find_unused_dependency_overrides(&state, config))
}
fn bun_lockb_skip_diagnostics(
root: &std::path::Path,
) -> Vec<fallow_config::WorkspaceDiagnostic> {
fallow_config::workspace_diagnostics_for(root)
.into_iter()
.filter(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped
)
})
.collect()
}
#[test]
fn bun_lockb_only_with_overrides_records_skip_diagnostic_once() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_bun_manifest(root, Some(r#"{ "ws": "^8.21.0" }"#));
std::fs::write(root.join(BUN_LOCKB_FILE), BUN_LOCKB_PLACEHOLDER).expect("write bun.lockb");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"bun.lockb is unreadable; the check must stay skipped: {findings:?}"
);
let diagnostics = bun_lockb_skip_diagnostics(root);
assert_eq!(
diagnostics.len(),
1,
"expected exactly one skip diagnostic: {diagnostics:?}"
);
let diagnostic = &diagnostics[0];
assert_eq!(diagnostic.path, root.join(ROOT_PACKAGE_JSON));
assert!(
diagnostic
.message
.contains("bun install --save-text-lockfile"),
"message carries the text-lockfile hint: {}",
diagnostic.message
);
let _ = run_unused_override_detector(&config);
assert_eq!(
bun_lockb_skip_diagnostics(root).len(),
1,
"the registry dedupes on kind + path"
);
}
#[test]
fn bun_lockb_only_without_overrides_records_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_bun_manifest(root, None);
std::fs::write(root.join(BUN_LOCKB_FILE), BUN_LOCKB_PLACEHOLDER).expect("write bun.lockb");
let config = resolve_config(root);
assert!(
run_unused_override_detector(&config).is_none(),
"no override entries means no override state at all"
);
assert!(
bun_lockb_skip_diagnostics(root).is_empty(),
"nothing was skipped, so nothing is announced"
);
}
#[test]
fn malformed_text_bun_lock_fails_closed_with_a_diagnostic() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_bun_manifest(root, Some(r#"{ "transitive-only": "^1.0.0" }"#));
std::fs::write(root.join(BUN_LOCK_FILE), "not valid json").expect("write bun.lock");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"unreadable resolution must not produce removal advice"
);
assert!(
fallow_config::workspace_diagnostics_for(root)
.iter()
.any(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped
)
})
);
}
fn write_pnpm_manifest(root: &std::path::Path) {
std::fs::write(
root.join(ROOT_PACKAGE_JSON),
r#"{
"name": "pnpm-lock-merge-conflict",
"private": true,
"dependencies": { "express": "^4.21.2" },
"pnpm": { "overrides": { "path-to-regexp": "0.1.12", "absent-pkg": "^1.0.0" } }
}"#,
)
.expect("write package.json");
}
fn pnpm_lock_skip_diagnostics(
root: &std::path::Path,
) -> Vec<fallow_config::WorkspaceDiagnostic> {
fallow_config::workspace_diagnostics_for(root)
.into_iter()
.filter(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped
)
})
.collect()
}
#[test]
fn pnpm_lock_with_merge_conflict_markers_fails_closed_with_a_diagnostic() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_pnpm_manifest(root);
std::fs::write(root.join(PNPM_LOCK_FILE), pnpm_lock_merge_conflict())
.expect("write pnpm-lock.yaml");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"an unparseable pnpm-lock.yaml must not produce removal advice: {:?}",
findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect::<Vec<_>>()
);
let diagnostics = pnpm_lock_skip_diagnostics(root);
assert_eq!(diagnostics.len(), 1, "got {diagnostics:?}");
assert_eq!(diagnostics[0].path, root.join(PNPM_LOCK_FILE));
}
fn npm_lock_merge_conflict() -> String {
let (ours, separator, theirs) = ("<".repeat(7), "=".repeat(7), ">".repeat(7));
format!(
r#"{{
"name": "npm-lock-merge-conflict",
"lockfileVersion": 3,
"packages": {{
"": {{ "dependencies": {{ "express": "^4.21.2" }} }},
{ours} HEAD
"node_modules/express": {{ "version": "4.21.2" }},
{separator}
"node_modules/express": {{ "version": "4.21.1" }},
{theirs} feature-branch
"node_modules/path-to-regexp": {{ "version": "0.1.12" }}
}}
}}"#
)
}
fn write_npm_manifest(root: &std::path::Path) {
std::fs::write(
root.join(ROOT_PACKAGE_JSON),
r#"{
"name": "npm-lock-merge-conflict",
"private": true,
"dependencies": { "express": "^4.21.2" },
"overrides": { "path-to-regexp": "0.1.12", "absent-pkg": "^1.0.0" }
}"#,
)
.expect("write package.json");
}
fn npm_lock_skip_diagnostics(
root: &std::path::Path,
) -> Vec<fallow_config::WorkspaceDiagnostic> {
fallow_config::workspace_diagnostics_for(root)
.into_iter()
.filter(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped
)
})
.collect()
}
#[test]
fn npm_lock_with_merge_conflict_markers_fails_closed_with_a_diagnostic() {
for lock_file in [NPM_LOCK_FILE, NPM_SHRINKWRAP_FILE] {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_npm_manifest(root);
std::fs::write(root.join(lock_file), npm_lock_merge_conflict())
.expect("write lockfile");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"an unparseable {lock_file} must not produce removal advice: {:?}",
findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect::<Vec<_>>()
);
let diagnostics = npm_lock_skip_diagnostics(root);
assert_eq!(diagnostics.len(), 1, "{lock_file}: got {diagnostics:?}");
assert_eq!(diagnostics[0].path, root.join(lock_file));
}
}
#[test]
fn non_utf8_npm_lock_fails_closed_with_a_diagnostic() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_npm_manifest(root);
std::fs::write(root.join(NPM_LOCK_FILE), b"{\xff\xfe}").expect("write package-lock.json");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"an unreadable package-lock.json must not produce removal advice"
);
let diagnostics = npm_lock_skip_diagnostics(root);
assert_eq!(diagnostics.len(), 1, "got {diagnostics:?}");
assert_eq!(diagnostics[0].path, root.join(NPM_LOCK_FILE));
}
#[test]
fn unparseable_npm_lock_next_to_parseable_shrinkwrap_keeps_the_analysis() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_npm_manifest(root);
std::fs::write(root.join(NPM_LOCK_FILE), npm_lock_merge_conflict())
.expect("write package-lock.json");
std::fs::write(
root.join(NPM_SHRINKWRAP_FILE),
r#"{
"lockfileVersion": 3,
"packages": {
"": { "dependencies": { "express": "^4.21.2" } },
"node_modules/express": { "version": "4.21.2" },
"node_modules/path-to-regexp": { "version": "0.1.12" }
}
}"#,
)
.expect("write npm-shrinkwrap.json");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
let flagged: Vec<&str> = findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect();
assert_eq!(flagged, vec!["absent-pkg"]);
assert!(npm_lock_skip_diagnostics(root).is_empty());
}
#[test]
fn unparseable_pnpm_lock_next_to_parseable_npm_lock_keeps_the_analysis() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_pnpm_manifest(root);
std::fs::write(root.join(PNPM_LOCK_FILE), pnpm_lock_merge_conflict())
.expect("write pnpm-lock.yaml");
std::fs::write(
root.join(NPM_LOCK_FILE),
r#"{
"lockfileVersion": 3,
"packages": {
"": { "dependencies": { "express": "^4.21.2" } },
"node_modules/express": { "version": "4.21.2" },
"node_modules/path-to-regexp": { "version": "0.1.12" }
}
}"#,
)
.expect("write package-lock.json");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
let flagged: Vec<&str> = findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect();
assert_eq!(flagged, vec!["absent-pkg"]);
assert!(pnpm_lock_skip_diagnostics(root).is_empty());
}
#[test]
fn bun_lockb_next_to_text_bun_lock_resolves_and_records_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_bun_manifest(root, Some(r#"{ "ws": "^8.21.0", "left-pad": "^1.3.0" }"#));
std::fs::write(root.join(BUN_LOCKB_FILE), BUN_LOCKB_PLACEHOLDER).expect("write bun.lockb");
std::fs::write(root.join(BUN_LOCK_FILE), BUN_LOCK_REAL_SHAPE).expect("write bun.lock");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
let flagged: Vec<&str> = findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect();
assert_eq!(
flagged,
vec!["left-pad"],
"bun.lock resolves ws transitively; left-pad stays unresolved"
);
assert!(
bun_lockb_skip_diagnostics(root).is_empty(),
"a parseable bun.lock means resolution ran, so no skip diagnostic"
);
}
#[test]
fn bun_lockb_next_to_yarn_lock_or_unparseable_bun_lock_still_records_skip() {
for (sibling, content) in [
(YARN_LOCK_FILE, "# yarn lockfile v1\n"),
(BUN_LOCK_FILE, ""),
] {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_bun_manifest(root, Some(r#"{ "ws": "^8.21.0" }"#));
std::fs::write(root.join(BUN_LOCKB_FILE), BUN_LOCKB_PLACEHOLDER)
.expect("write bun.lockb");
std::fs::write(root.join(sibling), content).expect("write sibling lockfile");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"{sibling} next to bun.lockb does not restore resolution: {findings:?}"
);
let diagnostics: Vec<_> = fallow_config::workspace_diagnostics_for(root)
.into_iter()
.filter(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped
| fallow_config::WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped
)
})
.collect();
assert_eq!(
diagnostics.len(),
1,
"{sibling} next to bun.lockb still skips and announces it: {diagnostics:?}"
);
let message = &diagnostics[0].message;
if sibling == YARN_LOCK_FILE {
assert!(
!message.contains("only bun.lockb")
&& message.contains("no parseable text lockfile")
&& message.contains("delete the stale bun.lockb"),
"message describes the binary-lock condition: {message}"
);
} else {
assert!(
message.contains("could not be parsed") && message.contains("regenerate"),
"message describes the malformed text lockfile: {message}"
);
}
}
}
#[test]
fn no_lockfile_keeps_declaration_only_analysis_without_diagnostic() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_bun_manifest(root, Some(r#"{ "ws": "^8.21.0" }"#));
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
let flagged: Vec<&str> = findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect();
assert_eq!(
flagged,
vec!["ws"],
"without any lockfile the declaration-only fallback still reports"
);
assert!(
bun_lockb_skip_diagnostics(root).is_empty(),
"the diagnostic is specific to bun.lockb, not to a missing lockfile"
);
}
fn write_manifest(
root: &std::path::Path,
package_manager: Option<&str>,
overrides: Option<&str>,
resolutions: Option<&str>,
) {
let package_manager_field = package_manager
.map(|value| format!(",\n \"packageManager\": \"{value}\""))
.unwrap_or_default();
let overrides_field = overrides
.map(|value| format!(",\n \"overrides\": {value}"))
.unwrap_or_default();
let resolutions_field = resolutions
.map(|value| format!(",\n \"resolutions\": {value}"))
.unwrap_or_default();
std::fs::write(
root.join(ROOT_PACKAGE_JSON),
format!(
r#"{{
"name": "issue-2367-bun-resolutions",
"private": true,
"devDependencies": {{ "happy-dom": "^20.10.6" }}{package_manager_field}{overrides_field}{resolutions_field}
}}"#
),
)
.expect("write package.json");
}
fn run_misconfigured_override_detector(
config: &ResolvedConfig,
) -> Option<Vec<MisconfiguredDependencyOverride>> {
let state = gather_pnpm_override_state(config, &[])?;
Some(find_misconfigured_dependency_overrides(&state, config))
}
fn flagged_targets(findings: &[UnusedDependencyOverride]) -> Vec<&str> {
findings
.iter()
.map(|finding| finding.target_package.as_str())
.collect()
}
const RESOLUTIONS_WS_AND_LEFT_PAD: &str = r#"{ "ws": "^8.21.0", "left-pad": "^1.3.0" }"#;
const RESOLUTIONS_LEFT_PAD: &str = r#"{ "left-pad": "^1.3.0" }"#;
#[test]
fn bun_nonempty_resolutions_shadowed_by_overrides_are_diagnostic_only() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(
root,
Some("bun@1.3.2"),
Some(r#"{ "ws": "^8.21.0" }"#),
Some(RESOLUTIONS_LEFT_PAD),
);
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert_eq!(flagged_targets(&findings), vec!["ws"]);
assert!(
fallow_config::workspace_diagnostics_for(root)
.iter()
.any(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides
)
})
);
}
#[test]
fn base_snapshot_keeps_override_diagnostic_structured_without_warning() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(
root,
Some("bun@1.3.2"),
Some(r#"{ "ws": "^8.21.0" }"#),
Some(RESOLUTIONS_LEFT_PAD),
);
let mut config = resolve_config(root);
config.analysis_snapshot = fallow_config::AnalysisSnapshot::Base;
assert!(!should_emit_override_warning(&config));
let _ = run_unused_override_detector(&config);
assert!(
fallow_config::workspace_diagnostics_for(root)
.iter()
.any(|diagnostic| {
matches!(
diagnostic.kind,
fallow_config::WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides
)
})
);
}
#[test]
fn bun_resolutions_only_next_to_bun_lockb_records_skip_diagnostic_once() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(
root,
Some("bun@1.3.2"),
None,
Some(RESOLUTIONS_WS_AND_LEFT_PAD),
);
std::fs::write(root.join(BUN_LOCKB_FILE), BUN_LOCKB_PLACEHOLDER).expect("write bun.lockb");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config)
.expect("resolutions are override state in a bun repository");
assert!(
findings.is_empty(),
"bun.lockb is unreadable; the check must stay skipped: {findings:?}"
);
let diagnostics = bun_lockb_skip_diagnostics(root);
assert_eq!(
diagnostics.len(),
1,
"a resolutions-only manifest announces the skip like an overrides one: {diagnostics:?}"
);
assert_eq!(diagnostics[0].path, root.join(ROOT_PACKAGE_JSON));
let _ = run_unused_override_detector(&config);
assert_eq!(
bun_lockb_skip_diagnostics(root).len(),
1,
"the registry dedupes on kind + path"
);
}
#[test]
fn bun_resolutions_resolve_against_text_bun_lock() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(
root,
Some("bun@1.3.2"),
None,
Some(RESOLUTIONS_WS_AND_LEFT_PAD),
);
std::fs::write(root.join(BUN_LOCK_FILE), BUN_LOCK_REAL_SHAPE).expect("write bun.lock");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config)
.expect("resolutions are override state in a bun repository");
assert_eq!(
flagged_targets(&findings),
vec!["left-pad"],
"bun.lock resolves ws transitively; left-pad stays unresolved"
);
let finding = &findings[0];
assert_eq!(finding.raw_key, "left-pad");
assert_eq!(finding.source, DependencyOverrideSource::PnpmPackageJson);
assert_eq!(finding.path, root.join(ROOT_PACKAGE_JSON));
assert_eq!(finding.line, 6, "the resolutions object sits on line 6");
let hint = finding.hint.as_deref().unwrap_or_default();
assert!(
hint.contains("resolutions") && hint.contains("bun install --frozen-lockfile"),
"the bun hint names the resolutions origin: {hint}"
);
assert!(
bun_lockb_skip_diagnostics(root).is_empty(),
"a parseable bun.lock means resolution ran"
);
}
#[test]
fn bun_overrides_key_shadows_resolutions() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(
root,
Some("bun@1.3.2"),
Some(r#"{ "ws": "^8.21.0" }"#),
Some(RESOLUTIONS_LEFT_PAD),
);
std::fs::write(root.join(BUN_LOCK_FILE), BUN_LOCK_REAL_SHAPE).expect("write bun.lock");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config).expect("overrides are declared");
assert!(
findings.is_empty(),
"bun never reads resolutions once an overrides key exists: {findings:?}"
);
let empty_dir = tempfile::tempdir().expect("tempdir");
let empty_root = empty_dir.path();
write_manifest(
empty_root,
Some("bun@1.3.2"),
Some("{}"),
Some(RESOLUTIONS_LEFT_PAD),
);
std::fs::write(empty_root.join(BUN_LOCK_FILE), BUN_LOCK_REAL_SHAPE)
.expect("write bun.lock");
let config = resolve_config(empty_root);
assert!(
run_unused_override_detector(&config).is_none(),
"an empty overrides object still shadows resolutions, so no override state exists"
);
}
#[derive(Debug)]
struct NonBunRepository {
package_manager: Option<&'static str>,
lockfile: Option<(&'static str, &'static str)>,
}
#[test]
fn resolutions_are_ignored_outside_bun_repositories() {
let cases = [
NonBunRepository {
package_manager: Some("yarn@4.5.0"),
lockfile: Some((YARN_LOCK_FILE, "# yarn lockfile v1\n")),
},
NonBunRepository {
package_manager: Some("npm@10.9.0"),
lockfile: Some((BUN_LOCK_FILE, BUN_LOCK_REAL_SHAPE)),
},
NonBunRepository {
package_manager: None,
lockfile: Some((PNPM_LOCK_FILE, "lockfileVersion: '9.0'\n")),
},
NonBunRepository {
package_manager: None,
lockfile: None,
},
];
for case in cases {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(root, case.package_manager, None, Some(RESOLUTIONS_LEFT_PAD));
if let Some((name, content)) = case.lockfile {
std::fs::write(root.join(name), content).expect("write lockfile");
}
let config = resolve_config(root);
assert!(
run_unused_override_detector(&config).is_none(),
"{case:?}: resolutions are not an override source outside bun repositories"
);
}
}
#[test]
fn bun_lockfile_without_package_manager_field_enables_resolutions() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(root, None, None, Some(RESOLUTIONS_WS_AND_LEFT_PAD));
std::fs::write(root.join(BUN_LOCK_FILE), BUN_LOCK_REAL_SHAPE).expect("write bun.lock");
let config = resolve_config(root);
let findings = run_unused_override_detector(&config)
.expect("a root bun.lock marks the repository as bun");
assert_eq!(flagged_targets(&findings), vec!["left-pad"]);
let lockb_dir = tempfile::tempdir().expect("tempdir");
let lockb_root = lockb_dir.path();
write_manifest(lockb_root, None, None, Some(RESOLUTIONS_WS_AND_LEFT_PAD));
std::fs::write(lockb_root.join(BUN_LOCKB_FILE), BUN_LOCKB_PLACEHOLDER)
.expect("write bun.lockb");
let config = resolve_config(lockb_root);
let findings = run_unused_override_detector(&config)
.expect("a root bun.lockb marks the repository as bun");
assert!(findings.is_empty(), "bun.lockb is unreadable: {findings:?}");
assert_eq!(
bun_lockb_skip_diagnostics(lockb_root).len(),
1,
"the skip is announced for the resolutions-only manifest"
);
}
#[test]
fn bun_resolutions_yarn_path_keys_credit_parents_and_flag_rejected_shapes() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write_manifest(
root,
Some("bun@1.3.2"),
None,
Some(
r#"{
"**/ws": "^8.21.0",
"happy-dom/left-pad": "^1.3.0",
"**/left-pad": "^1.3.0",
"a/b/c": "^1.0.0",
"nested": { "left-pad": "^1.3.0" }
}"#,
),
);
std::fs::write(root.join(BUN_LOCK_FILE), BUN_LOCK_REAL_SHAPE).expect("write bun.lock");
let config = resolve_config(root);
let unused = run_unused_override_detector(&config).expect("resolutions are declared");
let flagged: Vec<(&str, &str)> = unused
.iter()
.map(|finding| (finding.raw_key.as_str(), finding.target_package.as_str()))
.collect();
assert_eq!(
flagged,
vec![("**/left-pad", "left-pad")],
"ws resolves through bun.lock, happy-dom/left-pad is credited through its declared parent, and the shapes bun rejects never reach the unused check"
);
let misconfigured =
run_misconfigured_override_detector(&config).expect("resolutions are declared");
let reasons: Vec<(&str, DependencyOverrideMisconfigReason)> = misconfigured
.iter()
.map(|finding| (finding.raw_key.as_str(), finding.reason))
.collect();
assert_eq!(
reasons,
vec![
("a/b/c", DependencyOverrideMisconfigReason::UnparsableKey),
("nested", DependencyOverrideMisconfigReason::EmptyValue),
],
"a path deeper than one parent and a non-string value are the shapes bun warns about and skips"
);
assert!(
misconfigured
.iter()
.all(|finding| finding.source == DependencyOverrideSource::PnpmPackageJson)
);
}
}