diff --git a/src/cli/commands/monitor/index.ts b/src/cli/commands/monitor/index.ts index effdcf9659..2ad0f92578 100644 --- a/src/cli/commands/monitor/index.ts +++ b/src/cli/commands/monitor/index.ts @@ -72,6 +72,7 @@ import { } from '../../../lib/package-managers'; import { normalizeTargetFile } from '../../../lib/normalize-target-file'; import { getOrganizationID } from '../../../lib/organization'; +import { getPrintGraphMode } from '../../../lib/snyk-test/common'; const SEPARATOR = '\n-------------------------------------------------------\n'; const debug = Debug('snyk'); @@ -216,10 +217,11 @@ export default async function monitor(...args0: MethodArgs): Promise { let enableMavenDverboseExhaustiveDeps = false; try { const args = options['_doubleDashArgs'] || []; + const printGraphMode = getPrintGraphMode(options); const verboseEnabled = args.includes('-Dverbose') || args.includes('-Dverbose=true') || - !!options['print-graph']; + printGraphMode.printGraphEnabled; if (verboseEnabled) { enableMavenDverboseExhaustiveDeps = (await hasFeatureFlag( MAVEN_DVERBOSE_EXHAUSTIVE_DEPS_FF, diff --git a/src/lib/ecosystems/test.ts b/src/lib/ecosystems/test.ts index 6424419eec..b34d6030f1 100644 --- a/src/lib/ecosystems/test.ts +++ b/src/lib/ecosystems/test.ts @@ -11,7 +11,9 @@ import { getPlugin } from './plugins'; import { TestDependenciesResponse } from '../snyk-test/legacy'; import { assembleQueryString, + getPrintGraphMode, printDepGraph, + printDepGraphJsonl, shouldPrintDepGraph, } from '../snyk-test/common'; import { getAuthHeader } from '../api-token'; @@ -56,7 +58,7 @@ export async function testEcosystem( if (isUnmanagedEcosystem(ecosystem) && shouldPrintDepGraph(options)) { const [target] = paths; - return printUnmanagedDepGraph(results, target, process.stdout); + return printUnmanagedDepGraph(results, target, process.stdout, options); } const [testResults, errors] = await selectAndExecuteTestStrategy( @@ -99,11 +101,22 @@ export async function printUnmanagedDepGraph( results: ScanResultsByPath, target: string, destination: Writable, + options: Options, ): Promise { const [result] = await getUnmanagedDepGraph(results); const depGraph = convertDepGraph(result); - await printDepGraph(depGraph, target, destination); + if (getPrintGraphMode(options).jsonlOutput) { + await printDepGraphJsonl( + depGraph, + target, + undefined, + undefined, + destination, + ); + } else { + await printDepGraph(depGraph, target, destination); + } return TestCommandResult.createJsonTestCommandResult(''); } diff --git a/src/lib/plugins/get-deps-from-plugin.ts b/src/lib/plugins/get-deps-from-plugin.ts index 44fb556a4d..ee4d8feda4 100644 --- a/src/lib/plugins/get-deps-from-plugin.ts +++ b/src/lib/plugins/get-deps-from-plugin.ts @@ -21,6 +21,7 @@ import { convertSingleResultToMultiCustom } from './convert-single-splugin-res-t import { convertMultiResultToMultiCustom } from './convert-multi-plugin-res-to-multi-custom'; import { processYarnWorkspaces } from './nodejs-plugin/yarn-workspaces-parser'; import { ScannedProject } from '@snyk/cli-interface/legacy/common'; +import { shouldPrintDepGraphWithErrors } from '../snyk-test/common'; const debug = debugModule('snyk-test'); @@ -104,14 +105,9 @@ export async function getDepsFromPlugin( } let inspectRes; try { - inspectRes = await getSinglePluginResult( - root, - options, - '', - featureFlags, - ); + inspectRes = await getSinglePluginResult(root, options, '', featureFlags); } catch (error) { - if (options['print-effective-graph-with-errors']) { + if (shouldPrintDepGraphWithErrors(options)) { const errMessage = error?.message ?? 'Something went wrong getting dependencies'; debug( diff --git a/src/lib/plugins/get-multi-plugin-result.ts b/src/lib/plugins/get-multi-plugin-result.ts index c51c1b2779..bc58da40c4 100644 --- a/src/lib/plugins/get-multi-plugin-result.ts +++ b/src/lib/plugins/get-multi-plugin-result.ts @@ -21,6 +21,7 @@ import { errorMessageWithRetry, FailedToRunTestError } from '../errors'; import { processYarnWorkspaces } from './nodejs-plugin/yarn-workspaces-parser'; import { processNpmWorkspaces } from './nodejs-plugin/npm-workspaces-parser'; import { processPnpmWorkspaces } from 'snyk-nodejs-plugin'; +import { shouldPrintDepGraphWithErrors } from '../snyk-test/common'; const debug = debugModule('snyk-test'); export interface ScannedProjectCustom @@ -184,7 +185,7 @@ export async function getMultiPluginResult( if (!allResults.length) { // When allow-incomplete-sbom is active, return instead of throwing // so the caller can print per-project JSONL error entries - if (options['print-effective-graph-with-errors']) { + if (shouldPrintDepGraphWithErrors(options)) { return { plugin: { name: 'custom-auto-detect', diff --git a/src/lib/snyk-test/common.ts b/src/lib/snyk-test/common.ts index 86724edc67..2699063f96 100644 --- a/src/lib/snyk-test/common.ts +++ b/src/lib/snyk-test/common.ts @@ -80,6 +80,50 @@ export type FailOn = 'all' | 'upgradable' | 'patchable'; export const RETRY_ATTEMPTS = 3; export const RETRY_DELAY = 500; +export interface PrintGraphMode { + printGraphEnabled: boolean; + effectiveGraph: boolean; + jsonlOutput: boolean; + printErrors: boolean; +} + +/** + * getPrintGraphMode derives canonical print-graph behavior from both + * the new flag set and legacy aliases during the migration window. + */ +export function getPrintGraphMode(opts: Options): PrintGraphMode { + const legacyEffectiveGraph = !!opts['print-effective-graph']; + const legacyEffectiveGraphWithErrors = + !!opts['print-effective-graph-with-errors']; + + const printGraphEnabled = + !!opts['print-graph'] || + legacyEffectiveGraph || + legacyEffectiveGraphWithErrors; + + const effectiveGraph = + !!opts['effective-graph'] || + legacyEffectiveGraph || + legacyEffectiveGraphWithErrors; + + const printErrors = + printGraphEnabled && + (!!opts['print-errors'] || legacyEffectiveGraphWithErrors); + + const jsonlOutput = + printGraphEnabled && + (!!opts['jsonl-output'] || + legacyEffectiveGraph || + legacyEffectiveGraphWithErrors); + + return { + printGraphEnabled, + effectiveGraph, + jsonlOutput, + printErrors, + }; +} + /** * printDepGraph writes the given dep-graph and target name to the destination * stream as expected by the `depgraph` CLI workflow. @@ -102,15 +146,17 @@ export async function printDepGraph( } export function shouldPrintDepGraph(opts: Options): boolean { - return opts['print-graph'] && !opts['print-deps']; + const mode = getPrintGraphMode(opts); + return mode.printGraphEnabled && !mode.effectiveGraph && !opts['print-deps']; } /** - * printEffectiveDepGraph writes the given, possibly pruned dep-graph and target file to the destination - * stream as a JSON object containing both depGraph, normalisedTargetFile and targetFile from plugin. - * This allows extracting the effective dep-graph which is being used for the test. + * printDepGraphJsonl writes dep-graph metadata to the destination stream as one JSON object + * per line (JSONL): depGraph, normalisedTargetFile, optional targetFileFromPlugin, optional target. + * Used when --print-graph --jsonl-output is set for both complete and effective graphs; callers + * supply the dep-graph payload (full or pruned) they want to serialize. */ -export async function printEffectiveDepGraph( +export async function printDepGraphJsonl( depGraph: DepGraphData, normalisedTargetFile: string, targetFileFromPlugin: string | undefined, @@ -118,17 +164,14 @@ export async function printEffectiveDepGraph( destination: Writable, ): Promise { return new Promise((res, rej) => { - const effectiveGraphOutput = { + const record = { depGraph, normalisedTargetFile, targetFileFromPlugin, target, }; - new ConcatStream( - new JsonStreamStringify(effectiveGraphOutput), - Readable.from('\n'), - ) + new ConcatStream(new JsonStreamStringify(record), Readable.from('\n')) .on('end', res) .on('error', rej) .pipe(destination); @@ -136,17 +179,18 @@ export async function printEffectiveDepGraph( } /** - * printEffectiveDepGraphError writes an error output for failed dependency graph resolution - * to the destination stream in a format consistent with printEffectiveDepGraph. - * This is used when --print-effective-graph-with-errors is set but dependency resolution failed. + * printDepGraphJsonlError writes a JSONL line for failed dependency graph resolution, shaped for + * consumers that read the same stream as printDepGraphJsonl. + * Used when graph output includes errors (e.g. legacy --print-effective-graph-with-errors or + * --print-graph --print-errors) but resolution failed for a project. */ -export async function printEffectiveDepGraphError( +export async function printDepGraphJsonlError( root: string, failedProjectScanError: FailedProjectScanError, destination: Writable, ): Promise { return new Promise((res, rej) => { - // Normalize the target file path to be relative to root, consistent with printEffectiveDepGraph + // Normalize the target file path to be relative to root, consistent with printDepGraphJsonl const normalisedTargetFile = failedProjectScanError.targetFile ? path.relative(root, failedProjectScanError.targetFile) : failedProjectScanError.targetFile; @@ -154,15 +198,12 @@ export async function printEffectiveDepGraphError( const problemError = getOrCreateErrorCatalogError(failedProjectScanError); const serializedError = problemError.toJsonApi().body(); - const effectiveGraphErrorOutput = { + const errorRecord = { error: serializedError, normalisedTargetFile, }; - new ConcatStream( - new JsonStreamStringify(effectiveGraphErrorOutput), - Readable.from('\n'), - ) + new ConcatStream(new JsonStreamStringify(errorRecord), Readable.from('\n')) .on('end', res) .on('error', rej) .pipe(destination); @@ -173,18 +214,17 @@ export async function printEffectiveDepGraphError( * Checks if either --print-effective-graph or --print-effective-graph-with-errors is set. */ export function shouldPrintEffectiveDepGraph(opts: Options): boolean { - return ( - !!opts['print-effective-graph'] || - shouldPrintEffectiveDepGraphWithErrors(opts) - ); + const mode = getPrintGraphMode(opts); + return mode.printGraphEnabled && mode.effectiveGraph; } /** - * shouldPrintEffectiveDepGraphWithErrors checks if the --print-effective-graph-with-errors flag is set. - * This is used to determine if the effective dep-graph with errors should be printed. + * shouldPrintDepGraphWithErrors returns true when dependency graph output + * is requested and error entries should also be printed. */ -export function shouldPrintEffectiveDepGraphWithErrors(opts: Options): boolean { - return !!opts['print-effective-graph-with-errors']; +export function shouldPrintDepGraphWithErrors(opts: Options): boolean { + const mode = getPrintGraphMode(opts); + return mode.printGraphEnabled && mode.printErrors; } /** diff --git a/src/lib/snyk-test/index.js b/src/lib/snyk-test/index.js index 8006dbf63a..a9fc03015d 100644 --- a/src/lib/snyk-test/index.js +++ b/src/lib/snyk-test/index.js @@ -20,6 +20,7 @@ const { } = require('../package-managers'); const { getOrganizationID } = require('../organization'); const debug = require('debug')('snyk-test'); +const { getPrintGraphMode } = require('./common'); async function test(root, options, callback) { if (typeof options === 'function') { @@ -53,10 +54,11 @@ async function executeTest(root, options) { let enableMavenDverboseExhaustiveDeps = false; try { const args = options['_doubleDashArgs'] || []; + const printGraphMode = getPrintGraphMode(options); const verboseEnabled = args.includes('-Dverbose') || args.includes('-Dverbose=true') || - !!options['print-graph']; + printGraphMode.printGraphEnabled; if (verboseEnabled) { enableMavenDverboseExhaustiveDeps = await hasFeatureFlag( MAVEN_DVERBOSE_EXHAUSTIVE_DEPS_FF, diff --git a/src/lib/snyk-test/run-test.ts b/src/lib/snyk-test/run-test.ts index 56c6cc0816..9bf6a3f460 100644 --- a/src/lib/snyk-test/run-test.ts +++ b/src/lib/snyk-test/run-test.ts @@ -39,13 +39,14 @@ import { isCI } from '../is-ci'; import { RETRY_ATTEMPTS, RETRY_DELAY, + getPrintGraphMode, printDepGraph, - printEffectiveDepGraph, - printEffectiveDepGraphError, + printDepGraphJsonl, + printDepGraphJsonlError, assembleQueryString, shouldPrintDepGraph, shouldPrintEffectiveDepGraph, - shouldPrintEffectiveDepGraphWithErrors, + shouldPrintDepGraphWithErrors, } from './common'; import config from '../config'; import * as analytics from '../analytics'; @@ -246,7 +247,11 @@ async function sendAndParseResults( ): Promise { const results: TestResult[] = []; const ecosystem = getEcosystem(options); - const depGraphs = new Map(); + const depGraphPrintJobs: { + legacyTargetLabel: string; + graph: depGraphLib.DepGraphData; + normalisedTargetFile: string; + }[] = []; await spinner.clear(spinnerLbl)(); if (!options.quiet) { @@ -322,7 +327,11 @@ async function sendAndParseResults( if (ecosystem && depGraph) { const targetName = scanResult ? constructProjectName(scanResult) : ''; - depGraphs.set(targetName, depGraph.toJSON()); + depGraphPrintJobs.push({ + legacyTargetLabel: targetName, + graph: depGraph.toJSON(), + normalisedTargetFile: targetFile || displayTargetFile || '', + }); } const legacyRes = convertIssuesToAffectedPkgs(response); @@ -351,9 +360,20 @@ async function sendAndParseResults( } if (ecosystem && shouldPrintDepGraph(options)) { + const { jsonlOutput } = getPrintGraphMode(options); await spinner.clear(spinnerLbl)(); - for (const [targetName, depGraph] of depGraphs.entries()) { - await printDepGraph(depGraph, targetName, process.stdout); + for (const job of depGraphPrintJobs) { + if (jsonlOutput) { + await printDepGraphJsonl( + job.graph, + job.normalisedTargetFile || job.legacyTargetLabel, + undefined, + undefined, + process.stdout, + ); + } else { + await printDepGraph(job.graph, job.legacyTargetLabel, process.stdout); + } } return []; } @@ -656,7 +676,7 @@ async function assembleLocalPayloads( await spinner.clear(spinnerLbl)(); // When printing effective dep-graph with errors, suppress warning output — // the failures will be embedded in the generated SBOM as annotations. - const suppressWarnings = shouldPrintEffectiveDepGraphWithErrors(options); + const suppressWarnings = shouldPrintDepGraphWithErrors(options); const isNotJsonOrQueiet = !options.json && !options.quiet && !suppressWarnings; @@ -679,9 +699,9 @@ async function assembleLocalPayloads( failedResults, ); - if (shouldPrintEffectiveDepGraphWithErrors(options)) { + if (shouldPrintDepGraphWithErrors(options)) { for (const failed of failedResults) { - await printEffectiveDepGraphError(root, failed, process.stdout); + await printDepGraphJsonlError(root, failed, process.stdout); } } @@ -832,7 +852,17 @@ async function assembleLocalPayloads( ); } - await printDepGraph(root.toJSON(), targetFile || '', process.stdout); + if (getPrintGraphMode(options).jsonlOutput) { + await printDepGraphJsonl( + root.toJSON(), + targetFile || '', + project.plugin.targetFile, + target, + process.stdout, + ); + } else { + await printDepGraph(root.toJSON(), targetFile || '', process.stdout); + } } const body: PayloadBody = { @@ -871,7 +901,9 @@ async function assembleLocalPayloads( }); } - const pruneIsRequired = options.pruneRepeatedSubdependencies; + const pruneIsRequired = + options.pruneRepeatedSubdependencies || + shouldPrintEffectiveDepGraph(options); if (packageManager) { depGraph = await pruneGraph(depGraph, packageManager, pruneIsRequired); @@ -879,7 +911,7 @@ async function assembleLocalPayloads( if (shouldPrintEffectiveDepGraph(options)) { spinner.clear(spinnerLbl)(); - await printEffectiveDepGraph( + await printDepGraphJsonl( depGraph.toJSON(), targetFile, project.plugin.targetFile, diff --git a/src/lib/types.ts b/src/lib/types.ts index d87db84e70..c9e8132acb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,6 +68,10 @@ export interface Options { 'print-deps'?: boolean; 'print-tree'?: boolean; 'print-dep-paths'?: boolean; + 'print-graph'?: boolean; + 'jsonl-output'?: boolean; + 'effective-graph'?: boolean; + 'print-errors'?: boolean; 'print-effective-graph'?: boolean; 'print-effective-graph-with-errors'?: boolean; 'remote-repo-url'?: string; @@ -150,6 +154,10 @@ export interface MonitorOptions { json?: boolean; allSubProjects?: boolean; 'project-name'?: string; + 'print-graph'?: boolean; + 'jsonl-output'?: boolean; + 'effective-graph'?: boolean; + 'print-errors'?: boolean; 'print-deps'?: boolean; 'print-dep-paths'?: boolean; 'target-reference'?: string; diff --git a/test/jest/acceptance/print-effective-dep-graph-with-errors.spec.ts b/test/jest/acceptance/print-effective-dep-graph-with-errors.spec.ts index f9f687811e..6ed0472cff 100644 --- a/test/jest/acceptance/print-effective-dep-graph-with-errors.spec.ts +++ b/test/jest/acceptance/print-effective-dep-graph-with-errors.spec.ts @@ -181,7 +181,7 @@ describe('`test` command with `--print-effective-graph-with-errors` option', () // Should have at least one output (either success or error) expect(jsonObjects.length).toBeGreaterThan(0); - // Find error outputs from printEffectiveDepGraphError (has error.id field) + // Find error outputs from printDepGraphJsonlError (has error.id field) const errorOutputs = jsonObjects.filter( (obj) => obj.error !== undefined && obj.normalisedTargetFile !== undefined, diff --git a/test/jest/unit/lib/ecosystems/common.spec.ts b/test/jest/unit/lib/ecosystems/common.spec.ts index 74f586b5f9..8e056a1a9d 100644 --- a/test/jest/unit/lib/ecosystems/common.spec.ts +++ b/test/jest/unit/lib/ecosystems/common.spec.ts @@ -4,6 +4,7 @@ import { isUnmanagedEcosystem } from '../../../../../src/lib/ecosystems/common'; import { handleProcessingStatus } from '../../../../../src/lib/polling/common'; import { FailedToRunTestError } from '../../../../../src/lib/errors'; import { printUnmanagedDepGraph } from '../../../../../src/lib/ecosystems/test'; +import { Options } from '../../../../../src/lib/types'; import * as utils from '../../../../../src/lib/ecosystems/unmanaged/utils'; import { DepGraphDataOpenAPI } from '../../../../../src/lib/ecosystems/unmanaged/types'; @@ -90,7 +91,12 @@ describe('printUnmanagedDepGraph fn', () => { }, }); - const { result } = await printUnmanagedDepGraph({}, 'foo/bar', mockDest); + const { result } = await printUnmanagedDepGraph( + {}, + 'foo/bar', + mockDest, + {} as Options, + ); expect(result).toBe(''); expect(buffer.toString()).toMatchSnapshot(); diff --git a/test/jest/unit/lib/plugins/get-deps-from-plugin.spec.ts b/test/jest/unit/lib/plugins/get-deps-from-plugin.spec.ts index 516f58f303..5d5bf3956e 100644 --- a/test/jest/unit/lib/plugins/get-deps-from-plugin.spec.ts +++ b/test/jest/unit/lib/plugins/get-deps-from-plugin.spec.ts @@ -1,7 +1,6 @@ import { getDepsFromPlugin } from '../../../../../src/lib/plugins/get-deps-from-plugin'; import { Options, TestOptions } from '../../../../../src/lib/types'; import * as singlePluginResult from '../../../../../src/lib/plugins/get-single-plugin-result'; -import * as detect from '../../../../../src/lib/detect'; jest.mock('../../../../../src/lib/plugins/get-single-plugin-result'); jest.mock('../../../../../src/lib/detect', () => ({ @@ -23,9 +22,9 @@ describe('getDepsFromPlugin - print-effective-graph-with-errors', () => { it('should return failedResults when plugin throws and flag is set', async () => { const pluginError = new Error('missing lockfile'); - ( - singlePluginResult.getSinglePluginResult as jest.Mock - ).mockRejectedValue(pluginError); + (singlePluginResult.getSinglePluginResult as jest.Mock).mockRejectedValue( + pluginError, + ); const options = { ...baseOptions, @@ -45,19 +44,19 @@ describe('getDepsFromPlugin - print-effective-graph-with-errors', () => { it('should rethrow when plugin throws and flag is not set', async () => { const pluginError = new Error('missing lockfile'); - ( - singlePluginResult.getSinglePluginResult as jest.Mock - ).mockRejectedValue(pluginError); + (singlePluginResult.getSinglePluginResult as jest.Mock).mockRejectedValue( + pluginError, + ); - await expect( - getDepsFromPlugin('/test', baseOptions), - ).rejects.toThrow('missing lockfile'); + await expect(getDepsFromPlugin('/test', baseOptions)).rejects.toThrow( + 'missing lockfile', + ); }); it('should use fallback message when error has no message', async () => { - ( - singlePluginResult.getSinglePluginResult as jest.Mock - ).mockRejectedValue({ code: 'UNKNOWN' }); + (singlePluginResult.getSinglePluginResult as jest.Mock).mockRejectedValue({ + code: 'UNKNOWN', + }); const options = { ...baseOptions,