From dddcc9727be3a5b3c4780124cd4da0b7e0e0c15f Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 31 Oct 2026 20:58:30 +0000 Subject: [PATCH 1/5] fix: correctly handle distinct version requests in `resolveDependencyTree` --- server/utils/dependency-resolver.ts | 48 +++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts index 15c8331a36..1e6cc534f4 100644 --- a/server/utils/dependency-resolver.ts +++ b/server/utils/dependency-resolver.ts @@ -117,6 +117,25 @@ export interface ResolvedPackage { deprecated?: string } +type DependencyRequest = { + name: string + range: string + optional: boolean + path: string[] +} + +function requestKey(name: string, range: string): string { + // Match the alias-range extraction used by resolveVersion. + if (range.startsWith('npm:')) { + const atIndex = range.lastIndexOf('@') + if (atIndex > 4) { + range = range.slice(atIndex + 1) + } + } + + return JSON.stringify([name, range]) +} + /** * Resolve the entire dependency tree for a package. * Uses level-by-level BFS to ensure correct depth assignment when trackDepth is enabled. @@ -131,13 +150,16 @@ export async function resolveDependencyTree( // Process level by level for correct depth tracking // Each entry includes the path of package names leading to this dependency - let currentLevel = new Map([ - [rootName, { range: rootVersion, optional: false, path: [] }], + let currentLevel = new Map([ + [ + requestKey(rootName, rootVersion), + { name: rootName, range: rootVersion, optional: false, path: [] }, + ], ]) let level = 0 while (currentLevel.size > 0) { - const nextLevel = new Map() + const nextLevel = new Map() // Mark all packages in current level as seen before processing for (const name of currentLevel.keys()) { @@ -145,10 +167,10 @@ export async function resolveDependencyTree( } // Process current level with concurrency limit - const entries = [...currentLevel.entries()] + const entries = [...currentLevel.values()] await mapWithConcurrency( entries, - async ([name, { range, optional, path }]) => { + async ({ name, range, optional, path }) => { const packument = await fetchPackument(name) if (!packument) return @@ -202,8 +224,14 @@ export async function resolveDependencyTree( if (versionData.dependencies) { for (const [depKey, depRange] of Object.entries(versionData.dependencies)) { const depName = parseDependencyVersion(depRange).name ?? depKey - if (!seen.has(depName) && !nextLevel.has(depName)) { - nextLevel.set(depName, { range: depRange, optional: false, path: currentPath }) + const reqKey = requestKey(depName, depRange) + const queued = nextLevel.get(reqKey) + + if (queued) { + // A required request upgrades a queued optional request. + queued.optional = false + } else if (!seen.has(reqKey)) { + nextLevel.set(reqKey, { name: depName, range: depRange, optional: false, path: currentPath }) } } } @@ -212,8 +240,10 @@ export async function resolveDependencyTree( if (versionData.optionalDependencies) { for (const [depKey, depRange] of Object.entries(versionData.optionalDependencies)) { const depName = parseDependencyVersion(depRange).name ?? depKey - if (!seen.has(depName) && !nextLevel.has(depName)) { - nextLevel.set(depName, { range: depRange, optional: true, path: currentPath }) + const reqKey = requestKey(depName, depRange) + + if (!seen.has(reqKey) && !nextLevel.has(reqKey)) { + nextLevel.set(reqKey, { name: depName, range: depRange, optional: true, path: currentPath }) } } } From a7da224d14c64771851e83f005c3b783bc0b522d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:01:32 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- server/utils/dependency-resolver.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts index 1e6cc534f4..becd30f824 100644 --- a/server/utils/dependency-resolver.ts +++ b/server/utils/dependency-resolver.ts @@ -231,7 +231,12 @@ export async function resolveDependencyTree( // A required request upgrades a queued optional request. queued.optional = false } else if (!seen.has(reqKey)) { - nextLevel.set(reqKey, { name: depName, range: depRange, optional: false, path: currentPath }) + nextLevel.set(reqKey, { + name: depName, + range: depRange, + optional: false, + path: currentPath, + }) } } } @@ -243,7 +248,12 @@ export async function resolveDependencyTree( const reqKey = requestKey(depName, depRange) if (!seen.has(reqKey) && !nextLevel.has(reqKey)) { - nextLevel.set(reqKey, { name: depName, range: depRange, optional: true, path: currentPath }) + nextLevel.set(reqKey, { + name: depName, + range: depRange, + optional: true, + path: currentPath, + }) } } } From f0acb62e31137bf1ddd925ad64904bde567a88c4 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 31 Oct 2026 21:44:38 +0000 Subject: [PATCH 3/5] fix: address review --- server/utils/dependency-resolver.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts index becd30f824..fc4e149ef8 100644 --- a/server/utils/dependency-resolver.ts +++ b/server/utils/dependency-resolver.ts @@ -205,9 +205,12 @@ export async function resolveDependencyTree( const key = `${name}@${version}` // Build path for this package (path to parent + this package with version) - const currentPath = [...path, `${name}@${version}`] + const currentPath = [...path, key] - if (!resolved.has(key)) { + const existing = resolved.get(key) + if (existing) { + if (!optional) existing.optional = false + } else { const pkg: ResolvedPackage = { name, version, size, tarballUrl, optional } if (options.trackDepth) { pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive' From f69b2079866493b2f3dc52d04ffbf3779fb72507 Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:13:06 +0100 Subject: [PATCH 4/5] test: add regression tests --- server/utils/dependency-resolver.ts | 4 +- .../server/utils/dependency-resolver.spec.ts | 106 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts index fc4e149ef8..2744642520 100644 --- a/server/utils/dependency-resolver.ts +++ b/server/utils/dependency-resolver.ts @@ -162,8 +162,8 @@ export async function resolveDependencyTree( const nextLevel = new Map() // Mark all packages in current level as seen before processing - for (const name of currentLevel.keys()) { - seen.add(name) + for (const key of currentLevel.keys()) { + seen.add(key) } // Process current level with concurrency limit diff --git a/test/unit/server/utils/dependency-resolver.spec.ts b/test/unit/server/utils/dependency-resolver.spec.ts index e59b5c17a4..86857f97d3 100644 --- a/test/unit/server/utils/dependency-resolver.spec.ts +++ b/test/unit/server/utils/dependency-resolver.spec.ts @@ -538,5 +538,111 @@ describe('dependency-resolver', () => { expect(result.size).toBe(4) expect(result.has('shared@1.0.0')).toBe(true) }) + + it('keeps distinct versions requested through aliases of the same package', async () => { + mockAbbreviatedPackument.mockImplementation(async (name: string) => { + if (name === 'root') + return makePackument('root', [ + { + version: '1.0.0', + deps: { 'shared-v1': 'npm:shared@^1.0.0', 'shared-v2': 'npm:shared@^2.0.0' }, + }, + ]) + if (name === 'shared') + return makePackument('shared', [ + { version: '1.0.0', deps: { 'only-v1': '^1.0.0' } }, + { version: '2.0.0', deps: { 'only-v2': '^1.0.0' } }, + ]) + if (name === 'only-v1') return makePackument('only-v1', [{ version: '1.0.0' }]) + if (name === 'only-v2') return makePackument('only-v2', [{ version: '1.0.0' }]) + return null + }) + + const result = await resolveDependencyTree('root', '1.0.0') + + expect([...result.keys()].sort()).toEqual([ + 'only-v1@1.0.0', + 'only-v2@1.0.0', + 'root@1.0.0', + 'shared@1.0.0', + 'shared@2.0.0', + ]) + }) + + it('resolves a different version of a package that was already visited', async () => { + // root → shared@^1 and root → a → shared@^2 + mockAbbreviatedPackument.mockImplementation(async (name: string) => { + if (name === 'root') + return makePackument('root', [{ version: '1.0.0', deps: { shared: '^1.0.0', a: '^1.0.0' } }]) + if (name === 'a') + return makePackument('a', [{ version: '1.0.0', deps: { shared: '^2.0.0' } }]) + if (name === 'shared') + return makePackument('shared', [{ version: '1.0.0' }, { version: '2.0.0' }]) + return null + }) + + const result = await resolveDependencyTree('root', '1.0.0', { trackDepth: true }) + + expect(result.get('shared@1.0.0')).toMatchObject({ depth: 'direct' }) + expect(result.get('shared@2.0.0')).toMatchObject({ + depth: 'transitive', + path: ['root@1.0.0', 'a@1.0.0', 'shared@2.0.0'], + }) + }) + + it('keeps distinct versions requested as optional dependencies', async () => { + mockAbbreviatedPackument.mockImplementation(async (name: string) => { + if (name === 'root') + return makePackument('root', [ + { version: '1.0.0', deps: { a: '^1.0.0' }, optionalDeps: { opt: '^1.0.0' } }, + ]) + if (name === 'a') + return makePackument('a', [{ version: '1.0.0', optionalDeps: { opt: '^2.0.0' } }]) + if (name === 'opt') return makePackument('opt', [{ version: '1.0.0' }, { version: '2.0.0' }]) + return null + }) + + const result = await resolveDependencyTree('root', '1.0.0') + + expect(result.get('opt@1.0.0')).toMatchObject({ optional: true }) + expect(result.get('opt@2.0.0')).toMatchObject({ optional: true }) + }) + + it('resolves different ranges that match the same version once', async () => { + mockAbbreviatedPackument.mockImplementation(async (name: string) => { + if (name === 'root') + return makePackument('root', [{ version: '1.0.0', deps: { a: '^1.0.0', b: '^1.0.0' } }]) + if (name === 'a') + return makePackument('a', [{ version: '1.0.0', deps: { shared: '^1.0.0' } }]) + if (name === 'b') + return makePackument('b', [{ version: '1.0.0', deps: { shared: '~1.2.0' } }]) + if (name === 'shared') return makePackument('shared', [{ version: '1.2.3' }]) + return null + }) + + const result = await resolveDependencyTree('root', '1.0.0') + + expect([...result.keys()].sort()).toEqual([ + 'a@1.0.0', + 'b@1.0.0', + 'root@1.0.0', + 'shared@1.2.3', + ]) + }) + + it('terminates cycles between different versions of the same package', async () => { + mockAbbreviatedPackument.mockImplementation(async (name: string) => { + if (name === 'a') + return makePackument('a', [ + { version: '1.0.0', deps: { a: '^2.0.0' } }, + { version: '2.0.0', deps: { a: '^1.0.0' } }, + ]) + return null + }) + + const result = await resolveDependencyTree('a', '1.0.0') + + expect([...result.keys()].sort()).toEqual(['a@1.0.0', 'a@2.0.0']) + }) }) }) From dec7bda2f21a3ae17446242b32bace4c9dd3a2e1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:21:57 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- test/unit/server/utils/dependency-resolver.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/server/utils/dependency-resolver.spec.ts b/test/unit/server/utils/dependency-resolver.spec.ts index 86857f97d3..ac9b4e3fd1 100644 --- a/test/unit/server/utils/dependency-resolver.spec.ts +++ b/test/unit/server/utils/dependency-resolver.spec.ts @@ -573,7 +573,9 @@ describe('dependency-resolver', () => { // root → shared@^1 and root → a → shared@^2 mockAbbreviatedPackument.mockImplementation(async (name: string) => { if (name === 'root') - return makePackument('root', [{ version: '1.0.0', deps: { shared: '^1.0.0', a: '^1.0.0' } }]) + return makePackument('root', [ + { version: '1.0.0', deps: { shared: '^1.0.0', a: '^1.0.0' } }, + ]) if (name === 'a') return makePackument('a', [{ version: '1.0.0', deps: { shared: '^2.0.0' } }]) if (name === 'shared') @@ -598,7 +600,8 @@ describe('dependency-resolver', () => { ]) if (name === 'a') return makePackument('a', [{ version: '1.0.0', optionalDeps: { opt: '^2.0.0' } }]) - if (name === 'opt') return makePackument('opt', [{ version: '1.0.0' }, { version: '2.0.0' }]) + if (name === 'opt') + return makePackument('opt', [{ version: '1.0.0' }, { version: '2.0.0' }]) return null })