Skip to content

Commit 4864875

Browse files
ruromeroclaude
andcommitted
refactor: improve workspace discovery robustness and batch analysis maintainability
- Replace hand-rolled pnpm-workspace.yaml parser with js-yaml - Fix negation pattern handling in workspace discovery (e.g. !**/test/**) - Refactor stackAnalysisBatch into focused helpers, eliminating duplicated SBOM generation logic between fail-fast and continue-on-error paths - Add integration tests for stackAnalysisBatch with mocked providers and HTTP backend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Implements TC-3862
1 parent 67a13fe commit 4864875

5 files changed

Lines changed: 504 additions & 167 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@
5151
"@cyclonedx/cyclonedx-library": "^6.13.0",
5252
"eslint-import-resolver-typescript": "^4.4.4",
5353
"fast-glob": "^3.3.3",
54-
"micromatch": "^4.0.8",
5554
"fast-toml": "^0.5.4",
5655
"fast-xml-parser": "^5.3.4",
5756
"help": "^3.0.2",
5857
"https-proxy-agent": "^7.0.6",
58+
"js-yaml": "^4.1.1",
59+
"micromatch": "^4.0.8",
5960
"node-fetch": "^3.3.2",
6061
"p-limit": "^5.0.0",
6162
"packageurl-js": "~1.0.2",

src/index.js

Lines changed: 180 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,162 @@ function buildBatchAnalysisMetadata(root, ecosystem, totalSbomAttempts, successf
272272
}
273273
}
274274

275+
/**
276+
* @typedef {{ ok: true, purl: string, sbom: object } | { ok: false, manifestPath: string, reason: string }} SbomResult
277+
*/
278+
279+
/**
280+
* Generate an SBOM for a single manifest, returning a normalized result.
281+
*
282+
* @param {string} manifestPath
283+
* @param {Options} workspaceOpts - opts with `workspaceDir` set
284+
* @returns {SbomResult}
285+
* @private
286+
*/
287+
function generateOneSbom(manifestPath, workspaceOpts) {
288+
const provider = match(manifestPath, availableProviders, workspaceOpts)
289+
const provided = provider.provideStack(manifestPath, workspaceOpts)
290+
const sbom = JSON.parse(provided.content)
291+
const purl = sbom?.metadata?.component?.purl || sbom?.metadata?.component?.['bom-ref']
292+
if (!purl) {
293+
return { ok: false, manifestPath, reason: 'missing purl in SBOM' }
294+
}
295+
return { ok: true, purl, sbom }
296+
}
297+
298+
/**
299+
* Detect the workspace ecosystem and discover manifest paths.
300+
*
301+
* @param {string} root - Resolved workspace root
302+
* @param {Options} opts
303+
* @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'unknown', manifestPaths: string[] }>}
304+
* @private
305+
*/
306+
async function detectWorkspaceManifests(root, opts) {
307+
const cargoToml = path.join(root, 'Cargo.toml')
308+
const cargoLock = path.join(root, 'Cargo.lock')
309+
const packageJson = path.join(root, 'package.json')
310+
311+
if (fs.existsSync(cargoToml) && fs.existsSync(cargoLock)) {
312+
return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) }
313+
}
314+
315+
const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
316+
|| fs.existsSync(path.join(root, 'yarn.lock'))
317+
|| fs.existsSync(path.join(root, 'package-lock.json'))
318+
319+
if (fs.existsSync(packageJson) && hasJsLock) {
320+
let manifestPaths = await discoverWorkspacePackages(root, opts)
321+
if (manifestPaths.length === 0) {
322+
manifestPaths = [packageJson]
323+
}
324+
return { ecosystem: 'javascript', manifestPaths }
325+
}
326+
327+
return { ecosystem: 'unknown', manifestPaths: [] }
328+
}
329+
330+
/**
331+
* Validate discovered JS package.json manifests, collecting errors.
332+
*
333+
* @param {string[]} manifestPaths
334+
* @param {boolean} continueOnError
335+
* @param {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} collectedErrors - mutated in place
336+
* @returns {{ validPaths: string[] }}
337+
* @throws {Error} on first invalid manifest when `continueOnError` is false
338+
* @private
339+
*/
340+
function validateJsManifests(manifestPaths, continueOnError, collectedErrors) {
341+
const validPaths = []
342+
for (const p of manifestPaths) {
343+
const v = validatePackageJson(p)
344+
if (v.valid) {
345+
validPaths.push(p)
346+
} else {
347+
collectedErrors.push({ manifestPath: p, phase: 'validation', reason: v.error })
348+
console.warn(`Skipping invalid package.json (${v.error}): ${p}`)
349+
if (!continueOnError) {
350+
throw new Error(`Invalid package.json (${v.error}): ${p}`)
351+
}
352+
}
353+
}
354+
return { validPaths }
355+
}
356+
357+
/**
358+
* Generate SBOMs for all manifests. In fail-fast mode, stops on first error.
359+
* In continue-on-error mode, runs concurrently and collects failures.
360+
*
361+
* @param {string[]} manifestPaths
362+
* @param {Options} workspaceOpts
363+
* @param {boolean} continueOnError
364+
* @param {number} concurrency
365+
* @param {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} collectedErrors - mutated in place
366+
* @returns {Promise<Object.<string, object>>} sbomByPurl map
367+
* @throws {Error} on first SBOM failure when `continueOnError` is false
368+
* @private
369+
*/
370+
async function generateSboms(manifestPaths, workspaceOpts, continueOnError, concurrency, collectedErrors) {
371+
/** @type {SbomResult[]} */
372+
const results = []
373+
374+
if (!continueOnError) {
375+
for (const manifestPath of manifestPaths) {
376+
const result = generateOneSbom(manifestPath, workspaceOpts)
377+
if (!result.ok) {
378+
collectedErrors.push({ manifestPath: result.manifestPath, phase: 'sbom', reason: result.reason })
379+
throw new Error(`${result.manifestPath}: ${result.reason}`)
380+
}
381+
results.push(result)
382+
}
383+
} else {
384+
const limit = pLimit(concurrency)
385+
const settled = await Promise.all(
386+
manifestPaths.map(manifestPath => limit(() => {
387+
try {
388+
return generateOneSbom(manifestPath, workspaceOpts)
389+
} catch (err) {
390+
const msg = err instanceof Error ? err.message : String(err)
391+
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
392+
console.log(`Skipping ${manifestPath}: ${msg}`)
393+
}
394+
return { ok: false, manifestPath, reason: msg }
395+
}
396+
}))
397+
)
398+
for (const r of settled) {
399+
results.push(r)
400+
if (!r.ok) {
401+
collectedErrors.push({ manifestPath: r.manifestPath, phase: 'sbom', reason: r.reason })
402+
}
403+
}
404+
}
405+
406+
const sbomByPurl = {}
407+
for (const r of results) {
408+
if (r.ok) {
409+
sbomByPurl[r.purl] = r.sbom
410+
}
411+
}
412+
return sbomByPurl
413+
}
414+
415+
/**
416+
* Create an Error with optional `batchMetadata` attached.
417+
* @param {string} message
418+
* @param {boolean} wantMetadata
419+
* @param {BatchAnalysisMetadata} [metadata]
420+
* @returns {Error}
421+
* @private
422+
*/
423+
function batchError(message, wantMetadata, metadata) {
424+
const err = new Error(message)
425+
if (wantMetadata && metadata) {
426+
err.batchMetadata = metadata
427+
}
428+
return err
429+
}
430+
275431
/**
276432
* Get stack analysis for all workspace packages/crates (batch).
277433
* Detects ecosystem from workspace root: Cargo (Cargo.toml + Cargo.lock) or JS/TS (package.json + lock file).
@@ -295,54 +451,21 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
295451
/** @type {Array<{ manifestPath: string, phase: 'validation' | 'sbom', reason: string }>} */
296452
const collectedErrors = []
297453

298-
let manifestPaths = []
299-
/** @type {'javascript' | 'cargo' | 'unknown'} */
300-
let ecosystem = 'unknown'
454+
const { ecosystem, manifestPaths: discovered } = await detectWorkspaceManifests(root, opts)
455+
let manifestPaths = discovered
301456

302-
const cargoToml = path.join(root, 'Cargo.toml')
303-
const cargoLock = path.join(root, 'Cargo.lock')
304-
const packageJson = path.join(root, 'package.json')
305-
const hasPnpmLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
306-
const hasYarnLock = fs.existsSync(path.join(root, 'yarn.lock'))
307-
const hasNpmLock = fs.existsSync(path.join(root, 'package-lock.json'))
308-
309-
if (fs.existsSync(cargoToml) && fs.existsSync(cargoLock)) {
310-
ecosystem = 'cargo'
311-
manifestPaths = await discoverWorkspaceCrates(root, opts)
312-
} else if (fs.existsSync(packageJson) && (hasPnpmLock || hasYarnLock || hasNpmLock)) {
313-
ecosystem = 'javascript'
314-
manifestPaths = await discoverWorkspacePackages(root, opts)
315-
if (manifestPaths.length === 0) {
316-
manifestPaths = [packageJson]
317-
}
318-
const beforeValidation = manifestPaths.length
319-
const validPaths = []
320-
for (const p of manifestPaths) {
321-
const v = validatePackageJson(p)
322-
if (v.valid) {
323-
validPaths.push(p)
324-
} else {
325-
const reason = v.error
326-
const entry = { manifestPath: p, phase: 'validation', reason }
327-
collectedErrors.push(entry)
328-
console.warn(`Skipping invalid package.json (${reason}): ${p}`)
329-
if (!continueOnError) {
330-
const err = new Error(`Invalid package.json (${reason}): ${p}`)
331-
if (wantMetadata) {
332-
err.batchMetadata = buildBatchAnalysisMetadata(root, ecosystem, 0, 0, collectedErrors)
333-
}
334-
throw err
335-
}
336-
}
457+
if (ecosystem === 'javascript') {
458+
try {
459+
const { validPaths } = validateJsManifests(manifestPaths, continueOnError, collectedErrors)
460+
manifestPaths = validPaths
461+
} catch (err) {
462+
throw batchError(err.message, wantMetadata,
463+
buildBatchAnalysisMetadata(root, ecosystem, 0, 0, collectedErrors))
337464
}
338-
manifestPaths = validPaths
339-
if (manifestPaths.length === 0 && beforeValidation > 0) {
465+
if (manifestPaths.length === 0 && discovered.length > 0) {
340466
const detail = collectedErrors.map(e => `${e.manifestPath}: ${e.reason}`).join('; ')
341-
const err = new Error(`No valid packages after validation at ${root}. ${detail}`)
342-
if (wantMetadata) {
343-
err.batchMetadata = buildBatchAnalysisMetadata(root, ecosystem, 0, 0, collectedErrors)
344-
}
345-
throw err
467+
throw batchError(`No valid packages after validation at ${root}. ${detail}`, wantMetadata,
468+
buildBatchAnalysisMetadata(root, ecosystem, 0, 0, collectedErrors))
346469
}
347470
}
348471

@@ -353,102 +476,25 @@ async function stackAnalysisBatch(workspaceRoot, html = false, opts = {}) {
353476
const workspaceOpts = { ...opts, workspaceDir: root }
354477
const concurrency = resolveBatchConcurrency(opts)
355478

356-
/**
357-
* @returns {Promise<{ ok: boolean, purl?: string, sbom?: object, manifestPath?: string, reason?: string }>}
358-
*/
359-
async function runOneSbom(manifestPath) {
360-
try {
361-
const provider = match(manifestPath, availableProviders, workspaceOpts)
362-
const provided = await provider.provideStack(manifestPath, opts)
363-
const sbom = JSON.parse(provided.content)
364-
const purl = sbom?.metadata?.component?.purl || sbom?.metadata?.component?.['bom-ref']
365-
if (purl) {
366-
return { ok: true, purl, sbom }
367-
}
368-
return { ok: false, manifestPath, reason: 'missing purl in SBOM' }
369-
} catch (err) {
370-
const msg = err instanceof Error ? err.message : String(err)
371-
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
372-
console.log(`Skipping ${manifestPath}: ${msg}`)
373-
}
374-
return { ok: false, manifestPath, reason: msg }
375-
}
376-
}
377-
378-
/** @type {Array<{ ok: boolean, purl?: string, sbom?: object, manifestPath?: string, reason?: string }>} */
379-
let results
380-
381-
if (!continueOnError) {
382-
results = []
383-
for (const manifestPath of manifestPaths) {
384-
try {
385-
const provider = match(manifestPath, availableProviders, workspaceOpts)
386-
const provided = await provider.provideStack(manifestPath, opts)
387-
const sbom = JSON.parse(provided.content)
388-
const purl = sbom?.metadata?.component?.purl || sbom?.metadata?.component?.['bom-ref']
389-
if (!purl) {
390-
throw new Error('missing purl in SBOM')
391-
}
392-
results.push({ ok: true, purl, sbom })
393-
} catch (err) {
394-
const msg = err instanceof Error ? err.message : String(err)
395-
collectedErrors.push({ manifestPath, phase: 'sbom', reason: msg })
396-
const e = new Error(`${manifestPath}: ${msg}`)
397-
if (wantMetadata) {
398-
e.batchMetadata = buildBatchAnalysisMetadata(
399-
root,
400-
ecosystem,
401-
manifestPaths.length,
402-
results.length,
403-
collectedErrors
404-
)
405-
}
406-
throw e
407-
}
408-
}
409-
} else {
410-
const limit = pLimit(concurrency)
411-
const tasks = manifestPaths.map(manifestPath => limit(() => runOneSbom(manifestPath)))
412-
results = await Promise.all(tasks)
413-
for (const r of results) {
414-
if (!r.ok && r.manifestPath && r.reason) {
415-
collectedErrors.push({
416-
manifestPath: r.manifestPath,
417-
phase: 'sbom',
418-
reason: r.reason,
419-
})
420-
}
421-
}
422-
}
423-
424-
const sbomByPurl = {}
425-
for (const r of results) {
426-
if (r.ok && r.purl && r.sbom) {
427-
sbomByPurl[r.purl] = r.sbom
428-
}
479+
let sbomByPurl
480+
try {
481+
sbomByPurl = await generateSboms(manifestPaths, workspaceOpts, continueOnError, concurrency, collectedErrors)
482+
} catch (err) {
483+
throw batchError(err.message, wantMetadata,
484+
buildBatchAnalysisMetadata(root, ecosystem, manifestPaths.length, 0, collectedErrors))
429485
}
430486

431487
if (Object.keys(sbomByPurl).length === 0) {
432-
const err = new Error(`No valid SBOMs produced from ${manifestPaths.length} manifest(s) at ${root}`)
433-
if (wantMetadata) {
434-
err.batchMetadata = buildBatchAnalysisMetadata(
435-
root,
436-
ecosystem,
437-
manifestPaths.length,
438-
0,
439-
collectedErrors
440-
)
441-
}
442-
throw err
488+
throw batchError(
489+
`No valid SBOMs produced from ${manifestPaths.length} manifest(s) at ${root}`,
490+
wantMetadata,
491+
buildBatchAnalysisMetadata(root, ecosystem, manifestPaths.length, 0, collectedErrors)
492+
)
443493
}
444494

445495
const analysisResult = await analysis.requestStackBatch(sbomByPurl, theUrl, html, opts)
446496
const meta = buildBatchAnalysisMetadata(
447-
root,
448-
ecosystem,
449-
manifestPaths.length,
450-
Object.keys(sbomByPurl).length,
451-
collectedErrors
497+
root, ecosystem, manifestPaths.length, Object.keys(sbomByPurl).length, collectedErrors
452498
)
453499

454500
if (wantMetadata) {

0 commit comments

Comments
 (0)