Skip to content

Commit

Permalink
feat: traverse through workspace packages in why and list commands (
Browse files Browse the repository at this point in the history
#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>
  • Loading branch information
gluxon and zkochan committed Jan 3, 2023
1 parent 40a4818 commit 395a33a
Show file tree
Hide file tree
Showing 19 changed files with 424 additions and 80 deletions.
5 changes: 5 additions & 0 deletions .changeset/giant-wasps-wink.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/good-monkeys-explode.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "root",
"version": "1.0.0",
"dependencies": {
"@scope/a": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@scope/a",
"version": "1.0.0",
"private": true,
"dependencies": {
"@scope/b": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@scope/b",
"version": "1.0.0",
"private": true,
"dependencies": {
"@scope/c": "workspace:*",
"is-positive": "1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "@scope/c",
"version": "1.0.0",
"private": true,
"dependencies": {}
}
33 changes: 33 additions & 0 deletions __fixtures__/workspace-with-nested-workspace-deps/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
packages:
- 'packages/**'
25 changes: 15 additions & 10 deletions reviewing/dependencies-hierarchy/src/DependenciesCache.ts
Original file line number Diff line number Diff line change
@@ -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
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions reviewing/dependencies-hierarchy/src/TreeNodeId.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
}
41 changes: 28 additions & 13 deletions reviewing/dependencies-hierarchy/src/buildDependenciesHierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -33,6 +34,7 @@ export async function buildDependenciesHierarchy (
depth: number
include?: { [dependenciesField in DependenciesField]: boolean }
registries?: Registries
onlyProjects?: boolean
search?: SearchFunction
lockfileDir: string
}
Expand Down Expand Up @@ -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 ?? []),
Expand All @@ -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
Expand All @@ -107,23 +111,29 @@ 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,
search: opts.search,
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,
Expand All @@ -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) {
Expand Down
55 changes: 37 additions & 18 deletions reviewing/dependencies-hierarchy/src/getPkgInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -76,8 +98,5 @@ export function getPkgInfo (
if (typeof dev === 'boolean') {
packageInfo['dev'] = dev
}
return {
packageAbsolutePath,
packageInfo,
}
return packageInfo
}

0 comments on commit 395a33a

Please sign in to comment.