diff --git a/Cargo.lock b/Cargo.lock index b5beb37476672..9c227fb36a987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6815,6 +6815,7 @@ dependencies = [ "turborepo-vercel-api-mock", "twox-hash", "uds_windows", + "walkdir", "wax", "webbrowser", "which", diff --git a/Cargo.toml b/Cargo.toml index 51bc929251264..0d51b872c474c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -176,6 +176,7 @@ urlencoding = "2.1.2" webbrowser = "0.8.7" which = "4.4.0" unicode-segmentation = "1.10.1" +walkdir = "2.3.2" # Needed until a fix for https://github.com/async-graphql/async-graphql/issues/1703 is published diff --git a/crates/turborepo-lib/Cargo.toml b/crates/turborepo-lib/Cargo.toml index 090b3303b46b7..902099b7d4f8d 100644 --- a/crates/turborepo-lib/Cargo.toml +++ b/crates/turborepo-lib/Cargo.toml @@ -25,8 +25,8 @@ port_scanner = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } test-case = { workspace = true } +tracing = { workspace = true } tracing-test = { version = "0.2.4", features = ["no-env-filter"] } -tracing.workspace = true turborepo-vercel-api-mock = { workspace = true } [lints] @@ -91,7 +91,7 @@ prost = "0.12.3" radix_trie = { workspace = true } rand = { workspace = true } rayon = "1.7.0" -regex.workspace = true +regex = { workspace = true } reqwest = { workspace = true, default-features = false, features = ["json"] } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -116,10 +116,10 @@ tokio-util = { version = "0.7.7", features = ["compat"] } tonic = { version = "0.11.0", features = ["transport"] } tower = "0.4.13" tower-http = { version = "0.5.2", features = ["cors"] } +tracing = { workspace = true } tracing-appender = "0.2.2" tracing-chrome = "0.7.1" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } -tracing.workspace = true turbo-trace = { workspace = true } turbo-updater = { workspace = true } turbopath = { workspace = true } @@ -148,6 +148,7 @@ turborepo-unescape = { workspace = true } turborepo-vercel-api = { path = "../turborepo-vercel-api" } twox-hash = "1.6.3" uds_windows = "1.0.2" +walkdir = { workspace = true } wax = { workspace = true } webbrowser = { workspace = true } which = { workspace = true } diff --git a/crates/turborepo-lib/src/cli/error.rs b/crates/turborepo-lib/src/cli/error.rs index 56bf15f13d981..8596fb930902a 100644 --- a/crates/turborepo-lib/src/cli/error.rs +++ b/crates/turborepo-lib/src/cli/error.rs @@ -76,6 +76,8 @@ pub enum Error { SignalListener(#[from] turborepo_signals::listeners::Error), #[error(transparent)] Dialoguer(#[from] dialoguer::Error), + #[error(transparent)] + DepsSync(#[from] crate::commands::deps_sync::Error), } const MAX_CHARS_PER_TASK_LINE: usize = 100; diff --git a/crates/turborepo-lib/src/cli/mod.rs b/crates/turborepo-lib/src/cli/mod.rs index 81339812a1059..5ffdd535e9ba5 100644 --- a/crates/turborepo-lib/src/cli/mod.rs +++ b/crates/turborepo-lib/src/cli/mod.rs @@ -28,8 +28,8 @@ use turborepo_ui::{ColorConfig, GREY}; use crate::{ cli::error::print_potential_tasks, commands::{ - bin, boundaries, clone, config, daemon, generate, info, link, login, logout, ls, prune, - query, run, scan, telemetry, unlink, CommandBase, + bin, boundaries, clone, config, daemon, deps_sync, generate, info, link, login, logout, ls, + prune, query, run, scan, telemetry, unlink, CommandBase, }, get_version, run::watch::WatchClient, @@ -614,6 +614,15 @@ pub enum Command { #[clap(subcommand)] command: Option, }, + /// Check that all dependencies across workspaces are synchronized + #[clap(name = "deps-sync")] + DepsSync { + /// Generate allowlist configuration for current conflicts instead of + /// reporting them. This helps with incremental adoption by + /// writing configuration that ignores existing conflicts. + #[clap(long)] + allowlist: bool, + }, /// Generate a new app / package #[clap(aliases = ["g", "gen"])] Generate { @@ -1441,6 +1450,14 @@ pub async fn run( Ok(0) } + Command::DepsSync { allowlist } => { + let event = CommandEventBuilder::new("deps-sync").with_parent(&root_telemetry); + event.track_call(); + let base = CommandBase::new(cli_args.clone(), repo_root, version, color_config)?; + event.track_ui_mode(base.opts.run_opts.ui_mode); + + Ok(deps_sync::run(&base, *allowlist).await?) + } Command::Generate { tag, generator_name, diff --git a/crates/turborepo-lib/src/commands/deps_sync.rs b/crates/turborepo-lib/src/commands/deps_sync.rs new file mode 100644 index 0000000000000..770ef9d60b96d --- /dev/null +++ b/crates/turborepo-lib/src/commands/deps_sync.rs @@ -0,0 +1,1527 @@ +use std::collections::{HashMap, HashSet}; + +use biome_deserialize_macros::Deserializable; +use serde_json::Value; +use thiserror::Error; +use turbopath::AbsoluteSystemPath; +use turborepo_repository::{ + discovery::{ + DiscoveryResponse, LocalPackageDiscoveryBuilder, PackageDiscovery, PackageDiscoveryBuilder, + }, + package_json::PackageJson, +}; +use turborepo_ui::ColorConfig; + +use super::CommandBase; +use crate::turbo_json::RawTurboJson; + +const DEPENDENCY_TYPES: [&str; 3] = ["dependencies", "devDependencies", "optionalDependencies"]; +const SUCCESS_PREFIX: &str = "✅"; +const ERROR_PREFIX: &str = "❌"; +const SCANNING_MESSAGE: &str = "🔍 Scanning workspace packages for dependency conflicts..."; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Failed to read package.json at {path}: {source}")] + PackageJsonRead { + path: String, + #[source] + source: turborepo_repository::package_json::Error, + }, + #[error("Failed to read file at {path}: {source}")] + FileRead { + path: String, + #[source] + source: std::io::Error, + }, + #[error("Failed to parse JSON in {path}: {source}")] + JsonParse { + path: String, + #[source] + source: serde_json::Error, + }, + #[error("Failed to get path: {0}")] + Path(#[from] turbopath::PathError), + #[error("Failed to discover packages: {0}")] + Discovery(#[from] turborepo_repository::discovery::Error), + #[error("Failed to resolve package manager: {0}")] + PackageManager(#[from] turborepo_repository::package_manager::Error), + #[error("Failed to read turbo.json: {0}")] + Config(#[from] crate::config::Error), + #[error( + "deps-sync is not needed for single-package workspaces. This command analyzes dependency \ + conflicts across multiple packages in a workspace." + )] + SinglePackageWorkspace, +} + +#[derive(Debug, Clone, Deserializable, serde::Deserialize, serde::Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DepsSyncConfig { + /// Dependencies that should be pinned to a specific version across all + /// packages by default. Packages can be excluded using the `exceptions` + /// field. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub pinned_dependencies: HashMap, + /// Dependencies that should be ignored in specific packages. + /// The `exceptions` field lists the packages where the dependency should be + /// ignored. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub ignored_dependencies: HashMap, + /// Whether to include optionalDependencies in conflict analysis. + /// Defaults to false since optional dependencies are often + /// platform-specific and should gracefully handle version differences. + #[serde(default)] + pub include_optional_dependencies: bool, +} + +#[derive( + Debug, Clone, Default, PartialEq, Deserializable, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "camelCase")] +pub struct PinnedDependency { + /// The version to pin this dependency to + #[serde(default)] + pub version: String, + /// Packages where this dependency should NOT be pinned (exceptions to the + /// rule) + #[serde(default)] + pub exceptions: Vec, +} + +#[derive( + Debug, Clone, Default, PartialEq, Deserializable, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "camelCase")] +pub struct IgnoredDependency { + /// Packages where this dependency should be ignored + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exceptions: Vec, +} + +#[derive(Debug, Clone)] +struct DependencyInfo { + package_name: String, + package_path: String, + dependency_name: String, + version: String, + dependency_type: DependencyType, +} + +#[derive(Debug, Clone, PartialEq)] +enum DependencyType { + Dependencies, + DevDependencies, + OptionalDependencies, +} + +impl std::fmt::Display for DependencyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DependencyType::Dependencies => write!(f, "dependencies"), + DependencyType::DevDependencies => write!(f, "devDependencies"), + DependencyType::OptionalDependencies => write!(f, "optionalDependencies"), + } + } +} + +#[derive(Debug, Clone)] +struct DependencyUsage { + package_name: String, + version: String, + package_path: String, +} + +#[derive(Debug, Clone)] +struct DependencyConflict { + dependency_name: String, + conflicting_packages: Vec, + conflict_reason: Option, +} + +/// Performance optimization: Create lookup sets for faster exception checking +#[derive(Debug)] +struct OptimizedConfig { + pinned_dependencies: HashMap, + ignored_dependencies: HashMap, + include_optional_dependencies: bool, + // Optimized lookup sets + pinned_dependency_names: HashSet, + ignored_exception_sets: HashMap>, + pinned_exception_sets: HashMap>, +} + +impl From for OptimizedConfig { + fn from(config: DepsSyncConfig) -> Self { + let pinned_dependency_names = config.pinned_dependencies.keys().cloned().collect(); + + let ignored_exception_sets = config + .ignored_dependencies + .iter() + .map(|(dep_name, ignored_dep)| { + ( + dep_name.clone(), + ignored_dep.exceptions.iter().cloned().collect(), + ) + }) + .collect(); + + let pinned_exception_sets = config + .pinned_dependencies + .iter() + .map(|(dep_name, pinned_dep)| { + ( + dep_name.clone(), + pinned_dep.exceptions.iter().cloned().collect(), + ) + }) + .collect(); + + Self { + pinned_dependencies: config.pinned_dependencies, + ignored_dependencies: config.ignored_dependencies, + include_optional_dependencies: config.include_optional_dependencies, + pinned_dependency_names, + ignored_exception_sets, + pinned_exception_sets, + } + } +} + +impl From for DepsSyncConfig { + fn from(config: OptimizedConfig) -> Self { + Self { + pinned_dependencies: config.pinned_dependencies, + ignored_dependencies: config.ignored_dependencies, + include_optional_dependencies: config.include_optional_dependencies, + } + } +} + +pub async fn run(base: &CommandBase, allowlist: bool) -> Result { + let color_config = base.color_config; + + // Validate workspace has multiple packages first, before printing any messages + let workspace_response = discover_workspace_packages(&base.repo_root).await?; + validate_multi_package_workspace(&workspace_response)?; + + println!("{}", SCANNING_MESSAGE); + + let deps_sync_config = load_deps_sync_config(&base.repo_root).await?; + let optimized_config = OptimizedConfig::from(deps_sync_config); + + // Print configuration summary + print_configuration_summary(&optimized_config, color_config); + + // Collect and analyze dependencies + let all_dependencies = + collect_all_dependencies(&base.repo_root, workspace_response, &optimized_config).await?; + let version_conflicts = find_version_conflicts(&all_dependencies, &optimized_config); + let pinned_conflicts = find_pinned_version_conflicts(&all_dependencies, &optimized_config); + + let all_conflicts: Vec<_> = version_conflicts + .into_iter() + .chain(pinned_conflicts) + .collect(); + + // Handle results + handle_analysis_results( + all_conflicts, + allowlist, + &base.repo_root, + &optimized_config.into(), + color_config, + ) + .await +} + +async fn load_deps_sync_config(repo_root: &AbsoluteSystemPath) -> Result { + let config_opts = crate::config::ConfigurationOptions::default(); + let turbo_json_path = config_opts + .root_turbo_json_path(repo_root) + .map_err(Error::Config)?; + + let raw_turbo_json = match RawTurboJson::read(repo_root, &turbo_json_path)? { + Some(turbo_json) => turbo_json, + None => return Ok(DepsSyncConfig::default()), + }; + + Ok(raw_turbo_json.deps_sync.unwrap_or_default()) +} + +async fn discover_workspace_packages( + repo_root: &AbsoluteSystemPath, +) -> Result { + let discovery = LocalPackageDiscoveryBuilder::new(repo_root.to_owned(), None, None).build()?; + discovery + .discover_packages() + .await + .map_err(Error::Discovery) +} + +fn validate_multi_package_workspace(workspace_response: &DiscoveryResponse) -> Result<(), Error> { + if workspace_response.workspaces.len() <= 1 { + return Err(Error::SinglePackageWorkspace); + } + Ok(()) +} + +fn print_configuration_summary(config: &OptimizedConfig, color_config: ColorConfig) { + if !config.ignored_dependencies.is_empty() { + let ignored_count = config.ignored_dependencies.len(); + let dependency_word = if ignored_count == 1 { + "dependency" + } else { + "dependencies" + }; + let message = format!( + "→ {} ignored {} in `turbo.json`", + ignored_count, dependency_word + ); + + print_colored_message(&message, color_config, MessageType::Info); + } + println!(); +} + +enum MessageType { + Success, + Error, + Info, +} + +fn print_colored_message(message: &str, color_config: ColorConfig, message_type: MessageType) { + if color_config.should_strip_ansi { + println!("{}", message); + } else { + use turborepo_ui::{BOLD_GREEN, BOLD_RED, GREY}; + let styled_message = match message_type { + MessageType::Success => format!("{}", BOLD_GREEN.apply_to(message)), + MessageType::Error => format!("{}", BOLD_RED.apply_to(message)), + MessageType::Info => format!("{}", GREY.apply_to(message)), + }; + println!("{}", styled_message); + } +} + +async fn handle_analysis_results( + conflicts: Vec, + allowlist: bool, + repo_root: &AbsoluteSystemPath, + config: &DepsSyncConfig, + color_config: ColorConfig, +) -> Result { + if conflicts.is_empty() { + print_colored_message( + "✅ All dependencies are in sync!", + color_config, + MessageType::Success, + ); + Ok(0) + } else if allowlist { + generate_and_write_allowlist(conflicts, repo_root, config, color_config).await + } else { + print_conflicts(&conflicts, color_config); + Ok(1) + } +} + +async fn generate_and_write_allowlist( + conflicts: Vec, + repo_root: &AbsoluteSystemPath, + current_config: &DepsSyncConfig, + color_config: ColorConfig, +) -> Result { + let allowlist_config = generate_allowlist_config(&conflicts, current_config); + write_allowlist_config(repo_root, &allowlist_config).await?; + + let success_message = format!( + "✅ Generated allowlist configuration for {} conflicts in turbo.json. Dependencies are \ + now synchronized!", + conflicts.len() + ); + print_colored_message(&success_message, color_config, MessageType::Success); + Ok(0) +} + +async fn collect_all_dependencies( + repo_root: &AbsoluteSystemPath, + workspace_response: DiscoveryResponse, + config: &OptimizedConfig, +) -> Result, Error> { + let mut all_dependencies = Vec::new(); + + for workspace_data in workspace_response.workspaces { + let package_dependencies = + collect_package_dependencies(repo_root, &workspace_data.package_json, config).await?; + all_dependencies.extend(package_dependencies); + } + + Ok(all_dependencies) +} + +async fn collect_package_dependencies( + repo_root: &AbsoluteSystemPath, + package_json_path: &AbsoluteSystemPath, + config: &OptimizedConfig, +) -> Result, Error> { + let package_json = + PackageJson::load(package_json_path).map_err(|e| Error::PackageJsonRead { + path: package_json_path.to_string(), + source: e, + })?; + + let package_name = extract_package_name(&package_json, package_json_path); + let relative_package_path = calculate_relative_path(repo_root, package_json_path)?; + + let raw_content = std::fs::read_to_string(package_json_path).map_err(|e| Error::FileRead { + path: package_json_path.to_string(), + source: e, + })?; + + let raw_json: Value = serde_json::from_str(&raw_content).map_err(|e| Error::JsonParse { + path: package_json_path.to_string(), + source: e, + })?; + + Ok(extract_dependencies_from_json( + &raw_json, + &package_name, + &relative_package_path, + config, + )) +} + +fn extract_package_name( + package_json: &PackageJson, + package_json_path: &AbsoluteSystemPath, +) -> String { + package_json + .name + .as_ref() + .map(|s| s.as_str().to_string()) + .unwrap_or_else(|| { + // Use directory name as fallback + package_json_path + .parent() + .unwrap() + .file_name() + .unwrap_or("unknown") + .to_string() + }) +} + +fn calculate_relative_path( + repo_root: &AbsoluteSystemPath, + package_json_path: &AbsoluteSystemPath, +) -> Result { + let package_dir = package_json_path.parent().unwrap(); + let relative_path = repo_root.anchor(package_dir)?; + Ok(relative_path.to_string()) +} + +fn extract_dependencies_from_json( + raw_json: &Value, + package_name: &str, + relative_package_path: &str, + config: &OptimizedConfig, +) -> Vec { + let mut dependencies = Vec::new(); + + let mut dependency_types = vec![ + ("dependencies", DependencyType::Dependencies), + ("devDependencies", DependencyType::DevDependencies), + ]; + + // Only include optionalDependencies if the configuration allows it + if config.include_optional_dependencies { + dependency_types.push(("optionalDependencies", DependencyType::OptionalDependencies)); + } + + for (field_name, dependency_type) in &dependency_types { + if let Some(deps) = raw_json.get(field_name).and_then(|v| v.as_object()) { + for (dependency_name, version) in deps { + if let Some(version_str) = version.as_str() { + dependencies.push(DependencyInfo { + package_name: package_name.to_string(), + package_path: relative_package_path.to_string(), + dependency_name: dependency_name.clone(), + version: version_str.to_string(), + dependency_type: dependency_type.clone(), + }); + } + } + } + } + + dependencies +} + +/// Check if a dependency should be ignored based on configuration +fn should_ignore_dependency(dependency: &DependencyInfo, config: &OptimizedConfig) -> bool { + if let Some(exception_set) = config + .ignored_exception_sets + .get(&dependency.dependency_name) + { + // If package is in exceptions list, do NOT ignore it + !exception_set.contains(&dependency.package_name) + } else if config + .ignored_dependencies + .contains_key(&dependency.dependency_name) + { + // If dependency is in ignored list but no exceptions, ignore it + true + } else { + // Not in ignored list at all + false + } +} + +/// Check if a package is exempt from a pinned dependency +fn is_exempt_from_pinned_dependency(dependency: &DependencyInfo, config: &OptimizedConfig) -> bool { + config + .pinned_exception_sets + .get(&dependency.dependency_name) + .map(|exception_set| exception_set.contains(&dependency.package_name)) + .unwrap_or(false) +} + +fn find_pinned_version_conflicts( + dependencies: &[DependencyInfo], + config: &OptimizedConfig, +) -> Vec { + let mut conflicts = Vec::new(); + + for (dependency_name, pinned_config) in &config.pinned_dependencies { + let conflicting_packages = dependencies + .iter() + .filter(|dep| dep.dependency_name == *dependency_name) + .filter(|dep| !is_exempt_from_pinned_dependency(dep, config)) + .filter(|dep| !should_ignore_dependency(dep, config)) + .filter(|dep| dep.version != pinned_config.version) + .map(|dep| DependencyUsage { + package_name: dep.package_name.clone(), + version: dep.version.clone(), + package_path: dep.package_path.clone(), + }) + .collect::>(); + + if !conflicting_packages.is_empty() { + conflicts.push(DependencyConflict { + dependency_name: dependency_name.clone(), + conflicting_packages, + conflict_reason: Some(format!("pinned to {}", pinned_config.version)), + }); + } + } + + conflicts +} + +fn find_version_conflicts( + all_dependencies: &[DependencyInfo], + config: &OptimizedConfig, +) -> Vec { + let dependency_usage_map = build_dependency_usage_map(all_dependencies, config); + let mut conflicts = Vec::new(); + + for (dependency_name, usages) in dependency_usage_map { + let version_conflict = analyze_version_conflict(dependency_name, usages, config); + if let Some(conflict) = version_conflict { + conflicts.push(conflict); + } + } + + // Sort conflicts by dependency name for consistent output + conflicts.sort_by(|a, b| a.dependency_name.cmp(&b.dependency_name)); + conflicts +} + +fn build_dependency_usage_map( + all_dependencies: &[DependencyInfo], + config: &OptimizedConfig, +) -> HashMap> { + let mut dependency_map: HashMap> = HashMap::new(); + + for dependency in all_dependencies { + // Skip pinned dependencies - they're handled separately + if config + .pinned_dependency_names + .contains(&dependency.dependency_name) + { + continue; + } + + dependency_map + .entry(dependency.dependency_name.clone()) + .or_default() + .push(DependencyUsage { + package_name: dependency.package_name.clone(), + version: dependency.version.clone(), + package_path: dependency.package_path.clone(), + }); + } + + dependency_map +} + +fn analyze_version_conflict( + dependency_name: String, + usages: Vec, + config: &OptimizedConfig, +) -> Option { + // Check if we have multiple different versions + let unique_versions: HashSet<&String> = usages.iter().map(|usage| &usage.version).collect(); + + if unique_versions.len() <= 1 { + return None; + } + + // Filter out ignored packages + let filtered_usages = filter_ignored_packages(dependency_name.clone(), usages, config); + + if filtered_usages.len() <= 1 { + return None; + } + + // Check if we still have multiple versions after filtering + let unique_filtered_versions: HashSet<&String> = + filtered_usages.iter().map(|usage| &usage.version).collect(); + + if unique_filtered_versions.len() > 1 { + Some(DependencyConflict { + dependency_name, + conflicting_packages: filtered_usages, + conflict_reason: None, + }) + } else { + None + } +} + +fn filter_ignored_packages( + dependency_name: String, + usages: Vec, + config: &OptimizedConfig, +) -> Vec { + if let Some(exception_set) = config.ignored_exception_sets.get(&dependency_name) { + usages + .into_iter() + .filter(|usage| { + // Keep packages that ARE in the exceptions list (i.e., should not be ignored) + exception_set.contains(&usage.package_name) + }) + .collect() + } else { + usages + } +} + +fn print_conflicts(conflicts: &[DependencyConflict], color_config: ColorConfig) { + // Sort all conflicts alphabetically by dependency name + let mut sorted_conflicts = conflicts.to_vec(); + sorted_conflicts.sort_by(|a, b| a.dependency_name.cmp(&b.dependency_name)); + + for conflict in &sorted_conflicts { + print_single_conflict(conflict, color_config); + println!(); + } + + print_conflict_summary(conflicts.len(), color_config); +} + +fn print_single_conflict(conflict: &DependencyConflict, color_config: ColorConfig) { + let formatted_dependency_name = format_dependency_name(&conflict.dependency_name, color_config); + + if let Some(reason) = &conflict.conflict_reason { + println!(" {} ({})", formatted_dependency_name, reason); + print_pinned_conflict_packages(&conflict.conflicting_packages, color_config); + } else { + println!(" {} (version mismatch)", formatted_dependency_name); + print_version_conflict_packages(&conflict.conflicting_packages, color_config); + } +} + +fn format_dependency_name(dependency_name: &str, color_config: ColorConfig) -> String { + if color_config.should_strip_ansi { + dependency_name.to_string() + } else { + use turborepo_ui::BOLD; + format!("{}", BOLD.apply_to(dependency_name)) + } +} + +fn print_pinned_conflict_packages( + conflicting_packages: &[DependencyUsage], + color_config: ColorConfig, +) { + for usage in conflicting_packages { + let version_display = format_version(&usage.version, color_config); + let package_display = + format_package_info(&usage.package_name, &usage.package_path, color_config); + println!(" {} → {}", version_display, package_display); + } +} + +fn print_version_conflict_packages( + conflicting_packages: &[DependencyUsage], + color_config: ColorConfig, +) { + let version_groups = group_packages_by_version(conflicting_packages); + let mut sorted_versions: Vec<_> = version_groups.into_iter().collect(); + sorted_versions.sort_by(|a, b| a.0.cmp(&b.0)); + + for (version, packages) in sorted_versions { + let version_display = format_version(&version, color_config); + println!(" {} →", version_display); + + for (package_name, package_path) in packages { + let package_display = format_package_info(&package_name, &package_path, color_config); + println!(" {}", package_display); + } + } +} + +fn group_packages_by_version( + conflicting_packages: &[DependencyUsage], +) -> HashMap> { + let mut version_groups: HashMap> = HashMap::new(); + + for usage in conflicting_packages { + version_groups + .entry(usage.version.clone()) + .or_default() + .push((usage.package_name.clone(), usage.package_path.clone())); + } + + version_groups +} + +fn format_version(version: &str, color_config: ColorConfig) -> String { + if color_config.should_strip_ansi { + version.to_string() + } else { + use turborepo_ui::YELLOW; + format!("{}", YELLOW.apply_to(version)) + } +} + +fn format_package_info( + package_name: &str, + package_path: &str, + color_config: ColorConfig, +) -> String { + if color_config.should_strip_ansi { + format!("{} ({})", package_name, package_path) + } else { + use turborepo_ui::CYAN; + format!("{} ({})", CYAN.apply_to(package_name), package_path) + } +} + +fn print_conflict_summary(conflict_count: usize, color_config: ColorConfig) { + let error_prefix = if color_config.should_strip_ansi { + ERROR_PREFIX + } else { + use turborepo_ui::BOLD_RED; + &format!("{}", BOLD_RED.apply_to(ERROR_PREFIX)) + }; + + println!( + "\n{} Found {} dependency conflicts.", + error_prefix, conflict_count + ); +} + +fn generate_allowlist_config( + conflicts: &[DependencyConflict], + current_config: &DepsSyncConfig, +) -> DepsSyncConfig { + let mut new_config = DepsSyncConfig { + pinned_dependencies: HashMap::new(), + ignored_dependencies: HashMap::new(), + include_optional_dependencies: current_config.include_optional_dependencies, + }; + + // Only copy existing pinned dependencies that are being modified + for conflict in conflicts { + if conflict.conflict_reason.is_some() { + // This is a pinned dependency conflict + // Copy the existing pinned dependency and add exceptions + if let Some(existing_pinned_dep) = current_config + .pinned_dependencies + .get(&conflict.dependency_name) + { + let mut pinned_dep = existing_pinned_dep.clone(); + for usage in &conflict.conflicting_packages { + if !pinned_dep.exceptions.contains(&usage.package_name) { + pinned_dep.exceptions.push(usage.package_name.clone()); + } + } + new_config + .pinned_dependencies + .insert(conflict.dependency_name.clone(), pinned_dep); + } + } else { + // This is a regular version conflict + // Add the dependency to ignored_dependencies with all conflicting packages as + // exceptions + let package_names: Vec = conflict + .conflicting_packages + .iter() + .map(|usage| usage.package_name.clone()) + .collect(); + + new_config.ignored_dependencies.insert( + conflict.dependency_name.clone(), + IgnoredDependency { + exceptions: package_names, + }, + ); + } + } + + // Also copy any existing ignored dependencies + for (dep_name, ignored_dep) in ¤t_config.ignored_dependencies { + if !new_config.ignored_dependencies.contains_key(dep_name) { + new_config + .ignored_dependencies + .insert(dep_name.clone(), ignored_dep.clone()); + } + } + + // Copy any existing pinned dependencies that weren't modified + for (dep_name, pinned_dep) in ¤t_config.pinned_dependencies { + if !new_config.pinned_dependencies.contains_key(dep_name) { + new_config + .pinned_dependencies + .insert(dep_name.clone(), pinned_dep.clone()); + } + } + + new_config +} + +async fn write_allowlist_config( + repo_root: &AbsoluteSystemPath, + config: &DepsSyncConfig, +) -> Result<(), Error> { + let config_opts = crate::config::ConfigurationOptions::default(); + let turbo_json_path = config_opts + .root_turbo_json_path(repo_root) + .map_err(Error::Config)?; + + // Read the current turbo.json file + let mut raw_turbo_json: crate::turbo_json::RawTurboJson = + (RawTurboJson::read(repo_root, &turbo_json_path)?).unwrap_or_default(); + + // Update the deps_sync configuration + raw_turbo_json.deps_sync = Some(config.clone()); + + // Write the updated configuration back to the file + let json_content = + serde_json::to_string_pretty(&raw_turbo_json).map_err(|e| Error::JsonParse { + path: turbo_json_path.to_string(), + source: e, + })?; + std::fs::write(&turbo_json_path, json_content).map_err(|e| Error::FileRead { + path: turbo_json_path.to_string(), + source: e, + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + // Helper function to create test dependency info + fn create_dependency_info( + package_name: &str, + package_path: &str, + dependency_name: &str, + version: &str, + dependency_type: DependencyType, + ) -> DependencyInfo { + DependencyInfo { + package_name: package_name.to_string(), + package_path: package_path.to_string(), + dependency_name: dependency_name.to_string(), + version: version.to_string(), + dependency_type, + } + } + + // Helper function to create optimized config + fn create_optimized_config(config: DepsSyncConfig) -> OptimizedConfig { + OptimizedConfig::from(config) + } + + #[test] + fn test_deps_sync_config_default() { + let config = DepsSyncConfig::default(); + assert!(config.pinned_dependencies.is_empty()); + assert!(config.ignored_dependencies.is_empty()); + assert!(!config.include_optional_dependencies); + } + + #[test] + fn test_optimized_config_conversion() { + let mut config = DepsSyncConfig::default(); + config.pinned_dependencies.insert( + "react".to_string(), + PinnedDependency { + version: "18.0.0".to_string(), + exceptions: vec!["package-a".to_string()], + }, + ); + config.ignored_dependencies.insert( + "lodash".to_string(), + IgnoredDependency { + exceptions: vec!["package-b".to_string()], + }, + ); + + let optimized = OptimizedConfig::from(config.clone()); + assert!(optimized.pinned_dependency_names.contains("react")); + assert_eq!( + optimized.pinned_exception_sets.get("react").unwrap(), + &["package-a".to_string()].into_iter().collect() + ); + assert_eq!( + optimized.ignored_exception_sets.get("lodash").unwrap(), + &["package-b".to_string()].into_iter().collect() + ); + + let back_to_config = DepsSyncConfig::from(optimized); + assert_eq!( + config.pinned_dependencies, + back_to_config.pinned_dependencies + ); + assert_eq!( + config.ignored_dependencies, + back_to_config.ignored_dependencies + ); + } + + #[test] + fn test_extract_dependencies_from_json_basic() { + let json = json!({ + "dependencies": { + "react": "18.0.0", + "lodash": "4.17.21" + }, + "devDependencies": { + "typescript": "5.0.0" + } + }); + + let config = create_optimized_config(DepsSyncConfig::default()); + let deps = extract_dependencies_from_json(&json, "test-package", "./test", &config); + + assert_eq!(deps.len(), 3); + + let react_dep = deps.iter().find(|d| d.dependency_name == "react").unwrap(); + assert_eq!(react_dep.version, "18.0.0"); + assert_eq!(react_dep.dependency_type, DependencyType::Dependencies); + + let typescript_dep = deps + .iter() + .find(|d| d.dependency_name == "typescript") + .unwrap(); + assert_eq!( + typescript_dep.dependency_type, + DependencyType::DevDependencies + ); + } + + #[test] + fn test_extract_dependencies_with_optional_dependencies() { + let json = json!({ + "dependencies": { + "react": "18.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }); + + // Test without including optional dependencies + let config_without_optional = create_optimized_config(DepsSyncConfig::default()); + let deps_without = extract_dependencies_from_json( + &json, + "test-package", + "./test", + &config_without_optional, + ); + assert_eq!(deps_without.len(), 1); + assert!(deps_without.iter().all(|d| d.dependency_name != "fsevents")); + + // Test with including optional dependencies + let mut config_with_optional = DepsSyncConfig::default(); + config_with_optional.include_optional_dependencies = true; + let config_with_optional = create_optimized_config(config_with_optional); + let deps_with = + extract_dependencies_from_json(&json, "test-package", "./test", &config_with_optional); + assert_eq!(deps_with.len(), 2); + + let fsevents_dep = deps_with + .iter() + .find(|d| d.dependency_name == "fsevents") + .unwrap(); + assert_eq!( + fsevents_dep.dependency_type, + DependencyType::OptionalDependencies + ); + } + + #[test] + fn test_should_ignore_dependency() { + let mut config = DepsSyncConfig::default(); + config.ignored_dependencies.insert( + "test-lib".to_string(), + IgnoredDependency { + exceptions: vec!["package-a".to_string()], + }, + ); + let optimized_config = create_optimized_config(config); + + let dep_in_exception = create_dependency_info( + "package-a", + "./package-a", + "test-lib", + "1.0.0", + DependencyType::Dependencies, + ); + let dep_not_in_exception = create_dependency_info( + "package-b", + "./package-b", + "test-lib", + "1.0.0", + DependencyType::Dependencies, + ); + let dep_not_ignored = create_dependency_info( + "package-c", + "./package-c", + "other-lib", + "1.0.0", + DependencyType::Dependencies, + ); + + // Package in exception should NOT be ignored + assert!(!should_ignore_dependency( + &dep_in_exception, + &optimized_config + )); + + // Package not in exception should be ignored + assert!(should_ignore_dependency( + &dep_not_in_exception, + &optimized_config + )); + + // Dependency not in ignored list should not be ignored + assert!(!should_ignore_dependency( + &dep_not_ignored, + &optimized_config + )); + } + + #[test] + fn test_is_exempt_from_pinned_dependency() { + let mut config = DepsSyncConfig::default(); + config.pinned_dependencies.insert( + "react".to_string(), + PinnedDependency { + version: "18.0.0".to_string(), + exceptions: vec!["legacy-package".to_string()], + }, + ); + let optimized_config = create_optimized_config(config); + + let exempt_dep = create_dependency_info( + "legacy-package", + "./legacy", + "react", + "17.0.0", + DependencyType::Dependencies, + ); + let non_exempt_dep = create_dependency_info( + "modern-package", + "./modern", + "react", + "17.0.0", + DependencyType::Dependencies, + ); + + assert!(is_exempt_from_pinned_dependency( + &exempt_dep, + &optimized_config + )); + assert!(!is_exempt_from_pinned_dependency( + &non_exempt_dep, + &optimized_config + )); + } + + #[test] + fn test_find_version_conflicts_simple() { + let dependencies = vec![ + create_dependency_info( + "pkg-a", + "./pkg-a", + "lodash", + "4.17.20", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-b", + "./pkg-b", + "lodash", + "4.17.21", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-c", + "./pkg-c", + "react", + "18.0.0", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-d", + "./pkg-d", + "react", + "18.0.0", + DependencyType::Dependencies, + ), + ]; + + let config = create_optimized_config(DepsSyncConfig::default()); + let conflicts = find_version_conflicts(&dependencies, &config); + + assert_eq!(conflicts.len(), 1); + let lodash_conflict = &conflicts[0]; + assert_eq!(lodash_conflict.dependency_name, "lodash"); + assert_eq!(lodash_conflict.conflicting_packages.len(), 2); + assert!(lodash_conflict.conflict_reason.is_none()); + } + + #[test] + fn test_find_version_conflicts_with_ignored_dependencies() { + let dependencies = vec![ + create_dependency_info( + "pkg-a", + "./pkg-a", + "lodash", + "4.17.20", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-b", + "./pkg-b", + "lodash", + "4.17.21", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-c", + "./pkg-c", + "lodash", + "4.17.22", + DependencyType::Dependencies, + ), + ]; + + let mut config = DepsSyncConfig::default(); + config.ignored_dependencies.insert( + "lodash".to_string(), + IgnoredDependency { + exceptions: vec!["pkg-a".to_string(), "pkg-b".to_string()], + }, + ); + let optimized_config = create_optimized_config(config); + + let conflicts = find_version_conflicts(&dependencies, &optimized_config); + + // Should find conflict between pkg-a and pkg-b (they are exceptions so not + // ignored) pkg-c is ignored completely, so no conflict involving it + assert_eq!(conflicts.len(), 1); + let lodash_conflict = &conflicts[0]; + assert_eq!(lodash_conflict.conflicting_packages.len(), 2); + assert!(lodash_conflict + .conflicting_packages + .iter() + .any(|p| p.package_name == "pkg-a")); + assert!(lodash_conflict + .conflicting_packages + .iter() + .any(|p| p.package_name == "pkg-b")); + assert!(!lodash_conflict + .conflicting_packages + .iter() + .any(|p| p.package_name == "pkg-c")); + } + + #[test] + fn test_find_pinned_version_conflicts() { + let dependencies = vec![ + create_dependency_info( + "pkg-a", + "./pkg-a", + "react", + "17.0.0", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-b", + "./pkg-b", + "react", + "18.0.0", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-c", + "./pkg-c", + "react", + "16.0.0", + DependencyType::Dependencies, + ), + ]; + + let mut config = DepsSyncConfig::default(); + config.pinned_dependencies.insert( + "react".to_string(), + PinnedDependency { + version: "18.0.0".to_string(), + exceptions: vec![], + }, + ); + let optimized_config = create_optimized_config(config); + + let conflicts = find_pinned_version_conflicts(&dependencies, &optimized_config); + + assert_eq!(conflicts.len(), 1); + let react_conflict = &conflicts[0]; + assert_eq!(react_conflict.dependency_name, "react"); + assert_eq!(react_conflict.conflicting_packages.len(), 2); + + // Should include pkg-a and pkg-c (wrong versions), but not pkg-b (correct + // version) + assert!(react_conflict + .conflicting_packages + .iter() + .any(|p| p.package_name == "pkg-a")); + assert!(react_conflict + .conflicting_packages + .iter() + .any(|p| p.package_name == "pkg-c")); + assert!(!react_conflict + .conflicting_packages + .iter() + .any(|p| p.package_name == "pkg-b")); + + assert!(react_conflict.conflict_reason.is_some()); + assert!(react_conflict + .conflict_reason + .as_ref() + .unwrap() + .contains("pinned to 18.0.0")); + } + + #[test] + fn test_find_pinned_version_conflicts_with_exceptions() { + let dependencies = vec![ + create_dependency_info( + "pkg-a", + "./pkg-a", + "react", + "17.0.0", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-b", + "./pkg-b", + "react", + "18.0.0", + DependencyType::Dependencies, + ), + create_dependency_info( + "legacy-pkg", + "./legacy", + "react", + "16.0.0", + DependencyType::Dependencies, + ), + ]; + + let mut config = DepsSyncConfig::default(); + config.pinned_dependencies.insert( + "react".to_string(), + PinnedDependency { + version: "18.0.0".to_string(), + exceptions: vec!["legacy-pkg".to_string()], + }, + ); + let optimized_config = create_optimized_config(config); + + let conflicts = find_pinned_version_conflicts(&dependencies, &optimized_config); + + assert_eq!(conflicts.len(), 1); + let react_conflict = &conflicts[0]; + + // Should only include pkg-a (legacy-pkg is exempt) + assert_eq!(react_conflict.conflicting_packages.len(), 1); + assert_eq!(react_conflict.conflicting_packages[0].package_name, "pkg-a"); + } + + #[test] + fn test_generate_allowlist_config_for_version_conflicts() { + let conflicts = vec![DependencyConflict { + dependency_name: "lodash".to_string(), + conflicting_packages: vec![ + DependencyUsage { + package_name: "pkg-a".to_string(), + version: "4.17.20".to_string(), + package_path: "./pkg-a".to_string(), + }, + DependencyUsage { + package_name: "pkg-b".to_string(), + version: "4.17.21".to_string(), + package_path: "./pkg-b".to_string(), + }, + ], + conflict_reason: None, + }]; + + let current_config = DepsSyncConfig::default(); + let allowlist_config = generate_allowlist_config(&conflicts, ¤t_config); + + assert_eq!(allowlist_config.ignored_dependencies.len(), 1); + let lodash_ignored = allowlist_config.ignored_dependencies.get("lodash").unwrap(); + assert_eq!(lodash_ignored.exceptions.len(), 2); + assert!(lodash_ignored.exceptions.contains(&"pkg-a".to_string())); + assert!(lodash_ignored.exceptions.contains(&"pkg-b".to_string())); + } + + #[test] + fn test_generate_allowlist_config_for_pinned_conflicts() { + let conflicts = vec![DependencyConflict { + dependency_name: "react".to_string(), + conflicting_packages: vec![DependencyUsage { + package_name: "pkg-a".to_string(), + version: "17.0.0".to_string(), + package_path: "./pkg-a".to_string(), + }], + conflict_reason: Some("pinned to 18.0.0".to_string()), + }]; + + let mut current_config = DepsSyncConfig::default(); + current_config.pinned_dependencies.insert( + "react".to_string(), + PinnedDependency { + version: "18.0.0".to_string(), + exceptions: vec![], + }, + ); + + let allowlist_config = generate_allowlist_config(&conflicts, ¤t_config); + + assert_eq!(allowlist_config.pinned_dependencies.len(), 1); + let react_pinned = allowlist_config.pinned_dependencies.get("react").unwrap(); + assert_eq!(react_pinned.version, "18.0.0"); + assert_eq!(react_pinned.exceptions.len(), 1); + assert!(react_pinned.exceptions.contains(&"pkg-a".to_string())); + } + + #[test] + fn test_generate_allowlist_config_preserves_existing_config() { + let conflicts = vec![]; + + let mut current_config = DepsSyncConfig::default(); + current_config.pinned_dependencies.insert( + "vue".to_string(), + PinnedDependency { + version: "3.0.0".to_string(), + exceptions: vec!["legacy-vue-pkg".to_string()], + }, + ); + current_config.ignored_dependencies.insert( + "moment".to_string(), + IgnoredDependency { + exceptions: vec!["time-pkg".to_string()], + }, + ); + + let allowlist_config = generate_allowlist_config(&conflicts, ¤t_config); + + // Should preserve existing configuration even with no conflicts + assert_eq!(allowlist_config.pinned_dependencies.len(), 1); + assert_eq!(allowlist_config.ignored_dependencies.len(), 1); + assert_eq!( + allowlist_config + .pinned_dependencies + .get("vue") + .unwrap() + .version, + "3.0.0" + ); + assert_eq!( + allowlist_config + .ignored_dependencies + .get("moment") + .unwrap() + .exceptions, + vec!["time-pkg".to_string()] + ); + } + + #[test] + fn test_extract_package_name_fallback() { + use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; + use turborepo_errors::Spanned; + + // Test with package.json without name + let package_json = PackageJson { + name: None, + ..Default::default() + }; + + let path = AbsoluteSystemPath::new("/test/my-package/package.json").unwrap(); + let name = extract_package_name(&package_json, path); + assert_eq!(name, "my-package"); + + // Test with package.json with name + let package_json = PackageJson { + name: Some(Spanned::new("@scope/actual-name".to_string())), + ..Default::default() + }; + + let name = extract_package_name(&package_json, path); + assert_eq!(name, "@scope/actual-name"); + } + + #[test] + fn test_group_packages_by_version() { + let packages = vec![ + DependencyUsage { + package_name: "pkg-a".to_string(), + version: "1.0.0".to_string(), + package_path: "./pkg-a".to_string(), + }, + DependencyUsage { + package_name: "pkg-b".to_string(), + version: "1.0.0".to_string(), + package_path: "./pkg-b".to_string(), + }, + DependencyUsage { + package_name: "pkg-c".to_string(), + version: "2.0.0".to_string(), + package_path: "./pkg-c".to_string(), + }, + ]; + + let groups = group_packages_by_version(&packages); + + assert_eq!(groups.len(), 2); + assert_eq!(groups.get("1.0.0").unwrap().len(), 2); + assert_eq!(groups.get("2.0.0").unwrap().len(), 1); + + let v1_packages = groups.get("1.0.0").unwrap(); + assert!(v1_packages.contains(&("pkg-a".to_string(), "./pkg-a".to_string()))); + assert!(v1_packages.contains(&("pkg-b".to_string(), "./pkg-b".to_string()))); + } + + #[test] + fn test_dependency_type_display() { + assert_eq!(DependencyType::Dependencies.to_string(), "dependencies"); + assert_eq!( + DependencyType::DevDependencies.to_string(), + "devDependencies" + ); + assert_eq!( + DependencyType::OptionalDependencies.to_string(), + "optionalDependencies" + ); + } + + #[test] + fn test_build_dependency_usage_map_excludes_pinned() { + let dependencies = vec![ + create_dependency_info( + "pkg-a", + "./pkg-a", + "react", + "18.0.0", + DependencyType::Dependencies, + ), + create_dependency_info( + "pkg-b", + "./pkg-b", + "lodash", + "4.17.21", + DependencyType::Dependencies, + ), + ]; + + let mut config = DepsSyncConfig::default(); + config.pinned_dependencies.insert( + "react".to_string(), + PinnedDependency { + version: "18.0.0".to_string(), + exceptions: vec![], + }, + ); + let optimized_config = create_optimized_config(config); + + let usage_map = build_dependency_usage_map(&dependencies, &optimized_config); + + // Should only include lodash, not react (since react is pinned) + assert_eq!(usage_map.len(), 1); + assert!(usage_map.contains_key("lodash")); + assert!(!usage_map.contains_key("react")); + } + + #[test] + fn test_filter_ignored_packages() { + let usages = vec![ + DependencyUsage { + package_name: "pkg-a".to_string(), + version: "1.0.0".to_string(), + package_path: "./pkg-a".to_string(), + }, + DependencyUsage { + package_name: "pkg-b".to_string(), + version: "1.0.0".to_string(), + package_path: "./pkg-b".to_string(), + }, + DependencyUsage { + package_name: "pkg-c".to_string(), + version: "1.0.0".to_string(), + package_path: "./pkg-c".to_string(), + }, + ]; + + let mut config = DepsSyncConfig::default(); + config.ignored_dependencies.insert( + "test-dep".to_string(), + IgnoredDependency { + exceptions: vec!["pkg-a".to_string(), "pkg-b".to_string()], + }, + ); + let optimized_config = create_optimized_config(config); + + let filtered = filter_ignored_packages("test-dep".to_string(), usages, &optimized_config); + + // Should keep packages that are in the exception set (should not be ignored) + // pkg-a and pkg-b are in the exception set, so they should be kept + // pkg-c is not in the exception set, so it should be filtered out + assert_eq!(filtered.len(), 2); + assert!(filtered.iter().any(|u| u.package_name == "pkg-a")); + assert!(filtered.iter().any(|u| u.package_name == "pkg-b")); + assert!(!filtered.iter().any(|u| u.package_name == "pkg-c")); + } +} diff --git a/crates/turborepo-lib/src/commands/mod.rs b/crates/turborepo-lib/src/commands/mod.rs index ffaca101c4dd7..0d9c821fc6c5d 100644 --- a/crates/turborepo-lib/src/commands/mod.rs +++ b/crates/turborepo-lib/src/commands/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod boundaries; pub(crate) mod clone; pub(crate) mod config; pub(crate) mod daemon; +pub(crate) mod deps_sync; pub(crate) mod generate; pub(crate) mod info; pub(crate) mod link; diff --git a/crates/turborepo-lib/src/turbo_json/mod.rs b/crates/turborepo-lib/src/turbo_json/mod.rs index ba378a5ab1699..3fd9f6a25d521 100644 --- a/crates/turborepo-lib/src/turbo_json/mod.rs +++ b/crates/turborepo-lib/src/turbo_json/mod.rs @@ -164,6 +164,9 @@ pub struct RawTurboJson { #[serde(skip_serializing_if = "Option::is_none")] pub concurrency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deps_sync: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub future_flags: Option>, diff --git a/docs/site/content/docs/reference/deps-sync.mdx b/docs/site/content/docs/reference/deps-sync.mdx new file mode 100644 index 0000000000000..fccf510991685 --- /dev/null +++ b/docs/site/content/docs/reference/deps-sync.mdx @@ -0,0 +1,199 @@ +--- +title: deps-sync +description: API reference for the `turbo deps-sync` command +--- + +import { Callout } from '#components/callout'; + +Analyze and resolve dependency version conflicts across packages in your workspace. + +```bash title="Terminal" +turbo deps-sync [options] +``` + +The `deps-sync` command scans all packages in your workspace to identify dependencies that have conflicting versions. This helps maintain consistency across your monorepo by ensuring that shared dependencies use compatible versions. + + + This command only works with multi-package workspaces. Single-package + repositories don't need dependency synchronization analysis. + + +## When to use `deps-sync` + +Use `deps-sync` when you want to: + +- **Identify version conflicts**: Find dependencies that are used with different versions across packages +- **Enforce consistency**: Ensure critical dependencies use the same version everywhere +- **Resolve conflicts**: Generate configuration to manage unavoidable version differences +- **Maintain compatibility**: Prevent dependency version drift in your workspace + +## Options + +### `--allowlist` + +Generate configuration to resolve conflicts instead of reporting them. + +```bash title="Terminal" +turbo deps-sync --allowlist +``` + +When this flag is used, `turbo` will automatically add configuration to your `turbo.json` that resolves all detected conflicts by creating appropriate exception rules. This updates the [`depsSync`](#configuration) configuration and changes the command's exit code to `0`. + +## Configuration + +Configure dependency synchronization rules in your `turbo.json`: + +```json title="./turbo.json" +{ + "depsSync": { + "pinnedDependencies": { + "react": { + "version": "18.0.0", + "exceptions": ["legacy-app"] + } + }, + "ignoredDependencies": { + "typescript": { + "exceptions": ["docs-site", "scripts"] + } + } + } +} +``` + +### `pinnedDependencies` + +Pin specific dependencies to exact versions across all packages, with optional exceptions. + +```json title="./turbo.json" +{ + "depsSync": { + "pinnedDependencies": { + "react": { + "version": "18.0.0", + "exceptions": ["legacy-package"] + }, + "typescript": { + "version": "5.0.0", + "exceptions": [] + } + } + } +} +``` + +- **`version`**: The exact version that should be used across all packages +- **`exceptions`**: Array of package names that are exempt from this pinned version + +### `ignoredDependencies` + +Exclude specific dependencies from conflict analysis. + +```json title="./turbo.json" +{ + "depsSync": { + "ignoredDependencies": { + "eslint": { + "exceptions": ["web-app", "mobile-app"] + }, + "jest": { + "exceptions": [] + } + } + } +} +``` + +- **`exceptions`**: Array of package names where this dependency should **not** be ignored (i.e., still checked for conflicts) + + + When `exceptions` is empty, the dependency is ignored in **all** packages. + When `exceptions` contains package names, the dependency is ignored everywhere + **except** in those specified packages. + + +## Analyzed dependencies + +The command analyzes the following dependency types from `package.json`: + +- **`dependencies`**: Production dependencies +- **`devDependencies`**: Development dependencies +- **`optionalDependencies`**: Optional dependencies + + + **`peerDependencies`** are intentionally excluded from analysis since they're + meant to be provided by the consuming application and often have intentionally + different version constraints. + + +## Exit codes + +- **`0`**: No conflicts found or conflicts resolved with `--allowlist` +- **`1`**: Conflicts detected (only when not using `--allowlist`) + +## Examples + +### Basic conflict detection + +```bash title="Terminal" +turbo deps-sync +``` + +```bash title="Output" +🔍 Scanning workspace packages for dependency conflicts... + + lodash (version mismatch) + 4.17.0 → + web-app (packages/web-app) + mobile-app (packages/mobile-app) + 4.18.0 → + admin-dashboard (packages/admin-dashboard) + +❌ Found 1 dependency conflicts. +``` + +### Auto-resolve conflicts + +```bash title="Terminal" +turbo deps-sync --allowlist +``` + +```bash title="Output" +🔍 Scanning workspace packages for dependency conflicts... + +✅ Generated allowlist configuration for 1 conflicts in turbo.json. Dependencies are now synchronized! +``` + +### With configuration + +Given this `turbo.json`: + +```json title="./turbo.json" +{ + "depsSync": { + "pinnedDependencies": { + "react": { + "version": "18.0.0", + "exceptions": ["legacy-app"] + } + }, + "ignoredDependencies": { + "eslint": { + "exceptions": ["web-app"] + } + } + } +} +``` + +Running `turbo deps-sync` will: + +- Require all packages except `legacy-app` to use React version `18.0.0` +- Ignore ESLint version conflicts in all packages except `web-app` +- Report conflicts for any other dependencies with mismatched versions + + + The `--allowlist` flag is particularly useful during migration or when setting + up dependency synchronization for the first time, as it can automatically + generate the initial configuration based on your current workspace state. + diff --git a/docs/site/content/docs/reference/meta.json b/docs/site/content/docs/reference/meta.json index 8c3cff294b987..18771782a4b06 100644 --- a/docs/site/content/docs/reference/meta.json +++ b/docs/site/content/docs/reference/meta.json @@ -12,6 +12,7 @@ "watch", "prune", "boundaries", + "deps-sync", "ls", "query", "generate", diff --git a/turborepo-tests/integration/fixtures/deps_sync_mixed_types/apps/my-app/package.json b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/apps/my-app/package.json new file mode 100644 index 0000000000000..ce6f3092d2ab9 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/apps/my-app/package.json @@ -0,0 +1,14 @@ +{ + "name": "my-app", + "dependencies": { + "util": "*", + "lodash": "4.17.22" + }, + "devDependencies": { + "typescript": "5.0.0" + }, + "scripts": { + "build": "echo building", + "maybefails": "exit 4" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_mixed_types/package.json b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/package.json new file mode 100644 index 0000000000000..b12121fd8acec --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/package.json @@ -0,0 +1,9 @@ +{ + "name": "deps-sync-mixed-types", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "packageManager": "npm@10.5.0" +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_mixed_types/packages/another/package.json b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/packages/another/package.json new file mode 100644 index 0000000000000..81425372299cb --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/packages/another/package.json @@ -0,0 +1,9 @@ +{ + "name": "another", + "devDependencies": { + "typescript": "5.0.0" + }, + "scripts": { + "dev": "echo building" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_mixed_types/packages/util/package.json b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/packages/util/package.json new file mode 100644 index 0000000000000..e4ab54a75b623 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/packages/util/package.json @@ -0,0 +1,9 @@ +{ + "name": "util", + "dependencies": { + "lodash": "4.17.20" + }, + "devDependencies": { + "typescript": "5.1.0" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_mixed_types/turbo.json b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/turbo.json new file mode 100644 index 0000000000000..dca61b2c42db3 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_mixed_types/turbo.json @@ -0,0 +1,10 @@ +{ + "pipeline": { + "build": { + "outputs": ["dist/**"] + }, + "dev": { + "cache": false + } + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_pinned/apps/my-app/package.json b/turborepo-tests/integration/fixtures/deps_sync_pinned/apps/my-app/package.json new file mode 100644 index 0000000000000..4a54d3cf14f2d --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_pinned/apps/my-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "my-app", + "dependencies": { + "util": "*" + }, + "scripts": { + "build": "echo building", + "maybefails": "exit 4" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_pinned/package.json b/turborepo-tests/integration/fixtures/deps_sync_pinned/package.json new file mode 100644 index 0000000000000..cf316f8ff217a --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_pinned/package.json @@ -0,0 +1,9 @@ +{ + "name": "deps-sync-pinned", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "packageManager": "npm@10.5.0" +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_pinned/packages/another/package.json b/turborepo-tests/integration/fixtures/deps_sync_pinned/packages/another/package.json new file mode 100644 index 0000000000000..cdc6a771c244b --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_pinned/packages/another/package.json @@ -0,0 +1,9 @@ +{ + "name": "another", + "dependencies": { + "lodash": "4.17.21" + }, + "scripts": { + "dev": "echo building" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_pinned/packages/util/package.json b/turborepo-tests/integration/fixtures/deps_sync_pinned/packages/util/package.json new file mode 100644 index 0000000000000..e1ae634d9bfe5 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_pinned/packages/util/package.json @@ -0,0 +1,6 @@ +{ + "name": "util", + "dependencies": { + "lodash": "4.17.20" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_pinned/turbo.json b/turborepo-tests/integration/fixtures/deps_sync_pinned/turbo.json new file mode 100644 index 0000000000000..6eabf2837ddff --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_pinned/turbo.json @@ -0,0 +1,17 @@ +{ + "pipeline": { + "build": { + "outputs": ["dist/**"] + }, + "dev": { + "cache": false + } + }, + "depsSync": { + "pinnedDependencies": { + "lodash": { + "version": "4.17.22" + } + } + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/apps/my-app/package.json b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/apps/my-app/package.json new file mode 100644 index 0000000000000..4a54d3cf14f2d --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/apps/my-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "my-app", + "dependencies": { + "util": "*" + }, + "scripts": { + "build": "echo building", + "maybefails": "exit 4" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/package.json b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/package.json new file mode 100644 index 0000000000000..f09e166a4a75f --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/package.json @@ -0,0 +1,9 @@ +{ + "name": "deps-sync-version-conflicts", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "packageManager": "npm@10.5.0" +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/packages/another/package.json b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/packages/another/package.json new file mode 100644 index 0000000000000..dd02e2f4c4560 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/packages/another/package.json @@ -0,0 +1,6 @@ +{ + "name": "another", + "dependencies": { + "lodash": "4.17.21" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/packages/util/package.json b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/packages/util/package.json new file mode 100644 index 0000000000000..e1ae634d9bfe5 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/packages/util/package.json @@ -0,0 +1,6 @@ +{ + "name": "util", + "dependencies": { + "lodash": "4.17.20" + } +} diff --git a/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/turbo.json b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/turbo.json new file mode 100644 index 0000000000000..dca61b2c42db3 --- /dev/null +++ b/turborepo-tests/integration/fixtures/deps_sync_version_conflicts/turbo.json @@ -0,0 +1,10 @@ +{ + "pipeline": { + "build": { + "outputs": ["dist/**"] + }, + "dev": { + "cache": false + } + } +} diff --git a/turborepo-tests/integration/tests/deps-sync.t b/turborepo-tests/integration/tests/deps-sync.t new file mode 100644 index 0000000000000..72da658fdbeba --- /dev/null +++ b/turborepo-tests/integration/tests/deps-sync.t @@ -0,0 +1,136 @@ +Setup + $ . ${TESTDIR}/../../helpers/setup_integration_test.sh + +Test deps-sync from subdirectory (should work) + $ cd apps/my-app + $ ${TURBO} deps-sync + 🔍 Scanning workspace packages for dependency conflicts... + + ✅ All dependencies are in sync! + +Test deps-sync with no conflicts (basic monorepo) + $ cd ../.. + $ ${TURBO} deps-sync + 🔍 Scanning workspace packages for dependency conflicts... + + ✅ All dependencies are in sync! + +Test deps-sync with version conflicts + $ . ${TESTDIR}/../../helpers/copy_fixture.sh $(pwd) deps_sync_version_conflicts + + $ ${TURBO} deps-sync + 🔍 Scanning workspace packages for dependency conflicts... + + lodash (version mismatch) + 4.17.20 → + util (packages/util) + 4.17.21 → + another (packages/another) + + + \xe2\x9d\x8c Found 1 dependency conflicts. (no-eol) (esc) + +Test deps-sync with allowlist generation + $ ${TURBO} deps-sync --allowlist + 🔍 Scanning workspace packages for dependency conflicts... + + ✅ Generated allowlist configuration for 1 conflicts in turbo.json. Dependencies are now synchronized! + +Verify allowlist was written to turbo.json + $ cat turbo.json + { + "pipeline": { + "build": { + "outputs": [ + "dist/**" + ] + }, + "dev": { + "cache": false + } + }, + "depsSync": { + "ignoredDependencies": { + "lodash": { + "exceptions": [ + "another", + "util" + ] + } + } + } + } + +Test deps-sync with allowlist in place (should pass) + $ ${TURBO} deps-sync + 🔍 Scanning workspace packages for dependency conflicts... + + ✅ All dependencies are in sync! + +Test deps-sync with mixed dependency types + $ . ${TESTDIR}/../../helpers/copy_fixture.sh $(pwd) deps_sync_mixed_types ${TESTDIR}/../fixtures + + $ ${TURBO} deps-sync + 🔍 Scanning workspace packages for dependency conflicts... + + lodash (version mismatch) + 4.17.20 → + util (packages[\\/]util) (re) + 4.17.22 → + my-app (apps[\\/]my-app) (re) + typescript (version mismatch) + 5.0.0 → + another (packages[\\/]another) (re) + my-app (apps[\\/]my-app) (re) + 5.1.0 → + util (packages[\\/]util) (re) + + + \xe2\x9d\x8c Found 2 dependency conflicts. (no-eol) (esc) + [1] + +Test deps-sync with pinned dependencies + $ . ${TESTDIR}/../../helpers/copy_fixture.sh $(pwd) deps_sync_pinned ${TESTDIR}/../fixtures + + $ ${TURBO} deps-sync + 🔍 Scanning workspace packages for dependency conflicts... + + lodash (pinned to 4.17.22) + 4.17.20 → util (packages[\\/]util) (re) + 4.17.21 → another (packages[\\/]another) (re) + + + \xe2\x9d\x8c Found 1 dependency conflicts. (no-eol) (esc) + [1] + +Test deps-sync with allowlist for pinned dependencies + $ ${TURBO} deps-sync --allowlist + 🔍 Scanning workspace packages for dependency conflicts... + + ✅ Generated allowlist configuration for 1 conflicts in turbo.json. Dependencies are now synchronized! + +Verify pinned dependency exceptions were added + $ cat turbo.json + { + "pipeline": { + "build": { + "outputs": [ + "dist/**" + ] + }, + "dev": { + "cache": false + } + }, + "depsSync": { + "pinnedDependencies": { + "lodash": { + "version": "4.17.22", + "exceptions": [ + "another", + "util" + ] + } + } + } + } diff --git a/turborepo-tests/integration/tests/no-args.t b/turborepo-tests/integration/tests/no-args.t index f78b2cadbef97..7ba4ce13d32c3 100644 --- a/turborepo-tests/integration/tests/no-args.t +++ b/turborepo-tests/integration/tests/no-args.t @@ -11,6 +11,7 @@ Make sure exit code is 2 when no args are passed bin Get the path to the Turbo binary completion Generate the autocompletion script for the specified shell daemon Runs the Turborepo background daemon + deps-sync Check that all dependencies across workspaces are synchronized generate Generate a new app / package telemetry Enable or disable anonymous telemetry scan Turbo your monorepo by running a number of 'repo lints' to identify common issues, suggest fixes, and improve performance diff --git a/turborepo-tests/integration/tests/turbo-help.t b/turborepo-tests/integration/tests/turbo-help.t index 39663df7de4f9..dda43fcf19d3a 100644 --- a/turborepo-tests/integration/tests/turbo-help.t +++ b/turborepo-tests/integration/tests/turbo-help.t @@ -11,6 +11,7 @@ Test help flag bin Get the path to the Turbo binary completion Generate the autocompletion script for the specified shell daemon Runs the Turborepo background daemon + deps-sync Check that all dependencies across workspaces are synchronized generate Generate a new app / package telemetry Enable or disable anonymous telemetry scan Turbo your monorepo by running a number of 'repo lints' to identify common issues, suggest fixes, and improve performance @@ -135,6 +136,7 @@ Test help flag bin Get the path to the Turbo binary completion Generate the autocompletion script for the specified shell daemon Runs the Turborepo background daemon + deps-sync Check that all dependencies across workspaces are synchronized generate Generate a new app / package telemetry Enable or disable anonymous telemetry scan Turbo your monorepo by running a number of 'repo lints' to identify common issues, suggest fixes, and improve performance