use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use super::shared::{RegistrySelfDependency, registered_unmarkable_manifest_dirs, registry_self_dependency};
const NODE_DEPENDENCY_BUCKETS: [&str; 2] = ["dependencies", "devDependencies"];
const PACKAGE_JSON_EMITTING_LANGS: [&str; 2] = ["node", "wasm"];
#[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 self_dependencies: Vec<RegistrySelfDependency> = resolved_cfg
.map(|cfg| {
PACKAGE_JSON_EMITTING_LANGS
.into_iter()
.filter_map(|lang| registry_self_dependency(cfg, lang, |package| package.name.clone(), str::to_string))
.collect()
})
.unwrap_or_default();
if self_dependencies.is_empty() {
return Some(anyhow::anyhow!(stale_node_lock_message(&findings)));
}
let pending_locks: HashSet<PathBuf> = findings
.iter()
.filter(|finding| {
self_dependencies.iter().any(|self_dependency| {
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)))
}
}
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('/')
}
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 = "node_tests.rs"]
mod tests;