feat: traverse through workspace packages in `why` and `list` commands (#5863)

* refactor(dependencies-hierarchy): remove keypath argument from getTree

The `keypath` argument is an internal implementation detail of `getTree`
used to detect cycles in the package graph. Removing this from the call
signature of `getTree` since it exposes an implementation detail.

The start of a `getTree` call always passed in the starting node as the
initial value anyway.

```ts
const getChildrenTree = getTree.bind(null, { ... })
getChildrenTree([relativeId], relativeId)
```

It's simpler for that to happen in the first call to `getTreeHelper`
internally and better ensures the keypath is created correctly. A future
refactor makes construction of the keypath more involved.

* refactor(dependencies-hierarchy): remove refToRelative call in getPkgInfo

This removes an extra `refToRelative` call in `getPkgInfo`. The result
of this call wasn't used within the function and was simply passed back
to the caller.

Callers of `getPkgInfo` were checking the result of `refToRelative`,
from `getPkgInfo`'s return object only to call `refToRelative` again.
Calling `refToRelative` directly simplifies code a bit. We can remove an
unnecessary cast and an if statement.

* refactor(dependencies-hierarchy): create enum for getTree nodes

* feature(dependencies-hierarchy): traverse through workspace packages

This updates `pnpm list` and `pnpm why` to traverse through `link:`
packages by simply. This is done by simply implementing a new TreeNodeId
enum variant.

* test(dependencies-hierarchy): test transitive workspace package listing

* refactor(dependencies-hierarchy): create interface for GetPkgInfoOpts

A future commit adds new fields to `getPkgInfo`'s options. The dedicated
interface makes it easier to describe these new options with a JSDoc.

* fix(dependencies-hierarchy): fix path for link: deps in projects

This was a bug before the changes in this pull request. The bug was not
user facing since `pnpm list --json` doesn't print this computed path.

* fix(dependencies-hierarchy): print version paths rel to starting project

* feat(list): add --only-projects flag

* refactor: change description of --only-projects

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Brandon Cheng 2023-01-03 08:28:20 -05:00 committed by GitHub
parent 40a4818405
commit 395a33a50c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 424 additions and 80 deletions

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,7 @@
{
"name": "root",
"version": "1.0.0",
"dependencies": {
"@scope/a": "workspace:*"
}
}

View File

@ -0,0 +1,8 @@
{
"name": "@scope/a",
"version": "1.0.0",
"private": true,
"dependencies": {
"@scope/b": "workspace:*"
}
}

View File

@ -0,0 +1,9 @@
{
"name": "@scope/b",
"version": "1.0.0",
"private": true,
"dependencies": {
"@scope/c": "workspace:*",
"is-positive": "1.0.0"
}
}

View File

@ -0,0 +1,6 @@
{
"name": "@scope/c",
"version": "1.0.0",
"private": true,
"dependencies": {}
}

View File

@ -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

View File

@ -0,0 +1,2 @@
packages:
- 'packages/**'

View File

@ -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<string, TraversalResultFullyVisited>()
/**
* Maps packageAbsolutePath -> visitedDepth -> dependencies
* Maps cacheKey -> visitedDepth -> dependencies
*/
private readonly partiallyVisitedCache = new Map<string, Map<number, PackageNode[]>>()
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)

View File

@ -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 })
}
}
}

View File

@ -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<string>
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) {

View File

@ -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<string>
registries: Registries
skipped: Set<string>
wantedPackages: PackageSnapshots
}
) {
export interface GetPkgInfoOpts {
readonly alias: string
readonly modulesDir: string
readonly ref: string
readonly currentPackages: PackageSnapshots
readonly peers?: Set<string>
readonly registries: Registries
readonly skipped: Set<string>
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
}

View File

@ -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<string>
registries: Registries
importers: Record<string, ProjectSnapshot>
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)])
}
}

View File

@ -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<string, ProjectSnapshot>
}
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
}
}

View File

@ -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<string>(),
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<string>(),
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<string>(),
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({

View File

@ -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 }))

View File

@ -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,
})
)

View File

@ -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')}`)
})

View File

@ -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<Config,
lockfileDir?: string
long?: boolean
parseable?: boolean
onlyProjects?: boolean
recursive?: boolean
}
@ -156,6 +162,7 @@ export async function render (
lockfileDir: string
long?: boolean
json?: boolean
onlyProjects?: boolean
parseable?: boolean
}
) {
@ -165,6 +172,7 @@ export async function render (
include: opts.include,
lockfileDir: opts.lockfileDir,
long: opts.long,
onlyProjects: opts.onlyProjects,
reportAs: (opts.parseable ? 'parseable' : (opts.json ? 'json' : 'tree')) as ('parseable' | 'json' | 'tree'),
showExtraneous: false,
}