Skip to content

Commit 73a9453

Browse files
committed
WIP 75897
1 parent c58c5a3 commit 73a9453

File tree

2 files changed

+201
-75
lines changed

2 files changed

+201
-75
lines changed

crates/turborepo-lib/src/commands/check_deps.rs

Lines changed: 154 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ fn find_inconsistencies(
6363
dep_map: &HashMap<String, DependencyVersion>,
6464
turbo_config: Option<&RawTurboJson>,
6565
) -> HashMap<String, HashMap<String, Vec<String>>> {
66+
// Extract dependency configurations once to avoid borrowing issues
67+
let dep_configs: HashMap<String, (Option<Vec<String>>, Option<Vec<String>>)> =
68+
if let Some(config) = turbo_config {
69+
if let Some(deps) = &config.dependencies {
70+
deps.iter()
71+
.map(|(name, config)| {
72+
let ignore = config.ignore.clone();
73+
let pin_to_version = config.pin_to_version.clone();
74+
(name.clone(), (ignore, pin_to_version))
75+
})
76+
.collect()
77+
} else {
78+
HashMap::new()
79+
}
80+
} else {
81+
HashMap::new()
82+
};
83+
6684
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6785

6886
// First, group all dependencies by their base name (without version suffix)
@@ -80,44 +98,102 @@ fn find_inconsistencies(
8098
.extend(dep_info.locations.clone());
8199
}
82100

101+
// Make a copy of the names to process to avoid borrowing issues
102+
let dep_names: Vec<String> = result.keys().cloned().collect();
103+
83104
// Process dependencies according to rules
84-
result.retain(|name, versions| {
85-
// Check if this dependency has a rule in the turbo.json config
86-
if let Some(config) = turbo_config.and_then(|c| c.dependencies.as_ref()) {
87-
if let Some(dep_config) = config.get(name) {
88-
// Skip if no packages are specified in the rule
89-
if dep_config.packages.is_empty() {
90-
return versions.len() > 1; // Default behavior: only show
91-
// multiple versions
92-
}
105+
for name in dep_names {
106+
if let Some(versions) = result.get_mut(&name) {
107+
let mut should_keep = true;
108+
let mut has_config = false;
109+
110+
// Check if this dependency has a rule in the extracted configs
111+
if let Some((ignore_opt, pin_opt)) = dep_configs.get(&name) {
112+
has_config = true;
113+
114+
// First, check if there are "ignore" patterns
115+
if let Some(ignore_patterns) = ignore_opt {
116+
// Create a new version map with ignored locations filtered out
117+
let mut filtered_versions: HashMap<String, Vec<String>> = HashMap::new();
118+
let mut all_ignored = true;
119+
120+
// For each version, filter out the ignored locations
121+
for (version, locations) in versions.iter() {
122+
let non_ignored_locations: Vec<String> = locations
123+
.iter()
124+
.filter(|location| {
125+
!matches_any_package_pattern(location, ignore_patterns)
126+
})
127+
.cloned()
128+
.collect();
93129

94-
// Check if the rule should be applied by matching package patterns
95-
let has_matching_packages = versions.values().any(|locations| {
96-
locations
97-
.iter()
98-
.any(|location| matches_any_package_pattern(location, &dep_config.packages))
99-
});
130+
// If we have any non-ignored locations, keep this version
131+
if !non_ignored_locations.is_empty() {
132+
all_ignored = false;
133+
filtered_versions.insert(version.clone(), non_ignored_locations);
134+
}
135+
}
100136

101-
if has_matching_packages {
102-
// Rule applies to at least one package
103-
if dep_config.ignore {
104-
// If dependency is ignored, don't include in inconsistencies
105-
return false;
137+
// If all locations were ignored, mark for removal
138+
if all_ignored {
139+
should_keep = false;
140+
} else {
141+
// Replace the original versions with filtered ones
142+
*versions = filtered_versions;
106143
}
144+
}
145+
146+
// Second, check for pinned versions
147+
if should_keep {
148+
if let Some(pin_patterns) = pin_opt {
149+
// For each version and its locations, check if it matches any pin pattern
150+
let mut has_pins = false;
151+
let mut has_violations = false;
152+
153+
for (version, locations) in versions.iter() {
154+
// Collect all pin patterns that apply to any package in this version
155+
let matching_pin_locations: Vec<_> = locations
156+
.iter()
157+
.filter(|location| {
158+
matches_any_package_pattern(location, pin_patterns)
159+
})
160+
.collect();
161+
162+
// If any locations match, check if versions all match what's expected
163+
if !matching_pin_locations.is_empty() {
164+
has_pins = true;
165+
166+
// For simplicity in this implementation, we'll assume all pinned
167+
// packages should use the same version (first pattern)
168+
// In a more complete implementation, we'd need to match each
169+
// location to its specific pin
170+
// pattern and version
171+
let expected_version = &pin_patterns[0];
172+
if version != expected_version {
173+
has_violations = true;
174+
}
175+
}
176+
}
107177

108-
if let Some(pin_version) = &dep_config.pin_to_version {
109-
// For pinned dependencies, check if ANY version doesn't match the pinned
110-
// version If we find any non-matching version,
111-
// report it as an inconsistency
112-
return versions.keys().any(|v| v != pin_version);
178+
// Update should_keep based on pin analysis
179+
if has_pins {
180+
should_keep = has_violations;
181+
}
113182
}
114183
}
115184
}
116-
}
117185

118-
// Default behavior: only show if there are multiple versions
119-
versions.len() > 1
120-
});
186+
// If we don't have a specific config, use the default behavior
187+
if !has_config {
188+
should_keep = versions.len() > 1;
189+
}
190+
191+
// If we shouldn't keep this dependency, remove it from the result
192+
if !should_keep {
193+
result.remove(&name);
194+
}
195+
}
196+
}
121197

122198
result
123199
}
@@ -134,21 +210,33 @@ fn matches_any_package_pattern(location: &str, patterns: &[String]) -> bool {
134210

135211
// Check if any pattern matches
136212
patterns.iter().any(|pattern| {
137-
// Special case: "**" or "*" means all packages
138-
if pattern == "**" || pattern == "*" {
139-
return true;
140-
}
141-
142-
// Use glob pattern matching
143-
if let Ok(glob_pattern) = Pattern::new(pattern) {
144-
glob_pattern.matches(package_name)
213+
// Handle negation patterns
214+
if let Some(negated_pattern) = pattern.strip_prefix('!') {
215+
// This is a negation pattern, so we should return false if it matches
216+
!matches_single_pattern(package_name, negated_pattern)
145217
} else {
146-
// If pattern is invalid, just do an exact match
147-
pattern == package_name
218+
// Normal pattern
219+
matches_single_pattern(package_name, pattern)
148220
}
149221
})
150222
}
151223

224+
// Helper to match a single pattern without negation handling
225+
fn matches_single_pattern(package_name: &str, pattern: &str) -> bool {
226+
// Special case: "**" or "*" means all packages
227+
if pattern == "**" || pattern == "*" {
228+
return true;
229+
}
230+
231+
// Use glob pattern matching
232+
if let Ok(glob_pattern) = Pattern::new(pattern) {
233+
glob_pattern.matches(package_name)
234+
} else {
235+
// If pattern is invalid, just do an exact match
236+
pattern == package_name
237+
}
238+
}
239+
152240
pub async fn run(
153241
base: CommandBase,
154242
only: Option<cli::DependencyFilter>,
@@ -209,10 +297,11 @@ pub async fn run(
209297
if !dependencies.is_empty() {
210298
// Silently collect pinned deps info without printing
211299
for (dep_name, dep_config) in dependencies {
212-
if !dep_config.packages.is_empty() {
213-
if let Some(version) = &dep_config.pin_to_version {
214-
// Store pinned deps for better error messages
215-
pinned_deps.insert(dep_name.clone(), version.clone());
300+
if let Some(pin_patterns) = &dep_config.pin_to_version {
301+
if !pin_patterns.is_empty() {
302+
// Store pinned deps for better error messages - use first version for
303+
// now
304+
pinned_deps.insert(dep_name.clone(), pin_patterns[0].clone());
216305
}
217306
}
218307
}
@@ -366,13 +455,17 @@ fn enforce_pinned_dependencies(
366455
color_config: turborepo_ui::ColorConfig,
367456
) {
368457
for (dep_name, config) in dependencies {
369-
// Due to validation, we know only one of ignore or pin_to_version is set
370-
if let Some(pinned_version) = &config.pin_to_version {
371-
// Skip if no packages are specified
372-
if config.packages.is_empty() {
458+
// Only proceed if we have pin_to_version patterns
459+
if let Some(pin_patterns) = &config.pin_to_version {
460+
// Skip if no pin patterns are specified
461+
if pin_patterns.is_empty() {
373462
continue;
374463
}
375464

465+
// For simplicity, we'll use the first pin pattern's version
466+
// In a more sophisticated implementation, we'd need to handle multiple versions
467+
let pinned_version = &pin_patterns[0];
468+
376469
cprintln!(
377470
color_config,
378471
BOLD,
@@ -391,14 +484,21 @@ fn enforce_pinned_dependencies(
391484
let base_name = extract_base_name(key);
392485

393486
if base_name == dep_name {
394-
// Check if any location matches our package patterns
395-
let matching_locations: Vec<String> = dep_info
487+
// Get locations that match our pin patterns
488+
let mut matching_locations: Vec<String> = dep_info
396489
.locations
397490
.iter()
398-
.filter(|location| matches_any_package_pattern(location, &config.packages))
491+
.filter(|location| matches_any_package_pattern(location, pin_patterns))
399492
.cloned()
400493
.collect();
401494

495+
// If we have ignore patterns, filter out any locations that match them
496+
if let Some(ignore_patterns) = &config.ignore {
497+
matching_locations.retain(|location| {
498+
!matches_any_package_pattern(location, ignore_patterns)
499+
});
500+
}
501+
402502
if !matching_locations.is_empty() {
403503
// Add to locations that need to be consolidated
404504
all_locations.extend(matching_locations);
@@ -853,9 +953,8 @@ mod tests {
853953
dependencies.insert(
854954
"react".to_string(),
855955
DependencyConfig {
856-
packages: vec!["*".to_string()],
857-
ignore: false,
858-
pin_to_version: Some("18.0.0".to_string()),
956+
ignore: None,
957+
pin_to_version: Some(vec!["*".to_string(), "18.0.0".to_string()]),
859958
},
860959
);
861960

@@ -1177,9 +1276,8 @@ mod tests {
11771276
dep_config_map.insert(
11781277
"react".to_string(),
11791278
DependencyConfig {
1180-
packages: vec!["apps/*".to_string()], // Only match packages in apps directory
1181-
pin_to_version: Some("18.0.0".to_string()),
1182-
ignore: false,
1279+
ignore: None,
1280+
pin_to_version: Some(vec!["apps/*".to_string(), "18.0.0".to_string()]),
11831281
},
11841282
);
11851283

crates/turborepo-lib/src/turbo_json/mod.rs

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -110,30 +110,58 @@ impl From<&RawRemoteCacheOptions> for ConfigurationOptions {
110110
#[derive(Serialize, Deserialize, Debug, Clone, Iterable, Deserializable, Default)]
111111
#[serde(rename_all = "camelCase")]
112112
pub struct DependencyConfig {
113-
/// When true, dependency version inconsistencies for this package will be
114-
/// ignored
115-
#[serde(default)]
116-
pub ignore: bool,
117-
118-
/// List of package name patterns that this rule applies to
119-
/// Supports glob patterns like "*" or "**" for all packages
120-
#[serde(default)]
121-
pub packages: Vec<String>,
122-
123-
/// Optional version to enforce across all matching packages
124-
/// If specified, all packages matching the patterns must use this version
125-
#[serde(default, rename = "pinToVersion")]
126-
pub pin_to_version: Option<String>,
113+
/// List of package name patterns where dependency version inconsistencies
114+
/// should be ignored Supports glob patterns like "*" or "**" for all
115+
/// packages Negation patterns like "!pkg-name" can be used to exclude
116+
/// specific packages
117+
#[serde(default, skip_serializing_if = "Option::is_none")]
118+
pub ignore: Option<Vec<String>>,
119+
120+
/// Map of package name patterns to versions for enforcing specific versions
121+
/// Keys are package patterns (with glob support)
122+
/// Values are the version strings to be enforced
123+
#[serde(
124+
default,
125+
rename = "pinToVersion",
126+
skip_serializing_if = "Option::is_none"
127+
)]
128+
pub pin_to_version: Option<Vec<String>>,
127129
}
128130

129131
impl DependencyConfig {
130-
/// Validates that exactly one of "ignore" or "pinToVersion" is specified
132+
/// Validates that:
133+
/// 1. At least one of "ignore" or "pinToVersion" is specified
134+
/// 2. No package appears in both arrays (direct conflict)
131135
pub fn validate(&self) -> Result<(), &'static str> {
132-
match (self.ignore, self.pin_to_version.is_some()) {
133-
(true, true) => Err("Both 'ignore' and 'pinToVersion' cannot be specified together"),
134-
(false, false) => Err("Either 'ignore' or 'pinToVersion' must be specified"),
135-
_ => Ok(()),
136+
// Check that at least one field is provided
137+
if self.ignore.is_none() && self.pin_to_version.is_none() {
138+
return Err("Either 'ignore' or 'pinToVersion' must be specified");
136139
}
140+
141+
// If both are specified, check for conflicts
142+
if let (Some(ignore_patterns), Some(pin_patterns)) = (&self.ignore, &self.pin_to_version) {
143+
// Check for exact matches (direct conflicts)
144+
for ignore_pattern in ignore_patterns {
145+
for pin_pattern in pin_patterns {
146+
if ignore_pattern == pin_pattern {
147+
return Err(
148+
"The same package pattern cannot appear in both 'ignore' and \
149+
'pinToVersion'",
150+
);
151+
}
152+
153+
// Special case for "*" and "**" wildcards in ignore
154+
if (ignore_pattern == "*" || ignore_pattern == "**")
155+
&& !pin_pattern.starts_with('!')
156+
{
157+
return Err("When using '*' or '**' in 'ignore', all pinToVersion \
158+
patterns must use negation ('!') prefix");
159+
}
160+
}
161+
}
162+
}
163+
164+
Ok(())
137165
}
138166
}
139167

0 commit comments

Comments
 (0)