From da6edc0346e0c7329856f4517467058095ea37ab Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 22 Apr 2026 13:24:14 +0100 Subject: [PATCH 1/4] fix(npm): extract workspace member subtree from npm ls output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When npm ls --package-lock-only --json runs from the workspace root, it returns a tree rooted at the workspace root package with workspace members nested under dependencies. Component analysis failed because _getRootDependencies read the root-level deps (workspace member entries), not the member's actual deps — filtering by the member's manifest dependencies found no match, returning 0 dependencies. Now overrides _buildDependencyTree to detect when the tree root name differs from the manifest name and extracts the member's subtree. Implements TC-4181 Assisted-by: Claude Code --- src/providers/javascript_npm.js | 18 +++++++ test/providers/javascript.test.js | 34 +++++++++++++ .../component_expected_sbom.json | 36 ++++++++++++++ .../workspace_member/listing_component.json | 17 +++++++ .../npm/workspace_member/listing_stack.json | 24 +++++++++ .../npm/workspace_member/package-lock.json | 4 ++ .../npm/workspace_member/package.json | 5 ++ .../packages/member-a/package.json | 7 +++ .../workspace_member/stack_expected_sbom.json | 49 +++++++++++++++++++ 9 files changed, 194 insertions(+) create mode 100644 test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json create mode 100644 test/providers/tst_manifests/npm/workspace_member/listing_component.json create mode 100644 test/providers/tst_manifests/npm/workspace_member/listing_stack.json create mode 100644 test/providers/tst_manifests/npm/workspace_member/package-lock.json create mode 100644 test/providers/tst_manifests/npm/workspace_member/package.json create mode 100644 test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json create mode 100644 test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json diff --git a/src/providers/javascript_npm.js b/src/providers/javascript_npm.js index 037c0205..1946001d 100644 --- a/src/providers/javascript_npm.js +++ b/src/providers/javascript_npm.js @@ -17,4 +17,22 @@ export default class Javascript_npm extends Base_javascript { _updateLockFileCmdArgs() { return ['install', '--package-lock-only']; } + + _buildDependencyTree(includeTransitive, opts = {}) { + const tree = super._buildDependencyTree(includeTransitive, opts); + const memberName = this._getManifest().name; + if (tree.name === memberName) { + return tree; + } + const memberEntry = tree.dependencies?.[memberName]; + if (memberEntry) { + return { + name: memberName, + version: memberEntry.version || this._getManifest().version, + dependencies: memberEntry.dependencies, + optionalDependencies: memberEntry.optionalDependencies, + }; + } + return tree; + } } diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 5101d80f..b60a8874 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -106,6 +106,40 @@ suite('testing the javascript-npm data provider', async () => { }); + [ + { providerName: 'npm', testCase: 'workspace_member' }, + ].forEach(({ providerName, testCase }) => { + /// Verifies that stack analysis resolves transitive dependencies for a workspace member. + test(`verify workspace member data provided for ${providerName} - stack analysis`, async () => { + // Given a workspace member manifest and mock listing from the workspace root + const listing = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/listing_stack.json`).toString(); + const expectedSbom = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/stack_expected_sbom.json`).toString(); + const provider = await createMockProvider(providerName, listing); + const manifestPath = `test/providers/tst_manifests/${providerName}/${testCase}/packages/member-a/package.json`; + + // When running stack analysis on the workspace member + const result = provider.provideStack(manifestPath); + + // Then the SBOM should contain the member's transitive dependencies + compareSboms(result.content, expectedSbom); + }).timeout(process.env.GITHUB_ACTIONS ? 30000 : 10000); + + /// Verifies that component analysis resolves direct dependencies for a workspace member. + test(`verify workspace member data provided for ${providerName} - component analysis`, async () => { + // Given a workspace member manifest and mock listing from the workspace root + const listing = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/listing_component.json`).toString(); + const expectedSbom = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/component_expected_sbom.json`).toString(); + const provider = await createMockProvider(providerName, listing); + const manifestPath = `test/providers/tst_manifests/${providerName}/${testCase}/packages/member-a/package.json`; + + // When running component analysis on the workspace member + const result = provider.provideComponent(manifestPath); + + // Then the SBOM should contain only the member's direct dependencies + compareSboms(result.content, expectedSbom); + }).timeout(process.env.GITHUB_ACTIONS ? 15000 : 10000); + }); + test('loads a valid manifest with ignored dependencies', () => { const testCase = 'package_json_deps_with_exhortignore_object'; const manifestPath = `test/providers/tst_manifests/npm/${testCase}/package.json`; diff --git a/test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json b/test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json new file mode 100644 index 00000000..b1033c7e --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json @@ -0,0 +1,36 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "member-a", + "version": "1.0.0", + "purl": "pkg:npm/member-a@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/member-a@1.0.0" + } + }, + "components": [ + { + "name": "axios", + "version": "0.21.1", + "purl": "pkg:npm/axios@0.21.1", + "type": "library", + "bom-ref": "pkg:npm/axios@0.21.1" + } + ], + "dependencies": [ + { + "ref": "pkg:npm/member-a@1.0.0", + "dependsOn": [ + "pkg:npm/axios@0.21.1" + ] + }, + { + "ref": "pkg:npm/axios@0.21.1", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/npm/workspace_member/listing_component.json b/test/providers/tst_manifests/npm/workspace_member/listing_component.json new file mode 100644 index 00000000..d8d8cc9d --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/listing_component.json @@ -0,0 +1,17 @@ +{ + "name": "test-workspace-root", + "version": "1.0.0", + "dependencies": { + "member-a": { + "version": "1.0.0", + "resolved": "file:packages/member-a", + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "overridden": false + } + } + } + } +} diff --git a/test/providers/tst_manifests/npm/workspace_member/listing_stack.json b/test/providers/tst_manifests/npm/workspace_member/listing_stack.json new file mode 100644 index 00000000..827f8814 --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/listing_stack.json @@ -0,0 +1,24 @@ +{ + "name": "test-workspace-root", + "version": "1.0.0", + "dependencies": { + "member-a": { + "version": "1.0.0", + "resolved": "file:packages/member-a", + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "overridden": false, + "dependencies": { + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "overridden": false + } + } + } + } + } + } +} diff --git a/test/providers/tst_manifests/npm/workspace_member/package-lock.json b/test/providers/tst_manifests/npm/workspace_member/package-lock.json new file mode 100644 index 00000000..268208f5 --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace-root", + "lockfileVersion": 3 +} diff --git a/test/providers/tst_manifests/npm/workspace_member/package.json b/test/providers/tst_manifests/npm/workspace_member/package.json new file mode 100644 index 00000000..98101a90 --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-root", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json b/test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json new file mode 100644 index 00000000..fba8d2af --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "member-a", + "version": "1.0.0", + "dependencies": { + "axios": "0.21.1" + } +} diff --git a/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json b/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json new file mode 100644 index 00000000..d2166788 --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json @@ -0,0 +1,49 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "member-a", + "version": "1.0.0", + "purl": "pkg:npm/member-a@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/member-a@1.0.0" + } + }, + "components": [ + { + "name": "axios", + "version": "0.21.1", + "purl": "pkg:npm/axios@0.21.1", + "type": "library", + "bom-ref": "pkg:npm/axios@0.21.1" + }, + { + "name": "follow-redirects", + "version": "1.15.6", + "purl": "pkg:npm/follow-redirects@1.15.6", + "type": "library", + "bom-ref": "pkg:npm/follow-redirects@1.15.6" + } + ], + "dependencies": [ + { + "ref": "pkg:npm/member-a@1.0.0", + "dependsOn": [ + "pkg:npm/axios@0.21.1" + ] + }, + { + "ref": "pkg:npm/axios@0.21.1", + "dependsOn": [ + "pkg:npm/follow-redirects@1.15.6" + ] + }, + { + "ref": "pkg:npm/follow-redirects@1.15.6", + "dependsOn": [] + } + ] +} From 4be705b1904e526b1bed7954c9473ec5610107aa Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 22 Apr 2026 15:01:27 +0100 Subject: [PATCH 2/4] docs: add explanatory comment to npm workspace member fix Co-Authored-By: Claude Opus 4.6 --- src/providers/javascript_npm.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/providers/javascript_npm.js b/src/providers/javascript_npm.js index 1946001d..f6d46338 100644 --- a/src/providers/javascript_npm.js +++ b/src/providers/javascript_npm.js @@ -19,6 +19,10 @@ export default class Javascript_npm extends Base_javascript { } _buildDependencyTree(includeTransitive, opts = {}) { + // npm ls --json returns a single tree rooted at the workspace root. + // When analyzing a workspace member, its deps are nested under the + // root's dependencies keyed by the member name — extract that subtree + // so downstream analysis sees only the member's dependencies. const tree = super._buildDependencyTree(includeTransitive, opts); const memberName = this._getManifest().name; if (tree.name === memberName) { From 62f12c04d316d283c4ea7587c537342b402f33cf Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 22 Apr 2026 15:29:29 +0100 Subject: [PATCH 3/4] fix: regenerate npm workspace member listings from real CLI output Test fixtures regenerated from actual npm ls output (follow-redirects resolved to 1.16.0, root object has no version field, resolved path uses relative ../packages/member-a). Co-Authored-By: Claude Opus 4.6 --- .../npm/workspace_member/listing_component.json | 4 ++-- .../npm/workspace_member/listing_stack.json | 8 ++++---- .../npm/workspace_member/stack_expected_sbom.json | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/test/providers/tst_manifests/npm/workspace_member/listing_component.json b/test/providers/tst_manifests/npm/workspace_member/listing_component.json index d8d8cc9d..d78cabbd 100644 --- a/test/providers/tst_manifests/npm/workspace_member/listing_component.json +++ b/test/providers/tst_manifests/npm/workspace_member/listing_component.json @@ -1,10 +1,10 @@ { "name": "test-workspace-root", - "version": "1.0.0", "dependencies": { "member-a": { "version": "1.0.0", - "resolved": "file:packages/member-a", + "resolved": "file:../packages/member-a", + "overridden": false, "dependencies": { "axios": { "version": "0.21.1", diff --git a/test/providers/tst_manifests/npm/workspace_member/listing_stack.json b/test/providers/tst_manifests/npm/workspace_member/listing_stack.json index 827f8814..16a6a05e 100644 --- a/test/providers/tst_manifests/npm/workspace_member/listing_stack.json +++ b/test/providers/tst_manifests/npm/workspace_member/listing_stack.json @@ -1,10 +1,10 @@ { "name": "test-workspace-root", - "version": "1.0.0", "dependencies": { "member-a": { "version": "1.0.0", - "resolved": "file:packages/member-a", + "resolved": "file:../packages/member-a", + "overridden": false, "dependencies": { "axios": { "version": "0.21.1", @@ -12,8 +12,8 @@ "overridden": false, "dependencies": { "follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "overridden": false } } diff --git a/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json b/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json index d2166788..28da3d7a 100644 --- a/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json +++ b/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json @@ -22,10 +22,10 @@ }, { "name": "follow-redirects", - "version": "1.15.6", - "purl": "pkg:npm/follow-redirects@1.15.6", + "version": "1.16.0", + "purl": "pkg:npm/follow-redirects@1.16.0", "type": "library", - "bom-ref": "pkg:npm/follow-redirects@1.15.6" + "bom-ref": "pkg:npm/follow-redirects@1.16.0" } ], "dependencies": [ @@ -38,11 +38,11 @@ { "ref": "pkg:npm/axios@0.21.1", "dependsOn": [ - "pkg:npm/follow-redirects@1.15.6" + "pkg:npm/follow-redirects@1.16.0" ] }, { - "ref": "pkg:npm/follow-redirects@1.15.6", + "ref": "pkg:npm/follow-redirects@1.16.0", "dependsOn": [] } ] From a84ecb3f7a353ee345039502d87d7b3d3f89565a Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 23 Apr 2026 11:36:56 +0100 Subject: [PATCH 4/4] chore(test): remove GITHUB_ACTIONS timeout ternaries Use the larger timeout value unconditionally instead of branching on the CI environment variable. Co-Authored-By: Claude Opus 4.6 --- test/providers/javascript.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index b60a8874..1aa0b118 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -90,7 +90,7 @@ suite('testing the javascript-npm data provider', async () => { compareSboms(providedDataForStack.content, expectedSbom); - }).timeout(process.env.GITHUB_ACTIONS ? 30000 : 10000); + }).timeout(30000); test(`verify package.json data provided for ${providerName} - component analysis - ${scenario}`, async () => { // load the expected list for the scenario let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/js-common/${testCase}/component_expected_sbom.json`,).toString().trim() @@ -102,7 +102,7 @@ suite('testing the javascript-npm data provider', async () => { let providedDataForComponent = provider.provideComponent(manifestPath); compareSboms(providedDataForComponent.content, expectedSbom); - }).timeout(process.env.GITHUB_ACTIONS ? 15000 : 10000) + }).timeout(15000) }); @@ -122,7 +122,7 @@ suite('testing the javascript-npm data provider', async () => { // Then the SBOM should contain the member's transitive dependencies compareSboms(result.content, expectedSbom); - }).timeout(process.env.GITHUB_ACTIONS ? 30000 : 10000); + }).timeout(30000); /// Verifies that component analysis resolves direct dependencies for a workspace member. test(`verify workspace member data provided for ${providerName} - component analysis`, async () => { @@ -137,7 +137,7 @@ suite('testing the javascript-npm data provider', async () => { // Then the SBOM should contain only the member's direct dependencies compareSboms(result.content, expectedSbom); - }).timeout(process.env.GITHUB_ACTIONS ? 15000 : 10000); + }).timeout(15000); }); test('loads a valid manifest with ignored dependencies', () => {