SpotBugsAggregateMojo.groovy
/*
* Copyright 2005-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.codehaus.mojo.spotbugs
import groovy.xml.StreamingMarkupBuilder
import groovy.xml.XmlSlurper
import groovy.xml.slurpersupport.GPathResult
import groovy.xml.slurpersupport.NodeChild
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import org.xml.sax.SAXException
import org.apache.maven.plugin.MojoExecutionException
import org.apache.maven.plugins.annotations.Mojo
import org.apache.maven.plugins.annotations.Parameter
import org.apache.maven.plugins.annotations.ResolutionScope
import org.apache.maven.project.MavenProject
import org.apache.maven.reporting.AbstractMavenReport
import org.apache.maven.reporting.MavenReport
/**
* Generates an aggregate SpotBugs report for multi-module projects when the site plugin is run.
* The HTML report is generated from the individual SpotBugs XML results of each module.
* Run {@code spotbugs:spotbugs} on each module before using this goal.
*
* @since 4.9.4.2
*/
@Mojo(name = 'spotbugs-aggregate', aggregator = true, requiresDependencyResolution = ResolutionScope.TEST,
requiresProject = true, threadSafe = true)
class SpotBugsAggregateMojo extends AbstractMavenReport {
/** Location where the generated HTML aggregate report will be created. */
@Parameter(defaultValue = '${project.reporting.outputDirectory}', required = true)
File outputDirectory
/**
* The file encoding to use when creating the HTML reports. If the property
* <code>project.reporting.outputEncoding</code> is not set, utf-8 is used.
*
* @since 4.9.4.2
*/
@Parameter(defaultValue = '${project.reporting.outputEncoding}', property = 'outputEncoding')
String outputEncoding
/** Threshold of minimum bug severity to report. Valid values are High, Default, Low, Ignore, and Exp (for experimental). */
@Parameter(defaultValue = 'Default', property = 'spotbugs.threshold')
String threshold
/**
* Effort of the bug finders. Valid values are Min, Default and Max.
*
* @since 4.9.4.2
*/
@Parameter(defaultValue = 'Default', property = 'spotbugs.effort')
String effort
/** Turn on Spotbugs debugging. */
@Parameter(defaultValue = 'false', property = 'spotbugs.debug')
boolean debug
/**
* Skip entire check.
*
* @since 4.9.4.2
*/
@Parameter(defaultValue = 'false', property = 'spotbugs.skip')
boolean skip
/**
* Specifies the directory where the Spotbugs native XML output will be generated in each module.
* The aggregate mojo looks for this filename in each reactor project's build directory.
*
* @since 4.9.4.2
*/
@Parameter(defaultValue = 'spotbugsXml.xml', property = 'spotbugs.outputXmlFilename')
String spotbugsXmlOutputFilename
/**
* Skip the Spotbugs HTML report generation if there are no violations found. Defaults to
* <code>false</code>.
*
* @since 4.9.4.2
*/
@Parameter(defaultValue = 'false', property = 'spotbugs.skipEmptyReport')
boolean skipEmptyReport
/** The resource bundle. */
ResourceBundle bundle
/**
* Checks whether prerequisites for generating this report are given.
*
* @return true if the report can be generated, otherwise false
* @see AbstractMavenReport#canGenerateReport()
*/
@Override
boolean canGenerateReport() {
if (skip) {
log.info('Spotbugs aggregate plugin skipped')
return false
}
boolean anyResults = reactorProjects.any { MavenProject p ->
File xmlFile = new File(p.build.directory, spotbugsXmlOutputFilename)
xmlFile.exists() && xmlFile.size() > 0
}
if (!anyResults) {
log.info('No SpotBugs XML results found in any reactor project. ' +
'Run spotbugs:spotbugs on each module before generating the aggregate report.')
}
return anyResults
}
/**
* Returns the plugins description for the "generated reports" overview page.
*
* @param locale the locale the report should be generated for
* @return description of the report
* @see MavenReport#getDescription(Locale)
*/
@Override
String getDescription(Locale locale) {
return getBundle(locale).getString(SpotBugsInfo.AGGREGATE_DESCRIPTION_KEY)
}
/**
* Returns the plugins name for the "generated reports" overview page and the menu.
*
* @param locale the locale the report should be generated for
* @return name of the report
* @see MavenReport#getName(Locale)
*/
@Override
String getName(Locale locale) {
return getBundle(locale).getString(SpotBugsInfo.AGGREGATE_NAME_KEY)
}
/**
* Returns report output file name, without the extension.
*
* @return name of the generated page
* @see {@link MavenReport#getOutputName()}
*
* @deprecated Method name does not properly reflect its purpose. Implement and use
* {@link #getOutputPath()} instead. This is waiting on maven to switch in report
* plugin before we can remove it.
*/
@Override
@Deprecated
String getOutputName() {
return SpotBugsInfo.PLUGIN_NAME
}
/**
* Returns report output file name, without the extension.
*
* @return name of the generated page
* @see {@link MavenReport#getOutputPath()}
*/
@Override
String getOutputPath() {
return SpotBugsInfo.PLUGIN_NAME
}
/**
* Executes the generation of the aggregate report.
*
* @param locale the wanted locale to generate the report, could be null.
* @see AbstractMavenReport#executeReport(Locale)
*/
@Override
void executeReport(Locale locale) {
log.debug('****** SpotBugsAggregateMojo executeReport *******')
if (!canGenerateReport()) {
return
}
if (!outputDirectory.exists() && !outputDirectory.mkdirs()) {
throw new MojoExecutionException('Cannot create html output directory')
}
Charset effectiveEncoding = outputEncoding ?
Charset.forName(outputEncoding) : StandardCharsets.UTF_8
if (log.isDebugEnabled()) {
log.debug("Output Directory is ${outputDirectory}")
log.debug("Output Encoding is ${effectiveEncoding.name()}")
log.debug("Reactor projects: ${reactorProjects*.name}")
}
File aggregatedXmlFile = buildAggregatedXml(effectiveEncoding)
if (aggregatedXmlFile == null || !aggregatedXmlFile.exists()) {
log.warn('No SpotBugs XML results could be aggregated.')
return
}
XmlSlurper xmlSlurper = new XmlSlurper()
xmlSlurper.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
xmlSlurper.setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false)
GPathResult aggregatedResults = xmlSlurper.parse(aggregatedXmlFile)
int bugCount = aggregatedResults.BugInstance.size()
if (skipEmptyReport && bugCount == 0) {
log.info('Skipping generation of SpotBugs aggregate HTML report since there are no bugs found.')
return
}
log.debug('Generating SpotBugs aggregate HTML report')
SpotbugsReportGenerator generator = new SpotbugsReportGenerator(getSink(), getBundle(locale))
generator.setIsJXRReportEnabled(false)
generator.setLog(log)
generator.threshold = threshold
generator.effort = effort
generator.setSpotbugsResults(aggregatedResults)
generator.setOutputDirectory(outputDirectory)
generator.generateReport()
if (log.isDebugEnabled()) {
log.debug("Aggregate SpotBugs report generated with ${bugCount} bugs")
}
}
/**
* Collects and merges SpotBugs XML files from all reactor projects into a single aggregate XML file.
*
* @param effectiveEncoding the charset to use for writing the output file
* @return the merged XML file, or null if no XML files were found
*/
private File buildAggregatedXml(Charset effectiveEncoding) {
XmlSlurper xmlSlurper = new XmlSlurper()
xmlSlurper.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
xmlSlurper.setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false)
List<GPathResult> allResults = []
reactorProjects.each { MavenProject p ->
File xmlFile = new File(p.build.directory, spotbugsXmlOutputFilename)
if (xmlFile.exists() && xmlFile.size() > 0) {
if (log.isDebugEnabled()) {
log.debug("Adding SpotBugs results from ${p.name}: ${xmlFile}")
}
try {
allResults.add(xmlSlurper.parse(xmlFile))
} catch (SAXException | IOException e) {
log.warn("Failed to parse SpotBugs XML from ${xmlFile}: ${e.message}")
}
} else if (log.isDebugEnabled()) {
log.debug("No SpotBugs XML found for module ${p.name} at ${xmlFile}")
}
}
if (allResults.empty) {
return null
}
// Collect all source directories from all modules
List<String> allSrcDirs = []
allResults.each { GPathResult result ->
result.Project.SrcDir.each { srcDir ->
String dir = srcDir.text()
if (dir && !allSrcDirs.contains(dir)) {
allSrcDirs.add(dir)
}
}
}
// Count all bugs
int totalBugs = allResults.sum(0) { GPathResult result ->
result.BugInstance.size()
}
// Count total classes from FindBugsSummary attributes
int totalClasses = allResults.sum(0) { GPathResult result ->
String totalClassesStr = result.FindBugsSummary.@total_classes.text()
totalClassesStr ? totalClassesStr.toInteger() : 0
}
// Sum error counts
int totalErrors = allResults.sum(0) { GPathResult result ->
String errorsStr = result.Errors.@errors.text()
errorsStr ? errorsStr.toInteger() : 0
}
int totalMissingClasses = allResults.sum(0) { GPathResult result ->
String missingStr = result.Errors.@missingClasses.text()
missingStr ? missingStr.toInteger() : 0
}
if (log.isDebugEnabled()) {
log.debug("Aggregating ${totalBugs} bugs, ${totalClasses} classes from ${allResults.size()} modules")
}
// Write merged XML file
File outputDir = new File(project.build.directory)
Files.createDirectories(outputDir.toPath())
File aggregatedXmlFile = new File(outputDir, spotbugsXmlOutputFilename)
StreamingMarkupBuilder xmlBuilder = new StreamingMarkupBuilder()
xmlBuilder.encoding = effectiveEncoding.name()
BufferedWriter writer = Files.newBufferedWriter(aggregatedXmlFile.toPath(), effectiveEncoding)
if (effectiveEncoding.name().equalsIgnoreCase('Cp1252')) {
writer.write '<?xml version="1.0" encoding="windows-1252"?>'
} else {
writer.write '<?xml version="1.0" encoding="' +
effectiveEncoding.name().toLowerCase(Locale.getDefault()) + '"?>'
}
writer.write SpotBugsInfo.EOL
def markup = xmlBuilder.bind { builder ->
BugCollection {
Project(name: project.name) {
allSrcDirs.each { String srcDir ->
SrcDir(srcDir)
}
WrkDir(project.build.directory)
}
allResults.each { GPathResult result ->
result.BugInstance.each { NodeChild bugInstance ->
mkp.yield bugInstance
}
}
Errors(errors: totalErrors, missingClasses: totalMissingClasses)
FindBugsSummary(total_bugs: totalBugs, total_classes: totalClasses) {
allResults.each { GPathResult result ->
result.FindBugsSummary.PackageStats.each { NodeChild packageStats ->
mkp.yield packageStats
}
}
}
}
}
writer << markup
writer.close()
return aggregatedXmlFile
}
/**
* Returns the report output directory.
*
* @return full path to the directory where the files in the site get copied to
* @see AbstractMavenReport#getOutputDirectory()
*/
@Override
protected String getOutputDirectory() {
return outputDirectory.absolutePath
}
/**
* Sets the report output directory.
*
* @see AbstractMavenReport#setReportOutputDirectory(File)
*/
@Override
void setReportOutputDirectory(File reportOutputDirectory) {
super.setReportOutputDirectory(reportOutputDirectory)
this.outputDirectory = reportOutputDirectory
}
ResourceBundle getBundle(Locale locale) {
this.bundle = ResourceBundle.getBundle(SpotBugsInfo.BUNDLE_NAME, locale,
SpotBugsAggregateMojo.class.getClassLoader())
if (log.isDebugEnabled()) {
log.debug('Aggregate Mojo Locale is ' + this.bundle.getLocale().getLanguage())
}
return bundle
}
}