Skip to content

Commit 58d6120

Browse files
MLSTRMMLSTRM
authored and
MLSTRM
committed
feat: Add support for multiple/nested workspace traversal under new recursive flag
Signed-off-by: MLSTRM <[email protected]>
1 parent a70f74e commit 58d6120

File tree

4 files changed

+78
-65
lines changed

4 files changed

+78
-65
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ $ yarn CycloneDX make-sbom
7070
(choices: "application", "framework", "library", "container", "platform", "device-driver", default: "application")
7171
--reproducible Whether to go the extra mile and make the output reproducible.
7272
This might result in loss of time- and random-based values.
73+
--recursive Scan all nested workspaces within the current project, rather than just the one in the current working directory.
7374
7475
━━━ Details ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7576

sources/index.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
Configuration,
2525
type Plugin,
2626
Project,
27-
ThrowReport
27+
ThrowReport,
28+
type Workspace
2829
} from '@yarnpkg/core'
2930
import { type PortablePath, ppath } from '@yarnpkg/fslib'
3031
import { Command, Option } from 'clipanion'
@@ -73,6 +74,10 @@ class SBOMCommand extends BaseCommand {
7374
description: 'Whether to go the extra mile and make the output reproducible.\nThis might result in loss of time- and random-based values.'
7475
})
7576

77+
recursive = Option.Boolean('--recursive', false, {
78+
description: 'Resolve dependencies from all nested workspaces within the current one.'
79+
})
80+
7681
async execute (): Promise<void> {
7782
const configuration = await Configuration.find(
7883
this.context.cwd,
@@ -86,6 +91,9 @@ class SBOMCommand extends BaseCommand {
8691

8792
if (this.production) {
8893
workspace.manifest.devDependencies.clear()
94+
if (this.recursive) {
95+
project.workspaces.forEach((w: Workspace) => { w.manifest.devDependencies.clear() })
96+
}
8997
const cache = await Cache.find(project.configuration)
9098
await project.resolveEverything({ report: new ThrowReport(), cache })
9199
} else {
@@ -97,14 +105,15 @@ class SBOMCommand extends BaseCommand {
97105
outputFormat: parseOutputFormat(this.outputFormat),
98106
outputFile: parseOutputFile(workspace.cwd, this.outputFile),
99107
componentType: parseComponenttype(this.componentType),
100-
reproducible: this.reproducible
108+
reproducible: this.reproducible,
109+
recursive: this.recursive
101110
})
102111
}
103112
}
104113

105114
function parseSpecVersion (
106115
specVersion: string | undefined
107-
): OutputOptions['specVersion'] {
116+
): OutputOptions[ 'specVersion' ] {
108117
if (specVersion === undefined) {
109118
return CDX.Spec.Version.v1dot5
110119
}
@@ -119,7 +128,7 @@ function parseSpecVersion (
119128

120129
function parseOutputFormat (
121130
outputFormat: string | undefined
122-
): OutputOptions['outputFormat'] {
131+
): OutputOptions[ 'outputFormat' ] {
123132
if (outputFormat === undefined) {
124133
return CDX.Spec.Format.JSON
125134
}
@@ -136,7 +145,7 @@ function parseOutputFormat (
136145
function parseOutputFile (
137146
cwd: PortablePath,
138147
outputFile: string | undefined
139-
): OutputOptions['outputFile'] {
148+
): OutputOptions[ 'outputFile' ] {
140149
if (outputFile === undefined || outputFile === '-') {
141150
return stdOutOutput
142151
} else {

sources/sbom.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { PackageURL } from 'packageurl-js'
3232
import {
3333
type BuildtimeDependencies,
3434
type PackageInfo,
35-
traverseWorkspace
35+
traverseWorkspaces
3636
} from './traverseUtils'
3737

3838
const licenseFactory = new CDX.Factories.LicenseFactory()
@@ -55,6 +55,7 @@ export interface OutputOptions {
5555
outputFile: PortablePath | typeof stdOutOutput
5656
componentType: CDX.Enums.ComponentType
5757
reproducible: boolean
58+
recursive: boolean
5859
}
5960

6061
export async function generateSBOM (
@@ -74,9 +75,9 @@ export async function generateSBOM (
7475
bom.metadata.timestamp = new Date()
7576
}
7677

77-
const allDependencies = await traverseWorkspace(
78+
const allDependencies = await traverseWorkspaces(
7879
project,
79-
workspace,
80+
outputOptions.recursive ? project.workspaces : [workspace],
8081
config
8182
)
8283
const componentModels = new Map<LocatorHash, CDX.Models.Component>()
@@ -153,9 +154,9 @@ async function addMetadataTools (bom: CDX.Models.Bom): Promise<void> {
153154
*/
154155
function serialize (
155156
bom: CDX.Models.Bom,
156-
specVersion: OutputOptions['specVersion'],
157-
outputFormat: OutputOptions['outputFormat'],
158-
reproducible: OutputOptions['reproducible']
157+
specVersion: OutputOptions[ 'specVersion' ],
158+
outputFormat: OutputOptions[ 'outputFormat' ],
159+
reproducible: OutputOptions[ 'reproducible' ]
159160
): string {
160161
const spec = CDX.Spec.SpecVersionDict[specVersion]
161162
if (spec === undefined) { throw new RangeError('undefined specVersion') }
@@ -218,7 +219,7 @@ function getAuthorName (manifestRawAuthor: unknown): string | undefined {
218219
*/
219220
function packageInfoToCycloneComponent (
220221
pkgInfo: PackageInfo,
221-
reproducible: OutputOptions['reproducible']
222+
reproducible: OutputOptions[ 'reproducible' ]
222223
): CDX.Models.Component {
223224
const manifest = pkgInfo.manifest
224225
const component = componentBuilder.makeComponent(

sources/traverseUtils.ts

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ export interface PackageInfo {
4747

4848
// Modelled after traverseWorkspace in https://github.com/yarnpkg/berry/blob/master/packages/plugin-essentials/sources/commands/info.ts#L88
4949
/**
50-
* Recursively traveses workspace and its transitive dependencies.
50+
* Recursively traverses workspaces and their transitive dependencies.
5151
* @returns Packages and their resolved dependencies.
5252
*/
53-
export async function traverseWorkspace (
53+
export async function traverseWorkspaces (
5454
project: Project,
55-
workspace: Workspace,
55+
workspaces: Workspace[],
5656
config: Configuration
5757
): Promise<Set<PackageInfo>> {
5858
// Instantiate fetcher to be able to retrieve package manifest. Conversion to CycloneDX model needs this later.
@@ -67,62 +67,64 @@ export async function traverseWorkspace (
6767
cacheOptions: { skipIntegrityCheck: true }
6868
}
6969

70-
const workspaceHash = workspace.anchoredLocator.locatorHash
71-
72-
/** Packages that have been added to allPackages. */
73-
const seen = new Set<LocatorHash>()
7470
const allPackages = new Set<PackageInfo>()
75-
/** Resolved dependencies that still need processing to find their dependencies. */
76-
const pending = [workspaceHash]
77-
78-
while (true) {
79-
// pop to take most recently added job which traverses packages in depth-first style.
80-
// Doing probably results in smaller 'pending' array which makes includes-search cheaper below.
81-
const hash = pending.pop()
82-
if (hash === undefined) {
83-
// Nothing left to do as undefined value means no more item was in 'pending' array.
84-
break
85-
}
86-
87-
const pkg = project.storedPackages.get(hash)
88-
if (pkg === undefined) {
89-
throw new Error(
90-
'All package locator hashes should be resovable for consistent lockfiles.'
91-
)
92-
}
71+
for (const workspace of workspaces) {
72+
const workspaceHash = workspace.anchoredLocator.locatorHash
73+
74+
/** Packages that have been added to allPackages. */
75+
const seen = new Set<LocatorHash>()
76+
/** Resolved dependencies that still need processing to find their dependencies. */
77+
const pending = [workspaceHash]
78+
79+
while (true) {
80+
// pop to take most recently added job which traverses packages in depth-first style.
81+
// Doing probably results in smaller 'pending' array which makes includes-search cheaper below.
82+
const hash = pending.pop()
83+
if (hash === undefined) {
84+
// Nothing left to do as undefined value means no more item was in 'pending' array.
85+
break
86+
}
9387

94-
const fetchResult = await fetcher.fetch(pkg, fetcherOptions)
95-
let manifest: Manifest
96-
try {
97-
manifest = await Manifest.find(fetchResult.prefixPath, {
98-
baseFs: fetchResult.packageFs
99-
})
100-
} finally {
101-
fetchResult.releaseFs?.()
102-
}
103-
const packageInfo: PackageInfo = {
104-
package: pkg,
105-
manifest,
106-
dependencies: new Set()
107-
}
108-
seen.add(hash)
109-
allPackages.add(packageInfo)
110-
111-
// pkg.dependencies has dependencies+peerDependencies for transitive dependencies but not their devDependencies.
112-
for (const dependency of pkg.dependencies.values()) {
113-
const resolution = project.storedResolutions.get(
114-
dependency.descriptorHash
115-
)
116-
if (typeof resolution === 'undefined') {
117-
throw new Error('All package descriptor hashes should be resolvable for consistent lockfiles.')
88+
const pkg = project.storedPackages.get(hash)
89+
if (pkg === undefined) {
90+
throw new Error(
91+
'All package locator hashes should be resovable for consistent lockfiles.'
92+
)
11893
}
119-
packageInfo.dependencies.add(resolution)
12094

121-
if (!seen.has(resolution) && !pending.includes(resolution)) {
122-
pending.push(resolution)
95+
const fetchResult = await fetcher.fetch(pkg, fetcherOptions)
96+
let manifest: Manifest
97+
try {
98+
manifest = await Manifest.find(fetchResult.prefixPath, {
99+
baseFs: fetchResult.packageFs
100+
})
101+
} finally {
102+
fetchResult.releaseFs?.()
103+
}
104+
const packageInfo: PackageInfo = {
105+
package: pkg,
106+
manifest,
107+
dependencies: new Set()
108+
}
109+
seen.add(hash)
110+
allPackages.add(packageInfo)
111+
112+
// pkg.dependencies has dependencies+peerDependencies for transitive dependencies but not their devDependencies.
113+
for (const dependency of pkg.dependencies.values()) {
114+
const resolution = project.storedResolutions.get(
115+
dependency.descriptorHash
116+
)
117+
if (typeof resolution === 'undefined') {
118+
throw new Error('All package descriptor hashes should be resolvable for consistent lockfiles.')
119+
}
120+
packageInfo.dependencies.add(resolution)
121+
122+
if (!seen.has(resolution) && !pending.includes(resolution)) {
123+
pending.push(resolution)
124+
}
123125
}
124126
}
125127
}
126128

127129
return allPackages
128-
}
130+
};

0 commit comments

Comments
 (0)