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