diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java index b7a05ffc2178..93c720fc37e8 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Options.java @@ -202,6 +202,23 @@ public interface Options { @Nonnull Optional console(); + /** + * Returns the warning display mode. + *

+ * Controls how build warnings (diagnostics) are displayed: + *

+ * + * @return an {@link Optional} containing the warning mode, or empty if not set + * @since 4.1.0 + */ + @Nonnull + Optional warningMode(); + /** * Indicates whether Maven should operate in offline mode. * diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java index 9e4cb45ae9dc..724bbd2f2a93 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/BuilderProblem.java @@ -22,15 +22,25 @@ import org.apache.maven.api.annotations.Immutable; import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.annotations.ThreadSafe; /** - * Describes a problem that was encountered during project building. A problem can either be an exception that was - * thrown or a simple string message. In addition, a problem carries a hint about its source. + * Describes a problem that was encountered during project building or + * build execution. A problem can either be an exception that was thrown + * or a simple string message. In addition, a problem carries a hint + * about its source. + *

+ * Since 4.1.0, problems can optionally carry a deduplication + * {@link #getKey() key}, an actionable {@link #getSuggestion() suggestion}, + * and a {@link #getDocumentationUrl() documentation URL}. These fields + * enable richer build reports and a deduplicated warning summary at + * the end of the build. * * @since 4.0.0 */ @Experimental @Immutable +@ThreadSafe public interface BuilderProblem { /** @@ -94,6 +104,68 @@ public interface BuilderProblem { @Nonnull Severity getSeverity(); + /** + * Gets a stable deduplication key for this problem. + *

+ * When multiple modules produce the same warning (e.g. a deprecated + * POM element), reporting it with the same key allows the build report + * to count occurrences instead of repeating the message. A key such as + * {@code "deprecated-modules"} or {@code "compiler.unchecked:Foo.java:42"} + * should be unique per logical problem but identical across modules + * that encounter the same issue. + *

+ * If this returns {@code null}, the problem is not deduplicated. + * + * @return the deduplication key, or {@code null} if not applicable + * @since 4.1.0 + */ + @Nullable + default String getKey() { + return null; + } + + /** + * Gets an actionable suggestion for resolving this problem. + *

+ * For example, a deprecation warning for {@code } might + * suggest {@code "Use instead of "}. + * + * @return the suggestion text, or {@code null} if no suggestion is available + * @since 4.1.0 + */ + @Nullable + default String getSuggestion() { + return null; + } + + /** + * Gets a URL pointing to documentation relevant to this problem. + *

+ * For example, a warning about the deprecated {@code system} scope + * might link to the Maven dependency scope migration guide. + * + * @return the documentation URL, or {@code null} if not available + * @since 4.1.0 + */ + @Nullable + default String getDocumentationUrl() { + return null; + } + + /** + * Creates a new builder for constructing {@link BuilderProblem} instances. + *

+ * This is the recommended way for plugins and extensions to create + * structured problems to report via {@link DiagnosticReporter}. + * + * @return a new builder, never {@code null} + * @since 4.1.0 + */ + @Nonnull + static Builder builder() { + return new Builder(); + } + /** * The different severity levels for a problem, in decreasing order. * @@ -103,6 +175,206 @@ public interface BuilderProblem { enum Severity { FATAL, // ERROR, // - WARNING // + WARNING, // + INFO // + } + + /** + * A builder for constructing immutable {@link BuilderProblem} instances. + *

+ * Example usage: + *

{@code
+     * BuilderProblem problem = BuilderProblem.builder()
+     *     .severity(Severity.WARNING)
+     *     .message("source/target value 8 is obsolete")
+     *     .key("compiler:obsolete-source-target")
+     *     .source("maven-compiler-plugin:3.15.0:compile")
+     *     .suggestion("Update maven.compiler.source to 11 or higher")
+     *     .documentationUrl("https://maven.apache.org/plugins/maven-compiler-plugin/")
+     *     .build();
+     * }
+ * + * @since 4.1.0 + */ + final class Builder { + private String source = ""; + private int lineNumber = -1; + private int columnNumber = -1; + private Exception exception; + private String message = ""; + private Severity severity = Severity.WARNING; + private String key; + private String suggestion; + private String documentationUrl; + + Builder() {} + + @Nonnull + public Builder source(@Nullable String source) { + this.source = source != null ? source : ""; + return this; + } + + @Nonnull + public Builder lineNumber(int lineNumber) { + this.lineNumber = lineNumber; + return this; + } + + @Nonnull + public Builder columnNumber(int columnNumber) { + this.columnNumber = columnNumber; + return this; + } + + @Nonnull + public Builder exception(@Nullable Exception exception) { + this.exception = exception; + return this; + } + + @Nonnull + public Builder message(@Nonnull String message) { + this.message = message; + return this; + } + + @Nonnull + public Builder severity(@Nonnull Severity severity) { + this.severity = severity; + return this; + } + + @Nonnull + public Builder key(@Nullable String key) { + this.key = key; + return this; + } + + @Nonnull + public Builder suggestion(@Nullable String suggestion) { + this.suggestion = suggestion; + return this; + } + + @Nonnull + public Builder documentationUrl(@Nullable String documentationUrl) { + this.documentationUrl = documentationUrl; + return this; + } + + @Nonnull + public BuilderProblem build() { + return new DefaultProblem( + source, lineNumber, columnNumber, exception, message, severity, key, suggestion, documentationUrl); + } + + /** + * Immutable problem implementation returned by the builder. + * This is intentionally package-private — callers use the + * {@link BuilderProblem} interface. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + private record DefaultProblem( + String source, + int lineNumber, + int columnNumber, + Exception exception, + String message, + Severity severity, + String key, + String suggestion, + String documentationUrl) + implements BuilderProblem { + + @Override + @Nonnull + public String getSource() { + return source != null ? source : ""; + } + + @Override + public int getLineNumber() { + return lineNumber; + } + + @Override + public int getColumnNumber() { + return columnNumber; + } + + @Override + @Nonnull + public String getLocation() { + StringBuilder buffer = new StringBuilder(256); + if (source != null && !source.isEmpty()) { + buffer.append(source); + } + if (lineNumber > 0) { + if (!buffer.isEmpty()) { + buffer.append(", "); + } + buffer.append("line ").append(lineNumber); + } + if (columnNumber > 0) { + if (!buffer.isEmpty()) { + buffer.append(", "); + } + buffer.append("column ").append(columnNumber); + } + return buffer.toString(); + } + + @Override + @Nullable + public Exception getException() { + return exception; + } + + @Override + @Nonnull + public String getMessage() { + return message != null ? message : ""; + } + + @Override + @Nonnull + public Severity getSeverity() { + return severity != null ? severity : Severity.WARNING; + } + + @Override + @Nullable + public String getKey() { + return key; + } + + @Override + @Nullable + public String getSuggestion() { + return suggestion; + } + + @Override + @Nullable + public String getDocumentationUrl() { + return documentationUrl; + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(128); + buffer.append('[').append(getSeverity()).append("]"); + String msg = getMessage(); + if (!msg.isEmpty()) { + buffer.append(" ").append(msg); + } + String loc = getLocation(); + if (!loc.isEmpty()) { + buffer.append(" @ ").append(loc); + } + return buffer.toString(); + } + } } } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java b/api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java new file mode 100644 index 000000000000..b361455019af --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.maven.api.services; + +import org.apache.maven.api.Service; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.annotations.ThreadSafe; + +/** + * Service for reporting structured build diagnostics (warnings, errors, + * informational messages) that will appear in the build report and in + * the {@code mvnlog} output. + *

+ * This is the recommended way for Maven 4 plugins and extensions to + * report build problems with structured metadata (deduplication key, + * actionable suggestion, documentation URL). Problems reported through + * this service are: + *

+ *

+ * Plugins can inject this service via {@code @Inject} or retrieve it + * from the session: + *

{@code
+ * @Inject DiagnosticReporter diagnosticReporter;
+ *
+ * public void execute() {
+ *     diagnosticReporter.report(BuilderProblem.builder()
+ *         .severity(BuilderProblem.Severity.WARNING)
+ *         .message("source/target value 8 is obsolete")
+ *         .key("compiler:obsolete-source-target")
+ *         .source("maven-compiler-plugin:3.15.0:compile")
+ *         .suggestion("Update maven.compiler.source to 11 or higher")
+ *         .documentationUrl("https://maven.apache.org/plugins/maven-compiler-plugin/")
+ *         .build());
+ * }
+ * }
+ * + * @since 4.1.0 + * @see BuilderProblem#builder() + */ +@Experimental +@ThreadSafe +public interface DiagnosticReporter extends Service { + + /** + * Reports a structured build problem. + *

+ * If the problem has a non-null {@link BuilderProblem#getKey() key} + * and a problem with the same key has already been reported, the + * duplicate is counted but not stored again. + * + * @param problem the problem to report; must not be {@code null} + */ + void report(@Nonnull BuilderProblem problem); + + /** + * Convenience method to report a warning with all structured fields. + * + * @param message the warning message + * @param key deduplication key, or {@code null} + * @param source source hint (e.g. plugin GAV), or {@code null} + * @param suggestion actionable fix suggestion, or {@code null} + * @param documentationUrl URL to relevant documentation, or {@code null} + */ + default void warning( + @Nonnull String message, + @Nullable String key, + @Nullable String source, + @Nullable String suggestion, + @Nullable String documentationUrl) { + report(BuilderProblem.builder() + .severity(BuilderProblem.Severity.WARNING) + .message(message) + .key(key) + .source(source) + .suggestion(suggestion) + .documentationUrl(documentationUrl) + .build()); + } + + /** + * Convenience method to report a simple warning message. + * + * @param message the warning message + */ + default void warning(@Nonnull String message) { + warning(message, null, null, null, null); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java index 969ee8a80089..77d5faac61be 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java @@ -224,6 +224,14 @@ public Optional console() { return Optional.empty(); } + @Override + public Optional warningMode() { + if (commandLine.hasOption(CLIManager.WARNING_MODE)) { + return Optional.of(commandLine.getOptionValue(CLIManager.WARNING_MODE)); + } + return Optional.empty(); + } + @Override public Optional offline() { if (commandLine.hasOption(CLIManager.OFFLINE)) { @@ -328,6 +336,7 @@ protected static class CLIManager { public static final String RAW_STREAMS = "raw-streams"; public static final String COLOR = "color"; public static final String CONSOLE = "console"; + public static final String WARNING_MODE = "warning-mode"; public static final String OFFLINE = "o"; public static final String HELP = "h"; @@ -456,6 +465,13 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { + " interactive TTYs use 'rich' (status bar)." + " 'machine' outputs one JSON line per lifecycle event.") .get()); + options.addOption(Option.builder() + .longOpt(WARNING_MODE) + .hasArg() + .desc("Controls how build warnings are displayed." + + " Supported modes: 'summary' (default, deduplicated summary at end)," + + " 'all' (inline + summary), 'none' (suppress), 'fail' (treat warnings as errors).") + .get()); options.addOption(Option.builder(OFFLINE) .longOpt("offline") .desc("Work offline") diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java index 0b6dbda78756..09081eb5dd0f 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LayeredOptions.java @@ -137,6 +137,11 @@ public Optional console() { return returnFirstPresentOrEmpty(Options::console); } + @Override + public Optional warningMode() { + return returnFirstPresentOrEmpty(Options::warningMode); + } + @Override public Optional offline() { return returnFirstPresentOrEmpty(Options::offline); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index d4ea89d0db3c..25bcf8ab60c0 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -263,6 +263,11 @@ protected void populateRequest(MavenContext context, Lookup lookup, MavenExecuti } } + // Propagate warning mode and diagnostic suppression to the session so + // BuildReportCollector (an EventSpy) can read them from user properties + String warningMode = context.options().warningMode().orElse("summary"); + request.getUserProperties().put("maven.build.warningMode", warningMode); + request.setTransferListener(determineTransferListener( context, context.options().noTransferProgress().orElse(false))); request.setExecutionListener(determineExecutionListener(context)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java index 35934531f61e..8df72cc4eadc 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java @@ -18,6 +18,7 @@ */ package org.apache.maven.internal.build; +import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; @@ -34,8 +35,10 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.apache.maven.api.MonotonicClock; @@ -46,6 +49,7 @@ import org.apache.maven.api.build.report.LogLevel; import org.apache.maven.api.build.report.ModuleReport; import org.apache.maven.api.build.report.MojoReport; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.eventspy.AbstractEventSpy; import org.apache.maven.execution.BuildFailure; import org.apache.maven.execution.BuildSuccess; @@ -88,6 +92,29 @@ public final class BuildReportCollector extends AbstractEventSpy { private static final int MAX_STACKTRACE_LINES = 30; + private final DefaultDiagnosticCollector diagnosticCollector; + + @Inject + public BuildReportCollector(DefaultDiagnosticCollector diagnosticCollector) { + this.diagnosticCollector = diagnosticCollector; + } + + /** + * No-arg constructor for tests that don't need diagnostic collection. + */ + BuildReportCollector() { + this(new DefaultDiagnosticCollector()); + } + + /** + * Returns the diagnostic collector used by this build report collector. + * Plugins can inject {@link DefaultDiagnosticCollector} directly, but this + * accessor is provided for internal use and testing. + */ + DefaultDiagnosticCollector getDiagnosticCollector() { + return diagnosticCollector; + } + /** * Maximum number of log events captured per scope (mojo, module, or build). * Beyond this, events are dropped and a truncation notice is appended. @@ -96,7 +123,7 @@ public final class BuildReportCollector extends AbstractEventSpy { // ---- Mutable state, populated during the build ---- - /** Per-project mojo tracking: project key -> list of in-flight/completed mojos. */ + /** Per-project mojo tracking: project key → list of in-flight/completed mojos. */ private final Map> mojoTimings = new ConcurrentHashMap<>(); /** Per-project start instants for duration computation. */ @@ -105,28 +132,28 @@ public final class BuildReportCollector extends AbstractEventSpy { /** Per-mojo start instants for duration computation. */ private final Map mojoStartTimes = new ConcurrentHashMap<>(); - /** Session-level state - set once on SessionStarted. */ + /** Session-level state — set once on SessionStarted. */ private volatile MavenSession session; // ---- Log capture state ---- /** - * Maps thread ID -> mojo key for the currently-executing mojo on that thread. + * Maps thread ID → mojo key for the currently-executing mojo on that thread. * Lifecycle events and mojo execution run on the same thread, so this is safe * for parallel builds with {@code -T}. */ private final Map currentMojoByThread = new ConcurrentHashMap<>(); - /** Per-mojo log buffers: mojo key -> captured log events. */ + /** Per-mojo log buffers: mojo key → captured log events. */ private final Map> mojoLogBuffers = new ConcurrentHashMap<>(); /** - * Maps thread ID -> project key for the currently-building project on that thread. + * Maps thread ID → project key for the currently-building project on that thread. * Used to route log events that occur between mojo executions to the module-level buffer. */ private final Map currentProjectByThread = new ConcurrentHashMap<>(); - /** Per-module log buffers: project key -> events captured outside any mojo. */ + /** Per-module log buffers: project key → events captured outside any mojo. */ private final Map> moduleLogBuffers = new ConcurrentHashMap<>(); /** Build-level log buffer: events captured outside any module lifecycle. */ @@ -167,9 +194,42 @@ public void onEvent(Object event) { private void onSessionStarted(ExecutionEvent event) { this.session = event.getSession(); + configureDiagnosticSuppression(); installLogCapture(); } + /** + * Reads the {@code maven.diagnostic.suppress} user property and configures + * the diagnostic collector to suppress matching keys. The property accepts + * a comma-separated list of keys or patterns: + *

+ */ + private void configureDiagnosticSuppression() { + if (session == null) { + return; + } + String suppressProp = session.getUserProperties().getProperty("maven.diagnostic.suppress"); + if (suppressProp == null || suppressProp.isBlank()) { + return; + } + Set keys = new LinkedHashSet<>(); + for (String token : suppressProp.split(",")) { + String trimmed = token.trim(); + if (!trimmed.isEmpty()) { + keys.add(trimmed); + } + } + if (!keys.isEmpty()) { + diagnosticCollector.setSuppressedKeys(keys); + LOGGER.debug("Diagnostic suppression configured: {}", keys); + } + } + private void onSessionEnded(ExecutionEvent event) { removeLogCapture(); @@ -178,12 +238,28 @@ private void onSessionEnded(ExecutionEvent event) { return; } - try { - BuildReport report = buildReport(endSession); - writeReport(report, endSession); - } catch (Exception e) { - // Never let the report collector crash the build - LOGGER.debug("Failed to produce build report: {}", e.getMessage(), e); + // Read warning mode from user properties (set by MavenInvoker from --warning-mode) + String warningMode = endSession.getUserProperties().getProperty("maven.build.warningMode", "summary"); + + BuildReport report = buildReport(endSession); + writeReport(report, endSession); + + if (!"none".equalsIgnoreCase(warningMode)) { + printDiagnosticSummary(); + } + + // --warning-mode=fail: fail the build if any warnings were collected + if ("fail".equalsIgnoreCase(warningMode) && diagnosticCollector.hasWarnings()) { + int warningCount = 0; + for (DefaultDiagnosticSummary entry : diagnosticCollector.getSummary()) { + if (entry.problem().getSeverity() == BuilderProblem.Severity.WARNING) { + warningCount += entry.count(); + } + } + endSession + .getResult() + .addException(new RuntimeException( + "Build has " + warningCount + " warning(s) and --warning-mode=fail is set")); } } @@ -256,7 +332,7 @@ private void onMojoFinished(ExecutionEvent event) { /** * Installs a structured {@link MavenSimpleLogger.LogEventSink} to capture * log events with level, logger name, message, and throwable. This runs - * in parallel to the existing console output pipeline - no wrapping or + * in parallel to the existing console output pipeline — no wrapping or * forwarding needed. */ private void installLogCapture() { @@ -275,6 +351,21 @@ private void captureLogEvent(int level, String loggerName, String message, Throw String stackTrace = throwable != null ? truncateStackTrace(throwable) : null; LogEvent logEvent = new DefaultLogEvent(timestamp, logLevel, message, loggerName, stackTrace); + // Auto-collect WARN-level log events as build problems, giving Maven 3 plugins + // automatic deduplication and summary at end of build without code changes. + // Skip our own logger to avoid feedback loops from problem summary printing. + if (level == LocationAwareLogger.WARN_INT + && message != null + && !loggerName.equals(BuildReportCollector.class.getName())) { + String syntheticKey = syntheticDiagnosticKey(loggerName, message); + diagnosticCollector.report(BuilderProblem.builder() + .source(loggerName) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(syntheticKey) + .build()); + } + // 1. Mojo-level: event belongs to the currently-executing mojo on this thread String mKey = currentMojoByThread.get(threadId); if (mKey != null) { @@ -316,9 +407,6 @@ private static LogLevel toLogLevel(int level) { BuildReport buildReport(MavenSession endSession) { Instant now = MonotonicClock.now(); Instant startInstant = endSession.getRequest().getStartInstant(); - if (startInstant == null) { - startInstant = now; - } Duration totalDuration = Duration.between(startInstant, now); MavenExecutionResult result = endSession.getResult(); @@ -353,6 +441,9 @@ BuildReport buildReport(MavenSession endSession) { boolean multiModule = endSession.getProjects().size() > 1; int threads = endSession.getRequest().getDegreeOfConcurrency(); + // Problems (deduplicated) + List problems = diagnosticCollector.getProblems(); + // Build-level log events (outside any module lifecycle) List buildOutput = List.copyOf(buildLogBuffer); @@ -368,7 +459,7 @@ BuildReport buildReport(MavenSession endSession) { threads, moduleReports, failureReports, - List.of(), + problems, buildOutput); } @@ -391,7 +482,7 @@ private ModuleReport buildModuleReport(MavenProject project, MavenSession endSes status = BuildStatus.FAILURE; duration = summary.getExecTime(); } else if (summary != null) { - // Unknown summary type - use its timing + // Unknown summary type — use its timing duration = summary.getExecTime(); } else { // No summary means skipped @@ -511,7 +602,7 @@ void writeReport(BuildReport report, MavenSession endSession) { Files.createSymbolicLink(tmpLink, timestampedFile.getFileName()); atomicMove(tmpLink, latestFile); } catch (UnsupportedOperationException | IOException symEx) { - // Windows or restricted filesystem - fall back to a plain copy + // Windows or restricted filesystem — fall back to a plain copy Files.writeString(latestFile, json); } @@ -533,6 +624,67 @@ private static void atomicMove(Path source, Path target) throws IOException { } } + // ---- Diagnostic summary ---- + + /** + * Prints a deduplicated summary of diagnostics (warnings and errors) at the + * end of the build. This ensures important messages are not lost in scrollback. + */ + void printDiagnosticSummary() { + List summary = diagnosticCollector.getSummary(); + if (summary.isEmpty()) { + return; + } + + // Count unique warnings and total occurrences + int uniqueWarnings = 0; + int totalOccurrences = 0; + int uniqueErrors = 0; + for (DefaultDiagnosticSummary entry : summary) { + BuilderProblem.Severity sev = entry.problem().getSeverity(); + if (sev == BuilderProblem.Severity.WARNING) { + uniqueWarnings++; + totalOccurrences += entry.count(); + } else if (sev == BuilderProblem.Severity.ERROR) { + uniqueErrors++; + totalOccurrences += entry.count(); + } + } + + if (uniqueWarnings == 0 && uniqueErrors == 0) { + return; + } + + // Print header + StringBuilder header = new StringBuilder(); + if (uniqueWarnings > 0) { + header.append(uniqueWarnings).append(" warning"); + if (uniqueWarnings > 1) { + header.append('s'); + } + } + if (uniqueErrors > 0) { + if (header.length() > 0) { + header.append(", "); + } + header.append(uniqueErrors).append(" error"); + if (uniqueErrors > 1) { + header.append('s'); + } + } + if (totalOccurrences > (uniqueWarnings + uniqueErrors)) { + header.append(" (").append(totalOccurrences).append(" total occurrences)"); + } + + // Print the summary at INFO level. We intentionally do NOT re-print individual + // warning messages here — they were already logged inline at WARN level. Re-printing + // the raw message text would double warning counts in log parsers, trigger + // --fail-on-severity WARN again, and confuse tools that search for specific text. + // Full details are available in target/build-reports/. + LOGGER.info(""); + LOGGER.info("Diagnostics: {} — see target/{}/{} for details", header, REPORT_DIR, REPORT_LATEST); + } + // ---- Utility methods ---- private static String projectKey(MavenProject project) { @@ -543,6 +695,30 @@ private static String mojoKey(MavenProject project, MojoExecution mojo) { return projectKey(project) + "#" + mojo.getGoal() + "@" + mojo.getExecutionId(); } + /** + * Generates a stable deduplication key for a warning intercepted from a + * Maven 3 plugin's {@code Log.warn()} call. The key is derived from the + * logger name and a normalized hash of the message text, so that the same + * warning from different modules or files deduplicates correctly. + *

+ * File-specific coordinates (paths, line numbers) are stripped before hashing + * so that "Foo.java:42: unchecked cast" and "Bar.java:99: unchecked cast" + * map to the same key. + */ + static String syntheticDiagnosticKey(String loggerName, String message) { + // Strip file coordinates for dedup: remove paths and line/column numbers + String normalized = message.replaceAll("\\S+\\.java:\\d+(:\\d+)?:?\\s*", "") + .replaceAll("\\S+[\\\\/][\\w.]+:\\d+", "") + .trim(); + // Use a short logger suffix to namespace the key + String loggerSuffix = loggerName; + int lastDot = loggerName.lastIndexOf('.'); + if (lastDot >= 0 && lastDot < loggerName.length() - 1) { + loggerSuffix = loggerName.substring(lastDot + 1); + } + return "auto:" + loggerSuffix + ":" + Integer.toHexString(normalized.hashCode()); + } + static String truncateStackTrace(Throwable t) { StringWriter sw = new StringWriter(); t.printStackTrace(new PrintWriter(sw)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java index 03add8318ab0..4371b88ff082 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java @@ -131,6 +131,9 @@ private static void writeProblem(StringBuilder sb, BuilderProblem problem, int i sb.append("{\n"); writeField(sb, indent + 1, "severity", problem.getSeverity().name()); writeField(sb, indent + 1, "message", problem.getMessage()); + if (problem.getKey() != null) { + writeField(sb, indent + 1, "key", problem.getKey()); + } String source = problem.getSource(); if (source != null && !source.isEmpty()) { writeField(sb, indent + 1, "source", source); @@ -141,6 +144,12 @@ private static void writeProblem(StringBuilder sb, BuilderProblem problem, int i if (problem.getColumnNumber() > 0) { writeField(sb, indent + 1, "column", problem.getColumnNumber()); } + if (problem.getSuggestion() != null) { + writeField(sb, indent + 1, "suggestion", problem.getSuggestion()); + } + if (problem.getDocumentationUrl() != null) { + writeField(sb, indent + 1, "documentationUrl", problem.getDocumentationUrl()); + } // Remove the trailing comma from the last written field int lastComma = sb.lastIndexOf(",\n"); if (lastComma > 0) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java new file mode 100644 index 000000000000..435df4f0b558 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.maven.internal.build; + +import javax.inject.Named; +import javax.inject.Singleton; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; + +import org.apache.maven.api.services.BuilderProblem; + +import static java.util.Objects.requireNonNull; + +/** + * Thread-safe collector for {@link BuilderProblem}s with deduplication support. + *

+ * Problems with a non-null {@link BuilderProblem#getKey()} are deduplicated: + * the first occurrence is stored, subsequent duplicates only increment the + * counter. Problems without a key are always stored (up to the cap). + *

+ * This implementation is safe for use from parallel module builds + * ({@code -T}) and from any thread within a plugin execution. + * + * @since 4.1.0 + */ +@Named +@Singleton +public final class DefaultDiagnosticCollector { + + /** + * Maximum number of unique problems to store. + * Protects against runaway plugins that produce unbounded problems. + */ + static final int MAX_DIAGNOSTICS = 1000; + + /** + * Preserves insertion order: key → first problem. + * Using ConcurrentHashMap for thread safety; insertion order is tracked + * separately in {@link #orderedKeys}. + */ + private final Map uniqueProblems = new ConcurrentHashMap<>(); + + /** Counts per key (including the first occurrence). */ + private final Map counts = new ConcurrentHashMap<>(); + + /** + * Insertion-order tracking. Synchronized on itself for ordered access. + * The key list mirrors {@link #uniqueProblems} keys in insertion order. + */ + private final List orderedKeys = Collections.synchronizedList(new ArrayList<>()); + + /** Counter for problems without a key, used to generate synthetic keys. */ + private final LongAdder noKeyCounter = new LongAdder(); + + /** + * Keys to suppress. Problems with a key in this set are silently dropped. + * Configured via {@link #setSuppressedKeys(Set)}, typically from the + * {@code maven.diagnostic.suppress} user property. + */ + private volatile Set suppressedKeys = Set.of(); + + /** + * Sets the keys to suppress. Problems with a matching key will be + * silently dropped from {@link #report(BuilderProblem)}. + *

+ * Supports both exact keys ({@code "deprecated-source-target"}) and + * prefix matching with wildcard ({@code "auto:*"} to suppress all + * auto-collected warnings from Maven 3 plugins). + * + * @param keys the set of keys to suppress; must not be null + */ + public void setSuppressedKeys(Set keys) { + this.suppressedKeys = Set.copyOf(requireNonNull(keys, "keys")); + } + + /** + * Reports a problem. + *

+ * If the problem has a non-null {@link BuilderProblem#getKey()} and a + * problem with the same key has already been reported, the duplicate is + * counted but not stored again. + * + * @param problem the problem to report + */ + public void report(BuilderProblem problem) { + requireNonNull(problem, "problem"); + String key = problem.getKey(); + + // Problems without a key get a synthetic key for storage + if (key == null) { + noKeyCounter.increment(); + key = "__no_key__" + noKeyCounter.longValue(); + } + + // Check suppression: exact match or prefix wildcard (e.g. "auto:*") + if (isSuppressed(key)) { + return; + } + + // Always increment the count + counts.computeIfAbsent(key, k -> new LongAdder()).increment(); + + // Store the first occurrence only (if within cap) + if (uniqueProblems.putIfAbsent(key, problem) == null) { + if (uniqueProblems.size() <= MAX_DIAGNOSTICS) { + orderedKeys.add(key); + } else { + // Over cap — remove the entry we just added + uniqueProblems.remove(key); + } + } + } + + private boolean isSuppressed(String key) { + Set suppressed = this.suppressedKeys; + if (suppressed.isEmpty()) { + return false; + } + if (suppressed.contains(key)) { + return true; + } + // Check prefix wildcards: "auto:*" matches "auto:Compiler:1a2b3c" + for (String pattern : suppressed) { + if (pattern.endsWith("*") && key.startsWith(pattern.substring(0, pattern.length() - 1))) { + return true; + } + } + return false; + } + + /** + * Returns all unique problems reported so far, in the order they + * were first reported. + * + * @return an unmodifiable list of unique problems, never {@code null} + */ + public List getProblems() { + List result; + synchronized (orderedKeys) { + result = new ArrayList<>(orderedKeys.size()); + for (String key : orderedKeys) { + BuilderProblem p = uniqueProblems.get(key); + if (p != null) { + result.add(p); + } + } + } + return Collections.unmodifiableList(result); + } + + /** + * Returns a deduplicated summary of all reported problems. + * Each entry contains the problem and the number of times it was + * reported (across all modules). + * + * @return an unmodifiable list of summaries, never {@code null} + */ + public List getSummary() { + List result; + synchronized (orderedKeys) { + result = new ArrayList<>(orderedKeys.size()); + for (String key : orderedKeys) { + BuilderProblem p = uniqueProblems.get(key); + LongAdder counter = counts.get(key); + if (p != null && counter != null) { + result.add(new DefaultDiagnosticSummary(p, counter.intValue())); + } + } + } + return Collections.unmodifiableList(result); + } + + /** + * Returns {@code true} if at least one problem with severity + * {@link BuilderProblem.Severity#WARNING WARNING} or higher has been reported. + */ + public boolean hasWarnings() { + return hasAtSeverity(BuilderProblem.Severity.WARNING); + } + + /** + * Returns {@code true} if at least one problem with severity + * {@link BuilderProblem.Severity#ERROR ERROR} has been reported. + */ + public boolean hasErrors() { + return hasAtSeverity(BuilderProblem.Severity.ERROR); + } + + private boolean hasAtSeverity(BuilderProblem.Severity targetSeverity) { + for (BuilderProblem p : uniqueProblems.values()) { + // Severity enum is ordered most severe first: FATAL, ERROR, WARNING, INFO + // A problem "has" the target severity if its ordinal is <= target ordinal + if (p.getSeverity().ordinal() <= targetSeverity.ordinal()) { + return true; + } + } + return false; + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticReporter.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticReporter.java new file mode 100644 index 000000000000..d3b5cf09541b --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticReporter.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.maven.internal.build; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.maven.api.services.BuilderProblem; +import org.apache.maven.api.services.DiagnosticReporter; + +import static java.util.Objects.requireNonNull; + +/** + * Default implementation of {@link DiagnosticReporter} that delegates + * to {@link DefaultDiagnosticCollector}. + *

+ * Registered as a {@link org.apache.maven.api.Service} so it is + * automatically available to Maven 4 plugins via {@code @Inject} + * or {@code Session.getService(DiagnosticReporter.class)}. + * + * @since 4.1.0 + */ +@Named +@Singleton +public class DefaultDiagnosticReporter implements DiagnosticReporter { + + private final DefaultDiagnosticCollector collector; + + @Inject + public DefaultDiagnosticReporter(DefaultDiagnosticCollector collector) { + this.collector = requireNonNull(collector, "collector"); + } + + @Override + public void report(BuilderProblem problem) { + collector.report(problem); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java new file mode 100644 index 000000000000..cb3eedd9c116 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.maven.internal.build; + +import org.apache.maven.api.services.BuilderProblem; + +import static java.util.Objects.requireNonNull; + +/** + * A deduplicated summary entry pairing a unique {@link BuilderProblem} with the + * number of times it was reported across the build. + */ +record DefaultDiagnosticSummary(BuilderProblem problem, int count) { + + DefaultDiagnosticSummary { + requireNonNull(problem, "problem"); + if (count < 1) { + throw new IllegalArgumentException("count must be >= 1"); + } + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java index 433c166e1e2e..5733daf74d0a 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java @@ -27,6 +27,7 @@ import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.build.report.BuildReport; import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.BuildSuccess; import org.apache.maven.execution.DefaultMavenExecutionRequest; import org.apache.maven.execution.DefaultMavenExecutionResult; @@ -34,6 +35,7 @@ import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; +import org.apache.maven.impl.DefaultBuilderProblem; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.plugin.descriptor.PluginDescriptor; @@ -65,8 +67,7 @@ void testBuildReportAssembly() { MavenSession session = createSession(project); MavenExecutionResult result = session.getResult(); - // Simulate: session started -> project started -> mojo started -> mojo succeeded -> project succeeded -> - // session + // Simulate: session started → project started → mojo started → mojo succeeded → project succeeded → session // ended collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); @@ -97,7 +98,6 @@ void testBuildReportAssembly() { assertEquals("compile", report.modules().get(0).mojos().get(0).goal()); assertEquals(BuildStatus.SUCCESS, report.modules().get(0).mojos().get(0).status()); assertTrue(report.failures().isEmpty()); - assertTrue(report.problems().isEmpty()); } @Test @@ -128,7 +128,7 @@ void testBuildReportWithFailure() { assertNotNull(report.failures().get(0).timestamp(), "failure should have a timestamp"); assertEquals("RuntimeException", report.failures().get(0).exceptionType(), "exceptionType from cause"); - // Navigate from failure -> module -> mojo using lookup methods + // Navigate from failure → module → mojo using lookup methods var failureReport = report.failures().get(0); var moduleOpt = report.findModule(failureReport); assertTrue(moduleOpt.isPresent(), "findModule(FailureReport) should find the module"); @@ -194,30 +194,150 @@ void testMultiModuleBuild() { assertEquals("child-impl", report.modules().get(2).artifactId()); } + // ---- Warning mode tests ---- + + @Test + void testWarningModeNoneSuppressesSummary() { + // Register a diagnostic so there's something to print + collector.getDiagnosticCollector().report(warning("test-key", "test warning", "test-source")); + + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.build.warningMode", "none"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + // No exceptions means summary was suppressed (in "none" mode) + assertFalse(session.getResult().hasExceptions()); + } + + @Test + void testWarningModeFailAddsException() { + collector.getDiagnosticCollector().report(warning("test-key", "test warning", "test-source")); + + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.build.warningMode", "fail"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + assertTrue(session.getResult().hasExceptions(), "Build should fail with --warning-mode=fail and warnings"); + assertTrue( + session.getResult().getExceptions().get(0).getMessage().contains("warning"), + "Exception message should mention warnings"); + } + + @Test + void testWarningModeFailNoWarningsNoException() { + // No warnings registered — fail mode should not add exception + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.build.warningMode", "fail"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + assertFalse(session.getResult().hasExceptions(), "No warnings, no exception"); + } + + @Test + void testWarningModeSummaryIsDefault() { + collector.getDiagnosticCollector().report(warning("test-key", "test warning", "test-source")); + + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + // No warningMode property set — defaults to "summary" + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.SessionEnded, session, project, null)); + + // Summary mode should NOT fail the build + assertFalse(session.getResult().hasExceptions()); + } + + // ---- Synthetic diagnostic key tests ---- + + @Test + void testSyntheticDiagnosticKeyStripsPaths() { + String key1 = BuildReportCollector.syntheticDiagnosticKey("compiler", "Foo.java:42: unchecked cast"); + String key2 = BuildReportCollector.syntheticDiagnosticKey("compiler", "Bar.java:99: unchecked cast"); + assertEquals(key1, key2, "Same warning from different files should deduplicate"); + } + + @Test + void testSyntheticDiagnosticKeyDifferentMessages() { + String key1 = BuildReportCollector.syntheticDiagnosticKey("compiler", "unchecked cast"); + String key2 = BuildReportCollector.syntheticDiagnosticKey("compiler", "deprecated API"); + assertFalse(key1.equals(key2), "Different messages should produce different keys"); + } + @Test - void testStackTraceIsTruncated() { - // Build a throwable with a deep stack trace - RuntimeException deep = createDeepException(50); - String truncated = BuildReportCollector.truncateStackTrace(deep); + void testSyntheticDiagnosticKeyIncludesLoggerSuffix() { + String key = BuildReportCollector.syntheticDiagnosticKey( + "org.apache.maven.plugins.compiler.CompilerMojo", "unchecked cast"); + assertTrue(key.startsWith("auto:CompilerMojo:"), "Key should include short logger suffix"); + } + + // ---- Diagnostic suppression wiring tests ---- + + @Test + void testDiagnosticSuppressionFromUserProperty() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.diagnostic.suppress", "key-a,key-b"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + + // Report diagnostics — key-a and key-b should be suppressed + collector.getDiagnosticCollector().report(warning("key-a", "warning a", null)); + collector.getDiagnosticCollector().report(warning("key-b", "warning b", null)); + collector.getDiagnosticCollector().report(warning("key-c", "warning c", null)); - // Should contain the truncation notice - assertTrue(truncated.contains("more lines truncated"), "deep stack traces should be truncated"); + assertEquals(1, collector.getDiagnosticCollector().getProblems().size()); + assertEquals( + "key-c", collector.getDiagnosticCollector().getProblems().get(0).getKey()); } @Test - void testShortStackTraceIsNotTruncated() { - RuntimeException shallow = new RuntimeException("short"); - // Trim the stack to a known-small size so it's guaranteed under the limit - shallow.setStackTrace( - new StackTraceElement[] {new StackTraceElement("com.example.Foo", "bar", "Foo.java", 42)}); - String result = BuildReportCollector.truncateStackTrace(shallow); - - // Short stack traces should NOT contain the truncation notice - assertFalse(result.contains("more lines truncated"), "short stack traces should not be truncated"); + void testDiagnosticSuppressionWildcard() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getUserProperties().setProperty("maven.diagnostic.suppress", "auto:*"); + session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + + collector.getDiagnosticCollector().report(warning("auto:Compiler:abc", "unchecked", null)); + collector.getDiagnosticCollector().report(warning("explicit-key", "explicit", null)); + + assertEquals(1, collector.getDiagnosticCollector().getProblems().size()); + assertEquals( + "explicit-key", + collector.getDiagnosticCollector().getProblems().get(0).getKey()); } // ---- Test helpers ---- + private static BuilderProblem warning(String key, String message, String source) { + return new DefaultBuilderProblem( + source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + } + private MavenProject createProject(String groupId, String artifactId, String version) { MavenProject project = new MavenProject(); project.setGroupId(groupId); @@ -291,23 +411,4 @@ public Exception getException() { } }; } - - /** - * Creates an exception with a stack trace of at least {@code depth} lines. - */ - private static RuntimeException createDeepException(int depth) { - try { - throwDeep(depth); - } catch (RuntimeException e) { - return e; - } - throw new AssertionError("unreachable"); - } - - private static void throwDeep(int remaining) { - if (remaining <= 0) { - throw new RuntimeException("deep exception"); - } - throwDeep(remaining - 1); - } } diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java index cc48ed27d7e7..e200cd06b6f8 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java @@ -27,6 +27,7 @@ import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.build.report.BuildReport; import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.BuildSuccess; import org.apache.maven.execution.DefaultMavenExecutionRequest; import org.apache.maven.execution.DefaultMavenExecutionResult; @@ -34,6 +35,7 @@ import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; +import org.apache.maven.impl.DefaultBuilderProblem; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.plugin.descriptor.PluginDescriptor; @@ -49,29 +51,49 @@ /** * Integration test that exercises the full build report pipeline: - * BuildReportCollector -> BuildReportJsonWriter -> file. + * BuildReportCollector → DefaultDiagnosticCollector → BuildReportJsonWriter → file. *

* This test simulates a complete multi-module build lifecycle with mojo - * executions and failures, then verifies the resulting JSON report file - * contains all expected data. + * executions, problems, and failures, then verifies the resulting + * JSON report file contains all expected data. */ class BuildReportIntegrationTest { @TempDir Path tempDir; + private DefaultDiagnosticCollector diagnosticCollector; private BuildReportCollector collector; @BeforeEach void setUp() { - collector = new BuildReportCollector(); + diagnosticCollector = new DefaultDiagnosticCollector(); + collector = new BuildReportCollector(diagnosticCollector); + } + + private static BuilderProblem warning(String key, String message, String source) { + return new DefaultBuilderProblem( + source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + } + + private static BuilderProblem warning(String key, String message, String source, String suggestion, String docUrl) { + return new DefaultBuilderProblem( + source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, suggestion, docUrl); + } + + private static BuilderProblem info(String key, String message, String source) { + return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.INFO, key, null, null); + } + + private static BuilderProblem error(String key, String message, String source) { + return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.ERROR, key, null, null); } /** - * Full lifecycle: multi-module build with mojos and JSON persistence. + * Full lifecycle: multi-module build with mojos, problems, and JSON persistence. */ @Test - void testFullMultiModuleBuild() throws IOException { + void testFullMultiModuleBuildWithProblems() throws IOException { MavenProject parent = createProject("com.example", "parent", "2.0.0"); MavenProject api = createProject("com.example", "api", "2.0.0"); MavenProject impl = createProject("com.example", "impl", "2.0.0"); @@ -93,6 +115,15 @@ void testFullMultiModuleBuild() throws IOException { MojoExecution compileApi = createMojoExecution( "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, compileApi)); + + // Report a deprecation warning during compile + diagnosticCollector.report(warning( + "deprecated-source-target", + "source/target value 8 is deprecated", + "maven-compiler-plugin:3.15.0:compile", + "Update to 11 or higher", + null)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, compileApi)); MojoExecution testApi = createMojoExecution( @@ -109,8 +140,14 @@ void testFullMultiModuleBuild() throws IOException { MojoExecution compileImpl = createMojoExecution( "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, impl, compileImpl)); - collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl)); + // Same warning fires again in a different module — should be deduplicated + diagnosticCollector.report(warning("deprecated-source-target", "source/target value 8 is deprecated", "impl")); + + // Also report a unique info problem + diagnosticCollector.report(info("build-note", "Using JDK 21 toolchain", "toolchain")); + + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl)); result.addBuildSummary(new BuildSuccess(impl, 2000)); collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, impl, null)); @@ -123,8 +160,16 @@ void testFullMultiModuleBuild() throws IOException { assertTrue(report.multiModule()); assertEquals(3, report.modules().size()); - // Problems should always be empty in this simplified collector - assertTrue(report.problems().isEmpty()); + // Problems in the report + assertEquals(2, report.problems().size()); + assertEquals("deprecated-source-target", report.problems().get(0).getKey()); + assertEquals(BuilderProblem.Severity.WARNING, report.problems().get(0).getSeverity()); + assertEquals("build-note", report.problems().get(1).getKey()); + assertEquals(BuilderProblem.Severity.INFO, report.problems().get(1).getSeverity()); + + // Summary should show count = 2 for the warning + assertEquals(2, diagnosticCollector.getSummary().get(0).count()); + assertEquals(1, diagnosticCollector.getSummary().get(1).count()); // Module reports assertEquals("parent", report.modules().get(0).artifactId()); @@ -158,14 +203,47 @@ void testFullMultiModuleBuild() throws IOException { assertTrue(json.contains("\"goal\": \"compile\"")); assertTrue(json.contains("\"goal\": \"test\"")); - // Problems and failures should be empty - assertTrue(json.contains("\"problems\": []")); + // Problems in JSON + assertTrue(json.contains("\"problems\": [")); + assertTrue(json.contains("\"key\": \"deprecated-source-target\"")); + assertTrue(json.contains("\"severity\": \"WARNING\"")); + assertTrue(json.contains("\"suggestion\": \"Update to 11 or higher\"")); + assertTrue(json.contains("\"key\": \"build-note\"")); + assertTrue(json.contains("\"severity\": \"INFO\"")); + + // Failures should be empty assertTrue(json.contains("\"failures\": []")); - // Output arrays should be present (even if empty in unit test -- no SLF4J sink) + // Output arrays should be present (even if empty in unit test — no SLF4J sink) assertTrue(json.contains("\"output\": [")); } + /** + * Tests that the problem summary printing doesn't throw when there are no problems. + */ + @Test + void testDiagnosticSummaryWithNoProblems() { + // Should be a no-op, not throw + collector.printDiagnosticSummary(); + } + + /** + * Tests that the problem summary printing handles mixed severities. + */ + @Test + void testDiagnosticSummaryWithMixedSeverities() { + diagnosticCollector.report(warning("warn-1", "first warning", null)); + diagnosticCollector.report(warning("warn-1", "first warning (dup)", null)); + diagnosticCollector.report(error("err-1", "first error", null)); + diagnosticCollector.report(info("info-1", "info note", null)); + + // Should not throw + collector.printDiagnosticSummary(); + + assertTrue(diagnosticCollector.hasWarnings()); + assertTrue(diagnosticCollector.hasErrors()); + } + /** * Tests navigation methods on the built report. */ @@ -203,38 +281,6 @@ void testReportNavigationMethods() { assertFalse(report.findModule("nonexistent:module:1.0").isPresent()); } - /** - * Tests that timestamped report files are written alongside the latest symlink. - */ - @Test - void testTimestampedReportFile() throws IOException { - MavenProject project = createProject("org.example", "my-app", "1.0.0"); - MavenSession session = createSession(project); - session.getResult().addBuildSummary(new BuildSuccess(project, 1000)); - - collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); - collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); - collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); - - BuildReport report = collector.buildReport(session); - collector.writeReport(report, session); - - Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); - assertTrue(Files.exists(reportsDir), "reports directory should exist"); - - // Should have at least two files: the timestamped one and the latest link/copy - long fileCount = Files.list(reportsDir) - .filter(p -> p.getFileName().toString().startsWith("build-report-")) - .count(); - assertTrue(fileCount >= 2, "should have both timestamped and latest report files, found " + fileCount); - - // The latest file should contain valid JSON - Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); - String json = Files.readString(latestFile); - assertTrue(json.startsWith("{"), "report should start with JSON object"); - assertTrue(json.contains("\"formatVersion\": 1"), "report should contain format version"); - } - // ---- Test helpers ---- private MavenProject createProject(String groupId, String artifactId, String version) { diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java index d123cb363efc..b58d53262966 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java @@ -29,6 +29,8 @@ import org.apache.maven.api.build.report.LogLevel; import org.apache.maven.api.build.report.ModuleReport; import org.apache.maven.api.build.report.MojoReport; +import org.apache.maven.api.services.BuilderProblem; +import org.apache.maven.impl.DefaultBuilderProblem; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -152,7 +154,7 @@ void testFailedBuildReport() { assertTrue(json.contains("\"mojo\": \"maven-compiler-plugin:3.15.0:compile\"")); assertTrue(json.contains("\"message\": \"Compilation failure: 3 errors\"")); assertTrue(json.contains("\"stackTrace\"")); - // Enriched fields + // New enriched fields assertTrue(json.contains("\"timestamp\": \"2025-01-15T10:30:03Z\""), "failure timestamp"); assertTrue(json.contains("\"exceptionType\": \"CompilationFailureException\""), "failure exceptionType"); } @@ -398,6 +400,67 @@ void testLogEventWithJulMetadata() { "SLF4J event should not have sourceClassName"); } + @Test + void testProblemsSerialization() { + BuilderProblem p1 = new DefaultBuilderProblem( + "maven-compiler-plugin:3.15.0:compile", + 42, + 1, + null, + "source/target value 8 is deprecated", + BuilderProblem.Severity.WARNING, + "deprecated-source-target", + "Update to 11 or higher", + "https://example.com/docs/compiler"); + + BuilderProblem p2 = new DefaultBuilderProblem( + "maven-compiler-plugin", + -1, + -1, + null, + "3 errors found", + BuilderProblem.Severity.ERROR, + "compilation-failure", + null, + null); + + BuildReport report = new DefaultBuildReport( + BuildStatus.FAILURE, + Duration.ofSeconds(5), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("compile"), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(p1, p2), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // Problem structure + assertTrue(json.contains("\"problems\": ["), "problems array present"); + assertTrue(json.contains("\"key\": \"deprecated-source-target\""), "problem key"); + assertTrue(json.contains("\"severity\": \"WARNING\""), "problem severity"); + assertTrue(json.contains("\"message\": \"source/target value 8 is deprecated\""), "problem message"); + assertTrue(json.contains("\"source\": \"maven-compiler-plugin:3.15.0:compile\""), "problem source"); + assertTrue(json.contains("\"line\": 42"), "problem line"); + assertTrue(json.contains("\"column\": 1"), "problem column"); + assertTrue( + json.contains("\"suggestion\": \"Update to 11 or higher\""), + "problem suggestion"); + assertTrue( + json.contains("\"documentationUrl\": \"https://example.com/docs/compiler\""), + "problem documentationUrl"); + + // Second problem (minimal fields) + assertTrue(json.contains("\"key\": \"compilation-failure\""), "second problem key"); + assertTrue(json.contains("\"severity\": \"ERROR\""), "second problem severity"); + } + @Test void testEmptyProblems() { BuildReport report = new DefaultBuildReport( diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java new file mode 100644 index 000000000000..3276c3c533cc --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java @@ -0,0 +1,311 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.maven.internal.build; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.apache.maven.api.services.BuilderProblem; +import org.apache.maven.impl.DefaultBuilderProblem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultDiagnosticCollectorTest { + + private DefaultDiagnosticCollector collector; + + @BeforeEach + void setUp() { + collector = new DefaultDiagnosticCollector(); + } + + private static BuilderProblem warning(String key, String message, String source) { + return new DefaultBuilderProblem( + source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + } + + private static BuilderProblem error(String key, String message, String source) { + return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.ERROR, key, null, null); + } + + private static BuilderProblem info(String key, String message, String source) { + return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.INFO, key, null, null); + } + + @Test + void testEmptyCollector() { + assertTrue(collector.getProblems().isEmpty()); + assertTrue(collector.getSummary().isEmpty()); + assertFalse(collector.hasWarnings()); + assertFalse(collector.hasErrors()); + } + + @Test + void testSingleWarning() { + BuilderProblem p = warning("deprecated-source", "source 8 is deprecated", "compiler:3.15.0"); + collector.report(p); + + assertEquals(1, collector.getProblems().size()); + assertEquals("deprecated-source", collector.getProblems().get(0).getKey()); + assertTrue(collector.hasWarnings()); + assertFalse(collector.hasErrors()); + } + + @Test + void testSingleError() { + BuilderProblem p = error("compilation-failure", "3 errors found", "compiler:3.15.0"); + collector.report(p); + + assertEquals(1, collector.getProblems().size()); + assertTrue(collector.hasWarnings()); // errors are >= WARNING severity + assertTrue(collector.hasErrors()); + } + + @Test + void testInfoDoesNotCountAsWarningOrError() { + BuilderProblem p = info("build-summary", "Build completed", "reactor"); + collector.report(p); + + assertEquals(1, collector.getProblems().size()); + assertFalse(collector.hasWarnings()); + assertFalse(collector.hasErrors()); + } + + @Test + void testDeduplicationByKey() { + BuilderProblem d1 = warning("deprecated-source", "source 8 is deprecated", "module-a"); + BuilderProblem d2 = warning("deprecated-source", "source 8 is deprecated", "module-b"); + BuilderProblem d3 = warning("deprecated-source", "source 8 is deprecated", "module-c"); + + collector.report(d1); + collector.report(d2); + collector.report(d3); + + // Only one unique problem + assertEquals(1, collector.getProblems().size()); + assertEquals("deprecated-source", collector.getProblems().get(0).getKey()); + + // But the summary shows count = 3 + List summary = collector.getSummary(); + assertEquals(1, summary.size()); + assertEquals(3, summary.get(0).count()); + } + + @Test + void testMultipleDistinctProblems() { + collector.report(warning("deprecated-source", "source 8 is deprecated", "compiler")); + collector.report(warning("unused-dep", "unused dependency: guava", "dependency")); + collector.report(error("test-failure", "2 tests failed", "surefire")); + + assertEquals(3, collector.getProblems().size()); + assertTrue(collector.hasWarnings()); + assertTrue(collector.hasErrors()); + + List summary = collector.getSummary(); + assertEquals(3, summary.size()); + // Each reported once + for (DefaultDiagnosticSummary s : summary) { + assertEquals(1, s.count()); + } + } + + @Test + void testInsertionOrderPreserved() { + collector.report(warning("c-warning", "third", null)); + collector.report(warning("a-warning", "first", null)); + collector.report(warning("b-warning", "second", null)); + + List problems = collector.getProblems(); + assertEquals("c-warning", problems.get(0).getKey()); + assertEquals("a-warning", problems.get(1).getKey()); + assertEquals("b-warning", problems.get(2).getKey()); + } + + @Test + void testProblemsListIsUnmodifiable() { + collector.report(warning("test", "test", null)); + List problems = collector.getProblems(); + + try { + problems.add(warning("another", "another", null)); + // Should not reach here + assertFalse(true, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // expected + } + } + + @Test + void testSummaryListIsUnmodifiable() { + collector.report(warning("test", "test", null)); + List summary = collector.getSummary(); + + try { + summary.add(new DefaultDiagnosticSummary(warning("x", "x", null), 1)); + assertFalse(true, "Expected UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // expected + } + } + + @Test + void testFullProblemFields() { + BuilderProblem p = new DefaultBuilderProblem( + "maven-compiler-plugin:3.15.0:compile", + 42, + 15, + null, + "unchecked cast from Object to List", + BuilderProblem.Severity.WARNING, + "unchecked-cast", + "Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative", + "https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html"); + + collector.report(p); + + BuilderProblem stored = collector.getProblems().get(0); + assertEquals("unchecked-cast", stored.getKey()); + assertEquals(BuilderProblem.Severity.WARNING, stored.getSeverity()); + assertEquals("unchecked cast from Object to List", stored.getMessage()); + assertEquals("maven-compiler-plugin:3.15.0:compile", stored.getSource()); + assertEquals(42, stored.getLineNumber()); + assertEquals(15, stored.getColumnNumber()); + assertEquals("Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative", stored.getSuggestion()); + assertEquals( + "https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html", stored.getDocumentationUrl()); + } + + // ---- Suppression tests ---- + + @Test + void testSuppressionByExactKey() { + collector.setSuppressedKeys(Set.of("deprecated-source")); + + collector.report(warning("deprecated-source", "source 8 is deprecated", "compiler")); + collector.report(warning("unused-dep", "unused dependency: guava", "dependency")); + + assertEquals(1, collector.getProblems().size()); + assertEquals("unused-dep", collector.getProblems().get(0).getKey()); + } + + @Test + void testSuppressionByPrefixWildcard() { + collector.setSuppressedKeys(Set.of("auto:*")); + + collector.report(warning("auto:Compiler:1a2b3c", "unchecked cast", "compiler")); + collector.report(warning("auto:Surefire:4d5e6f", "deprecated API", "surefire")); + collector.report(warning("explicit-key", "some warning", "plugin")); + + assertEquals(1, collector.getProblems().size()); + assertEquals("explicit-key", collector.getProblems().get(0).getKey()); + } + + @Test + void testSuppressionMultipleKeys() { + collector.setSuppressedKeys(Set.of("key-a", "key-b")); + + collector.report(warning("key-a", "warning a", null)); + collector.report(warning("key-b", "warning b", null)); + collector.report(warning("key-c", "warning c", null)); + + assertEquals(1, collector.getProblems().size()); + assertEquals("key-c", collector.getProblems().get(0).getKey()); + } + + @Test + void testSuppressionDoesNotAffectCounts() { + collector.setSuppressedKeys(Set.of("suppressed")); + + // Report suppressed key — should be silently dropped, no count + collector.report(warning("suppressed", "suppressed warning", null)); + collector.report(warning("kept", "kept warning", null)); + + assertEquals(1, collector.getSummary().size()); + assertEquals("kept", collector.getSummary().get(0).problem().getKey()); + assertEquals(1, collector.getSummary().get(0).count()); + } + + @Test + void testEmptySuppressionSetAllowsAll() { + collector.setSuppressedKeys(Set.of()); + + collector.report(warning("key-a", "warning a", null)); + collector.report(warning("key-b", "warning b", null)); + + assertEquals(2, collector.getProblems().size()); + } + + @Test + void testConcurrentReporting() throws Exception { + int threadCount = 8; + int reportsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + + List> futures = new ArrayList<>(); + for (int t = 0; t < threadCount; t++) { + int threadId = t; + futures.add(executor.submit(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < reportsPerThread; i++) { + // Half use a shared key (will be deduped), half use unique keys + if (i % 2 == 0) { + collector.report(warning("shared-key", "shared warning", "thread-" + threadId)); + } else { + collector.report( + warning("unique-" + threadId + "-" + i, "unique warning " + i, "thread-" + threadId)); + } + } + })); + } + + startLatch.countDown(); + for (Future f : futures) { + f.get(); + } + executor.shutdown(); + + // "shared-key" should be deduplicated to 1 entry + // Unique keys: 8 threads * 50 unique = 400 + // Total unique = 401 + int expectedUniqueKeys = 1 + (threadCount * (reportsPerThread / 2)); + assertEquals(expectedUniqueKeys, collector.getProblems().size()); + + // shared-key should have count = 8 threads * 50 = 400 + DefaultDiagnosticSummary sharedSummary = collector.getSummary().stream() + .filter(s -> "shared-key".equals(s.problem().getKey())) + .findFirst() + .orElseThrow(); + assertEquals(threadCount * (reportsPerThread / 2), sharedSummary.count()); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultBuilderProblem.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultBuilderProblem.java index bf223cc0daa1..6418446fac39 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultBuilderProblem.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultBuilderProblem.java @@ -25,22 +25,42 @@ * thrown or a simple string message. In addition, a problem carries a hint about its source, e.g. the settings file * that exhibits the problem. */ -class DefaultBuilderProblem implements BuilderProblem { +public class DefaultBuilderProblem implements BuilderProblem { final String source; final int lineNumber; final int columnNumber; final Exception exception; final String message; final Severity severity; + final String key; + final String suggestion; + final String documentationUrl; - DefaultBuilderProblem( + public DefaultBuilderProblem( String source, int lineNumber, int columnNumber, Exception exception, String message, Severity severity) { + this(source, lineNumber, columnNumber, exception, message, severity, null, null, null); + } + + @SuppressWarnings("checkstyle:ParameterNumber") + public DefaultBuilderProblem( + String source, + int lineNumber, + int columnNumber, + Exception exception, + String message, + Severity severity, + String key, + String suggestion, + String documentationUrl) { this.source = source; this.lineNumber = lineNumber; this.columnNumber = columnNumber; this.exception = exception; this.message = message; this.severity = severity; + this.key = key; + this.suggestion = suggestion; + this.documentationUrl = documentationUrl; } @Override @@ -73,6 +93,21 @@ public Severity getSeverity() { return severity; } + @Override + public String getKey() { + return key; + } + + @Override + public String getSuggestion() { + return suggestion; + } + + @Override + public String getDocumentationUrl() { + return documentationUrl; + } + @Override public String getLocation() { StringBuilder buffer = new StringBuilder(256);