Skip to content

Commit cd2ee30

Browse files
ruromeroclaude
andcommitted
fix(python): build true dependency graph instead of tree
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 <noreply@anthropic.com>
1 parent d674093 commit cd2ee30

16 files changed

Lines changed: 685 additions & 711 deletions

src/providers/base_pyproject.js

Lines changed: 39 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -250,64 +250,27 @@ export default class Base_pyproject {
250250
}
251251

252252
/**
253-
* Build dependency tree from graph, starting from direct deps.
254-
* @param {Map<string, GraphEntry>} graph
255-
* @param {string[]} directDeps - canonical names of direct deps
253+
* Compute the set of graph nodes reachable from direct deps, excluding ignored.
254+
* @param {Map<string, {name: string, version: string, children: string[]}>} graph
255+
* @param {string[]} directDeps
256256
* @param {Set<string>} ignoredDeps
257-
* @param {boolean} includeTransitive
258-
* @returns {DepTreeEntry[]}
257+
* @returns {Set<string>}
259258
* @protected
260259
*/
261-
_buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) {
262-
let result = []
263-
264-
for (let key of directDeps) {
265-
if (ignoredDeps.has(key)) { continue }
266-
267-
let entry = graph.get(key)
268-
if (!entry) { continue }
269-
270-
let depTree = []
271-
if (includeTransitive) {
272-
let visited = new Set()
273-
visited.add(key)
274-
this._collectTransitive(graph, entry.children, depTree, ignoredDeps, visited)
260+
_reachableNodes(graph, directDeps, ignoredDeps) {
261+
let reachable = new Set()
262+
let queue = directDeps.filter(k => !ignoredDeps.has(k) && graph.has(k))
263+
while (queue.length > 0) {
264+
let key = queue.shift()
265+
if (reachable.has(key)) { continue }
266+
reachable.add(key)
267+
for (let child of graph.get(key).children) {
268+
if (!ignoredDeps.has(child) && graph.has(child) && !reachable.has(child)) {
269+
queue.push(child)
270+
}
275271
}
276-
277-
result.push({ name: entry.name, version: entry.version, dependencies: depTree })
278272
}
279-
280-
result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
281-
return result
282-
}
283-
284-
/**
285-
* Recursively collect transitive dependencies.
286-
* @param {Map<string, GraphEntry>} graph
287-
* @param {string[]} childKeys
288-
* @param {DepTreeEntry[]} result - mutated in place
289-
* @param {Set<string>} ignoredDeps
290-
* @param {Set<string>} visited
291-
* @returns {void}
292-
* @protected
293-
*/
294-
_collectTransitive(graph, childKeys, result, ignoredDeps, visited) {
295-
for (let childKey of childKeys) {
296-
let canonKey = this._canonicalize(childKey)
297-
if (ignoredDeps.has(canonKey)) { continue }
298-
if (visited.has(canonKey)) { continue }
299-
visited.add(canonKey)
300-
301-
let entry = graph.get(canonKey)
302-
if (!entry) { continue }
303-
304-
let childDeps = []
305-
this._collectTransitive(graph, entry.children, childDeps, ignoredDeps, visited)
306-
307-
result.push({ name: entry.name, version: entry.version, dependencies: childDeps })
308-
}
309-
310-
result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
273+
return reachable
311274
}
312275

313276
/**
@@ -320,22 +283,6 @@ export default class Base_pyproject {
320283
return new PackageURL('pypi', undefined, name, version, undefined, undefined)
321284
}
322285

323-
/**
324-
* Recursively add a dependency and its transitive deps to the SBOM.
325-
* @param {PackageURL} source
326-
* @param {DepTreeEntry} dep
327-
* @param {Sbom} sbom
328-
* @returns {void}
329-
* @private
330-
*/
331-
_addAllDependencies(source, dep, sbom) {
332-
let targetPurl = this._toPurl(dep.name, dep.version)
333-
sbom.addDependency(source, targetPurl)
334-
if (dep.dependencies && dep.dependencies.length > 0) {
335-
dep.dependencies.forEach(child => this._addAllDependencies(this._toPurl(dep.name, dep.version), child, sbom))
336-
}
337-
}
338-
339286
/**
340287
* Create SBOM json string for a pyproject.toml project.
341288
* @param {string} manifest - path to pyproject.toml
@@ -353,7 +300,6 @@ export default class Base_pyproject {
353300
let { directDeps, graph } = await this._getDependencyData(manifestDir, workspaceDir, parsed, opts)
354301

355302
let ignoredDeps = this._getIgnoredDeps(manifest)
356-
let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive)
357303

358304
let sbom = new Sbom()
359305
let rootName = this._getProjectName(parsed) || DEFAULT_ROOT_NAME
@@ -362,13 +308,30 @@ export default class Base_pyproject {
362308
let license = this.readLicenseFromManifest(manifest)
363309
sbom.addRoot(rootPurl, license)
364310

365-
dependencies.forEach(dep => {
366-
if (includeTransitive) {
367-
this._addAllDependencies(rootPurl, dep, sbom)
368-
} else {
369-
sbom.addDependency(rootPurl, this._toPurl(dep.name, dep.version))
311+
if (includeTransitive) {
312+
let reachable = this._reachableNodes(graph, directDeps, ignoredDeps)
313+
for (let key of directDeps) {
314+
if (!reachable.has(key)) { continue }
315+
let entry = graph.get(key)
316+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
370317
}
371-
})
318+
for (let [key, entry] of graph) {
319+
if (!reachable.has(key)) { continue }
320+
let parentPurl = this._toPurl(entry.name, entry.version)
321+
for (let child of entry.children) {
322+
if (!reachable.has(child)) { continue }
323+
let childEntry = graph.get(child)
324+
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version))
325+
}
326+
}
327+
} else {
328+
for (let key of directDeps) {
329+
if (ignoredDeps.has(key)) { continue }
330+
let entry = graph.get(key)
331+
if (!entry) { continue }
332+
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
333+
}
334+
}
372335

373336
return sbom.getAsJsonString(opts)
374337
}

src/providers/python_pip_pyproject.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ export default class Python_pip_pyproject extends Base_pyproject {
9191
let entry = graph.get(key)
9292
let requires = pkg.metadata.requires_dist || []
9393
for (let req of requires) {
94-
if (this._hasExtraMarker(req)) { continue }
9594
let depName = this._extractDepName(req)
9695
if (!depName) { continue }
9796
let depKey = this._canonicalize(depName)

test/providers/python_pyproject.test.js

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,6 @@ suite('testing the python-pyproject data provider', () => {
210210
expect(transNames).to.include('urllib3')
211211
}).timeout(TIMEOUT)
212212

213-
/** Verifies extras-only dependencies (e.g. PySocks for socks extra) are excluded. */
214-
test('extras-only dependencies are filtered from the dependency tree', async () => {
215-
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
216-
let sbom = JSON.parse(result.content)
217-
let names = sbom.components.map(c => c.name)
218-
expect(names).to.not.include('PySocks')
219-
expect(names).to.not.include('pysocks')
220-
}).timeout(TIMEOUT)
221-
222213
/** Verifies exhortignore marker produces expected SBOM for stack and component analysis. */
223214
SBOM_CASES.forEach(({type, method, fixture}) => {
224215
test(`verify exhortignore produces expected sbom for ${type} analysis with pip`, async () => {

test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,4 @@
6969
"dependsOn": []
7070
}
7171
]
72-
}
72+
}

test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,27 @@
2020
"type": "library",
2121
"bom-ref": "pkg:pypi/flask@2.0.3"
2222
},
23+
{
24+
"name": "pywin32",
25+
"version": "311",
26+
"purl": "pkg:pypi/pywin32@311",
27+
"type": "library",
28+
"bom-ref": "pkg:pypi/pywin32@311"
29+
},
30+
{
31+
"name": "requests",
32+
"version": "2.25.1",
33+
"purl": "pkg:pypi/requests@2.25.1",
34+
"type": "library",
35+
"bom-ref": "pkg:pypi/requests@2.25.1"
36+
},
37+
{
38+
"name": "typing-extensions",
39+
"version": "4.1.1",
40+
"purl": "pkg:pypi/typing-extensions@4.1.1",
41+
"type": "library",
42+
"bom-ref": "pkg:pypi/typing-extensions@4.1.1"
43+
},
2344
{
2445
"name": "click",
2546
"version": "8.3.1",
@@ -55,20 +76,6 @@
5576
"type": "library",
5677
"bom-ref": "pkg:pypi/werkzeug@2.0.3"
5778
},
58-
{
59-
"name": "pywin32",
60-
"version": "311",
61-
"purl": "pkg:pypi/pywin32@311",
62-
"type": "library",
63-
"bom-ref": "pkg:pypi/pywin32@311"
64-
},
65-
{
66-
"name": "requests",
67-
"version": "2.25.1",
68-
"purl": "pkg:pypi/requests@2.25.1",
69-
"type": "library",
70-
"bom-ref": "pkg:pypi/requests@2.25.1"
71-
},
7279
{
7380
"name": "certifi",
7481
"version": "2023.7.22",
@@ -96,13 +103,6 @@
96103
"purl": "pkg:pypi/urllib3@1.26.16",
97104
"type": "library",
98105
"bom-ref": "pkg:pypi/urllib3@1.26.16"
99-
},
100-
{
101-
"name": "typing-extensions",
102-
"version": "4.1.1",
103-
"purl": "pkg:pypi/typing-extensions@4.1.1",
104-
"type": "library",
105-
"bom-ref": "pkg:pypi/typing-extensions@4.1.1"
106106
}
107107
],
108108
"dependencies": [
@@ -124,6 +124,23 @@
124124
"pkg:pypi/werkzeug@2.0.3"
125125
]
126126
},
127+
{
128+
"ref": "pkg:pypi/pywin32@311",
129+
"dependsOn": []
130+
},
131+
{
132+
"ref": "pkg:pypi/requests@2.25.1",
133+
"dependsOn": [
134+
"pkg:pypi/certifi@2023.7.22",
135+
"pkg:pypi/chardet@4.0.0",
136+
"pkg:pypi/idna@2.10",
137+
"pkg:pypi/urllib3@1.26.16"
138+
]
139+
},
140+
{
141+
"ref": "pkg:pypi/typing-extensions@4.1.1",
142+
"dependsOn": []
143+
},
127144
{
128145
"ref": "pkg:pypi/click@8.3.1",
129146
"dependsOn": [
@@ -146,19 +163,6 @@
146163
"ref": "pkg:pypi/werkzeug@2.0.3",
147164
"dependsOn": []
148165
},
149-
{
150-
"ref": "pkg:pypi/pywin32@311",
151-
"dependsOn": []
152-
},
153-
{
154-
"ref": "pkg:pypi/requests@2.25.1",
155-
"dependsOn": [
156-
"pkg:pypi/certifi@2023.7.22",
157-
"pkg:pypi/chardet@4.0.0",
158-
"pkg:pypi/idna@2.10",
159-
"pkg:pypi/urllib3@1.26.16"
160-
]
161-
},
162166
{
163167
"ref": "pkg:pypi/certifi@2023.7.22",
164168
"dependsOn": []
@@ -174,10 +178,6 @@
174178
{
175179
"ref": "pkg:pypi/urllib3@1.26.16",
176180
"dependsOn": []
177-
},
178-
{
179-
"ref": "pkg:pypi/typing-extensions@4.1.1",
180-
"dependsOn": []
181181
}
182182
]
183-
}
183+
}

test/providers/tst_manifests/pyproject/pip_pep621/expected_stack_sbom.json

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,6 @@
2020
"type": "library",
2121
"bom-ref": "pkg:pypi/requests@2.32.3"
2222
},
23-
{
24-
"name": "certifi",
25-
"version": "2026.2.25",
26-
"purl": "pkg:pypi/certifi@2026.2.25",
27-
"type": "library",
28-
"bom-ref": "pkg:pypi/certifi@2026.2.25"
29-
},
3023
{
3124
"name": "charset-normalizer",
3225
"version": "3.4.7",
@@ -47,6 +40,20 @@
4740
"purl": "pkg:pypi/urllib3@2.6.3",
4841
"type": "library",
4942
"bom-ref": "pkg:pypi/urllib3@2.6.3"
43+
},
44+
{
45+
"name": "certifi",
46+
"version": "2026.2.25",
47+
"purl": "pkg:pypi/certifi@2026.2.25",
48+
"type": "library",
49+
"bom-ref": "pkg:pypi/certifi@2026.2.25"
50+
},
51+
{
52+
"name": "pysocks",
53+
"version": "1.7.1",
54+
"purl": "pkg:pypi/pysocks@1.7.1",
55+
"type": "library",
56+
"bom-ref": "pkg:pypi/pysocks@1.7.1"
5057
}
5158
],
5259
"dependencies": [
@@ -59,16 +66,13 @@
5966
{
6067
"ref": "pkg:pypi/requests@2.32.3",
6168
"dependsOn": [
62-
"pkg:pypi/certifi@2026.2.25",
6369
"pkg:pypi/charset-normalizer@3.4.7",
6470
"pkg:pypi/idna@3.11",
65-
"pkg:pypi/urllib3@2.6.3"
71+
"pkg:pypi/urllib3@2.6.3",
72+
"pkg:pypi/certifi@2026.2.25",
73+
"pkg:pypi/pysocks@1.7.1"
6674
]
6775
},
68-
{
69-
"ref": "pkg:pypi/certifi@2026.2.25",
70-
"dependsOn": []
71-
},
7276
{
7377
"ref": "pkg:pypi/charset-normalizer@3.4.7",
7478
"dependsOn": []
@@ -79,6 +83,16 @@
7983
},
8084
{
8185
"ref": "pkg:pypi/urllib3@2.6.3",
86+
"dependsOn": [
87+
"pkg:pypi/pysocks@1.7.1"
88+
]
89+
},
90+
{
91+
"ref": "pkg:pypi/certifi@2026.2.25",
92+
"dependsOn": []
93+
},
94+
{
95+
"ref": "pkg:pypi/pysocks@1.7.1",
8296
"dependsOn": []
8397
}
8498
]

test/providers/tst_manifests/pyproject/poetry_lock/expected_component_sbom.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@
4545
"dependsOn": []
4646
}
4747
]
48-
}
48+
}

0 commit comments

Comments
 (0)