use std::path::Path;
const STALE_OUTPUT: &[&str] = &["public/assets", "public/packs", "public/packs-test"];
const LOCKFILES: &[(&str, &str, &str)] = &[
("package-lock.json", "node_modules", "npm install"),
("npm-shrinkwrap.json", "node_modules", "npm install"),
("yarn.lock", "node_modules", "yarn install"),
("pnpm-lock.yaml", "node_modules", "pnpm install"),
];
fn inside(dir: &str, path: &str) -> bool {
path.strip_prefix(dir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn is_stale_output(path: &str) -> bool {
STALE_OUTPUT.iter().any(|dir| inside(dir, path))
}
pub(crate) fn without_stale_output(files: &[String]) -> Vec<String> {
files.iter().filter(|f| !is_stale_output(f)).cloned().collect()
}
enum Drift {
Changed,
Missing,
}
fn lock_drift(installed: Option<&str>, workspace: Option<&str>) -> Option<Drift> {
match (installed, workspace) {
(None, _) => None,
(Some(_), None) => Some(Drift::Missing),
(Some(a), Some(b)) if a != b => Some(Drift::Changed),
(Some(_), Some(_)) => None,
}
}
fn drift_line(
lock: &str,
deps: &str,
install: &str,
installed: Option<&str>,
workspace: Option<&str>,
) -> Option<String> {
match lock_drift(installed, workspace) {
Some(Drift::Changed) => Some(format!(
"Warning: {deps} was cloned from a {lock} that differs from this workspace's; run '{install}' in the workspace."
)),
Some(Drift::Missing) => Some(format!(
"Warning: {deps} was cloned from the project's {lock}, which this workspace does not have; run '{install}' in the workspace."
)),
None => None,
}
}
fn lockfile_hash(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| crate::trust::sha256_hex(&bytes))
}
fn drift_lines(project_dir: &Path, ws_dir: &Path) -> Vec<String> {
let mut lines = Vec::new();
for &(lock, deps, install) in LOCKFILES {
if !project_dir.join(deps).is_dir() {
continue;
}
let installed = lockfile_hash(&project_dir.join(lock));
let workspace = lockfile_hash(&ws_dir.join(lock));
if let Some(line) = drift_line(lock, deps, install, installed.as_deref(), workspace.as_deref()) {
lines.push(line);
}
}
lines
}
pub(crate) fn warn_lockfile_drift(project_dir: &Path, ws_dir: &Path) {
for line in drift_lines(project_dir, ws_dir) {
eprintln!("{line}");
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn tree(tmp: &Path, name: &str, files: &[(&str, &str)]) -> PathBuf {
let root = tmp.join(name);
for (rel, body) in files {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().expect("a parent")).expect("the parent dirs");
std::fs::write(path, body).expect("the file");
}
root
}
#[test]
fn inside_matches_whole_components_only() {
for (dir, path, expected) in [
("public/packs-test", "public/packs-test", true),
("public/packs-test", "public/packs-test/js/manifest.js", true),
("public/packs", "public/packs-old/x", false),
("public/assets", "public/assets-old/x", false),
("public/packs", "public/packs-test/x", false),
] {
assert_eq!(inside(dir, path), expected, "inside({dir:?}, {path:?})");
}
}
#[test]
fn without_stale_output_drops_generated_output_and_keeps_lookalikes() {
let files: Vec<String> = [
"node_modules/leftpad/index.js",
"public/packs-test/js/manifest.js",
"public/packs/js/manifest.js",
"public/assets/.sprockets-manifest.json",
"tmp/cache/bootsnap-test.bin",
"build/out.o",
"public/packs-old/keep.js",
"public/uploads/avatar.png",
".env",
]
.iter()
.map(|s| s.to_string())
.collect();
let expected: Vec<String> = [
"node_modules/leftpad/index.js",
"tmp/cache/bootsnap-test.bin",
"build/out.o",
"public/packs-old/keep.js",
"public/uploads/avatar.png",
".env",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(without_stale_output(&files), expected);
}
const LOCK_A: &str = "{\"lockfileVersion\":3,\"packages\":\"a\"}";
const LOCK_B: &str = "{\"lockfileVersion\":3,\"packages\":\"b\"}";
#[test]
fn drift_lines_warn_when_the_cloned_tree_came_from_a_different_lockfile() {
let tmp = tempfile::tempdir().unwrap();
let project =
tree(tmp.path(), "project", &[("package-lock.json", LOCK_A), ("node_modules/leftpad/index.js", "x\n")]);
let workspace = tree(tmp.path(), "workspace", &[("package-lock.json", LOCK_B)]);
assert_eq!(
drift_lines(&project, &workspace),
vec![
"Warning: node_modules was cloned from a package-lock.json that differs from this workspace's; run 'npm install' in the workspace."
.to_string()
]
);
}
#[test]
fn drift_lines_are_silent_when_the_lockfiles_match() {
let tmp = tempfile::tempdir().unwrap();
let project =
tree(tmp.path(), "project", &[("package-lock.json", LOCK_A), ("node_modules/leftpad/index.js", "x\n")]);
let workspace = tree(tmp.path(), "workspace", &[("package-lock.json", LOCK_A)]);
assert!(drift_lines(&project, &workspace).is_empty());
}
#[test]
fn drift_lines_warn_when_the_workspace_has_no_lockfile() {
let tmp = tempfile::tempdir().unwrap();
let project =
tree(tmp.path(), "project", &[("package-lock.json", LOCK_A), ("node_modules/leftpad/index.js", "x\n")]);
let workspace = tree(tmp.path(), "workspace", &[("node_modules/leftpad/index.js", "x\n")]);
assert_eq!(
drift_lines(&project, &workspace),
vec![
"Warning: node_modules was cloned from the project's package-lock.json, which this workspace does not have; run 'npm install' in the workspace."
.to_string()
]
);
}
#[test]
fn drift_lines_stay_silent_without_a_tree_or_a_project_lockfile() {
let tmp = tempfile::tempdir().unwrap();
let no_tree = tree(tmp.path(), "no-tree", &[("package-lock.json", LOCK_A)]);
let workspace_a = tree(tmp.path(), "ws-a", &[("package-lock.json", LOCK_B)]);
assert!(drift_lines(&no_tree, &workspace_a).is_empty());
let no_lock = tree(tmp.path(), "no-lock", &[("node_modules/leftpad/index.js", "x\n")]);
let workspace_b = tree(tmp.path(), "ws-b", &[("package-lock.json", LOCK_B)]);
assert!(drift_lines(&no_lock, &workspace_b).is_empty());
}
#[test]
fn drift_lines_warn_per_package_manager() {
let tmp = tempfile::tempdir().unwrap();
for (lock, install) in [("yarn.lock", "yarn install"), ("pnpm-lock.yaml", "pnpm install")] {
let project = tree(
tmp.path(),
&format!("project-{lock}"),
&[(lock, "old"), ("node_modules/leftpad/index.js", "x\n")],
);
let workspace = tree(tmp.path(), &format!("workspace-{lock}"), &[(lock, "new")]);
assert_eq!(
drift_lines(&project, &workspace),
vec![format!(
"Warning: node_modules was cloned from a {lock} that differs from this workspace's; run '{install}' in the workspace."
)]
);
}
}
const POOL: &[&str] = &[
"build",
"dist",
"tmp",
"public/assets",
"public/packs",
"public/packs-test",
"public/packs-old",
"public/assets-old",
"public",
"public/uploads",
"src",
"app",
"node_modules",
"vendor",
"deps",
".venv",
"target",
];
proptest::proptest! {
#[test]
fn a_path_is_dropped_exactly_when_it_sits_inside_a_stale_output_dir(
i in 0..POOL.len(),
deeper in proptest::collection::vec("[a-z0-9-]{1,10}", 0..3),
) {
let prefix = POOL[i];
let mut path = prefix.to_string();
for part in &deeper {
path.push('/');
path.push_str(part);
}
proptest::prop_assert_eq!(is_stale_output(&path), STALE_OUTPUT.contains(&prefix), "path: {}", path);
let all = vec![path.clone(), "node_modules/leftpad/index.js".to_string()];
let expected: Vec<String> = all.iter().filter(|f| !is_stale_output(f)).cloned().collect();
proptest::prop_assert_eq!(without_stale_output(&all), expected);
}
#[test]
fn drift_fires_exactly_when_the_lockfiles_differ_or_the_workspace_lacks_one(
hash in "[a-f0-9]{64}",
other in "[a-f0-9]{64}",
same in proptest::bool::ANY,
locked in proptest::bool::ANY,
workspace_locked in proptest::bool::ANY,
) {
let installed = locked.then_some(hash.as_str());
let workspace = workspace_locked.then_some(if same { hash.as_str() } else { other.as_str() });
let drift = lock_drift(installed, workspace);
match (installed, workspace) {
(None, _) => proptest::prop_assert!(drift.is_none()),
(Some(_), None) => proptest::prop_assert!(matches!(drift, Some(Drift::Missing))),
(Some(a), Some(b)) if a == b => proptest::prop_assert!(drift.is_none()),
(Some(_), Some(_)) => proptest::prop_assert!(matches!(drift, Some(Drift::Changed))),
}
}
}
}