use aube_lockfile::LockfileGraph;
use aube_lockfile::dep_path_filename::dep_path_to_filename;
use aube_store::PackageIndex;
use rayon::prelude::*;
use std::collections::BTreeMap;
use std::path::Path;
pub fn aube_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Engine {
Node,
Aube,
Pnpm,
}
impl Engine {
pub fn key(self) -> &'static str {
match self {
Self::Node => "node",
Self::Aube => "aube",
Self::Pnpm => "pnpm",
}
}
}
#[derive(Debug)]
pub struct Mismatch {
pub engine: Engine,
pub package: String,
pub declared: String,
pub current: String,
}
pub fn resolve_node_version(override_: Option<&str>) -> Option<String> {
if let Some(v) = override_ {
return Some(v.trim().trim_start_matches('v').to_string());
}
static PROBED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
PROBED.get_or_init(probe_node_version).clone()
}
fn probe_node_version() -> Option<String> {
let output = std::process::Command::new("node")
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
Some(s.trim().trim_start_matches('v').to_string())
}
fn version_satisfies(version: &str, range: &str) -> bool {
let Ok(v) = node_semver::Version::parse(version) else {
return true;
};
let Ok(r) = node_semver::Range::parse(range) else {
return true;
};
v.satisfies(&r)
}
fn check_engine_field(
engines: &BTreeMap<String, String>,
field: &str,
current_version: &str,
) -> Option<String> {
let range = engines.get(field)?;
if version_satisfies(current_version, range) {
None
} else {
Some(range.clone())
}
}
fn check_manifest_engines(
manifest: &aube_manifest::PackageJson,
label: &str,
node_version: Option<&str>,
) -> Vec<Mismatch> {
let aube_v = aube_version();
let mut out = Vec::new();
if let Some(node_v) = node_version
&& let Some(declared) = check_engine_field(&manifest.engines, "node", node_v)
{
out.push(Mismatch {
engine: Engine::Node,
package: label.to_string(),
declared,
current: node_v.to_string(),
});
}
if let Some(declared) = check_engine_field(&manifest.engines, "aube", aube_v) {
out.push(Mismatch {
engine: Engine::Aube,
package: label.to_string(),
declared,
current: aube_v.to_string(),
});
}
if let Some(declared) = check_engine_field(&manifest.engines, "pnpm", aube_v) {
out.push(Mismatch {
engine: Engine::Pnpm,
package: label.to_string(),
declared,
current: aube_v.to_string(),
});
}
out
}
pub fn collect_dep_mismatches(
aube_dir: &Path,
graph: &LockfileGraph,
indices: &BTreeMap<String, PackageIndex>,
node_version: &str,
virtual_store_dir_max_length: usize,
) -> miette::Result<Vec<Mismatch>> {
use miette::miette;
let per_pkg: miette::Result<Vec<Option<Mismatch>>> = graph
.packages
.par_iter()
.map(|(dep_path, pkg)| -> miette::Result<Option<Mismatch>> {
let materialized = aube_dir
.join(dep_path_to_filename(dep_path, virtual_store_dir_max_length))
.join("node_modules")
.join(&pkg.name)
.join("package.json");
let content = match std::fs::read_to_string(&materialized) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(stored) = indices
.get(dep_path)
.and_then(|idx| idx.get("package.json"))
else {
return Ok(None);
};
match std::fs::read_to_string(&stored.store_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(miette!(
"failed to read CAS `package.json` for {}@{} at {}: {e}",
pkg.name,
pkg.version,
stored.store_path.display()
));
}
}
}
Err(e) => {
return Err(miette!(
"failed to read materialized `package.json` for {}@{} at {}: {e}",
pkg.name,
pkg.version,
materialized.display()
));
}
};
let parsed: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
miette!(
"failed to parse `package.json` for {}@{}: {e}",
pkg.name,
pkg.version
)
})?;
let Some(engines) = parsed.get("engines").and_then(|v| v.as_object()) else {
return Ok(None);
};
let Some(node_range) = engines.get("node").and_then(|v| v.as_str()) else {
return Ok(None);
};
if version_satisfies(node_version, node_range) {
Ok(None)
} else {
Ok(Some(Mismatch {
engine: Engine::Node,
package: pkg.spec_key(),
declared: node_range.to_string(),
current: node_version.to_string(),
}))
}
})
.collect();
Ok(per_pkg?.into_iter().flatten().collect())
}
pub fn check_root(
manifest: &aube_manifest::PackageJson,
node_version: Option<&str>,
) -> Vec<Mismatch> {
let label = manifest.name.as_deref().unwrap_or("(root)");
check_manifest_engines(manifest, label, node_version)
}
pub fn check_workspace_importers(
manifests: &[(String, aube_manifest::PackageJson)],
node_version: Option<&str>,
) -> Vec<Mismatch> {
let mut out = Vec::new();
for (rel_path, manifest) in manifests {
if rel_path == "." || rel_path.is_empty() {
continue;
}
let label = manifest.name.as_deref().unwrap_or(rel_path.as_str());
out.extend(check_manifest_engines(manifest, label, node_version));
}
out
}
#[allow(clippy::too_many_arguments)]
pub fn run_checks(
aube_dir: &Path,
manifest: &aube_manifest::PackageJson,
workspace_manifests: &[(String, aube_manifest::PackageJson)],
graph: &LockfileGraph,
indices: &BTreeMap<String, PackageIndex>,
node_version: Option<&str>,
strict: bool,
virtual_store_dir_max_length: usize,
) -> miette::Result<()> {
let mut mismatches = Vec::new();
mismatches.extend(check_root(manifest, node_version));
mismatches.extend(check_workspace_importers(workspace_manifests, node_version));
if let Some(node_v) = node_version {
mismatches.extend(collect_dep_mismatches(
aube_dir,
graph,
indices,
node_v,
virtual_store_dir_max_length,
)?);
}
if mismatches.is_empty() {
return Ok(());
}
let header = if strict {
"Unsupported engine (engine-strict is on)"
} else {
"Unsupported engine"
};
eprintln!("warn: {header}");
for m in &mismatches {
eprintln!(
"warn: {}: wanted {} {}, got {}",
m.package,
m.engine.key(),
m.declared,
m.current,
);
}
if strict {
return Err(miette::miette!(
"engine-strict: {} package(s) declare incompatible engine constraints",
mismatches.len(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_manifest() -> aube_manifest::PackageJson {
aube_manifest::PackageJson {
name: Some("x".into()),
version: None,
dependencies: Default::default(),
dev_dependencies: Default::default(),
peer_dependencies: Default::default(),
optional_dependencies: Default::default(),
update_config: None,
scripts: Default::default(),
engines: Default::default(),
workspaces: None,
bundled_dependencies: None,
extra: Default::default(),
}
}
#[test]
fn range_satisfied_basic() {
assert!(version_satisfies("18.0.0", ">=16"));
assert!(!version_satisfies("14.0.0", ">=16"));
}
#[test]
fn unparseable_range_is_permissive() {
assert!(version_satisfies("18.0.0", "this-is-not-a-range"));
}
#[test]
fn check_root_skips_when_no_engines() {
let m = empty_manifest();
assert!(check_root(&m, Some("18.0.0")).is_empty());
}
#[test]
fn check_root_flags_node_mismatch() {
let mut m = empty_manifest();
m.engines.insert("node".into(), ">=20".into());
let v = check_root(&m, Some("18.0.0"));
assert_eq!(v.len(), 1);
assert_eq!(v[0].engine, Engine::Node);
assert_eq!(v[0].declared, ">=20");
}
#[test]
fn check_root_flags_aube_mismatch() {
let mut m = empty_manifest();
m.engines.insert("aube".into(), ">=99999".into());
let v = check_root(&m, Some("18.0.0"));
assert_eq!(v.len(), 1);
assert_eq!(v[0].engine, Engine::Aube);
assert_eq!(v[0].declared, ">=99999");
assert_eq!(v[0].current, aube_version());
}
#[test]
fn check_root_flags_pnpm_mismatch() {
let mut m = empty_manifest();
m.engines.insert("pnpm".into(), ">=99999".into());
let v = check_root(&m, Some("18.0.0"));
assert_eq!(v.len(), 1);
assert_eq!(v[0].engine, Engine::Pnpm);
assert_eq!(v[0].current, aube_version());
}
#[test]
fn check_root_aube_satisfied_when_range_matches() {
let mut m = empty_manifest();
m.engines.insert("aube".into(), ">=0.0.1".into());
assert!(check_root(&m, Some("18.0.0")).is_empty());
}
#[test]
fn check_root_flags_all_three_engines_independently() {
let mut m = empty_manifest();
m.engines.insert("node".into(), ">=99999".into());
m.engines.insert("aube".into(), ">=99999".into());
m.engines.insert("pnpm".into(), ">=99999".into());
let v = check_root(&m, Some("18.0.0"));
assert_eq!(v.len(), 3);
let engines: std::collections::HashSet<_> = v.iter().map(|m| m.engine).collect();
assert!(engines.contains(&Engine::Node));
assert!(engines.contains(&Engine::Aube));
assert!(engines.contains(&Engine::Pnpm));
}
#[test]
fn check_root_skips_engines_node_when_no_node() {
let mut m = empty_manifest();
m.engines.insert("node".into(), ">=20".into());
m.engines.insert("aube".into(), ">=99999".into());
let v = check_root(&m, None);
assert_eq!(v.len(), 1, "engines.node must be skipped, got {v:?}");
assert_eq!(v[0].engine, Engine::Aube);
}
#[test]
fn check_workspace_importers_skips_root() {
let mut root = empty_manifest();
root.engines.insert("node".into(), ">=99999".into());
let manifests = vec![(".".to_string(), root)];
assert!(check_workspace_importers(&manifests, Some("18.0.0")).is_empty());
}
#[test]
fn check_workspace_importers_flags_per_project() {
let mut a = empty_manifest();
a.name = Some("project-a".into());
a.engines.insert("node".into(), ">=99999".into());
let mut b = empty_manifest();
b.name = Some("project-b".into());
b.engines.insert("pnpm".into(), ">=99999".into());
let manifests = vec![
(".".to_string(), empty_manifest()),
("packages/a".to_string(), a),
("packages/b".to_string(), b),
];
let v = check_workspace_importers(&manifests, Some("18.0.0"));
assert_eq!(v.len(), 2);
assert!(
v.iter()
.any(|m| m.package == "project-a" && m.engine == Engine::Node)
);
assert!(
v.iter()
.any(|m| m.package == "project-b" && m.engine == Engine::Pnpm)
);
}
#[test]
fn check_workspace_importers_falls_back_to_rel_path_label() {
let mut unnamed = empty_manifest();
unnamed.name = None;
unnamed.engines.insert("node".into(), ">=99999".into());
let manifests = vec![("packages/unnamed".to_string(), unnamed)];
let v = check_workspace_importers(&manifests, Some("18.0.0"));
assert_eq!(v.len(), 1);
assert_eq!(v[0].package, "packages/unnamed");
}
#[test]
fn collect_dep_mismatches_reads_materialized_pkg_json() {
use aube_lockfile::dep_path_filename::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH;
use aube_lockfile::{DepType, DirectDep, LockedPackage};
use std::collections::BTreeMap;
let tmp = tempfile::tempdir().unwrap();
let dep_path = "pkg@1.0.0";
let pkg_dir = tmp
.path()
.join("node_modules/.aube")
.join(dep_path_to_filename(
dep_path,
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
))
.join("node_modules/pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(
pkg_dir.join("package.json"),
r#"{"name":"pkg","version":"1.0.0","engines":{"node":">=99"}}"#,
)
.unwrap();
let mut graph = LockfileGraph::default();
graph.packages.insert(
dep_path.into(),
LockedPackage {
name: "pkg".into(),
version: "1.0.0".into(),
..Default::default()
},
);
graph.importers.insert(
".".into(),
vec![DirectDep {
name: "pkg".into(),
dep_path: dep_path.into(),
dep_type: DepType::Production,
specifier: None,
}],
);
let indices: BTreeMap<String, PackageIndex> = BTreeMap::new();
let aube_dir = tmp.path().join("node_modules/.aube");
let mismatches = collect_dep_mismatches(
&aube_dir,
&graph,
&indices,
"18.0.0",
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
)
.unwrap();
assert_eq!(mismatches.len(), 1, "engine mismatch should be surfaced");
assert_eq!(mismatches[0].package, "pkg@1.0.0");
assert_eq!(mismatches[0].declared, ">=99");
assert_eq!(mismatches[0].engine, Engine::Node);
}
#[cfg(unix)]
#[test]
fn collect_dep_mismatches_propagates_non_not_found_io_errors() {
use aube_lockfile::dep_path_filename::DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH;
use aube_lockfile::{DepType, DirectDep, LockedPackage};
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dep_path = "pkg@1.0.0";
let pkg_dir = tmp
.path()
.join("node_modules/.aube")
.join(dep_path_to_filename(
dep_path,
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
))
.join("node_modules/pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let pkg_json = pkg_dir.join("package.json");
std::fs::write(&pkg_json, r#"{"name":"pkg","version":"1.0.0"}"#).unwrap();
let mut perms = std::fs::metadata(&pkg_json).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&pkg_json, perms).unwrap();
let mut graph = LockfileGraph::default();
graph.packages.insert(
dep_path.into(),
LockedPackage {
name: "pkg".into(),
version: "1.0.0".into(),
..Default::default()
},
);
graph.importers.insert(
".".into(),
vec![DirectDep {
name: "pkg".into(),
dep_path: dep_path.into(),
dep_type: DepType::Production,
specifier: None,
}],
);
let indices: BTreeMap<String, PackageIndex> = BTreeMap::new();
let aube_dir = tmp.path().join("node_modules/.aube");
let result = collect_dep_mismatches(
&aube_dir,
&graph,
&indices,
"18.0.0",
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
);
let mut perms = std::fs::metadata(&pkg_json).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&pkg_json, perms).unwrap();
let is_root = unsafe { libc::geteuid() } == 0;
if is_root {
eprintln!("skipping permission-error test under root");
return;
}
assert!(
result.is_err(),
"permission-denied read must propagate, got {result:?}"
);
}
#[test]
fn resolve_node_version_strips_v_prefix() {
assert_eq!(
resolve_node_version(Some("v18.17.1")).as_deref(),
Some("18.17.1")
);
assert_eq!(
resolve_node_version(Some("20.0.0")).as_deref(),
Some("20.0.0")
);
}
}