Skip to content

Commit 2efe223

Browse files
Merge pull request #287 from snyk/fix/OSM-2671/npm-nested-bundled-deps
fix: [OSM-2671] resolve bundled deps with non-hoisted bundle owner
2 parents 153cf60 + f9bef4b commit 2efe223

File tree

10 files changed

+767
-13
lines changed

10 files changed

+767
-13
lines changed

lib/dep-graph-builders/npm-lock-v2/index.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -399,17 +399,34 @@ export const getChildNodeKey = (
399399
}
400400
return candidateKeys[0];
401401
}
402-
// If we are bundled we assume we are scoped by the bundle root at least
403-
// otherwise the ancestry root is the root ignoring the true root
402+
403+
// Always use the full ancestry (excluding the true root)
404+
// The lockfile paths reflect the actual file system structure, which may or may not
405+
// include all ancestors depending on hoisting
404406
const isBundled = ancestry[ancestry.length - 1].inBundle;
405-
const rootOperatingIdx = isBundled
406-
? ancestry.findIndex((el) => el.inBundle === true) - 1
407-
: 1;
408407
const ancestryFromRootOperatingIdx = [
409-
...ancestry.slice(rootOperatingIdx),
408+
...ancestry.slice(1), // Always start from index 1 (skip only the true root)
410409
{ id: `${name}@${version}`, name, version },
411410
];
412411

412+
// Find the bundle owner for the bundle root check below
413+
let bundleOwnerName: string | undefined;
414+
if (isBundled) {
415+
const firstBundledIdx = ancestry.findIndex((el) => el.inBundle === true);
416+
if (firstBundledIdx > 0) {
417+
const potentialBundleOwner = ancestry[firstBundledIdx - 1];
418+
if (potentialBundleOwner && potentialBundleOwner.key) {
419+
const ownerPkg = pkgs[potentialBundleOwner.key];
420+
if (
421+
ownerPkg &&
422+
(ownerPkg.bundledDependencies || ownerPkg.bundleDependencies)
423+
) {
424+
bundleOwnerName = potentialBundleOwner.name;
425+
}
426+
}
427+
}
428+
}
429+
413430
// We filter on a number of cases
414431
let filteredCandidates = candidateKeys.filter((candidate) => {
415432
// This is splitting the candidate that looks like
@@ -441,13 +458,13 @@ export const getChildNodeKey = (
441458
return false;
442459
}
443460

444-
// If we are bundled we assume the bundle root is the first value
445-
// in the candidates scoping
446-
if (isBundled && ancestryFromRootOperatingIdx[0].id !== ROOT_NODE_ID) {
447-
const doesBundledPkgShareBundleRoot =
448-
candidateAncestry[0] === ancestryFromRootOperatingIdx[0].name;
461+
// If we are bundled, check if the candidate shares the same bundle owner
462+
// The bundle owner should appear in the candidate path
463+
if (isBundled && bundleOwnerName) {
464+
const doesCandidateIncludeBundleOwner =
465+
candidateAncestry.includes(bundleOwnerName);
449466

450-
if (doesBundledPkgShareBundleRoot === false) {
467+
if (!doesCandidateIncludeBundleOwner) {
451468
return false;
452469
}
453470
}
@@ -504,7 +521,18 @@ export const getChildNodeKey = (
504521
filteredCandidates = possibleFilteredKeys;
505522
}
506523

507-
return undefined;
524+
// If we still have multiple candidates, prefer the one with the most path segments
525+
// (i.e., the deepest one, which is closest to the requesting package)
526+
if (filteredCandidates.length > 1) {
527+
filteredCandidates.sort((a, b) => {
528+
const aDepth = a.split('/node_modules/').length;
529+
const bDepth = b.split('/node_modules/').length;
530+
return bDepth - aDepth; // Prefer deeper paths
531+
});
532+
return filteredCandidates[0];
533+
}
534+
535+
return filteredCandidates.length === 1 ? filteredCandidates[0] : undefined;
508536
};
509537

510538
const checkOverrides = (
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Nested Bundled Dependencies Test Fixture
2+
3+
⚠️ **This is a manually created synthetic test fixture.** The packages and versions are not real npm packages. This fixture was created specifically to test bundled dependency resolution.
4+
5+
🚫 **DO NOT run `npm install` in this directory.** The package.json includes a preinstall script that will error if you attempt to install. The dependencies listed do not exist in the npm registry.
6+
7+
This minimal fixture tests the resolution of bundled dependencies when the bundle owner is not hoisted to the root level.
8+
9+
## Structure
10+
11+
```
12+
test-app (root)
13+
└── @myorg/wrapper-tool (not hoisted)
14+
└── builder-tool (has bundledDependencies)
15+
├── semver (bundled)
16+
│ └── lru-cache (bundled, nested)
17+
│ └── yallist (bundled, deeply nested)
18+
└── chalk (bundled)
19+
├── ansi-styles (bundled, nested)
20+
│ └── color-convert (bundled, deeply nested)
21+
│ └── color-name (bundled, deeply nested)
22+
└── supports-color (bundled, nested)
23+
└── has-flag (bundled, deeply nested)
24+
```
25+
26+
## What It Tests
27+
28+
### 1. Non-Hoisted Bundle Owner
29+
30+
Unlike packages hoisted to `node_modules/`, `builder-tool` is nested under `@myorg/wrapper-tool`. This tests that the algorithm correctly includes the non-bundled parent in the ancestry check.
31+
32+
**Lockfile path**:
33+
34+
```
35+
node_modules/@myorg/wrapper-tool/node_modules/builder-tool/node_modules/semver/node_modules/lru-cache
36+
```
37+
38+
The `@myorg/wrapper-tool` part MUST be included in the ancestry check.
39+
40+
### 2. Nested Bundled Dependencies
41+
42+
Tests that bundled dependencies can have their own dependencies that are also bundled:
43+
44+
- `builder-tool` declares bundled dependencies
45+
- `semver` (bundled) depends on `lru-cache` (also bundled)
46+
- `lru-cache` depends on `yallist` (also bundled)
47+
48+
### 3. Multiple Bundled Dependency Trees
49+
50+
The fixture includes two bundled dependency subtrees (`semver` and `chalk`) to verify the algorithm handles multiple branches correctly.
51+
52+
### 4. Deep Nesting
53+
54+
With 4-5 levels of nesting, this tests that the algorithm can handle deeply nested bundled structures without performance issues.
55+
56+
## Key Algorithm Challenge
57+
58+
When resolving `lru-cache` from within `semver`, the algorithm must:
59+
60+
1. **Include full ancestry**: `[@myorg/wrapper-tool, builder-tool, semver, lru-cache]`
61+
2. **Validate bundle owner**: Verify `builder-tool` is in the candidate path
62+
3. **Handle lockfile path**: Match against `node_modules/@myorg/wrapper-tool/node_modules/builder-tool/node_modules/semver/node_modules/lru-cache`
63+
4. **Prefer correct candidate**: Choose the bundled one over any hoisted versions
64+
65+
This tests the exact scenario that was failing before the fix.
66+
67+
## Comparison to Other Fixtures
68+
69+
- **goof**: Bundle owner hoisted to root (`node_modules/nyc/...`)
70+
- **nested-bundled-deps**: Bundle owner NOT hoisted (`node_modules/@myorg/wrapper-tool/node_modules/builder-tool/...`)
71+
72+
Both patterns must work correctly to handle real-world package structures.

0 commit comments

Comments
 (0)