Skip to content

Commit ca48015

Browse files
ruromeroclaude
andcommitted
feat: calculate compatibility from backend information
Signed-off-by: Ruben Romero Montes <rromerom@redhat.com> Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d29c779 commit ca48015

3 files changed

Lines changed: 129 additions & 113 deletions

File tree

docs/license-resolution-and-compliance.md

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ The client looks for your project’s license in two places:
2626

2727
The backend’s license identification API is used for accurate LICENSE file detection.
2828

29-
### Dependency License Information
30-
31-
Dependency licenses come from the Trustify DA backend, which categorizes them as:
32-
- **PERMISSIVE** (MIT, Apache-2.0, BSD, etc.)
33-
- **WEAK_COPYLEFT** (LGPL, MPL, etc.)
34-
- **STRONG_COPYLEFT** (GPL, AGPL, etc.)
35-
- **UNKNOWN**
36-
3729
### Compatibility Checking
3830

3931
The client checks if dependency licenses are compatible with your project license. For example:
@@ -46,7 +38,7 @@ Compatibility results are included in the analysis report’s `licenseSummary`.
4638

4739
### Disable License Checking
4840

49-
License analysis runs automatically during component/stack analysis. To disable it:
41+
License analysis runs automatically during **component analysis only** (not stack analysis). To disable it:
5042

5143
**Environment variable:**
5244
```bash
@@ -58,13 +50,6 @@ export TRUSTIFY_DA_LICENSE_CHECK=false
5850
await componentAnalysis(‘pom.xml’, { licenseCheck: false });
5951
```
6052

61-
### Backend URL
62-
63-
License analysis requires the same backend URL as dependency analysis:
64-
```bash
65-
export TRUSTIFY_DA_BACKEND_URL=https://api.trustify.dev
66-
```
67-
6853
## CLI Usage
6954

7055
### Get License Information
@@ -76,62 +61,84 @@ exhort license path/to/pom.xml
7661
**Example output:**
7762
```json
7863
{
79-
"projectLicense": {
80-
"fromManifest": "Apache-2.0",
81-
"fromFile": "Apache-2.0",
82-
"mismatch": false
64+
"manifestLicense": {
65+
"spdxId": "Apache-2.0",
66+
"category": "PERMISSIVE",
67+
"name": "Apache License 2.0",
68+
"identifiers": ["Apache-2.0"]
8369
},
84-
"dependencies": {
85-
"pkg:maven/com.google.guava/guava@32.1.0": {
86-
"licenses": ["Apache-2.0"],
87-
"category": "PERMISSIVE",
88-
"compatible": true
89-
},
90-
"pkg:maven/org.postgresql/postgresql@42.6.0": {
91-
"licenses": ["BSD-2-Clause"],
92-
"category": "PERMISSIVE",
93-
"compatible": true
94-
}
95-
}
70+
"fileLicense": {
71+
"spdxId": "Apache-2.0",
72+
"category": "PERMISSIVE",
73+
"name": "Apache License 2.0",
74+
"identifiers": ["Apache-2.0"]
75+
},
76+
"mismatch": false
9677
}
9778
```
9879

80+
Note: The `license` command shows only your project's license. For dependency license information, use component analysis.
81+
9982
## Analysis Report Fields
10083

101-
When license checking is enabled, the analysis report includes:
84+
When license checking is enabled, component analysis includes a `licenseSummary` field:
10285

10386
```javascript
10487
{
105-
// ... standard analysis fields ...
88+
// ... standard analysis fields (providers, etc.) ...
10689
"licenseSummary": {
107-
"projectLicenseFromManifest": "Apache-2.0",
108-
"projectLicenseFromFile": "Apache-2.0",
109-
"manifestVsFileMismatch": false,
90+
"projectLicense": {
91+
"manifest": {
92+
"spdxId": "Apache-2.0",
93+
"category": "PERMISSIVE",
94+
"name": "Apache License 2.0",
95+
"identifiers": ["Apache-2.0"]
96+
},
97+
"file": {
98+
"spdxId": "Apache-2.0",
99+
"category": "PERMISSIVE",
100+
"name": "Apache License 2.0",
101+
"identifiers": ["Apache-2.0"]
102+
},
103+
"mismatch": false
104+
},
110105
"incompatibleDependencies": [
111106
{
112107
"purl": "pkg:maven/org.example/gpl-lib@1.0.0",
113108
"licenses": ["GPL-3.0"],
114109
"category": "STRONG_COPYLEFT",
115110
"reason": "Dependency license(s) are incompatible with the project license."
116111
}
117-
],
118-
"dependencyLicenses": [
119-
{ "purl": "...", "licenses": [...], "category": "..." }
120112
]
121113
}
122114
}
123115
```
124116

117+
**Note:** Dependency license information (for all dependencies, not just incompatible ones) is available in the standard backend response under the `licenses` field. The `licenseSummary` only includes project license details and flagged incompatibilities.
118+
125119
## Common Scenarios
126120

127121
### Mismatch Between Manifest and LICENSE File
128122

129-
If your `package.json` says `"license": "MIT"` but your LICENSE file contains Apache-2.0 text:
123+
If your `package.json` says `"license": "MIT"` but your LICENSE file contains Apache-2.0 text, the component analysis report will show:
130124
```json
131125
{
132-
"projectLicenseFromManifest": "MIT",
133-
"projectLicenseFromFile": "Apache-2.0",
134-
"manifestVsFileMismatch": true
126+
"licenseSummary": {
127+
"projectLicense": {
128+
"manifest": {
129+
"spdxId": "MIT",
130+
"category": "PERMISSIVE",
131+
"name": "MIT License"
132+
},
133+
"file": {
134+
"spdxId": "Apache-2.0",
135+
"category": "PERMISSIVE",
136+
"name": "Apache License 2.0"
137+
},
138+
"mismatch": true
139+
},
140+
"incompatibleDependencies": []
141+
}
135142
}
136143
```
137144

src/license/compatibility.js

Lines changed: 35 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,53 @@
11
/**
22
* License compatibility: whether a dependency license is compatible with the project license.
3-
* Uses a minimal in-client matrix; the backend may provide compatibility in the future.
3+
* Relies on backend-provided license categories.
4+
*
5+
* Compatibility is based on restrictiveness hierarchy:
6+
* PERMISSIVE < WEAK_COPYLEFT < STRONG_COPYLEFT
7+
*
8+
* A dependency is compatible if it's equal or less restrictive than the project license.
9+
* A dependency is incompatible if it's more restrictive than the project license.
410
*/
511

612
/**
7-
* Check if a dependency's license(s) are compatible with the project license.
8-
* When the backend provides dependencyCategory (from License Analysis API / analysis report), it is used for more accurate compatibility.
13+
* Check if a dependency's license is compatible with the project license based on backend categories.
914
*
10-
* @param {string|null} projectLicense - SPDX id or name from manifest/file
11-
* @param {string[]} dependencyLicenses - SPDX ids or names for the dependency
12-
* @param {string} [dependencyCategory] - optional category from backend: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN
15+
* @param {string} [projectCategory] - backend category for project license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN
16+
* @param {string} [dependencyCategory] - backend category for dependency license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN
1317
* @returns {'compatible'|'incompatible'|'unknown'}
1418
*/
15-
export function getCompatibility(projectLicense, dependencyLicenses, dependencyCategory) {
16-
if (!projectLicense) {return 'unknown';}
17-
if (!dependencyLicenses?.length) {return 'unknown';}
18-
19-
// Use backend category when available (from API v5 licenses / analysis report)
20-
const cat = String(dependencyCategory || '').toUpperCase();
21-
if (cat === 'STRONG_COPYLEFT') {
22-
const proj = projectLicense ? normalize(projectLicense) : '';
23-
if (isPermissive(proj)) {return 'incompatible';}
24-
if (isCopyleft(proj)) {return 'unknown';}
25-
return 'incompatible';
26-
}
27-
if (cat === 'WEAK_COPYLEFT') {
28-
const proj = projectLicense ? normalize(projectLicense) : '';
29-
if (isPermissive(proj)) {return 'unknown';} // weak copyleft often acceptable when used as library
19+
export function getCompatibility(projectCategory, dependencyCategory) {
20+
if (!projectCategory || !dependencyCategory) {
3021
return 'unknown';
3122
}
32-
if (cat === 'PERMISSIVE' && (!projectLicense || isPermissive(normalize(projectLicense)))) {return 'compatible';}
33-
34-
if (!projectLicense || !dependencyLicenses?.length) {return 'unknown';}
3523

36-
const proj = normalize(projectLicense);
37-
const depSet = new Set(dependencyLicenses.map(normalize).filter(Boolean));
24+
const proj = projectCategory.toUpperCase();
25+
const dep = dependencyCategory.toUpperCase();
3826

39-
// Same license or both permissive -> compatible
40-
if (depSet.has(proj)) {return 'compatible';}
41-
if (isPermissive(proj) && [...depSet].every(isPermissive)) {return 'compatible';}
42-
43-
// Project is permissive; dependency is copyleft -> flag for user awareness
44-
if (isPermissive(proj) && [...depSet].some(isCopyleft)) {return 'incompatible';}
27+
// Unknown categories
28+
if (proj === 'UNKNOWN' || dep === 'UNKNOWN') {
29+
return 'unknown';
30+
}
4531

46-
// Project is copyleft; dependency is different copyleft or proprietary -> unknown / depends on linking
47-
if (isCopyleft(proj)) {return 'unknown';}
32+
// Define restrictiveness levels (higher number = more restrictive)
33+
const restrictiveness = {
34+
'PERMISSIVE': 1,
35+
'WEAK_COPYLEFT': 2,
36+
'STRONG_COPYLEFT': 3
37+
};
4838

49-
return 'unknown';
50-
}
39+
const projLevel = restrictiveness[proj];
40+
const depLevel = restrictiveness[dep];
5141

52-
function normalize(id) {
53-
return String(id).trim().toLowerCase().replace(/\s+/g, '-');
54-
}
42+
if (projLevel === undefined || depLevel === undefined) {
43+
return 'unknown';
44+
}
5545

56-
function isPermissive(id) {
57-
const n = normalize(id);
58-
return ['mit', 'apache-2.0', 'bsd-2-clause', 'bsd-3-clause', 'isc', '0bsd'].includes(n) ||
59-
n.startsWith('apache-') || n.startsWith('bsd-');
60-
}
46+
// Dependency is more restrictive than project → incompatible
47+
if (depLevel > projLevel) {
48+
return 'incompatible';
49+
}
6150

62-
function isCopyleft(id) {
63-
const n = normalize(id);
64-
return ['gpl-2.0-only', 'gpl-2.0-or-later', 'gpl-3.0-only', 'gpl-3.0-or-later',
65-
'agpl-3.0-only', 'agpl-3.0-or-later'].includes(n) || n.startsWith('gpl-') || n.startsWith('agpl-');
51+
// Dependency is equal or less restrictive → compatible
52+
return 'compatible';
6653
}

src/license/index.js

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { getProjectLicense, findLicenseFilePath, identifyLicense } from './project_license.js';
6-
import { licenseMapFromAnalysisReport } from './licenses_api.js';
6+
import { licenseMapFromAnalysisReport, getLicenseDetails } from './licenses_api.js';
77
import { getCompatibility } from './compatibility.js';
88

99
export { getProjectLicense, getProjectLicenseFromManifest, findLicenseFilePath, identifyLicense as identifyLicenseViaBackend } from './project_license.js';
@@ -39,15 +39,15 @@ export function extractPurlsFromSbom(sbomContent, excludeRoot = true) {
3939
}
4040

4141
/**
42-
* Run full license check: resolve project license (with optional backend identification for LICENSE file),
42+
* Run full license check: resolve project license (with backend identification and details),
4343
* get dependency licenses from analysis report, and compute incompatibilities.
4444
*
4545
* @param {string} sbomContent - CycloneDX SBOM JSON string (the one sent for component analysis)
4646
* @param {string} manifestPath - path to manifest
4747
* @param {string} backendUrl - Trustify DA backend base URL
4848
* @param {import('../index.js').Options} [opts={}]
4949
* @param {import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport} [analysisResult] - analysis result that includes licenses array from backend
50-
* @returns {Promise<{ projectLicenseFromManifest: string|null, projectLicenseFromFile: string|null, manifestVsFileMismatch: boolean, incompatibleDependencies: Array<{ purl: string, licenses: string[], category?: string, reason: string }>, dependencyLicenses: Array<{ purl: string, licenses: string[], category?: string }>, error?: string }>}
50+
* @returns {Promise<{ projectLicense: { manifest: Object|null, file: Object|null, mismatch: boolean }, incompatibleDependencies: Array<{ purl: string, licenses: string[], category?: string, reason: string }>, error?: string }>}
5151
*/
5252
export async function runLicenseCheck(sbomContent, manifestPath, backendUrl, opts = {}, analysisResult = null) {
5353
// Get project license from manifest (always available)
@@ -71,14 +71,35 @@ export async function runLicenseCheck(sbomContent, manifestPath, backendUrl, opt
7171
projectLicense.fromManifest.toLowerCase() !== finalFromFile.toLowerCase()
7272
);
7373

74+
// Fetch detailed license info from backend for both manifest and file licenses
75+
let manifestLicenseInfo = null;
76+
let fileLicenseInfo = null;
77+
78+
if (projectLicense.fromManifest && backendUrl) {
79+
try {
80+
manifestLicenseInfo = await getLicenseDetails(projectLicense.fromManifest, backendUrl, opts);
81+
} catch {
82+
// Backend might not have this license; keep as null
83+
}
84+
}
85+
86+
if (finalFromFile && backendUrl) {
87+
try {
88+
fileLicenseInfo = await getLicenseDetails(finalFromFile, backendUrl, opts);
89+
} catch {
90+
// Backend might not have this license; keep as null
91+
}
92+
}
93+
7494
const purls = extractPurlsFromSbom(sbomContent, true);
7595
if (purls.length === 0) {
7696
return {
77-
projectLicenseFromManifest: projectLicense.fromManifest,
78-
projectLicenseFromFile: finalFromFile,
79-
manifestVsFileMismatch: finalMismatch,
80-
incompatibleDependencies: [],
81-
dependencyLicenses: []
97+
projectLicense: {
98+
manifest: manifestLicenseInfo,
99+
file: fileLicenseInfo,
100+
mismatch: finalMismatch
101+
},
102+
incompatibleDependencies: []
82103
};
83104
}
84105

@@ -87,23 +108,23 @@ export async function runLicenseCheck(sbomContent, manifestPath, backendUrl, opt
87108
if (licenseByPurl.size === 0 && analysisResult) {
88109
// No license data in analysis report - this might be expected for some backends
89110
return {
90-
projectLicenseFromManifest: projectLicense.fromManifest,
91-
projectLicenseFromFile: finalFromFile,
92-
manifestVsFileMismatch: finalMismatch,
111+
projectLicense: {
112+
manifest: manifestLicenseInfo,
113+
file: fileLicenseInfo,
114+
mismatch: finalMismatch
115+
},
93116
incompatibleDependencies: [],
94-
dependencyLicenses: [],
95117
error: 'No license data available in analysis report'
96118
};
97119
}
98120

99-
const projectLicenseForCheck = projectLicense.fromManifest || finalFromFile || null;
100-
const dependencyLicenses = [];
121+
// Use backend category from project license details (prefer manifest, fallback to file)
122+
const projectCategory = manifestLicenseInfo?.category || fileLicenseInfo?.category || null;
101123
const incompatibleDependencies = [];
102124

103125
for (const purl of purls) {
104126
const entry = licenseByPurl.get(purl) || { licenses: [], category: undefined };
105-
dependencyLicenses.push({ purl, licenses: entry.licenses, category: entry.category });
106-
const status = getCompatibility(projectLicenseForCheck, entry.licenses, entry.category);
127+
const status = getCompatibility(projectCategory, entry.category);
107128
if (status === 'incompatible') {
108129
incompatibleDependencies.push({
109130
purl,
@@ -115,10 +136,11 @@ export async function runLicenseCheck(sbomContent, manifestPath, backendUrl, opt
115136
}
116137

117138
return {
118-
projectLicenseFromManifest: projectLicense.fromManifest,
119-
projectLicenseFromFile: finalFromFile,
120-
manifestVsFileMismatch: finalMismatch,
121-
incompatibleDependencies,
122-
dependencyLicenses
139+
projectLicense: {
140+
manifest: manifestLicenseInfo,
141+
file: fileLicenseInfo,
142+
mismatch: finalMismatch
143+
},
144+
incompatibleDependencies
123145
};
124146
}

0 commit comments

Comments
 (0)