From 1e0120614fa4441f4ef3c28c9f783314be2a7a1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:14:48 +0000 Subject: [PATCH 1/2] fix(prune): relocate stranded transitive deps when promoting npm workspace-nested packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `turbo prune` promotes a workspace-nested package to the hoisted position (because the previously-hoisted version was only needed by a now-pruned workspace), its transitive dependencies that were resolved through workspace-nested siblings became unreachable. Node resolution only walks upward, so a package moved to `node_modules/send` could no longer reach `apps/app-a/node_modules/mime`, producing a structurally invalid pruned lockfile that crashes at runtime with "Cannot find module". After promoting a package, walk its dependency closure (using the original lockfile as the source of truth for which version each dep must resolve to) and relocate any dependency whose resolution changed — hoisting to the root slot when it's free, otherwise nesting it directly under the promoted package. Adds an npm regression fixture (`issue-13109`) plus an opt-in `validateResolution` meta flag that runs `npm ls --all` during lockfile validation, since `npm ci --dry-run` mirrors the lockfile tree verbatim and does not detect stranded transitive deps. Fixes #13109 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RDk2rsdyLGiRRW7VMZNr9h --- crates/turborepo-lockfiles/src/npm.rs | 297 ++++++++++++++ lockfile-tests/check-lockfiles.ts | 9 +- .../issue-13109/apps/app-1/package.json | 6 + .../issue-13109/apps/app-2/package.json | 6 + lockfile-tests/fixtures/issue-13109/meta.json | 9 + .../fixtures/issue-13109/package-lock.json | 361 ++++++++++++++++++ .../fixtures/issue-13109/package.json | 11 + .../fixtures/issue-13109/turbo.json | 4 + lockfile-tests/runners/local.ts | 20 +- lockfile-tests/types.ts | 6 + 10 files changed, 724 insertions(+), 5 deletions(-) create mode 100644 lockfile-tests/fixtures/issue-13109/apps/app-1/package.json create mode 100644 lockfile-tests/fixtures/issue-13109/apps/app-2/package.json create mode 100644 lockfile-tests/fixtures/issue-13109/meta.json create mode 100644 lockfile-tests/fixtures/issue-13109/package-lock.json create mode 100644 lockfile-tests/fixtures/issue-13109/package.json create mode 100644 lockfile-tests/fixtures/issue-13109/turbo.json diff --git a/crates/turborepo-lockfiles/src/npm.rs b/crates/turborepo-lockfiles/src/npm.rs index b06abb11614d2..3144474ea13fe 100644 --- a/crates/turborepo-lockfiles/src/npm.rs +++ b/crates/turborepo-lockfiles/src/npm.rs @@ -368,9 +368,153 @@ impl NpmLockfile { pruned.insert(new_key, pkg); } } + + // Promoting the package changed its position in the tree, so any of + // its transitive deps that were resolved through workspace-nested + // siblings (e.g. `apps/app-a/node_modules/mime`) are no longer + // reachable from the new hoisted position. Walk the promoted + // package's dependency closure and relocate any stranded versions. + let mut visited = std::collections::HashSet::new(); + Self::relocate_stranded_closure( + pruned, + original_packages, + &nested_key, + &hoisted_key, + &mut visited, + ); + } + } + + /// After a workspace-nested package has been promoted to the hoisted + /// position, its transitive dependencies that previously resolved through + /// workspace-nested siblings can become unreachable: Node's resolution only + /// walks upward, so a package now at `node_modules/send` cannot reach + /// `apps/app-a/node_modules/mime`. + /// + /// This walks the promoted package's dependency closure (using the original + /// lockfile as the source of truth for which version each dependency must + /// resolve to) and, for every dependency that no longer resolves to the + /// correct version, copies that version from the original lockfile to a + /// position the promoted package can reach — hoisted to the root slot when + /// it's free, otherwise nested directly under the promoted package. + /// See https://github.com/vercel/turborepo/issues/13109 + fn relocate_stranded_closure( + pruned: &mut HashMap, + original: &HashMap, + original_key: &str, + new_key: &str, + visited: &mut std::collections::HashSet, + ) { + if !visited.insert(new_key.to_string()) { + return; + } + + // The authoritative dependency list comes from the original lockfile. + let Some(pkg) = original.get(original_key) else { + return; + }; + let dep_names: Vec = pkg.dep_keys().cloned().collect(); + + for dep in dep_names { + // The version this dependency resolved to in the original tree. + let Some((orig_dep_key, Some(orig_version))) = + Self::resolve_in_map(original, original_key, &dep) + else { + // No concrete resolution (missing/optional dep or a workspace + // link without a version) — nothing to relocate. + continue; + }; + + // What it currently resolves to from the new (pruned) position. + if let Some((pruned_dep_key, Some(pruned_version))) = + Self::resolve_in_map(pruned, new_key, &dep) + && pruned_version == orig_version + { + // Already reachable and correct — descend to validate the + // dependency's own closure. + Self::relocate_stranded_closure( + pruned, + original, + &orig_dep_key, + &pruned_dep_key, + visited, + ); + continue; + } + + // Missing or wrong version. Place the correct version where the + // promoted package can reach it: hoist to the root slot if free, + // otherwise nest directly under the promoted package. + let hoisted = format!("node_modules/{dep}"); + let placement = if pruned.contains_key(&hoisted) { + format!("{new_key}/node_modules/{dep}") + } else { + hoisted + }; + + // Copy the dependency and its nested subtree from the original + // lockfile to the new placement. + if let Some(entry) = original.get(&orig_dep_key) { + pruned.insert(placement.clone(), entry.clone()); + } + let orig_sub_prefix = format!("{orig_dep_key}/node_modules/"); + let new_sub_prefix = format!("{placement}/node_modules/"); + for (k, v) in original.iter() { + if let Some(rest) = k.strip_prefix(&orig_sub_prefix) { + pruned.insert(format!("{new_sub_prefix}{rest}"), v.clone()); + } + } + + // Remove the now-unreachable copy left at the original nested + // location (and its subtree). + if orig_dep_key != placement { + pruned.remove(&orig_dep_key); + let strand_prefix = format!("{orig_dep_key}/node_modules/"); + let strays: Vec = pruned + .keys() + .filter(|k| k.starts_with(&strand_prefix)) + .cloned() + .collect(); + for s in strays { + pruned.remove(&s); + } + } + + // Descend into the relocated dependency. + Self::relocate_stranded_closure(pruned, original, &orig_dep_key, &placement, visited); } } + /// Resolve a dependency name within an arbitrary packages map by walking up + /// the node_modules hierarchy from `key`, mirroring Node's resolution. + /// Returns the resolved key and its version (if the entry has one). + fn resolve_in_map( + packages: &HashMap, + key: &str, + dep: &str, + ) -> Option<(String, Option)> { + // First candidate: nested directly under the current package. + let nested = format!("{key}/node_modules/{dep}"); + if let Some(entry) = packages.get(&nested) { + return Some((nested, entry.version.clone())); + } + + // Walk up the node_modules hierarchy. + let mut curr = Some(key); + while let Some(k) = curr { + let parent = Self::npm_path_parent(k); + let candidate = match parent { + Some(p) => format!("{p}node_modules/{dep}"), + None => format!("node_modules/{dep}"), + }; + if let Some(entry) = packages.get(&candidate) { + return Some((candidate, entry.version.clone())); + } + curr = parent; + } + None + } + /// Resolve a dependency name by walking up the node_modules hierarchy, /// checking each candidate key in the packages map. Uses `buf` to avoid /// allocating a new String for each candidate. @@ -826,6 +970,159 @@ mod test { ); } + // Regression test for https://github.com/vercel/turborepo/issues/13109 + // + // When the full monorepo has two incompatible versions of a shared + // transitive dep — one hoisted to root, the other workspace-nested — and + // the workspace-nested package gets promoted to root during pruning, its + // workspace-nested transitive deps (siblings under the workspace's + // node_modules, not under the package itself) must follow it so they remain + // reachable. + // + // Here app-1 forces send@1.2.1 + mime@3.0.0 (root devDep) to root, while + // app-2 needs send@0.17.2 which requires mime@1.6.0. mime@1.6.0 lives at + // `apps/app-2/node_modules/mime`. Pruning to app-2 drops send@1.2.1 and + // promotes send@0.17.2 to `node_modules/send`; mime@1.6.0 must move to a + // position where the promoted send can resolve it (here nested under send, + // since `node_modules/mime` is still occupied by the root devDep 3.0.0). + #[test] + fn test_subgraph_relocates_stranded_promoted_deps() { + let json = r#"{ + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monorepo", + "workspaces": ["apps/*"], + "devDependencies": { "mime": "^3.0.0" } + }, + "node_modules/app-1": { + "resolved": "apps/app-1", + "link": true + }, + "node_modules/app-2": { + "resolved": "apps/app-2", + "link": true + }, + "node_modules/destroy": { + "version": "1.0.4" + }, + "node_modules/mime": { + "version": "3.0.0" + }, + "node_modules/send": { + "version": "1.2.1", + "dependencies": { "encodeurl": "^2.0.0" } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0" + }, + "node_modules/encodeurl": { + "version": "1.0.2" + }, + "apps/app-1": { + "version": "1.0.0", + "dependencies": { "send": "^1.0.0" } + }, + "apps/app-2": { + "version": "1.0.0", + "dependencies": { "send": "^0.17.0" } + }, + "apps/app-2/node_modules/mime": { + "version": "1.6.0" + }, + "apps/app-2/node_modules/send": { + "version": "0.17.2", + "dependencies": { + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "mime": "1.6.0" + } + } + } + }"#; + + let lockfile = NpmLockfile::load(json.as_bytes()).unwrap(); + + // Pruning to app-2: the root workspace keeps its mime@3.0.0 devDep and + // app-2 pulls in send@0.17.2's closure (mime@1.6.0 nested, destroy and + // encodeurl@1.0.2 hoisted). + let workspace_packages = vec!["apps/app-2".to_string()]; + let packages = vec![ + "node_modules/destroy".to_string(), + "node_modules/mime".to_string(), + "node_modules/encodeurl".to_string(), + "apps/app-2/node_modules/mime".to_string(), + "apps/app-2/node_modules/send".to_string(), + ]; + + let pruned = lockfile.subgraph(&workspace_packages, &packages).unwrap(); + let encoded = pruned.encode().unwrap(); + let reparsed: NpmLockfile = NpmLockfile::load(&encoded).unwrap(); + + // send@0.17.2 was promoted to the hoisted slot (the root version 1.2.1 + // was only needed by the now-pruned app-1). + assert_eq!( + reparsed + .packages + .get("node_modules/send") + .and_then(|p| p.version.as_deref()), + Some("0.17.2"), + "send@0.17.2 should be promoted to node_modules/send" + ); + + // mime@1.6.0 must be reachable from the promoted send. node_modules/mime + // is still occupied by the root devDep (3.0.0), so it must be nested + // directly under send rather than stranded under apps/app-2. + assert_eq!( + reparsed + .packages + .get("node_modules/send/node_modules/mime") + .and_then(|p| p.version.as_deref()), + Some("1.6.0"), + "send@0.17.2's mime@1.6.0 must be relocated where send can resolve it" + ); + assert!( + !reparsed + .packages + .contains_key("apps/app-2/node_modules/mime"), + "the stranded apps/app-2/node_modules/mime copy must be removed" + ); + + // The root devDependency mime@3.0.0 must be preserved untouched. + assert_eq!( + reparsed + .packages + .get("node_modules/mime") + .and_then(|p| p.version.as_deref()), + Some("3.0.0"), + "root devDep mime@3.0.0 should be preserved" + ); + + // Hoisted deps that already resolve correctly must stay put. + assert!( + reparsed.packages.contains_key("node_modules/destroy"), + "hoisted destroy@1.0.4 should be reachable from send" + ); + + // No part of the old root send@1.2.1 subtree should linger. + assert!( + !reparsed + .packages + .contains_key("node_modules/send/node_modules/encodeurl"), + "old send@1.2.1's nested encodeurl@2.0.0 should be gone" + ); + // send@0.17.2 wants encodeurl@~1.0.2, which is the hoisted 1.0.2. + assert_eq!( + reparsed + .packages + .get("node_modules/encodeurl") + .and_then(|p| p.version.as_deref()), + Some("1.0.2"), + "hoisted encodeurl@1.0.2 (what send@0.17.2 needs) should remain" + ); + } + #[test] fn test_turbo_version_rejects_non_semver() { // Malicious version strings that could be used for RCE via npx should be diff --git a/lockfile-tests/check-lockfiles.ts b/lockfile-tests/check-lockfiles.ts index be7cfa82ac4c3..f607e83493b7d 100644 --- a/lockfile-tests/check-lockfiles.ts +++ b/lockfile-tests/check-lockfiles.ts @@ -38,6 +38,12 @@ interface FixtureMeta { pruneTargets?: string[]; /** Run `turbo prune --docker` and validate `out/json`. */ docker?: boolean; + /** + * Additionally assert that every package in the pruned lockfile can resolve + * its declared dependencies (via `npm ls`). Catches stranded transitive deps + * that `npm ci --dry-run` silently accepts. npm fixtures only. + */ + validateResolution?: boolean; /** Workspace names where pruning or lockfile validation is known to fail. */ expectedFailures?: string[]; } @@ -211,7 +217,8 @@ function buildTestCases( filepath: fixture.dir, packageManager: fixture.meta.packageManager, lockfileName: fixture.meta.lockfileName, - packageManagerVersion: fixture.meta.packageManagerVersion + packageManagerVersion: fixture.meta.packageManagerVersion, + validateResolution: fixture.meta.validateResolution }, targetWorkspace: { name: target }, label, diff --git a/lockfile-tests/fixtures/issue-13109/apps/app-1/package.json b/lockfile-tests/fixtures/issue-13109/apps/app-1/package.json new file mode 100644 index 0000000000000..fbd22b46803bb --- /dev/null +++ b/lockfile-tests/fixtures/issue-13109/apps/app-1/package.json @@ -0,0 +1,6 @@ +{ + "name": "app-1", + "dependencies": { + "send": "^1.0.0" + } +} diff --git a/lockfile-tests/fixtures/issue-13109/apps/app-2/package.json b/lockfile-tests/fixtures/issue-13109/apps/app-2/package.json new file mode 100644 index 0000000000000..0646874fb2f0a --- /dev/null +++ b/lockfile-tests/fixtures/issue-13109/apps/app-2/package.json @@ -0,0 +1,6 @@ +{ + "name": "app-2", + "dependencies": { + "send": "^0.17.0" + } +} diff --git a/lockfile-tests/fixtures/issue-13109/meta.json b/lockfile-tests/fixtures/issue-13109/meta.json new file mode 100644 index 0000000000000..ffef2413c6aa6 --- /dev/null +++ b/lockfile-tests/fixtures/issue-13109/meta.json @@ -0,0 +1,9 @@ +{ + "packageManager": "npm", + "packageManagerVersion": "npm@11.7.0", + "lockfileName": "package-lock.json", + "pruneTargets": ["app-2"], + "docker": true, + "validateResolution": true, + "frozenInstallCommand": ["npm", "ci"] +} diff --git a/lockfile-tests/fixtures/issue-13109/package-lock.json b/lockfile-tests/fixtures/issue-13109/package-lock.json new file mode 100644 index 0000000000000..a2aa4f4b21fb8 --- /dev/null +++ b/lockfile-tests/fixtures/issue-13109/package-lock.json @@ -0,0 +1,361 @@ +{ + "name": "turbo-prune-issue-13109", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "turbo-prune-issue-13109", + "workspaces": [ + "apps/*" + ], + "devDependencies": { + "mime": "^3.0.0" + } + }, + "apps/app-1": { + "dependencies": { + "send": "^1.0.0" + } + }, + "apps/app-2": { + "dependencies": { + "send": "^0.17.0" + } + }, + "apps/app-2/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "apps/app-2/node_modules/send": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", + "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "1.8.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/app-1": { + "resolved": "apps/app-1", + "link": true + }, + "node_modules/app-2": { + "resolved": "apps/app-2", + "link": true + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + } + } +} diff --git a/lockfile-tests/fixtures/issue-13109/package.json b/lockfile-tests/fixtures/issue-13109/package.json new file mode 100644 index 0000000000000..0e5269aa7b84e --- /dev/null +++ b/lockfile-tests/fixtures/issue-13109/package.json @@ -0,0 +1,11 @@ +{ + "name": "turbo-prune-issue-13109", + "private": true, + "workspaces": [ + "apps/*" + ], + "devDependencies": { + "mime": "^3.0.0" + }, + "packageManager": "npm@11.7.0" +} diff --git a/lockfile-tests/fixtures/issue-13109/turbo.json b/lockfile-tests/fixtures/issue-13109/turbo.json new file mode 100644 index 0000000000000..811e2488d3ca0 --- /dev/null +++ b/lockfile-tests/fixtures/issue-13109/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": {} +} diff --git a/lockfile-tests/runners/local.ts b/lockfile-tests/runners/local.ts index 439bdedb4d099..245427320ef1d 100644 --- a/lockfile-tests/runners/local.ts +++ b/lockfile-tests/runners/local.ts @@ -79,7 +79,8 @@ function copyDirSync(src: string, dest: string): void { function lockfileValidationCommand( pm: PackageManagerType, - cwd: string + cwd: string, + validateResolution = false ): LockfileValidationCommand { switch (pm) { case "pnpm": { @@ -103,8 +104,17 @@ function lockfileValidationCommand( }; } case "npm": { + // `npm ci --dry-run` proves the lockfile is in sync with the + // package.json files, but it mirrors the lockfile's tree verbatim and + // won't notice a transitive dep that was pruned into an unreachable + // position. When requested, `npm ls --all` additionally walks the tree + // and fails (ELSPROBLEMS) if any package can't resolve a declared dep. + // See https://github.com/vercel/turborepo/issues/13109 + const ci = "npm ci --dry-run --ignore-scripts --no-audit --no-fund"; return { - command: "npm ci --dry-run --ignore-scripts --no-audit --no-fund" + command: validateResolution + ? `${ci} && npm ls --package-lock-only --all` + : ci }; } case "yarn": { @@ -271,7 +281,8 @@ export class LocalRunner { const validation = lockfileValidationCommand( fixture.packageManager, - tmpDir + tmpDir, + fixture.validateResolution ); console.log( `[${fixture.filename}] Validating fixture (${validation.command})...` @@ -475,7 +486,8 @@ export class LocalRunner { const outLabel = tc.docker ? "out/json" : "out"; const validation = lockfileValidationCommand( fixture.packageManager, - outDir + outDir, + fixture.validateResolution ); log( `[${label}] ${validation.command} (lockfile validation in ${outLabel}/)` diff --git a/lockfile-tests/types.ts b/lockfile-tests/types.ts index c2708d2c63219..dbf3601805cab 100644 --- a/lockfile-tests/types.ts +++ b/lockfile-tests/types.ts @@ -7,6 +7,12 @@ export interface TestCase { packageManager: PackageManagerType; lockfileName: string; packageManagerVersion: string; + /** + * Additionally assert that every package in the (pruned) lockfile can + * resolve its declared dependencies to the correct version. Catches + * "stranded" transitive deps that `npm ci --dry-run` silently accepts. + */ + validateResolution?: boolean; }; targetWorkspace: { name: string; From ee0e59be15b57942466a5aa629bcb56ce7698956 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Thu, 18 Jun 2026 06:18:57 -0600 Subject: [PATCH 2/2] fix: Preserve shared nested npm dependencies --- crates/turborepo-lockfiles/src/npm.rs | 62 +++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/turborepo-lockfiles/src/npm.rs b/crates/turborepo-lockfiles/src/npm.rs index 3144474ea13fe..2e0826d807b8e 100644 --- a/crates/turborepo-lockfiles/src/npm.rs +++ b/crates/turborepo-lockfiles/src/npm.rs @@ -465,9 +465,12 @@ impl NpmLockfile { } } - // Remove the now-unreachable copy left at the original nested - // location (and its subtree). - if orig_dep_key != placement { + // Remove the original nested location only if no remaining package + // still resolves to it. Sibling consumers in the same workspace can + // share that nested copy even after this package is promoted. + if orig_dep_key != placement + && !Self::is_resolved_by_any_consumer(pruned, &orig_dep_key) + { pruned.remove(&orig_dep_key); let strand_prefix = format!("{orig_dep_key}/node_modules/"); let strays: Vec = pruned @@ -485,6 +488,20 @@ impl NpmLockfile { } } + fn is_resolved_by_any_consumer(packages: &HashMap, dep_key: &str) -> bool { + let Some(dep_name) = dep_key.rsplit_once("node_modules/").map(|(_, name)| name) else { + return false; + }; + + packages.iter().any(|(consumer_key, pkg)| { + pkg.dep_keys().any(|dep| { + dep == dep_name + && Self::resolve_in_map(packages, consumer_key, dep) + .is_some_and(|(resolved_key, _)| resolved_key == dep_key) + }) + }) + } + /// Resolve a dependency name within an arbitrary packages map by walking up /// the node_modules hierarchy from `key`, mirroring Node's resolution. /// Returns the resolved key and its version (if the entry has one). @@ -985,6 +1002,8 @@ mod test { // promotes send@0.17.2 to `node_modules/send`; mime@1.6.0 must move to a // position where the promoted send can resolve it (here nested under send, // since `node_modules/mime` is still occupied by the root devDep 3.0.0). + // The original nested copy must remain when another app-2 dependency still + // resolves to it. #[test] fn test_subgraph_relocates_stranded_promoted_deps() { let json = r#"{ @@ -994,7 +1013,10 @@ mod test { "": { "name": "monorepo", "workspaces": ["apps/*"], - "devDependencies": { "mime": "^3.0.0" } + "devDependencies": { + "legacy": "^2.0.0", + "mime": "^3.0.0" + } }, "node_modules/app-1": { "resolved": "apps/app-1", @@ -1010,6 +1032,9 @@ mod test { "node_modules/mime": { "version": "3.0.0" }, + "node_modules/legacy": { + "version": "2.0.0" + }, "node_modules/send": { "version": "1.2.1", "dependencies": { "encodeurl": "^2.0.0" } @@ -1026,7 +1051,14 @@ mod test { }, "apps/app-2": { "version": "1.0.0", - "dependencies": { "send": "^0.17.0" } + "dependencies": { + "legacy": "^1.0.0", + "send": "^0.17.0" + } + }, + "apps/app-2/node_modules/legacy": { + "version": "1.0.0", + "dependencies": { "mime": "1.6.0" } }, "apps/app-2/node_modules/mime": { "version": "1.6.0" @@ -1050,8 +1082,10 @@ mod test { let workspace_packages = vec!["apps/app-2".to_string()]; let packages = vec![ "node_modules/destroy".to_string(), + "node_modules/legacy".to_string(), "node_modules/mime".to_string(), "node_modules/encodeurl".to_string(), + "apps/app-2/node_modules/legacy".to_string(), "apps/app-2/node_modules/mime".to_string(), "apps/app-2/node_modules/send".to_string(), ]; @@ -1082,11 +1116,21 @@ mod test { Some("1.6.0"), "send@0.17.2's mime@1.6.0 must be relocated where send can resolve it" ); - assert!( - !reparsed + assert_eq!( + reparsed + .packages + .get("apps/app-2/node_modules/mime") + .and_then(|p| p.version.as_deref()), + Some("1.6.0"), + "the shared apps/app-2/node_modules/mime copy must remain for sibling consumers" + ); + assert_eq!( + reparsed .packages - .contains_key("apps/app-2/node_modules/mime"), - "the stranded apps/app-2/node_modules/mime copy must be removed" + .get("apps/app-2/node_modules/legacy") + .and_then(|p| p.version.as_deref()), + Some("1.0.0"), + "legacy@1.0.0 should remain nested and resolve apps/app-2/node_modules/mime" ); // The root devDependency mime@3.0.0 must be preserved untouched.