diff --git a/.yarn/versions/b4b98142.yml b/.yarn/versions/b4b98142.yml new file mode 100644 index 000000000000..1da40b7aca3a --- /dev/null +++ b/.yarn/versions/b4b98142.yml @@ -0,0 +1,36 @@ +releases: + "@yarnpkg/cli": minor + "@yarnpkg/core": minor + "@yarnpkg/plugin-essentials": minor + +declined: + - "@yarnpkg/plugin-catalog" + - "@yarnpkg/plugin-compat" + - "@yarnpkg/plugin-constraints" + - "@yarnpkg/plugin-dlx" + - "@yarnpkg/plugin-exec" + - "@yarnpkg/plugin-file" + - "@yarnpkg/plugin-git" + - "@yarnpkg/plugin-github" + - "@yarnpkg/plugin-http" + - "@yarnpkg/plugin-init" + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-jsr" + - "@yarnpkg/plugin-link" + - "@yarnpkg/plugin-nm" + - "@yarnpkg/plugin-npm" + - "@yarnpkg/plugin-npm-cli" + - "@yarnpkg/plugin-pack" + - "@yarnpkg/plugin-patch" + - "@yarnpkg/plugin-pnp" + - "@yarnpkg/plugin-pnpm" + - "@yarnpkg/plugin-stage" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/plugin-version" + - "@yarnpkg/plugin-workspace-tools" + - "@yarnpkg/builder" + - "@yarnpkg/doctor" + - "@yarnpkg/extensions" + - "@yarnpkg/nm" + - "@yarnpkg/pnpify" + - "@yarnpkg/sdks" diff --git a/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/index.js b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/index.js new file mode 100644 index 000000000000..bb9c6f687615 --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/index.js @@ -0,0 +1,7 @@ +module.exports = require(`./package.json`); + +for (const key of [`dependencies`, `devDependencies`, `peerDependencies`]) { + for (const dep of Object.keys(module.exports[key] || {})) { + module.exports[key][dep] = require(dep); + } +} diff --git a/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/package.json b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/package.json new file mode 100644 index 000000000000..0f5d7af59a12 --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "release-date", + "version": "2.0.0", + "dependencies": { + "release-date-transitive": "^2.0.0" + } +} diff --git a/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/index.js b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/index.js new file mode 100644 index 000000000000..bb9c6f687615 --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/index.js @@ -0,0 +1,7 @@ +module.exports = require(`./package.json`); + +for (const key of [`dependencies`, `devDependencies`, `peerDependencies`]) { + for (const dep of Object.keys(module.exports[key] || {})) { + module.exports[key][dep] = require(dep); + } +} diff --git a/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/package.json b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/package.json new file mode 100644 index 000000000000..32ee1aad4607 --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/package.json @@ -0,0 +1,4 @@ +{ + "name": "release-date-transitive", + "version": "2.0.0" +} diff --git a/packages/acceptance-tests/pkg-tests-specs/sources/commands/why.test.ts b/packages/acceptance-tests/pkg-tests-specs/sources/commands/why.test.ts index 974472fa8be7..845a37d82a0c 100644 --- a/packages/acceptance-tests/pkg-tests-specs/sources/commands/why.test.ts +++ b/packages/acceptance-tests/pkg-tests-specs/sources/commands/why.test.ts @@ -207,5 +207,169 @@ describe(`Commands`, () => { }]); }), ); + + describe(`with a specified version`, () => { + test( + `it should list workspaces using a specific version range`, + makeTemporaryEnv({ + workspaces: [`packages/*`], + }, async ({path, run, source}) => { + await fs.writeJson(ppath.join(path, `packages/a/package.json`), { + name: `a`, + dependencies: { + [`b`]: `workspace:^`, + [`c`]: `workspace:^`, + [`no-deps`]: `1.0.0`, + }, + }); + + await fs.writeJson(ppath.join(path, `packages/b/package.json`), { + name: `b`, + dependencies: { + [`no-deps`]: `1.1.0`, + }, + }); + + await fs.writeJson(ppath.join(path, `packages/c/package.json`), { + name: `c`, + dependencies: { + [`no-deps`]: `2.0.0`, + }, + }); + + await run(`install`); + + const {stdout} = await run(`why`, `no-deps@^1.1.0`, `--json`); + + // Don't list v1.0.0 (package A) nor v2.0.0 (package C) + expect(misc.parseJsonStream(stdout)).toEqual([{ + value: `b@workspace:packages/b`, + children: { + [`no-deps@npm:1.1.0`]: { + descriptor: `no-deps@npm:1.1.0`, + locator: `no-deps@npm:1.1.0`, + }, + }, + }]); + })); + + test( + `it should list workspaces transitively using a specific version range`, + makeTemporaryEnv({ + workspaces: [`packages/*`], + }, async ({path, run, source}) => { + await fs.writeJson(ppath.join(path, `packages/a/package.json`), { + name: `a`, + dependencies: { + [`b`]: `workspace:^`, + [`release-date`]: `1.0.0`, + }, + }); + + await fs.writeJson(ppath.join(path, `packages/b/package.json`), { + name: `b`, + dependencies: { + [`c`]: `workspace:^`, + [`release-date`]: `1.1.0`, + }, + }); + + await fs.writeJson(ppath.join(path, `packages/c/package.json`), { + name: `c`, + dependencies: { + [`d`]: `workspace:^`, + [`release-date`]: `1.1.1`, + }, + }); + + await fs.writeJson(ppath.join(path, `packages/d/package.json`), { + name: `d`, + dependencies: { + [`release-date`]: `2.0.0`, + }, + }); + + await run(`install`); + + const {stdout} = await run(`why`, `-R`, `release-date-transitive@^1.1.0`, `--json`); + + expect(stdout).not.toContain(`release-date-transitive@npm:1.0.0`); + expect(stdout).not.toContain(`release-date-transitive@npm:2.0.0`); + + // Don't list v1.0.0 (package A) nor v2.0.0 (package D) + expect(misc.parseJsonStream(stdout)).toEqual([{ + value: `a@workspace:packages/a`, + children: { + [`b@workspace:packages/b`]: { + children: {}, + value: { + descriptor: `b@workspace:^`, + locator: `b@workspace:packages/b`, + }, + }, + [`release-date@npm:1.0.0`]: { + value: { + descriptor: `release-date@npm:1.0.0`, + locator: `release-date@npm:1.0.0`, + }, + children: { + [`release-date-transitive@npm:1.1.1`]: { + children: {}, + value: { + descriptor: `release-date-transitive@npm:^1.0.0`, + locator: `release-date-transitive@npm:1.1.1`, + }, + }, + }, + }, + }, + }, { + value: `b@workspace:packages/b`, + children: { + [`c@workspace:packages/c`]: { + children: {}, + value: { + descriptor: `c@workspace:^`, + locator: `c@workspace:packages/c`, + }, + }, + "release-date@npm:1.1.0": { + children: { + "release-date-transitive@npm:1.1.1": { + children: {}, + value: { + descriptor: `release-date-transitive@npm:^1.0.0`, + locator: `release-date-transitive@npm:1.1.1`, + }, + }, + }, + value: { + descriptor: `release-date@npm:1.1.0`, + locator: `release-date@npm:1.1.0`, + }, + }, + }, + }, { + value: `c@workspace:packages/c`, + children: { + [`release-date@npm:1.1.1`]: { + value: { + descriptor: `release-date@npm:1.1.1`, + locator: `release-date@npm:1.1.1`, + }, + children: { + [`release-date-transitive@npm:1.1.1`]: { + children: {}, + value: { + descriptor: `release-date-transitive@npm:^1.0.0`, + locator: `release-date-transitive@npm:1.1.1`, + }, + }, + }, + }, + }, + }]); + })); + }); }); }); diff --git a/packages/plugin-essentials/sources/commands/why.ts b/packages/plugin-essentials/sources/commands/why.ts index faf1d834f8b0..86dd34ba4a85 100644 --- a/packages/plugin-essentials/sources/commands/why.ts +++ b/packages/plugin-essentials/sources/commands/why.ts @@ -1,6 +1,6 @@ import {BaseCommand, WorkspaceRequiredError} from '@yarnpkg/cli'; import {Configuration, LocatorHash, Package, formatUtils, Descriptor} from '@yarnpkg/core'; -import {IdentHash, Project} from '@yarnpkg/core'; +import {Ident, Project} from '@yarnpkg/core'; import {miscUtils, structUtils, treeUtils} from '@yarnpkg/core'; import {Command, Option, Usage} from 'clipanion'; @@ -13,13 +13,19 @@ export default class WhyCommand extends BaseCommand { static usage: Usage = Command.Usage({ description: `display the reason why a package is needed`, details: ` - This command prints the exact reasons why a package appears in the dependency tree. + This command prints the exact reasons why a package appears in the dependency tree. Specify a version or range to determine why the dependency tree contains a specific version of a package. This is particularly useful when trying to find out why your project depends on lower versions. If \`-R,--recursive\` is set, the listing will go in depth and will list, for each workspaces, what are all the paths that lead to the dependency. Note that the display is somewhat optimized in that it will not print the package listing twice for a single package, so if you see a leaf named "Foo" when looking for "Bar", it means that "Foo" already got printed higher in the tree. `, examples: [[ `Explain why lodash is used in your project`, `$0 why lodash`, + ], [ + `Explain why version 3.3.1 of lodash is in your project`, + `$0 why lodash@3.3.1`, + ], [ + `or why version 3.X of lodash is in your project`, + `$0 why lodash@^3`, ]], }); @@ -46,11 +52,11 @@ export default class WhyCommand extends BaseCommand { await project.restoreInstallState(); - const identHash = structUtils.parseIdent(this.package).identHash; + const descriptor = structUtils.parseDescriptor(this.package, false); const whyTree = this.recursive - ? whyRecursive(project, identHash, {configuration, peers: this.peers}) - : whySimple(project, identHash, {configuration, peers: this.peers}); + ? whyRecursive(project, descriptor, {configuration, peers: this.peers}) + : whySimple(project, descriptor, {configuration, peers: this.peers}); treeUtils.emitTree(whyTree, { configuration, @@ -61,7 +67,11 @@ export default class WhyCommand extends BaseCommand { } } -function whySimple(project: Project, identHash: IdentHash, {configuration, peers}: {configuration: Configuration, peers: boolean}) { +function isSameIdent(pkg: Ident, targetPkg: Ident) { + return pkg.identHash === targetPkg.identHash; +} + +function whySimple(project: Project, targetPkg: Descriptor, {configuration, peers}: {configuration: Configuration, peers: boolean}) { const sortedPackages = miscUtils.sortMap(project.storedPackages.values(), pkg => { return structUtils.stringifyLocator(pkg); }); @@ -85,7 +95,11 @@ function whySimple(project: Project, identHash: IdentHash, {configuration, peers if (!nextPkg) throw new Error(`Assertion failed: The package should have been registered`); - if (nextPkg.identHash !== identHash) + + if (!isSameIdent(nextPkg, targetPkg)) + continue; + + if (!structUtils.isPackageInRange(nextPkg, targetPkg.range)) continue; if (node === null) { @@ -104,7 +118,7 @@ function whySimple(project: Project, identHash: IdentHash, {configuration, peers return root; } -function whyRecursive(project: Project, identHash: IdentHash, {configuration, peers}: {configuration: Configuration, peers: boolean}) { +function whyRecursive(project: Project, targetPkg: Descriptor, {configuration, peers}: {configuration: Configuration, peers: boolean}) { const sortedWorkspaces = miscUtils.sortMap(project.workspaces, workspace => { return structUtils.stringifyLocator(workspace.anchoredLocator); }); @@ -118,16 +132,13 @@ function whyRecursive(project: Project, identHash: IdentHash, {configuration, pe seen.add(pkg.locatorHash); - if (pkg.identHash === identHash) { + if (isSameIdent(pkg, targetPkg)) { dependents.add(pkg.locatorHash); return true; } let depends = false; - if (pkg.identHash === identHash) - depends = true; - for (const dependency of pkg.dependencies.values()) { if (!peers && pkg.peerDependencies.has(dependency.identHash)) continue; @@ -140,6 +151,10 @@ function whyRecursive(project: Project, identHash: IdentHash, {configuration, pe if (!nextPkg) throw new Error(`Assertion failed: The package should have been registered`); + if (isSameIdent(nextPkg, targetPkg) && !structUtils.isPackageInRange(nextPkg, targetPkg.range)) + continue; + + if (markAllDependents(nextPkg)) { depends = true; } @@ -181,6 +196,10 @@ function whyRecursive(project: Project, identHash: IdentHash, {configuration, pe if (dependency !== null && project.tryWorkspaceByLocator(pkg)) return; + // We don't want to print the full path if it doesn't transitively depend on targetPkg.range + if (isSameIdent(pkg, targetPkg) && !structUtils.isPackageInRange(pkg, targetPkg.range)) + return; + // We don't want to reprint the children for a package that already got // printed as part of another branch if (printed.has(pkg.locatorHash)) diff --git a/packages/yarnpkg-core/sources/structUtils.ts b/packages/yarnpkg-core/sources/structUtils.ts index da70cf691dc8..6ccf0cae01ec 100644 --- a/packages/yarnpkg-core/sources/structUtils.ts +++ b/packages/yarnpkg-core/sources/structUtils.ts @@ -381,6 +381,7 @@ export function parseDescriptor(string: string, strict: boolean = false): Descri const DESCRIPTOR_REGEX_STRICT = /^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))$/; const DESCRIPTOR_REGEX_LOOSE = /^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))?$/; +const DESCRIPTOR_RANGE_UNSPECIFIED = `unknown`; /** * Parses a `string` into a descriptor @@ -388,7 +389,7 @@ const DESCRIPTOR_REGEX_LOOSE = /^(?:@([^/]+?)\/)?([^@/]+?)(?:@(.+))?$/; * Returns `null` if the descriptor cannot be parsed. * * @param string The descriptor string (eg. `lodash@^1.0.0`) - * @param strict If `false`, the range is optional (`unknown` will be used as fallback) + * @param strict If `false`, the range is optional ({@link DESCRIPTOR_RANGE_UNSPECIFIED `unknown`} will be used as fallback) */ export function tryParseDescriptor(string: string, strict: boolean = false): Descriptor | null { const match = strict @@ -399,7 +400,7 @@ export function tryParseDescriptor(string: string, strict: boolean = false): Des return null; const [, scope, name, range] = match; - if (range === `unknown`) + if (range === DESCRIPTOR_RANGE_UNSPECIFIED) throw new Error(`Invalid range (${string})`); const realScope = typeof scope !== `undefined` @@ -408,7 +409,7 @@ export function tryParseDescriptor(string: string, strict: boolean = false): Des const realRange = typeof range !== `undefined` ? range - : `unknown`; + : DESCRIPTOR_RANGE_UNSPECIFIED; return makeDescriptor(makeIdent(realScope, name), realRange); } @@ -888,6 +889,28 @@ export function getIdentVendorPath(ident: Ident) { return `node_modules/${stringifyIdent(ident)}` as PortablePath; } +/** + * Returns `true` in the following cases: + * + * - `range` === `'unknown'` + * - `pkg.version` is falsy + * - `pkg.version` satisfies the given `range` + * + * Returns `false` in any other case + * + * @remarks + * + * This function relies on the `range` param to be an actual {@link Descriptor.range range} or fall back to {@link DESCRIPTOR_RANGE_UNSPECIFIED `'unknown'`}. + * + * @see {@link parseDescriptor parseDescriptor's strict} parameter about {@link DESCRIPTOR_RANGE_UNSPECIFIED `'unknown'`} + */ +export function isPackageInRange(pkg: Package, range: Descriptor[`range`]) { + // console.log(`Checking if version is in range`, pkg.version, range); + if (range === DESCRIPTOR_RANGE_UNSPECIFIED || !pkg.version) + return true; + return semver.satisfies(pkg.version ?? ``, range); +} + /** * Returns whether the given package is compatible with the specified environment. */