Full disclosure: I enhanced this post with AI since I am not the best writer. So please forgive the artificial smell the rest of this post has.
Coming back to my own thread with findings from actually testing the proposed solutions on our Gradle build (~60 modules, Gradle 9.6.1, SonarScanner for Gradle 7.3.1.8318, SonarQube Server Enterprise 2026.3.1, sonar-jacoco 1.5.1), since the results may save other Gradle users some pain.
The accepted solution silently breaks coverage under Gradle
sonar.coverage.jacoco.aggregateXmlReportPaths works for Maven, but on our Gradle multi-module project it made the warnings disappear and silently dropped coverage from ~85% to 0, which is easy to miss, because the log looks cleaner than before. What we observed:
- The aggregate report is handled by a separate project-level sensor (
JaCoCo Aggregate XML Report Importer). It ran once at the end of the analysis and failed to resolve almost every file in the report: 2,532 of ~2,570 source files were logged as File '…' not found in project sources, regular sources, not just generated ones. Only files of a single module resolved.
- The docs only document
aggregateXmlReportPaths for Maven, there is no Gradle example, and in our testing it effectively does not work there.
So for Gradle users: if you apply the accepted fix, verify your coverage number afterwards, don’t trust the quieter log.
Gotcha: the Gradle scanner plugin re-creates the problem by itself
Even after removing our explicit per-module sonar.coverage.jacoco.xmlReportPaths, the warning storm continued. Cause: SonarScanner for Gradle auto-configures sonar.coverage.jacoco.xmlReportPaths from any JacocoReport task with XML output enabled (you can see it evaluating tasks in the logs: “JaCoCo report task detected, but XML report is not enabled or it was not produced”). Our root-level aggregate report task qualified, root properties are inherited by all modules, and every module went right back to importing the aggregate. To actually stop that, the root project must set the property explicitly empty:
// root project
sonarqube {
properties {
property 'sonar.coverage.jacoco.xmlReportPaths', ''
}
}
What ended up working: per-module report slices
The root cause of the warnings is inherent to importing an aggregated report into every module: each module warns about every file that belongs to a sibling. The fix that eliminated all warnings and kept cross-module coverage (our integration-test module exercises code in every other module) was to give each module its own JacocoReport built from all execution data but only its own classes, and point each module’s xmlReportPaths at its own slice:
// root build.gradle
subprojects {
pluginManager.withPlugin('java') {
sonarqube {
properties {
property 'sonar.coverage.jacoco.xmlReportPaths',
layout.buildDirectory.file('reports/jacoco/sonarCoverageReport/sonarCoverageReport.xml').get().asFile.absolutePath
}
}
tasks.register('sonarCoverageReport', JacocoReport) {
// Ordering only (no dependsOn): our CI analysis job reuses exec data and classes
// from an earlier build stage and must not re-run tests or compilation.
mustRunAfter(rootProject.subprojects.collect { it.tasks.withType(Test) })
mustRunAfter(rootProject.subprojects.collect { it.tasks.withType(JavaCompile) })
executionData.setFrom(rootProject.fileTree(rootProject.rootDir) {
include '**/build/jacoco/*.exec'
exclude '**/.git/**', '**/.gradle/**'
})
sourceDirectories.setFrom(sourceSets.main.java.sourceDirectories)
classDirectories.setFrom(sourceSets.main.output.classesDirs)
reports.xml.required = true
reports.html.required = false
}
}
}
tasks.named('sonar') {
dependsOn(subprojects.collect { sp -> sp.tasks.matching { it.name == 'sonarCoverageReport' } })
}
Results on our project, verified on real analyses:
|
Aggregate into every module (before) |
aggregateXmlReportPaths |
Per-module slices |
| “File not found” warnings |
~155,000 |
~2,500 (once, project level) |
0 |
| Coverage |
correct (~85%) |
0  |
correct (~85%, identical) |
Each slice re-parses all exec data, but the tasks run in parallel and the whole analysis job still completes in ~2 to 3 minutes for us.
(We also adopted Gradle’s jacoco-report-aggregation plugin for the aggregate XML we still publish to our CI platform, but Sonar no longer consumes that report.)
Bonus: keeping generated classes out of the coverage report
Krisztian’s suggestion (excluding generated classes from the JaCoCo report) applied to us too, with a twist: our generated sources live inside src/main/java, interleaved with hand-written code, so they can’t be excluded by directory the way build/generated/** output can. They are only identifiable by their @Generated annotation. We derive class-file exclusion patterns by scanning the sources for the marker:
// Memoized: every per-module report task queries this, and the scan reads all main sources.
def generatedPatternsCache = new java.util.concurrent.ConcurrentHashMap<String, List<String>>()
def generatedClassPatterns = providers.provider {
generatedPatternsCache.computeIfAbsent('patterns') {
def patterns = []
def marker = '@Generated("com.example.MyGenerator")'
def sourceRoot = '/src/main/java/'
fileTree(rootDir) {
include "**${sourceRoot}**/*.java"
exclude '**/build/**', '**/.git/**', '**/.gradle/**'
}.each { source ->
if (source.text.contains(marker)) {
def path = source.path.replace(File.separator, '/')
def rel = path.substring(path.indexOf(sourceRoot) + sourceRoot.length(), path.length() - '.java'.length())
patterns << (rel + '.class') // com/example/Foo.class
patterns << (rel + '$*.class') // ...and its inner classes
}
}
patterns
}
}
And in the sonarCoverageReport task above, swap the classDirectories line for:
def moduleClassDirs = sourceSets.main.output.classesDirs
classDirectories.setFrom(providers.provider {
moduleClassDirs.files.collect { dir -> fileTree(dir) { exclude generatedClassPatterns.get() } }
})
Notes from doing this:
- The same exclusion list is applied to our aggregate report task, so all coverage reports agree.
- These files were already excluded from Sonar analysis (
sonar.exclusions), so the Sonar coverage % doesn’t change. What changes is that the JaCoCo report itself stops counting generated code, and the residual “generated class” Hylke mentioned disappear because the report no longer references files Sonar doesn’t know.
Suggestion for SonarSource
Two things would help here:
- Document (or fix)
sonar.coverage.jacoco.aggregateXmlReportPaths behaviour for Gradle, right now it fails silently, producing 0 coverage with a cleaner log than the misconfiguration it’s meant to fix.
- Consider demoting the per-file
File '…' not found in project sources message, or summarizing it (N files in the report were not found in this module's sources), at tens of thousands of lines it truncates CI logs, and the per-file detail is rarely actionable.
Hope this saves the next Gradle multi-module user a few days.