Find the cost of adding an npm package to your app's bundle size teardown.kelinci.dev
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: conform hoisting and resolution

Mary 2b38a199 0c642f64

+1138 -332
+192 -71
src/npm/lib/fetch.test.ts
··· 5 5 import { hoist } from './hoist'; 6 6 import { resolve } from './resolve'; 7 7 8 + // #region fetchPackagesToVolume 9 + 8 10 describe('fetchPackagesToVolume', () => { 9 - it('fetches and extracts a simple package', async () => { 10 - // resolve a tiny package 11 - const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 12 - const hoisted = hoist(result.roots); 13 - const volume = new Volume(); 14 - await fetchPackagesToVolume(hoisted, volume); 11 + describe('basic fetching', () => { 12 + it('fetches and extracts a simple package with dependencies', async () => { 13 + const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 14 + const hoisted = hoist(result.roots); 15 + const volume = new Volume(); 16 + await fetchPackagesToVolume(hoisted, volume); 15 17 16 - // should have files from is-odd 17 - const isOddPackageJson = volume.readFileSync('/node_modules/is-odd/package.json', 'utf8'); 18 - expect(isOddPackageJson).toBeDefined(); 18 + // verify is-odd 19 + const isOddPackageJson = volume.readFileSync('/node_modules/is-odd/package.json', 'utf8'); 20 + const json = JSON.parse(isOddPackageJson as string); 21 + expect(json.name).toBe('is-odd'); 19 22 20 - // verify it's valid JSON 21 - const json = JSON.parse(isOddPackageJson as string); 22 - expect(json.name).toBe('is-odd'); 23 + // verify dependency (is-number) 24 + const isNumberPackageJson = volume.readFileSync( 25 + '/node_modules/is-number/package.json', 26 + 'utf8', 27 + ); 28 + expect(isNumberPackageJson).toBeDefined(); 29 + }); 23 30 24 - // should also have is-number (dependency) 25 - const isNumberPackageJson = volume.readFileSync('/node_modules/is-number/package.json', 'utf8'); 26 - expect(isNumberPackageJson).toBeDefined(); 31 + it('respects concurrency limit', async () => { 32 + const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 33 + const hoisted = hoist(result.roots); 34 + const volume = new Volume(); 35 + 36 + await fetchPackagesToVolume(hoisted, volume, { concurrency: 1 }); 37 + expect(Object.keys(volume.toJSON()).length).toBeGreaterThan(0); 38 + }); 27 39 }); 28 40 29 - it('respects concurrency limit', async () => { 30 - const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 31 - const hoisted = hoist(result.roots); 32 - const volume = new Volume(); 41 + describe('scoped packages', () => { 42 + it('fetches scoped packages correctly', async () => { 43 + const result = await resolve(['@babel/parser@7.23.0'], { installPeers: false }); 44 + const hoisted = hoist(result.roots); 45 + const volume = new Volume(); 46 + await fetchPackagesToVolume(hoisted, volume); 33 47 34 - // should work with concurrency of 1 35 - await fetchPackagesToVolume(hoisted, volume, { concurrency: 1 }); 36 - const files = volume.toJSON(); 37 - expect(Object.keys(files).length).toBeGreaterThan(0); 48 + const packageJson = volume.readFileSync( 49 + '/node_modules/@babel/parser/package.json', 50 + 'utf8', 51 + ); 52 + const json = JSON.parse(packageJson as string); 53 + expect(json.name).toBe('@babel/parser'); 54 + }); 38 55 }); 39 56 40 - it('excludes files matching default patterns', async () => { 41 - const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 42 - const hoisted = hoist(result.roots); 43 - const volume = new Volume(); 44 - await fetchPackagesToVolume(hoisted, volume); 57 + describe('nested packages', () => { 58 + it('handles version conflicts with nested packages', async () => { 59 + // is-odd depends on is-number@6.x, but we also request is-number@7.x 60 + const result = await resolve(['is-odd@3.0.1', 'is-number@7.0.0'], { installPeers: false }); 61 + const hoisted = hoist(result.roots); 62 + const volume = new Volume(); 63 + await fetchPackagesToVolume(hoisted, volume); 64 + 65 + // is-number@7 at root (explicitly requested) 66 + const rootIsNumber = volume.readFileSync('/node_modules/is-number/package.json', 'utf8'); 67 + const rootJson = JSON.parse(rootIsNumber as string); 68 + expect(rootJson.version).toBe('7.0.0'); 45 69 46 - // should not have README or LICENSE 47 - const files = volume.toJSON(); 48 - for (const path of Object.keys(files)) { 49 - const filename = path.split('/').pop()!; 50 - expect(filename.toUpperCase()).not.toMatch(/^README/); 51 - expect(filename.toUpperCase()).not.toMatch(/^LICENSE/); 52 - } 70 + // is-number@6 nested under is-odd 71 + const files = volume.toJSON(); 72 + const nestedIsNumber = Object.keys(files).find((p) => 73 + p.includes('/is-odd/node_modules/is-number/'), 74 + ); 75 + expect(nestedIsNumber).toBeDefined(); 76 + }); 53 77 }); 54 78 55 - it('can disable exclusions with empty array', async () => { 56 - const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 57 - const hoisted = hoist(result.roots); 79 + describe('file exclusions', () => { 80 + it('excludes files matching default patterns', async () => { 81 + const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 82 + const hoisted = hoist(result.roots); 83 + const volume = new Volume(); 84 + await fetchPackagesToVolume(hoisted, volume); 58 85 59 - const volumeNoExclude = new Volume(); 60 - await fetchPackagesToVolume(hoisted, volumeNoExclude, { exclude: [] }); 86 + const files = volume.toJSON(); 87 + for (const path of Object.keys(files)) { 88 + const filename = path.split('/').pop()!; 89 + expect(filename.toUpperCase()).not.toMatch(/^README/); 90 + expect(filename.toUpperCase()).not.toMatch(/^LICENSE/); 91 + } 92 + }); 93 + 94 + it('can disable exclusions with empty array', async () => { 95 + const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 96 + const hoisted = hoist(result.roots); 97 + 98 + const volumeNoExclude = new Volume(); 99 + await fetchPackagesToVolume(hoisted, volumeNoExclude, { exclude: [] }); 61 100 62 - const volumeWithExclude = new Volume(); 63 - await fetchPackagesToVolume(hoisted, volumeWithExclude); 101 + const volumeWithExclude = new Volume(); 102 + await fetchPackagesToVolume(hoisted, volumeWithExclude); 64 103 65 - // should have more files when nothing is excluded 66 - const noExcludeCount = Object.keys(volumeNoExclude.toJSON()).length; 67 - const withExcludeCount = Object.keys(volumeWithExclude.toJSON()).length; 68 - expect(noExcludeCount).toBeGreaterThanOrEqual(withExcludeCount); 104 + const noExcludeCount = Object.keys(volumeNoExclude.toJSON()).length; 105 + const withExcludeCount = Object.keys(volumeWithExclude.toJSON()).length; 106 + expect(noExcludeCount).toBeGreaterThanOrEqual(withExcludeCount); 107 + }); 69 108 }); 70 109 }); 71 110 111 + // #endregion 112 + 113 + // #region unpackedSize calculation 114 + 72 115 describe('unpackedSize calculation', () => { 73 116 it('populates unpackedSize from tarball when registry does not provide it', async () => { 74 117 // JSR packages don't have unpackedSize in registry metadata ··· 76 119 const hoisted = hoist(result.roots); 77 120 const volume = new Volume(); 78 121 79 - // before fetch, unpackedSize should be undefined (JSR doesn't provide it) 80 122 const rootNode = hoisted.root.get('@luca/flag')!; 81 123 expect(rootNode.unpackedSize).toBeUndefined(); 82 124 83 125 await fetchPackagesToVolume(hoisted, volume); 84 126 85 - // after fetch, unpackedSize should be populated from tarball 86 127 expect(rootNode.unpackedSize).toBeGreaterThan(0); 87 128 }); 88 129 ··· 91 132 const hoisted = hoist(result.roots); 92 133 const volume = new Volume(); 93 134 94 - // npm registry provides unpackedSize 95 135 const rootNode = hoisted.root.get('is-odd')!; 96 136 const registrySize = rootNode.unpackedSize; 97 137 expect(registrySize).toBeGreaterThan(0); 98 138 99 139 await fetchPackagesToVolume(hoisted, volume); 100 140 101 - // should preserve the registry-provided size 102 141 expect(rootNode.unpackedSize).toBe(registrySize); 103 142 }); 104 143 ··· 106 145 const result = await resolve(['is-odd@3.0.1'], { installPeers: false }); 107 146 const hoisted = hoist(result.roots); 108 147 109 - // clear the registry-provided size to force calculation from tarball 148 + // clear registry-provided size to force calculation from tarball 110 149 const rootNode = hoisted.root.get('is-odd')!; 111 150 rootNode.unpackedSize = undefined; 112 151 113 152 const volume = new Volume(); 114 153 await fetchPackagesToVolume(hoisted, volume); 115 154 116 - // size should include README, LICENSE, etc. even though they're excluded from extraction 117 155 const extractedFiles = volume.toJSON(); 118 156 const extractedSize = Object.values(extractedFiles).reduce( 119 157 (sum, content) => sum + (content as string).length, 120 158 0, 121 159 ); 122 160 123 - // tarball size should be >= extracted size (includes excluded files) 161 + // tarball size >= extracted size (includes excluded files) 124 162 expect(rootNode.unpackedSize).toBeGreaterThanOrEqual(extractedSize); 125 163 }); 126 164 }); 127 165 166 + // #endregion 167 + 168 + // #region DEFAULT_EXCLUDE_PATTERNS 169 + 128 170 describe('DEFAULT_EXCLUDE_PATTERNS', () => { 129 - it('matches README files', () => { 130 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('README.md'))).toBe(true); 131 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('README'))).toBe(true); 132 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('readme.txt'))).toBe(true); 171 + describe('documentation files', () => { 172 + it('matches README files', () => { 173 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('README.md'))).toBe(true); 174 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('README'))).toBe(true); 175 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('readme.txt'))).toBe(true); 176 + }); 177 + 178 + it('matches LICENSE files', () => { 179 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENSE'))).toBe(true); 180 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENSE.md'))).toBe(true); 181 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENCE'))).toBe(true); 182 + }); 183 + 184 + it('matches CHANGELOG and HISTORY files', () => { 185 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('CHANGELOG.md'))).toBe(true); 186 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('HISTORY.md'))).toBe(true); 187 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('changelog.txt'))).toBe(true); 188 + }); 189 + 190 + it('matches example directories', () => { 191 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('examples/basic.js'))).toBe(true); 192 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('example/demo.ts'))).toBe(true); 193 + }); 133 194 }); 134 195 135 - it('matches LICENSE files', () => { 136 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENSE'))).toBe(true); 137 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENSE.md'))).toBe(true); 138 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENCE'))).toBe(true); 196 + describe('test files', () => { 197 + it('matches test directories', () => { 198 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('__tests__/foo.js'))).toBe(true); 199 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('test/index.js'))).toBe(true); 200 + }); 201 + 202 + it('matches test file patterns', () => { 203 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('foo.test.js'))).toBe(true); 204 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('bar.spec.ts'))).toBe(true); 205 + }); 206 + }); 207 + 208 + describe('config files', () => { 209 + it('matches TypeScript config files', () => { 210 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('tsconfig.json'))).toBe(true); 211 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('tsconfig.build.json'))).toBe(true); 212 + }); 213 + 214 + it('matches linter configs', () => { 215 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('.eslintrc'))).toBe(true); 216 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('.eslintrc.json'))).toBe(true); 217 + }); 218 + 219 + it('matches test runner configs', () => { 220 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('jest.config.js'))).toBe(true); 221 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('vitest.config.ts'))).toBe(true); 222 + }); 223 + 224 + it('matches dot directories', () => { 225 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('.github/workflows/ci.yml'))).toBe( 226 + true, 227 + ); 228 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('.vscode/settings.json'))).toBe(true); 229 + }); 139 230 }); 140 231 141 - it('matches test directories', () => { 142 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('__tests__/foo.js'))).toBe(true); 143 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('test/index.js'))).toBe(true); 232 + describe('type definition files', () => { 233 + it('matches TypeScript declaration files', () => { 234 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.d.ts'))).toBe(true); 235 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('types.d.mts'))).toBe(true); 236 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('lib/foo.d.cts'))).toBe(true); 237 + }); 238 + 239 + it('matches Flow type files', () => { 240 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js.flow'))).toBe(true); 241 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('types.flow'))).toBe(true); 242 + }); 243 + 244 + it('does not match TypeScript source files', () => { 245 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.ts'))).toBe(false); 246 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.mts'))).toBe(false); 247 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.cts'))).toBe(false); 248 + }); 144 249 }); 145 250 146 - it('matches source maps', () => { 147 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js.map'))).toBe(true); 148 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('dist/bundle.js.map'))).toBe(true); 251 + describe('source maps', () => { 252 + it('matches source map files', () => { 253 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js.map'))).toBe(true); 254 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('dist/bundle.js.map'))).toBe(true); 255 + }); 149 256 }); 150 257 151 - it('does not match source files', () => { 152 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js'))).toBe(false); 153 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('src/utils.ts'))).toBe(false); 154 - expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('package.json'))).toBe(false); 258 + describe('source files (should NOT match)', () => { 259 + it('does not match JavaScript/TypeScript source files', () => { 260 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js'))).toBe(false); 261 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('src/utils.ts'))).toBe(false); 262 + }); 263 + 264 + it('does not match package.json', () => { 265 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('package.json'))).toBe(false); 266 + }); 267 + 268 + it('does not match dist/lib directories', () => { 269 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('dist/index.js'))).toBe(false); 270 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('lib/index.js'))).toBe(false); 271 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('cjs/index.js'))).toBe(false); 272 + expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('esm/index.js'))).toBe(false); 273 + }); 155 274 }); 156 275 }); 276 + 277 + // #endregion
+416
src/npm/lib/hoist.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { hoist, hoistedToPaths } from './hoist'; 4 + import type { ResolvedPackage } from './types'; 5 + 6 + // #region test helpers 7 + 8 + /** 9 + * creates a mock resolved package for testing. 10 + * supports: "name", "name@version", "@scope/name", "@scope/name@version" 11 + */ 12 + function pkg(spec: string, deps: ResolvedPackage[] = []): ResolvedPackage { 13 + let name: string; 14 + let version = '1.0.0'; 15 + 16 + if (spec.startsWith('@')) { 17 + // scoped package: @scope/name or @scope/name@version 18 + const slashIdx = spec.indexOf('/'); 19 + const atIdx = spec.indexOf('@', slashIdx); 20 + if (atIdx === -1) { 21 + name = spec; 22 + } else { 23 + name = spec.slice(0, atIdx); 24 + version = spec.slice(atIdx + 1); 25 + } 26 + } else if (spec.includes('@')) { 27 + // unscoped with version: name@version 28 + const atIdx = spec.indexOf('@'); 29 + name = spec.slice(0, atIdx); 30 + version = spec.slice(atIdx + 1); 31 + } else { 32 + // unscoped without version: name 33 + name = spec; 34 + } 35 + 36 + return { 37 + name, 38 + version, 39 + tarball: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, 40 + dependencies: new Map(deps.map((d) => [d.name, d])), 41 + }; 42 + } 43 + 44 + /** counts packages at root level vs nested */ 45 + function countLevels(paths: string[]): { root: number; nested: number } { 46 + let root = 0; 47 + let nested = 0; 48 + for (const path of paths) { 49 + const depth = (path.match(/node_modules/g) || []).length; 50 + if (depth === 1) { 51 + root++; 52 + } else { 53 + nested++; 54 + } 55 + } 56 + return { root, nested }; 57 + } 58 + 59 + // #endregion 60 + 61 + // #region basic hoisting 62 + 63 + describe('hoist', () => { 64 + describe('basic hoisting', () => { 65 + it('hoists single dependency to root', () => { 66 + // A -> B => both at root 67 + const b = pkg('B'); 68 + const a = pkg('A', [b]); 69 + const result = hoist([a]); 70 + const paths = hoistedToPaths(result); 71 + 72 + expect(paths).toContain('node_modules/A'); 73 + expect(paths).toContain('node_modules/B'); 74 + expect(countLevels(paths)).toEqual({ root: 2, nested: 0 }); 75 + }); 76 + 77 + it('hoists deep dependency chain to root', () => { 78 + // A -> B -> C -> D => all at root 79 + const d = pkg('D'); 80 + const c = pkg('C', [d]); 81 + const b = pkg('B', [c]); 82 + const a = pkg('A', [b]); 83 + const result = hoist([a]); 84 + 85 + expect(countLevels(hoistedToPaths(result))).toEqual({ root: 4, nested: 0 }); 86 + }); 87 + 88 + it('deduplicates shared dependencies', () => { 89 + // A -> C, B -> C => A, B, C all at root (C shared) 90 + const c = pkg('C'); 91 + const a = pkg('A', [c]); 92 + const b = pkg('B', [c]); 93 + const result = hoist([a, b]); 94 + const paths = hoistedToPaths(result); 95 + 96 + expect(countLevels(paths)).toEqual({ root: 3, nested: 0 }); 97 + expect(paths.filter((p) => p.includes('/C')).length).toBe(1); 98 + }); 99 + 100 + it('handles package with no dependencies', () => { 101 + const a = pkg('A'); 102 + const result = hoist([a]); 103 + 104 + expect(countLevels(hoistedToPaths(result))).toEqual({ root: 1, nested: 0 }); 105 + }); 106 + 107 + it('handles empty roots array', () => { 108 + const result = hoist([]); 109 + 110 + expect(result.root.size).toBe(0); 111 + expect(hoistedToPaths(result)).toHaveLength(0); 112 + }); 113 + }); 114 + 115 + // #endregion 116 + 117 + // #region version conflicts 118 + 119 + describe('version conflicts', () => { 120 + it('nests conflicting version under parent', () => { 121 + // A -> B@1, B@2 (root) => B@2 at root, B@1 nested under A 122 + const b1 = pkg('B@1.0.0'); 123 + const b2 = pkg('B@2.0.0'); 124 + const a = pkg('A', [b1]); 125 + const result = hoist([a, b2]); 126 + const paths = hoistedToPaths(result); 127 + 128 + expect(result.root.get('B')?.version).toBe('2.0.0'); 129 + expect(paths).toContain('node_modules/A/node_modules/B'); 130 + expect(countLevels(paths)).toEqual({ root: 2, nested: 1 }); 131 + }); 132 + 133 + it('first transitive version wins at root level', () => { 134 + // A -> B@1, C -> B@2 => B@1 hoisted (processed first), B@2 nested under C 135 + const b1 = pkg('B@1.0.0'); 136 + const b2 = pkg('B@2.0.0'); 137 + const a = pkg('A', [b1]); 138 + const c = pkg('C', [b2]); 139 + const result = hoist([a, c]); 140 + const paths = hoistedToPaths(result); 141 + 142 + expect(result.root.get('B')?.version).toBe('1.0.0'); 143 + expect(paths).toContain('node_modules/C/node_modules/B'); 144 + }); 145 + 146 + it('root package takes precedence over transitive', () => { 147 + // A@1 (root), B -> A@2 => A@1 at root, A@2 nested under B 148 + const a2 = pkg('A@2.0.0'); 149 + const b = pkg('B', [a2]); 150 + const a1 = pkg('A@1.0.0'); 151 + const result = hoist([a1, b]); 152 + const paths = hoistedToPaths(result); 153 + 154 + expect(result.root.get('A')?.version).toBe('1.0.0'); 155 + expect(paths).toContain('node_modules/B/node_modules/A'); 156 + }); 157 + 158 + it('later root package overwrites earlier for same name', () => { 159 + // A@1, A@2 (both roots) => A@2 at root (last wins) 160 + const a1 = pkg('A@1.0.0'); 161 + const a2 = pkg('A@2.0.0'); 162 + const result = hoist([a1, a2]); 163 + 164 + expect(result.root.get('A')?.version).toBe('2.0.0'); 165 + expect(countLevels(hoistedToPaths(result))).toEqual({ root: 1, nested: 0 }); 166 + }); 167 + 168 + it('handles diamond dependency with version conflict', () => { 169 + // A -> C -> D@1, B -> C -> D@2 => C deduplicated, D version conflict handled 170 + const d1 = pkg('D@1.0.0'); 171 + const d2 = pkg('D@2.0.0'); 172 + const c1 = pkg('C', [d1]); 173 + const c2 = pkg('C', [d2]); 174 + const a = pkg('A', [c1]); 175 + const b = pkg('B', [c2]); 176 + const result = hoist([a, b]); 177 + const paths = hoistedToPaths(result); 178 + 179 + // C should be deduplicated (same version) 180 + expect(paths.filter((p) => p.endsWith('/C')).length).toBe(1); 181 + }); 182 + 183 + it('preserves require chain when hoisting would break resolution', () => { 184 + // A -> B -> C@1, C@2 (root) => C@2 at root, C@1 nested to satisfy B 185 + const c1 = pkg('C@1.0.0'); 186 + const c2 = pkg('C@2.0.0'); 187 + const b = pkg('B', [c1]); 188 + const a = pkg('A', [b]); 189 + const result = hoist([a, c2]); 190 + const paths = hoistedToPaths(result); 191 + 192 + expect(result.root.get('C')?.version).toBe('2.0.0'); 193 + expect( 194 + paths.some((p) => p.includes('/B/node_modules/C') || p.includes('/A/node_modules/C')), 195 + ).toBe(true); 196 + }); 197 + }); 198 + 199 + // #endregion 200 + 201 + // #region cyclic dependencies 202 + 203 + describe('cyclic dependencies', () => { 204 + it('handles simple cycle between two packages', () => { 205 + // A <-> B (mutual dependency) 206 + const a: ResolvedPackage = { 207 + name: 'A', 208 + version: '1.0.0', 209 + tarball: 'https://example.com/a.tgz', 210 + dependencies: new Map(), 211 + }; 212 + const b: ResolvedPackage = { 213 + name: 'B', 214 + version: '1.0.0', 215 + tarball: 'https://example.com/b.tgz', 216 + dependencies: new Map([['A', a]]), 217 + }; 218 + a.dependencies.set('B', b); 219 + 220 + const result = hoist([a]); 221 + 222 + expect(countLevels(hoistedToPaths(result))).toEqual({ root: 2, nested: 0 }); 223 + }); 224 + 225 + it('handles longer cycle (A -> B -> C -> A)', () => { 226 + const a: ResolvedPackage = { 227 + name: 'A', 228 + version: '1.0.0', 229 + tarball: 'https://example.com/a.tgz', 230 + dependencies: new Map(), 231 + }; 232 + const c: ResolvedPackage = { 233 + name: 'C', 234 + version: '1.0.0', 235 + tarball: 'https://example.com/c.tgz', 236 + dependencies: new Map([['A', a]]), 237 + }; 238 + const b: ResolvedPackage = { 239 + name: 'B', 240 + version: '1.0.0', 241 + tarball: 'https://example.com/b.tgz', 242 + dependencies: new Map([['C', c]]), 243 + }; 244 + a.dependencies.set('B', b); 245 + 246 + const result = hoist([a]); 247 + 248 + expect(countLevels(hoistedToPaths(result))).toEqual({ root: 3, nested: 0 }); 249 + }); 250 + 251 + it('handles self-dependency', () => { 252 + const a: ResolvedPackage = { 253 + name: 'A', 254 + version: '1.0.0', 255 + tarball: 'https://example.com/a.tgz', 256 + dependencies: new Map(), 257 + }; 258 + a.dependencies.set('A', a); 259 + 260 + const result = hoist([a]); 261 + 262 + expect(countLevels(hoistedToPaths(result))).toEqual({ root: 1, nested: 0 }); 263 + }); 264 + }); 265 + 266 + // #endregion 267 + 268 + // #region deep nesting 269 + 270 + describe('deep nesting', () => { 271 + it('handles multiple levels of conflicts', () => { 272 + // A -> B@1 -> C@1, B@2 -> C@2, C@3 (root) 273 + const c1 = pkg('C@1.0.0'); 274 + const c2 = pkg('C@2.0.0'); 275 + const c3 = pkg('C@3.0.0'); 276 + const b1 = pkg('B@1.0.0', [c1]); 277 + const b2 = pkg('B@2.0.0', [c2]); 278 + const a = pkg('A', [b1]); 279 + const result = hoist([a, b2, c3]); 280 + const paths = hoistedToPaths(result); 281 + 282 + expect(result.root.get('C')?.version).toBe('3.0.0'); 283 + expect(result.root.get('B')?.version).toBe('2.0.0'); 284 + expect(paths).toContain('node_modules/A/node_modules/B'); 285 + }); 286 + 287 + it('nests package that would shadow parent dependency', () => { 288 + // A@1 -> B -> A@2 => A@1 at root, A@2 nested under B 289 + const a2 = pkg('A@2.0.0'); 290 + const b = pkg('B', [a2]); 291 + const a1 = pkg('A@1.0.0', [b]); 292 + const result = hoist([a1]); 293 + const paths = hoistedToPaths(result); 294 + 295 + expect(result.root.get('A')?.version).toBe('1.0.0'); 296 + expect(paths).toContain('node_modules/B/node_modules/A'); 297 + }); 298 + 299 + it('handles deeply nested version conflict chain', () => { 300 + // A -> B@1 -> C -> D@1, E -> B@2 -> C -> D@2 301 + const d1 = pkg('D@1.0.0'); 302 + const d2 = pkg('D@2.0.0'); 303 + const c1 = pkg('C', [d1]); 304 + const c2 = pkg('C', [d2]); 305 + const b1 = pkg('B@1.0.0', [c1]); 306 + const b2 = pkg('B@2.0.0', [c2]); 307 + const a = pkg('A', [b1]); 308 + const e = pkg('E', [b2]); 309 + const result = hoist([a, e]); 310 + const paths = hoistedToPaths(result); 311 + 312 + expect(paths).toContain('node_modules/A'); 313 + expect(paths).toContain('node_modules/E'); 314 + expect(result.root.get('B')?.version).toBe('1.0.0'); 315 + expect(paths).toContain('node_modules/E/node_modules/B'); 316 + }); 317 + }); 318 + 319 + // #endregion 320 + 321 + // #region scoped packages 322 + 323 + describe('scoped packages', () => { 324 + it('hoists scoped packages to root', () => { 325 + const scopedDep = pkg('@scope/dep'); 326 + const a = pkg('A', [scopedDep]); 327 + const result = hoist([a]); 328 + const paths = hoistedToPaths(result); 329 + 330 + expect(paths).toContain('node_modules/@scope/dep'); 331 + expect(result.root.get('@scope/dep')?.version).toBe('1.0.0'); 332 + }); 333 + 334 + it('handles scoped and unscoped packages with same last segment', () => { 335 + // @scope/foo and foo are different packages, no conflict 336 + const scopedFoo = pkg('@scope/foo'); 337 + const foo = pkg('foo'); 338 + const result = hoist([scopedFoo, foo]); 339 + const paths = hoistedToPaths(result); 340 + 341 + expect(paths).toContain('node_modules/@scope/foo'); 342 + expect(paths).toContain('node_modules/foo'); 343 + expect(countLevels(paths)).toEqual({ root: 2, nested: 0 }); 344 + }); 345 + 346 + it('handles version conflicts in scoped packages', () => { 347 + const scopedV1 = pkg('@scope/pkg@1.0.0'); 348 + const scopedV2 = pkg('@scope/pkg@2.0.0'); 349 + const a = pkg('A', [scopedV1]); 350 + const result = hoist([a, scopedV2]); 351 + const paths = hoistedToPaths(result); 352 + 353 + expect(result.root.get('@scope/pkg')?.version).toBe('2.0.0'); 354 + expect(paths).toContain('node_modules/A/node_modules/@scope/pkg'); 355 + }); 356 + }); 357 + 358 + // #endregion 359 + 360 + // #region metadata tracking 361 + 362 + describe('metadata tracking', () => { 363 + it('maintains correct dependency counts', () => { 364 + const c = pkg('C'); 365 + const b = pkg('B', [c]); 366 + const a = pkg('A', [b, c]); 367 + const result = hoist([a]); 368 + 369 + expect(result.root.get('A')?.dependencyCount).toBe(2); 370 + expect(result.root.get('B')?.dependencyCount).toBe(1); 371 + expect(result.root.get('C')?.dependencyCount).toBe(0); 372 + }); 373 + 374 + it('shared transitive at different depths is only hoisted once', () => { 375 + // A -> C -> D, B -> D => D hoisted once 376 + const d = pkg('D'); 377 + const c = pkg('C', [d]); 378 + const a = pkg('A', [c]); 379 + const b = pkg('B', [d]); 380 + const result = hoist([a, b]); 381 + const paths = hoistedToPaths(result); 382 + 383 + expect(paths.filter((p) => p.endsWith('/D'))).toHaveLength(1); 384 + }); 385 + }); 386 + 387 + // #endregion 388 + 389 + // #region hoistedToPaths utility 390 + 391 + describe('hoistedToPaths', () => { 392 + it('returns sorted paths', () => { 393 + const c = pkg('C'); 394 + const b = pkg('B'); 395 + const a = pkg('A', [b, c]); 396 + const result = hoist([a]); 397 + const paths = hoistedToPaths(result); 398 + 399 + expect(paths).toEqual([...paths].sort()); 400 + }); 401 + 402 + it('includes nested paths with full hierarchy', () => { 403 + const b2 = pkg('B@2.0.0'); 404 + const b1 = pkg('B@1.0.0'); 405 + const a = pkg('A', [b1]); 406 + const result = hoist([a, b2]); 407 + const paths = hoistedToPaths(result); 408 + 409 + expect(paths).toContain('node_modules/A'); 410 + expect(paths).toContain('node_modules/A/node_modules/B'); 411 + expect(paths).toContain('node_modules/B'); 412 + }); 413 + }); 414 + 415 + // #endregion 416 + });
+72 -39
src/npm/lib/hoist.ts
··· 39 39 40 40 /** 41 41 * hoists dependencies as high as possible in the tree. 42 - * follows npm's hoisting algorithm: 43 - * 1. try to place each package at root 44 - * 2. if conflict, nest it under its parent 42 + * follows npm's placement algorithm: 43 + * 1. explicitly requested (root) packages always get placed at root 44 + * 2. transitive dependencies try to hoist to root 45 + * 3. if conflict with a root package, nest under parent 45 46 * 46 47 * peer dependencies are handled by the resolver - they're added as regular 47 48 * dependencies of the package that requested them, so they naturally get ··· 56 57 57 58 // track which packages we've visited to avoid infinite loops 58 59 const visited = new Set<string>(); 60 + 61 + // track which package names are explicitly requested (root packages) 62 + // these take precedence over transitive dependencies 63 + const rootPackageVersions = new Map<string, string>(); 64 + for (const pkg of roots) { 65 + rootPackageVersions.set(pkg.name, pkg.version); 66 + } 59 67 60 68 /** 61 - * recursively process a package and its dependencies. 62 - * returns the hoisted node for this package. 69 + * creates a hoisted node from a resolved package. 63 70 */ 64 - function processPackage(pkg: ResolvedPackage, parentNode: HoistedNode | null): HoistedNode | null { 65 - const key = `${pkg.name}@${pkg.version}`; 71 + function createNode(pkg: ResolvedPackage): HoistedNode { 72 + return { 73 + name: pkg.name, 74 + version: pkg.version, 75 + tarball: pkg.tarball, 76 + integrity: pkg.integrity, 77 + unpackedSize: pkg.unpackedSize, 78 + dependencyCount: pkg.dependencies.size, 79 + nested: new Map(), 80 + }; 81 + } 66 82 67 - // skip if already processed 68 - if (visited.has(key)) { 69 - // return the existing node from root if it exists 70 - return root.get(pkg.name) ?? null; 71 - } 72 - visited.add(key); 83 + /** 84 + * recursively process a package's dependencies. 85 + * the package itself should already be placed. 86 + */ 87 + function processDependencies(pkg: ResolvedPackage, node: HoistedNode): void { 88 + for (const dep of pkg.dependencies.values()) { 89 + const depKey = `${dep.name}@${dep.version}`; 73 90 74 - // try to place at root first 75 - const placedAtRoot = tryPlaceAtRoot(root, pkg); 76 - let node: HoistedNode; 91 + // skip if already processed 92 + if (visited.has(depKey)) { 93 + continue; 94 + } 95 + visited.add(depKey); 96 + 97 + // check if this dep conflicts with a root package 98 + const rootVersion = rootPackageVersions.get(dep.name); 99 + if (rootVersion !== undefined && rootVersion !== dep.version) { 100 + // conflict with explicit root package - must nest 101 + const nestedNode = createNode(dep); 102 + node.nested.set(dep.name, nestedNode); 103 + processDependencies(dep, nestedNode); 104 + continue; 105 + } 77 106 78 - if (placedAtRoot) { 79 - node = root.get(pkg.name)!; 80 - } else if (parentNode) { 81 - // conflict at root, nest under parent 82 - node = { 83 - name: pkg.name, 84 - version: pkg.version, 85 - tarball: pkg.tarball, 86 - integrity: pkg.integrity, 87 - unpackedSize: pkg.unpackedSize, 88 - dependencyCount: pkg.dependencies.size, 89 - nested: new Map(), 90 - }; 91 - parentNode.nested.set(pkg.name, node); 92 - } else { 93 - // this shouldn't happen for root packages 94 - throw new Error(`cannot place root package ${pkg.name}@${pkg.version}`); 107 + // try to place at root 108 + const placedAtRoot = tryPlaceAtRoot(root, dep); 109 + if (placedAtRoot) { 110 + const rootNode = root.get(dep.name)!; 111 + processDependencies(dep, rootNode); 112 + } else { 113 + // conflict at root with another transitive dep - nest 114 + const nestedNode = createNode(dep); 115 + node.nested.set(dep.name, nestedNode); 116 + processDependencies(dep, nestedNode); 117 + } 95 118 } 119 + } 96 120 97 - // process dependencies 98 - for (const dep of pkg.dependencies.values()) { 99 - processPackage(dep, node); 121 + // first pass: place all root packages at root level 122 + // this ensures explicitly requested packages take precedence 123 + for (const rootPkg of roots) { 124 + const key = `${rootPkg.name}@${rootPkg.version}`; 125 + if (visited.has(key)) { 126 + continue; 100 127 } 128 + visited.add(key); 101 129 102 - return node; 130 + // root packages always go at root (overwrite if different version exists) 131 + const node = createNode(rootPkg); 132 + root.set(rootPkg.name, node); 103 133 } 104 134 105 - // process all root packages 135 + // second pass: process dependencies of all root packages 106 136 for (const rootPkg of roots) { 107 - processPackage(rootPkg, null); 137 + const node = root.get(rootPkg.name); 138 + if (node) { 139 + processDependencies(rootPkg, node); 140 + } 108 141 } 109 142 110 143 return { root };
+416 -217
src/npm/lib/resolve.test.ts
··· 1 1 import { describe, expect, it } from 'vitest'; 2 2 3 - import { hoist, hoistedToPaths } from './hoist'; 4 3 import { reverseJsrName, transformJsrName } from './registry'; 5 4 import { parseSpecifier, pickVersion, resolve } from './resolve'; 6 5 import type { AbbreviatedManifest } from './types'; 7 6 7 + // #region test helpers 8 + 9 + /** creates a mock manifest for testing */ 10 + function manifest(version: string, opts: { deprecated?: string } = {}): AbbreviatedManifest { 11 + return { 12 + name: 'test', 13 + version, 14 + deprecated: opts.deprecated, 15 + dist: { tarball: `https://example.com/test-${version}.tgz`, shasum: 'abc' }, 16 + }; 17 + } 18 + 19 + // #endregion 20 + 21 + // #region parseSpecifier 22 + 8 23 describe('parseSpecifier', () => { 9 - it('parses bare package name', () => { 10 - expect(parseSpecifier('react')).toEqual({ name: 'react', range: 'latest', registry: 'npm' }); 11 - }); 24 + describe('npm packages', () => { 25 + it('parses bare package name', () => { 26 + expect(parseSpecifier('react')).toEqual({ 27 + name: 'react', 28 + range: 'latest', 29 + registry: 'npm', 30 + }); 31 + }); 12 32 13 - it('parses package with version', () => { 14 - expect(parseSpecifier('react@18.2.0')).toEqual({ 15 - name: 'react', 16 - range: '18.2.0', 17 - registry: 'npm', 33 + it('parses package with exact version', () => { 34 + expect(parseSpecifier('react@18.2.0')).toEqual({ 35 + name: 'react', 36 + range: '18.2.0', 37 + registry: 'npm', 38 + }); 18 39 }); 19 - }); 20 40 21 - it('parses package with range', () => { 22 - expect(parseSpecifier('react@^18.0.0')).toEqual({ 23 - name: 'react', 24 - range: '^18.0.0', 25 - registry: 'npm', 41 + it('parses package with semver range', () => { 42 + expect(parseSpecifier('react@^18.0.0')).toEqual({ 43 + name: 'react', 44 + range: '^18.0.0', 45 + registry: 'npm', 46 + }); 26 47 }); 27 - }); 28 48 29 - it('parses scoped package', () => { 30 - expect(parseSpecifier('@babel/core')).toEqual({ 31 - name: '@babel/core', 32 - range: 'latest', 33 - registry: 'npm', 49 + it('parses npm: prefix as noop', () => { 50 + expect(parseSpecifier('npm:react')).toEqual({ 51 + name: 'react', 52 + range: 'latest', 53 + registry: 'npm', 54 + }); 34 55 }); 35 - }); 36 56 37 - it('parses scoped package with version', () => { 38 - expect(parseSpecifier('@babel/core@7.23.0')).toEqual({ 39 - name: '@babel/core', 40 - range: '7.23.0', 41 - registry: 'npm', 57 + it('parses npm: prefix with version', () => { 58 + expect(parseSpecifier('npm:react@18.2.0')).toEqual({ 59 + name: 'react', 60 + range: '18.2.0', 61 + registry: 'npm', 62 + }); 42 63 }); 43 64 }); 44 65 45 - it('parses scoped package with range', () => { 46 - expect(parseSpecifier('@types/node@^20.0.0')).toEqual({ 47 - name: '@types/node', 48 - range: '^20.0.0', 49 - registry: 'npm', 66 + describe('scoped packages', () => { 67 + it('parses scoped package without version', () => { 68 + expect(parseSpecifier('@babel/core')).toEqual({ 69 + name: '@babel/core', 70 + range: 'latest', 71 + registry: 'npm', 72 + }); 50 73 }); 51 - }); 52 74 53 - it('parses jsr package', () => { 54 - expect(parseSpecifier('jsr:@luca/flag')).toEqual({ 55 - name: '@luca/flag', 56 - range: 'latest', 57 - registry: 'jsr', 75 + it('parses scoped package with exact version', () => { 76 + expect(parseSpecifier('@babel/core@7.23.0')).toEqual({ 77 + name: '@babel/core', 78 + range: '7.23.0', 79 + registry: 'npm', 80 + }); 58 81 }); 59 - }); 60 82 61 - it('parses jsr package with version', () => { 62 - expect(parseSpecifier('jsr:@luca/flag@1.0.0')).toEqual({ 63 - name: '@luca/flag', 64 - range: '1.0.0', 65 - registry: 'jsr', 83 + it('parses scoped package with semver range', () => { 84 + expect(parseSpecifier('@types/node@^20.0.0')).toEqual({ 85 + name: '@types/node', 86 + range: '^20.0.0', 87 + registry: 'npm', 88 + }); 66 89 }); 67 - }); 68 90 69 - it('parses jsr package with range', () => { 70 - expect(parseSpecifier('jsr:@std/path@^1.0.0')).toEqual({ 71 - name: '@std/path', 72 - range: '^1.0.0', 73 - registry: 'jsr', 91 + it('parses npm: prefix with scoped package', () => { 92 + expect(parseSpecifier('npm:@babel/core@^7.0.0')).toEqual({ 93 + name: '@babel/core', 94 + range: '^7.0.0', 95 + registry: 'npm', 96 + }); 74 97 }); 75 98 }); 76 99 77 - it('throws for unscoped jsr package', () => { 78 - expect(() => parseSpecifier('jsr:flag')).toThrow('JSR packages must be scoped'); 79 - }); 100 + describe('JSR packages', () => { 101 + it('parses jsr package without version', () => { 102 + expect(parseSpecifier('jsr:@luca/flag')).toEqual({ 103 + name: '@luca/flag', 104 + range: 'latest', 105 + registry: 'jsr', 106 + }); 107 + }); 80 108 81 - it('parses npm: prefix as noop', () => { 82 - expect(parseSpecifier('npm:react')).toEqual({ 83 - name: 'react', 84 - range: 'latest', 85 - registry: 'npm', 109 + it('parses jsr package with exact version', () => { 110 + expect(parseSpecifier('jsr:@luca/flag@1.0.0')).toEqual({ 111 + name: '@luca/flag', 112 + range: '1.0.0', 113 + registry: 'jsr', 114 + }); 86 115 }); 87 - }); 88 116 89 - it('parses npm: prefix with version', () => { 90 - expect(parseSpecifier('npm:react@18.2.0')).toEqual({ 91 - name: 'react', 92 - range: '18.2.0', 93 - registry: 'npm', 117 + it('parses jsr package with semver range', () => { 118 + expect(parseSpecifier('jsr:@std/path@^1.0.0')).toEqual({ 119 + name: '@std/path', 120 + range: '^1.0.0', 121 + registry: 'jsr', 122 + }); 94 123 }); 95 - }); 96 124 97 - it('parses npm: prefix with scoped package', () => { 98 - expect(parseSpecifier('npm:@babel/core@^7.0.0')).toEqual({ 99 - name: '@babel/core', 100 - range: '^7.0.0', 101 - registry: 'npm', 125 + it('throws for unscoped jsr package', () => { 126 + expect(() => parseSpecifier('jsr:flag')).toThrow('JSR packages must be scoped'); 102 127 }); 103 128 }); 104 129 }); 105 130 106 - describe('transformJsrName', () => { 107 - it('transforms scoped package name', () => { 108 - expect(transformJsrName('@luca/flag')).toBe('@jsr/luca__flag'); 109 - }); 131 + // #endregion 110 132 111 - it('transforms std package name', () => { 112 - expect(transformJsrName('@std/path')).toBe('@jsr/std__path'); 113 - }); 133 + // #region JSR name utilities 114 134 115 - it('throws for unscoped package', () => { 116 - expect(() => transformJsrName('flag')).toThrow('JSR packages must be scoped'); 117 - }); 118 - }); 135 + describe('JSR name utilities', () => { 136 + describe('transformJsrName', () => { 137 + it('transforms scoped JSR name to npm-compatible format', () => { 138 + expect(transformJsrName('@luca/flag')).toBe('@jsr/luca__flag'); 139 + }); 119 140 120 - describe('reverseJsrName', () => { 121 - it('reverses npm-compatible JSR name', () => { 122 - expect(reverseJsrName('@jsr/luca__flag')).toBe('@luca/flag'); 141 + it('transforms @std packages', () => { 142 + expect(transformJsrName('@std/path')).toBe('@jsr/std__path'); 143 + }); 144 + 145 + it('throws for unscoped package', () => { 146 + expect(() => transformJsrName('flag')).toThrow('JSR packages must be scoped'); 147 + }); 123 148 }); 124 149 125 - it('reverses std package name', () => { 126 - expect(reverseJsrName('@jsr/std__internal')).toBe('@std/internal'); 127 - }); 150 + describe('reverseJsrName', () => { 151 + it('reverses npm-compatible JSR name to canonical format', () => { 152 + expect(reverseJsrName('@jsr/luca__flag')).toBe('@luca/flag'); 153 + }); 154 + 155 + it('reverses @std packages', () => { 156 + expect(reverseJsrName('@jsr/std__internal')).toBe('@std/internal'); 157 + }); 128 158 129 - it('throws for non-JSR name', () => { 130 - expect(() => reverseJsrName('@babel/core')).toThrow('not a JSR npm-compatible name'); 159 + it('throws for non-JSR name', () => { 160 + expect(() => reverseJsrName('@babel/core')).toThrow('not a JSR npm-compatible name'); 161 + }); 131 162 }); 132 163 }); 133 164 165 + // #endregion 166 + 167 + // #region pickVersion 168 + 134 169 describe('pickVersion', () => { 135 - const mockVersions: Record<string, AbbreviatedManifest> = { 136 - '1.0.0': { 137 - name: 'test', 138 - version: '1.0.0', 139 - dist: { tarball: 'https://example.com/test-1.0.0.tgz', shasum: 'abc123' }, 140 - }, 141 - '1.1.0': { 142 - name: 'test', 143 - version: '1.1.0', 144 - dist: { tarball: 'https://example.com/test-1.1.0.tgz', shasum: 'abc124' }, 145 - }, 146 - '2.0.0': { 147 - name: 'test', 148 - version: '2.0.0', 149 - dist: { tarball: 'https://example.com/test-2.0.0.tgz', shasum: 'abc125' }, 150 - }, 151 - '2.1.0-beta.1': { 152 - name: 'test', 153 - version: '2.1.0-beta.1', 154 - dist: { tarball: 'https://example.com/test-2.1.0-beta.1.tgz', shasum: 'abc126' }, 155 - }, 156 - }; 170 + describe('dist-tags', () => { 171 + const versions = { 172 + '1.0.0': manifest('1.0.0'), 173 + '2.0.0': manifest('2.0.0'), 174 + '3.0.0-beta.1': manifest('3.0.0-beta.1'), 175 + }; 176 + const distTags = { latest: '2.0.0', next: '3.0.0-beta.1' }; 157 177 158 - const distTags = { latest: '2.0.0', next: '2.1.0-beta.1' }; 178 + it('resolves "latest" tag', () => { 179 + expect(pickVersion(versions, distTags, 'latest')?.version).toBe('2.0.0'); 180 + }); 159 181 160 - it('resolves dist-tag', () => { 161 - const result = pickVersion(mockVersions, distTags, 'latest'); 162 - expect(result?.version).toBe('2.0.0'); 182 + it('resolves "next" tag', () => { 183 + expect(pickVersion(versions, distTags, 'next')?.version).toBe('3.0.0-beta.1'); 184 + }); 185 + 186 + it('returns null for unknown tag', () => { 187 + expect(pickVersion(versions, distTags, 'nonexistent')).toBeNull(); 188 + }); 189 + 190 + it('returns null when tag points to missing version', () => { 191 + expect(pickVersion(versions, { latest: '9.9.9' }, 'latest')).toBeNull(); 192 + }); 193 + 194 + it('treats empty string as latest', () => { 195 + expect(pickVersion(versions, distTags, '')?.version).toBe('2.0.0'); 196 + }); 163 197 }); 164 198 165 - it('resolves next dist-tag', () => { 166 - const result = pickVersion(mockVersions, distTags, 'next'); 167 - expect(result?.version).toBe('2.1.0-beta.1'); 199 + describe('exact versions', () => { 200 + const versions = { 201 + '1.0.0': manifest('1.0.0'), 202 + '2.0.0': manifest('2.0.0'), 203 + }; 204 + const distTags = { latest: '2.0.0' }; 205 + 206 + it('resolves exact version', () => { 207 + expect(pickVersion(versions, distTags, '1.0.0')?.version).toBe('1.0.0'); 208 + }); 209 + 210 + it('handles v-prefixed version', () => { 211 + expect(pickVersion(versions, distTags, 'v1.0.0')?.version).toBe('1.0.0'); 212 + }); 213 + 214 + it('handles = prefixed version', () => { 215 + expect(pickVersion(versions, distTags, '= 1.0.0')?.version).toBe('1.0.0'); 216 + }); 217 + 218 + it('returns null for non-existent version', () => { 219 + expect(pickVersion(versions, distTags, '9.9.9')).toBeNull(); 220 + }); 168 221 }); 169 222 170 - it('resolves exact version', () => { 171 - const result = pickVersion(mockVersions, distTags, '1.0.0'); 172 - expect(result?.version).toBe('1.0.0'); 173 - }); 223 + describe('semver ranges', () => { 224 + const versions = { 225 + '1.0.0': manifest('1.0.0'), 226 + '1.5.0': manifest('1.5.0'), 227 + '2.0.0': manifest('2.0.0'), 228 + '2.5.0': manifest('2.5.0'), 229 + '3.0.0': manifest('3.0.0'), 230 + }; 231 + const distTags = { latest: '3.0.0' }; 232 + 233 + it('resolves caret range (^)', () => { 234 + expect(pickVersion(versions, distTags, '^1.0.0')?.version).toBe('1.5.0'); 235 + }); 236 + 237 + it('resolves tilde range (~)', () => { 238 + expect(pickVersion(versions, distTags, '~1.0.0')?.version).toBe('1.0.0'); 239 + }); 240 + 241 + it('resolves >= range', () => { 242 + expect(pickVersion(versions, distTags, '>=2.0.0')?.version).toBe('3.0.0'); 243 + }); 244 + 245 + it('resolves > range', () => { 246 + expect(pickVersion(versions, distTags, '>2.0.0')?.version).toBe('3.0.0'); 247 + }); 248 + 249 + it('resolves < range', () => { 250 + expect(pickVersion(versions, distTags, '<2.0.0')?.version).toBe('1.5.0'); 251 + }); 174 252 175 - it('resolves caret range', () => { 176 - const result = pickVersion(mockVersions, distTags, '^1.0.0'); 177 - expect(result?.version).toBe('1.1.0'); 178 - }); 253 + it('resolves <= range', () => { 254 + expect(pickVersion(versions, distTags, '<=2.0.0')?.version).toBe('2.0.0'); 255 + }); 179 256 180 - it('resolves tilde range', () => { 181 - const result = pickVersion(mockVersions, distTags, '~1.0.0'); 182 - expect(result?.version).toBe('1.0.0'); 183 - }); 257 + it('resolves compound range (>=x <y)', () => { 258 + expect(pickVersion(versions, distTags, '>=1.0.0 <2.0.0')?.version).toBe('1.5.0'); 259 + }); 184 260 185 - it('returns null for unsatisfied range', () => { 186 - const result = pickVersion(mockVersions, distTags, '^3.0.0'); 187 - expect(result).toBeNull(); 188 - }); 189 - }); 261 + it('resolves hyphen range (x - y)', () => { 262 + expect(pickVersion(versions, distTags, '1.0.0 - 2.0.0')?.version).toBe('2.0.0'); 263 + }); 190 264 191 - describe('resolve', () => { 192 - it('resolves a simple package', async () => { 193 - const result = await resolve(['is-odd@3.0.1']); 265 + it('resolves OR range (||)', () => { 266 + expect(pickVersion(versions, distTags, '^1.0.0 || ^3.0.0')?.version).toBe('3.0.0'); 267 + }); 194 268 195 - expect(result.roots).toHaveLength(1); 196 - expect(result.roots[0].name).toBe('is-odd'); 197 - expect(result.roots[0].version).toBe('3.0.1'); 269 + it('resolves x-range (1.x)', () => { 270 + expect(pickVersion(versions, distTags, '1.x')?.version).toBe('1.5.0'); 271 + }); 272 + 273 + it('returns null for unsatisfied range', () => { 274 + expect(pickVersion(versions, distTags, '^9.0.0')).toBeNull(); 275 + }); 198 276 199 - // is-odd depends on is-number 200 - expect(result.roots[0].dependencies.has('is-number')).toBe(true); 277 + it('returns null for empty versions object', () => { 278 + expect(pickVersion({}, distTags, '^1.0.0')).toBeNull(); 279 + }); 201 280 }); 202 281 203 - it('resolves multiple packages', async () => { 204 - const result = await resolve(['is-odd@3.0.1', 'is-even@1.0.0']); 282 + describe('latest tag preference (pnpm behavior)', () => { 283 + // pnpm prefers the version tagged as 'latest' even when newer versions exist, 284 + // because publishers tag 'latest' intentionally (e.g., LTS versions) 205 285 206 - expect(result.roots).toHaveLength(2); 207 - expect(result.roots[0].name).toBe('is-odd'); 208 - expect(result.roots[1].name).toBe('is-even'); 209 - }); 286 + const versions = { 287 + '18.0.0': manifest('18.0.0'), 288 + '20.0.0': manifest('20.0.0'), 289 + '21.0.0': manifest('21.0.0'), 290 + }; 210 291 211 - it('deduplicates shared dependencies', async () => { 212 - // both is-odd and is-even depend on is-number 213 - const result = await resolve(['is-odd@3.0.1', 'is-even@1.0.0']); 292 + it('prefers latest over newer versions when range is satisfied', () => { 293 + const distTags = { latest: '20.0.0' }; 294 + // 21.0.0 exists and satisfies >=18.0.0, but latest (20.0.0) should win 295 + expect(pickVersion(versions, distTags, '>=18.0.0')?.version).toBe('20.0.0'); 296 + }); 214 297 215 - // count unique packages 216 - const isNumberVersions = new Set<string>(); 217 - for (const pkg of result.packages.values()) { 218 - if (pkg.name === 'is-number') { 219 - isNumberVersions.add(pkg.version); 220 - } 221 - } 298 + it('picks newer version when latest does not satisfy range', () => { 299 + const distTags = { latest: '18.0.0' }; 300 + // latest (18.0.0) doesn't satisfy >=20.0.0, so pick highest matching (21.0.0) 301 + expect(pickVersion(versions, distTags, '>=20.0.0')?.version).toBe('21.0.0'); 302 + }); 222 303 223 - // should have is-number in the packages map 224 - expect(isNumberVersions.size).toBeGreaterThan(0); 304 + it('prefers latest in OR ranges when satisfied', () => { 305 + const distTags = { latest: '20.0.0' }; 306 + expect(pickVersion(versions, distTags, '^18.0.0 || ^20.0.0')?.version).toBe('20.0.0'); 307 + }); 225 308 }); 226 309 227 - it('resolves a JSR package', async () => { 228 - const result = await resolve(['jsr:@luca/flag@1.0.1']); 310 + describe('deprecated version handling', () => { 311 + // pnpm/npm avoid deprecated versions when non-deprecated alternatives exist 229 312 230 - expect(result.roots).toHaveLength(1); 231 - expect(result.roots[0].name).toBe('@luca/flag'); 232 - expect(result.roots[0].version).toBe('1.0.1'); 233 - expect(result.roots[0].tarball).toContain('npm.jsr.io'); 234 - }); 313 + const versions = { 314 + '1.0.0': manifest('1.0.0'), 315 + '2.0.0': manifest('2.0.0', { deprecated: 'security issue' }), 316 + '3.0.0': manifest('3.0.0'), 317 + '3.1.0': manifest('3.1.0', { deprecated: 'use 3.0.0 instead' }), 318 + }; 319 + const distTags = { latest: '3.0.0' }; 235 320 236 - it('resolves a JSR package with JSR dependencies', async () => { 237 - // @std/path@1.1.4 depends on @jsr/std__internal (reversed to @std/internal) 238 - const result = await resolve(['jsr:@std/path@1.1.4']); 321 + it('picks non-deprecated over deprecated when both satisfy', () => { 322 + // ^3.0.0 matches 3.0.0 and 3.1.0, but 3.1.0 is deprecated 323 + expect(pickVersion(versions, distTags, '^3.0.0')?.version).toBe('3.0.0'); 324 + }); 239 325 240 - expect(result.roots).toHaveLength(1); 241 - expect(result.roots[0].name).toBe('@std/path'); 242 - expect(result.roots[0].version).toBe('1.1.4'); 326 + it('prefers non-deprecated even if deprecated is higher', () => { 327 + expect(pickVersion(versions, distTags, '>=3.0.0')?.version).toBe('3.0.0'); 328 + }); 329 + 330 + it('uses deprecated when no non-deprecated version satisfies', () => { 331 + // ^2.0.0 only matches 2.0.0 which is deprecated 332 + expect(pickVersion(versions, distTags, '^2.0.0')?.version).toBe('2.0.0'); 333 + }); 243 334 244 - // dependency is stored under the original name from the manifest 245 - expect(result.roots[0].dependencies.has('@jsr/std__internal')).toBe(true); 246 - const internal = result.roots[0].dependencies.get('@jsr/std__internal')!; 247 - // but the resolved package uses the canonical name 248 - expect(internal.name).toBe('@std/internal'); 249 - expect(internal.tarball).toContain('npm.jsr.io'); 335 + it('picks highest deprecated when all are deprecated', () => { 336 + const allDeprecated = { 337 + '1.0.0': manifest('1.0.0', { deprecated: 'old' }), 338 + '1.1.0': manifest('1.1.0', { deprecated: 'old' }), 339 + }; 340 + expect(pickVersion(allDeprecated, { latest: '1.1.0' }, '^1.0.0')?.version).toBe('1.1.0'); 341 + }); 250 342 }); 251 343 252 - it('auto-installs required peer dependencies', async () => { 253 - // use-sync-external-store has react as a required peer 254 - const result = await resolve(['use-sync-external-store@1.2.0']); 344 + describe('prerelease handling', () => { 345 + it('does not match prereleases with standard ranges', () => { 346 + const versions = { 347 + '1.0.0': manifest('1.0.0'), 348 + '2.0.0-beta.1': manifest('2.0.0-beta.1'), 349 + }; 350 + // ^1.0.0 should NOT match 2.0.0-beta.1 351 + expect(pickVersion(versions, { latest: '1.0.0' }, '^1.0.0')?.version).toBe('1.0.0'); 352 + }); 255 353 256 - // react should be added as a dependency of use-sync-external-store 257 - const mainPkg = result.roots[0]; 258 - expect(mainPkg.dependencies.has('react')).toBe(true); 354 + it('prefers stable over prerelease when both satisfy', () => { 355 + const versions = { 356 + '1.0.0-alpha.1': manifest('1.0.0-alpha.1'), 357 + '1.0.0': manifest('1.0.0'), 358 + }; 359 + expect(pickVersion(versions, { latest: '1.0.0' }, '>=1.0.0-alpha.1')?.version).toBe( 360 + '1.0.0', 361 + ); 362 + }); 259 363 260 - // react should also be in the packages map 261 - const hasReact = Array.from(result.packages.values()).some((p) => p.name === 'react'); 262 - expect(hasReact).toBe(true); 263 - }); 364 + it('matches explicit prerelease version', () => { 365 + const versions = { 366 + '1.0.0-beta.1': manifest('1.0.0-beta.1'), 367 + '1.0.0-beta.2': manifest('1.0.0-beta.2'), 368 + }; 369 + expect(pickVersion(versions, { latest: '1.0.0-beta.2' }, '1.0.0-beta.1')?.version).toBe( 370 + '1.0.0-beta.1', 371 + ); 372 + }); 264 373 265 - it('skips optional peer dependencies', async () => { 266 - // resolve something with optional peers and verify they're not installed 267 - const result = await resolve(['use-sync-external-store@1.2.0']); 374 + it('matches prerelease range correctly', () => { 375 + const versions = { 376 + '1.0.0-beta.1': manifest('1.0.0-beta.1'), 377 + '1.0.0-beta.2': manifest('1.0.0-beta.2'), 378 + }; 379 + // ^1.0.0-beta.1 should match other 1.0.0 betas 380 + expect(pickVersion(versions, { latest: '1.0.0-beta.2' }, '^1.0.0-beta.1')?.version).toBe( 381 + '1.0.0-beta.2', 382 + ); 383 + }); 268 384 269 - // react should be there (required peer) as a dependency 270 - const mainPkg = result.roots[0]; 271 - expect(mainPkg.dependencies.has('react')).toBe(true); 272 - }); 385 + it('sorts prereleases correctly (alpha < beta < rc)', () => { 386 + const versions = { 387 + '1.0.0-alpha.1': manifest('1.0.0-alpha.1'), 388 + '1.0.0-beta.1': manifest('1.0.0-beta.1'), 389 + '1.0.0-rc.1': manifest('1.0.0-rc.1'), 390 + }; 391 + expect( 392 + pickVersion(versions, { latest: '1.0.0-rc.1' }, '>=1.0.0-alpha.1')?.version, 393 + ).toBe('1.0.0-rc.1'); 394 + }); 273 395 274 - it('respects installPeers: false option', async () => { 275 - const result = await resolve(['use-sync-external-store@1.2.0'], { installPeers: false }); 396 + it('uses latest tag with wildcard when all versions are prerelease', () => { 397 + const versions = { 398 + '1.0.0-alpha.1': manifest('1.0.0-alpha.1'), 399 + '1.0.0-beta.1': manifest('1.0.0-beta.1'), 400 + }; 401 + // * with all prereleases should respect latest tag (pnpm/npm behavior) 402 + expect(pickVersion(versions, { latest: '1.0.0-alpha.1' }, '*')?.version).toBe( 403 + '1.0.0-alpha.1', 404 + ); 405 + }); 276 406 277 - // should only have the requested package 278 - expect(result.roots).toHaveLength(1); 279 - expect(result.roots[0].name).toBe('use-sync-external-store'); 407 + it('picks prerelease with wildcard when only prereleases exist', () => { 408 + const versions = { 409 + '1.0.0-alpha.1': manifest('1.0.0-alpha.1'), 410 + }; 411 + expect(pickVersion(versions, { latest: '1.0.0-alpha.1' }, '*')?.version).toBe( 412 + '1.0.0-alpha.1', 413 + ); 414 + }); 280 415 }); 281 416 }); 282 417 283 - describe('hoist', () => { 284 - it('hoists simple dependencies to root', async () => { 285 - const result = await resolve(['is-odd@3.0.1']); 286 - const hoisted = hoist(result.roots); 287 - const paths = hoistedToPaths(hoisted); 418 + // #endregion 419 + 420 + // #region resolve (integration tests) 288 421 289 - // both is-odd and is-number should be at root 290 - expect(paths).toContain('node_modules/is-odd'); 291 - expect(paths).toContain('node_modules/is-number'); 422 + describe('resolve', () => { 423 + describe('basic resolution', () => { 424 + it('resolves a single package with dependencies', async () => { 425 + const result = await resolve(['is-odd@3.0.1']); 426 + 427 + expect(result.roots).toHaveLength(1); 428 + expect(result.roots[0].name).toBe('is-odd'); 429 + expect(result.roots[0].version).toBe('3.0.1'); 430 + expect(result.roots[0].dependencies.has('is-number')).toBe(true); 431 + }); 432 + 433 + it('resolves multiple packages', async () => { 434 + const result = await resolve(['is-odd@3.0.1', 'is-even@1.0.0']); 435 + 436 + expect(result.roots).toHaveLength(2); 437 + expect(result.roots[0].name).toBe('is-odd'); 438 + expect(result.roots[1].name).toBe('is-even'); 439 + }); 440 + 441 + it('deduplicates shared dependencies', async () => { 442 + const result = await resolve(['is-odd@3.0.1', 'is-even@1.0.0']); 443 + 444 + const isNumberVersions = new Set<string>(); 445 + for (const pkg of result.packages.values()) { 446 + if (pkg.name === 'is-number') { 447 + isNumberVersions.add(pkg.version); 448 + } 449 + } 450 + expect(isNumberVersions.size).toBeGreaterThan(0); 451 + }); 292 452 }); 293 453 294 - it('nests conflicting versions', async () => { 295 - // this test would need packages with conflicting versions 296 - // for now, just verify the basic structure works 297 - const result = await resolve(['is-odd@3.0.1']); 298 - const hoisted = hoist(result.roots); 454 + describe('JSR packages', () => { 455 + it('resolves a JSR package', async () => { 456 + const result = await resolve(['jsr:@luca/flag@1.0.1']); 457 + 458 + expect(result.roots).toHaveLength(1); 459 + expect(result.roots[0].name).toBe('@luca/flag'); 460 + expect(result.roots[0].version).toBe('1.0.1'); 461 + expect(result.roots[0].tarball).toContain('npm.jsr.io'); 462 + }); 463 + 464 + it('resolves JSR package with JSR dependencies', async () => { 465 + const result = await resolve(['jsr:@std/path@1.1.4']); 466 + 467 + expect(result.roots[0].name).toBe('@std/path'); 468 + // dependency stored under npm-compatible name, resolved to canonical 469 + expect(result.roots[0].dependencies.has('@jsr/std__internal')).toBe(true); 470 + const internal = result.roots[0].dependencies.get('@jsr/std__internal')!; 471 + expect(internal.name).toBe('@std/internal'); 472 + expect(internal.tarball).toContain('npm.jsr.io'); 473 + }); 474 + }); 475 + 476 + describe('peer dependencies', () => { 477 + it('auto-installs required peer dependencies', async () => { 478 + const result = await resolve(['use-sync-external-store@1.2.0']); 479 + 480 + const mainPkg = result.roots[0]; 481 + expect(mainPkg.dependencies.has('react')).toBe(true); 482 + expect(Array.from(result.packages.values()).some((p) => p.name === 'react')).toBe(true); 483 + }); 484 + 485 + it('skips optional peer dependencies', async () => { 486 + const result = await resolve(['use-sync-external-store@1.2.0']); 487 + 488 + // react is required, should be present 489 + const mainPkg = result.roots[0]; 490 + expect(mainPkg.dependencies.has('react')).toBe(true); 491 + }); 492 + 493 + it('respects installPeers: false option', async () => { 494 + const result = await resolve(['use-sync-external-store@1.2.0'], { installPeers: false }); 299 495 300 - expect(hoisted.root.size).toBeGreaterThan(0); 301 - expect(hoisted.root.has('is-odd')).toBe(true); 496 + expect(result.roots).toHaveLength(1); 497 + expect(result.roots[0].name).toBe('use-sync-external-store'); 498 + }); 302 499 }); 303 500 }); 501 + 502 + // #endregion
+42 -5
src/npm/lib/resolve.ts
··· 65 65 66 66 /** 67 67 * picks the best version from a packument that satisfies a range. 68 - * follows npm's algorithm: 68 + * follows npm/pnpm's algorithm: 69 69 * 1. if range is a dist-tag, use that version 70 - * 2. if range is a specific version, use that 71 - * 3. otherwise, find highest version that satisfies the semver range 70 + * 2. if range is empty, treat as 'latest' 71 + * 3. if range is a specific version (possibly with v prefix), use that 72 + * 4. if 'latest' tag satisfies the range, prefer it over newer versions 73 + * 5. otherwise, find highest non-deprecated version that satisfies the range 74 + * 6. fall back to deprecated version if no non-deprecated match 72 75 * 73 76 * @param versions available versions (version string -> manifest) 74 77 * @param distTags dist-tags mapping (e.g., { latest: "1.2.3" }) ··· 80 83 distTags: Record<string, string>, 81 84 range: string, 82 85 ): AbbreviatedManifest | null { 86 + // empty range means latest 87 + if (range === '') { 88 + return versions[distTags.latest] ?? null; 89 + } 90 + 83 91 // check if range is a dist-tag 84 92 if (range in distTags) { 85 93 const taggedVersion = distTags[range]; 86 94 return versions[taggedVersion] ?? null; 87 95 } 88 96 97 + // normalize loose version formats (v1.0.0, = 1.0.0) 98 + const cleanedRange = semver.validRange(range, { loose: true }) ?? range; 99 + 89 100 // check if range is an exact version 90 101 if (versions[range]) { 91 102 return versions[range]; 92 103 } 93 104 94 - // find highest version satisfying the range 105 + // check cleaned version (handles v1.0.0 -> 1.0.0) 106 + const cleanedVersion = semver.clean(range, { loose: true }); 107 + if (cleanedVersion && versions[cleanedVersion]) { 108 + return versions[cleanedVersion]; 109 + } 110 + 111 + // for wildcard ranges, use loose mode to include prereleases 112 + const isWildcard = range === '*' || range === 'x' || range === ''; 113 + const satisfiesOptions = { loose: true, includePrerelease: isWildcard }; 114 + 115 + // prefer 'latest' tag if it satisfies the range (pnpm behavior) 116 + // publishers tag 'latest' intentionally, so respect that choice 117 + const latestVersion = distTags.latest; 118 + if (latestVersion && versions[latestVersion]) { 119 + if (semver.satisfies(latestVersion, cleanedRange, satisfiesOptions)) { 120 + return versions[latestVersion]; 121 + } 122 + } 123 + 124 + // find all versions satisfying the range 95 125 const validVersions = Object.keys(versions) 96 - .filter((v) => semver.satisfies(v, range)) 126 + .filter((v) => semver.satisfies(v, cleanedRange, satisfiesOptions)) 97 127 .sort(semver.rcompare); 98 128 99 129 if (validVersions.length === 0) { 100 130 return null; 101 131 } 102 132 133 + // prefer non-deprecated versions (pnpm behavior) 134 + const nonDeprecated = validVersions.filter((v) => !versions[v].deprecated); 135 + if (nonDeprecated.length > 0) { 136 + return versions[nonDeprecated[0]]; 137 + } 138 + 139 + // fall back to deprecated if no alternatives 103 140 return versions[validVersions[0]]; 104 141 } 105 142