use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
const MAX_REACHABLE_MANIFESTS: usize = 512;
const DEPENDENCY_TABLES: [&str; 3] = ["dependencies", "build-dependencies", "dev-dependencies"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_versions: Vec<String>,
}
struct DeclaredRequirement {
manifest: PathBuf,
name: String,
requirement: String,
}
#[cfg(test)]
pub(crate) fn check_generated_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
check_generated_lock_freshness_tolerating_pending_publish(generated_paths, Path::new("."), None)
}
fn collect_generated_lock_findings(generated_paths: &HashSet<PathBuf>) -> Vec<StaleLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated Rust manifests against their committed lockfiles"
);
findings
}
pub(crate) fn check_generated_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
workspace_root: &Path,
canonical: Option<&str>,
) -> Option<anyhow::Error> {
let findings = collect_generated_lock_findings(generated_paths);
if findings.is_empty() {
return None;
}
let Some(canonical) = canonical else {
return Some(anyhow::anyhow!(stale_lock_message(&findings)));
};
let tracked = crate::cli::git::tracked_paths_under(workspace_root);
let blocked: std::collections::HashMap<PathBuf, String> =
crate::cli::commands::version_manifests::discover_cargo_locks(workspace_root, canonical, tracked.as_ref())
.into_iter()
.filter_map(|lock| lock.blocked_on_publish.map(|waiting_on| (lock.path, waiting_on)))
.collect();
let (pending, real): (Vec<_>, Vec<_>) = findings
.into_iter()
.partition(|finding| super::version_lockfiles::explained_by_pending_publish(finding, &blocked));
if !pending.is_empty() {
tracing::warn!(
"{} committed Cargo.lock pin(s) below require this crate's own version, which is not on the \
registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_lock_message(&real)))
}
}
fn registry_self_dependency(
resolved_cfg: &crate::core::config::ResolvedCrateConfig,
lang: &str,
normalize: impl Fn(&str) -> String,
) -> Option<RegistrySelfDependency> {
let mut e2e_config = resolved_cfg.e2e.clone()?;
e2e_config.dep_mode = crate::core::config::e2e::DependencyMode::Registry;
let package = e2e_config.resolve_package(lang)?;
let name = package.name?;
let version = package.version?;
Some(RegistrySelfDependency {
name,
requirement: normalize(&version),
})
}
struct RegistrySelfDependency {
name: String,
requirement: String,
}
pub(crate) fn stale_lock_findings(manifest_dir: &Path) -> Vec<StaleLockFinding> {
let manifest_path = manifest_dir.join("Cargo.toml");
let lock_path = manifest_dir.join("Cargo.lock");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let locked = locked_versions(&lock_text);
if locked.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for declared in reachable_requirements(&manifest_path) {
let Some(versions) = locked.get(&declared.name) else {
continue;
};
let Ok(requirement) = semver::VersionReq::parse(&declared.requirement) else {
continue;
};
if versions.iter().any(|version| requirement.matches(version)) {
continue;
}
findings.push(StaleLockFinding {
lock: lock_path.clone(),
declared_in: declared.manifest.clone(),
dependency: declared.name.clone(),
requirement: declared.requirement.clone(),
locked_versions: versions.iter().map(ToString::to_string).collect(),
});
}
findings.sort_by(|left, right| {
left.dependency
.cmp(&right.dependency)
.then_with(|| left.requirement.cmp(&right.requirement))
});
findings.dedup_by(|left, right| left.dependency == right.dependency && left.requirement == right.requirement);
findings
}
fn locked_versions(lock_text: &str) -> BTreeMap<String, Vec<semver::Version>> {
let mut locked: BTreeMap<String, Vec<semver::Version>> = BTreeMap::new();
let Some(packages) = toml::from_str::<toml::Value>(lock_text)
.ok()
.and_then(|value| value.get("package").and_then(toml::Value::as_array).cloned())
else {
return locked;
};
for package in packages {
let (Some(name), Some(version)) = (
package.get("name").and_then(toml::Value::as_str),
package.get("version").and_then(toml::Value::as_str),
) else {
continue;
};
if let Ok(parsed) = semver::Version::parse(version) {
locked.entry(name.to_string()).or_default().push(parsed);
}
}
for versions in locked.values_mut() {
versions.sort();
}
locked
}
struct QueuedManifest {
path: PathBuf,
requested_features: Vec<String>,
default_features: bool,
}
fn reachable_requirements(root_manifest: &Path) -> Vec<DeclaredRequirement> {
let mut requirements = Vec::new();
let mut queue = vec![QueuedManifest {
path: root_manifest.to_path_buf(),
requested_features: Vec::new(),
default_features: true,
}];
let mut visited: HashSet<PathBuf> = HashSet::new();
while let Some(item) = queue.pop() {
if visited.len() >= MAX_REACHABLE_MANIFESTS {
tracing::warn!(
root = %root_manifest.display(),
limit = MAX_REACHABLE_MANIFESTS,
"stopped walking path dependencies at the manifest limit; lock freshness for this \
crate was checked against a partial requirement set"
);
break;
}
let key = std::fs::canonicalize(&item.path).unwrap_or_else(|_| item.path.clone());
if !visited.insert(key) {
continue;
}
let Ok(text) = std::fs::read_to_string(&item.path) else {
continue;
};
let Ok(document) = toml::from_str::<toml::Value>(&text) else {
continue;
};
let is_root = item.path == root_manifest;
let activated_optional_deps =
activated_optional_dependencies(&document, &item.requested_features, item.default_features);
collect_requirements(
&item.path,
&document,
is_root,
&activated_optional_deps,
&mut requirements,
&mut queue,
);
}
requirements
}
fn activated_optional_dependencies(
document: &toml::Value,
requested: &[String],
default_features: bool,
) -> HashSet<String> {
let features_table = document.get("features").and_then(toml::Value::as_table);
let mut activated_deps = HashSet::new();
let mut queue: Vec<String> = requested.to_vec();
if default_features {
queue.push("default".to_string());
}
let mut visited_features: HashSet<String> = HashSet::new();
while let Some(feature) = queue.pop() {
if !visited_features.insert(feature.clone()) {
continue;
}
let Some(entries) = features_table
.and_then(|table| table.get(feature.as_str()))
.and_then(toml::Value::as_array)
else {
continue;
};
for entry in entries {
let Some(entry) = entry.as_str() else { continue };
if let Some(dep_key) = entry.strip_prefix("dep:") {
activated_deps.insert(dep_key.to_string());
} else if let Some((dep_key, _sub_feature)) = entry.split_once('/') {
activated_deps.insert(dep_key.trim_end_matches('?').to_string());
} else {
activated_deps.insert(entry.to_string());
queue.push(entry.to_string());
}
}
}
activated_deps
}
fn edge_feature_request(table: &toml::Table, inherited_table: Option<&toml::Table>) -> (Vec<String>, bool) {
let mut features: Vec<String> = inherited_table
.and_then(|entry| entry.get("features"))
.and_then(toml::Value::as_array)
.into_iter()
.flatten()
.chain(
table
.get("features")
.and_then(toml::Value::as_array)
.into_iter()
.flatten(),
)
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
features.sort();
features.dedup();
let default_features = table
.get("default-features")
.or_else(|| inherited_table.and_then(|entry| entry.get("default-features")))
.and_then(toml::Value::as_bool)
.unwrap_or(true);
(features, default_features)
}
fn collect_requirements(
manifest_path: &Path,
document: &toml::Value,
include_dev: bool,
activated_optional_deps: &HashSet<String>,
requirements: &mut Vec<DeclaredRequirement>,
queue: &mut Vec<QueuedManifest>,
) {
let mut tables: Vec<&toml::Value> = vec![document];
if let Some(targets) = document.get("target").and_then(toml::Value::as_table) {
tables.extend(targets.values());
}
for table in tables {
for section in DEPENDENCY_TABLES {
if section == "dev-dependencies" && !include_dev {
continue;
}
let Some(entries) = table.get(section).and_then(toml::Value::as_table) else {
continue;
};
for (alias, spec) in entries {
collect_one_requirement(manifest_path, alias, spec, activated_optional_deps, requirements, queue);
}
}
}
}
fn resolve_dependency_identity(
manifest_path: &Path,
alias: &str,
table: &toml::Table,
) -> (Option<toml::Value>, String) {
let inherited = table
.get("workspace")
.and_then(toml::Value::as_bool)
.unwrap_or(false)
.then(|| workspace_dependency_spec(manifest_path, alias))
.flatten();
let name = inherited
.as_ref()
.and_then(toml::Value::as_table)
.and_then(|entry| entry.get("package"))
.or_else(|| table.get("package"))
.and_then(toml::Value::as_str)
.unwrap_or(alias)
.to_string();
(inherited, name)
}
fn collect_one_requirement(
manifest_path: &Path,
alias: &str,
spec: &toml::Value,
activated_optional_deps: &HashSet<String>,
requirements: &mut Vec<DeclaredRequirement>,
queue: &mut Vec<QueuedManifest>,
) {
if let Some(requirement) = spec.as_str() {
requirements.push(DeclaredRequirement {
manifest: manifest_path.to_path_buf(),
name: alias.to_string(),
requirement: requirement.to_string(),
});
return;
}
let Some(table) = spec.as_table() else {
return;
};
let (inherited, name) = resolve_dependency_identity(manifest_path, alias, table);
let inherited_table = inherited.as_ref().and_then(toml::Value::as_table);
let is_optional = table.get("optional").and_then(toml::Value::as_bool).unwrap_or(false);
if is_optional && !activated_optional_deps.contains(alias) {
return;
}
let (requested_features, default_features) = edge_feature_request(table, inherited_table);
if let Some(relative) = table.get("path").and_then(toml::Value::as_str)
&& let Some(dir) = manifest_path.parent()
{
queue.push(QueuedManifest {
path: normalize_lexically(&dir.join(relative).join("Cargo.toml")),
requested_features,
default_features,
});
}
let is_source_pinned = |entry: &toml::Table| entry.contains_key("path") || entry.contains_key("git");
if is_source_pinned(table) || inherited_table.is_some_and(is_source_pinned) {
return;
}
let requirement = match inherited.as_ref() {
Some(value) => value
.as_str()
.or_else(|| value.get("version").and_then(toml::Value::as_str)),
None => table.get("version").and_then(toml::Value::as_str),
};
let Some(requirement) = requirement else {
return;
};
requirements.push(DeclaredRequirement {
manifest: manifest_path.to_path_buf(),
name,
requirement: requirement.to_string(),
});
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut components: Vec<std::path::Component<'_>> = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir if matches!(components.last(), Some(std::path::Component::Normal(_))) => {
components.pop();
}
other => components.push(other),
}
}
components.into_iter().collect()
}
fn workspace_dependency_spec(manifest_path: &Path, alias: &str) -> Option<toml::Value> {
let mut directory = manifest_path.parent();
while let Some(current) = directory {
let candidate = current.join("Cargo.toml");
if let Ok(text) = std::fs::read_to_string(&candidate)
&& let Ok(document) = toml::from_str::<toml::Value>(&text)
&& let Some(workspace) = document.get("workspace")
{
return workspace
.get("dependencies")
.and_then(toml::Value::as_table)
.and_then(|table| table.get(alias))
.cloned();
}
directory = current.parent();
}
None
}
fn stale_lock_message(findings: &[StaleLockFinding]) -> String {
let mut message = format!(
"{} committed Cargo.lock pin(s) cannot satisfy a requirement from a manifest alef generated; \
`cargo metadata --locked` and `cargo build --locked` will fail in these directories:",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` by {}, but the lock pins only {}. Fix with: cargo \
update --manifest-path {} -p {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.locked_versions.join(", "),
finding
.lock
.parent()
.unwrap_or(Path::new("."))
.join("Cargo.toml")
.display(),
finding.dependency,
));
}
message.push_str(
"\nA pin held back on purpose belongs in the manifest that declares the requirement -- a lockfile \
cannot record an exception to its own resolution.",
);
message
}
const NODE_DEPENDENCY_BUCKETS: [&str; 2] = ["dependencies", "devDependencies"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleNodeLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) bucket: &'static str,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_requirement: String,
}
#[cfg(test)]
pub(crate) fn check_generated_node_lock_freshness(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Option<anyhow::Error> {
check_generated_node_lock_freshness_tolerating_pending_publish(generated_paths, base_dir, None)
}
fn collect_generated_node_lock_findings(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
) -> Vec<StaleNodeLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("package.json") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
let registered_dirs = registered_unmarkable_manifest_dirs(base_dir, "package.json");
let registered_only = registered_dirs.difference(&directories).count();
directories.extend(registered_dirs);
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_node_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
registered_only_dirs = registered_only,
findings = findings.len(),
"checked generated package.json files against their committed pnpm-lock.yaml"
);
findings
}
pub(crate) fn check_generated_node_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
base_dir: &Path,
resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
let findings = collect_generated_node_lock_findings(generated_paths, base_dir);
if findings.is_empty() {
return None;
}
let Some(self_dependency) = resolved_cfg.and_then(|cfg| registry_self_dependency(cfg, "node", str::to_string))
else {
return Some(anyhow::anyhow!(stale_node_lock_message(&findings)));
};
let pending_locks: HashSet<PathBuf> = findings
.iter()
.filter(|finding| {
finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
})
.map(|finding| finding.lock.clone())
.collect();
let (pending, real): (Vec<_>, Vec<_>) = findings
.into_iter()
.partition(|finding| pending_locks.contains(&finding.lock));
if !pending.is_empty() {
tracing::warn!(
"{} committed pnpm-lock.yaml pin(s) below require this crate's own version, which is not on the \
registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_node_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_node_lock_message(&real)))
}
}
fn registered_unmarkable_manifest_dirs(base_dir: &Path, file_name: &str) -> BTreeSet<PathBuf> {
crate::cli::cache::read_committed_owned_paths(base_dir)
.iter()
.map(|relative| base_dir.join(relative))
.filter(|path| path.file_name().and_then(|name| name.to_str()) == Some(file_name))
.filter_map(|path| path.parent().map(Path::to_path_buf))
.collect()
}
pub(crate) fn stale_node_lock_findings(package_json_dir: &Path) -> Vec<StaleNodeLockFinding> {
let manifest_path = package_json_dir.join("package.json");
let lock_path = package_json_dir.join("pnpm-lock.yaml");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
return Vec::new();
};
let Ok(manifest_json) = serde_json::from_str::<serde_json::Value>(&manifest_text) else {
return Vec::new();
};
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let Ok(lock_yaml) = serde_saphyr::from_str::<serde_json::Value>(&lock_text) else {
return Vec::new();
};
let mut findings = Vec::new();
for bucket in NODE_DEPENDENCY_BUCKETS {
let locked = locked_node_specifiers(&lock_yaml, bucket);
if locked.is_empty() {
continue;
}
let Some(declared) = manifest_json.get(bucket).and_then(serde_json::Value::as_object) else {
continue;
};
for (name, spec_value) in declared {
let Some(requirement) = spec_value.as_str() else {
continue;
};
if !is_checkable_node_specifier(requirement) {
continue;
}
let Some(locked_requirement) = locked.get(name.as_str()) else {
continue;
};
if !is_checkable_node_specifier(locked_requirement) {
continue;
}
if locked_requirement.trim() == requirement.trim() {
continue;
}
findings.push(StaleNodeLockFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
bucket,
dependency: name.clone(),
requirement: requirement.to_string(),
locked_requirement: locked_requirement.clone(),
});
}
}
findings.sort_by(|left, right| {
left.bucket
.cmp(right.bucket)
.then_with(|| left.dependency.cmp(&right.dependency))
});
findings
}
fn locked_node_specifiers(lock: &serde_json::Value, bucket: &str) -> BTreeMap<String, String> {
let table = lock
.get("importers")
.and_then(|importers| importers.get("."))
.and_then(|root| root.get(bucket))
.or_else(|| lock.get(bucket))
.and_then(serde_json::Value::as_object);
let Some(table) = table else {
return BTreeMap::new();
};
let mut specifiers = BTreeMap::new();
for (name, value) in table {
let Some(specifier) = value.get("specifier").and_then(serde_json::Value::as_str) else {
continue;
};
specifiers.insert(name.to_string(), specifier.to_string());
}
specifiers
}
fn is_checkable_node_specifier(specifier: &str) -> bool {
let trimmed = specifier.trim();
if trimmed.is_empty() {
return false;
}
const UNCHECKABLE_PREFIXES: [&str; 8] = [
"npm:",
"workspace:",
"catalog:",
"file:",
"link:",
"git+",
"git:",
"github:",
];
if UNCHECKABLE_PREFIXES.iter().any(|prefix| trimmed.starts_with(prefix)) {
return false;
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return false;
}
!trimmed.contains('/')
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleUvLockFinding {
pub(crate) lock: PathBuf,
pub(crate) declared_in: PathBuf,
pub(crate) dependency: String,
pub(crate) requirement: String,
pub(crate) locked_requirement: String,
}
#[cfg(test)]
pub(crate) fn check_generated_uv_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
check_generated_uv_lock_freshness_tolerating_pending_publish(generated_paths, None)
}
fn collect_generated_uv_lock_findings(generated_paths: &HashSet<PathBuf>) -> Vec<StaleUvLockFinding> {
let mut directories = BTreeSet::new();
for path in generated_paths {
if path.file_name().and_then(|name| name.to_str()) != Some("pyproject.toml") {
continue;
}
if let Some(dir) = path.parent() {
directories.insert(dir.to_path_buf());
}
}
let mut findings = Vec::new();
for dir in &directories {
findings.extend(stale_uv_lock_findings(dir));
}
tracing::debug!(
manifest_dirs = directories.len(),
findings = findings.len(),
"checked generated pyproject.toml files against their committed uv.lock"
);
findings
}
pub(crate) fn check_generated_uv_lock_freshness_tolerating_pending_publish(
generated_paths: &HashSet<PathBuf>,
resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
let findings = collect_generated_uv_lock_findings(generated_paths);
if findings.is_empty() {
return None;
}
let Some(self_dependency) = resolved_cfg.and_then(|cfg| {
registry_self_dependency(
cfg,
"python",
crate::e2e::codegen::python::config::normalize_python_version,
)
}) else {
return Some(anyhow::anyhow!(stale_uv_lock_message(&findings)));
};
let (pending, real): (Vec<_>, Vec<_>) = findings.into_iter().partition(|finding| {
finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
});
if !pending.is_empty() {
tracing::warn!(
"{} committed uv.lock pin(s) below require this crate's own version, which is not on the \
registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
pending.len(),
stale_uv_lock_message(&pending)
);
}
if real.is_empty() {
None
} else {
Some(anyhow::anyhow!(stale_uv_lock_message(&real)))
}
}
pub(crate) fn stale_uv_lock_findings(pyproject_dir: &Path) -> Vec<StaleUvLockFinding> {
let manifest_path = pyproject_dir.join("pyproject.toml");
let lock_path = pyproject_dir.join("uv.lock");
if !manifest_path.is_file() {
return Vec::new();
}
let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
return Vec::new();
};
let Ok(manifest_toml) = toml::from_str::<toml::Value>(&manifest_text) else {
return Vec::new();
};
let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
return Vec::new();
};
let Ok(lock_toml) = toml::from_str::<toml::Value>(&lock_text) else {
return Vec::new();
};
let Some(project) = manifest_toml.get("project") else {
return Vec::new();
};
let Some(project_name) = project.get("name").and_then(toml::Value::as_str) else {
return Vec::new();
};
let Some(dependencies) = project.get("dependencies").and_then(toml::Value::as_array) else {
return Vec::new();
};
let locked = locked_uv_requirements(&lock_toml, project_name);
if locked.is_empty() {
return Vec::new();
}
let overridden = uv_source_override_names(&manifest_toml);
let mut findings = Vec::new();
for entry in dependencies {
let Some(raw) = entry.as_str() else { continue };
let Some((name, requirement)) = parse_pep508_requirement(raw) else {
continue;
};
let normalized = normalize_pep503_name(&name);
if overridden.contains(&normalized) {
continue;
}
let Some(locked_requirement) = locked.get(&normalized) else {
continue;
};
if locked_requirement.trim() == requirement.trim() {
continue;
}
findings.push(StaleUvLockFinding {
lock: lock_path.clone(),
declared_in: manifest_path.clone(),
dependency: name,
requirement,
locked_requirement: locked_requirement.clone(),
});
}
findings.sort_by(|left, right| left.dependency.cmp(&right.dependency));
findings
}
fn locked_uv_requirements(lock: &toml::Value, project_name: &str) -> BTreeMap<String, String> {
let normalized_project = normalize_pep503_name(project_name);
let root_requires_dist = lock
.get("package")
.and_then(toml::Value::as_array)
.and_then(|packages| {
packages.iter().find(|package| {
package
.get("name")
.and_then(toml::Value::as_str)
.is_some_and(|name| normalize_pep503_name(name) == normalized_project)
})
})
.and_then(|package| package.get("metadata"))
.and_then(|metadata| metadata.get("requires-dist"))
.and_then(toml::Value::as_array);
if let Some(entries) = root_requires_dist {
let map = requires_dist_map(entries);
if !map.is_empty() {
return map;
}
}
lock.get("manifest")
.and_then(|manifest| manifest.get("requirements"))
.and_then(toml::Value::as_array)
.map(|entries| requires_dist_map(entries))
.unwrap_or_default()
}
fn requires_dist_map(entries: &[toml::Value]) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
for entry in entries {
let Some(table) = entry.as_table() else { continue };
if table.contains_key("marker") || table.contains_key("extra") {
continue;
}
let Some(name) = table.get("name").and_then(toml::Value::as_str) else {
continue;
};
let specifier = table.get("specifier").and_then(toml::Value::as_str).unwrap_or("");
map.insert(normalize_pep503_name(name), specifier.to_string());
}
map
}
fn uv_source_override_names(manifest_toml: &toml::Value) -> HashSet<String> {
manifest_toml
.get("tool")
.and_then(|tool| tool.get("uv"))
.and_then(|uv| uv.get("sources"))
.and_then(toml::Value::as_table)
.map(|table| table.keys().map(|name| normalize_pep503_name(name)).collect())
.unwrap_or_default()
}
fn parse_pep508_requirement(raw: &str) -> Option<(String, String)> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.contains(';') || trimmed.contains('@') || trimmed.contains('[') {
return None;
}
let name_len = trimmed
.find(|character: char| !(character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')))
.unwrap_or(trimmed.len());
if name_len == 0 {
return None;
}
let (name, specifier) = trimmed.split_at(name_len);
Some((name.to_string(), specifier.trim().to_string()))
}
fn normalize_pep503_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
let mut previous_was_separator = false;
for character in name.chars() {
if matches!(character, '-' | '_' | '.') {
if !previous_was_separator {
normalized.push('-');
}
previous_was_separator = true;
} else {
normalized.push(character.to_ascii_lowercase());
previous_was_separator = false;
}
}
normalized
}
fn stale_uv_lock_message(findings: &[StaleUvLockFinding]) -> String {
let mut message = format!(
"{} committed uv.lock specifier(s) disagree with a pyproject.toml alef generated; `uv sync \
--locked` (and frozen-lockfile CI jobs) will fail with \"The lockfile at `uv.lock` needs to be \
updated\":",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is required as `{}` by {}, but the lock records `{}`. Fix with: uv lock \
--project {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.locked_requirement,
finding.lock.parent().unwrap_or(Path::new(".")).display(),
));
}
message.push_str(
"\nA pin held back on purpose belongs in pyproject.toml -- a lockfile cannot record an exception \
to its own resolution.",
);
message
}
fn stale_node_lock_message(findings: &[StaleNodeLockFinding]) -> String {
let mut message = format!(
"{} committed pnpm-lock.yaml specifier(s) disagree with a package.json alef generated; `pnpm \
install --frozen-lockfile` (the CI default) will fail with ERR_PNPM_OUTDATED_LOCKFILE:",
findings.len()
);
for finding in findings {
message.push_str(&format!(
"\n - {}: `{}` is `{}` in {} ({}), but the lock records `{}`. Fix with: pnpm install \
--lockfile-only -C {}",
finding.lock.display(),
finding.dependency,
finding.requirement,
finding.declared_in.display(),
finding.bucket,
finding.locked_requirement,
finding.lock.parent().unwrap_or(Path::new(".")).display(),
));
}
message.push_str(
"\nA pin held back on purpose belongs in package.json -- a lockfile cannot record an exception \
to its own resolution.",
);
message
}
#[cfg(test)]
#[path = "lock_freshness_tests.rs"]
mod tests;