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:
parent
40a4818405
commit
395a33a50c
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "root",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@scope/a": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@scope/a",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@scope/b": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@scope/b",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@scope/c": "workspace:*",
|
||||
"is-positive": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@scope/c",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
packages:
|
||||
- 'packages/**'
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')}`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue