From 56f6ec734abf2740fd552f56cc26661480d250e5 Mon Sep 17 00:00:00 2001 From: Remy Parzinski Date: Wed, 26 Nov 2025 16:39:09 +0100 Subject: [PATCH 1/2] feat(why): allow specifying a version or range Signed-off-by: Remy Parzinski --- .../packages/release-date-2.0.0/index.js | 7 + .../packages/release-date-2.0.0/package.json | 7 + .../release-date-transitive-2.0.0/index.js | 7 + .../package.json | 4 + .../sources/commands/why.test.ts | 164 ++++++++++++++++++ .../plugin-essentials/sources/commands/why.ts | 43 +++-- packages/yarnpkg-core/sources/structUtils.ts | 29 +++- 7 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/index.js create mode 100644 packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-2.0.0/package.json create mode 100644 packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/index.js create mode 100644 packages/acceptance-tests/pkg-tests-fixtures/packages/release-date-transitive-2.0.0/package.json 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. */ From 7855f006753e1cc9d1bf6108a047e241a7dd34cc Mon Sep 17 00:00:00 2001 From: Remy Parzinski Date: Wed, 26 Nov 2025 16:59:02 +0100 Subject: [PATCH 2/2] chore: bump versions Signed-off-by: Remy Parzinski --- .yarn/versions/b4b98142.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .yarn/versions/b4b98142.yml 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"