diff --git a/crates/turborepo-lockfiles/src/closure_dp.rs b/crates/turborepo-lockfiles/src/closure_dp.rs index 4f81ba6c36872..37467c4111de0 100644 --- a/crates/turborepo-lockfiles/src/closure_dp.rs +++ b/crates/turborepo-lockfiles/src/closure_dp.rs @@ -379,13 +379,13 @@ mod tests { /// The DP must agree with the legacy per-workspace walk. The legacy /// walk is invoked directly (single-workspace entry point never uses /// the DP), making it an independent oracle. - fn assert_dp_matches_legacy( - lockfile: &PnpmLockfile, + fn assert_dp_matches_legacy( + lockfile: &L, workspaces: HashMap>, ) { let via_dp = { let resolver = crate::Lockfile::transitive_edge_resolver(lockfile) - .expect("pnpm supports edge resolution"); + .expect("lockfile supports edge resolution"); all_transitive_closures_dp(lockfile, resolver.as_ref(), &workspaces, false) .expect("dp closure") }; @@ -433,6 +433,111 @@ mod tests { assert_dp_matches_legacy(&lockfile, workspaces_of(&lockfile)); } + #[test] + fn test_npm_dp_matches_legacy_with_workspace_nesting() { + // `apps/web` has a workspace-nested foo@2.0.0 while everything else + // hoists foo@1.0.0. Direct deps are resolved per workspace (step 1), + // and every transitive edge references an exact lockfile key, so the + // DP must reproduce the legacy result including the nested variation + // and the deeply nested `nested-only` package. + let lockfile_json = serde_json::json!({ + "name": "test", + "lockfileVersion": 3, + "packages": { + "": { "name": "test" }, + "apps/web": { + "name": "web", + "dependencies": { "foo": "^2.0.0", "shared": "^1.0.0" } + }, + "apps/docs": { + "name": "docs", + "dependencies": { "foo": "^1.0.0", "shared": "^1.0.0" } + }, + "node_modules/foo": { + "version": "1.0.0", + "dependencies": { "shared": "^1.0.0" } + }, + "apps/web/node_modules/foo": { + "version": "2.0.0", + "dependencies": { "shared": "^1.0.0", "nested-only": "^1.0.0" } + }, + "apps/web/node_modules/foo/node_modules/nested-only": { + "version": "1.0.0" + }, + "node_modules/shared": { + "version": "1.0.0", + "dependencies": { "chain-a": "*" } + }, + "node_modules/chain-a": { + "version": "1.0.0", + "dependencies": { "chain-b": "*" } + }, + "node_modules/chain-b": { "version": "1.0.0" } + } + }); + let lockfile = + crate::NpmLockfile::load(lockfile_json.to_string().as_bytes()).expect("parse"); + + let workspaces: HashMap> = [ + ( + "apps/web".to_string(), + BTreeMap::from([ + ("foo".to_string(), "^2.0.0".to_string()), + ("shared".to_string(), "^1.0.0".to_string()), + ]), + ), + ( + "apps/docs".to_string(), + BTreeMap::from([ + ("foo".to_string(), "^1.0.0".to_string()), + ("shared".to_string(), "^1.0.0".to_string()), + ]), + ), + ] + .into_iter() + .collect(); + + assert_dp_matches_legacy(&lockfile, workspaces); + } + + #[test] + fn test_yarn1_dp_matches_legacy() { + let yarn_lock = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +bar@^2.0.0: + version "2.1.0" + +foo@^1.0.0: + version "1.0.0" + dependencies: + bar "^2.0.0" + +lodash@^4.0.0, lodash@^4.17.0: + version "4.17.21" +"#; + let lockfile = crate::Yarn1Lockfile::from_bytes(yarn_lock.as_bytes()).expect("parse"); + + let workspaces: HashMap> = [ + ( + "apps/web".to_string(), + BTreeMap::from([ + ("foo".to_string(), "^1.0.0".to_string()), + ("lodash".to_string(), "^4.17.0".to_string()), + ]), + ), + ( + "apps/docs".to_string(), + BTreeMap::from([("lodash".to_string(), "^4.0.0".to_string())]), + ), + ] + .into_iter() + .collect(); + + assert_dp_matches_legacy(&lockfile, workspaces); + } + #[test] fn test_dp_falls_back_on_divergent_edge() { // Workspace `.` pins `shadowed` with an exact specifier equal to diff --git a/crates/turborepo-lockfiles/src/npm.rs b/crates/turborepo-lockfiles/src/npm.rs index 432612c11a92a..92f0407fcfedf 100644 --- a/crates/turborepo-lockfiles/src/npm.rs +++ b/crates/turborepo-lockfiles/src/npm.rs @@ -169,6 +169,10 @@ impl Lockfile for NpmLockfile { Ok(Some(std::borrow::Cow::Owned(deps))) } + fn transitive_edge_resolver(&self) -> Option> { + Some(Box::new(NpmEdgeResolver { lockfile: self })) + } + fn subgraph( &self, workspace_packages: &[String], @@ -244,6 +248,36 @@ impl Lockfile for NpmLockfile { } } +/// Proves per-edge workspace independence for the shared closure DP. +/// +/// npm transitive edges come from `all_dependencies`, which emits fully +/// resolved lockfile keys (`find_dep_in_lockfile` only returns keys present +/// in the packages map). `resolve_package`'s first candidate matches such a +/// key directly without consulting the workspace, so every workspace +/// resolves the edge identically. A name that is not a lockfile key would +/// fall through to the workspace-scoped candidates, so report it sensitive +/// (defensive; unreachable via `all_dependencies` output). +struct NpmEdgeResolver<'a> { + lockfile: &'a NpmLockfile, +} + +impl crate::TransitiveEdgeResolver for NpmEdgeResolver<'_> { + fn resolve_edge( + &self, + name: &str, + _version: &str, + ) -> Result { + Ok(match self.lockfile.packages.get(name) { + // Mirrors the `name` candidate in `resolve_package`. + Some(pkg) => crate::TransitiveEdgeResolution::Global(Some(Package { + key: name.to_string(), + version: pkg.version.clone().unwrap_or_default(), + })), + None => crate::TransitiveEdgeResolution::WorkspaceSensitive, + }) + } +} + impl NpmLockfile { pub fn load(content: &[u8]) -> Result { let lockfile: NpmLockfile = serde_json::from_slice(content)?; diff --git a/crates/turborepo-lockfiles/src/yarn1/mod.rs b/crates/turborepo-lockfiles/src/yarn1/mod.rs index 177968d2f829c..f2e4ebf8bdbee 100644 --- a/crates/turborepo-lockfiles/src/yarn1/mod.rs +++ b/crates/turborepo-lockfiles/src/yarn1/mod.rs @@ -102,6 +102,10 @@ impl Lockfile for Yarn1Lockfile { }) } + fn transitive_edge_resolver(&self) -> Option> { + Some(Box::new(Yarn1EdgeResolver { lockfile: self })) + } + fn subgraph( &self, workspace_packages: &[String], @@ -167,6 +171,27 @@ impl Lockfile for Yarn1Lockfile { } } +/// Proves per-edge workspace independence for the shared closure DP. +/// +/// yarn1 resolution never consults the workspace: `resolve_package` ignores +/// its workspace argument entirely and resolves purely from the lockfile's +/// `name@specifier` keys, so every edge is globally uniform by construction. +struct Yarn1EdgeResolver<'a> { + lockfile: &'a Yarn1Lockfile, +} + +impl crate::TransitiveEdgeResolver for Yarn1EdgeResolver<'_> { + fn resolve_edge( + &self, + name: &str, + version: &str, + ) -> Result { + Ok(crate::TransitiveEdgeResolution::Global( + self.lockfile.resolve_package("", name, version)?, + )) + } +} + pub fn yarn_subgraph(contents: &[u8], packages: &[String]) -> Result, crate::Error> { let lockfile = Yarn1Lockfile::from_bytes(contents)?; let pruned_lockfile = lockfile.subgraph(&[], packages)?;