Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 108 additions & 3 deletions crates/turborepo-lockfiles/src/closure_dp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<L: Lockfile>(
lockfile: &L,
workspaces: HashMap<String, BTreeMap<String, String>>,
) {
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")
};
Expand Down Expand Up @@ -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<String, BTreeMap<String, String>> = [
(
"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<String, BTreeMap<String, String>> = [
(
"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
Expand Down
34 changes: 34 additions & 0 deletions crates/turborepo-lockfiles/src/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ impl Lockfile for NpmLockfile {
Ok(Some(std::borrow::Cow::Owned(deps)))
}

fn transitive_edge_resolver(&self) -> Option<Box<dyn crate::TransitiveEdgeResolver + '_>> {
Some(Box::new(NpmEdgeResolver { lockfile: self }))
}

fn subgraph(
&self,
workspace_packages: &[String],
Expand Down Expand Up @@ -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<crate::TransitiveEdgeResolution, crate::Error> {
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<Self, Error> {
let lockfile: NpmLockfile = serde_json::from_slice(content)?;
Expand Down
25 changes: 25 additions & 0 deletions crates/turborepo-lockfiles/src/yarn1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ impl Lockfile for Yarn1Lockfile {
})
}

fn transitive_edge_resolver(&self) -> Option<Box<dyn crate::TransitiveEdgeResolver + '_>> {
Some(Box::new(Yarn1EdgeResolver { lockfile: self }))
}

fn subgraph(
&self,
workspace_packages: &[String],
Expand Down Expand Up @@ -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<crate::TransitiveEdgeResolution, crate::Error> {
Ok(crate::TransitiveEdgeResolution::Global(
self.lockfile.resolve_package("", name, version)?,
))
}
}

pub fn yarn_subgraph(contents: &[u8], packages: &[String]) -> Result<Vec<u8>, crate::Error> {
let lockfile = Yarn1Lockfile::from_bytes(contents)?;
let pruned_lockfile = lockfile.subgraph(&[], packages)?;
Expand Down
Loading