-
Notifications
You must be signed in to change notification settings - Fork 55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prioritize direct dependency if available #192
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,12 @@ | ||
import * as lockfile from '@yarnpkg/lockfile'; | ||
import semver from 'semver'; | ||
|
||
type PackageJson = { | ||
dependencies?: Record<string, string>, | ||
devDependencies?: Record<string, string>, | ||
optionalDependencies?: Record<string, string> | ||
} | ||
|
||
type YarnEntry = { | ||
resolved: string | ||
version: string | ||
|
@@ -23,20 +29,43 @@ type Package = { | |
|
||
type Version = { | ||
pkg: YarnEntry, | ||
isDirectDependency: boolean, | ||
satisfies: Set<Package> | ||
} | ||
|
||
type Versions = Map<string, Version>; | ||
|
||
export type Strategy = 'highest' | 'fewer' | 'direct'; | ||
|
||
type Options = { | ||
packageJson?: string | null; | ||
includeScopes?: string[]; | ||
includePackages?: string[]; | ||
excludePackages?: string[]; | ||
excludeScopes?: string[]; | ||
useMostCommon?: boolean; | ||
strategy?: Strategy; | ||
includePrerelease?: boolean; | ||
} | ||
|
||
const getDirectDependencies = (file: string | null): Set<string> => { | ||
const result = new Set<string>(); | ||
if (file === null) { | ||
return result; | ||
} | ||
|
||
const packageJson = JSON.parse(file) as PackageJson; | ||
for (const [packageName, requestedVersion] of Object.entries(packageJson.dependencies ?? {})) { | ||
result.add(`${packageName}@${requestedVersion}`); | ||
} | ||
for (const [packageName, requestedVersion] of Object.entries(packageJson.devDependencies ?? {})) { | ||
result.add(`${packageName}@${requestedVersion}`); | ||
} | ||
for (const [packageName, requestedVersion] of Object.entries(packageJson.optionalDependencies ?? {})) { | ||
result.add(`${packageName}@${requestedVersion}`); | ||
} | ||
return result; | ||
} | ||
|
||
const parseYarnLock = (file:string) => lockfile.parse(file).object as YarnEntries; | ||
|
||
const extractPackages = ( | ||
|
@@ -100,18 +129,32 @@ const extractPackages = ( | |
return packages; | ||
}; | ||
|
||
const computePackageInstances = (packages: Packages, name: string, useMostCommon: boolean, includePrerelease = false): Package[] => { | ||
const computePackageInstances = ( | ||
packages: Packages, | ||
name: string, | ||
strategy: Strategy, | ||
directDependencies: Set<string>, | ||
includePrerelease = false, | ||
): Package[] => { | ||
// Instances of this package in the tree | ||
const packageInstances = packages[name]; | ||
|
||
// Extract the list of unique versions for this package | ||
const versions:Versions = new Map(); | ||
for (const packageInstance of packageInstances) { | ||
if (versions.has(packageInstance.installedVersion)) continue; | ||
versions.set(packageInstance.installedVersion, { | ||
pkg: packageInstance.pkg, | ||
satisfies: new Set(), | ||
}) | ||
// Mark candidates which have at least one requested version matching a | ||
// direct dependency as direct | ||
const isDirectDependency = directDependencies.has(`${name}@${packageInstance.requestedVersion}`); | ||
if (versions.has(packageInstance.installedVersion)) { | ||
const existingPackage = versions.get(packageInstance.installedVersion)!; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't get why we need to add Wouldn't checking There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've moved this down here so only |
||
existingPackage.isDirectDependency ||= isDirectDependency; | ||
} else { | ||
versions.set(packageInstance.installedVersion, { | ||
pkg: packageInstance.pkg, | ||
satisfies: new Set(), | ||
isDirectDependency, | ||
}); | ||
} | ||
} | ||
|
||
// Link each package instance with all the versions it could satisfy. | ||
|
@@ -139,7 +182,15 @@ const computePackageInstances = (packages: Packages, name: string, useMostCommon | |
// Compute the versions that actually satisfy this instance | ||
packageInstance.candidateVersions = Array.from(packageInstance.satisfiedBy); | ||
packageInstance.candidateVersions.sort((versionA:string, versionB:string) => { | ||
if (useMostCommon) { | ||
if (strategy === 'direct') { | ||
// Sort versions that are specified in package.json first. In | ||
// case of a tie, use the highest version. | ||
const isDirectA = versions.get(versionA)!.isDirectDependency; | ||
const isDirectB = versions.get(versionB)!.isDirectDependency; | ||
if (isDirectA && !isDirectB) return -1; | ||
if (!isDirectB && isDirectA) return 1; | ||
} | ||
if (strategy === 'fewer') { | ||
// Sort verions based on how many packages it satisfies. In case of a tie, put the | ||
// highest version first. | ||
const satisfiesA = (versions.get(versionA) as Version).satisfies; | ||
|
@@ -160,11 +211,12 @@ const computePackageInstances = (packages: Packages, name: string, useMostCommon | |
export const getDuplicates = ( | ||
yarnEntries: YarnEntries, | ||
{ | ||
packageJson = null, | ||
includeScopes = [], | ||
includePackages = [], | ||
excludePackages = [], | ||
excludeScopes = [], | ||
useMostCommon = false, | ||
strategy = 'highest', | ||
includePrerelease = false, | ||
}: Options = {} | ||
): Package[] => { | ||
|
@@ -176,11 +228,13 @@ export const getDuplicates = ( | |
excludeScopes | ||
); | ||
|
||
const directDependencies = getDirectDependencies(packageJson); | ||
|
||
return Object.keys(packages) | ||
.reduce( | ||
(acc:Package[], name) => | ||
acc.concat( | ||
computePackageInstances(packages, name, useMostCommon, includePrerelease) | ||
computePackageInstances(packages, name, strategy, directDependencies, includePrerelease) | ||
), | ||
[] | ||
) | ||
|
@@ -206,4 +260,3 @@ export const fixDuplicates = ( yarnLock: string, options: Options = {} ) => { | |
|
||
return lockfile.stringify(json); | ||
}; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,7 +44,7 @@ test('dedupes lockfile to most common compatible version', () => { | |
resolved "https://example.net/library@^2.1.0" | ||
`; | ||
const deduped = fixDuplicates(yarn_lock, { | ||
useMostCommon: true, | ||
strategy: 'fewer', | ||
}); | ||
const json = lockfile.parse(deduped).object; | ||
|
||
|
@@ -53,7 +53,7 @@ test('dedupes lockfile to most common compatible version', () => { | |
expect(json['library@^2.0.0']['version']).toEqual('2.1.0'); | ||
|
||
const list = listDuplicates(yarn_lock, { | ||
useMostCommon: true, | ||
strategy: 'fewer', | ||
}); | ||
|
||
expect(list).toContain('Package "library" wants >=1.0.0 and could get 2.1.0, but got 3.0.0'); | ||
|
@@ -272,3 +272,45 @@ test('should support the integrity field if present', () => { | |
// We should not have made any change to the order of outputted lines (@yarnpkg/lockfile 1.0.0 had this bug) | ||
expect(yarn_lock).toBe(deduped); | ||
}); | ||
|
||
test('prioritizes direct requirements if present', () => { | ||
const yarn_lock = outdent` | ||
a-package@*: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally (and I acknowledge this is slightly out of the scope of the PR), we should use a similar |
||
version "2.0.0" | ||
resolved "http://example.com/a-package/2.0.0" | ||
|
||
a-package@^1.0.0, a-package@^1.0.1, a-package@^1.0.2: | ||
version "1.0.2" | ||
resolved "http://example.com/a-package/1.0.2" | ||
|
||
a-package@^0.1.0: | ||
version "0.1.0" | ||
resolved "http://example.com/a-package/0.1.0" | ||
|
||
other-package@>=1.0.0: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should remove any mention to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is actually important as it verifies that packages that are not in package.json fall back to the |
||
version "2.0.0" | ||
resolved "http://example.com/other-package/2.0.0" | ||
|
||
other-package@^1.0.0: | ||
version "1.0.12" | ||
resolved "http://example.com/other-package/1.0.12" | ||
`; | ||
const package_json = outdent` | ||
{ | ||
"dependencies": { | ||
"a-package": "^1.0.1" | ||
} | ||
} | ||
`; | ||
|
||
const deduped = fixDuplicates(yarn_lock, { | ||
strategy: 'direct', | ||
packageJson: package_json, | ||
}); | ||
const json = lockfile.parse(deduped).object; | ||
expect(json['a-package@*']['version']).toEqual('1.0.2'); | ||
expect(json['a-package@^1.0.0']['version']).toEqual('1.0.2'); | ||
expect(json['a-package@^0.1.0']['version']).toEqual('0.1.0'); | ||
expect(json['other-package@>=1.0.0']['version']).toEqual('2.0.0'); | ||
expect(json['other-package@^1.0.0']['version']).toEqual('1.0.12'); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
commander already shows the default value so I removed
Default is "highest"
as it was redundant.