Skip to content

feat: add license compatibility checking and mismatch detection#243

Merged
ruromero merged 3 commits into
redhat-developer:mainfrom
soul2zimate:TC-3650-license
Apr 20, 2026
Merged

feat: add license compatibility checking and mismatch detection#243
ruromero merged 3 commits into
redhat-developer:mainfrom
soul2zimate:TC-3650-license

Conversation

@soul2zimate

@soul2zimate soul2zimate commented Mar 24, 2026

Copy link
Copy Markdown
Contributor
  • 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)

DRAFT This is ready for review, however, it won't compile until next trustify-da-java-client update.

@ruromero

ruromero commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

Verification Report for TC-3650

Task: Client side license data - IntelliJ
Parent Epic: TC-3424 — OSS Licence Visibility - Architecture and Implementation
PR: #243 — feat: add license compatibility checking and mismatch detection

Deterministic Checks

Check Result Details
Scope Containment WARN PyprojectCAAnnotator.java not modified — missing abstract method implementations (see AC-7)
Diff Size PASS +550/-32 across 14 files — proportionate to scope
Commit Traceability FAIL Commit does not reference TC-3650 in message, body, or trailers
Sensitive Patterns PASS No secrets, passwords, or API keys detected
CI Status FAIL Build fails on all platforms (linux, macos, windows) + verify — expected since Java client 0.0.14-SNAPSHOT not published yet, and PyprojectCAAnnotator missing abstract methods

Acceptance Criteria (derived from parent epic + VS Code reference implementation)

# Criterion Result Details
AC-1 Switch to license-aware API (componentAnalysisWithLicense) PASS ApiService.getComponentAnalysis() now calls exhortApi.componentAnalysisWithLicense(manifestPath) and returns ComponentAnalysisResult
AC-2 License mismatch detection (manifest vs LICENSE file) PASS applyLicenseMismatchAnnotation() checks projectLicense.mismatch(), creates ERROR-severity annotation on the license field PSI element
AC-3 Incompatible dependency license warnings PASS applyIncompatibleDependencyAnnotations() creates WARNING-severity annotations; merged into vulnerability annotations via buildIncompatibleLicenseInfo() when both apply to same dependency
AC-4 Quick fix to update manifest license PASS LicenseUpdateIntentionAction with BiConsumer<PsiElement, String> replacer; title matches VS Code pattern: "Update manifest license to \"<license>\" (from LICENSE file)"
AC-5 License field extraction per ecosystem WARN Implemented for NPM (JsonProperty), Maven (XmlTag/XmlText), Cargo (TomlLiteral). Go, Gradle, requirements.txt correctly return null. Missing: pyproject.toml — has standard project.license (PEP 621) and tool.poetry.license fields, same TOML PSI as Cargo
AC-6 Setting to enable/disable license checking PASS licenseCheckEnabled boolean in ApiSettingsState (default true), UI checkbox in settings, triggers re-analysis on change, sets TRUSTIFY_DA_LICENSE_CHECK system property
AC-7 All annotators compile with new abstract methods FAIL PyprojectCAAnnotator not updated — missing getLicenseFieldPsiElement() and createLicenseUpdateFix() implementations. Will cause compilation failure since CAAnnotator declares them abstract
AC-8 Cache license data alongside vulnerability data PASS licenseCache in CAService with put/invalidate lifecycle; deleteReports() invalidates all three caches
AC-9 Alignment with VS Code extension patterns WARN See details below

VS Code Alignment Analysis

Aspect VS Code Extension IntelliJ PR Alignment
API call exhort.componentAnalysis() with TRUSTIFY_DA_LICENSE_CHECK option exhortApi.componentAnalysisWithLicense() Different APIs — Java client uses a separate method while JS client enriches the standard response. Both valid
Mismatch severity DiagnosticSeverity.Error HighlightSeverity.ERROR ✅ Aligned
Mismatch message License mismatch: manifest declares "X" but LICENSE file contains "Y" Same format ✅ Aligned
Mismatch code/identifier code: 'license-mismatch' No explicit code set Minor gap
Incompatible severity DiagnosticSeverity.Warning HighlightSeverity.WARNING ✅ Aligned
Quick fix title Update manifest license to "X" (from LICENSE file) Same format ✅ Aligned
Incompatible quick fix None (warning only) None (warning only) ✅ Aligned
License extraction Falls back: expression → name → identifiers[0].id Falls back: expression → name → identifiers[0].id (via extractLicenseName/extractSpdxId) ✅ Aligned
pyproject.toml support Not implemented (no pyproject provider in VS Code) Not implemented (returns null) Aligned but both should add it
HTML sanitization N/A (VS Code diagnostics are plain text) Unsanitized HTML tooltips (XSS risk flagged by Qodo) ⚠️ Gap
Manifest content path Always uses file path Removed manifestContent byte-passing for go.mod/requirements.txt ⚠️ Needs verification

Additional Findings

  1. XSS in HTML tooltips (also flagged by Qodo): License names, SPDX expressions, and reason strings from the analysis response are concatenated into HTML tooltips without escaping. Use StringUtil.escapeXmlEntities() before embedding in HTML
  2. manifestContent path removed: Old code passed file content bytes for go.mod and requirements.txt via componentAnalysis(path, content). New code uses componentAnalysisWithLicense(path) only. Verify the path-only overload handles these manifests correctly
  3. AnnotationData fields: results and allDependencies are package-private but accessed via getters — should be private final
  4. No tests: No unit tests for license annotation logic, mismatch detection, or quick fix action

Overall: FAIL

Blocking issues:

  • PyprojectCAAnnotator missing abstract method implementations → compilation failure
  • Commit message does not reference TC-3650

Non-blocking issues to address:

  • HTML tooltip XSS (sanitize remote strings)
  • Verify componentAnalysisWithLicense() handles go.mod/requirements.txt without content bytes
  • Add pyproject.toml license field extraction (PEP 621 project.license and Poetry tool.poetry.license)
  • Minor: AnnotationData field visibility, missing diagnostic code for mismatch

This comment was AI-generated by sdlc-workflow/verify-pr v0.5.1.

@qodo-code-review

qodo-code-review Bot commented Mar 26, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit c66dc9a)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

TC-3650 - Partially compliant

Compliant requirements:

  • Provide client-side handling of license data as part of component analysis results
  • Surface license-related findings to users in the IDE (notifications/annotations)
  • Support license compatibility checking for dependencies
  • Detect and report license mismatches between manifest metadata and LICENSE file contents

Non-compliant requirements:

  • Implement IntelliJ integration of license detection (as described in parent story)

Requires further human verification:

  • Implement IntelliJ integration of license detection (as described in parent story)
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 Security concerns

HTML injection:
Tooltip strings are built as HTML using unescaped values from licenseSummary (licenseNames, reason, project license fields). If any of these fields can include HTML special characters (or attacker-controlled content via malicious dependency metadata), it could result in HTML injection in the IDE tooltip rendering. Consider escaping before concatenation.

⚡ Recommended focus areas for review

Global State

System.setProperty is used to control request behavior (e.g., TRUSTIFY_DA_LICENSE_CHECK, proxy URL, match versions). This is process-wide mutable state and can lead to cross-request contamination between projects/threads, especially if multiple analyses run concurrently with different settings. Validate thread-safety and ensure properties are always reset/isolated per request.

        System.setProperty("MATCH_MANIFEST_VERSIONS", "true");
    } else {
        System.setProperty("MATCH_MANIFEST_VERSIONS","false");
    }
}
if (!"go.mod".equals(manifestName) && !"requirements.txt".equals(manifestName)) {
    System.clearProperty("MATCH_MANIFEST_VERSIONS");
}
if (settings.licenseCheckEnabled) {
    System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "true");
} else {
    System.setProperty("TRUSTIFY_DA_LICENSE_CHECK", "false");
}

Optional<String> proxyUrlOpt = getProxyUrl();
if (proxyUrlOpt.isPresent()) {
    System.setProperty("TRUSTIFY_DA_PROXY_URL", proxyUrlOpt.get());
} else {
    System.clearProperty("TRUSTIFY_DA_PROXY_URL");
HTML Injection

Tooltip HTML is built via string concatenation using license names/reasons coming from analysis results. If these values can contain special characters, the rendered tooltip may break or allow HTML injection in the IDE UI. Consider escaping/sanitizing content before embedding into HTML.

    String message = "License mismatch: manifest declares \"" + manifestLicense
            + "\" but LICENSE file contains \"" + fileLicense + "\"";
    String tooltip = "<html><p>License mismatch:</p>"
            + "<p>Manifest declares: <b>" + manifestLicense + "</b></p>"
            + "<p>LICENSE file contains: <b>" + fileLicense + "</b></p></html>";

    AnnotationBuilder builder = holder
            .newAnnotation(HighlightSeverity.ERROR, message)
            .tooltip(tooltip)
            .range(licenseElement);

    // Use the SPDX ID for replacement so the manifest matches what the client compares against
    LicenseUpdateIntentionAction fix = createLicenseUpdateFix(licenseElement, fileSpdxId);
    if (fix != null) {
        builder.withFix(fix);
    }

    builder.create();
}

private void buildIncompatibleLicenseInfo(
        LicenseSummary licenseSummary,
        Map<Dependency, String> messages,
        Map<Dependency, String> tooltips) {
    if (licenseSummary.incompatibleDependencies() == null || licenseSummary.incompatibleDependencies().isEmpty()) {
        return;
    }

    ProjectLicenseSummary projectLicense = licenseSummary.projectLicense();
    String projectLicenseName = projectLicense != null
            ? extractLicenseName(projectLicense.manifest() != null ? projectLicense.manifest() : projectLicense.file())
            : "unknown";

    for (IncompatibleDependency incompatible : licenseSummary.incompatibleDependencies()) {
        try {
            PackageURL purl = new PackageURL(incompatible.purl());
            Dependency dep = new Dependency(purl, false);

            String licenseNames = incompatible.licenses() != null
                    ? incompatible.licenses().stream()
                        .map(LicenseIdentifier::getName)
                        .collect(Collectors.joining(", "))
                    : "unknown";

            String reason = incompatible.reason() != null
                    ? incompatible.reason()
                    : "This dependency may require relicensing if distributed.";

            messages.put(dep, buildLicenseWarningMessage(licenseNames, projectLicenseName, reason));
            tooltips.put(dep, buildLicenseWarningTooltip(licenseNames, projectLicenseName, reason));

        } catch (MalformedPackageURLException ex) {
            LOG.warn("Failed to parse PURL from incompatible dependency: " + incompatible.purl(), ex);
        }
    }
}

private void applyIncompatibleDependencyAnnotations(
        Map<Dependency, List<PsiElement>> allDependencies,
        LicenseSummary licenseSummary,
        Set<Dependency> alreadyAnnotated,
        @NotNull AnnotationHolder holder) {
    if (licenseSummary.incompatibleDependencies() == null || licenseSummary.incompatibleDependencies().isEmpty()) {
        return;
    }
    if (allDependencies == null || allDependencies.isEmpty()) {
        return;
    }

    ProjectLicenseSummary projectLicense = licenseSummary.projectLicense();
    String projectLicenseName = projectLicense != null
            ? extractLicenseName(projectLicense.manifest() != null ? projectLicense.manifest() : projectLicense.file())
            : "unknown";

    // Build a lookup map without version for matching
    Map<Dependency, List<PsiElement>> noVersionMap = allDependencies.entrySet().stream()
            .collect(Collectors.toMap(
                    e -> new Dependency(e.getKey(), false),
                    Map.Entry::getValue,
                    (o1, o2) -> o1));

    for (IncompatibleDependency incompatible : licenseSummary.incompatibleDependencies()) {
        try {
            PackageURL purl = new PackageURL(incompatible.purl());
            Dependency dep = new Dependency(purl, false);

            // Skip deps already annotated with merged license info
            if (alreadyAnnotated.contains(dep)) {
                continue;
            }

            List<PsiElement> elements = noVersionMap.get(dep);
            if (elements == null || elements.isEmpty()) {
                continue;
            }

            String licenseNames = incompatible.licenses() != null
                    ? incompatible.licenses().stream()
                        .map(LicenseIdentifier::getName)
                        .collect(Collectors.joining(", "))
                    : "unknown";

            String reason = incompatible.reason() != null
                    ? incompatible.reason()
                    : "This dependency may require relicensing if distributed.";
            String message = buildLicenseWarningMessage(licenseNames, projectLicenseName, reason);
            String tooltip = buildLicenseWarningTooltip(licenseNames, projectLicenseName, reason);

            for (PsiElement element : elements) {
                if (element != null) {
                    holder.newAnnotation(HighlightSeverity.WARNING, message)
                            .tooltip(tooltip)
                            .range(element)
                            .create();
                }
            }
        } catch (MalformedPackageURLException ex) {
            LOG.warn("Failed to parse PURL from incompatible dependency: " + incompatible.purl(), ex);
        }
    }
}

private static String extractLicenseName(@Nullable JsonNode licenseDetails) {
    if (licenseDetails == null) {
        return "unknown";
    }
    // Try "expression" first (SPDX expressions like "MIT OR Apache-2.0")
    JsonNode exprNode = licenseDetails.get("expression");
    if (exprNode != null && !exprNode.isNull() && !exprNode.asText().isBlank()) {
        return exprNode.asText();
    }
    // Try "name" field for human-readable display
    JsonNode nameNode = licenseDetails.get("name");
    if (nameNode != null && !nameNode.isNull() && !nameNode.asText().isBlank()) {
        return nameNode.asText();
    }
    // Fall back to SPDX ID
    return extractSpdxId(licenseDetails);
}

private static String extractSpdxId(@Nullable JsonNode licenseDetails) {
    if (licenseDetails == null) {
        return "unknown";
    }
    // Try identifiers array for the SPDX ID (e.g., "Apache-2.0", "MIT")
    JsonNode identifiers = licenseDetails.get("identifiers");
    if (identifiers != null && identifiers.isArray() && !identifiers.isEmpty()) {
        JsonNode first = identifiers.get(0);
        JsonNode idNode = first.get("id");
        if (idNode != null && !idNode.isNull() && !idNode.asText().isBlank()) {
            return idNode.asText();
        }
    }
    // Fall back to name
    JsonNode nameNode = licenseDetails.get("name");
    if (nameNode != null && !nameNode.isNull() && !nameNode.asText().isBlank()) {
        return nameNode.asText();
    }
    return "unknown";
}

private static String buildLicenseWarningMessage(String depLicense, String projectLicense, String reason) {
    return "License compatibility warning: "
            + "Dependency license: " + depLicense
            + ", Project license: " + projectLicense
            + ". " + reason;
}

private static String buildLicenseWarningTooltip(String depLicense, String projectLicense, String reason) {
    return "<p>License compatibility warning:</p>"
            + "<p>Dependency license: " + depLicense + "</p>"
            + "<p>Project license: " + projectLicense + "</p>"
            + "<p>" + reason + "</p>";
}
Cache Correctness

licenseCache is keyed only by filePath and updated only on performAnalysis. Ensure it is invalidated/updated for all flows that change dependencies or license inputs (e.g., file edits, toggling license check, quick-fix updates). Also verify that disabling license checks doesn’t leave stale license annotations due to cached summaries.

private final Cache<String, LicenseSummary> licenseCache = Caffeine.newBuilder()
        .maximumSize(100)
        .build();

public static Map<Dependency, Map<VulnerabilitySource, DependencyReport>> getReports(String filePath) {
    return Collections.unmodifiableMap(getInstance().vulnerabilityCache.get(filePath, p -> Collections.emptyMap()));
}

public static void deleteReports(String filePath) {
    getInstance().vulnerabilityCache.invalidate(filePath);
    getInstance().licenseCache.invalidate(filePath);
    getInstance().dependencyCache.invalidate(filePath);
}

public static LicenseSummary getLicenseSummary(String filePath) {
    return getInstance().licenseCache.getIfPresent(filePath);
}

public static boolean dependenciesModified(String filePath, Set<Dependency> dependencies) {
    return !dependencies.equals(getInstance().dependencyCache.get(filePath, p -> Collections.emptySet()));
}

public static boolean performAnalysis(String packageManager,
                                      String fileName,
                                      String filePath,
                                      Set<Dependency> dependencies,
                                      PsiFile file) {
    if (dependenciesModified(filePath, dependencies)) {
        ApiService apiService = ServiceManager.getService(ApiService.class);
        Project project = file.getProject();
        Document document = PsiDocumentManager.getInstance(project).getDocument(file);
        if (document == null) {
            throw new RuntimeException("Failed to perform component analysis, document " + file + " is invalid.");
        }
        // Fix for TC-1631 to avoid analysis happens before changes is saved to disk.
        // Explicitly synchronize PSI file cached document content and disk file content
        if (FileDocumentManager.getInstance().isDocumentUnsaved(document)) {
            ApplicationManager.getApplication().invokeAndWait(() ->
                    WriteCommandAction.runWriteCommandAction(project, () ->
                            FileDocumentManager.getInstance().saveDocument(document)
                    )
            );
        }

        ComponentAnalysisResult analysisResult = apiService.getComponentAnalysis(packageManager, fileName, filePath);
        if (analysisResult == null) {
            throw new RuntimeException("Failed to perform component analysis, result is invalid.");
        }
        AnalysisReport report = analysisResult.report();
        if (report == null) {
            throw new RuntimeException("Failed to perform component analysis, report is invalid.");
        }

        // Cache license summary
        LicenseSummary licenseSummary = analysisResult.licenseSummary();
        if (licenseSummary != null) {
            getInstance().licenseCache.put(filePath, licenseSummary);
        } else {
            getInstance().licenseCache.invalidate(filePath);
        }

- 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>
@soul2zimate

Copy link
Copy Markdown
Contributor Author

/review

@qodo-code-review

Copy link
Copy Markdown

Persistent review updated to latest commit c66dc9a

…s results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@soul2zimate
soul2zimate marked this pull request as ready for review March 30, 2026 03:12
@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Add license compatibility checking with mismatch detection and quick fixes

✨ Enhancement 🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Add license compatibility checking with mismatch detection between manifest and LICENSE file
• Implement license quick fix to update manifest with SPDX ID from LICENSE file
• Report incompatible dependency licenses as warnings, merged with vulnerability annotations
• Cache license summary data alongside vulnerability reports for efficient analysis
• Escape HTML in tooltip strings to prevent injection from analysis results
• Add licenseCheckEnabled setting (default true) with UI checkbox for license checks
• Use WEAK_WARNING severity for recommendation-only dependencies without vulnerabilities
• Implement license field extraction for NPM, Maven, and Cargo; return null for Go, Gradle, Python
Diagram
flowchart LR
  A["API Call"] -->|componentAnalysisWithLicense| B["ComponentAnalysisResult"]
  B -->|extract| C["LicenseSummary"]
  C -->|cache| D["License Cache"]
  D -->|build| E["License Annotations"]
  E -->|merge with| F["Vulnerability Annotations"]
  C -->|detect mismatch| G["License Mismatch ERROR"]
  C -->|incompatible deps| H["License Warning"]
  G -->|quick fix| I["Update Manifest"]
  I -->|invalidate| D
Loading

Grey Divider

File Changes

1. src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java Enhancement, error handling +280/-17

Add license checking and HTML escaping to annotations

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


2. src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java Enhancement, caching +26/-2

Add license summary caching and cache invalidation

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


3. src/main/java/org/jboss/tools/intellij/componentanalysis/LicenseUpdateIntentionAction.java ✨ Enhancement +76/-0

New quick fix action for updating manifest licenses

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


View more (10)
4. src/main/java/org/jboss/tools/intellij/componentanalysis/cargo/CargoCAAnnotator.java ✨ Enhancement +44/-0

Implement license field extraction for Cargo TOML files

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


5. src/main/java/org/jboss/tools/intellij/componentanalysis/golang/GoCAAnnotator.java ✨ Enhancement +13/-0

Add license field stubs returning null for Go modules

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


6. src/main/java/org/jboss/tools/intellij/componentanalysis/gradle/GradleCAAnnotator.java ✨ Enhancement +13/-0

Add license field stubs returning null for Gradle

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


7. src/main/java/org/jboss/tools/intellij/componentanalysis/maven/MavenCAAnnotator.java ✨ Enhancement +30/-0

Implement license field extraction for Maven POM files

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


8. src/main/java/org/jboss/tools/intellij/componentanalysis/npm/NpmCAAnnotator.java ✨ Enhancement +28/-0

Implement license field extraction for NPM package.json

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


9. src/main/java/org/jboss/tools/intellij/componentanalysis/pypi/PipCAAnnotator.java ✨ Enhancement +13/-0

Add license field stubs returning null for Python

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


10. src/main/java/org/jboss/tools/intellij/exhort/ApiService.java Enhancement, dependencies +13/-15

Switch to componentAnalysisWithLicense API and add license check property

src/main/java/org/jboss/tools/intellij/exhort/ApiService.java


11. src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java Enhancement, configuration changes +16/-0

Add UI checkbox for license check enabled setting

src/main/java/org/jboss/tools/intellij/settings/ApiSettingsComponent.java


12. src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java Enhancement, configuration changes +8/-2

Handle license check setting changes and trigger re-analysis

src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java


13. src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java Enhancement, configuration changes +2/-0

Add licenseCheckEnabled boolean property with default true

src/main/java/org/jboss/tools/intellij/settings/ApiSettingsState.java


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Mar 30, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Incompatible deps dropped on merge🐞 Bug ≡ Correctness
Description
applyIncompatibleDependencyAnnotations() collapses dependencies to a no-version map using a merge
function that discards later entries, so only one PSI location list is kept and other occurrences of
the same dependency (e.g., multiple versions/sections) won’t receive license annotations.
Code

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[R333-337]

+        Map<Dependency, List<PsiElement>> noVersionMap = allDependencies.entrySet().stream()
+                .collect(Collectors.toMap(
+                        e -> new Dependency(e.getKey(), false),
+                        Map.Entry::getValue,
+                        (o1, o2) -> o1));
Evidence
The no-version lookup uses Collectors.toMap with merge (o1,o2)->o1, which drops the second list of
PsiElements entirely when keys collide. As a result, only a subset of dependency occurrences can be
annotated.

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[315-374]
src/main/java/org/jboss/tools/intellij/componentanalysis/Dependency.java[33-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
When building `noVersionMap`, key collisions (same dependency name/type/namespace but different versions/locations) discard later PSI element lists. This prevents license warnings from being applied to all occurrences.

### Issue Context
`Dependency.equals/hashCode` includes `version`, but the map key is created via `new Dependency(e.getKey(), false)` which nulls `version`, increasing collisions.

### Fix Focus Areas
- src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[332-338]

### Suggested fix
Change the merge function to combine element lists instead of discarding:
- Use something like `(l1, l2) -> { var merged = new ArrayList<>(l1); merged.addAll(l2); return merged; }`
Optionally de-duplicate PSI elements if needed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. License toggle doesn't reanalyze🐞 Bug ≡ Correctness
Description
ApiSettingsConfigurable.apply() restarts the daemon when licenseCheckEnabled changes but does not
invalidate CAService caches, so CAAnnotator.doAnnotate() often sees dependenciesModified()==false
and skips performAnalysis(), leaving stale/no license results until dependencyCache expires or
dependencies change.
Code

src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java[R108-121]

+        // Check if license check setting changed
+        boolean licenseCheckChanged = settingsComponent.getLicenseCheckEnabledCheck() != settings.licenseCheckEnabled;
+        settings.licenseCheckEnabled = settingsComponent.getLicenseCheckEnabledCheck();
+
        // Check if exclusion patterns changed
        String oldPatterns = settings.manifestExclusionPatterns;
        String newPatterns = settingsComponent.getManifestExclusionPatternsText();
        boolean patternsChanged = !Objects.equals(oldPatterns, newPatterns);
        settings.manifestExclusionPatterns = newPatterns;

-        // Trigger re-analysis if exclusion patterns changed
-        if (patternsChanged) {
+        // Trigger re-analysis if exclusion patterns or license check changed
+        if (patternsChanged || licenseCheckChanged) {
            refreshComponentAnalysis();
        }
Evidence
The settings change path only restarts highlighting, while analysis execution is gated by
CAService.dependenciesModified() which compares against dependencyCache (25s TTL). Because caches
are not invalidated on toggle, unchanged dependencies will typically prevent a new API call, so
license summaries/annotations won’t update immediately (or at all if the cache remains warm).

src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java[78-137]
src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[74-109]
src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java[56-81]
src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java[69-73]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Toggling `licenseCheckEnabled` restarts the daemon but does not force re-analysis because `CAAnnotator.doAnnotate()` only calls `performAnalysis()` when `CAService.dependenciesModified()` is true. With a warm `dependencyCache`, unchanged dependencies skip re-analysis, so license data may remain stale/missing.

### Issue Context
`CAService.deleteReports()` already invalidates `vulnerabilityCache`, `licenseCache`, and `dependencyCache`, but it’s never invoked when the license-check setting changes.

### Fix Focus Areas
- src/main/java/org/jboss/tools/intellij/settings/ApiSettingsConfigurable.java[108-137]
- src/main/java/org/jboss/tools/intellij/componentanalysis/CAService.java[69-77]

### Suggested fix
When `licenseCheckChanged` is true, invalidate caches so the next annotator pass triggers a fresh API call. Options:
1) Add a `CAService.invalidateAllCaches()` (or similar) that calls `invalidateAll()` on the three caches, and invoke it for each open project when the setting changes.
2) Alternatively, for each open project and relevant open manifest file, call `CAService.deleteReports(manifestPath)`.
Then restart the daemon as you already do.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Tooltip HTML wrapper missing🐞 Bug ◔ Observability
Description
Standalone incompatible-license annotations pass tooltip text that starts with "<p>" instead of
"<html>", which can cause tooltips to render with raw tags rather than formatted HTML.
Code

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[R426-431]

+    private static String buildLicenseWarningTooltip(String depLicense, String projectLicense, String reason) {
+        return "<p>License compatibility warning:</p>"
+                + "<p>Dependency license: " + escapeHtml(depLicense) + "</p>"
+                + "<p>Project license: " + escapeHtml(projectLicense) + "</p>"
+                + "<p>" + escapeHtml(reason) + "</p>";
+    }
Evidence
applyIncompatibleDependencyAnnotations() uses buildLicenseWarningTooltip() directly as the tooltip
string, but buildLicenseWarningTooltip() returns only HTML fragments without an <html> root, unlike
other tooltips in this class (e.g., mismatch tooltip).

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[315-374]
src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[426-431]
src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[258-267]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`buildLicenseWarningTooltip()` returns a tooltip fragment starting with `<p>`, and that fragment is used directly as a tooltip for new annotations. IntelliJ tooltips are typically expected to be rooted with `<html>...</html>`.

### Issue Context
The same tooltip content is also appended into an existing tooltip builder that already starts with `<html>`, so a one-size wrapper may cause nested `<html>` tags.

### Fix Focus Areas
- src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[315-374]
- src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[426-431]

### Suggested fix
Split tooltip generation into:
- a fragment/body method (no `<html>` root) for merging into `tooltipBuilder`
- a wrapped method for standalone annotations, e.g. `"<html>" + body + "</html>"`
Then use the wrapped version in `applyIncompatibleDependencyAnnotations()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Mismatch fix may not be SPDX🐞 Bug ≡ Correctness
Description
applyLicenseMismatchAnnotation() builds the quick-fix replacement from extractSpdxId(), but
extractSpdxId() can fall back to the license "name" or "unknown", so the fix can update the manifest
to a non-SPDX/placeholder value and may not resolve the mismatch.
Code

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[R254-273]

+        String manifestLicense = extractLicenseName(projectLicense.manifest());
+        String fileLicense = extractLicenseName(projectLicense.file());
+        String fileSpdxId = extractSpdxId(projectLicense.file());
+
+        String message = "License mismatch: manifest declares \"" + manifestLicense
+                + "\" but LICENSE file contains \"" + fileLicense + "\"";
+        String tooltip = "<html><p>License mismatch:</p>"
+                + "<p>Manifest declares: <b>" + escapeHtml(manifestLicense) + "</b></p>"
+                + "<p>LICENSE file contains: <b>" + escapeHtml(fileLicense) + "</b></p></html>";
+
+        AnnotationBuilder builder = holder
+                .newAnnotation(HighlightSeverity.ERROR, message)
+                .tooltip(tooltip)
+                .range(licenseElement);
+
+        // Use the SPDX ID for replacement so the manifest matches what the client compares against
+        LicenseUpdateIntentionAction fix = createLicenseUpdateFix(licenseElement, fileSpdxId);
+        if (fix != null) {
+            builder.withFix(fix);
+        }
Evidence
The code explicitly intends to "Use the SPDX ID for replacement", yet extractSpdxId() returns the
first identifier id when present, otherwise returns the name field, otherwise "unknown". That value
is passed directly into createLicenseUpdateFix(), so the quick-fix can write a non-ID fallback
string into the manifest.

src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[254-276]
src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[398-417]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The license mismatch quick-fix can propose updating the manifest to a value that is not guaranteed to be an SPDX ID/expression (it may be the license `name` or `unknown`). This can fail to align the manifest with what the client compares against.

### Issue Context
`applyLicenseMismatchAnnotation()` comments that the fix should use the SPDX ID, but `extractSpdxId()` has fallback behavior that contradicts that assumption.

### Fix Focus Areas
- src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[254-276]
- src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java[398-417]

### Suggested fix
Return `null` (or empty Optional) from `extractSpdxId()` when no SPDX `identifiers[].id` is available, and only attach the quick-fix when a real identifier is present. Optionally fall back to `expression` (if that is what the backend expects) rather than `name`/`unknown`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread src/main/java/org/jboss/tools/intellij/componentanalysis/CAAnnotator.java Outdated
- 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>
@sonarqubecloud

Copy link
Copy Markdown

@ruromero ruromero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ruromero
ruromero merged commit 1682b80 into redhat-developer:main Apr 20, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants