From 6fb148d4edf16345512938f436eb0e74e1c094d1 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 10 Apr 2026 17:51:21 +0200 Subject: [PATCH 01/11] feat(pip): add pyproject.toml support via pip --dry-run --report Add Python_pip_pyproject provider that resolves PEP 621 pyproject.toml dependencies using `pip install --dry-run --ignore-installed --report`. This is the fallback provider when no lock file (uv.lock/poetry.lock) is found, enabling analysis of standard pyproject.toml projects without requiring uv or poetry. The pip report JSON provides resolved versions, requires_dist for building the dependency tree, and requested flags for direct vs transitive classification. Extras-only deps are filtered out. Supports TRUSTIFY_DA_PIP_REPORT env var for testing without pip. Implements TC-4065 Assisted-by: Claude Code --- src/provider.js | 2 + src/providers/python_pip_pyproject.js | 132 ++++++++++++++++++ test/providers/python_pyproject.test.js | 122 ++++++++++++++++ .../pip_pep621/expected_component_sbom.json | 36 +++++ .../pip_pep621/expected_stack_sbom.json | 85 +++++++++++ .../pyproject/pip_pep621/pip_report.json | 62 ++++++++ .../pyproject/pip_pep621/pyproject.toml | 7 + .../pip_pep621_ignore/pip_report.json | 62 ++++++++ .../pip_pep621_ignore/pyproject.toml | 7 + 9 files changed, 515 insertions(+) create mode 100644 src/providers/python_pip_pyproject.js create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml diff --git a/src/provider.js b/src/provider.js index 9a7f3074..5c5d06ec 100644 --- a/src/provider.js +++ b/src/provider.js @@ -8,6 +8,7 @@ import Javascript_npm from './providers/javascript_npm.js'; import Javascript_pnpm from './providers/javascript_pnpm.js'; import Javascript_yarn from './providers/javascript_yarn.js'; import pythonPipProvider from './providers/python_pip.js' +import Python_pip_pyproject from './providers/python_pip_pyproject.js' import Python_poetry from './providers/python_poetry.js' import Python_uv from './providers/python_uv.js' import rustCargoProvider from './providers/rust_cargo.js' @@ -30,6 +31,7 @@ export const availableProviders = [ pythonPipProvider, new Python_poetry(), new Python_uv(), + new Python_pip_pyproject(), rustCargoProvider] /** diff --git a/src/providers/python_pip_pyproject.js b/src/providers/python_pip_pyproject.js new file mode 100644 index 00000000..a5c0a573 --- /dev/null +++ b/src/providers/python_pip_pyproject.js @@ -0,0 +1,132 @@ +import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js' + +import Base_pyproject from './base_pyproject.js' + +/** + * Python provider for pyproject.toml files using PEP 621 format without a lock file. + * Uses `pip install --dry-run --ignore-installed --report` to resolve the full dependency tree. + * Acts as the fallback provider when no lock file (uv.lock/poetry.lock) is found. + */ +export default class Python_pip_pyproject extends Base_pyproject { + + /** @returns {string} */ + _lockFileName() { + return '.pip-lock-nonexistent' + } + + /** @returns {string} */ + _cmdName() { + return 'pip' + } + + /** + * Always returns true — pip provider is the fallback when no lock file is found. + * @param {string} manifestDir + * @param {{}} [opts={}] + * @returns {boolean} + */ + // eslint-disable-next-line no-unused-vars + validateLockFile(manifestDir, opts = {}) { + return true + } + + /** + * Get pip report output from env var override or by running pip. + * @param {string} manifestDir - directory containing pyproject.toml + * @param {{}} [opts={}] + * @returns {string} pip report JSON string + */ + _getPipReportOutput(manifestDir, opts) { + if (environmentVariableIsPopulated('TRUSTIFY_DA_PIP_REPORT')) { + return Buffer.from(process.env['TRUSTIFY_DA_PIP_REPORT'], 'base64').toString('ascii') + } + let pipBin = getCustomPath('pip3', opts) + try { + invokeCommand(pipBin, ['--version']) + } catch { + pipBin = getCustomPath('pip', opts) + } + return invokeCommand(pipBin, [ + 'install', '--dry-run', '--ignore-installed', '--report', '-', '.' + ], { cwd: manifestDir }).toString() + } + + /** + * Parse pip report JSON and build dependency graph. + * @param {string} reportJson - pip report JSON string + * @returns {{directDeps: string[], graph: Map}} + */ + _parsePipReport(reportJson) { + let report = JSON.parse(reportJson) + let packages = report.install || [] + + let rootEntry = packages.find(p => p.download_info?.dir_info !== undefined) + let rootRequires = rootEntry?.metadata?.requires_dist || [] + + let directDepNames = new Set() + for (let req of rootRequires) { + if (this._hasExtraMarker(req)) { continue } + let name = this._extractDepName(req) + if (name) { directDepNames.add(this._canonicalize(name)) } + } + + let graph = new Map() + let nonRootPackages = packages.filter(p => p.download_info?.dir_info === undefined) + + for (let pkg of nonRootPackages) { + let name = pkg.metadata.name + let version = pkg.metadata.version + let key = this._canonicalize(name) + graph.set(key, { name, version, children: [] }) + } + + for (let pkg of nonRootPackages) { + let key = this._canonicalize(pkg.metadata.name) + let entry = graph.get(key) + let requires = pkg.metadata.requires_dist || [] + for (let req of requires) { + if (this._hasExtraMarker(req)) { continue } + let depName = this._extractDepName(req) + if (!depName) { continue } + let depKey = this._canonicalize(depName) + if (graph.has(depKey)) { + entry.children.push(depKey) + } + } + } + + let directDeps = [...directDepNames].filter(key => graph.has(key)) + return { directDeps, graph } + } + + /** + * Check if a requires_dist entry is an extras-only dependency. + * @param {string} req - e.g. "PySocks!=1.5.7,>=1.5.6; extra == \"socks\"" + * @returns {boolean} + */ + _hasExtraMarker(req) { + return /;\s*.*extra\s*==/.test(req) + } + + /** + * Extract package name from a requires_dist entry. + * @param {string} req - e.g. "charset_normalizer<4,>=2" + * @returns {string|null} + */ + _extractDepName(req) { + let match = req.match(/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)/) + return match ? match[1] : null + } + + /** + * Resolve dependencies using pip install --dry-run --report. + * @param {string} manifestDir + * @param {object} parsed - parsed pyproject.toml + * @param {{}} [opts={}] + * @returns {Promise<{directDeps: string[], graph: Map}>} + */ + async _getDependencyData(manifestDir, parsed, opts) { + let reportOutput = this._getPipReportOutput(manifestDir, opts) + return this._parsePipReport(reportOutput) + } +} diff --git a/test/providers/python_pyproject.test.js b/test/providers/python_pyproject.test.js index 57d64dbc..9370b145 100644 --- a/test/providers/python_pyproject.test.js +++ b/test/providers/python_pyproject.test.js @@ -4,6 +4,7 @@ import path from 'path' import { expect } from 'chai' import { useFakeTimers } from 'sinon' +import Python_pip_pyproject from '../../src/providers/python_pip_pyproject.js' import Python_poetry from '../../src/providers/python_poetry.js' import Python_uv from '../../src/providers/python_uv.js' @@ -13,6 +14,7 @@ const TIMEOUT = process.env.GITHUB_ACTIONS ? 30000 : 10000 const uvProvider = new Python_uv() const poetryProvider = new Python_poetry() +const pipProvider = new Python_pip_pyproject() suite('testing the python-pyproject data provider', () => { [ @@ -184,6 +186,126 @@ suite('testing the python-pyproject data provider', () => { }).timeout(TIMEOUT) }) + /** Verifies the pip provider's validateLockFile always returns true (fallback). */ + test('verify pip validateLockFile always returns true (fallback provider)', () => { + expect(pipProvider.validateLockFile('test/providers/tst_manifests/pyproject/pip_pep621')).to.equal(true) + expect(pipProvider.validateLockFile('/nonexistent/dir')).to.equal(true) + }) + + suite('pip projects (via pip --dry-run --report)', () => { + const pipFixtureDir = 'test/providers/tst_manifests/pyproject/pip_pep621' + const pipIgnoreDir = 'test/providers/tst_manifests/pyproject/pip_pep621_ignore' + let savedEnv + + setup(() => { + savedEnv = process.env.TRUSTIFY_DA_PIP_REPORT + let report = fs.readFileSync(path.join(pipFixtureDir, 'pip_report.json'), 'utf-8') + process.env.TRUSTIFY_DA_PIP_REPORT = Buffer.from(report).toString('base64') + }) + + teardown(() => { + if (savedEnv === undefined) { + delete process.env.TRUSTIFY_DA_PIP_REPORT + } else { + process.env.TRUSTIFY_DA_PIP_REPORT = savedEnv + } + }) + + /** Verifies stack analysis produces correct SBOM with transitive deps. */ + test('verify pyproject.toml sbom provided for stack analysis with pip', async () => { + // Given a PEP 621 pyproject.toml and pre-recorded pip report + let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, 'expected_stack_sbom.json')).toString() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + + // When running stack analysis + let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) + + // Then the SBOM matches expected output + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + + /** Verifies component analysis produces correct SBOM with direct deps only. */ + test('verify pyproject.toml sbom provided for component analysis with pip', async () => { + // Given a PEP 621 pyproject.toml and pre-recorded pip report + let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, 'expected_component_sbom.json')).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + + // When running component analysis + let result = await pipProvider.provideComponent(path.join(pipFixtureDir, 'pyproject.toml')) + + // Then the SBOM matches expected output + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + + /** Verifies direct and transitive deps are correctly classified in stack SBOM. */ + test('stack analysis classifies direct and transitive dependencies correctly', async () => { + // When running stack analysis + let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) + let sbom = JSON.parse(result.content) + + // Then requests is a direct dep of the root + let rootDep = sbom.dependencies.find(d => d.ref.includes('/test-project@')) + expect(rootDep.dependsOn).to.have.lengthOf(1) + expect(rootDep.dependsOn[0]).to.include('/requests@') + + // And requests has its own transitive deps + let requestsDep = sbom.dependencies.find(d => d.ref.includes('/requests@')) + let transNames = requestsDep.dependsOn.map(d => d.split('/').pop().split('@')[0]) + expect(transNames).to.include('certifi') + expect(transNames).to.include('charset-normalizer') + expect(transNames).to.include('idna') + expect(transNames).to.include('urllib3') + }).timeout(TIMEOUT) + + /** Verifies extras-only dependencies (e.g. PySocks for socks extra) are excluded. */ + test('extras-only dependencies are filtered from the dependency tree', async () => { + let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) + let sbom = JSON.parse(result.content) + let names = sbom.components.map(c => c.name) + expect(names).to.not.include('PySocks') + expect(names).to.not.include('pysocks') + }).timeout(TIMEOUT) + + /** Verifies exhortignore marker in PEP 621 dependencies excludes the dep. */ + test('exhortignore marker excludes dep from component analysis', async () => { + // Given a pyproject.toml with requests marked as exhortignore + let result = await pipProvider.provideComponent(path.join(pipIgnoreDir, 'pyproject.toml')) + let sbom = JSON.parse(result.content) + + // Then requests is excluded + let names = sbom.components.map(c => c.name) + expect(names).to.not.include('requests') + }).timeout(TIMEOUT) + + /** Verifies exhortignore excludes dep and its exclusive transitive deps from stack analysis. */ + test('exhortignore marker excludes dep from stack analysis', async () => { + // Given a pyproject.toml with requests marked as exhortignore + let result = await pipProvider.provideStack(path.join(pipIgnoreDir, 'pyproject.toml')) + let sbom = JSON.parse(result.content) + + // Then requests and all its exclusive transitive deps are excluded + let names = sbom.components.map(c => c.name) + expect(names).to.not.include('requests') + }).timeout(TIMEOUT) + + /** Verifies name canonicalization (charset_normalizer → charset-normalizer). */ + test('name canonicalization: charset_normalizer resolved as charset-normalizer', async () => { + let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) + let sbom = JSON.parse(result.content) + let pkg = sbom.components.find(c => c.name === 'charset-normalizer') + expect(pkg).to.exist + expect(pkg.version).to.equal('3.4.7') + }).timeout(TIMEOUT) + }) + test('validateLockFile returns false when no lock file is present', () => { let tmpDir = 'test/providers/tst_manifests/pyproject/no_lock_file_dummy' fs.mkdirSync(tmpDir, { recursive: true }) diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json new file mode 100644 index 00000000..e0d8f0ce --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json @@ -0,0 +1,36 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "test-project", + "version": "1.0.0", + "purl": "pkg:pypi/test-project@1.0.0", + "type": "application", + "bom-ref": "pkg:pypi/test-project@1.0.0" + } + }, + "components": [ + { + "name": "requests", + "version": "2.33.1", + "purl": "pkg:pypi/requests@2.33.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.1" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/test-project@1.0.0", + "dependsOn": [ + "pkg:pypi/requests@2.33.1" + ] + }, + { + "ref": "pkg:pypi/requests@2.33.1", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json new file mode 100644 index 00000000..490d0d1a --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json @@ -0,0 +1,85 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "test-project", + "version": "1.0.0", + "purl": "pkg:pypi/test-project@1.0.0", + "type": "application", + "bom-ref": "pkg:pypi/test-project@1.0.0" + } + }, + "components": [ + { + "name": "requests", + "version": "2.33.1", + "purl": "pkg:pypi/requests@2.33.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.1" + }, + { + "name": "certifi", + "version": "2026.2.2", + "purl": "pkg:pypi/certifi@2026.2.2", + "type": "library", + "bom-ref": "pkg:pypi/certifi@2026.2.2" + }, + { + "name": "charset-normalizer", + "version": "3.4.7", + "purl": "pkg:pypi/charset-normalizer@3.4.7", + "type": "library", + "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" + }, + { + "name": "idna", + "version": "3.11", + "purl": "pkg:pypi/idna@3.11", + "type": "library", + "bom-ref": "pkg:pypi/idna@3.11" + }, + { + "name": "urllib3", + "version": "2.6.3", + "purl": "pkg:pypi/urllib3@2.6.3", + "type": "library", + "bom-ref": "pkg:pypi/urllib3@2.6.3" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/test-project@1.0.0", + "dependsOn": [ + "pkg:pypi/requests@2.33.1" + ] + }, + { + "ref": "pkg:pypi/requests@2.33.1", + "dependsOn": [ + "pkg:pypi/certifi@2026.2.2", + "pkg:pypi/charset-normalizer@3.4.7", + "pkg:pypi/idna@3.11", + "pkg:pypi/urllib3@2.6.3" + ] + }, + { + "ref": "pkg:pypi/certifi@2026.2.2", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/charset-normalizer@3.4.7", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/idna@3.11", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/urllib3@2.6.3", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json b/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json new file mode 100644 index 00000000..1350f80b --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json @@ -0,0 +1,62 @@ +{ + "version": "1", + "pip_version": "24.0", + "install": [ + { + "download_info": {"url": "file:///project", "dir_info": {}}, + "requested": true, + "metadata": { + "name": "test-project", + "version": "1.0.0", + "requires_dist": ["requests>=2.32"] + } + }, + { + "download_info": {"url": "https://pypi.org/requests-2.33.1.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "requests", + "version": "2.33.1", + "requires_dist": [ + "charset_normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", + "certifi>=2017.4.17", + "PySocks!=1.5.7,>=1.5.6; extra == \"socks\"" + ] + } + }, + { + "download_info": {"url": "https://pypi.org/charset_normalizer-3.4.7.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "charset-normalizer", + "version": "3.4.7" + } + }, + { + "download_info": {"url": "https://pypi.org/idna-3.11.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "idna", + "version": "3.11" + } + }, + { + "download_info": {"url": "https://pypi.org/urllib3-2.6.3.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "urllib3", + "version": "2.6.3" + } + }, + { + "download_info": {"url": "https://pypi.org/certifi-2026.2.2.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "certifi", + "version": "2026.2.2" + } + } + ] +} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml new file mode 100644 index 00000000..f66eb6c2 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "test-project" +version = "1.0.0" +requires-python = ">=3.12" +dependencies = [ + "requests>=2.32", +] diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json new file mode 100644 index 00000000..1350f80b --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json @@ -0,0 +1,62 @@ +{ + "version": "1", + "pip_version": "24.0", + "install": [ + { + "download_info": {"url": "file:///project", "dir_info": {}}, + "requested": true, + "metadata": { + "name": "test-project", + "version": "1.0.0", + "requires_dist": ["requests>=2.32"] + } + }, + { + "download_info": {"url": "https://pypi.org/requests-2.33.1.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "requests", + "version": "2.33.1", + "requires_dist": [ + "charset_normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", + "certifi>=2017.4.17", + "PySocks!=1.5.7,>=1.5.6; extra == \"socks\"" + ] + } + }, + { + "download_info": {"url": "https://pypi.org/charset_normalizer-3.4.7.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "charset-normalizer", + "version": "3.4.7" + } + }, + { + "download_info": {"url": "https://pypi.org/idna-3.11.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "idna", + "version": "3.11" + } + }, + { + "download_info": {"url": "https://pypi.org/urllib3-2.6.3.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "urllib3", + "version": "2.6.3" + } + }, + { + "download_info": {"url": "https://pypi.org/certifi-2026.2.2.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "certifi", + "version": "2026.2.2" + } + } + ] +} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml new file mode 100644 index 00000000..e9bd6a27 --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "test-project" +version = "1.0.0" +requires-python = ">=3.12" +dependencies = [ + "requests>=2.32", #exhortignore +] From 5aed939da2a2171be02141260ba0e048cbdb3268 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 14 Apr 2026 15:36:05 +0200 Subject: [PATCH 02/11] fix(python): align pip pyproject _getDependencyData signature with base class Python_pip_pyproject._getDependencyData() had only 3 parameters (manifestDir, parsed, opts) instead of the 4 defined by the base class (manifestDir, workspaceDir, parsed, opts). When called from _createSbom() with 4 arguments, workspaceDir was silently received as parsed, and parsed was received as opts, causing argument misalignment. Add the missing _workspaceDir parameter to match the base class contract and sibling providers (python_uv, python_poetry). Implements TC-4098 Assisted-by: Claude Code --- src/providers/python_pip_pyproject.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/providers/python_pip_pyproject.js b/src/providers/python_pip_pyproject.js index a5c0a573..7c6d8e55 100644 --- a/src/providers/python_pip_pyproject.js +++ b/src/providers/python_pip_pyproject.js @@ -121,11 +121,13 @@ export default class Python_pip_pyproject extends Base_pyproject { /** * Resolve dependencies using pip install --dry-run --report. * @param {string} manifestDir + * @param {string} _workspaceDir - unused (pip resolves from manifest directory) * @param {object} parsed - parsed pyproject.toml * @param {{}} [opts={}] * @returns {Promise<{directDeps: string[], graph: Map}>} */ - async _getDependencyData(manifestDir, parsed, opts) { + // eslint-disable-next-line no-unused-vars + async _getDependencyData(manifestDir, _workspaceDir, parsed, opts) { let reportOutput = this._getPipReportOutput(manifestDir, opts) return this._parsePipReport(reportOutput) } From 475deea3af9e70a6b5eb2a7044667c54507bd3e7 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 14 Apr 2026 16:18:48 +0200 Subject: [PATCH 03/11] fix(python): only exclude root entry from pip report graph, not all dir_info entries _parsePipReport() filtered nonRootPackages by checking p.download_info?.dir_info === undefined, which excluded ALL packages with dir_info (including local/path dependencies). Changed to filter by identity (p !== rootEntry) so only the specific root project entry is excluded, matching the Java implementation's behavior. Implements TC-4099 Assisted-by: Claude Code --- src/providers/python_pip_pyproject.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/python_pip_pyproject.js b/src/providers/python_pip_pyproject.js index 7c6d8e55..2ce774f2 100644 --- a/src/providers/python_pip_pyproject.js +++ b/src/providers/python_pip_pyproject.js @@ -71,7 +71,7 @@ export default class Python_pip_pyproject extends Base_pyproject { } let graph = new Map() - let nonRootPackages = packages.filter(p => p.download_info?.dir_info === undefined) + let nonRootPackages = packages.filter(p => p !== rootEntry) for (let pkg of nonRootPackages) { let name = pkg.metadata.name From 3ef9468d9f434d00d5c7214a25ddfa400401761c Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Tue, 14 Apr 2026 17:14:27 +0200 Subject: [PATCH 04/11] refactor(test): parameterize pyproject SBOM tests and add doc comments Extract shared SBOM_CASES constant and use forEach loops to eliminate repetitive stack/component test pairs across uv, poetry, and pip suites. Parameterize validateLockFile and workspace tests similarly. Add JSDoc comments to all 39 test functions. Implements TC-4065 Co-Authored-By: Claude Opus 4.6 --- test/providers/python_pyproject.test.js | 365 +++++++++--------------- 1 file changed, 139 insertions(+), 226 deletions(-) diff --git a/test/providers/python_pyproject.test.js b/test/providers/python_pyproject.test.js index 9370b145..bb57a69d 100644 --- a/test/providers/python_pyproject.test.js +++ b/test/providers/python_pyproject.test.js @@ -16,7 +16,15 @@ const uvProvider = new Python_uv() const poetryProvider = new Python_poetry() const pipProvider = new Python_pip_pyproject() +const MANIFESTS = 'test/providers/tst_manifests/pyproject' + +const SBOM_CASES = [ + {type: 'stack', method: 'provideStack', fixture: 'expected_stack_sbom.json'}, + {type: 'component', method: 'provideComponent', fixture: 'expected_component_sbom.json'}, +] + suite('testing the python-pyproject data provider', () => { + /** Verifies isSupported correctly identifies pyproject.toml manifests. */ [ {name: 'pyproject.toml', expected: true}, {name: 'requirements.txt', expected: false}, @@ -25,49 +33,40 @@ suite('testing the python-pyproject data provider', () => { test(`verify isSupported returns ${testCase.expected} for ${testCase.name}`, () => expect(uvProvider.isSupported(testCase.name)).to.equal(testCase.expected) ) - }) - - test('verify uv validateLockFile returns true when uv.lock exists', () => { - expect(uvProvider.validateLockFile('test/providers/tst_manifests/pyproject/uv_lock')).to.equal(true) - }) - - test('verify uv validateLockFile returns false when uv.lock is missing', () => { - expect(uvProvider.validateLockFile('test/providers/tst_manifests/pyproject/poetry_lock')).to.equal(false) - }) - - test('verify poetry validateLockFile returns true when poetry.lock exists', () => { - expect(poetryProvider.validateLockFile('test/providers/tst_manifests/pyproject/poetry_lock')).to.equal(true) - }) + }); - test('verify poetry validateLockFile returns false when poetry.lock is missing', () => { - expect(poetryProvider.validateLockFile('test/providers/tst_manifests/pyproject/uv_lock')).to.equal(false) + /** Verifies each provider's validateLockFile detects or rejects its lock file. */ + [ + {provider: uvProvider, name: 'uv', dir: 'uv_lock', expected: true}, + {provider: uvProvider, name: 'uv', dir: 'poetry_lock', expected: false}, + {provider: poetryProvider, name: 'poetry', dir: 'poetry_lock', expected: true}, + {provider: poetryProvider, name: 'poetry', dir: 'uv_lock', expected: false}, + ].forEach(({provider, name, dir, expected}) => { + test(`verify ${name} validateLockFile returns ${expected} for ${dir}`, () => { + expect(provider.validateLockFile(`${MANIFESTS}/${dir}`)).to.equal(expected) + }) }) suite('uv projects (via uv export)', () => { - test('verify pyproject.toml sbom provided for stack analysis with uv', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json').toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideStack('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify pyproject.toml sbom provided for component analysis with uv', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json').toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideComponent('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) + const fixtureDir = `${MANIFESTS}/pep621_ignore_and_extras` + + /** Verifies stack and component SBOM output matches expected fixtures. */ + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify pyproject.toml sbom provided for ${type} analysis with uv`, async () => { + let expectedSbom = fs.readFileSync(path.join(fixtureDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await uvProvider[method](path.join(fixtureDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + }) + /** Verifies exhortignore and trustify-da-ignore markers exclude deps from component analysis. */ test('exhortignore and trustify-da-ignore exclude deps from component analysis', async () => { - let result = await uvProvider.provideComponent('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/pyproject.toml') + let result = await uvProvider.provideComponent(path.join(fixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) let names = sbom.components.map(c => c.name) expect(names).to.not.include('uvicorn') @@ -76,8 +75,9 @@ suite('testing the python-pyproject data provider', () => { expect(names).to.include('requests') }).timeout(TIMEOUT) + /** Verifies ignored transitive deps are pruned from the stack dependency tree. */ test('ignored transitive dep excluded from stack analysis tree', async () => { - let result = await uvProvider.provideStack('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/pyproject.toml') + let result = await uvProvider.provideStack(path.join(fixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) let names = sbom.components.map(c => c.name) expect(names).to.not.include('uvicorn') @@ -87,8 +87,9 @@ suite('testing the python-pyproject data provider', () => { expect(jinja2Dep.dependsOn).to.deep.equal([]) }).timeout(TIMEOUT) + /** Verifies name canonicalization normalizes underscores to hyphens. */ test('name canonicalization: typing_extensions matches typing-extensions', async () => { - let result = await uvProvider.provideComponent('test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/pyproject.toml') + let result = await uvProvider.provideComponent(path.join(fixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) let typingExt = sbom.components.find(c => c.name === 'typing-extensions') expect(typingExt).to.exist @@ -97,54 +98,43 @@ suite('testing the python-pyproject data provider', () => { }) suite('uv projects - uv_lock manifest', () => { - test('verify pyproject.toml sbom provided for stack analysis with uv_lock', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/uv_lock/expected_stack_sbom.json').toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideStack('test/providers/tst_manifests/pyproject/uv_lock/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify pyproject.toml sbom provided for component analysis with uv_lock', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/uv_lock/expected_component_sbom.json').toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideComponent('test/providers/tst_manifests/pyproject/uv_lock/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) + const fixtureDir = `${MANIFESTS}/uv_lock` + + /** Verifies stack and component SBOM output matches expected fixtures. */ + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify pyproject.toml sbom provided for ${type} analysis with uv_lock`, async () => { + let expectedSbom = fs.readFileSync(path.join(fixtureDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await uvProvider[method](path.join(fixtureDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + }) }) suite('poetry projects (via poetry show)', () => { - test('verify pyproject.toml sbom provided for stack analysis with poetry', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/poetry_lock/expected_stack_sbom.json').toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await poetryProvider.provideStack('test/providers/tst_manifests/pyproject/poetry_lock/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify pyproject.toml sbom provided for component analysis with poetry', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json').toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await poetryProvider.provideComponent('test/providers/tst_manifests/pyproject/poetry_lock/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) + const fixtureDir = `${MANIFESTS}/poetry_lock` + + /** Verifies stack and component SBOM output matches expected fixtures. */ + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify pyproject.toml sbom provided for ${type} analysis with poetry`, async () => { + let expectedSbom = fs.readFileSync(path.join(fixtureDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await poetryProvider[method](path.join(fixtureDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + }) + /** Verifies resolved versions come from poetry show --all, not dependency constraints. */ test('resolved versions come from poetry show --all, not constraints', async () => { - let result = await poetryProvider.provideStack('test/providers/tst_manifests/pyproject/poetry_lock/pyproject.toml') + let result = await poetryProvider.provideStack(path.join(fixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) let markupsafe = sbom.components.find(c => c.name === 'markupsafe') expect(markupsafe.version).to.equal('3.0.3') @@ -152,8 +142,9 @@ suite('testing the python-pyproject data provider', () => { expect(urllib3.version).to.equal('2.6.3') }).timeout(TIMEOUT) + /** Verifies exhortignore filtering excludes click and its exclusive transitive deps. */ test('exhortignore filtering excludes click and its exclusive transitive deps', async () => { - let result = await poetryProvider.provideStack('test/providers/tst_manifests/pyproject/poetry_lock/pyproject.toml') + let result = await poetryProvider.provideStack(path.join(fixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) let names = sbom.components.map(c => c.name) expect(names).to.not.include('click') @@ -163,38 +154,32 @@ suite('testing the python-pyproject data provider', () => { }) suite('poetry projects - poetry_only_deps manifest', () => { - test('verify pyproject.toml sbom provided for stack analysis with poetry_only_deps', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/poetry_only_deps/expected_stack_sbom.json').toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await poetryProvider.provideStack('test/providers/tst_manifests/pyproject/poetry_only_deps/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify pyproject.toml sbom provided for component analysis with poetry_only_deps', async () => { - let expectedSbom = fs.readFileSync('test/providers/tst_manifests/pyproject/poetry_only_deps/expected_component_sbom.json').toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await poetryProvider.provideComponent('test/providers/tst_manifests/pyproject/poetry_only_deps/pyproject.toml') - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) + const fixtureDir = `${MANIFESTS}/poetry_only_deps` + + /** Verifies stack and component SBOM output matches expected fixtures. */ + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify pyproject.toml sbom provided for ${type} analysis with poetry_only_deps`, async () => { + let expectedSbom = fs.readFileSync(path.join(fixtureDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await poetryProvider[method](path.join(fixtureDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + }) }) /** Verifies the pip provider's validateLockFile always returns true (fallback). */ test('verify pip validateLockFile always returns true (fallback provider)', () => { - expect(pipProvider.validateLockFile('test/providers/tst_manifests/pyproject/pip_pep621')).to.equal(true) + expect(pipProvider.validateLockFile(`${MANIFESTS}/pip_pep621`)).to.equal(true) expect(pipProvider.validateLockFile('/nonexistent/dir')).to.equal(true) }) suite('pip projects (via pip --dry-run --report)', () => { - const pipFixtureDir = 'test/providers/tst_manifests/pyproject/pip_pep621' - const pipIgnoreDir = 'test/providers/tst_manifests/pyproject/pip_pep621_ignore' + const pipFixtureDir = `${MANIFESTS}/pip_pep621` + const pipIgnoreDir = `${MANIFESTS}/pip_pep621_ignore` let savedEnv setup(() => { @@ -211,52 +196,27 @@ suite('testing the python-pyproject data provider', () => { } }) - /** Verifies stack analysis produces correct SBOM with transitive deps. */ - test('verify pyproject.toml sbom provided for stack analysis with pip', async () => { - // Given a PEP 621 pyproject.toml and pre-recorded pip report - let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, 'expected_stack_sbom.json')).toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - - // When running stack analysis - let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) - - // Then the SBOM matches expected output - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - /** Verifies component analysis produces correct SBOM with direct deps only. */ - test('verify pyproject.toml sbom provided for component analysis with pip', async () => { - // Given a PEP 621 pyproject.toml and pre-recorded pip report - let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, 'expected_component_sbom.json')).toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - - // When running component analysis - let result = await pipProvider.provideComponent(path.join(pipFixtureDir, 'pyproject.toml')) - - // Then the SBOM matches expected output - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) + /** Verifies stack and component SBOM output matches expected pip fixtures. */ + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify pyproject.toml sbom provided for ${type} analysis with pip`, async () => { + let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await pipProvider[method](path.join(pipFixtureDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + }) /** Verifies direct and transitive deps are correctly classified in stack SBOM. */ test('stack analysis classifies direct and transitive dependencies correctly', async () => { - // When running stack analysis let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) - - // Then requests is a direct dep of the root let rootDep = sbom.dependencies.find(d => d.ref.includes('/test-project@')) expect(rootDep.dependsOn).to.have.lengthOf(1) expect(rootDep.dependsOn[0]).to.include('/requests@') - - // And requests has its own transitive deps let requestsDep = sbom.dependencies.find(d => d.ref.includes('/requests@')) let transNames = requestsDep.dependsOn.map(d => d.split('/').pop().split('@')[0]) expect(transNames).to.include('certifi') @@ -276,27 +236,21 @@ suite('testing the python-pyproject data provider', () => { /** Verifies exhortignore marker in PEP 621 dependencies excludes the dep. */ test('exhortignore marker excludes dep from component analysis', async () => { - // Given a pyproject.toml with requests marked as exhortignore let result = await pipProvider.provideComponent(path.join(pipIgnoreDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) - - // Then requests is excluded let names = sbom.components.map(c => c.name) expect(names).to.not.include('requests') }).timeout(TIMEOUT) /** Verifies exhortignore excludes dep and its exclusive transitive deps from stack analysis. */ test('exhortignore marker excludes dep from stack analysis', async () => { - // Given a pyproject.toml with requests marked as exhortignore let result = await pipProvider.provideStack(path.join(pipIgnoreDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) - - // Then requests and all its exclusive transitive deps are excluded let names = sbom.components.map(c => c.name) expect(names).to.not.include('requests') }).timeout(TIMEOUT) - /** Verifies name canonicalization (charset_normalizer → charset-normalizer). */ + /** Verifies name canonicalization (charset_normalizer -> charset-normalizer). */ test('name canonicalization: charset_normalizer resolved as charset-normalizer', async () => { let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) let sbom = JSON.parse(result.content) @@ -306,8 +260,9 @@ suite('testing the python-pyproject data provider', () => { }).timeout(TIMEOUT) }) + /** Verifies uv and poetry validateLockFile returns false when no lock file is present. */ test('validateLockFile returns false when no lock file is present', () => { - let tmpDir = 'test/providers/tst_manifests/pyproject/no_lock_file_dummy' + let tmpDir = `${MANIFESTS}/no_lock_file_dummy` fs.mkdirSync(tmpDir, { recursive: true }) fs.writeFileSync(`${tmpDir}/pyproject.toml`, '[project]\nname = "test"\nversion = "1.0.0"\ndependencies = ["requests>=2.0"]') @@ -320,18 +275,20 @@ suite('testing the python-pyproject data provider', () => { }) suite('workspace/monorepo support', () => { - const uvWorkspace = 'test/providers/tst_manifests/pyproject/uv_workspace' + const uvWorkspace = `${MANIFESTS}/uv_workspace` + /** Verifies uv walks up to parent directory to find uv.lock. */ test('uv validateLockFile finds uv.lock in parent directory', () => { expect(uvProvider.validateLockFile( path.join(uvWorkspace, 'packages/sub-pkg') )).to.equal(true) }) + /** Verifies poetry does not walk up directories since it has no native workspace support. */ test('poetry validateLockFile does not walk up to parent directory', () => { // Poetry has no native workspace support (python-poetry/poetry#2270). // Each poetry project is treated independently — no lock file walk-up. - let tmpDir = 'test/providers/tst_manifests/pyproject/boundary_test_poetry' + let tmpDir = `${MANIFESTS}/boundary_test_poetry` let subDir = path.join(tmpDir, 'packages', 'child') fs.mkdirSync(subDir, { recursive: true }) fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), @@ -340,18 +297,17 @@ suite('testing the python-pyproject data provider', () => { fs.writeFileSync(path.join(subDir, 'pyproject.toml'), '[tool.poetry]\nname = "child"\nversion = "0.1.0"\n') try { - // poetry.lock exists at root but poetry should NOT walk up expect(poetryProvider.validateLockFile(subDir)).to.equal(false) } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) } }) + /** Verifies lock file search stops at uv workspace root when uv.lock is absent. */ test('validateLockFile stops at uv workspace root boundary when lock file is absent', () => { - let tmpDir = 'test/providers/tst_manifests/pyproject/boundary_test' + let tmpDir = `${MANIFESTS}/boundary_test` let subDir = path.join(tmpDir, 'packages', 'child') fs.mkdirSync(subDir, { recursive: true }) - // root has workspace marker but no lock file fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[tool.uv.workspace]\nmembers = ["packages/*"]\n') fs.writeFileSync(path.join(subDir, 'pyproject.toml'), @@ -363,84 +319,41 @@ suite('testing the python-pyproject data provider', () => { } }) + /** Verifies TRUSTIFY_DA_WORKSPACE_DIR override redirects lock file search. */ test('TRUSTIFY_DA_WORKSPACE_DIR override directs lock file search', () => { let overrideDir = path.resolve(uvWorkspace) expect(uvProvider.validateLockFile( - 'test/providers/tst_manifests/pyproject/poetry_lock', + `${MANIFESTS}/poetry_lock`, { TRUSTIFY_DA_WORKSPACE_DIR: overrideDir } )).to.equal(true) expect(uvProvider.validateLockFile( - 'test/providers/tst_manifests/pyproject/poetry_lock', + `${MANIFESTS}/poetry_lock`, { TRUSTIFY_DA_WORKSPACE_DIR: '/nonexistent/dir' } )).to.equal(false) - }) - - test('verify uv workspace root stack analysis', async () => { - let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'expected_stack_sbom.json')).toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideStack(path.join(uvWorkspace, 'pyproject.toml')) - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom + }); + + /** Verifies SBOM output for each workspace package (root, mid-pkg, sub-pkg). */ + [ + {manifestPath: '', label: 'root'}, + {manifestPath: 'packages/mid-pkg', label: 'mid-package'}, + {manifestPath: 'packages/sub-pkg', label: 'sub-package'}, + ].forEach(({manifestPath, label}) => { + const pkgDir = manifestPath ? path.join(uvWorkspace, manifestPath) : uvWorkspace + + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify uv workspace ${label} ${type} analysis`, async () => { + let expectedSbom = fs.readFileSync(path.join(pkgDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await uvProvider[method](path.join(pkgDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) }) - }).timeout(TIMEOUT) - - test('verify uv workspace root component analysis', async () => { - let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'expected_component_sbom.json')).toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'pyproject.toml')) - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify uv workspace mid-package stack analysis', async () => { - let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/mid-pkg/expected_stack_sbom.json')).toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideStack(path.join(uvWorkspace, 'packages/mid-pkg/pyproject.toml')) - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify uv workspace mid-package component analysis', async () => { - let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/mid-pkg/expected_component_sbom.json')).toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'packages/mid-pkg/pyproject.toml')) - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify uv workspace sub-package stack analysis', async () => { - let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/sub-pkg/expected_stack_sbom.json')).toString() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideStack(path.join(uvWorkspace, 'packages/sub-pkg/pyproject.toml')) - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) - - test('verify uv workspace sub-package component analysis', async () => { - let expectedSbom = fs.readFileSync(path.join(uvWorkspace, 'packages/sub-pkg/expected_component_sbom.json')).toString().trim() - expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) - let result = await uvProvider.provideComponent(path.join(uvWorkspace, 'packages/sub-pkg/pyproject.toml')) - expect(result).to.deep.equal({ - ecosystem: 'pip', - contentType: 'application/vnd.cyclonedx+json', - content: expectedSbom - }) - }).timeout(TIMEOUT) + }) }) }).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore()) From d3525b16a50c6fb1ac1edd6454a4d35f2e6b20c2 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 15 Apr 2026 16:03:20 +0200 Subject: [PATCH 05/11] test(pip): add extras package to pip fixture for realistic extras filtering Add requests[socks] to the test pyproject.toml and include PySocks in the pip_report.json install list. This makes the extras-filtering test exercise the actual code path: PySocks is now present in the report but correctly excluded from the SBOM via _hasExtraMarker. Previously the test trivially passed because PySocks was never in the install list. Implements TC-4065 Co-Authored-By: Claude Opus 4.6 --- .../tst_manifests/pyproject/pip_pep621/pip_report.json | 10 +++++++++- .../tst_manifests/pyproject/pip_pep621/pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json b/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json index 1350f80b..c2f15c4f 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json +++ b/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json @@ -8,7 +8,7 @@ "metadata": { "name": "test-project", "version": "1.0.0", - "requires_dist": ["requests>=2.32"] + "requires_dist": ["requests[socks]>=2.32"] } }, { @@ -57,6 +57,14 @@ "name": "certifi", "version": "2026.2.2" } + }, + { + "download_info": {"url": "https://pypi.org/PySocks-1.7.1.whl", "archive_info": {}}, + "requested": false, + "metadata": { + "name": "PySocks", + "version": "1.7.1" + } } ] } diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml index f66eb6c2..7ca39b30 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml +++ b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml @@ -3,5 +3,5 @@ name = "test-project" version = "1.0.0" requires-python = ">=3.12" dependencies = [ - "requests>=2.32", + "requests[socks]>=2.32", ] From 18a8947b39434a91b7b2e291e68506e6272e31db Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 15 Apr 2026 16:26:41 +0200 Subject: [PATCH 06/11] test(pip): call pip directly instead of using env var overrides Remove TRUSTIFY_DA_PIP_REPORT setup/teardown from pip tests and let them invoke pip directly. Pin fixture pyproject.toml versions to get deterministic output, add --quiet flag to suppress non-JSON stdout, and update expected SBOMs with real resolved versions. Add testing convention to CONVENTIONS.md and ignore *.egg-info build artifacts. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + CONVENTIONS.md | 98 +++++++++++++++++++ src/providers/python_pip_pyproject.js | 2 +- test/providers/python_pyproject.test.js | 17 +--- .../pip_pep621/expected_component_sbom.json | 10 +- .../pip_pep621/expected_stack_sbom.json | 20 ++-- .../pyproject/pip_pep621/pip_report.json | 70 ------------- .../pyproject/pip_pep621/pyproject.toml | 2 +- .../pip_pep621_ignore/pip_report.json | 62 ------------ .../pip_pep621_ignore/pyproject.toml | 2 +- 10 files changed, 118 insertions(+), 166 deletions(-) create mode 100644 CONVENTIONS.md delete mode 100644 test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json delete mode 100644 test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json diff --git a/.gitignore b/.gitignore index 50a69abc..008c5a75 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build target .npmrc src/providers/*.wasm +*.egg-info diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 00000000..872d04fc --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,98 @@ +# Coding Conventions + + + +## Language and Framework + +- **Primary Language**: JavaScript (ES modules, `"type": "module"` in package.json) +- **TypeScript**: Configuration present but code is primarily JavaScript with JSDoc +- **Node.js**: Requires Node >= 20.0.0, npm >= 11.5.1 +- **CLI**: `yargs` for command-line argument parsing +- **Parsing Libraries**: `fast-xml-parser`, `fast-toml`, `smol-toml`, `tree-sitter-requirements` + +## Code Style + +- **Linter**: ESLint with recommended config + editorconfig + import plugins +- **Indentation**: Tabs (4 spaces for YAML/Markdown) +- **Line endings**: LF +- **Max line length**: 100 (120 for Markdown) +- **Charset**: UTF-8, final newline, trim trailing whitespace +- **Import ordering** (ESLint enforced): builtin, external, internal, parent, sibling, index — alphabetical within groups +- **Strict equality**: `eqeqeq: ["warn", "always", {"null": "never"}]` +- **Curly braces**: Required (`curly: "warn"`) +- **No throw literals**: `no-throw-literal: "warn"` +- **No Prettier** — ESLint + EditorConfig handle formatting + +## Naming Conventions + +- **Classes**: PascalCase with underscore-separated language names (`Java_maven`, `Base_java`, `Javascript_npm`) +- **Files**: snake_case for providers (`base_java.js`, `javascript_npm.js`, `python_pip.js`) +- **Test files**: `*.test.js` suffix (`analysis.test.js`, `provider.test.js`) +- **Functions/Methods**: camelCase (`provideComponent()`, `provideStack()`, `validateLockFile()`) +- **Variables**: camelCase (`manifestPath`, `backendUrl`) +- **Constants**: UPPER_SNAKE_CASE (`ecosystem_maven`, `DEFAULT_WORKSPACE_DISCOVERY_IGNORE`) +- **Private class fields**: `#` prefix (`#manifest`, `#cmd`, `#ecosystem`) +- **Protected methods**: `_` prefix (`_lockFileName()`, `_cmdName()`, `_listCmdArgs()`) + +## File Organization + +``` +src/ +├── index.js # Main export +├── cli.js # CLI entry point +├── analysis.js # API request handling +├── provider.js # Provider matching logic +├── workspace.js # Workspace discovery +├── tools.js # Utilities +├── sbom.js # SBOM handling +├── cyclone_dx_sbom.js # CycloneDX SBOM generation +├── providers/ # Ecosystem providers +│ ├── base_java.js +│ ├── base_javascript.js +│ ├── java_maven.js +│ ├── javascript_npm.js +│ ├── python_pip.js +│ ├── rust_cargo.js +│ └── processors/ # Specialized processors +├── license/ # License detection +└── oci_image/ # OCI image analysis + +test/ +├── analysis.test.js +├── provider.test.js +├── tools.test.js +└── providers/ # Provider-specific tests +``` + +## Error Handling + +- **Throw Error objects**: `throw new Error("message")`, `throw new TypeError("message")` +- **No custom error classes** — uses built-in `Error` and `TypeError` +- **HTTP errors**: Check `resp.status`, throw with status code and response text +- **Async errors**: Bubble up naturally via async/await (no blanket try-catch) +- **Validation errors**: Thrown early with descriptive context (manifest type, lock file) + +## Testing Conventions + +- **Framework**: Mocha with TDD UI (`suite()` / `test()`) +- **Assertions**: Chai with `expect()` syntax +- **Mocking**: Sinon for stubs; MSW (Mock Service Worker) for HTTP mocking +- **Module mocking**: `esmock` with experimental loader +- **Coverage**: C8 with 82% line coverage requirement +- **Test patterns**: `expect(res).to.deep.equal(...)`, `expect(() => ...).to.throw('message')` +- **Higher-order setup**: Functions like `interceptAndRun()` for test setup/teardown +- **Prefer real tool invocations over env var overrides**: Tests should call the actual ecosystem tools (pip, uv, poetry, mvn, npm, etc.) rather than injecting pre-recorded output via `TRUSTIFY_DA_*` environment variables. The CI environment has these tools available. Env var overrides (`TRUSTIFY_DA_PIP_REPORT`, `TRUSTIFY_DA_UV_EXPORT`, etc.) exist for users who lack the tool locally, but tests should exercise the real tool path to catch integration issues. + +## Commit Messages + +- Likely Conventional Commits format +- DCO (Developer Certificate of Origin) required +- Semantic versioning (`0.3.0` in package.json) + +## Dependencies + +- **Package manager**: npm with `package-lock.json` +- **Module system**: ES modules with explicit `.js` extensions in relative imports +- **Import convention**: `import fs from 'node:fs'` (node: protocol for built-ins) +- **Environment variables**: Prefixed with `TRUSTIFY_DA_` (e.g., `TRUSTIFY_DA_MVN_PATH`, `TRUSTIFY_DA_TOKEN`, `TRUSTIFY_DA_DEBUG`) +- **Multi-ecosystem support**: npm, pnpm, yarn, Maven, Gradle, pip, cargo, Go modules, Docker/Podman diff --git a/src/providers/python_pip_pyproject.js b/src/providers/python_pip_pyproject.js index 2ce774f2..caa8f700 100644 --- a/src/providers/python_pip_pyproject.js +++ b/src/providers/python_pip_pyproject.js @@ -47,7 +47,7 @@ export default class Python_pip_pyproject extends Base_pyproject { pipBin = getCustomPath('pip', opts) } return invokeCommand(pipBin, [ - 'install', '--dry-run', '--ignore-installed', '--report', '-', '.' + 'install', '--dry-run', '--ignore-installed', '--quiet', '--report', '-', '.' ], { cwd: manifestDir }).toString() } diff --git a/test/providers/python_pyproject.test.js b/test/providers/python_pyproject.test.js index bb57a69d..7dbd77ff 100644 --- a/test/providers/python_pyproject.test.js +++ b/test/providers/python_pyproject.test.js @@ -10,7 +10,7 @@ import Python_uv from '../../src/providers/python_uv.js' let clock -const TIMEOUT = process.env.GITHUB_ACTIONS ? 30000 : 10000 +const TIMEOUT = process.env.GITHUB_ACTIONS ? 30000 : 15000 const uvProvider = new Python_uv() const poetryProvider = new Python_poetry() @@ -180,21 +180,6 @@ suite('testing the python-pyproject data provider', () => { suite('pip projects (via pip --dry-run --report)', () => { const pipFixtureDir = `${MANIFESTS}/pip_pep621` const pipIgnoreDir = `${MANIFESTS}/pip_pep621_ignore` - let savedEnv - - setup(() => { - savedEnv = process.env.TRUSTIFY_DA_PIP_REPORT - let report = fs.readFileSync(path.join(pipFixtureDir, 'pip_report.json'), 'utf-8') - process.env.TRUSTIFY_DA_PIP_REPORT = Buffer.from(report).toString('base64') - }) - - teardown(() => { - if (savedEnv === undefined) { - delete process.env.TRUSTIFY_DA_PIP_REPORT - } else { - process.env.TRUSTIFY_DA_PIP_REPORT = savedEnv - } - }) /** Verifies stack and component SBOM output matches expected pip fixtures. */ SBOM_CASES.forEach(({type, method, fixture}) => { diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json index e0d8f0ce..583a8dff 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json +++ b/test/providers/tst_manifests/pyproject/pip_pep621/expected_component_sbom.json @@ -15,21 +15,21 @@ "components": [ { "name": "requests", - "version": "2.33.1", - "purl": "pkg:pypi/requests@2.33.1", + "version": "2.32.3", + "purl": "pkg:pypi/requests@2.32.3", "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.1" + "bom-ref": "pkg:pypi/requests@2.32.3" } ], "dependencies": [ { "ref": "pkg:pypi/test-project@1.0.0", "dependsOn": [ - "pkg:pypi/requests@2.33.1" + "pkg:pypi/requests@2.32.3" ] }, { - "ref": "pkg:pypi/requests@2.33.1", + "ref": "pkg:pypi/requests@2.32.3", "dependsOn": [] } ] diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json index 490d0d1a..8f92848d 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json @@ -15,17 +15,17 @@ "components": [ { "name": "requests", - "version": "2.33.1", - "purl": "pkg:pypi/requests@2.33.1", + "version": "2.32.3", + "purl": "pkg:pypi/requests@2.32.3", "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.1" + "bom-ref": "pkg:pypi/requests@2.32.3" }, { "name": "certifi", - "version": "2026.2.2", - "purl": "pkg:pypi/certifi@2026.2.2", + "version": "2026.2.25", + "purl": "pkg:pypi/certifi@2026.2.25", "type": "library", - "bom-ref": "pkg:pypi/certifi@2026.2.2" + "bom-ref": "pkg:pypi/certifi@2026.2.25" }, { "name": "charset-normalizer", @@ -53,20 +53,20 @@ { "ref": "pkg:pypi/test-project@1.0.0", "dependsOn": [ - "pkg:pypi/requests@2.33.1" + "pkg:pypi/requests@2.32.3" ] }, { - "ref": "pkg:pypi/requests@2.33.1", + "ref": "pkg:pypi/requests@2.32.3", "dependsOn": [ - "pkg:pypi/certifi@2026.2.2", + "pkg:pypi/certifi@2026.2.25", "pkg:pypi/charset-normalizer@3.4.7", "pkg:pypi/idna@3.11", "pkg:pypi/urllib3@2.6.3" ] }, { - "ref": "pkg:pypi/certifi@2026.2.2", + "ref": "pkg:pypi/certifi@2026.2.25", "dependsOn": [] }, { diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json b/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json deleted file mode 100644 index c2f15c4f..00000000 --- a/test/providers/tst_manifests/pyproject/pip_pep621/pip_report.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "version": "1", - "pip_version": "24.0", - "install": [ - { - "download_info": {"url": "file:///project", "dir_info": {}}, - "requested": true, - "metadata": { - "name": "test-project", - "version": "1.0.0", - "requires_dist": ["requests[socks]>=2.32"] - } - }, - { - "download_info": {"url": "https://pypi.org/requests-2.33.1.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "requests", - "version": "2.33.1", - "requires_dist": [ - "charset_normalizer<4,>=2", - "idna<4,>=2.5", - "urllib3<3,>=1.21.1", - "certifi>=2017.4.17", - "PySocks!=1.5.7,>=1.5.6; extra == \"socks\"" - ] - } - }, - { - "download_info": {"url": "https://pypi.org/charset_normalizer-3.4.7.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "charset-normalizer", - "version": "3.4.7" - } - }, - { - "download_info": {"url": "https://pypi.org/idna-3.11.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "idna", - "version": "3.11" - } - }, - { - "download_info": {"url": "https://pypi.org/urllib3-2.6.3.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "urllib3", - "version": "2.6.3" - } - }, - { - "download_info": {"url": "https://pypi.org/certifi-2026.2.2.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "certifi", - "version": "2026.2.2" - } - }, - { - "download_info": {"url": "https://pypi.org/PySocks-1.7.1.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "PySocks", - "version": "1.7.1" - } - } - ] -} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml index 7ca39b30..7d9b8920 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml +++ b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml @@ -3,5 +3,5 @@ name = "test-project" version = "1.0.0" requires-python = ">=3.12" dependencies = [ - "requests[socks]>=2.32", + "requests[socks]==2.32.3", ] diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json deleted file mode 100644 index 1350f80b..00000000 --- a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pip_report.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "version": "1", - "pip_version": "24.0", - "install": [ - { - "download_info": {"url": "file:///project", "dir_info": {}}, - "requested": true, - "metadata": { - "name": "test-project", - "version": "1.0.0", - "requires_dist": ["requests>=2.32"] - } - }, - { - "download_info": {"url": "https://pypi.org/requests-2.33.1.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "requests", - "version": "2.33.1", - "requires_dist": [ - "charset_normalizer<4,>=2", - "idna<4,>=2.5", - "urllib3<3,>=1.21.1", - "certifi>=2017.4.17", - "PySocks!=1.5.7,>=1.5.6; extra == \"socks\"" - ] - } - }, - { - "download_info": {"url": "https://pypi.org/charset_normalizer-3.4.7.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "charset-normalizer", - "version": "3.4.7" - } - }, - { - "download_info": {"url": "https://pypi.org/idna-3.11.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "idna", - "version": "3.11" - } - }, - { - "download_info": {"url": "https://pypi.org/urllib3-2.6.3.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "urllib3", - "version": "2.6.3" - } - }, - { - "download_info": {"url": "https://pypi.org/certifi-2026.2.2.whl", "archive_info": {}}, - "requested": false, - "metadata": { - "name": "certifi", - "version": "2026.2.2" - } - } - ] -} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml index e9bd6a27..2285d111 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml +++ b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml @@ -3,5 +3,5 @@ name = "test-project" version = "1.0.0" requires-python = ">=3.12" dependencies = [ - "requests>=2.32", #exhortignore + "requests==2.32.3", #exhortignore ] From 08f7bd1409ff8cbd8069acdacb824610ff72747a Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 15 Apr 2026 16:41:05 +0200 Subject: [PATCH 07/11] fix(test): lower requires-python to >=3.9 to match CI Python version CI uses Python 3.9 (setup-python in test.yml), so pip rejects pyproject.toml fixtures that require >=3.12. Co-Authored-By: Claude Opus 4.6 --- .../providers/tst_manifests/pyproject/pip_pep621/pyproject.toml | 2 +- .../tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml index 7d9b8920..14d1ba70 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml +++ b/test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "test-project" version = "1.0.0" -requires-python = ">=3.12" +requires-python = ">=3.9" dependencies = [ "requests[socks]==2.32.3", ] diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml index 2285d111..3c315ee3 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml +++ b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "test-project" version = "1.0.0" -requires-python = ">=3.12" +requires-python = ">=3.9" dependencies = [ "requests==2.32.3", #exhortignore ] From d4b7007a048de67f9c09fffb26c79d4979044807 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Wed, 15 Apr 2026 16:54:24 +0200 Subject: [PATCH 08/11] fix(pip): clean up .egg-info directories created by pip --dry-run pip creates .egg-info as a side effect of --dry-run --report. Snapshot existing .egg-info dirs before invoking pip and remove only newly created ones afterward to avoid polluting the user's project. Co-Authored-By: Claude Opus 4.6 --- src/providers/python_pip_pyproject.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/providers/python_pip_pyproject.js b/src/providers/python_pip_pyproject.js index caa8f700..c6cdd5b0 100644 --- a/src/providers/python_pip_pyproject.js +++ b/src/providers/python_pip_pyproject.js @@ -1,3 +1,6 @@ +import fs from 'node:fs' +import path from 'node:path' + import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js' import Base_pyproject from './base_pyproject.js' @@ -46,9 +49,12 @@ export default class Python_pip_pyproject extends Base_pyproject { } catch { pipBin = getCustomPath('pip', opts) } - return invokeCommand(pipBin, [ + let eggInfoDirs = this._findEggInfoDirs(manifestDir) + let result = invokeCommand(pipBin, [ 'install', '--dry-run', '--ignore-installed', '--quiet', '--report', '-', '.' ], { cwd: manifestDir }).toString() + this._cleanupEggInfo(manifestDir, eggInfoDirs) + return result } /** @@ -131,4 +137,20 @@ export default class Python_pip_pyproject extends Base_pyproject { let reportOutput = this._getPipReportOutput(manifestDir, opts) return this._parsePipReport(reportOutput) } + + _findEggInfoDirs(dir) { + try { + return fs.readdirSync(dir).filter(f => f.endsWith('.egg-info')) + } catch { + return [] + } + } + + _cleanupEggInfo(dir, existing) { + for (let entry of this._findEggInfoDirs(dir)) { + if (!existing.includes(entry)) { + fs.rmSync(path.join(dir, entry), { recursive: true, force: true }) + } + } + } } From d6740936abcaf38eebbf7ebe962a93b78de42859 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 16 Apr 2026 09:02:39 +0200 Subject: [PATCH 09/11] test(pip): replace manual ignore assertions with golden SBOM tests The pip_pep621_ignore fixture was missing expected_stack_sbom.json and expected_component_sbom.json, relying on partial manual assertions instead of the standard SBOM_CASES golden-file pattern. Add the golden files and switch to deep.equal comparison. Document the golden SBOM convention in CONVENTIONS.md to prevent this gap in future implementations. Co-Authored-By: Claude Opus 4.6 --- CONVENTIONS.md | 1 + test/providers/python_pyproject.test.js | 28 +++++++++---------- .../expected_component_sbom.json | 17 +++++++++++ .../expected_stack_sbom.json | 17 +++++++++++ 4 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_stack_sbom.json diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 872d04fc..a58277c2 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -82,6 +82,7 @@ test/ - **Test patterns**: `expect(res).to.deep.equal(...)`, `expect(() => ...).to.throw('message')` - **Higher-order setup**: Functions like `interceptAndRun()` for test setup/teardown - **Prefer real tool invocations over env var overrides**: Tests should call the actual ecosystem tools (pip, uv, poetry, mvn, npm, etc.) rather than injecting pre-recorded output via `TRUSTIFY_DA_*` environment variables. The CI environment has these tools available. Env var overrides (`TRUSTIFY_DA_PIP_REPORT`, `TRUSTIFY_DA_UV_EXPORT`, etc.) exist for users who lack the tool locally, but tests should exercise the real tool path to catch integration issues. +- **Golden SBOM files for every test fixture**: Every provider test fixture directory must include `expected_stack_sbom.json` and `expected_component_sbom.json` golden files. Tests must use the `SBOM_CASES` pattern to do a full `deep.equal` comparison of the provider output against these golden files. Manual partial assertions (e.g. checking a single component name) are not a substitute — they may be added as supplementary tests but never as the only verification for a fixture. ## Commit Messages diff --git a/test/providers/python_pyproject.test.js b/test/providers/python_pyproject.test.js index 7dbd77ff..0ec7b9c3 100644 --- a/test/providers/python_pyproject.test.js +++ b/test/providers/python_pyproject.test.js @@ -219,21 +219,19 @@ suite('testing the python-pyproject data provider', () => { expect(names).to.not.include('pysocks') }).timeout(TIMEOUT) - /** Verifies exhortignore marker in PEP 621 dependencies excludes the dep. */ - test('exhortignore marker excludes dep from component analysis', async () => { - let result = await pipProvider.provideComponent(path.join(pipIgnoreDir, 'pyproject.toml')) - let sbom = JSON.parse(result.content) - let names = sbom.components.map(c => c.name) - expect(names).to.not.include('requests') - }).timeout(TIMEOUT) - - /** Verifies exhortignore excludes dep and its exclusive transitive deps from stack analysis. */ - test('exhortignore marker excludes dep from stack analysis', async () => { - let result = await pipProvider.provideStack(path.join(pipIgnoreDir, 'pyproject.toml')) - let sbom = JSON.parse(result.content) - let names = sbom.components.map(c => c.name) - expect(names).to.not.include('requests') - }).timeout(TIMEOUT) + /** Verifies exhortignore marker produces expected SBOM for stack and component analysis. */ + SBOM_CASES.forEach(({type, method, fixture}) => { + test(`verify exhortignore produces expected sbom for ${type} analysis with pip`, async () => { + let expectedSbom = fs.readFileSync(path.join(pipIgnoreDir, fixture)).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + let result = await pipProvider[method](path.join(pipIgnoreDir, 'pyproject.toml')) + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + }).timeout(TIMEOUT) + }) /** Verifies name canonicalization (charset_normalizer -> charset-normalizer). */ test('name canonicalization: charset_normalizer resolved as charset-normalizer', async () => { diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_component_sbom.json new file mode 100644 index 00000000..36e34eae --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_component_sbom.json @@ -0,0 +1,17 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "test-project", + "version": "1.0.0", + "purl": "pkg:pypi/test-project@1.0.0", + "type": "application", + "bom-ref": "pkg:pypi/test-project@1.0.0" + } + }, + "components": [], + "dependencies": [] +} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_stack_sbom.json new file mode 100644 index 00000000..36e34eae --- /dev/null +++ b/test/providers/tst_manifests/pyproject/pip_pep621_ignore/expected_stack_sbom.json @@ -0,0 +1,17 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "test-project", + "version": "1.0.0", + "purl": "pkg:pypi/test-project@1.0.0", + "type": "application", + "bom-ref": "pkg:pypi/test-project@1.0.0" + } + }, + "components": [], + "dependencies": [] +} From cd2ee303878c05378cd9b967080e252cbfed9546 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 16 Apr 2026 19:01:22 +0200 Subject: [PATCH 10/11] fix(python): build true dependency graph instead of tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pyproject base class built an intermediate tree with a visited set that prevented a dependency from appearing under multiple parents. This caused PySocks to be missing from requests' dependsOn when both requests and urllib3 declared it (via extras). All other providers (npm, cargo, go, maven) already produce true CycloneDX graphs. Replace _buildDependencyTree/_collectTransitive/_addAllDependencies with graph-based BFS reachability + flat edge iteration. No recursion prevention needed — Python resolvers reject circular dependencies. Regenerate all pyproject golden SBOM files (pip, uv, poetry, workspace). Co-Authored-By: Claude Opus 4.6 --- src/providers/base_pyproject.js | 115 ++--- src/providers/python_pip_pyproject.js | 1 - test/providers/python_pyproject.test.js | 9 - .../expected_component_sbom.json | 2 +- .../expected_stack_sbom.json | 78 ++-- .../pip_pep621/expected_stack_sbom.json | 40 +- .../poetry_lock/expected_component_sbom.json | 2 +- .../poetry_lock/expected_stack_sbom.json | 35 +- .../poetry_only_deps/expected_stack_sbom.json | 90 ++-- .../uv_lock/expected_stack_sbom.json | 44 +- .../uv_workspace/expected_component_sbom.json | 92 ++-- .../uv_workspace/expected_stack_sbom.json | 394 +++++++++--------- .../mid-pkg/expected_component_sbom.json | 68 +-- .../packages/mid-pkg/expected_stack_sbom.json | 192 ++++----- .../sub-pkg/expected_component_sbom.json | 68 +-- .../packages/sub-pkg/expected_stack_sbom.json | 166 ++++---- 16 files changed, 685 insertions(+), 711 deletions(-) diff --git a/src/providers/base_pyproject.js b/src/providers/base_pyproject.js index fcc309f7..17ef146a 100644 --- a/src/providers/base_pyproject.js +++ b/src/providers/base_pyproject.js @@ -250,64 +250,27 @@ export default class Base_pyproject { } /** - * Build dependency tree from graph, starting from direct deps. - * @param {Map} graph - * @param {string[]} directDeps - canonical names of direct deps + * Compute the set of graph nodes reachable from direct deps, excluding ignored. + * @param {Map} graph + * @param {string[]} directDeps * @param {Set} ignoredDeps - * @param {boolean} includeTransitive - * @returns {DepTreeEntry[]} + * @returns {Set} * @protected */ - _buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) { - let result = [] - - for (let key of directDeps) { - if (ignoredDeps.has(key)) { continue } - - let entry = graph.get(key) - if (!entry) { continue } - - let depTree = [] - if (includeTransitive) { - let visited = new Set() - visited.add(key) - this._collectTransitive(graph, entry.children, depTree, ignoredDeps, visited) + _reachableNodes(graph, directDeps, ignoredDeps) { + let reachable = new Set() + let queue = directDeps.filter(k => !ignoredDeps.has(k) && graph.has(k)) + while (queue.length > 0) { + let key = queue.shift() + if (reachable.has(key)) { continue } + reachable.add(key) + for (let child of graph.get(key).children) { + if (!ignoredDeps.has(child) && graph.has(child) && !reachable.has(child)) { + queue.push(child) + } } - - result.push({ name: entry.name, version: entry.version, dependencies: depTree }) } - - result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) - return result - } - - /** - * Recursively collect transitive dependencies. - * @param {Map} graph - * @param {string[]} childKeys - * @param {DepTreeEntry[]} result - mutated in place - * @param {Set} ignoredDeps - * @param {Set} visited - * @returns {void} - * @protected - */ - _collectTransitive(graph, childKeys, result, ignoredDeps, visited) { - for (let childKey of childKeys) { - let canonKey = this._canonicalize(childKey) - if (ignoredDeps.has(canonKey)) { continue } - if (visited.has(canonKey)) { continue } - visited.add(canonKey) - - let entry = graph.get(canonKey) - if (!entry) { continue } - - let childDeps = [] - this._collectTransitive(graph, entry.children, childDeps, ignoredDeps, visited) - - result.push({ name: entry.name, version: entry.version, dependencies: childDeps }) - } - - result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + return reachable } /** @@ -320,22 +283,6 @@ export default class Base_pyproject { return new PackageURL('pypi', undefined, name, version, undefined, undefined) } - /** - * Recursively add a dependency and its transitive deps to the SBOM. - * @param {PackageURL} source - * @param {DepTreeEntry} dep - * @param {Sbom} sbom - * @returns {void} - * @private - */ - _addAllDependencies(source, dep, sbom) { - let targetPurl = this._toPurl(dep.name, dep.version) - sbom.addDependency(source, targetPurl) - if (dep.dependencies && dep.dependencies.length > 0) { - dep.dependencies.forEach(child => this._addAllDependencies(this._toPurl(dep.name, dep.version), child, sbom)) - } - } - /** * Create SBOM json string for a pyproject.toml project. * @param {string} manifest - path to pyproject.toml @@ -353,7 +300,6 @@ export default class Base_pyproject { let { directDeps, graph } = await this._getDependencyData(manifestDir, workspaceDir, parsed, opts) let ignoredDeps = this._getIgnoredDeps(manifest) - let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) let sbom = new Sbom() let rootName = this._getProjectName(parsed) || DEFAULT_ROOT_NAME @@ -362,13 +308,30 @@ export default class Base_pyproject { let license = this.readLicenseFromManifest(manifest) sbom.addRoot(rootPurl, license) - dependencies.forEach(dep => { - if (includeTransitive) { - this._addAllDependencies(rootPurl, dep, sbom) - } else { - sbom.addDependency(rootPurl, this._toPurl(dep.name, dep.version)) + if (includeTransitive) { + let reachable = this._reachableNodes(graph, directDeps, ignoredDeps) + for (let key of directDeps) { + if (!reachable.has(key)) { continue } + let entry = graph.get(key) + sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version)) } - }) + for (let [key, entry] of graph) { + if (!reachable.has(key)) { continue } + let parentPurl = this._toPurl(entry.name, entry.version) + for (let child of entry.children) { + if (!reachable.has(child)) { continue } + let childEntry = graph.get(child) + sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version)) + } + } + } else { + for (let key of directDeps) { + if (ignoredDeps.has(key)) { continue } + let entry = graph.get(key) + if (!entry) { continue } + sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version)) + } + } return sbom.getAsJsonString(opts) } diff --git a/src/providers/python_pip_pyproject.js b/src/providers/python_pip_pyproject.js index c6cdd5b0..72354d2b 100644 --- a/src/providers/python_pip_pyproject.js +++ b/src/providers/python_pip_pyproject.js @@ -91,7 +91,6 @@ export default class Python_pip_pyproject extends Base_pyproject { let entry = graph.get(key) let requires = pkg.metadata.requires_dist || [] for (let req of requires) { - if (this._hasExtraMarker(req)) { continue } let depName = this._extractDepName(req) if (!depName) { continue } let depKey = this._canonicalize(depName) diff --git a/test/providers/python_pyproject.test.js b/test/providers/python_pyproject.test.js index 0ec7b9c3..7be6476e 100644 --- a/test/providers/python_pyproject.test.js +++ b/test/providers/python_pyproject.test.js @@ -210,15 +210,6 @@ suite('testing the python-pyproject data provider', () => { expect(transNames).to.include('urllib3') }).timeout(TIMEOUT) - /** Verifies extras-only dependencies (e.g. PySocks for socks extra) are excluded. */ - test('extras-only dependencies are filtered from the dependency tree', async () => { - let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml')) - let sbom = JSON.parse(result.content) - let names = sbom.components.map(c => c.name) - expect(names).to.not.include('PySocks') - expect(names).to.not.include('pysocks') - }).timeout(TIMEOUT) - /** Verifies exhortignore marker produces expected SBOM for stack and component analysis. */ SBOM_CASES.forEach(({type, method, fixture}) => { test(`verify exhortignore produces expected sbom for ${type} analysis with pip`, async () => { diff --git a/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json index 49fc7f43..7d1936f7 100644 --- a/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json +++ b/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json @@ -69,4 +69,4 @@ "dependsOn": [] } ] -} \ No newline at end of file +} diff --git a/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json index d7a29d9b..a89d1db8 100644 --- a/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json @@ -20,6 +20,27 @@ "type": "library", "bom-ref": "pkg:pypi/flask@2.0.3" }, + { + "name": "pywin32", + "version": "311", + "purl": "pkg:pypi/pywin32@311", + "type": "library", + "bom-ref": "pkg:pypi/pywin32@311" + }, + { + "name": "requests", + "version": "2.25.1", + "purl": "pkg:pypi/requests@2.25.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.25.1" + }, + { + "name": "typing-extensions", + "version": "4.1.1", + "purl": "pkg:pypi/typing-extensions@4.1.1", + "type": "library", + "bom-ref": "pkg:pypi/typing-extensions@4.1.1" + }, { "name": "click", "version": "8.3.1", @@ -55,20 +76,6 @@ "type": "library", "bom-ref": "pkg:pypi/werkzeug@2.0.3" }, - { - "name": "pywin32", - "version": "311", - "purl": "pkg:pypi/pywin32@311", - "type": "library", - "bom-ref": "pkg:pypi/pywin32@311" - }, - { - "name": "requests", - "version": "2.25.1", - "purl": "pkg:pypi/requests@2.25.1", - "type": "library", - "bom-ref": "pkg:pypi/requests@2.25.1" - }, { "name": "certifi", "version": "2023.7.22", @@ -96,13 +103,6 @@ "purl": "pkg:pypi/urllib3@1.26.16", "type": "library", "bom-ref": "pkg:pypi/urllib3@1.26.16" - }, - { - "name": "typing-extensions", - "version": "4.1.1", - "purl": "pkg:pypi/typing-extensions@4.1.1", - "type": "library", - "bom-ref": "pkg:pypi/typing-extensions@4.1.1" } ], "dependencies": [ @@ -124,6 +124,23 @@ "pkg:pypi/werkzeug@2.0.3" ] }, + { + "ref": "pkg:pypi/pywin32@311", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/requests@2.25.1", + "dependsOn": [ + "pkg:pypi/certifi@2023.7.22", + "pkg:pypi/chardet@4.0.0", + "pkg:pypi/idna@2.10", + "pkg:pypi/urllib3@1.26.16" + ] + }, + { + "ref": "pkg:pypi/typing-extensions@4.1.1", + "dependsOn": [] + }, { "ref": "pkg:pypi/click@8.3.1", "dependsOn": [ @@ -146,19 +163,6 @@ "ref": "pkg:pypi/werkzeug@2.0.3", "dependsOn": [] }, - { - "ref": "pkg:pypi/pywin32@311", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/requests@2.25.1", - "dependsOn": [ - "pkg:pypi/certifi@2023.7.22", - "pkg:pypi/chardet@4.0.0", - "pkg:pypi/idna@2.10", - "pkg:pypi/urllib3@1.26.16" - ] - }, { "ref": "pkg:pypi/certifi@2023.7.22", "dependsOn": [] @@ -174,10 +178,6 @@ { "ref": "pkg:pypi/urllib3@1.26.16", "dependsOn": [] - }, - { - "ref": "pkg:pypi/typing-extensions@4.1.1", - "dependsOn": [] } ] -} \ No newline at end of file +} diff --git a/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json index 8f92848d..1d0849c7 100644 --- a/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json @@ -20,13 +20,6 @@ "type": "library", "bom-ref": "pkg:pypi/requests@2.32.3" }, - { - "name": "certifi", - "version": "2026.2.25", - "purl": "pkg:pypi/certifi@2026.2.25", - "type": "library", - "bom-ref": "pkg:pypi/certifi@2026.2.25" - }, { "name": "charset-normalizer", "version": "3.4.7", @@ -47,6 +40,20 @@ "purl": "pkg:pypi/urllib3@2.6.3", "type": "library", "bom-ref": "pkg:pypi/urllib3@2.6.3" + }, + { + "name": "certifi", + "version": "2026.2.25", + "purl": "pkg:pypi/certifi@2026.2.25", + "type": "library", + "bom-ref": "pkg:pypi/certifi@2026.2.25" + }, + { + "name": "pysocks", + "version": "1.7.1", + "purl": "pkg:pypi/pysocks@1.7.1", + "type": "library", + "bom-ref": "pkg:pypi/pysocks@1.7.1" } ], "dependencies": [ @@ -59,16 +66,13 @@ { "ref": "pkg:pypi/requests@2.32.3", "dependsOn": [ - "pkg:pypi/certifi@2026.2.25", "pkg:pypi/charset-normalizer@3.4.7", "pkg:pypi/idna@3.11", - "pkg:pypi/urllib3@2.6.3" + "pkg:pypi/urllib3@2.6.3", + "pkg:pypi/certifi@2026.2.25", + "pkg:pypi/pysocks@1.7.1" ] }, - { - "ref": "pkg:pypi/certifi@2026.2.25", - "dependsOn": [] - }, { "ref": "pkg:pypi/charset-normalizer@3.4.7", "dependsOn": [] @@ -79,6 +83,16 @@ }, { "ref": "pkg:pypi/urllib3@2.6.3", + "dependsOn": [ + "pkg:pypi/pysocks@1.7.1" + ] + }, + { + "ref": "pkg:pypi/certifi@2026.2.25", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/pysocks@1.7.1", "dependsOn": [] } ] diff --git a/test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json index 846a9746..990ca08f 100644 --- a/test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json +++ b/test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json @@ -45,4 +45,4 @@ "dependsOn": [] } ] -} \ No newline at end of file +} diff --git a/test/providers/tst_manifests/pyproject/poetry_lock/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/poetry_lock/expected_stack_sbom.json index a70f75a5..bac66b4b 100644 --- a/test/providers/tst_manifests/pyproject/poetry_lock/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/poetry_lock/expected_stack_sbom.json @@ -20,6 +20,13 @@ "type": "library", "bom-ref": "pkg:pypi/flask@3.1.3" }, + { + "name": "requests", + "version": "2.33.0", + "purl": "pkg:pypi/requests@2.33.0", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.0" + }, { "name": "blinker", "version": "1.9.0", @@ -55,13 +62,6 @@ "type": "library", "bom-ref": "pkg:pypi/werkzeug@3.1.7" }, - { - "name": "requests", - "version": "2.33.0", - "purl": "pkg:pypi/requests@2.33.0", - "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.0" - }, { "name": "certifi", "version": "2026.2.25", @@ -105,9 +105,19 @@ "pkg:pypi/blinker@1.9.0", "pkg:pypi/itsdangerous@2.2.0", "pkg:pypi/jinja2@3.1.6", + "pkg:pypi/markupsafe@3.0.3", "pkg:pypi/werkzeug@3.1.7" ] }, + { + "ref": "pkg:pypi/requests@2.33.0", + "dependsOn": [ + "pkg:pypi/certifi@2026.2.25", + "pkg:pypi/charset-normalizer@3.4.6", + "pkg:pypi/idna@3.11", + "pkg:pypi/urllib3@2.6.3" + ] + }, { "ref": "pkg:pypi/blinker@1.9.0", "dependsOn": [] @@ -128,15 +138,8 @@ }, { "ref": "pkg:pypi/werkzeug@3.1.7", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/requests@2.33.0", "dependsOn": [ - "pkg:pypi/certifi@2026.2.25", - "pkg:pypi/charset-normalizer@3.4.6", - "pkg:pypi/idna@3.11", - "pkg:pypi/urllib3@2.6.3" + "pkg:pypi/markupsafe@3.0.3" ] }, { @@ -156,4 +159,4 @@ "dependsOn": [] } ] -} \ No newline at end of file +} diff --git a/test/providers/tst_manifests/pyproject/poetry_only_deps/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/poetry_only_deps/expected_stack_sbom.json index 2f7167af..31d20f56 100644 --- a/test/providers/tst_manifests/pyproject/poetry_only_deps/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/poetry_only_deps/expected_stack_sbom.json @@ -20,13 +20,6 @@ "type": "library", "bom-ref": "pkg:pypi/click@8.1.8" }, - { - "name": "colorama", - "version": "0.4.6", - "purl": "pkg:pypi/colorama@0.4.6", - "type": "library", - "bom-ref": "pkg:pypi/colorama@0.4.6" - }, { "name": "flask", "version": "2.3.3", @@ -34,6 +27,20 @@ "type": "library", "bom-ref": "pkg:pypi/flask@2.3.3" }, + { + "name": "requests", + "version": "2.32.4", + "purl": "pkg:pypi/requests@2.32.4", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.32.4" + }, + { + "name": "colorama", + "version": "0.4.6", + "purl": "pkg:pypi/colorama@0.4.6", + "type": "library", + "bom-ref": "pkg:pypi/colorama@0.4.6" + }, { "name": "blinker", "version": "1.8.2", @@ -48,13 +55,6 @@ "type": "library", "bom-ref": "pkg:pypi/importlib-metadata@8.5.0" }, - { - "name": "zipp", - "version": "3.20.2", - "purl": "pkg:pypi/zipp@3.20.2", - "type": "library", - "bom-ref": "pkg:pypi/zipp@3.20.2" - }, { "name": "itsdangerous", "version": "2.2.0", @@ -69,13 +69,6 @@ "type": "library", "bom-ref": "pkg:pypi/jinja2@3.1.6" }, - { - "name": "markupsafe", - "version": "2.1.5", - "purl": "pkg:pypi/markupsafe@2.1.5", - "type": "library", - "bom-ref": "pkg:pypi/markupsafe@2.1.5" - }, { "name": "werkzeug", "version": "3.0.6", @@ -84,11 +77,18 @@ "bom-ref": "pkg:pypi/werkzeug@3.0.6" }, { - "name": "requests", - "version": "2.32.4", - "purl": "pkg:pypi/requests@2.32.4", + "name": "zipp", + "version": "3.20.2", + "purl": "pkg:pypi/zipp@3.20.2", "type": "library", - "bom-ref": "pkg:pypi/requests@2.32.4" + "bom-ref": "pkg:pypi/zipp@3.20.2" + }, + { + "name": "markupsafe", + "version": "2.1.5", + "purl": "pkg:pypi/markupsafe@2.1.5", + "type": "library", + "bom-ref": "pkg:pypi/markupsafe@2.1.5" }, { "name": "certifi", @@ -141,10 +141,6 @@ "pkg:pypi/colorama@0.4.6" ] }, - { - "ref": "pkg:pypi/colorama@0.4.6", - "dependsOn": [] - }, { "ref": "pkg:pypi/flask@2.3.3", "dependsOn": [ @@ -156,6 +152,20 @@ "pkg:pypi/werkzeug@3.0.6" ] }, + { + "ref": "pkg:pypi/requests@2.32.4", + "dependsOn": [ + "pkg:pypi/certifi@2026.2.25", + "pkg:pypi/charset-normalizer@3.4.6", + "pkg:pypi/idna@3.11", + "pkg:pypi/pysocks@1.7.1", + "pkg:pypi/urllib3@2.2.3" + ] + }, + { + "ref": "pkg:pypi/colorama@0.4.6", + "dependsOn": [] + }, { "ref": "pkg:pypi/blinker@1.8.2", "dependsOn": [] @@ -166,10 +176,6 @@ "pkg:pypi/zipp@3.20.2" ] }, - { - "ref": "pkg:pypi/zipp@3.20.2", - "dependsOn": [] - }, { "ref": "pkg:pypi/itsdangerous@2.2.0", "dependsOn": [] @@ -181,22 +187,18 @@ ] }, { - "ref": "pkg:pypi/markupsafe@2.1.5", - "dependsOn": [] + "ref": "pkg:pypi/werkzeug@3.0.6", + "dependsOn": [ + "pkg:pypi/markupsafe@2.1.5" + ] }, { - "ref": "pkg:pypi/werkzeug@3.0.6", + "ref": "pkg:pypi/zipp@3.20.2", "dependsOn": [] }, { - "ref": "pkg:pypi/requests@2.32.4", - "dependsOn": [ - "pkg:pypi/certifi@2026.2.25", - "pkg:pypi/charset-normalizer@3.4.6", - "pkg:pypi/idna@3.11", - "pkg:pypi/pysocks@1.7.1", - "pkg:pypi/urllib3@2.2.3" - ] + "ref": "pkg:pypi/markupsafe@2.1.5", + "dependsOn": [] }, { "ref": "pkg:pypi/certifi@2026.2.25", diff --git a/test/providers/tst_manifests/pyproject/uv_lock/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/uv_lock/expected_stack_sbom.json index 6d528dd8..9f90d870 100644 --- a/test/providers/tst_manifests/pyproject/uv_lock/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_lock/expected_stack_sbom.json @@ -20,6 +20,13 @@ "type": "library", "bom-ref": "pkg:pypi/flask@2.0.3" }, + { + "name": "requests", + "version": "2.25.1", + "purl": "pkg:pypi/requests@2.25.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.25.1" + }, { "name": "click", "version": "8.3.1", @@ -48,13 +55,6 @@ "type": "library", "bom-ref": "pkg:pypi/jinja2@3.0.3" }, - { - "name": "markupsafe", - "version": "2.0.1", - "purl": "pkg:pypi/markupsafe@2.0.1", - "type": "library", - "bom-ref": "pkg:pypi/markupsafe@2.0.1" - }, { "name": "werkzeug", "version": "2.0.3", @@ -63,11 +63,11 @@ "bom-ref": "pkg:pypi/werkzeug@2.0.3" }, { - "name": "requests", - "version": "2.25.1", - "purl": "pkg:pypi/requests@2.25.1", + "name": "markupsafe", + "version": "2.0.1", + "purl": "pkg:pypi/markupsafe@2.0.1", "type": "library", - "bom-ref": "pkg:pypi/requests@2.25.1" + "bom-ref": "pkg:pypi/markupsafe@2.0.1" }, { "name": "certifi", @@ -115,6 +115,15 @@ "pkg:pypi/werkzeug@2.0.3" ] }, + { + "ref": "pkg:pypi/requests@2.25.1", + "dependsOn": [ + "pkg:pypi/certifi@2023.7.22", + "pkg:pypi/chardet@4.0.0", + "pkg:pypi/idna@2.10", + "pkg:pypi/urllib3@1.26.16" + ] + }, { "ref": "pkg:pypi/click@8.3.1", "dependsOn": [ @@ -135,22 +144,13 @@ "pkg:pypi/markupsafe@2.0.1" ] }, - { - "ref": "pkg:pypi/markupsafe@2.0.1", - "dependsOn": [] - }, { "ref": "pkg:pypi/werkzeug@2.0.3", "dependsOn": [] }, { - "ref": "pkg:pypi/requests@2.25.1", - "dependsOn": [ - "pkg:pypi/certifi@2023.7.22", - "pkg:pypi/chardet@4.0.0", - "pkg:pypi/idna@2.10", - "pkg:pypi/urllib3@1.26.16" - ] + "ref": "pkg:pypi/markupsafe@2.0.1", + "dependsOn": [] }, { "ref": "pkg:pypi/certifi@2023.7.22", diff --git a/test/providers/tst_manifests/pyproject/uv_workspace/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/uv_workspace/expected_component_sbom.json index 9e09c503..625332dc 100644 --- a/test/providers/tst_manifests/pyproject/uv_workspace/expected_component_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_workspace/expected_component_sbom.json @@ -1,48 +1,48 @@ { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": "2023-10-01T00:00:00.000Z", - "component": { - "name": "uv-mono", - "version": "0.1.0", - "purl": "pkg:pypi/uv-mono@0.1.0", - "type": "application", - "bom-ref": "pkg:pypi/uv-mono@0.1.0" - } - }, - "components": [ - { - "name": "flask", - "version": "2.0.3", - "purl": "pkg:pypi/flask@2.0.3", - "type": "library", - "bom-ref": "pkg:pypi/flask@2.0.3" - }, - { - "name": "mid-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/mid-pkg@0.1.0", - "type": "library", - "bom-ref": "pkg:pypi/mid-pkg@0.1.0" - } - ], - "dependencies": [ - { - "ref": "pkg:pypi/uv-mono@0.1.0", - "dependsOn": [ - "pkg:pypi/flask@2.0.3", - "pkg:pypi/mid-pkg@0.1.0" - ] - }, - { - "ref": "pkg:pypi/flask@2.0.3", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/mid-pkg@0.1.0", - "dependsOn": [] - } - ] + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "uv-mono", + "version": "0.1.0", + "purl": "pkg:pypi/uv-mono@0.1.0", + "type": "application", + "bom-ref": "pkg:pypi/uv-mono@0.1.0" + } + }, + "components": [ + { + "name": "mid-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/mid-pkg@0.1.0", + "type": "library", + "bom-ref": "pkg:pypi/mid-pkg@0.1.0" + }, + { + "name": "flask", + "version": "2.0.3", + "purl": "pkg:pypi/flask@2.0.3", + "type": "library", + "bom-ref": "pkg:pypi/flask@2.0.3" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/uv-mono@0.1.0", + "dependsOn": [ + "pkg:pypi/mid-pkg@0.1.0", + "pkg:pypi/flask@2.0.3" + ] + }, + { + "ref": "pkg:pypi/mid-pkg@0.1.0", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/flask@2.0.3", + "dependsOn": [] + } + ] } diff --git a/test/providers/tst_manifests/pyproject/uv_workspace/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/uv_workspace/expected_stack_sbom.json index 77df939e..c4b77af6 100644 --- a/test/providers/tst_manifests/pyproject/uv_workspace/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_workspace/expected_stack_sbom.json @@ -1,198 +1,200 @@ { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": "2023-10-01T00:00:00.000Z", - "component": { - "name": "uv-mono", - "version": "0.1.0", - "purl": "pkg:pypi/uv-mono@0.1.0", - "type": "application", - "bom-ref": "pkg:pypi/uv-mono@0.1.0" - } - }, - "components": [ - { - "name": "flask", - "version": "2.0.3", - "purl": "pkg:pypi/flask@2.0.3", - "type": "library", - "bom-ref": "pkg:pypi/flask@2.0.3" - }, - { - "name": "click", - "version": "8.3.2", - "purl": "pkg:pypi/click@8.3.2", - "type": "library", - "bom-ref": "pkg:pypi/click@8.3.2" - }, - { - "name": "colorama", - "version": "0.4.6", - "purl": "pkg:pypi/colorama@0.4.6", - "type": "library", - "bom-ref": "pkg:pypi/colorama@0.4.6" - }, - { - "name": "itsdangerous", - "version": "2.2.0", - "purl": "pkg:pypi/itsdangerous@2.2.0", - "type": "library", - "bom-ref": "pkg:pypi/itsdangerous@2.2.0" - }, - { - "name": "jinja2", - "version": "3.1.6", - "purl": "pkg:pypi/jinja2@3.1.6", - "type": "library", - "bom-ref": "pkg:pypi/jinja2@3.1.6" - }, - { - "name": "markupsafe", - "version": "3.0.3", - "purl": "pkg:pypi/markupsafe@3.0.3", - "type": "library", - "bom-ref": "pkg:pypi/markupsafe@3.0.3" - }, - { - "name": "werkzeug", - "version": "3.1.8", - "purl": "pkg:pypi/werkzeug@3.1.8", - "type": "library", - "bom-ref": "pkg:pypi/werkzeug@3.1.8" - }, - { - "name": "mid-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/mid-pkg@0.1.0", - "type": "library", - "bom-ref": "pkg:pypi/mid-pkg@0.1.0" - }, - { - "name": "sub-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/sub-pkg@0.1.0", - "type": "library", - "bom-ref": "pkg:pypi/sub-pkg@0.1.0" - }, - { - "name": "requests", - "version": "2.33.1", - "purl": "pkg:pypi/requests@2.33.1", - "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.1" - }, - { - "name": "certifi", - "version": "2026.2.25", - "purl": "pkg:pypi/certifi@2026.2.25", - "type": "library", - "bom-ref": "pkg:pypi/certifi@2026.2.25" - }, - { - "name": "charset-normalizer", - "version": "3.4.7", - "purl": "pkg:pypi/charset-normalizer@3.4.7", - "type": "library", - "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" - }, - { - "name": "idna", - "version": "3.11", - "purl": "pkg:pypi/idna@3.11", - "type": "library", - "bom-ref": "pkg:pypi/idna@3.11" - }, - { - "name": "urllib3", - "version": "2.6.3", - "purl": "pkg:pypi/urllib3@2.6.3", - "type": "library", - "bom-ref": "pkg:pypi/urllib3@2.6.3" - } - ], - "dependencies": [ - { - "ref": "pkg:pypi/uv-mono@0.1.0", - "dependsOn": [ - "pkg:pypi/flask@2.0.3", - "pkg:pypi/mid-pkg@0.1.0" - ] - }, - { - "ref": "pkg:pypi/flask@2.0.3", - "dependsOn": [ - "pkg:pypi/click@8.3.2", - "pkg:pypi/itsdangerous@2.2.0", - "pkg:pypi/jinja2@3.1.6", - "pkg:pypi/werkzeug@3.1.8" - ] - }, - { - "ref": "pkg:pypi/click@8.3.2", - "dependsOn": [ - "pkg:pypi/colorama@0.4.6" - ] - }, - { - "ref": "pkg:pypi/colorama@0.4.6", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/itsdangerous@2.2.0", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/jinja2@3.1.6", - "dependsOn": [ - "pkg:pypi/markupsafe@3.0.3" - ] - }, - { - "ref": "pkg:pypi/markupsafe@3.0.3", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/werkzeug@3.1.8", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/mid-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/sub-pkg@0.1.0" - ] - }, - { - "ref": "pkg:pypi/sub-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/requests@2.33.1" - ] - }, - { - "ref": "pkg:pypi/requests@2.33.1", - "dependsOn": [ - "pkg:pypi/certifi@2026.2.25", - "pkg:pypi/charset-normalizer@3.4.7", - "pkg:pypi/idna@3.11", - "pkg:pypi/urllib3@2.6.3" - ] - }, - { - "ref": "pkg:pypi/certifi@2026.2.25", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/charset-normalizer@3.4.7", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/idna@3.11", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/urllib3@2.6.3", - "dependsOn": [] - } - ] + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "uv-mono", + "version": "0.1.0", + "purl": "pkg:pypi/uv-mono@0.1.0", + "type": "application", + "bom-ref": "pkg:pypi/uv-mono@0.1.0" + } + }, + "components": [ + { + "name": "mid-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/mid-pkg@0.1.0", + "type": "library", + "bom-ref": "pkg:pypi/mid-pkg@0.1.0" + }, + { + "name": "flask", + "version": "2.0.3", + "purl": "pkg:pypi/flask@2.0.3", + "type": "library", + "bom-ref": "pkg:pypi/flask@2.0.3" + }, + { + "name": "sub-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/sub-pkg@0.1.0", + "type": "library", + "bom-ref": "pkg:pypi/sub-pkg@0.1.0" + }, + { + "name": "requests", + "version": "2.33.1", + "purl": "pkg:pypi/requests@2.33.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.1" + }, + { + "name": "click", + "version": "8.3.2", + "purl": "pkg:pypi/click@8.3.2", + "type": "library", + "bom-ref": "pkg:pypi/click@8.3.2" + }, + { + "name": "colorama", + "version": "0.4.6", + "purl": "pkg:pypi/colorama@0.4.6", + "type": "library", + "bom-ref": "pkg:pypi/colorama@0.4.6" + }, + { + "name": "itsdangerous", + "version": "2.2.0", + "purl": "pkg:pypi/itsdangerous@2.2.0", + "type": "library", + "bom-ref": "pkg:pypi/itsdangerous@2.2.0" + }, + { + "name": "jinja2", + "version": "3.1.6", + "purl": "pkg:pypi/jinja2@3.1.6", + "type": "library", + "bom-ref": "pkg:pypi/jinja2@3.1.6" + }, + { + "name": "werkzeug", + "version": "3.1.8", + "purl": "pkg:pypi/werkzeug@3.1.8", + "type": "library", + "bom-ref": "pkg:pypi/werkzeug@3.1.8" + }, + { + "name": "markupsafe", + "version": "3.0.3", + "purl": "pkg:pypi/markupsafe@3.0.3", + "type": "library", + "bom-ref": "pkg:pypi/markupsafe@3.0.3" + }, + { + "name": "certifi", + "version": "2026.2.25", + "purl": "pkg:pypi/certifi@2026.2.25", + "type": "library", + "bom-ref": "pkg:pypi/certifi@2026.2.25" + }, + { + "name": "charset-normalizer", + "version": "3.4.7", + "purl": "pkg:pypi/charset-normalizer@3.4.7", + "type": "library", + "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" + }, + { + "name": "idna", + "version": "3.11", + "purl": "pkg:pypi/idna@3.11", + "type": "library", + "bom-ref": "pkg:pypi/idna@3.11" + }, + { + "name": "urllib3", + "version": "2.6.3", + "purl": "pkg:pypi/urllib3@2.6.3", + "type": "library", + "bom-ref": "pkg:pypi/urllib3@2.6.3" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/uv-mono@0.1.0", + "dependsOn": [ + "pkg:pypi/mid-pkg@0.1.0", + "pkg:pypi/flask@2.0.3" + ] + }, + { + "ref": "pkg:pypi/mid-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/sub-pkg@0.1.0" + ] + }, + { + "ref": "pkg:pypi/flask@2.0.3", + "dependsOn": [ + "pkg:pypi/click@8.3.2", + "pkg:pypi/itsdangerous@2.2.0", + "pkg:pypi/jinja2@3.1.6", + "pkg:pypi/werkzeug@3.1.8" + ] + }, + { + "ref": "pkg:pypi/sub-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/requests@2.33.1" + ] + }, + { + "ref": "pkg:pypi/requests@2.33.1", + "dependsOn": [ + "pkg:pypi/certifi@2026.2.25", + "pkg:pypi/charset-normalizer@3.4.7", + "pkg:pypi/idna@3.11", + "pkg:pypi/urllib3@2.6.3" + ] + }, + { + "ref": "pkg:pypi/click@8.3.2", + "dependsOn": [ + "pkg:pypi/colorama@0.4.6" + ] + }, + { + "ref": "pkg:pypi/colorama@0.4.6", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/itsdangerous@2.2.0", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/jinja2@3.1.6", + "dependsOn": [ + "pkg:pypi/markupsafe@3.0.3" + ] + }, + { + "ref": "pkg:pypi/werkzeug@3.1.8", + "dependsOn": [ + "pkg:pypi/markupsafe@3.0.3" + ] + }, + { + "ref": "pkg:pypi/markupsafe@3.0.3", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/certifi@2026.2.25", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/charset-normalizer@3.4.7", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/idna@3.11", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/urllib3@2.6.3", + "dependsOn": [] + } + ] } diff --git a/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_component_sbom.json index 6028991b..149a7c8c 100644 --- a/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_component_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_component_sbom.json @@ -1,36 +1,36 @@ { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": "2023-10-01T00:00:00.000Z", - "component": { - "name": "mid-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/mid-pkg@0.1.0", - "type": "application", - "bom-ref": "pkg:pypi/mid-pkg@0.1.0" - } - }, - "components": [ - { - "name": "sub-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/sub-pkg@0.1.0", - "type": "library", - "bom-ref": "pkg:pypi/sub-pkg@0.1.0" - } - ], - "dependencies": [ - { - "ref": "pkg:pypi/mid-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/sub-pkg@0.1.0" - ] - }, - { - "ref": "pkg:pypi/sub-pkg@0.1.0", - "dependsOn": [] - } - ] + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "mid-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/mid-pkg@0.1.0", + "type": "application", + "bom-ref": "pkg:pypi/mid-pkg@0.1.0" + } + }, + "components": [ + { + "name": "sub-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/sub-pkg@0.1.0", + "type": "library", + "bom-ref": "pkg:pypi/sub-pkg@0.1.0" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/mid-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/sub-pkg@0.1.0" + ] + }, + { + "ref": "pkg:pypi/sub-pkg@0.1.0", + "dependsOn": [] + } + ] } diff --git a/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_stack_sbom.json index 506c9dca..0c72587b 100644 --- a/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_workspace/packages/mid-pkg/expected_stack_sbom.json @@ -1,98 +1,98 @@ { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": "2023-10-01T00:00:00.000Z", - "component": { - "name": "mid-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/mid-pkg@0.1.0", - "type": "application", - "bom-ref": "pkg:pypi/mid-pkg@0.1.0" - } - }, - "components": [ - { - "name": "sub-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/sub-pkg@0.1.0", - "type": "library", - "bom-ref": "pkg:pypi/sub-pkg@0.1.0" - }, - { - "name": "requests", - "version": "2.33.1", - "purl": "pkg:pypi/requests@2.33.1", - "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.1" - }, - { - "name": "certifi", - "version": "2026.2.25", - "purl": "pkg:pypi/certifi@2026.2.25", - "type": "library", - "bom-ref": "pkg:pypi/certifi@2026.2.25" - }, - { - "name": "charset-normalizer", - "version": "3.4.7", - "purl": "pkg:pypi/charset-normalizer@3.4.7", - "type": "library", - "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" - }, - { - "name": "idna", - "version": "3.11", - "purl": "pkg:pypi/idna@3.11", - "type": "library", - "bom-ref": "pkg:pypi/idna@3.11" - }, - { - "name": "urllib3", - "version": "2.6.3", - "purl": "pkg:pypi/urllib3@2.6.3", - "type": "library", - "bom-ref": "pkg:pypi/urllib3@2.6.3" - } - ], - "dependencies": [ - { - "ref": "pkg:pypi/mid-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/sub-pkg@0.1.0" - ] - }, - { - "ref": "pkg:pypi/sub-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/requests@2.33.1" - ] - }, - { - "ref": "pkg:pypi/requests@2.33.1", - "dependsOn": [ - "pkg:pypi/certifi@2026.2.25", - "pkg:pypi/charset-normalizer@3.4.7", - "pkg:pypi/idna@3.11", - "pkg:pypi/urllib3@2.6.3" - ] - }, - { - "ref": "pkg:pypi/certifi@2026.2.25", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/charset-normalizer@3.4.7", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/idna@3.11", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/urllib3@2.6.3", - "dependsOn": [] - } - ] + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "mid-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/mid-pkg@0.1.0", + "type": "application", + "bom-ref": "pkg:pypi/mid-pkg@0.1.0" + } + }, + "components": [ + { + "name": "sub-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/sub-pkg@0.1.0", + "type": "library", + "bom-ref": "pkg:pypi/sub-pkg@0.1.0" + }, + { + "name": "requests", + "version": "2.33.1", + "purl": "pkg:pypi/requests@2.33.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.1" + }, + { + "name": "certifi", + "version": "2026.2.25", + "purl": "pkg:pypi/certifi@2026.2.25", + "type": "library", + "bom-ref": "pkg:pypi/certifi@2026.2.25" + }, + { + "name": "charset-normalizer", + "version": "3.4.7", + "purl": "pkg:pypi/charset-normalizer@3.4.7", + "type": "library", + "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" + }, + { + "name": "idna", + "version": "3.11", + "purl": "pkg:pypi/idna@3.11", + "type": "library", + "bom-ref": "pkg:pypi/idna@3.11" + }, + { + "name": "urllib3", + "version": "2.6.3", + "purl": "pkg:pypi/urllib3@2.6.3", + "type": "library", + "bom-ref": "pkg:pypi/urllib3@2.6.3" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/mid-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/sub-pkg@0.1.0" + ] + }, + { + "ref": "pkg:pypi/sub-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/requests@2.33.1" + ] + }, + { + "ref": "pkg:pypi/requests@2.33.1", + "dependsOn": [ + "pkg:pypi/certifi@2026.2.25", + "pkg:pypi/charset-normalizer@3.4.7", + "pkg:pypi/idna@3.11", + "pkg:pypi/urllib3@2.6.3" + ] + }, + { + "ref": "pkg:pypi/certifi@2026.2.25", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/charset-normalizer@3.4.7", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/idna@3.11", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/urllib3@2.6.3", + "dependsOn": [] + } + ] } diff --git a/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_component_sbom.json b/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_component_sbom.json index dec8c565..6c3f39a5 100644 --- a/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_component_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_component_sbom.json @@ -1,36 +1,36 @@ { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": "2023-10-01T00:00:00.000Z", - "component": { - "name": "sub-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/sub-pkg@0.1.0", - "type": "application", - "bom-ref": "pkg:pypi/sub-pkg@0.1.0" - } - }, - "components": [ - { - "name": "requests", - "version": "2.33.1", - "purl": "pkg:pypi/requests@2.33.1", - "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.1" - } - ], - "dependencies": [ - { - "ref": "pkg:pypi/sub-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/requests@2.33.1" - ] - }, - { - "ref": "pkg:pypi/requests@2.33.1", - "dependsOn": [] - } - ] + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "sub-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/sub-pkg@0.1.0", + "type": "application", + "bom-ref": "pkg:pypi/sub-pkg@0.1.0" + } + }, + "components": [ + { + "name": "requests", + "version": "2.33.1", + "purl": "pkg:pypi/requests@2.33.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.1" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/sub-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/requests@2.33.1" + ] + }, + { + "ref": "pkg:pypi/requests@2.33.1", + "dependsOn": [] + } + ] } diff --git a/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_stack_sbom.json b/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_stack_sbom.json index e9d1f86d..62e86b30 100644 --- a/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_stack_sbom.json +++ b/test/providers/tst_manifests/pyproject/uv_workspace/packages/sub-pkg/expected_stack_sbom.json @@ -1,85 +1,85 @@ { - "bomFormat": "CycloneDX", - "specVersion": "1.4", - "version": 1, - "metadata": { - "timestamp": "2023-10-01T00:00:00.000Z", - "component": { - "name": "sub-pkg", - "version": "0.1.0", - "purl": "pkg:pypi/sub-pkg@0.1.0", - "type": "application", - "bom-ref": "pkg:pypi/sub-pkg@0.1.0" - } - }, - "components": [ - { - "name": "requests", - "version": "2.33.1", - "purl": "pkg:pypi/requests@2.33.1", - "type": "library", - "bom-ref": "pkg:pypi/requests@2.33.1" - }, - { - "name": "certifi", - "version": "2026.2.25", - "purl": "pkg:pypi/certifi@2026.2.25", - "type": "library", - "bom-ref": "pkg:pypi/certifi@2026.2.25" - }, - { - "name": "charset-normalizer", - "version": "3.4.7", - "purl": "pkg:pypi/charset-normalizer@3.4.7", - "type": "library", - "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" - }, - { - "name": "idna", - "version": "3.11", - "purl": "pkg:pypi/idna@3.11", - "type": "library", - "bom-ref": "pkg:pypi/idna@3.11" - }, - { - "name": "urllib3", - "version": "2.6.3", - "purl": "pkg:pypi/urllib3@2.6.3", - "type": "library", - "bom-ref": "pkg:pypi/urllib3@2.6.3" - } - ], - "dependencies": [ - { - "ref": "pkg:pypi/sub-pkg@0.1.0", - "dependsOn": [ - "pkg:pypi/requests@2.33.1" - ] - }, - { - "ref": "pkg:pypi/requests@2.33.1", - "dependsOn": [ - "pkg:pypi/certifi@2026.2.25", - "pkg:pypi/charset-normalizer@3.4.7", - "pkg:pypi/idna@3.11", - "pkg:pypi/urllib3@2.6.3" - ] - }, - { - "ref": "pkg:pypi/certifi@2026.2.25", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/charset-normalizer@3.4.7", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/idna@3.11", - "dependsOn": [] - }, - { - "ref": "pkg:pypi/urllib3@2.6.3", - "dependsOn": [] - } - ] + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "sub-pkg", + "version": "0.1.0", + "purl": "pkg:pypi/sub-pkg@0.1.0", + "type": "application", + "bom-ref": "pkg:pypi/sub-pkg@0.1.0" + } + }, + "components": [ + { + "name": "requests", + "version": "2.33.1", + "purl": "pkg:pypi/requests@2.33.1", + "type": "library", + "bom-ref": "pkg:pypi/requests@2.33.1" + }, + { + "name": "certifi", + "version": "2026.2.25", + "purl": "pkg:pypi/certifi@2026.2.25", + "type": "library", + "bom-ref": "pkg:pypi/certifi@2026.2.25" + }, + { + "name": "charset-normalizer", + "version": "3.4.7", + "purl": "pkg:pypi/charset-normalizer@3.4.7", + "type": "library", + "bom-ref": "pkg:pypi/charset-normalizer@3.4.7" + }, + { + "name": "idna", + "version": "3.11", + "purl": "pkg:pypi/idna@3.11", + "type": "library", + "bom-ref": "pkg:pypi/idna@3.11" + }, + { + "name": "urllib3", + "version": "2.6.3", + "purl": "pkg:pypi/urllib3@2.6.3", + "type": "library", + "bom-ref": "pkg:pypi/urllib3@2.6.3" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/sub-pkg@0.1.0", + "dependsOn": [ + "pkg:pypi/requests@2.33.1" + ] + }, + { + "ref": "pkg:pypi/requests@2.33.1", + "dependsOn": [ + "pkg:pypi/certifi@2026.2.25", + "pkg:pypi/charset-normalizer@3.4.7", + "pkg:pypi/idna@3.11", + "pkg:pypi/urllib3@2.6.3" + ] + }, + { + "ref": "pkg:pypi/certifi@2026.2.25", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/charset-normalizer@3.4.7", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/idna@3.11", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/urllib3@2.6.3", + "dependsOn": [] + } + ] } From af5587c7f62ffb4e5c6dd597aa830c4fd0dce15c Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 17 Apr 2026 16:44:14 +0200 Subject: [PATCH 11/11] fix: use GraphEntry typedef instead of inline type in JSDoc Co-Authored-By: Claude Opus 4.6 --- src/providers/base_pyproject.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/base_pyproject.js b/src/providers/base_pyproject.js index 17ef146a..d745e7bc 100644 --- a/src/providers/base_pyproject.js +++ b/src/providers/base_pyproject.js @@ -251,7 +251,7 @@ export default class Base_pyproject { /** * Compute the set of graph nodes reachable from direct deps, excluding ignored. - * @param {Map} graph + * @param {Map} graph * @param {string[]} directDeps * @param {Set} ignoredDeps * @returns {Set}