diff --git a/.changeset/giant-wasps-wink.md b/.changeset/giant-wasps-wink.md new file mode 100644 index 000000000..6c0d2829b --- /dev/null +++ b/.changeset/giant-wasps-wink.md @@ -0,0 +1,5 @@ +--- +"@pnpm/reviewing.dependencies-hierarchy": minor +--- + +The `path` field for direct dependencies returned from `buildDependenciesHierarchy` was incorrect if the dependency used the `workspace:` or `link:` protocols. diff --git a/.changeset/good-monkeys-explode.md b/.changeset/good-monkeys-explode.md new file mode 100644 index 000000000..562942442 --- /dev/null +++ b/.changeset/good-monkeys-explode.md @@ -0,0 +1,6 @@ +--- +"@pnpm/reviewing.dependencies-hierarchy": minor +pnpm: minor +--- + +The `pnpm list` and `pnpm why` commands will now look through transitive dependencies of `workspace:` packages. A new `--only-projects` flag is available to only print `workspace:` packages. diff --git a/__fixtures__/workspace-with-nested-workspace-deps/package.json b/__fixtures__/workspace-with-nested-workspace-deps/package.json new file mode 100644 index 000000000..a4963352b --- /dev/null +++ b/__fixtures__/workspace-with-nested-workspace-deps/package.json @@ -0,0 +1,7 @@ +{ + "name": "root", + "version": "1.0.0", + "dependencies": { + "@scope/a": "workspace:*" + } +} diff --git a/__fixtures__/workspace-with-nested-workspace-deps/packages/a/package.json b/__fixtures__/workspace-with-nested-workspace-deps/packages/a/package.json new file mode 100644 index 000000000..e005e9d21 --- /dev/null +++ b/__fixtures__/workspace-with-nested-workspace-deps/packages/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/a", + "version": "1.0.0", + "private": true, + "dependencies": { + "@scope/b": "workspace:*" + } +} diff --git a/__fixtures__/workspace-with-nested-workspace-deps/packages/b/package.json b/__fixtures__/workspace-with-nested-workspace-deps/packages/b/package.json new file mode 100644 index 000000000..3f872e0c6 --- /dev/null +++ b/__fixtures__/workspace-with-nested-workspace-deps/packages/b/package.json @@ -0,0 +1,9 @@ +{ + "name": "@scope/b", + "version": "1.0.0", + "private": true, + "dependencies": { + "@scope/c": "workspace:*", + "is-positive": "1.0.0" + } +} diff --git a/__fixtures__/workspace-with-nested-workspace-deps/packages/c/package.json b/__fixtures__/workspace-with-nested-workspace-deps/packages/c/package.json new file mode 100644 index 000000000..320e66421 --- /dev/null +++ b/__fixtures__/workspace-with-nested-workspace-deps/packages/c/package.json @@ -0,0 +1,6 @@ +{ + "name": "@scope/c", + "version": "1.0.0", + "private": true, + "dependencies": {} +} diff --git a/__fixtures__/workspace-with-nested-workspace-deps/pnpm-lock.yaml b/__fixtures__/workspace-with-nested-workspace-deps/pnpm-lock.yaml new file mode 100644 index 000000000..b3380e905 --- /dev/null +++ b/__fixtures__/workspace-with-nested-workspace-deps/pnpm-lock.yaml @@ -0,0 +1,33 @@ +lockfileVersion: 5.4 + +importers: + + .: + specifiers: + '@scope/a': workspace:* + dependencies: + '@scope/a': link:packages/a + + packages/a: + specifiers: + '@scope/b': workspace:* + dependencies: + '@scope/b': link:../b + + packages/b: + specifiers: + '@scope/c': workspace:* + is-positive: 1.0.0 + dependencies: + '@scope/c': link:../c + is-positive: 1.0.0 + + packages/c: + specifiers: {} + +packages: + + /is-positive/1.0.0: + resolution: {integrity: sha512-xxzPGZ4P2uN6rROUa5N9Z7zTX6ERuE0hs6GUOc/cKBLF2NqKc16UwqHMt3tFg4CO6EBTE5UecUasg+3jZx3Ckg==} + engines: {node: '>=0.10.0'} + dev: false diff --git a/__fixtures__/workspace-with-nested-workspace-deps/pnpm-workspace.yaml b/__fixtures__/workspace-with-nested-workspace-deps/pnpm-workspace.yaml new file mode 100644 index 000000000..eccc335f9 --- /dev/null +++ b/__fixtures__/workspace-with-nested-workspace-deps/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/**' \ No newline at end of file diff --git a/reviewing/dependencies-hierarchy/src/DependenciesCache.ts b/reviewing/dependencies-hierarchy/src/DependenciesCache.ts index b765f9092..526f84d81 100644 --- a/reviewing/dependencies-hierarchy/src/DependenciesCache.ts +++ b/reviewing/dependencies-hierarchy/src/DependenciesCache.ts @@ -1,7 +1,8 @@ import { PackageNode } from './PackageNode' +import { serializeTreeNodeId, TreeNodeId } from './TreeNodeId' export interface GetDependenciesCacheEntryArgs { - readonly packageAbsolutePath: string + readonly parentId: TreeNodeId readonly requestedDepth: number } @@ -75,18 +76,20 @@ export class DependenciesCache { private readonly fullyVisitedCache = new Map() /** - * Maps packageAbsolutePath -> visitedDepth -> dependencies + * Maps cacheKey -> visitedDepth -> dependencies */ private readonly partiallyVisitedCache = new Map>() public get (args: GetDependenciesCacheEntryArgs): CacheHit | undefined { + const cacheKey = serializeTreeNodeId(args.parentId) + // The fully visited cache is only usable if the height doesn't exceed the // requested depth. Otherwise the final dependencies listing will print // entries with a greater depth than requested. // // If that is the case, the partially visited cache should be checked to see // if dependencies were requested at that exact depth before. - const fullyVisitedEntry = this.fullyVisitedCache.get(args.packageAbsolutePath) + const fullyVisitedEntry = this.fullyVisitedCache.get(cacheKey) if (fullyVisitedEntry !== undefined && fullyVisitedEntry.height <= args.requestedDepth) { return { dependencies: fullyVisitedEntry.dependencies, @@ -95,7 +98,7 @@ export class DependenciesCache { } } - const partiallyVisitedEntry = this.partiallyVisitedCache.get(args.packageAbsolutePath)?.get(args.requestedDepth) + const partiallyVisitedEntry = this.partiallyVisitedCache.get(cacheKey)?.get(args.requestedDepth) if (partiallyVisitedEntry != null) { return { dependencies: partiallyVisitedEntry, @@ -107,14 +110,16 @@ export class DependenciesCache { return undefined } - public addFullyVisitedResult (packageAbsolutePath: string, result: TraversalResultFullyVisited): void { - this.fullyVisitedCache.set(packageAbsolutePath, result) + public addFullyVisitedResult (treeNodeId: TreeNodeId, result: TraversalResultFullyVisited): void { + const cacheKey = serializeTreeNodeId(treeNodeId) + this.fullyVisitedCache.set(cacheKey, result) } - public addPartiallyVisitedResult (packageAbsolutePath: string, result: TraversalResultPartiallyVisited): void { - const dependenciesByDepth = this.partiallyVisitedCache.get(packageAbsolutePath) ?? new Map() - if (!this.partiallyVisitedCache.has(packageAbsolutePath)) { - this.partiallyVisitedCache.set(packageAbsolutePath, dependenciesByDepth) + public addPartiallyVisitedResult (treeNodeId: TreeNodeId, result: TraversalResultPartiallyVisited): void { + const cacheKey = serializeTreeNodeId(treeNodeId) + const dependenciesByDepth = this.partiallyVisitedCache.get(cacheKey) ?? new Map() + if (!this.partiallyVisitedCache.has(cacheKey)) { + this.partiallyVisitedCache.set(cacheKey, dependenciesByDepth) } dependenciesByDepth.set(result.depth, result.dependencies) diff --git a/reviewing/dependencies-hierarchy/src/TreeNodeId.ts b/reviewing/dependencies-hierarchy/src/TreeNodeId.ts new file mode 100644 index 000000000..06317edb4 --- /dev/null +++ b/reviewing/dependencies-hierarchy/src/TreeNodeId.ts @@ -0,0 +1,32 @@ +export type TreeNodeId = TreeNodeIdImporter | TreeNodeIdPackage + +/** + * A project local to the pnpm workspace. + */ +interface TreeNodeIdImporter { + readonly type: 'importer' + readonly importerId: string +} + +/** + * An npm package depended on externally. + */ +interface TreeNodeIdPackage { + readonly type: 'package' + readonly depPath: string +} + +export function serializeTreeNodeId (treeNodeId: TreeNodeId): string { + switch (treeNodeId.type) { + case 'importer': { + // Only serialize known fields from TreeNodeId. TypeScript is duck typed and + // objects can have any number of unknown extra fields. + const { type, importerId } = treeNodeId + return JSON.stringify({ type, importerId }) + } + case 'package': { + const { type, depPath } = treeNodeId + return JSON.stringify({ type, depPath }) + } + } +} diff --git a/reviewing/dependencies-hierarchy/src/buildDependenciesHierarchy.ts b/reviewing/dependencies-hierarchy/src/buildDependenciesHierarchy.ts index 324659eed..7c6731d46 100644 --- a/reviewing/dependencies-hierarchy/src/buildDependenciesHierarchy.ts +++ b/reviewing/dependencies-hierarchy/src/buildDependenciesHierarchy.ts @@ -11,14 +11,15 @@ import { normalizeRegistries } from '@pnpm/normalize-registries' import { readModulesDir } from '@pnpm/read-modules-dir' import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json' import { DependenciesField, DEPENDENCIES_FIELDS, Registries } from '@pnpm/types' -import { refToRelative } from '@pnpm/dependency-path' import normalizePath from 'normalize-path' import realpathMissing from 'realpath-missing' import resolveLinkTarget from 'resolve-link-target' import { PackageNode } from './PackageNode' import { SearchFunction } from './types' import { getTree } from './getTree' +import { getTreeNodeChildId } from './getTreeNodeChildId' import { getPkgInfo } from './getPkgInfo' +import { TreeNodeId } from './TreeNodeId' export interface DependenciesHierarchy { dependencies?: PackageNode[] @@ -33,6 +34,7 @@ export async function buildDependenciesHierarchy ( depth: number include?: { [dependenciesField in DependenciesField]: boolean } registries?: Registries + onlyProjects?: boolean search?: SearchFunction lockfileDir: string } @@ -65,6 +67,7 @@ export async function buildDependenciesHierarchy ( optionalDependencies: true, }, lockfileDir: maybeOpts.lockfileDir, + onlyProjects: maybeOpts.onlyProjects, registries, search: maybeOpts.search, skipped: new Set(modules?.skipped ?? []), @@ -89,6 +92,7 @@ async function dependenciesHierarchyForPackage ( depth: number include: { [dependenciesField in DependenciesField]: boolean } registries: Registries + onlyProjects?: boolean search?: SearchFunction skipped: Set lockfileDir: string @@ -107,8 +111,11 @@ async function dependenciesHierarchyForPackage ( const getChildrenTree = getTree.bind(null, { currentPackages: currentLockfile.packages ?? {}, + importers: currentLockfile.importers, includeOptionalDependencies: opts.include.optionalDependencies, lockfileDir: opts.lockfileDir, + onlyProjects: opts.onlyProjects, + rewriteLinkVersionDir: projectPath, maxDepth: opts.depth, modulesDir, registries: opts.registries, @@ -116,14 +123,17 @@ async function dependenciesHierarchyForPackage ( skipped: opts.skipped, wantedPackages: wantedLockfile.packages ?? {}, }) + const parentId: TreeNodeId = { type: 'importer', importerId } const result: DependenciesHierarchy = {} for (const dependenciesField of DEPENDENCIES_FIELDS.sort().filter(dependenciedField => opts.include[dependenciedField])) { const topDeps = currentLockfile.importers[importerId][dependenciesField] ?? {} result[dependenciesField] = [] Object.entries(topDeps).forEach(([alias, ref]) => { - const { packageInfo, packageAbsolutePath } = getPkgInfo({ + const packageInfo = getPkgInfo({ alias, currentPackages: currentLockfile.packages ?? {}, + rewriteLinkVersionDir: projectPath, + linkedPathBaseDir: projectPath, modulesDir, ref, registries: opts.registries, @@ -132,21 +142,26 @@ async function dependenciesHierarchyForPackage ( }) let newEntry: PackageNode | null = null const matchedSearched = opts.search?.(packageInfo) - if (packageAbsolutePath === null) { + const nodeId = getTreeNodeChildId({ + parentId, + dep: { alias, ref }, + lockfileDir: opts.lockfileDir, + importers: currentLockfile.importers, + }) + if (opts.onlyProjects && nodeId?.type !== 'importer') { + return + } else if (nodeId == null) { if ((opts.search != null) && !matchedSearched) return newEntry = packageInfo } else { - const relativeId = refToRelative(ref, alias) - if (relativeId) { - const dependencies = getChildrenTree([relativeId], relativeId) - if (dependencies.length > 0) { - newEntry = { - ...packageInfo, - dependencies, - } - } else if ((opts.search == null) || matchedSearched) { - newEntry = packageInfo + const dependencies = getChildrenTree(nodeId) + if (dependencies.length > 0) { + newEntry = { + ...packageInfo, + dependencies, } + } else if ((opts.search == null) || matchedSearched) { + newEntry = packageInfo } } if (newEntry != null) { diff --git a/reviewing/dependencies-hierarchy/src/getPkgInfo.ts b/reviewing/dependencies-hierarchy/src/getPkgInfo.ts index 03f40881d..18697cb0c 100644 --- a/reviewing/dependencies-hierarchy/src/getPkgInfo.ts +++ b/reviewing/dependencies-hierarchy/src/getPkgInfo.ts @@ -9,19 +9,34 @@ import { } from '@pnpm/lockfile-utils' import { Registries } from '@pnpm/types' import { depPathToFilename, refToRelative } from '@pnpm/dependency-path' +import normalizePath from 'normalize-path' -export function getPkgInfo ( - opts: { - alias: string - modulesDir: string - ref: string - currentPackages: PackageSnapshots - peers?: Set - registries: Registries - skipped: Set - wantedPackages: PackageSnapshots - } -) { +export interface GetPkgInfoOpts { + readonly alias: string + readonly modulesDir: string + readonly ref: string + readonly currentPackages: PackageSnapshots + readonly peers?: Set + readonly registries: Registries + readonly skipped: Set + readonly wantedPackages: PackageSnapshots + + /** + * The base dir if the `ref` argument is a `"link:"` relative path. + */ + readonly linkedPathBaseDir: string + + /** + * If the `ref` argument is a `"link:"` relative path, the ref is reused for + * the version field. (Since the true semver may not be known.) + * + * Optionally rewrite this relative path to a base dir before writing it to + * version. + */ + readonly rewriteLinkVersionDir?: string +} + +export function getPkgInfo (opts: GetPkgInfoOpts) { let name!: string let version!: string let resolved: string | undefined @@ -57,14 +72,21 @@ export function getPkgInfo ( name = opts.alias version = opts.ref } - const packageAbsolutePath = refToRelative(opts.ref, opts.alias) + const fullPackagePath = depPath + ? path.join(opts.modulesDir, '.pnpm', depPathToFilename(depPath)) + : path.join(opts.linkedPathBaseDir, opts.ref.slice(5)) + + if (version.startsWith('link:') && opts.rewriteLinkVersionDir) { + version = `link:${normalizePath(path.relative(opts.rewriteLinkVersionDir, fullPackagePath))}` + } + const packageInfo = { alias: opts.alias, isMissing, isPeer: Boolean(opts.peers?.has(opts.alias)), isSkipped, name, - path: depPath ? path.join(opts.modulesDir, '.pnpm', depPathToFilename(depPath)) : path.join(opts.modulesDir, '..', opts.ref.slice(5)), + path: fullPackagePath, version, } if (resolved) { @@ -76,8 +98,5 @@ export function getPkgInfo ( if (typeof dev === 'boolean') { packageInfo['dev'] = dev } - return { - packageAbsolutePath, - packageInfo, - } + return packageInfo } diff --git a/reviewing/dependencies-hierarchy/src/getTree.ts b/reviewing/dependencies-hierarchy/src/getTree.ts index 316aeb503..6d908a101 100644 --- a/reviewing/dependencies-hierarchy/src/getTree.ts +++ b/reviewing/dependencies-hierarchy/src/getTree.ts @@ -1,20 +1,24 @@ -import { - PackageSnapshots, -} from '@pnpm/lockfile-file' +import path from 'path' +import { PackageSnapshots, ProjectSnapshot } from '@pnpm/lockfile-file' import { Registries } from '@pnpm/types' -import { refToRelative } from '@pnpm/dependency-path' import { SearchFunction } from './types' import { PackageNode } from './PackageNode' import { getPkgInfo } from './getPkgInfo' +import { getTreeNodeChildId } from './getTreeNodeChildId' import { DependenciesCache } from './DependenciesCache' +import { serializeTreeNodeId, TreeNodeId } from './TreeNodeId' interface GetTreeOpts { maxDepth: number + rewriteLinkVersionDir: string modulesDir: string includeOptionalDependencies: boolean + lockfileDir: string + onlyProjects?: boolean search?: SearchFunction skipped: Set registries: Registries + importers: Record currentPackages: PackageSnapshots wantedPackages: PackageSnapshots } @@ -38,33 +42,43 @@ interface DependencyInfo { export function getTree ( opts: GetTreeOpts, - keypath: string[], - parentId: string + parentId: TreeNodeId ): PackageNode[] { const dependenciesCache = new DependenciesCache() - return getTreeHelper(dependenciesCache, opts, keypath, parentId).dependencies + return getTreeHelper(dependenciesCache, opts, Keypath.initialize(parentId), parentId).dependencies } function getTreeHelper ( dependenciesCache: DependenciesCache, opts: GetTreeOpts, - keypath: string[], - parentId: string + keypath: Keypath, + parentId: TreeNodeId ): DependencyInfo { if (opts.maxDepth <= 0) { return { dependencies: [], height: 'unknown' } } - if (!opts.currentPackages?.[parentId]) { + function getSnapshot (treeNodeId: TreeNodeId) { + switch (treeNodeId.type) { + case 'importer': + return opts.importers[treeNodeId.importerId] + case 'package': + return opts.currentPackages[treeNodeId.depPath] + } + } + + const snapshot = getSnapshot(parentId) + + if (!snapshot) { return { dependencies: [], height: 0 } } const deps = !opts.includeOptionalDependencies - ? opts.currentPackages[parentId].dependencies + ? snapshot.dependencies : { - ...opts.currentPackages[parentId].dependencies, - ...opts.currentPackages[parentId].optionalDependencies, + ...snapshot.dependencies, + ...snapshot.optionalDependencies, } if (deps == null) { @@ -77,16 +91,42 @@ function getTreeHelper ( maxDepth: childTreeMaxDepth, }) - const peers = new Set(Object.keys(opts.currentPackages[parentId].peerDependencies ?? {})) + function getPeerDependencies () { + switch (parentId.type) { + case 'importer': + // Projects in the pnpm workspace can declare peer dependencies, but pnpm + // doesn't record this block to the importers lockfile object. Returning + // undefined for now. + return undefined + case 'package': + return opts.currentPackages[parentId.depPath]?.peerDependencies + } + } + const peers = new Set(Object.keys(getPeerDependencies() ?? {})) + + // If the "ref" of any dependency is a file system path (e.g. link:../), the + // base directory of this relative path depends on whether the dependent + // package is in the pnpm workspace or from node_modules. + function getLinkedPathBaseDir () { + switch (parentId.type) { + case 'importer': + return path.join(opts.lockfileDir, parentId.importerId) + case 'package': + return opts.lockfileDir + } + } + const linkedPathBaseDir = getLinkedPathBaseDir() const resultDependencies: PackageNode[] = [] let resultHeight: number | 'unknown' = 0 let resultCircular: boolean = false Object.entries(deps).forEach(([alias, ref]) => { - const { packageInfo, packageAbsolutePath } = getPkgInfo({ + const packageInfo = getPkgInfo({ alias, currentPackages: opts.currentPackages, + rewriteLinkVersionDir: opts.rewriteLinkVersionDir, + linkedPathBaseDir, modulesDir: opts.modulesDir, peers, ref, @@ -97,7 +137,16 @@ function getTreeHelper ( let circular: boolean const matchedSearched = opts.search?.(packageInfo) let newEntry: PackageNode | null = null - if (packageAbsolutePath === null) { + const nodeId = getTreeNodeChildId({ + parentId, + dep: { alias, ref }, + lockfileDir: opts.lockfileDir, + importers: opts.importers, + }) + + if (opts.onlyProjects && nodeId?.type !== 'importer') { + return + } else if (nodeId == null) { circular = false if (opts.search == null || matchedSearched) { newEntry = packageInfo @@ -105,23 +154,22 @@ function getTreeHelper ( } else { let dependencies: PackageNode[] | undefined - const relativeId = refToRelative(ref, alias) as string // we know for sure that relative is not null if pkgPath is not null - circular = keypath.includes(relativeId) + circular = keypath.includes(nodeId) if (circular) { dependencies = [] } else { - const cacheEntry = dependenciesCache.get({ packageAbsolutePath, requestedDepth: childTreeMaxDepth }) - const children = cacheEntry ?? getChildrenTree(keypath.concat([relativeId]), relativeId) + const cacheEntry = dependenciesCache.get({ parentId: nodeId, requestedDepth: childTreeMaxDepth }) + const children = cacheEntry ?? getChildrenTree(keypath.concat(nodeId), nodeId) if (cacheEntry == null && !children.circular) { if (children.height === 'unknown') { - dependenciesCache.addPartiallyVisitedResult(packageAbsolutePath, { + dependenciesCache.addPartiallyVisitedResult(nodeId, { dependencies: children.dependencies, depth: childTreeMaxDepth, }) } else { - dependenciesCache.addFullyVisitedResult(packageAbsolutePath, { + dependenciesCache.addFullyVisitedResult(nodeId, { dependencies: children.dependencies, height: children.height, }) @@ -171,3 +219,22 @@ function getTreeHelper ( return result } + +/** + * Useful for detecting cycles. + */ +class Keypath { + private constructor (private readonly keypath: readonly string[]) {} + + public static initialize (treeNodeId: TreeNodeId): Keypath { + return new Keypath([serializeTreeNodeId(treeNodeId)]) + } + + public includes (treeNodeId: TreeNodeId): boolean { + return this.keypath.includes(serializeTreeNodeId(treeNodeId)) + } + + public concat (treeNodeId: TreeNodeId): Keypath { + return new Keypath([...this.keypath, serializeTreeNodeId(treeNodeId)]) + } +} diff --git a/reviewing/dependencies-hierarchy/src/getTreeNodeChildId.ts b/reviewing/dependencies-hierarchy/src/getTreeNodeChildId.ts new file mode 100644 index 000000000..9fdccf3b4 --- /dev/null +++ b/reviewing/dependencies-hierarchy/src/getTreeNodeChildId.ts @@ -0,0 +1,50 @@ +import { refToRelative } from '@pnpm/dependency-path' +import path from 'path' +import { getLockfileImporterId, ProjectSnapshot } from '@pnpm/lockfile-file' +import { TreeNodeId } from './TreeNodeId' + +export interface getTreeNodeChildIdOpts { + readonly parentId: TreeNodeId + readonly dep: { + readonly alias: string + readonly ref: string + } + readonly lockfileDir: string + readonly importers: Record +} + +export function getTreeNodeChildId (opts: getTreeNodeChildIdOpts): TreeNodeId | undefined { + const depPath = refToRelative(opts.dep.ref, opts.dep.alias) + if (depPath !== null) { + return { type: 'package', depPath } + } + + switch (opts.parentId.type) { + case 'importer': { + // This should be a link given depPath is null. + // + // TODO: Consider updating refToRelative (or writing a new function) to + // return an enum so there's no implicit assumptions. + const linkValue = opts.dep.ref.slice('link:'.length) + + // It's a bit roundabout to prepend the lockfile dir only to remove it + // through getLockfileImporterId, but we can be more certain the right + // importerId is created by reusing the getLockfileImporterId function. + const absoluteLinkedPath = path.join(opts.lockfileDir, opts.parentId.importerId, linkValue) + const childImporterId = getLockfileImporterId(opts.lockfileDir, absoluteLinkedPath) + + // A 'link:' reference may refer to a package outside of the pnpm workspace. + // Return undefined in that case since it would be difficult to list/traverse + // that package outside of the pnpm workspace. + const isLinkOutsideWorkspace = opts.importers[childImporterId] == null + return isLinkOutsideWorkspace + ? undefined + : { type: 'importer', importerId: childImporterId } + } + case 'package': + // In theory an external package could be overridden to link to a + // dependency in the pnpm workspace. Avoid traversing through this + // edge case for now. + return undefined + } +} diff --git a/reviewing/dependencies-hierarchy/test/getTree.test.ts b/reviewing/dependencies-hierarchy/test/getTree.test.ts index 12da76d27..0f1f1e493 100644 --- a/reviewing/dependencies-hierarchy/test/getTree.test.ts +++ b/reviewing/dependencies-hierarchy/test/getTree.test.ts @@ -2,6 +2,7 @@ import { refToRelative } from '@pnpm/dependency-path' import { PackageSnapshots } from '@pnpm/lockfile-file' import { PackageNode } from '@pnpm/reviewing.dependencies-hierarchy' import { getTree } from '../lib/getTree' +import { TreeNodeId } from '../lib/TreeNodeId' /** * Maps an npm package name to its dependencies. @@ -79,12 +80,15 @@ describe('getTree', () => { b1: ['c1'], c1: ['d1'], }) - const startingDepPath = refToRelativeOrThrow(version, 'a') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'a') } const getTreeArgs = { maxDepth: 0, + rewriteLinkVersionDir: '', modulesDir: '', + importers: {}, includeOptionalDependencies: false, + lockfileDir: '', skipped: new Set(), registries: { default: 'mock-registry-for-testing.example', @@ -94,7 +98,7 @@ describe('getTree', () => { } test('full test case to print when max depth is large', () => { - const result = normalizePackageNodeForTesting(getTree({ ...getTreeArgs, maxDepth: 9999 }, [], startingDepPath)) + const result = normalizePackageNodeForTesting(getTree({ ...getTreeArgs, maxDepth: 9999 }, rootNodeId)) expect(result).toEqual([ expect.objectContaining({ @@ -114,12 +118,12 @@ describe('getTree', () => { }) test('no result when current depth exceeds max depth', () => { - const result = getTree({ ...getTreeArgs, maxDepth: 0 }, [], startingDepPath) + const result = getTree({ ...getTreeArgs, maxDepth: 0 }, rootNodeId) expect(result).toEqual([]) }) test('max depth of 1 to print flat dependencies', () => { - const result = getTree({ ...getTreeArgs, maxDepth: 1 }, [], startingDepPath) + const result = getTree({ ...getTreeArgs, maxDepth: 1 }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ expect.objectContaining({ alias: 'b1', dependencies: undefined }), @@ -129,7 +133,7 @@ describe('getTree', () => { }) test('max depth of 2 to print a1 -> b1 -> c1, but not d1', () => { - const result = getTree({ ...getTreeArgs, maxDepth: 2 }, [], startingDepPath) + const result = getTree({ ...getTreeArgs, maxDepth: 2 }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ expect.objectContaining({ @@ -155,8 +159,11 @@ describe('getTree', () => { // result in incorrect output if the cache was used when it's not supposed to. describe('prints at expected depth for cache regression testing cases', () => { const commonMockGetTreeArgs = { + rewriteLinkVersionDir: '', modulesDir: '', + importers: {}, includeOptionalDependencies: false, + lockfileDir: '', skipped: new Set(), registries: { default: 'mock-registry-for-testing.example', @@ -181,14 +188,14 @@ describe('getTree', () => { inflight: ['once'], once: ['wrappy'], }) - const rootDepPath = refToRelativeOrThrow(version, 'root') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'root') } const result = getTree({ ...commonMockGetTreeArgs, maxDepth: 3, currentPackages, wantedPackages: currentPackages, - }, [rootDepPath], rootDepPath) + }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ // depth 0 @@ -239,14 +246,14 @@ describe('getTree', () => { b: ['c'], d: ['b'], }) - const rootDepPath = refToRelativeOrThrow(version, 'root') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'root') } const result = getTree({ ...commonMockGetTreeArgs, maxDepth: 3, currentPackages, wantedPackages: currentPackages, - }, [rootDepPath], rootDepPath) + }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ expect.objectContaining({ @@ -287,8 +294,11 @@ describe('getTree', () => { // result in incorrect output if the cache was used when it's not supposed to. describe('fully visited cache optimization handles requested depth correctly', () => { const commonMockGetTreeArgs = { + rewriteLinkVersionDir: '', modulesDir: '', + importers: {}, includeOptionalDependencies: false, + lockfileDir: '', skipped: new Set(), registries: { default: 'mock-registry-for-testing.example', @@ -311,14 +321,14 @@ describe('getTree', () => { a: ['b'], b: ['c'], }) - const rootDepPath = refToRelativeOrThrow(version, 'root') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'root') } const result = getTree({ ...commonMockGetTreeArgs, maxDepth: 4, currentPackages, wantedPackages: currentPackages, - }, [rootDepPath], rootDepPath) + }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ expect.objectContaining({ @@ -363,14 +373,14 @@ describe('getTree', () => { c: ['d'], d: ['a'], }) - const rootDepPath = refToRelativeOrThrow(version, 'root') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'root') } const result = getTree({ ...commonMockGetTreeArgs, maxDepth: 4, currentPackages, wantedPackages: currentPackages, - }, [rootDepPath], rootDepPath) + }, rootNodeId) const expectedA = expect.objectContaining({ alias: 'a', @@ -414,14 +424,14 @@ describe('getTree', () => { c: ['d'], d: ['a'], }) - const rootDepPath = refToRelativeOrThrow(version, 'root') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'root') } const result = getTree({ ...commonMockGetTreeArgs, maxDepth: 3, currentPackages, wantedPackages: currentPackages, - }, [rootDepPath], rootDepPath) + }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ expect.objectContaining({ @@ -475,14 +485,14 @@ describe('getTree', () => { f: ['g'], g: ['a'], }) - const rootDepPath = refToRelativeOrThrow(version, 'root') + const rootNodeId: TreeNodeId = { type: 'package', depPath: refToRelativeOrThrow(version, 'root') } const result = getTree({ ...commonMockGetTreeArgs, maxDepth: 5, currentPackages, wantedPackages: currentPackages, - }, [rootDepPath], rootDepPath) + }, rootNodeId) expect(normalizePackageNodeForTesting(result)).toEqual([ expect.objectContaining({ diff --git a/reviewing/dependencies-hierarchy/test/index.ts b/reviewing/dependencies-hierarchy/test/index.ts index b01743703..7ebdc12af 100644 --- a/reviewing/dependencies-hierarchy/test/index.ts +++ b/reviewing/dependencies-hierarchy/test/index.ts @@ -14,6 +14,7 @@ const withLinksOnlyFixture = f.find('fixtureWithLinks/with-links-only') const withUnsavedDepsFixture = f.find('with-unsaved-deps') const fixtureMonorepo = path.join(__dirname, '..', 'fixtureMonorepo') const withAliasedDepFixture = f.find('with-aliased-dep') +const workspaceWithNestedWorkspaceDeps = f.find('workspace-with-nested-workspace-deps') test('one package depth 0', async () => { const tree = await buildDependenciesHierarchy([generalFixture], { depth: 0, lockfileDir: generalFixture }) @@ -381,6 +382,46 @@ test('on a package that has only links', async () => { }) }) +// Test for feature request at https://github.com/pnpm/pnpm/issues/4154 +test('on a package with nested workspace links', async () => { + const tree = await buildDependenciesHierarchy( + [workspaceWithNestedWorkspaceDeps], + { depth: 1000, lockfileDir: workspaceWithNestedWorkspaceDeps } + ) + + expect(tree).toEqual({ + [workspaceWithNestedWorkspaceDeps]: { + dependencies: [ + expect.objectContaining({ + alias: '@scope/a', + version: 'link:packages/a', + path: path.join(workspaceWithNestedWorkspaceDeps, 'packages/a'), + dependencies: [ + expect.objectContaining({ + alias: '@scope/b', + version: 'link:packages/b', + path: path.join(workspaceWithNestedWorkspaceDeps, 'packages/b'), + dependencies: [ + expect.objectContaining({ + alias: '@scope/c', + version: 'link:packages/c', + path: path.join(workspaceWithNestedWorkspaceDeps, 'packages/c'), + }), + expect.objectContaining({ + alias: 'is-positive', + version: '1.0.0', + }), + ], + }), + ], + }), + ], + devDependencies: [], + optionalDependencies: [], + }, + }) +}) + test('unsaved dependencies are listed', async () => { const modulesDir = path.join(withUnsavedDepsFixture, 'node_modules') expect(await buildDependenciesHierarchy([withUnsavedDepsFixture], { depth: 0, lockfileDir: withUnsavedDepsFixture })) diff --git a/reviewing/list/src/index.ts b/reviewing/list/src/index.ts index fb02840af..45cf0fe52 100644 --- a/reviewing/list/src/index.ts +++ b/reviewing/list/src/index.ts @@ -25,6 +25,7 @@ export async function listForPackages ( lockfileDir: string long?: boolean include?: { [dependenciesField in DependenciesField]: boolean } + onlyProjects?: boolean reportAs?: 'parseable' | 'tree' | 'json' registries?: Registries } @@ -38,6 +39,7 @@ export async function listForPackages ( depth: opts.depth, include: maybeOpts?.include, lockfileDir: maybeOpts?.lockfileDir, + onlyProjects: maybeOpts?.onlyProjects, registries: opts.registries, search, })) @@ -71,6 +73,7 @@ export async function list ( lockfileDir: string long?: boolean include?: { [dependenciesField in DependenciesField]: boolean } + onlyProjects?: boolean reportAs?: 'parseable' | 'tree' | 'json' registries?: Registries showExtraneous?: boolean @@ -89,6 +92,7 @@ export async function list ( depth: opts.depth, include: maybeOpts?.include, lockfileDir: maybeOpts?.lockfileDir, + onlyProjects: maybeOpts?.onlyProjects, registries: opts.registries, }) ) diff --git a/reviewing/list/test/index.ts b/reviewing/list/test/index.ts index 383bc9047..c2c2d5c68 100644 --- a/reviewing/list/test/index.ts +++ b/reviewing/list/test/index.ts @@ -796,3 +796,20 @@ ${highlighted(`ajv ${VERSION_CLR('6.10.2')}`)} ajv-keywords ${VERSION_CLR('3.4.1')} └── ${highlighted(`ajv ${VERSION_CLR('6.10.2')} peer`)}`) }) + +test('--only-projects shows only projects', async () => { + const fixture = f.find('workspace-with-nested-workspace-deps') + const output = await list([fixture], { depth: 999, lockfileDir: fixture, onlyProjects: true }) + + // The "workspace-with-nested-workspace-deps" test case has an external + // dependency under @scope/b, but that package should not be printed when + // --only-projects is passed to the list command. + expect(output).toBe(`${LEGEND} + +${boldHighlighted(`root@1.0.0 ${fixture}`)} + +${DEPENDENCIES} +@scope/a ${VERSION_CLR('link:packages/a')} +└─┬ @scope/b ${VERSION_CLR('link:packages/b')} + └── @scope/c ${VERSION_CLR('link:packages/c')}`) +}) diff --git a/reviewing/plugin-commands-listing/src/list.ts b/reviewing/plugin-commands-listing/src/list.ts index ac1f6a335..93df3273e 100644 --- a/reviewing/plugin-commands-listing/src/list.ts +++ b/reviewing/plugin-commands-listing/src/list.ts @@ -24,6 +24,7 @@ export function rcOptionsTypes () { export const cliOptionsTypes = () => ({ ...rcOptionsTypes(), + 'only-projects': Boolean, recursive: Boolean, }) @@ -91,6 +92,10 @@ For options that may be used with `-r`, see "pnpm help recursive"', name: '--dev', shortAlias: '-D', }, + { + description: 'Display only dependencies that are also projects within the workspace', + name: '--only-projects', + }, { description: "Don't display packages from `optionalDependencies`", name: '--no-optional', @@ -121,6 +126,7 @@ export type ListCommandOptions = Pick