Skip to content

Commit 1682b80

Browse files
soul2zimateclaude
andauthored
feat: add license compatibility checking and mismatch detection (#243)
* feat: add license compatibility checking and mismatch detection - Switch API to componentAnalysisWithLicense() for license-aware analysis - Add licenseCheckEnabled setting (default true) with UI checkbox - Detect license mismatch between manifest and LICENSE file (ERROR) - Report incompatible dependency licenses (WARNING), merged into vulnerability annotations when both apply to the same dependency - Quick fix to update manifest license with SPDX ID from LICENSE file - Cache license summary alongside vulnerability data - Invalidate all caches after license quick fix to trigger re-analysis - Use WEAK_WARNING severity for recommendation-only dependencies - Implement license field extraction for NPM, Maven, and Cargo; Go, Gradle, and Python return null (no standard license field) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: escape HTML in tooltip strings to prevent injection from analysis results Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address license annotation issues found in code review - Invalidate all caches when licenseCheckEnabled setting changes so re-analysis is triggered on the next annotator pass - Merge PSI element lists on noVersionMap key collisions instead of discarding duplicates, ensuring all occurrences get license warnings - Wrap standalone license warning tooltips with <html> root element - Return null from extractSpdxId() when no real SPDX identifier is available, preventing quick-fixes from proposing non-SPDX values Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 172dcf3 commit 1682b80

13 files changed

Lines changed: 577 additions & 36 deletions

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java

Lines changed: 285 additions & 17 deletions
Large diffs are not rendered by default.

src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
import com.intellij.openapi.project.Project;
2424
import com.intellij.psi.PsiDocumentManager;
2525
import com.intellij.psi.PsiFile;
26+
import io.github.guacsec.trustifyda.ComponentAnalysisResult;
2627
import io.github.guacsec.trustifyda.api.v5.AnalysisReport;
2728
import io.github.guacsec.trustifyda.api.v5.DependencyReport;
2829
import io.github.guacsec.trustifyda.api.v5.ProviderReport;
2930
import io.github.guacsec.trustifyda.api.v5.Source;
31+
import io.github.guacsec.trustifyda.license.LicenseCheck.LicenseSummary;
3032
import org.jboss.tools.intellij.exhort.ApiService;
3133

3234
import java.util.Collections;
@@ -56,12 +58,28 @@ public static CAService getInstance() {
5658
.maximumSize(100)
5759
.build();
5860

61+
private final Cache<String, LicenseSummary> licenseCache = Caffeine.newBuilder()
62+
.maximumSize(100)
63+
.build();
64+
5965
public static Map<Dependency, Map<VulnerabilitySource, DependencyReport>> getReports(String filePath) {
6066
return Collections.unmodifiableMap(getInstance().vulnerabilityCache.get(filePath, p -> Collections.emptyMap()));
6167
}
6268

6369
public static void deleteReports(String filePath) {
6470
getInstance().vulnerabilityCache.invalidate(filePath);
71+
getInstance().licenseCache.invalidate(filePath);
72+
getInstance().dependencyCache.invalidate(filePath);
73+
}
74+
75+
public static void invalidateAllCaches() {
76+
getInstance().vulnerabilityCache.invalidateAll();
77+
getInstance().licenseCache.invalidateAll();
78+
getInstance().dependencyCache.invalidateAll();
79+
}
80+
81+
public static LicenseSummary getLicenseSummary(String filePath) {
82+
return getInstance().licenseCache.getIfPresent(filePath);
6583
}
6684

6785
public static boolean dependenciesModified(String filePath, Set<Dependency> dependencies) {
@@ -90,10 +108,22 @@ public static boolean performAnalysis(String packageManager,
90108
);
91109
}
92110

93-
AnalysisReport report = apiService.getComponentAnalysis(packageManager, fileName, filePath);
94-
if (report == null) {
111+
ComponentAnalysisResult analysisResult = apiService.getComponentAnalysis(packageManager, fileName, filePath);
112+
if (analysisResult == null) {
95113
throw new RuntimeException("Failed to perform component analysis, result is invalid.");
96114
}
115+
AnalysisReport report = analysisResult.report();
116+
if (report == null) {
117+
throw new RuntimeException("Failed to perform component analysis, report is invalid.");
118+
}
119+
120+
// Cache license summary
121+
LicenseSummary licenseSummary = analysisResult.licenseSummary();
122+
if (licenseSummary != null) {
123+
getInstance().licenseCache.put(filePath, licenseSummary);
124+
} else {
125+
getInstance().licenseCache.invalidate(filePath);
126+
}
97127

98128
Map<Dependency, Map<VulnerabilitySource, DependencyReport>> resultMap = new ConcurrentHashMap<>();
99129

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
12+
package org.jboss.tools.intellij.componentanalysis;
13+
14+
import com.intellij.codeInsight.intention.FileModifier;
15+
import com.intellij.codeInsight.intention.IntentionAction;
16+
import com.intellij.codeInspection.util.IntentionFamilyName;
17+
import com.intellij.codeInspection.util.IntentionName;
18+
import com.intellij.openapi.editor.Editor;
19+
import com.intellij.openapi.project.Project;
20+
import com.intellij.psi.PsiElement;
21+
import com.intellij.psi.PsiFile;
22+
import com.intellij.psi.util.PsiTreeUtil;
23+
import com.intellij.util.IncorrectOperationException;
24+
import org.jetbrains.annotations.NotNull;
25+
import org.jetbrains.annotations.Nullable;
26+
27+
import java.util.function.BiConsumer;
28+
29+
public class LicenseUpdateIntentionAction implements IntentionAction {
30+
31+
private final @FileModifier.SafeFieldForPreview PsiElement element;
32+
private final String newLicense;
33+
private final BiConsumer<PsiElement, String> replacer;
34+
35+
public LicenseUpdateIntentionAction(PsiElement element, String newLicense, BiConsumer<PsiElement, String> replacer) {
36+
this.element = element;
37+
this.newLicense = newLicense;
38+
this.replacer = replacer;
39+
}
40+
41+
@Override
42+
public @IntentionName @NotNull String getText() {
43+
return "Update manifest license to \"" + newLicense + "\" (from LICENSE file)";
44+
}
45+
46+
@Override
47+
public @NotNull @IntentionFamilyName String getFamilyName() {
48+
return "RHDA";
49+
}
50+
51+
@Override
52+
public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) {
53+
return element != null && element.isValid();
54+
}
55+
56+
@Override
57+
public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException {
58+
replacer.accept(element, newLicense);
59+
60+
// Invalidate caches so the next annotator pass triggers a fresh API call
61+
if (file != null && file.getVirtualFile() != null) {
62+
CAService.deleteReports(file.getVirtualFile().getPath());
63+
}
64+
}
65+
66+
@Override
67+
public boolean startInWriteAction() {
68+
return true;
69+
}
70+
71+
@Override
72+
public @Nullable FileModifier getFileModifierForPreview(@NotNull PsiFile target) {
73+
PsiElement copy = PsiTreeUtil.findSameElementInCopy(this.element, target);
74+
return new LicenseUpdateIntentionAction(copy, this.newLicense, this.replacer);
75+
}
76+
}

src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
1919
import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
2020
import org.jboss.tools.intellij.componentanalysis.Dependency;
21+
import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
2122
import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
23+
import org.jetbrains.annotations.Nullable;
2224
import com.intellij.psi.PsiComment;
2325
import com.intellij.psi.PsiDocumentManager;
2426
import com.intellij.psi.util.PsiTreeUtil;
@@ -320,6 +322,48 @@ private String extractVersionFromTomlValue(TomlValue tomlValue) {
320322
return UNRESOLVED_VERSION;
321323
}
322324

325+
@Override
326+
protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
327+
// Find [package] table, then "license" key-value
328+
List<TomlTable> tables = PsiTreeUtil.getChildrenOfTypeAsList(file, TomlTable.class);
329+
for (TomlTable table : tables) {
330+
TomlTableHeader header = table.getHeader();
331+
TomlKey key = header.getKey();
332+
if (key == null) continue;
333+
List<TomlKeySegment> segments = key.getSegments();
334+
if (segments.size() == 1 && "package".equals(segments.get(0).getName())) {
335+
List<TomlKeyValue> keyValues = PsiTreeUtil.getChildrenOfTypeAsList(table, TomlKeyValue.class);
336+
for (TomlKeyValue kv : keyValues) {
337+
if ("license".equals(normalizeKeyName(kv.getKey().getText()))) {
338+
TomlValue value = kv.getValue();
339+
if (value instanceof TomlLiteral) {
340+
return value;
341+
}
342+
}
343+
}
344+
}
345+
}
346+
return null;
347+
}
348+
349+
@Override
350+
protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
351+
if (!(element instanceof TomlLiteral)) {
352+
return null;
353+
}
354+
return new LicenseUpdateIntentionAction(element, newLicense, (el, license) -> {
355+
// Replace the TOML literal text (including quotes), matching CargoCAIntentionAction pattern
356+
Document doc = PsiDocumentManager.getInstance(el.getProject())
357+
.getDocument(el.getContainingFile());
358+
if (doc != null) {
359+
int start = el.getTextRange().getStartOffset();
360+
int end = el.getTextRange().getEndOffset();
361+
doc.replaceString(start, end, "\"" + license + "\"");
362+
PsiDocumentManager.getInstance(el.getProject()).commitDocument(doc);
363+
}
364+
});
365+
}
366+
323367
private void parseFlatDependencies(TomlTable table, Set<String> ignoredDeps, Map<Dependency, List<PsiElement>> resultMap) {
324368
// Parse inline format: [dependencies] with key-value pairs
325369
// serde = "1.0"

src/main/java/org/jboss/tools/intellij/componentanalysis/golang/GoCAAnnotator.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
1919
import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
2020
import org.jboss.tools.intellij.componentanalysis.Dependency;
21+
import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
2122
import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
23+
import org.jetbrains.annotations.Nullable;
2224

2325
import java.util.Collections;
2426
import java.util.HashMap;
@@ -142,6 +144,17 @@ protected boolean isQuickFixApplicable(PsiElement element) {
142144
return element != null && element.getContainingFile().getName().equals("go.mod");
143145
}
144146

147+
// go.mod has no license field; license detection relies on the LICENSE file
148+
@Override
149+
protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
150+
return null;
151+
}
152+
153+
@Override
154+
protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
155+
return null;
156+
}
157+
145158
private PsiElement findElementAtLine(PsiFile file, int lineNumber) {
146159
String[] lines = file.getText().split("\\n");
147160
if (lineNumber >= lines.length) return null;

src/main/java/org/jboss/tools/intellij/componentanalysis/gradle/GradleCAAnnotator.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
1919
import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
2020
import org.jboss.tools.intellij.componentanalysis.Dependency;
21+
import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
2122
import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
23+
import org.jetbrains.annotations.Nullable;
2224
import org.jboss.tools.intellij.componentanalysis.gradle.build.psi.Artifact;
2325

2426
import java.util.Arrays;
@@ -75,4 +77,15 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe
7577
protected boolean isQuickFixApplicable(PsiElement element) {
7678
return true;
7779
}
80+
81+
// Gradle has no standardized license field location; license detection relies on the LICENSE file
82+
@Override
83+
protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
84+
return null;
85+
}
86+
87+
@Override
88+
protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
89+
return null;
90+
}
7891
}

src/main/java/org/jboss/tools/intellij/componentanalysis/maven/MavenCAAnnotator.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
2323
import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
2424
import org.jboss.tools.intellij.componentanalysis.Dependency;
25+
import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
2526
import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
27+
import org.jetbrains.annotations.Nullable;
2628

2729

2830
import java.util.Arrays;
@@ -146,4 +148,32 @@ protected boolean isQuickFixApplicable(PsiElement element) {
146148
}
147149
return false;
148150
}
151+
152+
@Override
153+
protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
154+
// Find <project><licenses><license><name> text element
155+
return Arrays.stream(file.getChildren())
156+
.filter(e -> e instanceof XmlDocument)
157+
.flatMap(e -> Arrays.stream(e.getChildren()))
158+
.filter(e -> e instanceof XmlTag && "project".equals(((XmlTag) e).getName()))
159+
.flatMap(e -> Arrays.stream(e.getChildren()))
160+
.filter(e -> e instanceof XmlTag && "licenses".equals(((XmlTag) e).getName()))
161+
.flatMap(e -> Arrays.stream(e.getChildren()))
162+
.filter(e -> e instanceof XmlTag && "license".equals(((XmlTag) e).getName()))
163+
.flatMap(e -> Arrays.stream(e.getChildren()))
164+
.filter(e -> e instanceof XmlTag && "name".equals(((XmlTag) e).getName()))
165+
.flatMap(e -> Arrays.stream(((XmlTag) e).getValue().getChildren()))
166+
.filter(e -> e instanceof XmlText)
167+
.findFirst()
168+
.orElse(null);
169+
}
170+
171+
@Override
172+
protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
173+
if (!(element instanceof XmlText)) {
174+
return null;
175+
}
176+
return new LicenseUpdateIntentionAction(element, newLicense, (el, license) ->
177+
((XmlText) el).setValue(license));
178+
}
149179
}

src/main/java/org/jboss/tools/intellij/componentanalysis/npm/NpmCAAnnotator.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
package org.jboss.tools.intellij.componentanalysis.npm;
1313

14+
import com.intellij.json.psi.JsonElementGenerator;
15+
import com.intellij.json.psi.JsonObject;
1416
import com.intellij.json.psi.JsonProperty;
1517
import com.intellij.json.psi.JsonStringLiteral;
1618
import com.intellij.psi.PsiElement;
@@ -20,8 +22,11 @@
2022
import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
2123
import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
2224
import org.jboss.tools.intellij.componentanalysis.Dependency;
25+
import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
2326
import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
27+
import org.jetbrains.annotations.Nullable;
2428

29+
import java.util.Arrays;
2530
import java.util.List;
2631
import java.util.Map;
2732

@@ -53,4 +58,27 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe
5358
protected boolean isQuickFixApplicable(PsiElement element) {
5459
return element instanceof JsonProperty && ((JsonProperty) element).getValue() instanceof JsonStringLiteral;
5560
}
61+
62+
@Override
63+
protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
64+
return Arrays.stream(file.getChildren())
65+
.filter(e -> e instanceof JsonObject)
66+
.flatMap(e -> Arrays.stream(e.getChildren()))
67+
.filter(e -> e instanceof JsonProperty && "license".equals(((JsonProperty) e).getName()))
68+
.map(e -> ((JsonProperty) e).getValue())
69+
.filter(v -> v instanceof JsonStringLiteral)
70+
.findFirst()
71+
.orElse(null);
72+
}
73+
74+
@Override
75+
protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
76+
if (!(element instanceof JsonStringLiteral)) {
77+
return null;
78+
}
79+
return new LicenseUpdateIntentionAction(element, newLicense, (el, license) -> {
80+
JsonStringLiteral newValue = new JsonElementGenerator(el.getProject()).createStringLiteral(license);
81+
el.replace(newValue);
82+
});
83+
}
5684
}

src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PipCAAnnotator.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import org.jboss.tools.intellij.componentanalysis.CAIntentionAction;
1919
import org.jboss.tools.intellij.componentanalysis.CAUpdateManifestIntentionAction;
2020
import org.jboss.tools.intellij.componentanalysis.Dependency;
21+
import org.jboss.tools.intellij.componentanalysis.LicenseUpdateIntentionAction;
2122
import org.jboss.tools.intellij.componentanalysis.VulnerabilitySource;
23+
import org.jetbrains.annotations.Nullable;
2224
import org.jboss.tools.intellij.componentanalysis.pypi.requirements.psi.NameReq;
2325
import org.jboss.tools.intellij.componentanalysis.pypi.requirements.psi.NameReqComment;
2426

@@ -82,4 +84,15 @@ protected CAUpdateManifestIntentionAction patchManifest(PsiElement element, Depe
8284
protected boolean isQuickFixApplicable(PsiElement element) {
8385
return element instanceof NameReq && ((NameReq) element).getVersionspec() != null;
8486
}
87+
88+
// requirements.txt has no license field; license detection relies on the LICENSE file
89+
@Override
90+
protected @Nullable PsiElement getLicenseFieldPsiElement(PsiFile file) {
91+
return null;
92+
}
93+
94+
@Override
95+
protected @Nullable LicenseUpdateIntentionAction createLicenseUpdateFix(PsiElement element, String newLicense) {
96+
return null;
97+
}
8598
}

0 commit comments

Comments
 (0)