diff --git a/cliv2/go.mod b/cliv2/go.mod index c15567b8f5..f93bd6ebc1 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -12,7 +12,7 @@ require ( github.com/rs/zerolog v1.34.0 github.com/snyk/cli-extension-agent-scan v0.0.0-20260312152423-bc36193ecaa8 github.com/snyk/cli-extension-ai-bom v0.0.0-20260303103300-ea9a5a717cbb - github.com/snyk/cli-extension-dep-graph v0.26.1 + github.com/snyk/cli-extension-dep-graph v0.27.0 github.com/snyk/cli-extension-iac v0.0.0-20260206082514-00c443ccee80 github.com/snyk/cli-extension-iac-rules v0.0.0-20260206080712-9cbb5f95465d github.com/snyk/cli-extension-os-flows v0.0.0-20260306115903-79ae783267c1 diff --git a/cliv2/go.sum b/cliv2/go.sum index 760fc24398..0183115e44 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -535,8 +535,8 @@ github.com/snyk/cli-extension-agent-scan v0.0.0-20260312152423-bc36193ecaa8 h1:K github.com/snyk/cli-extension-agent-scan v0.0.0-20260312152423-bc36193ecaa8/go.mod h1:+Znlgu2v7sOTNAVjsoldFjDZUIo8tpdKnFlMptZHzz0= github.com/snyk/cli-extension-ai-bom v0.0.0-20260303103300-ea9a5a717cbb h1:ZgBgiMMtY8XK8WJZpmnlt08wKPMitDEU1TS99OK+k8A= github.com/snyk/cli-extension-ai-bom v0.0.0-20260303103300-ea9a5a717cbb/go.mod h1:eIq61+KliPjLwhaAZT87FfeyfK/4mJaGP0wqyFtf8pQ= -github.com/snyk/cli-extension-dep-graph v0.26.1 h1:aOov0GvCuvNbrkkk/CWggXFIIrFcBmRkNyE0yTMsTrc= -github.com/snyk/cli-extension-dep-graph v0.26.1/go.mod h1:JQ37TXutjFa585Ocak1jfBRN6+QPppmFIlJ6+nrfgaY= +github.com/snyk/cli-extension-dep-graph v0.27.0 h1:yVy/QFeKdQUVL0PHZtPDSTk7icY2QrQPGKPjMFoCJwQ= +github.com/snyk/cli-extension-dep-graph v0.27.0/go.mod h1:JQ37TXutjFa585Ocak1jfBRN6+QPppmFIlJ6+nrfgaY= github.com/snyk/cli-extension-iac v0.0.0-20260206082514-00c443ccee80 h1:JHbnSkgGc2oUejjzdWdeTghl0BZV7QamcRuyh7ornVo= github.com/snyk/cli-extension-iac v0.0.0-20260206082514-00c443ccee80/go.mod h1:Ht5k+sWdi//fM2MjcmBMWjcJmr35iMvQpYlBWnUHL4I= github.com/snyk/cli-extension-iac-rules v0.0.0-20260206080712-9cbb5f95465d h1:xkxHgZ+DT4hRiIEeAEv1JWLJRYV4MbAFvtEUpUkndPA= diff --git a/src/lib/plugins/types.ts b/src/lib/plugins/types.ts index f6e1b77be9..03af8a712b 100644 --- a/src/lib/plugins/types.ts +++ b/src/lib/plugins/types.ts @@ -21,6 +21,7 @@ export interface Options { systemVersions?: object; scanAllUnmanaged?: boolean; showNpmScope?: boolean; + allProjects?: boolean; } export interface Plugin { diff --git a/src/lib/plugins/uv/index.ts b/src/lib/plugins/uv/index.ts index 076b42a1a0..9fe19259e0 100644 --- a/src/lib/plugins/uv/index.ts +++ b/src/lib/plugins/uv/index.ts @@ -7,11 +7,18 @@ import { CustomError } from '../../errors'; import { execGoCommand, GoCommandResult } from '../../go-bridge'; import { truncateForLog } from '../../utils'; import * as types from '../types'; +import { ScannedProjectCustom } from '../get-multi-plugin-result'; const debug = Debug('snyk:plugins:uv'); const UV_LOCKFILE_NAME = 'uv.lock'; const PYPROJECT_MANIFEST_NAME = 'pyproject.toml'; +// JSON type returned from the Go plugin when using the --internal-uv-workspace-packages flag +interface WorkspacePackageResult { + depGraph: DepGraphData; + targetFile: string; +} + export async function inspect( root: string, targetFile: string, @@ -41,6 +48,9 @@ export async function inspect( if (strictOutOfSync !== undefined) { args.push(`--strict-out-of-sync=${strictOutOfSync}`); } + if (options?.allProjects) { + args.push('--internal-uv-workspace-packages'); + } const result = await execGoCommand(args, { cwd: root }); @@ -48,9 +58,40 @@ export async function inspect( throw createDepgraphError(extractErrorDetail(result)); } - let depGraphData: DepGraphData; + const resolvedTargetFile = getResolvedTargetFile(targetFile); + + let scannedProjects: ScannedProjectCustom[]; try { - depGraphData = JSON.parse(result.stdout); + if (options?.allProjects) { + scannedProjects = result.stdout + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as WorkspacePackageResult) + .map((workspacePkg) => ({ + depGraph: createFromJSON(workspacePkg.depGraph), + targetFile: workspacePkg.targetFile, + packageManager: 'uv', + plugin: { + name: 'snyk-uv-plugin', + packageManager: 'uv', + targetFile: workspacePkg.targetFile, + }, + })); + } else { + const depGraphData = JSON.parse(result.stdout) as DepGraphData; + scannedProjects = [ + { + depGraph: createFromJSON(depGraphData), + targetFile: resolvedTargetFile, + packageManager: 'uv', + plugin: { + name: 'snyk-uv-plugin', + packageManager: 'uv', + targetFile: resolvedTargetFile, + }, + }, + ]; + } } catch (error) { const parseError = error instanceof Error ? error.message : String(error); debug( @@ -64,15 +105,6 @@ export async function inspect( ); } - const resolvedTargetFile = getResolvedTargetFile(targetFile); - - const scannedProjects = [ - { - depGraph: createFromJSON(depGraphData), - targetFile: resolvedTargetFile, - }, - ]; - return { plugin: { name: 'snyk-uv-plugin', diff --git a/src/lib/plugins/uv/uv.spec.ts b/src/lib/plugins/uv/uv.spec.ts index f74257d75f..65685bedbf 100644 --- a/src/lib/plugins/uv/uv.spec.ts +++ b/src/lib/plugins/uv/uv.spec.ts @@ -84,8 +84,8 @@ describe('uv plugin', () => { expect(result.plugin).toEqual({ name: 'snyk-uv-plugin', - targetFile: 'pyproject.toml', packageManager: 'uv', + targetFile: 'pyproject.toml', }); expect(result.scannedProjects).toHaveLength(1); @@ -111,7 +111,6 @@ describe('uv plugin', () => { ['depgraph', '--file=path/to/uv.lock', '--use-sbom-resolution', '--json'], { cwd: '.' }, ); - expect(result.plugin.targetFile).toBe('path/to/pyproject.toml'); expect(result.scannedProjects[0].targetFile).toBe('path/to/pyproject.toml'); }); @@ -242,7 +241,7 @@ describe('uv plugin', () => { const catalog = err.errorCatalog; expect(catalog).toBeInstanceOf(CLI.GeneralCLIFailureError); expect(catalog).toBeInstanceOf(ProblemError); - expect(catalog!.detail).toBe('some depgraph failure'); + expect(catalog?.detail).toBe('some depgraph failure'); }); it('attaches an error catalog entry for invalid JSON errors', async () => { @@ -252,6 +251,123 @@ describe('uv plugin', () => { const catalog = err.errorCatalog; expect(catalog).toBeInstanceOf(CLI.GeneralCLIFailureError); - expect(catalog!.detail).toBe('Unable to process dependency information'); + expect(catalog?.detail).toBe('Unable to process dependency information'); + }); + + it('throws a generic error when depgraph command fails with --json error', async () => { + const errorJSON = `{ + "ok": false, + "error": "/path/to/uv version 0.9.9 is not supported. Minimum required version is 0.9.29", + "path": "/cwd/uv-project" + }`; + execGoCommandSpy.mockResolvedValueOnce(mockResult(errorJSON, 2, '')); + + const err: CustomError = await inspect('.', 'uv.lock').catch((e) => e); + + expect(err).toBeInstanceOf(CustomError); + expect(err.message).toBe( + '/path/to/uv version 0.9.9 is not supported. Minimum required version is 0.9.29', + ); + expect(err.errorCatalog).toBeInstanceOf(CLI.GeneralCLIFailureError); + expect(err.errorCatalog?.detail).toBe( + '/path/to/uv version 0.9.9 is not supported. Minimum required version is 0.9.29', + ); + }); + + it('passes --internal-uv-workspace-packages when allProjects is true', async () => { + const workspaceResult = JSON.stringify({ + depGraph: MOCK_DEP_GRAPH_DATA, + targetFile: 'pyproject.toml', + }); + execGoCommandSpy.mockResolvedValueOnce(mockResult(workspaceResult)); + + await inspect('.', 'uv.lock', { allProjects: true }); + + expect(execGoCommandSpy).toHaveBeenCalledWith( + [ + 'depgraph', + '--file=uv.lock', + '--use-sbom-resolution', + '--json', + '--internal-uv-workspace-packages', + ], + { cwd: '.' }, + ); + }); + + it('does not pass --internal-uv-workspace-packages when allProjects is false', async () => { + await inspect('.', 'uv.lock'); + + expect(execGoCommandSpy).toHaveBeenCalledWith( + ['depgraph', '--file=uv.lock', '--use-sbom-resolution', '--json'], + { cwd: '.' }, + ); + }); + + it('handles JSONL dep graphs from uv workspace when --all-projects is true', async () => { + const depGraphA: DepGraphData = { + schemaVersion: '1.3.0', + pkgManager: { name: 'uv' }, + pkgs: [{ id: 'pkg-a@1.0.0', info: { name: 'pkg-a', version: '1.0.0' } }], + graph: { + rootNodeId: 'pkg-a@1.0.0', + nodes: [{ nodeId: 'pkg-a@1.0.0', pkgId: 'pkg-a@1.0.0', deps: [] }], + }, + }; + const depGraphB: DepGraphData = { + schemaVersion: '1.3.0', + pkgManager: { name: 'uv' }, + pkgs: [{ id: 'pkg-b@2.0.0', info: { name: 'pkg-b', version: '2.0.0' } }], + graph: { + rootNodeId: 'pkg-b@2.0.0', + nodes: [{ nodeId: 'pkg-b@2.0.0', pkgId: 'pkg-b@2.0.0', deps: [] }], + }, + }; + + const jsonl = [ + JSON.stringify({ depGraph: depGraphA, targetFile: 'pyproject.toml' }), + JSON.stringify({ + depGraph: depGraphB, + targetFile: 'packages/pkg-b/pyproject.toml', + }), + ].join('\n'); + + execGoCommandSpy.mockResolvedValueOnce(mockResult(jsonl)); + + const result = await inspect('.', 'uv.lock', { + allProjects: true, + } as any); + + expect(result.plugin).toEqual({ + name: 'snyk-uv-plugin', + packageManager: 'uv', + targetFile: 'pyproject.toml', + }); + + expect(result.scannedProjects).toHaveLength(2); + + const firstProject = result.scannedProjects[0]; + expect(firstProject.targetFile).toBe('pyproject.toml'); + expect(firstProject.depGraph?.rootPkg).toEqual({ + name: 'pkg-a', + version: '1.0.0', + }); + + const secondProject = result.scannedProjects[1]; + expect(secondProject.targetFile).toBe('packages/pkg-b/pyproject.toml'); + expect(secondProject.depGraph?.rootPkg).toEqual({ + name: 'pkg-b', + version: '2.0.0', + }); + }); + + it('handles a single dep graph object (backward compatibility)', async () => { + const result = await inspect('.', 'uv.lock'); + + expect(result.scannedProjects).toHaveLength(1); + expect(result.scannedProjects[0].depGraph?.rootPkg).toEqual({ + name: 'uv-project', + version: '0.1.0', + }); }); });