1919
2020package org.ossreviewtoolkit.plugins.scanners.scanoss
2121
22+ import com.scanoss.dto.LicenseDetails
2223import com.scanoss.dto.ScanFileDetails
2324import com.scanoss.dto.ScanFileResult
2425import com.scanoss.dto.enums.MatchType
@@ -36,7 +37,6 @@ import org.ossreviewtoolkit.model.TextLocation
3637import org.ossreviewtoolkit.utils.spdx.SpdxConstants
3738import org.ossreviewtoolkit.utils.spdx.SpdxExpression
3839import org.ossreviewtoolkit.utils.spdx.SpdxLicenseIdExpression
39- import org.ossreviewtoolkit.utils.spdx.toExpression
4040
4141/* *
4242 * Generate a summary from the given SCANOSS [result], using [startTime], [endTime] as metadata. This variant can be
@@ -134,7 +134,7 @@ private fun getCopyrightFindings(details: ScanFileDetails): List<CopyrightFindin
134134}
135135
136136/* *
137- * Get the snippet findings from the given [details]. If a snippet returned by ScanOSS contains several Purls,
137+ * Get the snippet findings from the given [details]. If a snippet returned by SCANOSS contains several Purls,
138138 * several snippets are created in ORT each containing a single Purl.
139139 */
140140private fun getSnippets (details : ScanFileDetails ): Set <Snippet > {
@@ -144,9 +144,7 @@ private fun getSnippets(details: ScanFileDetails): Set<Snippet> {
144144 val url = requireNotNull(details.url)
145145 val purls = requireNotNull(details.purls)
146146
147- val licenses = details.licenseDetails.orEmpty().mapTo(mutableSetOf ()) { license ->
148- SpdxExpression .parse(license.name)
149- }
147+ val license = getUniqueLicenseExpression(details.licenseDetails.toList())
150148
151149 val score = matched.substringBeforeLast(" %" ).toFloat()
152150 val locations = convertLines(fileUrl, ossLines)
@@ -157,8 +155,6 @@ private fun getSnippets(details: ScanFileDetails): Set<Snippet> {
157155 return buildSet {
158156 purls.forEach { purl ->
159157 locations.forEach { snippetLocation ->
160- val license = licenses.toExpression()?.sorted() ? : SpdxLicenseIdExpression (SpdxConstants .NOASSERTION )
161-
162158 add(Snippet (score, snippetLocation, provenance, purl, license))
163159 }
164160 }
@@ -178,3 +174,36 @@ private fun convertLines(file: String, lineRanges: String): List<TextLocation> =
178174 else -> throw IllegalArgumentException (" Unsupported line range '$lineRange '." )
179175 }
180176 }
177+
178+ /* *
179+ * Generates a unified SPDX license expression by combining multiple license declarations using the AND operator.
180+ *
181+ * During license scanning, components may have multiple license declarations from various sources
182+ * (such as package manifests, SPDX tags, file headers, LICENSE files, or automated detection tools).
183+ * This function creates a single, normalized SPDX expression that represents all discovered licenses.
184+ *
185+ *
186+ * @param licensesDetails A list of LicenseDetails objects, each containing information about a
187+ * discovered license.
188+ *
189+ * @return A combined SpdxExpression using AND operator. If the input list is empty,
190+ * returns an SpdxLicenseIdExpression with the value "NOASSERTION".
191+ *
192+ * Note: The function removes duplicate licenses during processing through the simplify() method,
193+ * so identical licenses detected from multiple sources will appear only once in the final
194+ * expression.
195+ *
196+ * Example:
197+ * Input: [LicenseDetails("MIT"), LicenseDetails("Apache-2.0"), LicenseDetails("MIT")]
198+ * Output: SpdxExpression representing "MIT AND Apache-2.0" (duplicate MIT license is removed)
199+ */
200+ fun getUniqueLicenseExpression (licensesDetails : List <LicenseDetails >): SpdxExpression {
201+ if (licensesDetails.isEmpty()) {
202+ return SpdxLicenseIdExpression (SpdxConstants .NOASSERTION )
203+ }
204+
205+ return licensesDetails
206+ .map { license -> SpdxExpression .parse(license.name) }
207+ .reduce { acc, expr -> acc and expr }
208+ .simplify()
209+ }
0 commit comments