From 63da8ce9f96857d89c1d3e7994fa58bfcf9f429a Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 13:57:24 +0200 Subject: [PATCH 01/10] =?UTF-8?q?Fix=20#12571:=20Build=20Report=20Foundati?= =?UTF-8?q?on=20=E2=80=94=20structured=20report,=20console=20modes,=20warn?= =?UTF-8?q?ing=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comprehensive build reporting infrastructure to Maven 4.1.0: **Structured Build Report** (Phase 0-1) - BuildReport API in maven-api-core with ModuleReport, MojoReport, FailureReport, and LogEvent data model - BuildReportCollector EventSpy that captures lifecycle events, per-mojo log output, and build diagnostics - JSON writer producing timestamped build-report-*.json files - Deduplicated warning summary printed at end of build **Console Modes** (Phase 2-4) - ConsoleMode enum (AUTO/PLAIN/RICH/MACHINE) with --console CLI flag - PlainBuildEventListener for compact CI output - RichBuildEventListener with JLine status bar for interactive terminals - MachineBuildEventListener for JSON-lines structured output **Warning Control** (Phase 5) - --warning-mode CLI flag (ALL/SUMMARY/NONE) - Log.warn() interception for automatic Maven 3 plugin coverage - -Dmaven.diagnostic.suppress=key for selective suppression **Build Log Viewer** (mvnlog) - mvnlog CLI tool for post-build report inspection - --json flag for raw JSON output - Shell scripts for Unix and Windows **Plugin API** (DiagnosticReporter) - BuilderProblem.builder() static factory with fluent Builder API - DiagnosticReporter Service interface for Maven 4 plugins - DefaultDiagnosticReporter implementation with auto-discovery Co-Authored-By: Claude Opus 4.6 --- apache-maven/src/assembly/component.xml | 1 + apache-maven/src/assembly/maven/bin/mvn | 18 + apache-maven/src/assembly/maven/bin/mvn.cmd | 2 + apache-maven/src/assembly/maven/bin/mvnlog | 30 + .../src/assembly/maven/bin/mvnlog.cmd | 39 + .../org/apache/maven/api/cli/Options.java | 37 + .../apache/maven/api/cli/ParserRequest.java | 24 + .../java/org/apache/maven/api/cli/Tools.java | 3 + .../maven/api/cli/mvnlog/LogOptions.java | 77 ++ .../maven/api/build/report/BuildReport.java | 198 +++++ .../maven/api/build/report/BuildStatus.java | 44 + .../maven/api/build/report/FailureReport.java | 89 +++ .../maven/api/build/report/LogEvent.java | 108 +++ .../maven/api/build/report/LogLevel.java | 36 + .../maven/api/build/report/ModuleReport.java | 134 ++++ .../maven/api/build/report/MojoReport.java | 138 ++++ .../maven/api/build/report/package-info.java | 39 + .../maven/api/services/BuilderProblem.java | 278 ++++++- .../api/services/DiagnosticReporter.java | 111 +++ .../org/apache/maven/cling/MavenLogCling.java | 95 +++ .../cling/event/ExecutionEventLogger.java | 10 + .../event/MachineBuildEventListener.java | 275 +++++++ .../event/MachineExecutionEventLogger.java | 306 +++++++ .../event/PlainExecutionEventLogger.java | 323 ++++++++ .../cling/event/RichBuildEventListener.java | 691 ++++++++++++++++ .../cling/event/RichExecutionEventLogger.java | 377 +++++++++ .../cling/invoker/CommonsCliOptions.java | 45 ++ .../maven/cling/invoker/LayeredOptions.java | 10 + .../maven/cling/invoker/LookupInvoker.java | 25 + .../maven/cling/invoker/mvn/MavenInvoker.java | 99 ++- .../invoker/mvnlog/BuildReportRenderer.java | 565 +++++++++++++ .../invoker/mvnlog/CommonsCliLogOptions.java | 147 ++++ .../cling/invoker/mvnlog/LogContext.java | 38 + .../cling/invoker/mvnlog/LogInvoker.java | 169 ++++ .../maven/cling/invoker/mvnlog/LogParser.java | 37 + .../invoker/mvnlog/SimpleJsonReader.java | 276 +++++++ .../BuiltinShellCommandRegistryFactory.java | 26 + .../event/MachineBuildEventListenerTest.java | 239 ++++++ .../MachineExecutionEventLoggerTest.java | 445 +++++++++++ .../event/PlainExecutionEventLoggerTest.java | 317 ++++++++ .../event/RichBuildEventListenerTest.java | 291 +++++++ .../event/RichExecutionEventLoggerTest.java | 424 ++++++++++ .../mvnlog/BuildReportRendererTest.java | 250 ++++++ .../invoker/mvnlog/SimpleJsonReaderTest.java | 141 ++++ .../internal/build/BuildReportCollector.java | 751 ++++++++++++++++++ .../internal/build/BuildReportJsonWriter.java | 368 +++++++++ .../internal/build/DefaultBuildReport.java | 82 ++ .../build/DefaultDiagnosticCollector.java | 221 ++++++ .../build/DefaultDiagnosticReporter.java | 55 ++ .../build/DefaultDiagnosticSummary.java | 37 + .../internal/build/DefaultFailureReport.java | 30 + .../maven/internal/build/DefaultLogEvent.java | 55 ++ .../internal/build/DefaultModuleReport.java | 53 ++ .../internal/build/DefaultMojoReport.java | 49 ++ .../maven/logging/BuildEventListener.java | 3 +- .../logging/ProjectBuildLogAppender.java | 42 +- .../logging/SimpleBuildEventListener.java | 6 +- .../build/BuildReportCollectorTest.java | 414 ++++++++++ .../build/BuildReportIntegrationTest.java | 359 +++++++++ .../build/BuildReportJsonWriterTest.java | 416 ++++++++++ .../build/DefaultDiagnosticCollectorTest.java | 311 ++++++++ .../maven/impl/DefaultBuilderProblem.java | 39 +- .../apache/maven/slf4j/MavenBaseLogger.java | 36 +- .../apache/maven/slf4j/MavenSimpleLogger.java | 132 ++- its/core-it-suite/pom.xml | 6 +- .../it/MavenITgh12571BuildReportTest.java | 423 ++++++++++ .../it/MavenITmng5760ResumeFeatureTest.java | 11 + ...ng5965ParallelBuildMultipliesWorkTest.java | 1 + .../MavenITmng6057CheckReactorOrderTest.java | 1 + .../it/MavenITmng6065FailOnSeverityTest.java | 2 + .../it/MavenITmng6118SubmoduleInvocation.java | 6 + .../it/MavenITmng6391PrintVersionTest.java | 2 + ...AnnotationShouldNotReExecuteGoalsTest.java | 3 + ...1ProjectListShouldIncludeChildrenTest.java | 2 + .../MavenITmng7353CliGoalInvocationTest.java | 1 + ...avenITmng7804PluginExecutionOrderTest.java | 1 + .../maven/it/MavenITmng8594AtFileTest.java | 1 + .../resources/gh-12571-build-report/pom.xml | 28 + .../gh-12571-multi-module/module-a/pom.xml | 31 + .../gh-12571-multi-module/module-b/pom.xml | 31 + .../resources/gh-12571-multi-module/pom.xml | 33 + 81 files changed, 11045 insertions(+), 24 deletions(-) create mode 100644 apache-maven/src/assembly/maven/bin/mvnlog create mode 100644 apache-maven/src/assembly/maven/bin/mvnlog.cmd create mode 100644 api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/services/DiagnosticReporter.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticCollector.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticReporter.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultDiagnosticSummary.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/DefaultDiagnosticCollectorTest.java create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java create mode 100644 its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml create mode 100644 its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml create mode 100644 its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml create mode 100644 its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml index 5f55a310c8bd..ce303ea4960d 100644 --- a/apache-maven/src/assembly/component.xml +++ b/apache-maven/src/assembly/component.xml @@ -87,6 +87,7 @@ under the License. mvn mvnenc + mvnlog mvnsh mvnup mvnDebug diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn index 0adc4eabeb2b..93127f8d8e9f 100755 --- a/apache-maven/src/assembly/maven/bin/mvn +++ b/apache-maven/src/assembly/maven/bin/mvn @@ -303,6 +303,9 @@ handle_args() { --up) MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenUpCling" ;; + --log) + MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenLogCling" + ;; *) ;; esac @@ -311,6 +314,21 @@ handle_args() { } handle_args "$@" + +# Strip routing flags (--debug, --yjp, --enc, --shell, --up, --log) from $@ +# so they are not passed to the Java process where they may collide with +# Commons CLI option-prefix matching (e.g. --log matches --log-file). +_argc=$# +_i=0 +while [ $_i -lt $_argc ]; do + _arg="$1" + shift + case $_arg in + --debug|--yjp|--enc|--shell|--up|--log) ;; + *) set -- "$@" "$_arg" ;; + esac + _i=$((_i + 1)) +done MAVEN_MAIN_CLASS=${MAVEN_MAIN_CLASS:=org.apache.maven.cling.MavenCling} # Build base command string for eval (only contains Maven-controlled values) diff --git a/apache-maven/src/assembly/maven/bin/mvn.cmd b/apache-maven/src/assembly/maven/bin/mvn.cmd index 74d4a5a984d2..7e3c8bdae83b 100644 --- a/apache-maven/src/assembly/maven/bin/mvn.cmd +++ b/apache-maven/src/assembly/maven/bin/mvn.cmd @@ -275,6 +275,8 @@ if "%~1"=="--debug" ( set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling" ) else if "%~1"=="--up" ( set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling" +) else if "%~1"=="--log" ( + set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenLogCling" ) exit /b 0 diff --git a/apache-maven/src/assembly/maven/bin/mvnlog b/apache-maven/src/assembly/maven/bin/mvnlog new file mode 100644 index 000000000000..8170bdb16785 --- /dev/null +++ b/apache-maven/src/assembly/maven/bin/mvnlog @@ -0,0 +1,30 @@ +#!/bin/sh + +# 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. + +# ----------------------------------------------------------------------------- +# Apache Maven Build Log Viewer Script +# +# Environment Variable Prerequisites +# +# JAVA_HOME (Optional) Points to a Java installation. +# MAVEN_OPTS (Optional) Java runtime options used when Maven is executed. +# MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files. +# ----------------------------------------------------------------------------- + +"`dirname "$0"`/mvn" --log "$@" diff --git a/apache-maven/src/assembly/maven/bin/mvnlog.cmd b/apache-maven/src/assembly/maven/bin/mvnlog.cmd new file mode 100644 index 000000000000..7069255cd817 --- /dev/null +++ b/apache-maven/src/assembly/maven/bin/mvnlog.cmd @@ -0,0 +1,39 @@ +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. + +@REM ----------------------------------------------------------------------------- +@REM Apache Maven Build Log Viewer Script +@REM +@REM Environment Variable Prerequisites +@REM +@REM JAVA_HOME (Optional) Points to a Java installation. +@REM MAVEN_BATCH_ECHO (Optional) Set to 'on' to enable the echoing of the batch commands. +@REM MAVEN_BATCH_PAUSE (Optional) set to 'on' to wait for a key stroke before ending. +@REM MAVEN_OPTS (Optional) Java runtime options used when Maven is executed. +@REM MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files. +@REM ----------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO% + +@setlocal + +@call "%~dp0"mvn.cmd --log %* 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 d2bf596cd916..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 @@ -182,6 +182,43 @@ public interface Options { @Nonnull Optional color(); + /** + * Returns the console output mode. + *

+ * Supported modes: + *

    + *
  • {@code "auto"} — selects {@code "plain"} in CI, {@code "rich"} on interactive TTYs, + * {@code "verbose"} otherwise
  • + *
  • {@code "plain"} — compact one-line-per-module output with structured summary
  • + *
  • {@code "rich"} — JLine status bar with live reactor progress (requires TTY)
  • + *
  • {@code "verbose"} — full mojo-level output (Maven 4.0 default behavior)
  • + *
  • {@code "machine"} — JSON lines: one typed JSON object per lifecycle event, + * designed for piping to external tools, CI systems, and LLM agents
  • + *
+ * + * @return an {@link Optional} containing the console mode, or empty if not set + * @since 4.1.0 + */ + @Nonnull + Optional console(); + + /** + * Returns the warning display mode. + *

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

    + *
  • {@code "summary"} (default) — collect warnings, show deduplicated summary at end of build
  • + *
  • {@code "all"} — show warnings inline as they occur AND show summary at end
  • + *
  • {@code "none"} — suppress the diagnostic summary entirely
  • + *
  • {@code "fail"} — show summary AND fail the build if any warnings exist
  • + *
+ * + * @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-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java index ee25ec63dab3..848151bc2511 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java @@ -253,6 +253,30 @@ static Builder mvnup(@Nonnull List args, @Nonnull MessageBuilderFactory return builder(Tools.MVNUP_CMD, Tools.MVNUP_NAME, args, messageBuilderFactory); } + /** + * Creates a new Builder instance for constructing a Maven Build Log Viewer ParserRequest. + * + * @param args the command-line arguments + * @param messageBuilderFactory the factory for creating message builders + * @return a new Builder instance + */ + @Nonnull + static Builder mvnlog(@Nonnull String[] args, @Nonnull MessageBuilderFactory messageBuilderFactory) { + return mvnlog(Arrays.asList(args), messageBuilderFactory); + } + + /** + * Creates a new Builder instance for constructing a Maven Build Log Viewer ParserRequest. + * + * @param args the command-line arguments + * @param messageBuilderFactory the factory for creating message builders + * @return a new Builder instance + */ + @Nonnull + static Builder mvnlog(@Nonnull List args, @Nonnull MessageBuilderFactory messageBuilderFactory) { + return builder(Tools.MVNLOG_CMD, Tools.MVNLOG_NAME, args, messageBuilderFactory); + } + /** * Creates a new Builder instance for constructing a ParserRequest. * diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java index 7559d7ffee06..136268657a91 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java @@ -42,4 +42,7 @@ private Tools() {} public static final String MVNUP_CMD = "mvnup"; public static final String MVNUP_NAME = "Maven Upgrade Tool"; + + public static final String MVNLOG_CMD = "mvnlog"; + public static final String MVNLOG_NAME = "Maven Build Log Viewer"; } diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java new file mode 100644 index 000000000000..f5cd4de8cb30 --- /dev/null +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java @@ -0,0 +1,77 @@ +/* + * 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.cli.mvnlog; + +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.cli.Options; + +/** + * Defines the options specific to the Maven build log viewer tool ({@code mvnlog}). + * This interface extends the general {@link Options} interface, adding log-viewing options. + * + * @since 4.1.0 + */ +@Experimental +public interface LogOptions extends Options { + /** + * Whether to show detailed diagnostics (warnings and errors) from the build. + * + * @return an {@link Optional} containing {@code true} if diagnostics should be shown + */ + Optional diagnostics(); + + /** + * Whether to show detailed failure information including stack traces. + * + * @return an {@link Optional} containing {@code true} if failures should be shown in detail + */ + Optional failures(); + + /** + * Whether to show a full per-mojo timing breakdown. + * + * @return an {@link Optional} containing {@code true} if the full breakdown should be shown + */ + Optional full(); + + /** + * Whether to list all available build reports instead of showing one. + * + * @return an {@link Optional} containing {@code true} if reports should be listed + */ + Optional list(); + + /** + * Whether to output the raw JSON build report instead of formatted text. + * Useful for piping to tools like {@code jq} or for programmatic consumption. + * + * @return an {@link Optional} containing {@code true} if raw JSON should be output + */ + Optional json(); + + /** + * Returns the path to a specific build report file to display. + * If not specified, defaults to {@code target/build-reports/build-report-latest.json}. + * + * @return an {@link Optional} containing the report file path, or empty if not specified + */ + Optional reportFile(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java new file mode 100644 index 000000000000..9200b6fa6f21 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java @@ -0,0 +1,198 @@ +/* + * 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.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.services.BuilderProblem; + +/** + * A structured report of a Maven build execution, persisted to + * {@code target/build-report.json} at the end of every build. + *

+ * The report captures metadata, per-module results (including mojo execution + * timings), and any failures. It is intended to be consumed by tools, IDEs, + * CI systems, and LLM agents without having to re-run the build or parse + * console output. + * + * @since 4.1.0 + * @see ModuleReport + * @see FailureReport + */ +@Experimental +public interface BuildReport { + + /** + * Schema version of the report format. Consumers should check this + * to handle forward compatibility. + * + * @return the format version, currently {@code 1} + */ + int formatVersion(); + + /** + * The overall build status. + * + * @return the build outcome, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * Wall-clock duration of the entire build. + * + * @return the total duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * When the build started (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * The Maven version that produced this report. + * + * @return the Maven version string, never {@code null} + */ + @Nonnull + String mavenVersion(); + + /** + * The Java version used for the build. + * + * @return the Java version string, never {@code null} + */ + @Nonnull + String javaVersion(); + + /** + * The goals or phases that were requested. + * + * @return the list of goals, never {@code null} + */ + @Nonnull + List goals(); + + /** + * The GAV of the top-level project ({@code groupId:artifactId:version}). + * + * @return the project identifier, never {@code null} + */ + @Nonnull + String project(); + + /** + * Whether this was a multi-module (reactor) build. + * + * @return {@code true} for multi-module builds + */ + boolean multiModule(); + + /** + * The degree of concurrency ({@code -T} flag), or 1 for sequential builds. + * + * @return the thread count + */ + int threads(); + + /** + * Per-module build results, in reactor execution order. + * + * @return the module reports, never {@code null} + */ + @Nonnull + List modules(); + + /** + * Failures that occurred during the build, if any. + * + * @return the failure reports, never {@code null}; empty if the build succeeded + */ + @Nonnull + List failures(); + + /** + * Structured problems (warnings, errors, informational notes) reported + * during the build by Maven itself or by plugins. + *

+ * Problems are deduplicated by {@link BuilderProblem#getKey()} — the list + * contains only unique entries. + * + * @return the problems, never {@code null}; empty if none were reported + * @since 4.1.0 + */ + @Nonnull + List problems(); + + /** + * Structured log events captured outside of any module's lifecycle — + * Maven startup messages, reactor ordering, and the final reactor summary. + *

+ * For per-module events see {@link ModuleReport#output()}, and for + * per-mojo events see {@link MojoReport#output()}. + *

+ * Together, {@code BuildReport.output()}, {@code ModuleReport.output()}, + * and {@code MojoReport.output()} form a non-overlapping partition of + * the full build log. + * + * @return the captured log events, never {@code null}; may be empty + */ + @Nonnull + List output(); + + /** + * Find a module report by its GAV identifier. + *

+ * The identifier format is {@code "groupId:artifactId:version"}, matching + * the format returned by {@link ModuleReport#id()} and used in + * {@link FailureReport#module()}. + * + * @param moduleId the module GAV string + * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"}) + * @return the matching module report, or empty if not found + */ + @Nonnull + default Optional findModule(String moduleId) { + Objects.requireNonNull(moduleId); + return modules().stream().filter(m -> moduleId.equals(m.id())).findFirst(); + } + + /** + * Find the module report that corresponds to a given failure. + * + * @param failure the failure report + * @return the matching module report, or empty if not found + */ + @Nonnull + default Optional findModule(FailureReport failure) { + Objects.requireNonNull(failure); + return findModule(failure.module()); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java new file mode 100644 index 000000000000..1ee25f8cb3a6 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java @@ -0,0 +1,44 @@ +/* + * 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.build.report; + +import org.apache.maven.api.annotations.Experimental; + +/** + * The outcome of a build, module, or mojo execution. + * + * @since 4.1.0 + */ +@Experimental +public enum BuildStatus { + /** + * Completed successfully. + */ + SUCCESS, + + /** + * Failed with an error. + */ + FAILURE, + + /** + * Skipped (e.g. because a dependency failed). + */ + SKIPPED +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java new file mode 100644 index 000000000000..20e5d95b8aea --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java @@ -0,0 +1,89 @@ +/* + * 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.build.report; + +import java.time.Instant; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Details about a build failure. + * + * @since 4.1.0 + * @see BuildReport#failures() + */ +@Experimental +public interface FailureReport { + + /** + * The GAV of the module where the failure occurred + * ({@code groupId:artifactId:version}). + * + * @return the module identifier, never {@code null} + */ + @Nonnull + String module(); + + /** + * The mojo that failed, formatted as {@code artifactId:version:goal} + * (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}). + * + * @return the mojo identifier, or {@code null} if the failure was not mojo-specific + */ + @Nullable + String mojo(); + + /** + * When the failure occurred (wall-clock time). + * + * @return the failure instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The simple class name of the root cause exception + * (e.g. {@code "MojoFailureException"}, {@code "LifecycleExecutionException"}). + *

+ * Useful for programmatic triage — tools can pattern-match on known + * exception types without parsing the message. + * + * @return the exception type name, or {@code null} if unavailable + */ + @Nullable + String exceptionType(); + + /** + * The exception message. + * + * @return the error message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The exception stack trace, truncated to a reasonable length. + * + * @return the stack trace string, or {@code null} if unavailable + */ + @Nullable + String stackTrace(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java new file mode 100644 index 000000000000..70384fad4fdb --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -0,0 +1,108 @@ +/* + * 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.build.report; + +import java.time.Instant; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * A structured log event captured during the build. + *

+ * Each event carries the log level, timestamp, message, and optionally + * the logger name and a stack trace. This replaces raw log line strings + * in the build report, enabling programmatic filtering by level and + * correlation by timestamp. + *

+ * Log events are captured at three levels forming a non-overlapping + * partition of the full build log: + *

    + *
  • {@link BuildReport#output()} — events outside any module lifecycle
  • + *
  • {@link ModuleReport#output()} — events during a module build but outside any mojo
  • + *
  • {@link MojoReport#output()} — events during a mojo execution
  • + *
+ * + * @since 4.1.0 + */ +@Experimental +public interface LogEvent { + + /** + * When this log event was produced (wall-clock time). + * + * @return the event instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The severity level of this log event. + * + * @return the log level, never {@code null} + */ + @Nonnull + LogLevel level(); + + /** + * The log message, without level prefix or timestamp formatting. + * + * @return the formatted message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The name of the logger that produced this event + * (e.g. {@code "org.apache.maven.plugins.compiler.CompilerMojo"}). + * + * @return the logger name, or {@code null} if unavailable + */ + @Nullable + String loggerName(); + + /** + * The stack trace associated with this event, if an exception was logged. + *

+ * The trace is formatted as a multi-line string and may be truncated + * for very deep stack traces. + * + * @return the stack trace string, or {@code null} if no exception was logged + */ + @Nullable + String stackTrace(); + + /** + * The fully formatted log line as rendered for console output, including + * the level prefix, timestamp, and any ANSI styling applied by the logger. + *

+ * This is the string that would be printed to the terminal in verbose mode. + * Console renderers that just need pass-through output can use this directly, + * while renderers that apply custom formatting (e.g. rich mode) can use the + * structured fields ({@link #level()}, {@link #message()}) instead. + *

+ * May be {@code null} if the event was created outside the SLF4J pipeline + * (e.g. in tests or by programmatic construction). + * + * @return the formatted log line, or {@code null} + */ + @Nullable + String formattedMessage(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java new file mode 100644 index 000000000000..684ea610a5bc --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java @@ -0,0 +1,36 @@ +/* + * 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.build.report; + +import org.apache.maven.api.annotations.Experimental; + +/** + * Log severity levels, mirroring the standard SLF4J levels. + * + * @since 4.1.0 + * @see LogEvent#level() + */ +@Experimental +public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java new file mode 100644 index 000000000000..7746545a4217 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java @@ -0,0 +1,134 @@ +/* + * 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.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; + +/** + * Build results for a single module in a reactor build. + * + * @since 4.1.0 + * @see BuildReport#modules() + */ +@Experimental +public interface ModuleReport { + + /** + * The module's group ID. + * + * @return the group ID, never {@code null} + */ + @Nonnull + String groupId(); + + /** + * The module's artifact ID. + * + * @return the artifact ID, never {@code null} + */ + @Nonnull + String artifactId(); + + /** + * The module's version. + * + * @return the version string, never {@code null} + */ + @Nonnull + String version(); + + /** + * The build outcome for this module. + * + * @return the status, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * When this module started building (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * How long this module took to build. + * + * @return the duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * The mojo executions that ran within this module, in execution order. + * + * @return the mojo reports, never {@code null} + */ + @Nonnull + List mojos(); + + /** + * Structured log events captured during this module's build lifecycle + * but outside any mojo execution — dependency resolution messages, + * resource copying, and other Maven infrastructure output. + *

+ * For per-mojo events see {@link MojoReport#output()}. + * + * @return the captured log events, never {@code null}; may be empty + */ + @Nonnull + List output(); + + /** + * The module identifier formatted as {@code "groupId:artifactId:version"}. + *

+ * This matches the format used by {@link FailureReport#module()}, allowing + * direct lookup from a failure report. + * + * @return the GAV string, never {@code null} + */ + @Nonnull + default String id() { + return groupId() + ":" + artifactId() + ":" + version(); + } + + /** + * Find a mojo execution by its identifier string. + *

+ * The identifier format is {@code "artifactId:version:goal"}, matching + * the format used by {@link FailureReport#mojo()}. + * + * @param mojoId the mojo identifier (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}) + * @return the matching mojo report, or empty if not found + */ + @Nonnull + default Optional findMojo(String mojoId) { + Objects.requireNonNull(mojoId); + return mojos().stream().filter(m -> mojoId.equals(m.id())).findFirst(); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java new file mode 100644 index 000000000000..76001babbd6b --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java @@ -0,0 +1,138 @@ +/* + * 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.build.report; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * Report for a single mojo (plugin goal) execution within a module. + * + * @since 4.1.0 + * @see ModuleReport#mojos() + */ +@Experimental +public interface MojoReport { + + /** + * The plugin's group ID. + * + * @return the group ID, never {@code null} + */ + @Nonnull + String groupId(); + + /** + * The plugin's artifact ID. + * + * @return the artifact ID, never {@code null} + */ + @Nonnull + String artifactId(); + + /** + * The plugin version. + * + * @return the version string, never {@code null} + */ + @Nonnull + String version(); + + /** + * The goal that was executed (e.g. {@code "compile"}, {@code "test"}). + * + * @return the goal name, never {@code null} + */ + @Nonnull + String goal(); + + /** + * The execution ID (e.g. {@code "default-compile"}). + * + * @return the execution ID, or {@code null} if not set + */ + @Nullable + String executionId(); + + /** + * The lifecycle phase this mojo was bound to (e.g. {@code "compile"}, {@code "test"}). + * + * @return the phase name, or {@code null} if invoked directly + */ + @Nullable + String phase(); + + /** + * The outcome of this mojo execution. + * + * @return the status, never {@code null} + */ + @Nonnull + BuildStatus status(); + + /** + * When this mojo execution started (wall-clock time). + * + * @return the start instant, never {@code null} + */ + @Nonnull + Instant startTime(); + + /** + * How long this mojo execution took. + * + * @return the duration, never {@code null} + */ + @Nonnull + Duration duration(); + + /** + * Structured log events captured during this mojo's execution. + *

+ * The list may be truncated if the mojo produced excessive output. + *

+ * This captures all SLF4J output that occurred on the mojo's execution + * thread between the mojo's start and finish events, regardless of + * whether the mojo used the legacy {@code Mojo.getLog()}, the Maven 4 + * injected {@code Log}, or plain SLF4J. + * + * @return the captured log events, never {@code null}; may be empty + * @since 4.1.0 + */ + @Nonnull + List output(); + + /** + * The mojo identifier formatted as {@code "artifactId:version:goal"}. + *

+ * This matches the format used by {@link FailureReport#mojo()}, allowing + * direct lookup via {@link ModuleReport#findMojo(String)}. + * + * @return the mojo identifier string, never {@code null} + */ + @Nonnull + default String id() { + return artifactId() + ":" + version() + ":" + goal(); + } +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java new file mode 100644 index 000000000000..509dad61a6d8 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java @@ -0,0 +1,39 @@ +/* + * 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. + */ + +/** + * Structured build report data model. + *

+ * The {@link org.apache.maven.api.build.report.BuildReport} is the root of a structured + * representation of a Maven build execution. It is persisted to + * {@code target/build-report.json} at the end of every build and can be consumed + * by tools, CI systems, IDEs, and LLM agents without re-running the build or + * parsing console output. + *

+ * Build problems (warnings, errors) are represented as + * {@link org.apache.maven.api.services.BuilderProblem} instances, deduplicated + * by {@link org.apache.maven.api.services.BuilderProblem#getKey() key} and + * summarized at the end of the build. + * + * @since 4.1.0 + */ +@Experimental +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; 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: + *

    + *
  • Deduplicated by {@link BuilderProblem#getKey() key} across modules
  • + *
  • Included in the JSON build report ({@code build-report-*.json})
  • + *
  • Shown in the warning summary at the end of the build
  • + *
  • Displayed with structured details by {@code mvnlog --diagnostics}
  • + *
+ *

+ * 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/MavenLogCling.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java new file mode 100644 index 000000000000..baeda012571c --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenLogCling.java @@ -0,0 +1,95 @@ +/* + * 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.cling; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.cli.Invoker; +import org.apache.maven.api.cli.Parser; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.cling.invoker.ProtoLookup; +import org.apache.maven.cling.invoker.mvnlog.LogInvoker; +import org.apache.maven.cling.invoker.mvnlog.LogParser; +import org.codehaus.plexus.classworlds.ClassWorld; + +/** + * Maven build log viewer CLI ("new-gen"). + *

+ * Displays formatted summaries of previous Maven build reports. + * Invoked via {@code mvnlog} or {@code mvn --log}. + * + * @since 4.1.0 + */ +public class MavenLogCling extends ClingSupport { + /** + * "Normal" Java entry point. Note: Maven uses ClassWorld Launcher and this entry point is NOT used under normal + * circumstances. + */ + public static void main(String[] args) throws IOException { + int exitCode = new MavenLogCling().run(args, null, null, null, false); + System.exit(exitCode); + } + + /** + * ClassWorld Launcher "enhanced" entry point: returning exitCode and accepts Class World. + */ + public static int main(String[] args, ClassWorld world) throws IOException { + return new MavenLogCling(world).run(args, null, null, null, false); + } + + /** + * ClassWorld Launcher "embedded" entry point: returning exitCode and accepts Class World and streams. + */ + public static int main( + String[] args, + ClassWorld world, + @Nullable InputStream stdIn, + @Nullable OutputStream stdOut, + @Nullable OutputStream stdErr) + throws IOException { + return new MavenLogCling(world).run(args, stdIn, stdOut, stdErr, true); + } + + public MavenLogCling() { + super(); + } + + public MavenLogCling(ClassWorld classWorld) { + super(classWorld); + } + + @Override + protected Invoker createInvoker() { + return new LogInvoker( + ProtoLookup.builder().addMapping(ClassWorld.class, classWorld).build(), null); + } + + @Override + protected Parser createParser() { + return new LogParser(); + } + + @Override + protected ParserRequest.Builder createParserRequestBuilder(String[] args) { + return ParserRequest.mvnlog(args, createMessageBuilderFactory()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java index d85cc7188483..b42697e97b29 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/ExecutionEventLogger.java @@ -31,6 +31,7 @@ import org.apache.maven.api.MonotonicClock; import org.apache.maven.api.services.MessageBuilder; import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; import org.apache.maven.execution.AbstractExecutionListener; import org.apache.maven.execution.BuildFailure; import org.apache.maven.execution.BuildSuccess; @@ -304,6 +305,15 @@ private void logStats(MavenSession session) { logger.info("Total time: {}{}", formatDuration(time), wallClock); + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + ZonedDateTime rounded = finish.truncatedTo(ChronoUnit.SECONDS).atZone(ZoneId.systemDefault()); logger.info("Finished at: {}", formatTimestamp(rounded)); } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java new file mode 100644 index 000000000000..79280ac4fa76 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java @@ -0,0 +1,275 @@ +/* + * 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.cling.event; + +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.logging.BuildEventListener; +import org.eclipse.aether.transfer.TransferEvent; + +/** + * A machine-readable build event listener that outputs one JSON object per line + * to the configured writer. Each line is a self-contained JSON object with an + * {@code "event"} field identifying its type. + *

+ * This listener handles the {@link BuildEventListener} events: log messages, + * transfer progress, and execution failures. Session and project lifecycle events + * are emitted by the companion {@link MachineExecutionEventLogger}. + *

+ * The JSON lines format is designed for piping to external tools (CI systems, + * LLM agents, IDE integrations) that consume structured build events in real time. + *

+ * Example output: + *

+ * {"event":"log","timestamp":"...","message":"Compiling 42 source files"}
+ * {"event":"transfer.started","timestamp":"...","artifact":"core-4.1.0.jar","size":524288}
+ * {"event":"transfer.progressed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":262144,"total":524288}
+ * {"event":"transfer.completed","timestamp":"...","artifact":"core-4.1.0.jar","transferred":524288}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineExecutionEventLogger + */ +public class MachineBuildEventListener implements BuildEventListener { + + private final Consumer output; + + /** + * Creates a new MachineBuildEventListener. + * + * @param output the consumer that receives each JSON line (typically writes to terminal/stdout) + */ + public MachineBuildEventListener(Consumer output) { + this.output = output; + } + + /** + * Emit a pre-built JSON line to the output. Thread-safe — output is serialized + * to prevent interleaved lines from parallel builds. + * + * @param json the complete JSON object string (no trailing newline) + */ + public synchronized void emitEvent(String json) { + output.accept(json); + } + + @Override + public void sessionStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.sessionStarted() + } + + @Override + public void projectStarted(String projectId) { + // Handled by MachineExecutionEventLogger.projectStarted() + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + emitEvent(new JsonLine("log") + .field("level", event.level().name()) + .field("module", projectId) + .field("logger", event.loggerName()) + .field("message", event.message()) + .build()); + } + + @Override + public void projectFinished(String projectId) { + // Handled by MachineExecutionEventLogger.projectSucceeded/Failed/Skipped() + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + emitEvent(new JsonLine("execution.failure") + .field("module", projectId) + .field("halted", halted) + .field("error", exception) + .build()); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + // Handled by MachineExecutionEventLogger.mojoStarted() + } + + @Override + public void finish(int exitCode) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void fail(Throwable t) throws Exception { + // No-op — build.finished is emitted by MachineExecutionEventLogger.sessionEnded() + } + + @Override + public void log(String msg) { + emitEvent(new JsonLine("log").field("message", msg).build()); + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + String artifactName = extractArtifactName(resource); + long contentLength = event.getResource().getContentLength(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + JsonLine started = new JsonLine("transfer.started").field("artifact", artifactName); + if (projectId != null) { + started.field("module", projectId); + } + if (contentLength > 0) { + started.field("size", contentLength); + } + started.field("url", resource); + emitEvent(started.build()); + break; + case PROGRESSED: + JsonLine progressed = new JsonLine("transfer.progressed").field("artifact", artifactName); + progressed.field("transferred", event.getTransferredBytes()); + if (contentLength > 0) { + progressed.field("total", contentLength); + } + emitEvent(progressed.build()); + break; + case SUCCEEDED: + JsonLine succeeded = new JsonLine("transfer.completed").field("artifact", artifactName); + succeeded.field("transferred", event.getTransferredBytes()); + emitEvent(succeeded.build()); + break; + case FAILED: + JsonLine failed = new JsonLine("transfer.failed").field("artifact", artifactName); + if (event.getException() != null) { + failed.field("error", event.getException().getMessage()); + } + emitEvent(failed.build()); + break; + default: + break; + } + } + + // ---- Helpers ---- + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + // ---- JSON line builder ---- + + /** + * Lightweight builder for single-line JSON objects. Builds a flat JSON object + * with an {@code "event"} type and a {@code "timestamp"} field, plus any + * additional fields. Thread-safe when used within a single thread per instance. + */ + static class JsonLine { + private final StringBuilder sb; + private boolean hasFields; + + JsonLine(String eventType) { + sb = new StringBuilder(256); + sb.append("{\"event\":\""); + sb.append(eventType); + sb.append("\",\"timestamp\":\""); + sb.append(MonotonicClock.now().toString()); + sb.append('"'); + hasFields = true; + } + + JsonLine field(String key, String value) { + if (value != null) { + sb.append(",\"").append(key).append("\":"); + writeJsonString(sb, value); + } + return this; + } + + JsonLine field(String key, long value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, double value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + JsonLine field(String key, boolean value) { + sb.append(",\"").append(key).append("\":").append(value); + return this; + } + + String build() { + sb.append('}'); + return sb.toString(); + } + + /** + * Write a JSON-escaped string value (with surrounding quotes) to the builder. + */ + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java new file mode 100644 index 000000000000..f44e9fa91ad1 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineExecutionEventLogger.java @@ -0,0 +1,306 @@ +/* + * 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.cling.event; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.cling.event.MachineBuildEventListener.JsonLine; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; + +/** + * Execution event logger for machine-readable output ({@code --console=machine}). + *

+ * Emits one JSON line per lifecycle event to the shared + * {@link MachineBuildEventListener#emitEvent(String)} writer. This logger + * handles the {@link org.apache.maven.execution.ExecutionListener} events: + * session start/end, project start/success/failure/skip, and mojo + * start/success/failure/skip. + *

+ * Together with {@link MachineBuildEventListener} (which handles log messages, + * transfers, and execution failures), this provides a complete, typed event + * stream suitable for piping to external tools, CI systems, and LLM agents. + *

+ * Example output: + *

+ * {"event":"build.started","timestamp":"...","projectCount":12,"goals":"clean install"}
+ * {"event":"module.started","timestamp":"...","module":"maven-core","groupId":"org.apache.maven","artifactId":"maven-core","version":"4.1.0-SNAPSHOT","index":1,"total":12}
+ * {"event":"mojo.started","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","phase":"compile"}
+ * {"event":"mojo.succeeded","timestamp":"...","module":"maven-core","plugin":"maven-compiler-plugin","goal":"compile","duration":1.2}
+ * {"event":"module.succeeded","timestamp":"...","module":"maven-core","duration":2.1}
+ * {"event":"build.finished","timestamp":"...","status":"SUCCESS","duration":32.1,"total":12,"passed":12,"failed":0,"skipped":0}
+ * 
+ * + * Selected via {@code --console=machine}. + * + * @since 4.1.0 + * @see MachineBuildEventListener + */ +public class MachineExecutionEventLogger extends AbstractExecutionListener { + + private final MachineBuildEventListener machineBel; + + // Track mojo start times for duration calculation + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + // Reactor state + private volatile int totalProjects; + private volatile int currentVisitedProjectCount; + private volatile Instant buildStartTime; + + public MachineExecutionEventLogger(MachineBuildEventListener machineBel) { + this.machineBel = Objects.requireNonNull(machineBel, "machineBel cannot be null"); + } + + // ---- Session lifecycle ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + totalProjects = allProjects.size(); + currentVisitedProjectCount = allProjects.size() - projects.size(); + buildStartTime = MonotonicClock.now(); + + String goals = session.getRequest().getGoals().stream().collect(Collectors.joining(" ")); + + JsonLine line = new JsonLine("build.started") + .field("projectCount", totalProjects) + .field("goals", goals); + + List profiles = session.getRequest().getActiveProfiles(); + if (profiles != null && !profiles.isEmpty()) { + line.field("profiles", String.join(",", profiles)); + } + + machineBel.emitEvent(line.build()); + } + + @Override + public void sessionEnded(ExecutionEvent event) { + MavenSession session = event.getSession(); + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + String status = session.getResult().hasExceptions() ? "FAILURE" : "SUCCESS"; + double duration = 0; + if (buildStartTime != null) { + duration = Duration.between(buildStartTime, MonotonicClock.now()).toMillis() / 1000.0; + } + + machineBel.emitEvent(new JsonLine("build.finished") + .field("status", status) + .field("duration", duration) + .field("total", totalProjects) + .field("passed", passed) + .field("failed", failed) + .field("skipped", skipped) + .build()); + } + + // ---- Module lifecycle ---- + + @Override + public void projectStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + int index; + synchronized (this) { + index = ++currentVisitedProjectCount; + } + + machineBel.emitEvent(new JsonLine("module.started") + .field("module", project.getName()) + .field("groupId", project.getGroupId()) + .field("artifactId", project.getArtifactId()) + .field("version", project.getVersion()) + .field("index", index) + .field("total", totalProjects) + .build()); + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + logModuleFinished(event, "module.succeeded"); + } + + @Override + public void projectFailed(ExecutionEvent event) { + logModuleFinished(event, "module.failed"); + } + + @Override + public void projectSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("module.skipped") + .field("module", project.getName()) + .build()); + } + + // ---- Mojo lifecycle ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + mojoStartTimes.put(mojoKey, MonotonicClock.now()); + + machineBel.emitEvent(new JsonLine("mojo.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .field("phase", mojo.getLifecyclePhase()) + .field("executionId", mojo.getExecutionId()) + .build()); + } + + @Override + public void mojoSucceeded(ExecutionEvent event) { + logMojoFinished(event, "mojo.succeeded"); + } + + @Override + public void mojoFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine("mojo.failed") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("mojo.skipped") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + // ---- Fork lifecycle (machine mode emits these for completeness) ---- + + @Override + public void forkStarted(ExecutionEvent event) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + machineBel.emitEvent(new JsonLine("fork.started") + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()) + .build()); + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + MavenProject project = event.getProject(); + machineBel.emitEvent(new JsonLine("fork.succeeded") + .field("module", project.getName()) + .build()); + } + + @Override + public void forkFailed(ExecutionEvent event) { + MavenProject project = event.getProject(); + JsonLine line = new JsonLine("fork.failed").field("module", project.getName()); + if (event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + // ---- Helpers ---- + + private void logModuleFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + BuildSummary summary = session.getResult().getBuildSummary(project); + + JsonLine line = new JsonLine(eventType).field("module", project.getName()); + if (summary != null) { + line.field("duration", summary.getExecTime().toMillis() / 1000.0); + } + if ("module.failed".equals(eventType) && event.getException() != null) { + line.field("error", event.getException().getMessage()); + } + machineBel.emitEvent(line.build()); + } + + private void logMojoFinished(ExecutionEvent event, String eventType) { + MavenProject project = event.getProject(); + MojoExecution mojo = event.getMojoExecution(); + + String mojoKey = project.getArtifactId() + ":" + mojo.getExecutionId() + ":" + mojo.getGoal(); + Instant start = mojoStartTimes.remove(mojoKey); + + JsonLine line = new JsonLine(eventType) + .field("module", project.getName()) + .field("plugin", mojo.getArtifactId()) + .field("goal", mojo.getGoal()); + if (start != null) { + double duration = Duration.between(start, MonotonicClock.now()).toMillis() / 1000.0; + line.field("duration", duration); + } + machineBel.emitEvent(line.build()); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java new file mode 100644 index 000000000000..f35588ff2e9e --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/PlainExecutionEventLogger.java @@ -0,0 +1,323 @@ +/* + * 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.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Compact execution event logger for CI and batch environments. + *

+ * Produces one line per completed module instead of the verbose per-mojo + * output of {@link ExecutionEventLogger}. Designed for CI log viewers + * and LLM-based tools where signal density matters more than verbosity. + *

+ * Example output: + *

+ * [INFO] maven-api-core ................................ SUCCESS [  2.1s]
+ * [INFO] maven-core ..................................... FAILURE [  5.3s]
+ * [INFO]
+ * [INFO] BUILD FAILURE
+ * [INFO] Total time:  32.1s
+ * 
+ * + * Selected via {@code --console=plain} or automatically in CI environments. + * + * @since 4.1.0 + * @see ExecutionEventLogger + */ +public class PlainExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory) { + this(messageBuilderFactory, LoggerFactory.getLogger(PlainExecutionEventLogger.class)); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger) { + this(messageBuilderFactory, logger, -1); + } + + public PlainExecutionEventLogger(MessageBuilderFactory messageBuilderFactory, Logger logger, int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + private void infoMain(String msg) { + logger.info(builder().strong(msg).toString()); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + List projects = event.getSession().getProjects(); + List allProjects = event.getSession().getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle: one line per completed module ---- + + @Override + public void projectStarted(ExecutionEvent event) { + // In plain mode, we only log when a project finishes (succeeded/failed/skipped) + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SUCCESS"); + } + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed in plain mode ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed in plain mode — plugin execution details go to build report + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in plain mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in plain mode + } + + // ---- Formatting helpers ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + if (buffer.length() <= maxProjectNameLength) { + while (buffer.length() < maxProjectNameLength) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + logger.info(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + logger.info(buffer.toString()); + + // Compact stats line: "12 modules | 11 passed | 1 failed | 0 skipped" + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + logger.info(stats.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + logger.info("Total time: {}{}", formatDuration(time), wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + logger.info("Maven: {}", CLIReportingUtils.showVersionMinimal()); + logger.info( + "Java: {} ({})", + System.getProperty("java.version", ""), + System.getProperty("java.vendor", "")); + } + + logger.info("Full report: target/build-reports/build-report-latest.json"); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java new file mode 100644 index 000000000000..6b5bf397dfed --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java @@ -0,0 +1,691 @@ +/* + * 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.cling.event; + +import java.io.PrintWriter; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.logging.BuildEventListener; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.jline.terminal.Terminal; +import org.jline.utils.Display; + +/** + * A rich terminal build event listener using JLine's {@link Display} in + * non-fullscreen mode — the same approach as mvnd. + *

+ * The status area is rendered at the current cursor position using + * {@link Display#updateAnsi}. When log output arrives, the display is + * cleared (updated with empty lines), the log line is printed normally, + * and then the status is redrawn below it. JLine handles all the cursor + * math (moving up, erasing changed lines, etc.) and only repaints what + * actually changed. + *

+ * At the end of the build, the display is cleared and nothing remains + * on screen — the summary then prints as normal scrolling text. + *

+ * The status area has a fixed height based on the degree of concurrency, + * so the separator and summary line stay anchored at the bottom. Active + * projects are packed to the top of the slot area; empty lines fill the + * gap between the last active project and the separator. + *

+ * Falls back to simple log passthrough on dumb terminals. + * + * @since 4.1.0 + * @see PlainExecutionEventLogger + * @see ExecutionEventLogger + */ +public class RichBuildEventListener implements BuildEventListener { + + // ---- ANSI colors ---- + + private static final String ESC = "\033["; + private static final String CYAN = ESC + "36m"; + private static final String YELLOW = ESC + "33m"; + private static final String BLUE = ESC + "34m"; + private static final String GREEN = ESC + "32m"; + private static final String RED = ESC + "31m"; + private static final String BOLD = ESC + "1m"; + private static final String DIM = ESC + "2m"; + private static final String RESET = ESC + "0m"; + + // ---- Terminal & output ---- + + private final Terminal terminal; + private final PrintWriter writer; + private final boolean supported; + + // ---- JLine Display ---- + + /** JLine display in non-fullscreen mode — handles cursor math. */ + private volatile Display display; + /** Whether the display is currently active. */ + private volatile boolean displayActive; + /** Fixed number of lines in the status area (set once in initReactor). */ + private volatile int statusHeight; + + // ---- Reactor state ---- + + private volatile int totalProjects; + private volatile int completedProjects; + private volatile Instant buildStartTime; + /** One-line header shown at the top of the status area. */ + private volatile String headerLine; + + // ---- Project display ---- + + private final Map activeProjects = new ConcurrentHashMap<>(); + private final List projectOrder = new ArrayList<>(); + private final Map projectNames = new ConcurrentHashMap<>(); + + // ---- Active downloads ---- + + private final Map activeTransfers = new ConcurrentHashMap<>(); + + // ---- Synchronization ---- + + /** Guards all terminal output and slot mutations. */ + private final Object outputLock = new Object(); + + // ---- Periodic refresh ---- + + /** Scheduler for 1-second display refresh so elapsed timers stay live. */ + private volatile ScheduledExecutorService refreshScheduler; + /** Handle for the periodic refresh task. */ + private volatile ScheduledFuture refreshFuture; + + // ---- Warning tracking ---- + + /** Number of WARN-level messages seen during the build. */ + private final AtomicInteger warningCount = new AtomicInteger(); + + /** Number of ERROR-level messages seen during the build. */ + private final AtomicInteger errorCount = new AtomicInteger(); + + // ---- Constructor ---- + + /** + * Creates a new RichBuildEventListener. + * + * @param terminal the JLine terminal for output + * @param output fallback output consumer (unused — kept for API compat) + */ + public RichBuildEventListener(Terminal terminal, java.util.function.Consumer output) { + this.terminal = terminal; + this.writer = terminal.writer(); + // Support ANSI if terminal type is not "dumb" and has reasonable size + String type = terminal.getType(); + this.supported = type != null && !Terminal.TYPE_DUMB.equals(type) && terminal.getWidth() > 0; + } + + // ---- Reactor lifecycle ---- + + /** + * Initialize reactor state from the session. Called by {@link RichExecutionEventLogger} + * during {@code sessionStarted}. + */ + public void initReactor(MavenSession session) { + List allProjects = session.getAllProjects(); + List projects = session.getProjects(); + + this.totalProjects = allProjects.size(); + this.completedProjects = allProjects.size() - projects.size(); + this.buildStartTime = MonotonicClock.now(); + + for (MavenProject project : allProjects) { + projectOrder.add(project.getArtifactId()); + projectNames.put(project.getArtifactId(), project.getName()); + } + + // Build header line + this.headerLine = buildHeaderLine(session); + + // Slot count = degree of concurrency (capped for sanity) + int concurrency = 1; + try { + concurrency = Math.max(1, session.getRequest().getDegreeOfConcurrency()); + } catch (Exception e) { + // fallback to 1 + } + int slotCount = Math.min(concurrency, 8); + // Fixed height: 1 header + N project slots + 1 separator + 1 summary + this.statusHeight = slotCount + 3; + + if (supported) { + setupDisplay(); + } + } + + private String buildHeaderLine(MavenSession session) { + StringBuilder h = new StringBuilder(); + h.append(' ').append(BOLD); + + // Maven version + String mavenVersion = null; + if (session.getSystemProperties() != null) { + mavenVersion = session.getSystemProperties().getProperty("maven.version"); + } + if (mavenVersion != null) { + h.append("Maven ").append(mavenVersion); + } else { + h.append("Maven"); + } + h.append(RESET); + + // Project name + MavenProject top = session.getTopLevelProject(); + if (top != null) { + h.append(DIM).append(" ─ ").append(RESET); + h.append("building "); + h.append(CYAN).append(top.getName()).append(RESET); + if (top.getVersion() != null) { + h.append(' ').append(DIM).append(top.getVersion()).append(RESET); + } + } + + // Goals + List goals = session.getGoals(); + if (goals != null && !goals.isEmpty()) { + h.append(DIM).append(" ─ ").append(RESET); + h.append(YELLOW).append(String.join(" ", goals)).append(RESET); + } + + return h.toString(); + } + + /** + * Set up the JLine Display in non-fullscreen mode. + */ + private void setupDisplay() { + synchronized (outputLock) { + display = new Display(terminal, false); + display.resize(statusHeight, terminal.getWidth()); + displayActive = true; + display.updateAnsi(buildStatusLines(), 0); + } + + // Start a 1-second periodic refresh so that elapsed-time counters + // stay live even when no build events are arriving (e.g. during + // a slow mojo execution with no log output). + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "maven-rich-display-refresh"); + t.setDaemon(true); + return t; + }); + executor.setRemoveOnCancelPolicy(true); + refreshScheduler = executor; + refreshFuture = refreshScheduler.scheduleAtFixedRate(this::redraw, 1, 1, TimeUnit.SECONDS); + } + + /** + * Tear down the status display. Called by {@link RichExecutionEventLogger} + * during {@code sessionEnded} before printing the summary. + *

+ * The flush at the end is critical: {@link Display} writes through + * {@link Terminal#writer()} (a {@code PrintWriter} that does not auto-flush), + * while subsequent log output from SLF4J goes through {@code System.out} + * (which does auto-flush on {@code println}). Without the flush, + * the clear sequences sit in the writer's buffer while the summary text + * reaches the terminal first via {@code System.out} — then the belated + * clear erases the summary the user was supposed to see. + */ + public void tearDown() { + // Stop the periodic refresh first (outside outputLock to avoid deadlock) + if (refreshFuture != null) { + refreshFuture.cancel(false); + refreshFuture = null; + } + if (refreshScheduler != null) { + refreshScheduler.shutdownNow(); + refreshScheduler = null; + } + + synchronized (outputLock) { + if (!displayActive) { + return; + } + displayActive = false; + + // Clear the display area: update with empty lines, cursor at top + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + // Erase from cursor to end of screen — removes any leftover artifacts + writer.print("\033[J"); + // Flush immediately so the clear reaches the terminal BEFORE + // any subsequent log output that goes through System.out + writer.flush(); + } + } + + // ---- BuildEventListener interface ---- + + @Override + public void sessionStarted(ExecutionEvent event) { + // Reactor init is handled via initReactor() called from RichExecutionEventLogger + } + + @Override + public void projectStarted(String projectId) { + activeProjects.put(projectId, new ProjectState(projectId, MonotonicClock.now())); + redraw(); + } + + @Override + public void projectFinished(String projectId) { + activeProjects.remove(projectId); + completedProjects++; + redraw(); + } + + @Override + public void projectLogMessage(String projectId, LogEvent event) { + // In rich mode, suppress INFO/DEBUG/TRACE/WARN — the status bar provides + // live progress and warnings are summarized at the end of the build. + // Only ERROR passes through to the terminal immediately. + if (event.level() == LogLevel.WARN) { + warningCount.incrementAndGet(); + return; + } + if (event.level() == LogLevel.INFO || event.level() == LogLevel.DEBUG || event.level() == LogLevel.TRACE) { + return; + } + if (event.level() == LogLevel.ERROR) { + errorCount.incrementAndGet(); + } + String output = event.formattedMessage(); + if (output == null) { + output = event.message(); + } + printAboveStatus(output); + } + + /** + * Returns the number of WARN-level log messages seen during the build. + */ + public int getWarningCount() { + return warningCount.get(); + } + + /** + * Returns the number of ERROR-level log messages seen during the build. + */ + public int getErrorCount() { + return errorCount.get(); + } + + @Override + public void log(String msg) { + printAboveStatus(msg); + } + + @Override + public void mojoStarted(ExecutionEvent event) { + String projectId = event.getProject().getArtifactId(); + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.currentMojo = event.getMojoExecution().getArtifactId() + ":" + + event.getMojoExecution().getGoal(); + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void executionFailure(String projectId, boolean halted, String exception) { + ProjectState state = activeProjects.get(projectId); + if (state != null) { + state.failed = true; + } + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + @Override + public void transfer(String projectId, TransferEvent event) { + String resource = event.getResource().getResourceName(); + + switch (event.getType()) { + case INITIATED: + case STARTED: + String artifactName = extractArtifactName(resource); + activeTransfers.put( + resource, + new TransferInfo(artifactName, 0, event.getResource().getContentLength())); + redraw(); + break; + case PROGRESSED: + TransferInfo info = activeTransfers.get(resource); + if (info != null) { + info.transferred = event.getTransferredBytes(); + // Only update every ~50KB to avoid too-frequent redraws + if (info.transferred - info.lastUpdateBytes > 51200) { + info.lastUpdateBytes = info.transferred; + redraw(); + } + } + break; + case SUCCEEDED: + case FAILED: + activeTransfers.remove(resource); + redraw(); + break; + default: + break; + } + } + + @Override + public void finish(int exitCode) throws Exception { + tearDown(); + } + + @Override + public void fail(Throwable t) throws Exception { + tearDown(); + } + + // ---- Display helpers ---- + + /** + * Print a message above the status area: clear the display, print + * the message as normal scrolling text, then redraw the status below. + */ + private void printAboveStatus(String msg) { + synchronized (outputLock) { + if (displayActive) { + // Clear status area so the message prints where it was + display.updateAnsi(Collections.nCopies(statusHeight, ""), 0); + display.reset(); + // Print the log message (scrolls normally) + writer.println(msg); + writer.flush(); + // Redraw status below the new output + display.updateAnsi(buildStatusLines(), 0); + } else { + writer.println(msg); + writer.flush(); + } + } + } + + private void redraw() { + synchronized (outputLock) { + if (displayActive) { + display.updateAnsi(buildStatusLines(), 0); + } + } + } + + // ---- Status line building ---- + + /** + * Build exactly {@link #statusHeight} status lines. + * Layout: 1 header + active projects (packed top) + empty padding + 1 separator + 1 summary. + * The separator and summary are always at the bottom — they never move. + */ + private List buildStatusLines() { + int termWidth = Math.max(terminal.getWidth(), 40); + + // Collect active projects sorted by start time for visual stability + List active = new ArrayList<>(activeProjects.values()); + active.sort((a, b) -> a.startTime.compareTo(b.startTime)); + + // Number of project slot lines = statusHeight - 3 (header, separator, summary) + int slotCount = statusHeight - 3; + + List lines = new ArrayList<>(statusHeight); + + // Header line + lines.add(headerLine != null ? headerLine : ""); + + // Active projects packed to the top (up to slotCount) + int projectsShown = 0; + for (ProjectState state : active) { + if (projectsShown >= slotCount) { + break; + } + lines.add(formatProjectSlot(state)); + projectsShown++; + } + + // Empty padding lines at the bottom of the slot area + for (int i = projectsShown; i < slotCount; i++) { + lines.add(""); + } + + // Separator line (always at the same position) + lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET); + + // Summary line (progress indicators + counter + elapsed + downloads) + lines.add(buildSummaryLine(termWidth)); + + return lines; + } + + private String formatProjectSlot(ProjectState state) { + StringBuilder b = new StringBuilder(); + if (state.failed) { + b.append(RED).append(" ✗ ").append(RESET); + } else { + b.append(CYAN).append(" ● ").append(RESET); + } + b.append(BOLD); + b.append(projectNames.getOrDefault(state.projectId, state.projectId)); + b.append(RESET); + if (state.currentMojo != null) { + b.append(" ").append(YELLOW).append(state.currentMojo).append(RESET); + } + Duration elapsed = Duration.between(state.startTime, MonotonicClock.now()); + b.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + return b.toString(); + } + + private String buildSummaryLine(int termWidth) { + StringBuilder s = new StringBuilder(" "); + + // Module status indicators (✓ done, ● active, ○ pending) + int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3); + if (totalProjects > 1 && maxIndicators > 0) { + int shown = 0; + for (String pid : projectOrder) { + if (shown >= maxIndicators) { + s.append("… "); + break; + } + if (activeProjects.containsKey(pid)) { + ProjectState ps = activeProjects.get(pid); + if (ps != null && ps.failed) { + s.append(RED).append("✗ ").append(RESET); + } else { + s.append(YELLOW).append("● ").append(RESET); + } + } else if (projectOrder.indexOf(pid) < completedProjects) { + s.append(GREEN).append("✓ ").append(RESET); + } else { + s.append(DIM).append("○ ").append(RESET); + } + shown++; + } + } + + // Progress counter + s.append('['); + s.append(BOLD) + .append(completedProjects) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + + // Elapsed time + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + + // Downloads (merged into summary line to keep height fixed) + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + // ---- Helpers ---- + + /** + * Truncate a string containing ANSI escape sequences to + * {@code maxVisible} visible characters. If truncation occurs, + * a RESET is appended to close any open styling. + */ + static String truncateAnsi(String s, int maxVisible) { + StringBuilder out = new StringBuilder(s.length()); + int visible = 0; + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\033') { + // Start of escape sequence — copy through without counting + out.append(c); + i++; + if (i < s.length()) { + char next = s.charAt(i); + if (next == '[') { + // CSI sequence: ESC [ ... + out.append(next); + i++; + while (i < s.length()) { + char cc = s.charAt(i); + out.append(cc); + i++; + if (Character.isLetter(cc)) { + break; + } + } + } else { + // Two-char escape (DECSC, DECRC, etc.) + out.append(next); + i++; + } + } + } else { + if (visible >= maxVisible) { + out.append(RESET); + break; + } + out.append(c); + visible++; + i++; + } + } + return out.toString(); + } + + private static String extractArtifactName(String resourceName) { + if (resourceName == null) { + return "unknown"; + } + int lastSlash = resourceName.lastIndexOf('/'); + return lastSlash >= 0 ? resourceName.substring(lastSlash + 1) : resourceName; + } + + private static String formatCompactDuration(Duration duration) { + long totalSeconds = duration.getSeconds(); + if (totalSeconds < 60) { + return totalSeconds + "s"; + } else if (totalSeconds < 3600) { + return (totalSeconds / 60) + "m " + (totalSeconds % 60) + "s"; + } else { + return (totalSeconds / 3600) + "h " + ((totalSeconds % 3600) / 60) + "m"; + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } else if (bytes < 1024 * 1024) { + return String.format("%.0f KB", bytes / 1024.0); + } else { + return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + } + } + + // ---- Inner state classes ---- + + private static class ProjectState { + final String projectId; + final Instant startTime; + volatile String currentMojo; + volatile boolean failed; + + ProjectState(String projectId, Instant startTime) { + this.projectId = projectId; + this.startTime = startTime; + } + } + + private static class TransferInfo { + final String artifactName; + final long totalBytes; + volatile long transferred; + volatile long lastUpdateBytes; + + TransferInfo(String artifactName, long transferred, long totalBytes) { + this.artifactName = artifactName; + this.transferred = transferred; + this.totalBytes = totalBytes; + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java new file mode 100644 index 000000000000..87213a088a2c --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichExecutionEventLogger.java @@ -0,0 +1,377 @@ +/* + * 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.cling.event; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; +import org.apache.maven.cling.utils.CLIReportingUtils; +import org.apache.maven.execution.AbstractExecutionListener; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.maven.cling.utils.CLIReportingUtils.formatDuration; + +/** + * Execution event logger for the rich terminal mode ({@code --console=rich}). + *

+ * In rich mode, the {@link RichBuildEventListener} manages a JLine status bar at + * the bottom of the terminal showing live reactor progress. This logger is deliberately + * minimal — it suppresses the verbose per-mojo and per-project banners that + * {@link ExecutionEventLogger} produces, since the status bar replaces them. + *

+ * What this logger DOES print (above the status bar): + *

    + *
  • One line per completed module (like {@link PlainExecutionEventLogger})
  • + *
  • Build result summary ({@code BUILD SUCCESS/FAILURE})
  • + *
  • Compact module statistics and timing
  • + *
  • Pointer to the structured build report
  • + *
+ *

+ * What the status bar shows (managed by {@link RichBuildEventListener}): + *

    + *
  • Currently building modules with active mojo name
  • + *
  • Reactor progress ({@code [n/total]}) and elapsed time
  • + *
  • Active downloads with progress
  • + *
+ * + * @since 4.1.0 + * @see RichBuildEventListener + * @see PlainExecutionEventLogger + */ +public class RichExecutionEventLogger extends AbstractExecutionListener { + + private static final int MAX_LOG_PREFIX_SIZE = 8; // "[ERROR] " + private static final int PROJECT_STATUS_SUFFIX_SIZE = 20; // "SUCCESS [ 0.000 s]" + private static final int MIN_TERMINAL_WIDTH = 60; + private static final int DEFAULT_TERMINAL_WIDTH = 80; + private static final int MAX_TERMINAL_WIDTH = 130; + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Logger logger; + private final RichBuildEventListener buildEventListener; + private int terminalWidth; + private int lineLength; + private int maxProjectNameLength; + private int totalProjects; + private volatile int currentVisitedProjectCount; + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener) { + this(messageBuilderFactory, buildEventListener, LoggerFactory.getLogger(RichExecutionEventLogger.class)); + } + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, RichBuildEventListener buildEventListener, Logger logger) { + this(messageBuilderFactory, buildEventListener, logger, -1); + } + + public RichExecutionEventLogger( + MessageBuilderFactory messageBuilderFactory, + RichBuildEventListener buildEventListener, + Logger logger, + int terminalWidth) { + this.logger = Objects.requireNonNull(logger, "logger cannot be null"); + this.messageBuilderFactory = messageBuilderFactory; + this.buildEventListener = Objects.requireNonNull(buildEventListener, "buildEventListener cannot be null"); + this.terminalWidth = terminalWidth; + } + + private void init() { + if (maxProjectNameLength == 0) { + if (terminalWidth < 0) { + terminalWidth = messageBuilderFactory.getTerminalWidth(); + } + terminalWidth = Math.min( + MAX_TERMINAL_WIDTH, + Math.max(terminalWidth <= 0 ? DEFAULT_TERMINAL_WIDTH : terminalWidth, MIN_TERMINAL_WIDTH)); + lineLength = terminalWidth - MAX_LOG_PREFIX_SIZE; + maxProjectNameLength = lineLength - PROJECT_STATUS_SUFFIX_SIZE; + } + } + + private MessageBuilder builder() { + return messageBuilderFactory.builder(); + } + + private static String chars(char c, int count) { + return String.valueOf(c).repeat(Math.max(0, count)); + } + + // ---- Session lifecycle ---- + + @Override + public void projectDiscoveryStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logger.info("Scanning for projects..."); + } + } + + @Override + public void sessionStarted(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + MavenSession session = event.getSession(); + List projects = session.getProjects(); + List allProjects = session.getAllProjects(); + + currentVisitedProjectCount = allProjects.size() - projects.size(); + totalProjects = allProjects.size(); + + // Initialize the status bar + buildEventListener.initReactor(session); + } + } + + @Override + public void sessionEnded(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + + // Tear down the status bar before printing summary + buildEventListener.tearDown(); + + // Write summary directly through the terminal writer (via buildEventListener.log) + // rather than logger.info() — all SLF4J output is routed through + // ProjectBuildLogAppender → projectLogMessage which filters INFO in rich mode. + buildEventListener.log(""); + logResult(event.getSession()); + logStats(event.getSession()); + } + } + + // ---- Module lifecycle ---- + // In rich mode, per-module success lines are suppressed — the status bar already + // shows ✓/●/○ indicators and the [n/total] counter for every module. + // Only FAILURE and SKIPPED scroll above the status bar since they're actionable. + + @Override + public void projectStarted(ExecutionEvent event) { + // Suppressed — the status bar shows active modules + } + + @Override + public void projectSucceeded(ExecutionEvent event) { + // Suppressed — the status bar checkmarks already indicate completion + } + + @Override + public void projectFailed(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "FAILURE"); + } + } + + @Override + public void projectSkipped(ExecutionEvent event) { + if (logger.isInfoEnabled()) { + init(); + logProjectLine(event, "SKIPPED"); + } + } + + // ---- Mojo lifecycle: suppressed (status bar shows active mojo) ---- + + @Override + public void mojoStarted(ExecutionEvent event) { + // Suppressed — the status bar shows the active mojo + } + + @Override + public void mojoSkipped(ExecutionEvent event) { + if (logger.isWarnEnabled()) { + logger.warn( + "Goal '{}' requires online mode for execution but Maven is currently offline, skipping", + event.getMojoExecution().getGoal()); + } + } + + @Override + public void forkStarted(ExecutionEvent event) { + // Suppressed in rich mode + } + + @Override + public void forkSucceeded(ExecutionEvent event) { + // Suppressed in rich mode + } + + // ---- Formatting helpers (reuses PlainExecutionEventLogger patterns) ---- + + private void logProjectLine(ExecutionEvent event, String status) { + MavenProject project = event.getProject(); + MavenSession session = event.getSession(); + MavenExecutionResult result = session.getResult(); + BuildSummary buildSummary = result.getBuildSummary(project); + + StringBuilder buffer = new StringBuilder(128); + + // Status icon + switch (status) { + case "SUCCESS": + buffer.append(" ✓ "); + break; + case "FAILURE": + buffer.append(" ✗ "); + break; + default: + buffer.append(" ○ "); + break; + } + + buffer.append(project.getName()); + buffer.append(' '); + + if (totalProjects > 1) { + int number; + synchronized (this) { + number = ++currentVisitedProjectCount; + } + String progress = "[" + number + "/" + totalProjects + "]"; + buffer.append(progress); + buffer.append(' '); + } + + // Pad with dots to align status + int effectiveMax = maxProjectNameLength - 3; // account for status icon + if (buffer.length() <= effectiveMax) { + while (buffer.length() < effectiveMax) { + buffer.append('.'); + } + buffer.append(' '); + } + + // Status with color + MessageBuilder mb = builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (buildSummary != null) { + mb.a(" ["); + String duration = formatDuration(buildSummary.getExecTime()); + int padSize = MAX_PADDED_BUILD_TIME_DURATION_LENGTH - duration.length(); + if (padSize > 0) { + mb.a(chars(' ', padSize)); + } + mb.a(duration); + mb.a(']'); + } + + buildEventListener.log(mb.toString()); + } + + private void logResult(MavenSession session) { + MessageBuilder buffer = builder(); + if (session.getResult().hasExceptions()) { + buffer.failure("BUILD FAILURE"); + } else { + buffer.success("BUILD SUCCESS"); + } + + int passed = 0; + int failed = 0; + int skipped = 0; + for (MavenProject project : session.getProjects()) { + BuildSummary summary = session.getResult().getBuildSummary(project); + if (summary instanceof BuildSuccess) { + passed++; + } else if (summary instanceof BuildFailure) { + failed++; + } else { + skipped++; + } + } + + buildEventListener.log(buffer.toString()); + + // Compact stats line + if (totalProjects > 1) { + StringBuilder stats = new StringBuilder(); + stats.append(totalProjects).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + buildEventListener.log(stats.toString()); + } + + // Warning/error summary — warnings are suppressed inline in rich mode, + // so the count and a command hint help the user find them. + int warnings = buildEventListener.getWarningCount(); + int errors = buildEventListener.getErrorCount(); + if (warnings > 0 || errors > 0) { + MessageBuilder diag = builder(); + diag.a("Diagnostics: "); + if (warnings > 0) { + diag.warning(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + if (warnings > 0 && errors > 0) { + diag.a(", "); + } + if (errors > 0) { + diag.failure(errors + " error" + (errors > 1 ? "s" : "")); + } + diag.a(" — run ").strong("mvnlog").a(" to see details"); + buildEventListener.log(diag.toString()); + } + } + + private void logStats(MavenSession session) { + Duration time = Duration.between(session.getRequest().getStartInstant(), MonotonicClock.now()); + String wallClock = session.getRequest().getDegreeOfConcurrency() > 1 ? " (Wall Clock)" : ""; + buildEventListener.log("Total time: " + formatDuration(time) + wallClock); + + // On failure, show Maven and Java version to help with bug reports (MNG-7372) + if (session.getResult().hasExceptions()) { + buildEventListener.log("Maven: " + CLIReportingUtils.showVersionMinimal()); + buildEventListener.log("Java: " + System.getProperty("java.version", "") + " (" + + System.getProperty("java.vendor", "") + ")"); + } + + buildEventListener.log("Full report: target/build-reports/build-report-latest.json"); + } +} 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 c417f24f40f0..d75f07b81aec 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 @@ -212,6 +212,26 @@ public Optional color() { return Optional.empty(); } + @Override + public Optional console() { + if (commandLine.hasOption(CLIManager.CONSOLE)) { + if (commandLine.getOptionValue(CLIManager.CONSOLE) != null) { + return Optional.of(commandLine.getOptionValue(CLIManager.CONSOLE)); + } else { + return Optional.of("auto"); + } + } + 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)) { @@ -315,6 +335,8 @@ protected static class CLIManager { public static final String LOG_FILE = "l"; 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"; @@ -327,6 +349,7 @@ protected static class CLIManager { public static final String UPGRADE = "up"; public static final String SHELL = "shell"; public static final String YJP = "yjp"; + public static final String LOG = "log"; // deprecated ones @Deprecated @@ -344,6 +367,7 @@ protected CLIManager() { prepareOptions(options); } + @SuppressWarnings("checkstyle:MethodLength") protected void prepareOptions(org.apache.commons.cli.Options options) { options.addOption(Option.builder(HELP) .longOpt("help") @@ -432,6 +456,23 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { .optionalArg(true) .desc("Defines the color mode of the output. Supported are 'auto', 'always', 'never'.") .get()); + options.addOption(Option.builder() + .longOpt(CONSOLE) + .hasArg() + .optionalArg(true) + .desc("Defines the console output mode. Supported are 'auto' (default)," + + " 'plain', 'rich', 'verbose', 'machine'." + + " In 'auto' mode, CI environments use 'plain'," + + " 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") @@ -458,6 +499,10 @@ protected void prepareOptions(org.apache.commons.cli.Options options) { .longOpt(YJP) .desc("Launch the JVM with Yourkit profiler (script option).") .get()); + options.addOption(Option.builder() + .longOpt(LOG) + .desc("Launch the Maven Build Log Viewer (script option).") + .get()); // Deprecated options.addOption(Option.builder(ALTERNATE_GLOBAL_SETTINGS) 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 2f9c367c4786..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 @@ -132,6 +132,16 @@ public Optional color() { return returnFirstPresentOrEmpty(Options::color); } + @Override + 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/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index 40d3bb4926a4..42f8758c14c1 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java @@ -155,6 +155,7 @@ protected int doInvoke(C context) throws Exception { pushUserProperties(context); setupGuiceClassLoading(context); configureLogging(context); + preliminaryInteractiveDetection(context); createTerminal(context); activateLogging(context); helpOrVersionAndMayExit(context); @@ -301,6 +302,30 @@ protected void configureLogging(C context) throws Exception { } } + /** + * Sets {@code context.interactive} based on CLI flags and CI detection before + * {@link #createTerminal(LookupContext)} runs. This is necessary because + * {@code createTerminal} caches the {@link BuildEventListener} (via + * {@link #determineBuildEventListener}), and the console-mode auto-detection + * in subclasses reads {@code context.interactive} to decide between rich/plain/verbose. + * + *

The full settings-based interactive-mode resolution still runs later in + * {@link #settings}, so this is a best-effort early pass using only CLI flags and + * CI environment detection — which is sufficient for the console-mode decision.

+ */ + protected void preliminaryInteractiveDetection(C context) { + if (context.options().forceInteractive().orElse(false)) { + context.interactive = true; + } else if (context.options().nonInteractive().orElse(false)) { + context.interactive = false; + } else if (context.invokerRequest.ciInfo().isPresent()) { + context.interactive = false; + } else { + // Default: assume interactive (settings may refine later) + context.interactive = true; + } + } + protected BuildEventListener determineBuildEventListener(C context) { if (context.buildEventListener == null) { context.buildEventListener = doDetermineBuildEventListener(context); 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 e6372ccfd818..3924977e56e3 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 @@ -48,6 +48,11 @@ import org.apache.maven.api.services.model.ModelProcessor; import org.apache.maven.api.toolchain.PersistedToolchains; import org.apache.maven.cling.event.ExecutionEventLogger; +import org.apache.maven.cling.event.MachineBuildEventListener; +import org.apache.maven.cling.event.MachineExecutionEventLogger; +import org.apache.maven.cling.event.PlainExecutionEventLogger; +import org.apache.maven.cling.event.RichBuildEventListener; +import org.apache.maven.cling.event.RichExecutionEventLogger; import org.apache.maven.cling.invoker.CliUtils; import org.apache.maven.cling.invoker.LookupContext; import org.apache.maven.cling.invoker.LookupInvoker; @@ -66,6 +71,7 @@ import org.apache.maven.execution.ProjectActivation; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; +import org.apache.maven.logging.BuildEventListener; import org.apache.maven.logging.LoggingExecutionListener; import org.apache.maven.logging.MavenTransferListener; import org.apache.maven.project.MavenProject; @@ -257,6 +263,14 @@ 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); + + // Diagnostic suppression: forward -Dmaven.diagnostic.suppress if set + // (already in user properties via -D, just ensure it's visible) + request.setTransferListener(determineTransferListener( context, context.options().noTransferProgress().orElse(false))); request.setExecutionListener(determineExecutionListener(context)); @@ -343,21 +357,102 @@ protected String determineGlobalChecksumPolicy(MavenContext context) { } protected ExecutionListener determineExecutionListener(MavenContext context) { - ExecutionListener listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + ExecutionListener listener; + String consoleMode = determineConsoleMode(context); + switch (consoleMode) { + case "machine": + BuildEventListener machineBel = determineBuildEventListener(context); + if (machineBel instanceof MachineBuildEventListener machineListener) { + listener = new MachineExecutionEventLogger(machineListener); + } else { + // Fallback if machine listener couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "rich": + BuildEventListener richBel = determineBuildEventListener(context); + if (richBel instanceof RichBuildEventListener richListener) { + listener = + new RichExecutionEventLogger(context.invokerRequest.messageBuilderFactory(), richListener); + } else { + // Fallback if status bar couldn't be created + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + } + break; + case "plain": + listener = new PlainExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + default: + listener = new ExecutionEventLogger(context.invokerRequest.messageBuilderFactory()); + break; + } if (context.eventSpyDispatcher != null) { listener = context.eventSpyDispatcher.chainListener(listener); } return new LoggingExecutionListener(listener, determineBuildEventListener(context)); } + @Override + protected BuildEventListener doDetermineBuildEventListener(MavenContext context) { + String consoleMode = determineConsoleMode(context); + if ("machine".equals(consoleMode)) { + return new MachineBuildEventListener(determineWriter(context)); + } + if ("rich".equals(consoleMode) && context.terminal != null) { + return new RichBuildEventListener(context.terminal, determineWriter(context)); + } + return super.doDetermineBuildEventListener(context); + } + + /** + * Resolves the effective console mode from the {@code --console} flag and CI/TTY detection. + *

+ * Resolution order: + *

    + *
  1. Explicit {@code --console=plain}, {@code --console=verbose}, {@code --console=rich}, + * or {@code --console=machine} — always honored
  2. + *
  3. {@code --console=auto} (or unset) — selects mode based on environment: + *
      + *
    • CI detected → "plain"
    • + *
    • Interactive TTY → "rich"
    • + *
    • Otherwise → "verbose"
    • + *
    + *
  4. + *
+ */ + String determineConsoleMode(MavenContext context) { + String consoleMode = context.options().console().orElse("auto"); + if ("plain".equalsIgnoreCase(consoleMode) + || "verbose".equalsIgnoreCase(consoleMode) + || "rich".equalsIgnoreCase(consoleMode) + || "machine".equalsIgnoreCase(consoleMode)) { + return consoleMode.toLowerCase(); + } + // "auto" mode: CI → plain, interactive TTY → rich, otherwise → verbose + if (context.invokerRequest.ciInfo().isPresent() + && !context.options().forceInteractive().orElse(false)) { + return "plain"; + } + if (context.interactive && context.terminal != null && !context.invokerRequest.embedded()) { + return "rich"; + } + return "verbose"; + } + protected TransferListener determineTransferListener(MavenContext context, boolean noTransferProgress) { boolean quiet = context.options().quiet().orElse(false); boolean logFile = context.options().logFile().isPresent(); boolean quietCI = context.invokerRequest.ciInfo().isPresent() && !context.options().forceInteractive().orElse(false); + String mode = determineConsoleMode(context); + boolean richMode = "rich".equals(mode); + boolean machineMode = "machine".equals(mode); TransferListener delegate; - if (quiet || noTransferProgress || quietCI) { + if (quiet || noTransferProgress || quietCI || richMode || machineMode) { + // In rich mode, transfer progress is shown in the JLine status bar. + // In machine mode, transfer events are emitted as JSON lines. + // In both cases, suppress the console transfer listener. delegate = new QuietMavenTransferListener(); } else if (context.interactive && !logFile) { if (context.simplexTransferListener == null) { diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java new file mode 100644 index 000000000000..3448c8c00fab --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java @@ -0,0 +1,565 @@ +/* + * 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.cling.invoker.mvnlog; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.maven.api.services.MessageBuilder; +import org.apache.maven.api.services.MessageBuilderFactory; + +/** + * Renders a build report (parsed from JSON into a {@code Map}) + * as formatted terminal output. + *

+ * Shared between the standalone {@code mvnlog} command and the {@code mvnsh} subcommand. + * + * @since 4.1.0 + */ +public class BuildReportRenderer { + + private static final int MAX_PADDED_BUILD_TIME_DURATION_LENGTH = 9; + + private final MessageBuilderFactory messageBuilderFactory; + private final Consumer output; + + public BuildReportRenderer(MessageBuilderFactory messageBuilderFactory, Consumer output) { + this.messageBuilderFactory = messageBuilderFactory; + this.output = output; + } + + /** + * Render the default summary view of a build report. + */ + @SuppressWarnings("unchecked") + public void renderSummary(Map report) { + renderHeader(report); + + // Module summary + List> modules = getList(report, "modules"); + if (!modules.isEmpty()) { + for (Map module : modules) { + renderModuleLine(module); + } + output.accept(""); + + // Stats line + int passed = 0, failed = 0, skipped = 0; + for (Map module : modules) { + String status = getString(module, "status"); + switch (status) { + case "SUCCESS": + passed++; + break; + case "FAILURE": + failed++; + break; + default: + skipped++; + break; + } + } + StringBuilder stats = new StringBuilder(); + stats.append(modules.size()).append(" modules"); + stats.append(" | ").append(passed).append(" passed"); + if (failed > 0) { + stats.append(" | ").append(failed).append(" failed"); + } + if (skipped > 0) { + stats.append(" | ").append(skipped).append(" skipped"); + } + output.accept(stats.toString()); + } + + // Problems — show structured warnings/errors so the user doesn't have to re-run the build + List> problems = getList(report, "problems"); + if (!problems.isEmpty()) { + long warnings = problems.stream() + .filter(d -> "WARNING".equals(getString(d, "severity"))) + .count(); + long errors = problems.stream() + .filter(d -> "ERROR".equals(getString(d, "severity"))) + .count(); + if (warnings > 0 || errors > 0) { + output.accept(""); + MessageBuilder header = messageBuilderFactory.builder(); + header.a("Problems: "); + if (errors > 0) { + header.failure(errors + " error" + (errors > 1 ? "s" : "")); + } + if (errors > 0 && warnings > 0) { + header.a(", "); + } + if (warnings > 0) { + header.warning(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + output.accept(header.toString()); + + // Show each problem with structured details + for (Map p : problems) { + renderProblemCompact(p); + } + } + } + + // Failures count + List> failures = getList(report, "failures"); + if (!failures.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .failure(failures.size() + " failure" + (failures.size() > 1 ? "s" : "")) + .toString()); + } + + output.accept(""); + String duration = getString(report, "duration"); + output.accept("Total time: " + (duration != null ? formatDuration(duration) : "?")); + } + + /** + * Render the detailed diagnostics view. + */ + @SuppressWarnings("unchecked") + public void renderDiagnostics(Map report) { + renderHeader(report); + + List> problems = getList(report, "problems"); + if (problems.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .success("No problems recorded.") + .toString()); + return; + } + + // Count by severity + long errors = problems.stream() + .filter(p -> "ERROR".equals(getString(p, "severity"))) + .count(); + long warnings = problems.stream() + .filter(p -> "WARNING".equals(getString(p, "severity"))) + .count(); + long infos = problems.size() - errors - warnings; + + MessageBuilder header = messageBuilderFactory.builder(); + header.strong("Problems (" + problems.size() + ")"); + header.a(": "); + List parts = new ArrayList<>(); + if (errors > 0) { + parts.add(errors + " error" + (errors > 1 ? "s" : "")); + } + if (warnings > 0) { + parts.add(warnings + " warning" + (warnings > 1 ? "s" : "")); + } + if (infos > 0) { + parts.add(infos + " info"); + } + header.a(String.join(", ", parts)); + output.accept(header.toString()); + output.accept(""); + + for (Map problem : problems) { + renderProblemDetailed(problem); + } + } + + /** + * Render the detailed failures view. + */ + @SuppressWarnings("unchecked") + public void renderFailures(Map report) { + renderHeader(report); + + List> failures = getList(report, "failures"); + if (failures.isEmpty()) { + output.accept(messageBuilderFactory + .builder() + .success("No failures recorded.") + .toString()); + return; + } + + output.accept("Failures (" + failures.size() + "):"); + for (Map failure : failures) { + output.accept(""); + String module = getString(failure, "module"); + String mojo = getString(failure, "mojo"); + MessageBuilder mb = messageBuilderFactory.builder(); + mb.failure(" [FAIL] ").a(module); + if (mojo != null && !mojo.isEmpty()) { + mb.a(" - ").a(mojo); + } + output.accept(mb.toString()); + + String message = getString(failure, "message"); + if (message != null) { + output.accept(" " + message); + } + + String stackTrace = getString(failure, "stackTrace"); + if (stackTrace != null && !stackTrace.isEmpty()) { + // Show first few lines of stack trace + String[] lines = stackTrace.split("\n"); + int limit = Math.min(lines.length, 10); + for (int i = 0; i < limit; i++) { + output.accept(" " + lines[i]); + } + if (lines.length > limit) { + output.accept(" ... " + (lines.length - limit) + " more lines"); + } + } + } + } + + /** + * Render the full per-mojo timing breakdown. + */ + @SuppressWarnings("unchecked") + public void renderFull(Map report) { + renderHeader(report); + + List> modules = getList(report, "modules"); + if (modules.isEmpty()) { + output.accept("No modules recorded."); + return; + } + + for (Map module : modules) { + String artifactId = getString(module, "artifactId"); + String status = getString(module, "status"); + String duration = getString(module, "duration"); + + MessageBuilder mb = messageBuilderFactory.builder(); + mb.strong("Module: " + artifactId); + mb.a(" (").a(duration != null ? formatDuration(duration) : "?").a(") "); + if ("SUCCESS".equals(status)) { + mb.success(status); + } else if ("FAILURE".equals(status)) { + mb.failure(status); + } else { + mb.warning(status); + } + output.accept(mb.toString()); + + List> mojos = getList(module, "mojos"); + for (Map mojo : mojos) { + String goal = getString(mojo, "goal"); + String mojoArtifactId = getString(mojo, "artifactId"); + String mojoDuration = getString(mojo, "duration"); + String mojoStatus = getString(mojo, "status"); + String executionId = getString(mojo, "executionId"); + + StringBuilder line = new StringBuilder(" "); + String prefix = mojoArtifactId != null + ? mojoArtifactId.replace("maven-", "").replace("-plugin", "") + : ""; + line.append(prefix); + if (goal != null) { + line.append(":").append(goal); + } + if (executionId != null && !executionId.isEmpty()) { + line.append(" (").append(executionId).append(")"); + } + + // Pad with dots + int padTo = 50; + while (line.length() < padTo) { + line.append('.'); + } + line.append(' '); + + MessageBuilder mojoMb = messageBuilderFactory.builder(); + mojoMb.a(line); + mojoMb.a(mojoDuration != null ? formatDuration(mojoDuration) : "?"); + if ("FAILURE".equals(mojoStatus)) { + mojoMb.a(" ").failure("FAILED"); + } + output.accept(mojoMb.toString()); + } + output.accept(""); + } + } + + /** + * List all available build report files in the given directory. + */ + public void listReports(Path buildReportsDir) throws IOException { + if (!Files.isDirectory(buildReportsDir)) { + output.accept("No build reports directory found at: " + buildReportsDir); + return; + } + + List reports = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(buildReportsDir, "build-report-*.json")) { + for (Path entry : stream) { + if (Files.isRegularFile(entry) + && !entry.getFileName().toString().equals("build-report-latest.json")) { + reports.add(entry); + } + } + } + + if (reports.isEmpty()) { + output.accept("No build reports found in: " + buildReportsDir); + return; + } + + reports.sort(Comparator.comparing(Path::getFileName).reversed()); + + output.accept(messageBuilderFactory + .builder() + .strong("Available build reports:") + .toString()); + output.accept(""); + + Path latestLink = buildReportsDir.resolve("build-report-latest.json"); + Path latestTarget = null; + if (Files.isSymbolicLink(latestLink)) { + try { + latestTarget = Files.readSymbolicLink(latestLink).getFileName(); + } catch (IOException e) { + // ignore + } + } + + for (Path report : reports) { + String name = report.getFileName().toString(); + StringBuilder line = new StringBuilder(" "); + line.append(name); + if (latestTarget != null && name.equals(latestTarget.toString())) { + line.append(" <- latest"); + } + output.accept(line.toString()); + } + } + + // ---- Problem rendering ---- + + /** + * Compact problem rendering for the default summary view. + * Shows severity, message, source, and suggestion on two lines. + */ + private void renderProblemCompact(Map problem) { + String severity = getString(problem, "severity"); + String message = getString(problem, "message"); + String source = getString(problem, "source"); + String suggestion = getString(problem, "suggestion"); + + MessageBuilder mb = messageBuilderFactory.builder(); + if ("ERROR".equals(severity)) { + mb.failure(" [ERROR] "); + } else if ("WARNING".equals(severity)) { + mb.warning(" [WARN] "); + } else { + mb.a(" [INFO] "); + } + mb.a(message); + if (source != null && !source.isEmpty()) { + mb.a(" ").a(messageBuilderFactory.builder().strong(source).toString()); + } + output.accept(mb.toString()); + + if (suggestion != null && !suggestion.isEmpty()) { + output.accept(" suggestion: " + suggestion); + } + } + + /** + * Detailed problem rendering for the {@code --diagnostics} view. + * Shows all available fields: key, severity, message, source, location, + * suggestion, and documentation URL. + */ + private void renderProblemDetailed(Map problem) { + String severity = getString(problem, "severity"); + String message = getString(problem, "message"); + String key = getString(problem, "key"); + String source = getString(problem, "source"); + String suggestion = getString(problem, "suggestion"); + String docUrl = getString(problem, "documentationUrl"); + + // Severity label + message + MessageBuilder mb = messageBuilderFactory.builder(); + if ("ERROR".equals(severity)) { + mb.failure(" [ERROR] "); + } else if ("WARNING".equals(severity)) { + mb.warning(" [WARN] "); + } else { + mb.a(" [INFO] "); + } + mb.a(message); + output.accept(mb.toString()); + + // Key (diagnostic identifier for suppression) + if (key != null && !key.isEmpty()) { + output.accept(" key: " + key); + } + + // Source + location + if (source != null && !source.isEmpty()) { + StringBuilder loc = new StringBuilder(" source: "); + loc.append(source); + Number line = getNumber(problem, "line"); + if (line != null && line.intValue() > 0) { + loc.append(":").append(line.intValue()); + Number column = getNumber(problem, "column"); + if (column != null && column.intValue() > 0) { + loc.append(":").append(column.intValue()); + } + } + output.accept(loc.toString()); + } + + // Suggestion + if (suggestion != null && !suggestion.isEmpty()) { + MessageBuilder sugMb = messageBuilderFactory.builder(); + sugMb.a(" suggestion: ").success(suggestion); + output.accept(sugMb.toString()); + } + + // Documentation URL + if (docUrl != null && !docUrl.isEmpty()) { + output.accept(" docs: " + docUrl); + } + + output.accept(""); + } + + // ---- Internal helpers ---- + + private void renderHeader(Map report) { + String mavenVersion = getString(report, "mavenVersion"); + String startTime = getString(report, "startTime"); + + MessageBuilder header = messageBuilderFactory.builder(); + header.strong("Build Report"); + if (mavenVersion != null) { + header.a(" — Maven ").a(mavenVersion); + } + if (startTime != null) { + header.a(" — ").a(startTime); + } + output.accept(header.toString()); + + // Result line + String status = getString(report, "status"); + MessageBuilder result = messageBuilderFactory.builder(); + if ("FAILURE".equals(status)) { + result.failure("BUILD FAILURE"); + } else { + result.success("BUILD SUCCESS"); + } + output.accept(result.toString()); + output.accept(""); + } + + private void renderModuleLine(Map module) { + String artifactId = getString(module, "artifactId"); + String status = getString(module, "status"); + String duration = getString(module, "duration"); + + StringBuilder buffer = new StringBuilder(128); + + // Status marker + buffer.append(' '); + + buffer.append(artifactId); + buffer.append(' '); + + // Pad with dots + int maxLen = 60; + if (buffer.length() <= maxLen) { + while (buffer.length() < maxLen) { + buffer.append('.'); + } + buffer.append(' '); + } + + MessageBuilder mb = messageBuilderFactory.builder(); + mb.a(buffer); + switch (status) { + case "SUCCESS": + mb.success(status); + break; + case "FAILURE": + mb.failure(status); + break; + default: + mb.warning(status); + break; + } + + // Duration + if (duration != null) { + mb.a(" [").a(formatDuration(duration)).a("]"); + } + + output.accept(mb.toString()); + } + + /** + * Format an ISO-8601 duration string (e.g. "PT2.1S") into a human-readable form. + */ + static String formatDuration(String isoDuration) { + try { + Duration d = Duration.parse(isoDuration); + long totalSeconds = d.getSeconds(); + int millis = d.getNano() / 1_000_000; + + if (totalSeconds >= 60) { + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + return String.format("%d:%02d min", minutes, seconds); + } else { + return String.format("%d.%03d s", totalSeconds, millis); + } + } catch (Exception e) { + return isoDuration; // fallback to raw string + } + } + + @SuppressWarnings("unchecked") + private static List> getList(Map map, String key) { + Object value = map.get(key); + if (value instanceof List) { + return (List>) value; + } + return List.of(); + } + + private static String getString(Map map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } + + private static Number getNumber(Map map, String key) { + Object value = map.get(key); + if (value instanceof Number) { + return (Number) value; + } + return null; + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java new file mode 100644 index 000000000000..5f551644d35a --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java @@ -0,0 +1,147 @@ +/* + * 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.cling.invoker.mvnlog; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.ParseException; +import org.apache.maven.api.cli.Options; +import org.apache.maven.api.cli.ParserRequest; +import org.apache.maven.api.cli.mvnlog.LogOptions; +import org.apache.maven.cling.invoker.CommonsCliOptions; + +/** + * Implementation of {@link LogOptions} using Commons CLI. + */ +public class CommonsCliLogOptions extends CommonsCliOptions implements LogOptions { + + public static CommonsCliLogOptions parse(String[] args) throws ParseException { + CLIManager cliManager = new CLIManager(); + return new CommonsCliLogOptions(Options.SOURCE_CLI, cliManager, cliManager.parse(args)); + } + + protected CommonsCliLogOptions(String source, CLIManager cliManager, CommandLine commandLine) { + super(source, cliManager, commandLine); + } + + @Override + public Optional diagnostics() { + if (commandLine.hasOption(CLIManager.DIAGNOSTICS)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional failures() { + if (commandLine.hasOption(CLIManager.FAILURES)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional full() { + if (commandLine.hasOption(CLIManager.FULL)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional list() { + if (commandLine.hasOption(CLIManager.LIST)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional json() { + if (commandLine.hasOption(CLIManager.JSON)) { + return Optional.of(Boolean.TRUE); + } + return Optional.empty(); + } + + @Override + public Optional reportFile() { + List args = commandLine.getArgList(); + if (!args.isEmpty()) { + return Optional.of(args.get(0)); + } + return Optional.empty(); + } + + @Override + public void displayHelp(ParserRequest request, Consumer printStream) { + super.displayHelp(request, printStream); + printStream.accept(""); + printStream.accept("Usage: mvnlog [options] [report-file]"); + printStream.accept(""); + printStream.accept("Displays a formatted summary of the last Maven build report."); + printStream.accept("If no report-file is specified, reads target/build-reports/build-report-latest.json."); + printStream.accept(""); + printStream.accept("Use --json to output the raw JSON report (e.g. mvnlog --json | jq '.modules')."); + printStream.accept(""); + } + + @Override + protected CommonsCliLogOptions copy( + String source, CommonsCliOptions.CLIManager cliManager, CommandLine commandLine) { + return new CommonsCliLogOptions(source, (CLIManager) cliManager, commandLine); + } + + protected static class CLIManager extends CommonsCliOptions.CLIManager { + public static final String DIAGNOSTICS = "d"; + public static final String FAILURES = "f"; + public static final String FULL = "F"; + public static final String LIST = "L"; + public static final String JSON = "j"; + + @Override + protected void prepareOptions(org.apache.commons.cli.Options options) { + super.prepareOptions(options); + options.addOption(Option.builder(DIAGNOSTICS) + .longOpt("diagnostics") + .desc("Show detailed warnings and errors from the build") + .get()); + options.addOption(Option.builder(FAILURES) + .longOpt("failures") + .desc("Show detailed failure information including stack traces") + .get()); + options.addOption(Option.builder(FULL) + .longOpt("full") + .desc("Show full per-mojo timing breakdown") + .get()); + options.addOption(Option.builder(LIST) + .longOpt("list") + .desc("List all available build reports") + .get()); + options.addOption(Option.builder(JSON) + .longOpt("json") + .desc("Output the raw JSON build report (useful for piping to jq)") + .get()); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java new file mode 100644 index 000000000000..cf6193e19a67 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java @@ -0,0 +1,38 @@ +/* + * 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.cling.invoker.mvnlog; + +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.mvnlog.LogOptions; +import org.apache.maven.cling.invoker.LookupContext; + +/** + * Context for the {@code mvnlog} build log viewer. + */ +@SuppressWarnings("VisibilityModifier") +public class LogContext extends LookupContext { + public LogContext(InvokerRequest invokerRequest, LogOptions logOptions) { + super(invokerRequest, true, logOptions); + } + + @Override + public LogOptions options() { + return (LogOptions) super.options(); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java new file mode 100644 index 000000000000..e7549944f06f --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java @@ -0,0 +1,169 @@ +/* + * 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.cling.invoker.mvnlog; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.cli.InvokerRequest; +import org.apache.maven.api.cli.mvnlog.LogOptions; +import org.apache.maven.api.services.Lookup; +import org.apache.maven.cling.invoker.LookupContext; +import org.apache.maven.cling.invoker.LookupInvoker; + +/** + * Invoker for the {@code mvnlog} build log viewer. + *

+ * This is a lightweight invoker that does NOT set up the DI container, + * Maven settings, or any build infrastructure. It only needs a terminal + * (for colors and width detection) and the parsed CLI options. + * + * @since 4.1.0 + */ +public class LogInvoker extends LookupInvoker { + + public static final int OK = 0; + public static final int ERROR = 1; + public static final int BAD_INPUT = 2; + + private static final String DEFAULT_REPORT_DIR = "target/build-reports"; + private static final String DEFAULT_REPORT_FILE = "build-report-latest.json"; + + public LogInvoker(Lookup protoLookup, @Nullable Consumer contextConsumer) { + super(protoLookup, contextConsumer); + } + + @Override + protected LogContext createContext(InvokerRequest invokerRequest) { + return new LogContext( + invokerRequest, (LogOptions) invokerRequest.options().orElse(null)); + } + + /** + * Override doInvoke to skip the heavyweight DI container, settings, + * and repository setup that mvnlog does not need. + */ + @Override + protected int doInvoke(LogContext context) throws Exception { + validate(context); + pushCoreProperties(context); + configureLogging(context); + createTerminal(context); + activateLogging(context); + helpOrVersionAndMayExit(context); + return execute(context); + } + + @Override + protected void lookup(LogContext context) throws Exception { + // No DI container needed for log viewing + } + + @Override + protected int execute(LogContext context) throws Exception { + LogOptions options = context.options(); + Consumer output = line -> { + if (context.writer != null) { + context.writer.accept(line); + } else { + context.logger.info(line); + } + }; + + BuildReportRenderer renderer = new BuildReportRenderer(context.invokerRequest.messageBuilderFactory(), output); + + // Handle --list: show available reports + if (options != null && options.list().orElse(false)) { + Path reportDir = resolveReportDir(context); + renderer.listReports(reportDir); + return OK; + } + + // Resolve and read the report file + Path reportFile = resolveReportFile(context); + if (!Files.isRegularFile(reportFile)) { + context.logger.error("Build report not found: " + reportFile); + context.logger.error("Run a Maven build first, then use mvnlog to view the report."); + return BAD_INPUT; + } + + String json; + try { + json = Files.readString(reportFile); + } catch (IOException e) { + context.logger.error("Failed to read report file: " + e.getMessage()); + return ERROR; + } + + // Handle --json: output the raw JSON and exit + if (options != null && options.json().orElse(false)) { + output.accept(json); + return OK; + } + + Map report; + try { + report = SimpleJsonReader.parse(json); + } catch (IllegalArgumentException e) { + context.logger.error("Failed to parse report file: " + e.getMessage()); + return ERROR; + } + + // Render based on flags + if (options != null && options.full().orElse(false)) { + renderer.renderFull(report); + } else if (options != null && options.failures().orElse(false)) { + renderer.renderFailures(report); + } else if (options != null && options.diagnostics().orElse(false)) { + renderer.renderDiagnostics(report); + } else { + renderer.renderSummary(report); + } + + return OK; + } + + private Path resolveReportDir(LogContext context) { + Path cwd = context.invokerRequest.cwd(); + return cwd.resolve(DEFAULT_REPORT_DIR); + } + + private Path resolveReportFile(LogContext context) { + LogOptions options = context.options(); + + // Explicit report file path from command line + if (options != null) { + String reportFile = options.reportFile().orElse(null); + if (reportFile != null) { + Path path = Path.of(reportFile); + if (path.isAbsolute()) { + return path; + } + return context.invokerRequest.cwd().resolve(path); + } + } + + // Default: target/build-reports/build-report-latest.json + return resolveReportDir(context).resolve(DEFAULT_REPORT_FILE); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java new file mode 100644 index 000000000000..a26219ec8a01 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.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.cling.invoker.mvnlog; + +import org.apache.commons.cli.ParseException; +import org.apache.maven.api.cli.Options; +import org.apache.maven.cling.invoker.BaseParser; + +/** + * Parser for the {@code mvnlog} command-line arguments. + */ +public class LogParser extends BaseParser { + @Override + protected Options parseCliOptions(LocalContext context) { + try { + return CommonsCliLogOptions.parse(context.parserRequest.args().toArray(new String[0])); + } catch (ParseException e) { + throw new IllegalArgumentException("Failed to parse command line options: " + e.getMessage(), e); + } + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java new file mode 100644 index 000000000000..818ad1cad136 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReader.java @@ -0,0 +1,276 @@ +/* + * 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.cling.invoker.mvnlog; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal recursive-descent JSON parser that reads a JSON string into + * {@code Map} / {@code List} / {@code String} / {@code Number} / {@code Boolean} / null. + *

+ * No external dependencies. Companion to {@code BuildReportJsonWriter} which writes + * JSON without any library; this reader follows the same zero-dependency principle. + *

+ * This parser handles the full JSON spec (objects, arrays, strings with escapes, + * numbers, booleans, null) and is sufficient for reading Maven build report files. + */ +final class SimpleJsonReader { + + private final String json; + private int pos; + + private SimpleJsonReader(String json) { + this.json = json; + this.pos = 0; + } + + /** + * Parse a JSON string into a nested structure of Maps, Lists, and primitives. + * + * @param json the JSON string to parse + * @return the parsed value (typically a {@code Map} for a JSON object) + * @throws IllegalArgumentException if the JSON is malformed + */ + @SuppressWarnings("unchecked") + static Map parse(String json) { + SimpleJsonReader reader = new SimpleJsonReader(json.strip()); + Object result = reader.parseValue(); + if (!(result instanceof Map)) { + throw new IllegalArgumentException("Expected JSON object at root"); + } + return (Map) result; + } + + private Object parseValue() { + skipWhitespace(); + if (pos >= json.length()) { + throw error("Unexpected end of input"); + } + char c = json.charAt(pos); + if (c == '{') { + return parseObject(); + } + if (c == '[') { + return parseArray(); + } + if (c == '"') { + return parseString(); + } + if (c == 't' || c == 'f') { + return parseBoolean(); + } + if (c == 'n') { + return parseNull(); + } + if (c == '-' || (c >= '0' && c <= '9')) { + return parseNumber(); + } + throw error("Unexpected character: " + c); + } + + private Map parseObject() { + expect('{'); + Map map = new LinkedHashMap<>(); + skipWhitespace(); + if (pos < json.length() && json.charAt(pos) == '}') { + pos++; + return map; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + Object value = parseValue(); + map.put(key, value); + skipWhitespace(); + if (pos < json.length() && json.charAt(pos) == ',') { + pos++; + } else { + break; + } + } + skipWhitespace(); + expect('}'); + return map; + } + + private List parseArray() { + expect('['); + List list = new ArrayList<>(); + skipWhitespace(); + if (pos < json.length() && json.charAt(pos) == ']') { + pos++; + return list; + } + while (true) { + list.add(parseValue()); + skipWhitespace(); + if (pos < json.length() && json.charAt(pos) == ',') { + pos++; + } else { + break; + } + } + skipWhitespace(); + expect(']'); + return list; + } + + private String parseString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (pos < json.length()) { + char c = json.charAt(pos++); + if (c == '"') { + return sb.toString(); + } + if (c == '\\') { + if (pos >= json.length()) { + throw error("Unexpected end of string escape"); + } + char escaped = json.charAt(pos++); + switch (escaped) { + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'u': + if (pos + 4 > json.length()) { + throw error("Incomplete unicode escape"); + } + String hex = json.substring(pos, pos + 4); + sb.append((char) Integer.parseInt(hex, 16)); + pos += 4; + break; + default: + sb.append(escaped); + } + } else { + sb.append(c); + } + } + throw error("Unterminated string"); + } + + private Number parseNumber() { + int start = pos; + if (pos < json.length() && json.charAt(pos) == '-') { + pos++; + } + while (pos < json.length() && json.charAt(pos) >= '0' && json.charAt(pos) <= '9') { + pos++; + } + boolean isFloat = false; + if (pos < json.length() && json.charAt(pos) == '.') { + isFloat = true; + pos++; + while (pos < json.length() && json.charAt(pos) >= '0' && json.charAt(pos) <= '9') { + pos++; + } + } + if (pos < json.length() && (json.charAt(pos) == 'e' || json.charAt(pos) == 'E')) { + isFloat = true; + pos++; + if (pos < json.length() && (json.charAt(pos) == '+' || json.charAt(pos) == '-')) { + pos++; + } + while (pos < json.length() && json.charAt(pos) >= '0' && json.charAt(pos) <= '9') { + pos++; + } + } + String numStr = json.substring(start, pos); + if (isFloat) { + return Double.parseDouble(numStr); + } + long value = Long.parseLong(numStr); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return (int) value; + } + return value; + } + + private Boolean parseBoolean() { + if (json.startsWith("true", pos)) { + pos += 4; + return Boolean.TRUE; + } + if (json.startsWith("false", pos)) { + pos += 5; + return Boolean.FALSE; + } + throw error("Expected boolean"); + } + + private Object parseNull() { + if (json.startsWith("null", pos)) { + pos += 4; + return null; + } + throw error("Expected null"); + } + + private void skipWhitespace() { + while (pos < json.length()) { + char c = json.charAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + break; + } + } + } + + private void expect(char expected) { + if (pos >= json.length() || json.charAt(pos) != expected) { + throw error("Expected '" + expected + "'"); + } + pos++; + } + + private IllegalArgumentException error(String message) { + int contextStart = Math.max(0, pos - 20); + int contextEnd = Math.min(json.length(), pos + 20); + String context = json.substring(contextStart, contextEnd); + return new IllegalArgumentException(message + " at position " + pos + " near: ..." + context + "..."); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java index a221e1b5b152..18ee72f9d0f9 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java @@ -43,6 +43,8 @@ import org.apache.maven.cling.invoker.mvnenc.EncryptInvoker; import org.apache.maven.cling.invoker.mvnenc.EncryptParser; import org.apache.maven.cling.invoker.mvnenc.Goal; +import org.apache.maven.cling.invoker.mvnlog.LogInvoker; +import org.apache.maven.cling.invoker.mvnlog.LogParser; import org.apache.maven.cling.invoker.mvnsh.ShellCommandRegistryFactory; import org.apache.maven.cling.invoker.mvnup.UpgradeInvoker; import org.apache.maven.cling.invoker.mvnup.UpgradeParser; @@ -75,6 +77,8 @@ private static class BuiltinShellCommandRegistry extends JlineCommandRegistry im private final EncryptParser encryptParser; private final UpgradeInvoker shellUpgradeInvoker; private final UpgradeParser upgradeParser; + private final LogInvoker shellLogInvoker; + private final LogParser logParser; private BuiltinShellCommandRegistry(LookupContext shellContext) { this.shellContext = requireNonNull(shellContext, "shellContext"); @@ -84,12 +88,15 @@ private BuiltinShellCommandRegistry(LookupContext shellContext) { this.encryptParser = new EncryptParser(); this.shellUpgradeInvoker = new UpgradeInvoker(shellContext.invokerRequest.lookup(), contextCopier()); this.upgradeParser = new UpgradeParser(); + this.shellLogInvoker = new LogInvoker(shellContext.invokerRequest.lookup(), contextCopier()); + this.logParser = new LogParser(); Map commandExecute = new HashMap<>(); commandExecute.put("!", new CommandMethods(this::shell, this::defaultCompleter)); commandExecute.put("cd", new CommandMethods(this::cd, this::cdCompleter)); commandExecute.put("pwd", new CommandMethods(this::pwd, this::defaultCompleter)); commandExecute.put("mvn", new CommandMethods(this::mvn, this::mvnCompleter)); commandExecute.put("mvnenc", new CommandMethods(this::mvnenc, this::mvnencCompleter)); + commandExecute.put("mvnlog", new CommandMethods(this::mvnlog, this::mvnlogCompleter)); commandExecute.put("mvnup", new CommandMethods(this::mvnup, this::mvnupCompleter)); registerCommands(commandExecute); } @@ -121,6 +128,7 @@ private Consumer contextCopier() { public void close() throws Exception { shellMavenInvoker.close(); shellEncryptInvoker.close(); + shellLogInvoker.close(); shellUpgradeInvoker.close(); } @@ -251,6 +259,24 @@ private List mvnencCompleter(String name) { shellContext.lookup.lookupMap(Goal.class).keySet()))); } + private void mvnlog(CommandInput input) { + try { + shellLogInvoker.invoke(logParser.parseInvocation( + ParserRequest.mvnlog(input.args(), shellContext.invokerRequest.messageBuilderFactory()) + .cwd(shellContext.cwd.get()) + .build())); + } catch (InvokerException.ExitException e) { + shellContext.logger.error("mvnlog command exited with exit code " + e.getExitCode()); + } catch (Exception e) { + saveException(e); + } + } + + private List mvnlogCompleter(String name) { + return List.of( + new ArgumentCompleter(new StringsCompleter("--diagnostics", "--failures", "--full", "--list"))); + } + private void mvnup(CommandInput input) { try { shellUpgradeInvoker.invoke(upgradeParser.parseInvocation( diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java new file mode 100644 index 000000000000..67bbe36ff3bc --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineBuildEventListenerTest.java @@ -0,0 +1,239 @@ +/* + * 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.cling.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineBuildEventListener}. + */ +class MachineBuildEventListenerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener listener; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + listener = new MachineBuildEventListener(capturedOutput::add); + } + + @org.junit.jupiter.api.AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testLogEmitsJsonLine() { + listener.log("Hello world"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.startsWith("{"), "Should be JSON object"); + assertTrue(json.endsWith("}"), "Should be JSON object"); + assertTrue(json.contains("\"event\":\"log\""), "Should have event type"); + assertTrue(json.contains("\"message\":\"Hello world\""), "Should have message"); + assertTrue(json.contains("\"timestamp\":\""), "Should have timestamp"); + } + + @Test + void testProjectLogMessageIncludesModule() { + listener.projectLogMessage( + "my-core", + new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files")); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"log\"")); + assertTrue(json.contains("\"level\":\"INFO\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"message\":\"Compiling 42 source files\"")); + } + + @Test + void testExecutionFailureEmitsJsonLine() { + listener.executionFailure("my-core", true, "Compilation failed"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"execution.failure\"")); + assertTrue(json.contains("\"module\":\"my-core\"")); + assertTrue(json.contains("\"halted\":true")); + assertTrue(json.contains("\"error\":\"Compilation failed\"")); + } + + @Test + void testTransferStartedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(event.getResource()).thenReturn(resource); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.started\"")); + assertTrue(json.contains("\"artifact\":\"core-4.1.0.jar\"")); + assertTrue(json.contains("\"size\":524288")); + assertTrue(json.contains("\"module\":\"my-core\"")); + } + + @Test + void testTransferProgressedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.progressed\"")); + assertTrue(json.contains("\"transferred\":262144")); + assertTrue(json.contains("\"total\":524288")); + } + + @Test + void testTransferCompletedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(event.getResource()).thenReturn(resource); + when(event.getTransferredBytes()).thenReturn(524288L); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.completed\"")); + assertTrue(json.contains("\"transferred\":524288")); + } + + @Test + void testTransferFailedEmitsJsonLine() { + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent event = mock(TransferEvent.class); + when(event.getType()).thenReturn(TransferEvent.EventType.FAILED); + when(event.getResource()).thenReturn(resource); + when(event.getException()).thenReturn(new RuntimeException("Connection timed out")); + + listener.transfer("my-core", event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"transfer.failed\"")); + assertTrue(json.contains("\"error\":\"Connection timed out\"")); + } + + @Test + void testJsonEscapingInMessages() { + listener.log("Message with \"quotes\" and \\backslash and\nnewline"); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + // Verify the JSON is properly escaped + assertTrue(json.contains("\\\"quotes\\\""), "Quotes should be escaped"); + assertTrue(json.contains("\\\\backslash"), "Backslash should be escaped"); + assertTrue(json.contains("\\n"), "Newline should be escaped"); + // Verify it's parseable as a single line (no raw newlines) + assertFalse(json.contains("\n"), "JSON line should not contain raw newlines"); + } + + @Test + void testEmitEventIsSynchronized() throws Exception { + // Verify that concurrent calls to emitEvent don't interleave + List output = new ArrayList<>(); + MachineBuildEventListener concurrentListener = new MachineBuildEventListener(output::add); + + Thread t1 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread1-" + i); + } + }); + Thread t2 = new Thread(() -> { + for (int i = 0; i < 100; i++) { + concurrentListener.log("Thread2-" + i); + } + }); + + t1.start(); + t2.start(); + t1.join(); + t2.join(); + + assertEquals(200, output.size(), "All events should be emitted"); + // Each line should be a complete JSON object + for (String line : output) { + assertTrue(line.startsWith("{") && line.endsWith("}"), "Each line should be a complete JSON object"); + } + } + + @Test + void testNoOpMethods() throws Exception { + // These should not produce any output (handled by MachineExecutionEventLogger) + listener.sessionStarted(null); + listener.projectStarted("my-core"); + listener.projectFinished("my-core"); + listener.mojoStarted(null); + listener.finish(0); + listener.fail(new RuntimeException("error")); + + assertEquals(0, capturedOutput.size(), "No-op methods should not produce output"); + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java new file mode 100644 index 000000000000..825f81aa3af0 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/MachineExecutionEventLoggerTest.java @@ -0,0 +1,445 @@ +/* + * 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.cling.event; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link MachineExecutionEventLogger}. + */ +class MachineExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private List capturedOutput; + private MachineBuildEventListener machineBel; + private MachineExecutionEventLogger logger; + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + capturedOutput = new ArrayList<>(); + machineBel = new MachineBuildEventListener(capturedOutput::add); + logger = new MachineExecutionEventLogger(machineBel); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testSessionStartedEmitsBuildStarted() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("clean", "install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getSession()).thenReturn(session); + + logger.sessionStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.started\"")); + assertTrue(json.contains("\"projectCount\":2")); + assertTrue(json.contains("\"goals\":\"clean install\"")); + } + + @Test + void testSessionEndedEmitsBuildFinished() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"build.finished\"")); + assertTrue(json.contains("\"status\":\"SUCCESS\"")); + assertTrue(json.contains("\"total\":2")); + assertTrue(json.contains("\"passed\":2")); + assertTrue(json.contains("\"failed\":0")); + assertTrue(json.contains("\"skipped\":0")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Compile error"))); + result.addException(new Exception("Compile error")); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + logger.sessionStarted(sessionEvent); + capturedOutput.clear(); + + logger.sessionEnded(sessionEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"status\":\"FAILURE\"")); + assertTrue(json.contains("\"passed\":1")); + assertTrue(json.contains("\"failed\":1")); + assertTrue(json.contains("\"skipped\":1")); + } + + @Test + void testProjectStartedEmitsModuleStarted() { + MavenProject project = createProject("Maven Core", "maven-core"); + setupSessionForProjectEvents(project); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + + logger.projectStarted(event); + + // build.started + module.started + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.started\"")); + assertTrue(json.contains("\"module\":\"Maven Core\"")); + assertTrue(json.contains("\"groupId\":\"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\":\"maven-core\"")); + assertTrue(json.contains("\"version\":\"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"index\":1")); + assertTrue(json.contains("\"total\":1")); + } + + @Test + void testProjectSucceededEmitsModuleSucceeded() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 2100)); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + + logger.projectSucceeded(event); + + // build.started + module.succeeded + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.succeeded\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"duration\":2.1")); + } + + @Test + void testProjectFailedEmitsModuleFailed() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + when(session.getResult()).thenReturn(result); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + when(event.getException()).thenReturn(new Exception("Compile error")); + + logger.projectFailed(event); + + assertEquals(2, capturedOutput.size()); + String json = capturedOutput.get(1); + assertTrue(json.contains("\"event\":\"module.failed\"")); + assertTrue(json.contains("\"duration\":5.0")); + assertTrue(json.contains("\"error\":\"Compile error\"")); + } + + @Test + void testMojoStartedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.started\"")); + assertTrue(json.contains("\"module\":\"Core\"")); + assertTrue(json.contains("\"plugin\":\"maven-compiler-plugin\"")); + assertTrue(json.contains("\"goal\":\"compile\"")); + assertTrue(json.contains("\"phase\":\"compile\"")); + assertTrue(json.contains("\"executionId\":\"default-compile\"")); + } + + @Test + void testMojoSucceededIncludesDuration() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoSucceeded(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.succeeded\"")); + assertTrue(json.contains("\"duration\":")); + } + + @Test + void testMojoFailedIncludesError() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent startEvent = mock(ExecutionEvent.class); + when(startEvent.getProject()).thenReturn(project); + when(startEvent.getMojoExecution()).thenReturn(mojo); + + ExecutionEvent endEvent = mock(ExecutionEvent.class); + when(endEvent.getProject()).thenReturn(project); + when(endEvent.getMojoExecution()).thenReturn(mojo); + when(endEvent.getException()).thenReturn(new Exception("Cannot find symbol: class Foo")); + + logger.mojoStarted(startEvent); + capturedOutput.clear(); + + logger.mojoFailed(endEvent); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.failed\"")); + assertTrue(json.contains("\"error\":\"Cannot find symbol: class Foo\"")); + } + + @Test + void testMojoSkippedEmitsJsonLine() { + MavenProject project = createProject("Core", "core"); + MojoExecution mojo = createMojoExecution("maven-deploy-plugin", "deploy", "deploy", "default-deploy"); + + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getMojoExecution()).thenReturn(mojo); + + logger.mojoSkipped(event); + + assertEquals(1, capturedOutput.size()); + String json = capturedOutput.get(0); + assertTrue(json.contains("\"event\":\"mojo.skipped\"")); + assertTrue(json.contains("\"plugin\":\"maven-deploy-plugin\"")); + assertTrue(json.contains("\"goal\":\"deploy\"")); + } + + @Test + void testMultiModuleLifecycle() { + MavenProject project1 = createProject("API", "api"); + MavenProject project2 = createProject("Core", "core"); + MavenProject project3 = createProject("CLI", "cli"); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project1, 1000)); + result.addBuildSummary(new BuildSuccess(project2, 3000)); + result.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + // Full lifecycle + logger.sessionStarted(sessionEvent); + + for (MavenProject p : List.of(project1, project2, project3)) { + ExecutionEvent pe = mock(ExecutionEvent.class); + when(pe.getProject()).thenReturn(p); + when(pe.getSession()).thenReturn(session); + logger.projectStarted(pe); + logger.projectSucceeded(pe); + } + + logger.sessionEnded(sessionEvent); + + // build.started + 3*(module.started + module.succeeded) + build.finished = 8 + assertEquals(8, capturedOutput.size()); + + // First event is build.started + assertTrue(capturedOutput.get(0).contains("\"event\":\"build.started\"")); + // Check module indices + assertTrue(capturedOutput.get(1).contains("\"index\":1")); + assertTrue(capturedOutput.get(3).contains("\"index\":2")); + assertTrue(capturedOutput.get(5).contains("\"index\":3")); + // Last event is build.finished + assertTrue(capturedOutput.get(7).contains("\"event\":\"build.finished\"")); + } + + @Test + void testAllOutputIsValidJsonLines() { + MavenProject project = createProject("Core", "core"); + MavenSession session = setupSessionForProjectEvents(project); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + result.addBuildSummary(new BuildSuccess(project, 1000)); + when(session.getResult()).thenReturn(result); + + MojoExecution mojo = createMojoExecution("maven-compiler-plugin", "compile", "compile", "default-compile"); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + when(projectEvent.getMojoExecution()).thenReturn(mojo); + + // Generate various events + logger.projectStarted(projectEvent); + logger.mojoStarted(projectEvent); + machineBel.log("Some log message"); + logger.mojoSucceeded(projectEvent); + logger.projectSucceeded(projectEvent); + + // All lines should be valid JSON (start with { and end with }) + for (String line : capturedOutput) { + assertTrue(line.startsWith("{"), "Should start with {: " + line); + assertTrue(line.endsWith("}"), "Should end with }: " + line); + // Should not contain raw newlines + assertFalse(line.contains("\n"), "Should not contain raw newlines: " + line); + assertFalse(line.contains("\r"), "Should not contain raw carriage returns: " + line); + } + } + + // ---- Helpers ---- + + private static MavenProject createProject(String name, String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getName()).thenReturn(name); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getGroupId()).thenReturn("org.apache.maven"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getPackaging()).thenReturn("jar"); + return project; + } + + private static MojoExecution createMojoExecution(String artifactId, String goal, String phase, String executionId) { + MojoExecution mojo = mock(MojoExecution.class); + lenient().when(mojo.getArtifactId()).thenReturn(artifactId); + lenient().when(mojo.getGoal()).thenReturn(goal); + lenient().when(mojo.getLifecyclePhase()).thenReturn(phase); + lenient().when(mojo.getExecutionId()).thenReturn(executionId); + return mojo; + } + + private MavenSession setupSessionForProjectEvents(MavenProject project) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(List.of("install")); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(result); + when(session.getRequest()).thenReturn(request); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + logger.sessionStarted(sessionEvent); + + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java new file mode 100644 index 000000000000..4503ba4e2615 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/PlainExecutionEventLoggerTest.java @@ -0,0 +1,317 @@ +/* + * 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.cling.event; + +import java.util.List; + +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link PlainExecutionEventLogger} — the compact one-line-per-module renderer. + */ +class PlainExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + + private Logger logger; + private PlainExecutionEventLogger plainLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + plainLogger = new PlainExecutionEventLogger(messageBuilderFactory, logger); + } + + @AfterEach + void afterEach() { + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In plain mode, projectStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.projectStarted(event); + + // Verify no logger calls were made + verify(logger, never()).info(anyString()); + } + + @Test + void testMojoStartedSuppressed() { + // In plain mode, mojoStarted should produce NO output + ExecutionEvent event = mock(ExecutionEvent.class); + + plainLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + } + + @Test + void testSingleProjectSucceeded() { + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + when(projectEvent.getProject()).thenReturn(project); + when(projectEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.projectSucceeded(projectEvent); + + // Single project: no progress counter + verify(logger).info(matches("Maven Core.*SUCCESS.*2\\.1")); + } + + @Test + void testMultiModuleProjectOneLinePerModule() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + // Simulate lifecycle + ExecutionEvent event1 = mockProjectEvent(project1, session); + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event1); + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Multi-module: each line has progress counter [n/total] + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("API.*\\[1/3\\].*SUCCESS.*1\\.0")); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS.*3\\.0")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS.*2\\.0")); + } + + @Test + void testProjectFailedShowsFailure() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + plainLogger.projectFailed(event); + + verify(logger).info(matches("Core.*FAILURE.*5\\.0")); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + // Verify BUILD SUCCESS and stats + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD SUCCESS"); + inOrder.verify(logger).info(matches("2 modules.*2 passed")); + inOrder.verify(logger).info(eq("Total time: {}{}"), anyString(), anyString()); + inOrder.verify(logger).info("Full report: target/build-reports/build-report-latest.json"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + plainLogger.sessionStarted(sessionEvent); + plainLogger.sessionEnded(sessionEvent); + + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(""); + inOrder.verify(logger).info("BUILD FAILURE"); + inOrder.verify(logger).info(matches("3 modules.*1 passed.*1 failed.*1 skipped")); + } + + @Test + void testMojoSkippedStillWarns() { + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + plainLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testResumeFromProgress() { + // When resuming, allProjects > projects (some already built) + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project2, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project2, project3)); // resumed from project2 + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + plainLogger.sessionStarted(sessionEvent); + + ExecutionEvent event2 = mockProjectEvent(project2, session); + ExecutionEvent event3 = mockProjectEvent(project3, session); + + plainLogger.projectSucceeded(event2); + plainLogger.projectSucceeded(event3); + + // Progress should start from 2/3, not 1/3 + InOrder inOrder = inOrder(logger); + inOrder.verify(logger).info(matches("Core.*\\[2/3\\].*SUCCESS")); + inOrder.verify(logger).info(matches("CLI.*\\[3/3\\].*SUCCESS")); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java new file mode 100644 index 000000000000..5548dd53fa60 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java @@ -0,0 +1,291 @@ +/* + * 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.cling.event; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.transfer.TransferEvent; +import org.eclipse.aether.transfer.TransferResource; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichBuildEventListener}. + */ +class RichBuildEventListenerTest { + + private MockitoSession mockitoSession; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener listener; + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + terminalOutput = new ByteArrayOutputStream(); + // DumbTerminal: supported=false (fallback mode), output goes to terminalOutput + terminal = new DumbTerminal(new ByteArrayInputStream(new byte[0]), terminalOutput); + terminal.setSize(new Size(120, 40)); + listener = new RichBuildEventListener(terminal, msg -> {}); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedAndFinished() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + } + + @Test + void testLogMessagePassthrough() { + listener.log("Test log message"); + String output = terminalOutput.toString(); + assertTrue(output.contains("Test log message"), "Expected log message in output: " + output); + } + + @Test + void testProjectLogMessageFiltersInfo() { + LogEvent infoEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.INFO, + "Compiling 42 source files", + "compiler", + null, + "[INFO] Compiling 42 source files"); + listener.projectLogMessage("api", infoEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("Compiling 42 source files"), + "INFO messages should be suppressed in rich mode: " + output); + } + + @Test + void testProjectLogMessageSuppressesWarningInline() { + // In rich mode, warnings are suppressed inline and only counted for the + // end-of-build summary — they don't scroll above the status bar. + LogEvent warnEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.WARN, + "Deprecated API usage", + "compiler", + null, + "[WARNING] Deprecated API usage"); + listener.projectLogMessage("api", warnEvent); + String output = terminalOutput.toString(); + assertFalse( + output.contains("[WARNING] Deprecated API usage"), + "WARN messages should be suppressed inline in rich mode: " + output); + assertEquals(1, listener.getWarningCount(), "Warning count should be tracked"); + } + + @Test + void testProjectLogMessageShowsError() { + LogEvent errorEvent = new DefaultLogEvent( + MonotonicClock.now(), + LogLevel.ERROR, + "Compilation failure", + "compiler", + null, + "[ERROR] Compilation failure"); + listener.projectLogMessage("api", errorEvent); + String output = terminalOutput.toString(); + assertTrue(output.contains("[ERROR] Compilation failure"), "ERROR messages should pass through: " + output); + } + + @Test + void testMojoStartedUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"), createProject("core"))); + listener.initReactor(session); + + listener.projectStarted("api"); + + ExecutionEvent mojoEvent = mock(ExecutionEvent.class); + MavenProject project = createProject("api"); + when(mojoEvent.getProject()).thenReturn(project); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getArtifactId()).thenReturn("maven-compiler-plugin"); + when(mojoExec.getGoal()).thenReturn("compile"); + when(mojoEvent.getMojoExecution()).thenReturn(mojoExec); + + listener.mojoStarted(mojoEvent); + + listener.projectFinished("api"); + } + + @Test + void testTransferEvents() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + TransferResource resource = mock(TransferResource.class); + when(resource.getResourceName()).thenReturn("org/apache/maven/core/4.1.0/core-4.1.0.jar"); + when(resource.getContentLength()).thenReturn(524288L); + + TransferEvent startEvent = mock(TransferEvent.class); + when(startEvent.getType()).thenReturn(TransferEvent.EventType.STARTED); + when(startEvent.getResource()).thenReturn(resource); + + listener.transfer("api", startEvent); + + TransferEvent progressEvent = mock(TransferEvent.class); + when(progressEvent.getType()).thenReturn(TransferEvent.EventType.PROGRESSED); + when(progressEvent.getResource()).thenReturn(resource); + when(progressEvent.getTransferredBytes()).thenReturn(262144L); + + listener.transfer("api", progressEvent); + + TransferEvent doneEvent = mock(TransferEvent.class); + when(doneEvent.getType()).thenReturn(TransferEvent.EventType.SUCCEEDED); + when(doneEvent.getResource()).thenReturn(resource); + + listener.transfer("api", doneEvent); + } + + @Test + void testParallelProjects() { + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectStarted("core"); + + listener.projectFinished("api"); + + listener.projectStarted("cli"); + + listener.projectFinished("core"); + listener.projectFinished("cli"); + } + + @Test + void testExecutionFailureUpdatesState() { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.executionFailure("api", true, "Compilation error"); + listener.projectFinished("api"); + } + + @Test + void testTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + listener.projectStarted("api"); + + listener.tearDown(); + } + + @Test + void testFinishCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.finish(0); + } + + @Test + void testFailCallsTearDown() throws Exception { + MavenSession session = createSession(List.of(createProject("api"))); + listener.initReactor(session); + + listener.fail(new RuntimeException("build error")); + } + + @Test + void testTruncateAnsiPlainText() { + String truncated = RichBuildEventListener.truncateAnsi("hello world", 5); + // When truncated, a RESET escape is appended to close any open styling + assertTrue(truncated.startsWith("hello"), "Should start with 'hello': " + truncated); + // Should not contain characters beyond "hello" (except ANSI reset) + String stripped = truncated.replaceAll("\033\\[[^a-zA-Z]*[a-zA-Z]", ""); + assertEquals("hello", stripped); + } + + @Test + void testTruncateAnsiPreservesEscapeSequences() { + // ANSI color codes should not count toward visible length + String colored = "\033[1mhello\033[0m world"; + String truncated = RichBuildEventListener.truncateAnsi(colored, 5); + // Should keep "hello" (5 visible chars) with the bold prefix + assertTrue(truncated.contains("hello"), "Truncated should contain 'hello': " + truncated); + assertTrue(truncated.contains("\033[1m"), "Truncated should preserve ANSI prefix"); + } + + @Test + void testTruncateAnsiNoTruncationNeeded() { + String s = "short"; + assertEquals(s, RichBuildEventListener.truncateAnsi(s, 100)); + } + + // ---- Helpers ---- + + private static MavenProject createProject(String artifactId) { + MavenProject project = mock(MavenProject.class); + lenient().when(project.getArtifactId()).thenReturn(artifactId); + lenient().when(project.getName()).thenReturn(artifactId); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + return project; + } + + private static MavenSession createSession(List projects) { + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(projects); + when(session.getAllProjects()).thenReturn(projects); + MavenExecutionRequest request = mock(MavenExecutionRequest.class); + lenient().when(request.getDegreeOfConcurrency()).thenReturn(1); + lenient().when(session.getRequest()).thenReturn(request); + return session; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java new file mode 100644 index 000000000000..762d71548430 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichExecutionEventLoggerTest.java @@ -0,0 +1,424 @@ +/* + * 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.cling.event; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.execution.BuildFailure; +import org.apache.maven.execution.BuildSuccess; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.apache.maven.project.MavenProject; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.MockitoSession; +import org.slf4j.Logger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RichExecutionEventLogger}. + *

+ * In rich mode, all output goes directly to the terminal writer via + * {@link RichBuildEventListener#log(String)}, bypassing SLF4J entirely. + * Tests capture the terminal output to verify content. + */ +class RichExecutionEventLoggerTest { + + private MockitoSession mockitoSession; + private Logger logger; + private Terminal terminal; + private ByteArrayOutputStream terminalOutput; + private RichBuildEventListener buildEventListener; + private RichExecutionEventLogger richLogger; + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + @BeforeEach + void beforeEach() throws Exception { + mockitoSession = Mockito.mockitoSession().startMocking(); + logger = mock(Logger.class); + lenient().when(logger.isInfoEnabled()).thenReturn(true); + lenient().when(logger.isWarnEnabled()).thenReturn(true); + terminalOutput = new ByteArrayOutputStream(); + terminal = new DumbTerminal(System.in, terminalOutput); + terminal.setSize(new Size(120, 40)); + buildEventListener = new RichBuildEventListener(terminal, msg -> {}); + richLogger = new RichExecutionEventLogger(messageBuilderFactory, buildEventListener, logger); + } + + @AfterEach + void afterEach() throws Exception { + terminal.close(); + mockitoSession.finishMocking(); + } + + @Test + void testProjectStartedSuppressed() { + // In rich mode, projectStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.projectStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testMojoStartedSuppressed() { + // In rich mode, mojoStarted should produce NO output (status bar handles it) + ExecutionEvent event = mock(ExecutionEvent.class); + + richLogger.mojoStarted(event); + + verify(logger, never()).info(anyString()); + assertTrue(terminalOutput.toString().isEmpty(), "No terminal output expected"); + } + + @Test + void testProjectSucceededSuppressed() { + // In rich mode, projectSucceeded produces NO scrolling output — + // the status bar checkmarks already indicate completion. + MavenProject project = generateMavenProject("Maven Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 2100)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output accumulated from sessionStarted (status bar init) + terminalOutput.reset(); + + ExecutionEvent projectEvent = mock(ExecutionEvent.class); + lenient().when(projectEvent.getProject()).thenReturn(project); + lenient().when(projectEvent.getSession()).thenReturn(session); + + richLogger.projectSucceeded(projectEvent); + + // No output — SUCCESS lines are suppressed in rich mode + String output = terminalOutput.toString(); + assertFalse(output.contains("SUCCESS"), "SUCCESS line should be suppressed in rich mode"); + } + + @Test + void testProjectFailedShowsCross() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildFailure(project, 5000, new Exception("Compile error"))); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + ExecutionEvent event = mockProjectEvent(project, session); + richLogger.projectFailed(event); + + String output = terminalOutput.toString(); + assertTrue(output.contains("Core"), "Should contain project name"); + assertTrue(output.contains("FAILURE"), "Should contain FAILURE status"); + } + + @Test + void testMultiModuleSuccessSuppressed() { + // In rich mode, per-module SUCCESS lines are suppressed — the status bar + // already shows ✓/●/○ indicators and the [n/total] counter. + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 3000)); + executionResult.addBuildSummary(new BuildSuccess(project3, 2000)); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + lenient().when(session.getResult()).thenReturn(executionResult); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + richLogger.sessionStarted(sessionEvent); + + // Clear terminal output from sessionStarted + terminalOutput.reset(); + + // mockProjectEvent stubs are unused since projectSucceeded is a no-op; + // call with a plain mock to avoid UnnecessaryStubbing errors. + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + richLogger.projectSucceeded(mock(ExecutionEvent.class)); + + String output = terminalOutput.toString(); + // No per-module SUCCESS lines should appear + assertFalse(output.contains("SUCCESS"), "SUCCESS lines should be suppressed in rich mode"); + assertFalse(output.contains("[1/3]"), "Progress counters should not appear"); + } + + @Test + void testSessionEndedShowsSummary() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildSuccess(project2, 2000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // Summary goes to terminal writer, not logger + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("2 modules"), "Should contain module count"); + assertTrue(output.contains("2 passed"), "Should contain passed count"); + assertTrue(output.contains("Total time:"), "Should contain total time"); + assertTrue( + output.contains("Full report: target/build-reports/build-report-latest.json"), + "Should contain report path"); + // MNG-7372: version info only on failure, not success + assertFalse(output.contains("Maven:"), "Should NOT contain Maven version on success"); + } + + @Test + void testSessionEndedWithFailures() { + MavenProject project1 = generateMavenProject("API"); + MavenProject project2 = generateMavenProject("Core"); + MavenProject project3 = generateMavenProject("CLI"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project1, 1000)); + executionResult.addBuildSummary(new BuildFailure(project2, 2000, new Exception("Error"))); + executionResult.addException(new Exception("Error")); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + when(session.getProjects()).thenReturn(List.of(project1, project2, project3)); + when(session.getAllProjects()).thenReturn(List.of(project1, project2, project3)); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD FAILURE"), "Should contain BUILD FAILURE"); + assertTrue(output.contains("3 modules"), "Should contain module count"); + assertTrue(output.contains("1 passed"), "Should contain passed count"); + assertTrue(output.contains("1 failed"), "Should contain failed count"); + assertTrue(output.contains("1 skipped"), "Should contain skipped count"); + // MNG-7372: version info shown on failure + assertTrue(output.contains("Maven:"), "Should contain Maven version on failure"); + assertTrue(output.contains("Java:"), "Should contain Java version on failure"); + } + + @Test + void testMojoSkippedStillWarns() { + // mojoSkipped still uses logger.warn (goes through SLF4J for WARN level) + ExecutionEvent event = mock(ExecutionEvent.class); + var mojoExec = mock(org.apache.maven.plugin.MojoExecution.class); + when(mojoExec.getGoal()).thenReturn("deploy"); + when(event.getMojoExecution()).thenReturn(mojoExec); + + richLogger.mojoSkipped(event); + + verify(logger).warn(anyString(), eq("deploy")); + } + + @Test + void testOutputBypassesSLF4J() { + // Verify that summary output does NOT go through logger.info + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + // No logger.info calls — all output goes through terminal writer + verify(logger, never()).info(anyString()); + + // But the output IS in the terminal + String output = terminalOutput.toString(); + assertFalse(output.isEmpty(), "Terminal should have output"); + assertTrue(output.contains("BUILD SUCCESS"), "Terminal should contain BUILD SUCCESS"); + } + + @Test + void testWarningSummaryShown() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + + // Simulate 3 warnings arriving during the build + Instant now = Instant.now(); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unchecked cast", "javac", null, "[WARNING] unchecked cast")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "deprecated API", "javac", null, "[WARNING] deprecated API")); + buildEventListener.projectLogMessage( + "core", + new DefaultLogEvent(now, LogLevel.WARN, "unused import", "javac", null, "[WARNING] unused import")); + + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("3 warning"), "Should contain warning count"); + assertTrue(output.contains("Diagnostics:"), "Should contain Diagnostics label"); + assertTrue(output.contains("mvnlog"), "Should hint how to see warning details"); + } + + @Test + void testNoWarningSummaryWhenClean() { + MavenProject project = generateMavenProject("Core"); + + MavenExecutionResult executionResult = new DefaultMavenExecutionResult(); + executionResult.addBuildSummary(new BuildSuccess(project, 1000)); + + MavenExecutionRequest executionRequest = new DefaultMavenExecutionRequest(); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getAllProjects()).thenReturn(List.of(project)); + when(session.getResult()).thenReturn(executionResult); + when(session.getRequest()).thenReturn(executionRequest); + + ExecutionEvent sessionEvent = mock(ExecutionEvent.class); + when(sessionEvent.getSession()).thenReturn(session); + + richLogger.sessionStarted(sessionEvent); + richLogger.sessionEnded(sessionEvent); + + String output = terminalOutput.toString(); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertFalse(output.contains("Diagnostics:"), "Should NOT contain Diagnostics when no warnings"); + } + + // ---- Helpers ---- + + private static MavenProject generateMavenProject(String projectName) { + MavenProject project = mock(MavenProject.class); + lenient() + .when(project.getArtifactId()) + .thenReturn(projectName.toLowerCase().replace(" ", "-")); + lenient().when(project.getPackaging()).thenReturn("jar"); + lenient().when(project.getVersion()).thenReturn("4.1.0-SNAPSHOT"); + lenient().when(project.getName()).thenReturn(projectName); + return project; + } + + private static ExecutionEvent mockProjectEvent(MavenProject project, MavenSession session) { + ExecutionEvent event = mock(ExecutionEvent.class); + when(event.getProject()).thenReturn(project); + when(event.getSession()).thenReturn(session); + return event; + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java new file mode 100644 index 000000000000..8e7286718960 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRendererTest.java @@ -0,0 +1,250 @@ +/* + * 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.cling.invoker.mvnlog; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.maven.jline.JLineMessageBuilderFactory; +import org.apache.maven.jline.MessageUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportRendererTest { + + private final JLineMessageBuilderFactory messageBuilderFactory = new JLineMessageBuilderFactory(); + + @BeforeAll + static void setUp() { + MessageUtils.setColorEnabled(false); + } + + @AfterAll + static void tearDown() { + MessageUtils.setColorEnabled(true); + } + + private Map createSuccessReport() { + String json = """ + { + "formatVersion": "1.0", + "status": "SUCCESS", + "duration": "PT6.7S", + "startTime": "2026-07-29T10:00:00Z", + "mavenVersion": "4.1.0-SNAPSHOT", + "javaVersion": "21.0.1", + "goals": ["clean", "install"], + "project": "org.example:root", + "multiModule": true, + "threads": 1, + "modules": [ + { + "groupId": "org.example", + "artifactId": "api", + "version": "1.0", + "status": "SUCCESS", + "startTime": "2026-07-29T10:00:01Z", + "duration": "PT2.1S", + "mojos": [ + { + "groupId": "org.apache.maven.plugins", + "artifactId": "maven-compiler-plugin", + "version": "3.15.0", + "goal": "compile", + "executionId": "default-compile", + "phase": "compile", + "status": "SUCCESS", + "startTime": "2026-07-29T10:00:01Z", + "duration": "PT1.5S", + "output": [] + } + ], + "output": [] + }, + { + "groupId": "org.example", + "artifactId": "core", + "version": "1.0", + "status": "SUCCESS", + "startTime": "2026-07-29T10:00:03Z", + "duration": "PT3.4S", + "mojos": [], + "output": [] + } + ], + "problems": [ + { + "key": "deprecated-source-target", + "severity": "WARNING", + "message": "source/target value 8 is obsolete and will be removed in a future release", + "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/" + } + ], + "failures": [], + "output": [] + }"""; + return SimpleJsonReader.parse(json); + } + + private Map createFailureReport() { + String json = """ + { + "formatVersion": "1.0", + "status": "FAILURE", + "duration": "PT5.0S", + "startTime": "2026-07-29T10:00:00Z", + "mavenVersion": "4.1.0-SNAPSHOT", + "javaVersion": "21.0.1", + "goals": ["compile"], + "project": "org.example:root", + "multiModule": false, + "threads": 1, + "modules": [ + { + "groupId": "org.example", + "artifactId": "core", + "version": "1.0", + "status": "FAILURE", + "startTime": "2026-07-29T10:00:01Z", + "duration": "PT5.0S", + "mojos": [], + "output": [] + } + ], + "problems": [], + "failures": [ + { + "module": "org.example:core", + "mojo": "compiler:compile", + "timestamp": "2026-07-29T10:00:05Z", + "message": "Compilation failure", + "stackTrace": "org.apache.maven.lifecycle.LifecycleExecutionException\\nat Lifecycle.java:42" + } + ], + "output": [] + }"""; + return SimpleJsonReader.parse(json); + } + + @Test + void testRenderSummarySuccess() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderSummary(createSuccessReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("Build Report"), "Should contain header"); + assertTrue(output.contains("Maven 4.1.0-SNAPSHOT"), "Should contain Maven version"); + assertTrue(output.contains("BUILD SUCCESS"), "Should contain BUILD SUCCESS"); + assertTrue(output.contains("api"), "Should contain first module"); + assertTrue(output.contains("core"), "Should contain second module"); + assertTrue(output.contains("2 modules"), "Should contain module count"); + assertTrue(output.contains("2 passed"), "Should contain passed count"); + assertTrue(output.contains("1 warning"), "Should contain warning count"); + assertTrue(output.contains("source/target value 8"), "Should show actual warning message in default view"); + assertTrue(output.contains("maven-compiler-plugin"), "Should show warning source in default view"); + assertTrue(output.contains("Update maven.compiler.source"), "Should show suggestion in default view"); + assertTrue(output.contains("Total time: 6.700 s"), "Should contain formatted total time"); + } + + @Test + void testRenderSummaryFailure() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderSummary(createFailureReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("BUILD FAILURE"), "Should contain BUILD FAILURE"); + assertTrue(output.contains("1 failure"), "Should contain failure count"); + } + + @Test + void testRenderDiagnostics() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderDiagnostics(createSuccessReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("Problems (1)"), "Should contain problems header"); + assertTrue(output.contains("source/target value 8"), "Should contain warning message"); + assertTrue(output.contains("deprecated-source-target"), "Should contain problem key"); + assertTrue(output.contains("maven-compiler-plugin"), "Should contain source"); + assertTrue(output.contains("Update maven.compiler.source"), "Should contain suggestion"); + assertTrue( + output.contains("https://maven.apache.org/plugins/maven-compiler-plugin/"), + "Should contain documentation URL"); + } + + @Test + void testRenderDiagnosticsWhenEmpty() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderDiagnostics(createFailureReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("No problems recorded"), "Should show empty message"); + } + + @Test + void testRenderFailures() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderFailures(createFailureReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("Failures (1)"), "Should contain failures header"); + assertTrue(output.contains("org.example:core"), "Should contain module name"); + assertTrue(output.contains("compiler:compile"), "Should contain mojo"); + assertTrue(output.contains("Compilation failure"), "Should contain error message"); + } + + @Test + void testRenderFull() { + List lines = new ArrayList<>(); + BuildReportRenderer renderer = new BuildReportRenderer(messageBuilderFactory, lines::add); + + renderer.renderFull(createSuccessReport()); + + String output = String.join("\n", lines); + assertTrue(output.contains("Module: api"), "Should contain module name"); + assertTrue(output.contains("compiler"), "Should contain mojo plugin"); + assertTrue(output.contains("compile"), "Should contain mojo goal"); + assertTrue(output.contains("default-compile"), "Should contain execution id"); + } + + @Test + void testFormatDuration() { + assertEquals("6.700 s", BuildReportRenderer.formatDuration("PT6.7S")); + assertEquals("0.100 s", BuildReportRenderer.formatDuration("PT0.1S")); + assertEquals("1:30 min", BuildReportRenderer.formatDuration("PT1M30S")); + assertEquals("PT-invalid", BuildReportRenderer.formatDuration("PT-invalid")); // fallback + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java new file mode 100644 index 000000000000..451151387cef --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonReaderTest.java @@ -0,0 +1,141 @@ +/* + * 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.cling.invoker.mvnlog; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SimpleJsonReaderTest { + + @Test + void testParseEmptyObject() { + Map result = SimpleJsonReader.parse("{}"); + assertTrue(result.isEmpty()); + } + + @Test + void testParseSimpleObject() { + Map result = SimpleJsonReader.parse(""" + {"name": "test", "version": "1.0"}"""); + assertEquals("test", result.get("name")); + assertEquals("1.0", result.get("version")); + } + + @Test + void testParseNumbers() { + Map result = SimpleJsonReader.parse(""" + {"count": 42, "ratio": 3.14, "negative": -7}"""); + assertEquals(42, result.get("count")); + assertEquals(3.14, result.get("ratio")); + assertEquals(-7, result.get("negative")); + } + + @Test + void testParseBooleanAndNull() { + Map result = SimpleJsonReader.parse(""" + {"active": true, "deleted": false, "extra": null}"""); + assertEquals(true, result.get("active")); + assertEquals(false, result.get("deleted")); + assertNull(result.get("extra")); + } + + @Test + @SuppressWarnings("unchecked") + void testParseArray() { + Map result = SimpleJsonReader.parse(""" + {"goals": ["clean", "install"]}"""); + List goals = (List) result.get("goals"); + assertEquals(2, goals.size()); + assertEquals("clean", goals.get(0)); + assertEquals("install", goals.get(1)); + } + + @Test + @SuppressWarnings("unchecked") + void testParseNestedObject() { + Map result = SimpleJsonReader.parse(""" + {"module": {"artifactId": "core", "status": "SUCCESS"}}"""); + Map module = (Map) result.get("module"); + assertEquals("core", module.get("artifactId")); + assertEquals("SUCCESS", module.get("status")); + } + + @Test + void testParseStringEscapes() { + Map result = SimpleJsonReader.parse(""" + {"msg": "line1\\nline2", "path": "C:\\\\Users"}"""); + assertEquals("line1\nline2", result.get("msg")); + assertEquals("C:\\Users", result.get("path")); + } + + @Test + @SuppressWarnings("unchecked") + void testParseBuildReportFragment() { + String json = """ + { + "formatVersion": "1.0", + "status": "SUCCESS", + "duration": "PT6.7S", + "mavenVersion": "4.1.0-SNAPSHOT", + "modules": [ + { + "artifactId": "maven-api-core", + "status": "SUCCESS", + "duration": "PT2.1S", + "mojos": [] + }, + { + "artifactId": "maven-core", + "status": "SUCCESS", + "duration": "PT3.4S", + "mojos": [] + } + ], + "problems": [], + "failures": [] + }"""; + + Map report = SimpleJsonReader.parse(json); + assertEquals("1.0", report.get("formatVersion")); + assertEquals("SUCCESS", report.get("status")); + assertEquals("PT6.7S", report.get("duration")); + + List> modules = (List>) (List) report.get("modules"); + assertEquals(2, modules.size()); + assertEquals("maven-api-core", modules.get(0).get("artifactId")); + assertEquals("maven-core", modules.get(1).get("artifactId")); + } + + @Test + void testParseInvalidJson() { + assertThrows(IllegalArgumentException.class, () -> SimpleJsonReader.parse("not json")); + } + + @Test + void testParseNonObjectRoot() { + assertThrows(IllegalArgumentException.class, () -> SimpleJsonReader.parse("[1, 2, 3]")); + } +} 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 new file mode 100644 index 000000000000..8df72cc4eadc --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java @@ -0,0 +1,751 @@ +/* + * 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 java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +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; +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +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; +import org.apache.maven.execution.BuildSummary; +import org.apache.maven.execution.ExecutionEvent; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.apache.maven.slf4j.MavenSimpleLogger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LocationAwareLogger; + +/** + * Collects build lifecycle events and produces a structured {@link BuildReport} + * at the end of the session. + *

+ * Registered as an {@link org.apache.maven.eventspy.EventSpy} via {@code @Named}/{@code @Singleton}, + * following the same pattern as {@code DefaultPluginValidationManager}. + *

+ * Thread-safe: concurrent module builds (with {@code -T}) each write to their + * own entry in a {@link ConcurrentHashMap}. + *

+ * Log capture: installs a structured {@link MavenSimpleLogger.LogEventSink} + * that receives level, logger name, message, and throwable independently of + * the formatted console output. Uses thread-based tracking to associate events + * with the currently-executing mojo or module. + * + * @since 4.1.0 + */ +@Singleton +@Named +public final class BuildReportCollector extends AbstractEventSpy { + + private static final Logger LOGGER = LoggerFactory.getLogger(BuildReportCollector.class); + + static final String REPORT_DIR = "build-reports"; + static final String REPORT_LATEST = "build-report-latest.json"; + + 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. + */ + static final int MAX_LOG_EVENTS_PER_SCOPE = 500; + + // ---- Mutable state, populated during the build ---- + + /** 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. */ + private final Map projectStartTimes = new ConcurrentHashMap<>(); + + /** Per-mojo start instants for duration computation. */ + private final Map mojoStartTimes = new ConcurrentHashMap<>(); + + /** 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. + * 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. */ + private final Map> mojoLogBuffers = new ConcurrentHashMap<>(); + + /** + * 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. */ + private final Map> moduleLogBuffers = new ConcurrentHashMap<>(); + + /** Build-level log buffer: events captured outside any module lifecycle. */ + private final List buildLogBuffer = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void onEvent(Object event) { + if (event instanceof ExecutionEvent executionEvent) { + switch (executionEvent.getType()) { + case SessionStarted: + onSessionStarted(executionEvent); + break; + case SessionEnded: + onSessionEnded(executionEvent); + break; + case ProjectStarted: + onProjectStarted(executionEvent); + break; + case ProjectSucceeded: + case ProjectFailed: + case ProjectSkipped: + onProjectFinished(executionEvent); + break; + case MojoStarted: + onMojoStarted(executionEvent); + break; + case MojoSucceeded: + case MojoFailed: + onMojoFinished(executionEvent); + break; + default: + break; + } + } + } + + // ---- Event handlers ---- + + 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: + *

    + *
  • Exact keys: {@code -Dmaven.diagnostic.suppress=deprecated-source-target}
  • + *
  • Prefix wildcards: {@code -Dmaven.diagnostic.suppress=auto:*} (suppresses all + * auto-collected warnings from Maven 3 plugins)
  • + *
  • Multiple: {@code -Dmaven.diagnostic.suppress=key1,key2,auto:*}
  • + *
+ */ + 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(); + + MavenSession endSession = event.getSession(); + if (endSession == null) { + return; + } + + // 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")); + } + } + + private void onProjectStarted(ExecutionEvent event) { + String key = projectKey(event.getProject()); + projectStartTimes.put(key, MonotonicClock.now()); + mojoTimings.putIfAbsent(key, Collections.synchronizedList(new ArrayList<>())); + moduleLogBuffers.put(key, Collections.synchronizedList(new ArrayList<>())); + currentProjectByThread.put(Thread.currentThread().getId(), key); + } + + private void onProjectFinished(ExecutionEvent event) { + // Unregister the project from this thread so subsequent log events + // fall through to the build-level buffer + currentProjectByThread.remove(Thread.currentThread().getId()); + } + + private void onMojoStarted(ExecutionEvent event) { + String mKey = mojoKey(event.getProject(), event.getMojoExecution()); + mojoStartTimes.put(mKey, MonotonicClock.now()); + + // Register the current mojo for this thread so the log event sink + // can associate events with this mojo execution + currentMojoByThread.put(Thread.currentThread().getId(), mKey); + mojoLogBuffers.put(mKey, Collections.synchronizedList(new ArrayList<>())); + } + + private void onMojoFinished(ExecutionEvent event) { + MojoExecution mojo = event.getMojoExecution(); + MavenProject project = event.getProject(); + String mKey = mojoKey(project, mojo); + String pKey = projectKey(project); + + // Unregister the mojo from this thread + currentMojoByThread.remove(Thread.currentThread().getId()); + + Instant now = MonotonicClock.now(); + Instant startInstant = mojoStartTimes.remove(mKey); + if (startInstant == null) { + startInstant = now; + } + Duration duration = Duration.between(startInstant, now); + + BuildStatus status = + event.getType() == ExecutionEvent.Type.MojoSucceeded ? BuildStatus.SUCCESS : BuildStatus.FAILURE; + + // Drain the log buffer for this mojo + List logBuffer = mojoLogBuffers.remove(mKey); + List output = logBuffer != null ? List.copyOf(logBuffer) : List.of(); + + MojoTiming timing = new MojoTiming( + mojo.getGroupId(), + mojo.getArtifactId(), + mojo.getVersion(), + mojo.getGoal(), + mojo.getExecutionId(), + mojo.getLifecyclePhase(), + status, + startInstant, + duration, + output); + + mojoTimings + .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>())) + .add(timing); + } + + // ---- Structured log capture ---- + + /** + * 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 + * forwarding needed. + */ + private void installLogCapture() { + MavenSimpleLogger.setLogEventSink(this::captureLogEvent); + } + + private void removeLogCapture() { + MavenSimpleLogger.setLogEventSink(null); + } + + private void captureLogEvent(int level, String loggerName, String message, Throwable throwable) { + long threadId = Thread.currentThread().getId(); + + LogLevel logLevel = toLogLevel(level); + Instant timestamp = MonotonicClock.now(); + 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) { + List buffer = mojoLogBuffers.get(mKey); + if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buffer.add(logEvent); + } + return; + } + + // 2. Module-level: project is active but no mojo is running + String pKey = currentProjectByThread.get(threadId); + if (pKey != null) { + List buffer = moduleLogBuffers.get(pKey); + if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buffer.add(logEvent); + } + return; + } + + // 3. Build-level: no project active (startup, reactor summary, post-build) + if (buildLogBuffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buildLogBuffer.add(logEvent); + } + } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + // ---- Report assembly ---- + + BuildReport buildReport(MavenSession endSession) { + Instant now = MonotonicClock.now(); + Instant startInstant = endSession.getRequest().getStartInstant(); + Duration totalDuration = Duration.between(startInstant, now); + + MavenExecutionResult result = endSession.getResult(); + boolean hasFailures = result != null && result.hasExceptions(); + BuildStatus overallStatus = hasFailures ? BuildStatus.FAILURE : BuildStatus.SUCCESS; + + // Collect module reports + List moduleReports = new ArrayList<>(); + for (MavenProject project : endSession.getProjects()) { + moduleReports.add(buildModuleReport(project, endSession)); + } + + // Collect failures + List failureReports = new ArrayList<>(); + if (result != null) { + for (MavenProject project : endSession.getProjects()) { + BuildSummary summary = result.getBuildSummary(project); + if (summary instanceof BuildFailure buildFailure) { + failureReports.add(buildFailureReport(project, buildFailure)); + } + } + } + + // Metadata + String mavenVersion = endSession.getSystemProperties().getProperty("maven.version", "unknown"); + String javaVersion = System.getProperty("java.version", "unknown"); + List goals = endSession.getGoals(); + MavenProject topProject = endSession.getTopLevelProject(); + String projectId = topProject != null + ? topProject.getGroupId() + ":" + topProject.getArtifactId() + ":" + topProject.getVersion() + : "unknown"; + 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); + + return new DefaultBuildReport( + overallStatus, + totalDuration, + startInstant, + mavenVersion, + javaVersion, + goals, + projectId, + multiModule, + threads, + moduleReports, + failureReports, + problems, + buildOutput); + } + + private ModuleReport buildModuleReport(MavenProject project, MavenSession endSession) { + String key = projectKey(project); + + // Duration from BuildSummary (preferred) or fallback to our own tracking + MavenExecutionResult result = endSession.getResult(); + Duration duration = Duration.ZERO; + BuildStatus status = BuildStatus.SKIPPED; + Instant moduleStartTime = + projectStartTimes.getOrDefault(key, endSession.getRequest().getStartInstant()); + + if (result != null) { + BuildSummary summary = result.getBuildSummary(project); + if (summary instanceof BuildSuccess) { + status = BuildStatus.SUCCESS; + duration = summary.getExecTime(); + } else if (summary instanceof BuildFailure) { + status = BuildStatus.FAILURE; + duration = summary.getExecTime(); + } else if (summary != null) { + // Unknown summary type — use its timing + duration = summary.getExecTime(); + } else { + // No summary means skipped + Instant start = projectStartTimes.get(key); + if (start != null) { + duration = Duration.between(start, MonotonicClock.now()); + } + } + } + + // Mojo reports + List timings = mojoTimings.getOrDefault(key, Collections.emptyList()); + List mojoReports; + synchronized (timings) { + mojoReports = timings.stream() + .map(t -> (MojoReport) new DefaultMojoReport( + t.groupId, + t.artifactId, + t.version, + t.goal, + t.executionId, + t.phase, + t.status, + t.startTime, + t.duration, + t.output)) + .toList(); + } + + // Module-level log events (between mojos) + List moduleLogBuffer = moduleLogBuffers.getOrDefault(key, Collections.emptyList()); + List moduleOutput; + synchronized (moduleLogBuffer) { + moduleOutput = List.copyOf(moduleLogBuffer); + } + + return new DefaultModuleReport( + project.getGroupId(), + project.getArtifactId(), + project.getVersion(), + status, + moduleStartTime, + duration, + mojoReports, + moduleOutput); + } + + private FailureReport buildFailureReport(MavenProject project, BuildFailure buildFailure) { + String module = project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion(); + + // Try to find which mojo failed + String mojoId = null; + List timings = mojoTimings.getOrDefault(projectKey(project), Collections.emptyList()); + synchronized (timings) { + for (MojoTiming t : timings) { + if (t.status == BuildStatus.FAILURE) { + mojoId = t.artifactId + ":" + t.version + ":" + t.goal; + break; + } + } + } + + Throwable cause = buildFailure.getCause(); + String message = cause != null ? cause.getMessage() : "Unknown error"; + String stackTrace = cause != null ? truncateStackTrace(cause) : null; + + Instant failureTimestamp = MonotonicClock.now(); + String exceptionType = cause != null ? cause.getClass().getSimpleName() : null; + + return new DefaultFailureReport( + module, + mojoId, + failureTimestamp, + exceptionType, + message != null ? message : "Unknown error", + stackTrace); + } + + // ---- JSON persistence ---- + + void writeReport(BuildReport report, MavenSession endSession) { + Path topDirectory = endSession.getTopDirectory(); + if (topDirectory == null) { + LOGGER.debug("No top directory available, skipping build report"); + return; + } + + Path reportsDir = topDirectory.resolve("target").resolve(REPORT_DIR); + + try { + Files.createDirectories(reportsDir); + String json = BuildReportJsonWriter.toJson(report); + + // Timestamped file: build-report-20250729T143000Z.json + String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(ZoneOffset.UTC) + .format(report.startTime()); + Path timestampedFile = reportsDir.resolve("build-report-" + timestamp + ".json"); + + // Write to a temp file, then atomic-move into place so a crash + // never leaves a half-written report on disk. + Path tmpFile = Files.createTempFile(reportsDir, ".build-report-", ".tmp"); + try { + Files.writeString(tmpFile, json); + atomicMove(tmpFile, timestampedFile); + } catch (IOException e) { + Files.deleteIfExists(tmpFile); + throw e; + } + + // Latest symlink (or copy on filesystems that don't support symlinks) + Path latestFile = reportsDir.resolve(REPORT_LATEST); + try { + // Atomic symlink swap: create new link, then rename over the old one + Path tmpLink = Files.createTempFile(reportsDir, ".latest-", ".tmp"); + Files.delete(tmpLink); // createTempFile creates a regular file + Files.createSymbolicLink(tmpLink, timestampedFile.getFileName()); + atomicMove(tmpLink, latestFile); + } catch (UnsupportedOperationException | IOException symEx) { + // Windows or restricted filesystem — fall back to a plain copy + Files.writeString(latestFile, json); + } + + LOGGER.debug("Build report written to {}", timestampedFile); + } catch (IOException e) { + LOGGER.warn("Failed to write build report to {}: {}", reportsDir, e.getMessage()); + } + } + + /** + * Attempts an atomic move; falls back to a plain move if the filesystem + * does not support {@code ATOMIC_MOVE}. + */ + private static void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + // ---- 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) { + return project.getGroupId() + ":" + project.getArtifactId(); + } + + 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)); + String full = sw.toString(); + String[] lines = full.split("\n"); + if (lines.length <= MAX_STACKTRACE_LINES) { + return full; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < MAX_STACKTRACE_LINES; i++) { + sb.append(lines[i]).append('\n'); + } + sb.append("... ").append(lines.length - MAX_STACKTRACE_LINES).append(" more lines truncated\n"); + return sb.toString(); + } + + // ---- Internal records ---- + + record MojoTiming( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) {} +} 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 new file mode 100644 index 000000000000..5b497f9d90fe --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java @@ -0,0 +1,368 @@ +/* + * 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.build.report.BuildReport; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; +import org.apache.maven.api.services.BuilderProblem; + +/** + * Serializes a {@link BuildReport} to JSON without any external library dependency. + *

+ * The output is human-readable (indented with 2 spaces) and designed to be + * stable across Maven versions — field order is fixed, and new fields are + * appended at the end of each object. + */ +final class BuildReportJsonWriter { + + private BuildReportJsonWriter() {} + + /** + * Serialize the given report to a pretty-printed JSON string. + */ + static String toJson(BuildReport report) { + StringBuilder sb = new StringBuilder(4096); + writeReport(sb, report, 0); + sb.append('\n'); + return sb.toString(); + } + + private static void writeReport(StringBuilder sb, BuildReport report, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "formatVersion", report.formatVersion()); + writeField(sb, indent + 1, "status", report.status().name()); + writeField(sb, indent + 1, "duration", report.duration().toString()); + writeField(sb, indent + 1, "startTime", report.startTime().toString()); + writeField(sb, indent + 1, "mavenVersion", report.mavenVersion()); + writeField(sb, indent + 1, "javaVersion", report.javaVersion()); + writeStringArray(sb, indent + 1, "goals", report.goals()); + writeField(sb, indent + 1, "project", report.project()); + writeField(sb, indent + 1, "multiModule", report.multiModule()); + writeField(sb, indent + 1, "threads", report.threads()); + + // modules array + writeIndent(sb, indent + 1); + sb.append("\"modules\": "); + if (report.modules().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.modules().size(); i++) { + writeIndent(sb, indent + 2); + writeModule(sb, report.modules().get(i), indent + 2); + if (i < report.modules().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // failures array + writeIndent(sb, indent + 1); + sb.append("\"failures\": "); + if (report.failures().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.failures().size(); i++) { + writeIndent(sb, indent + 2); + writeFailure(sb, report.failures().get(i), indent + 2); + if (i < report.failures().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // problems array + writeIndent(sb, indent + 1); + sb.append("\"problems\": "); + if (report.problems().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < report.problems().size(); i++) { + writeIndent(sb, indent + 2); + writeProblem(sb, report.problems().get(i), indent + 2); + if (i < report.problems().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // output array — build-level log lines (outside any module) + writeOutputArray(sb, indent + 1, report.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeProblem(StringBuilder sb, BuilderProblem problem, int indent) { + 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); + } + if (problem.getLineNumber() > 0) { + writeField(sb, indent + 1, "line", problem.getLineNumber()); + } + 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) { + sb.replace(lastComma, lastComma + 1, ""); + } + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeModule(StringBuilder sb, ModuleReport module, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "groupId", module.groupId()); + writeField(sb, indent + 1, "artifactId", module.artifactId()); + writeField(sb, indent + 1, "version", module.version()); + writeField(sb, indent + 1, "status", module.status().name()); + writeField(sb, indent + 1, "startTime", module.startTime().toString()); + writeField(sb, indent + 1, "duration", module.duration().toString()); + + // mojos array + writeIndent(sb, indent + 1); + sb.append("\"mojos\": "); + if (module.mojos().isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < module.mojos().size(); i++) { + writeIndent(sb, indent + 2); + writeMojo(sb, module.mojos().get(i), indent + 2); + if (i < module.mojos().size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent + 1); + sb.append(']'); + } + sb.append(",\n"); + + // output array — module-level log lines (between mojos) + writeOutputArray(sb, indent + 1, module.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeMojo(StringBuilder sb, MojoReport mojo, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "groupId", mojo.groupId()); + writeField(sb, indent + 1, "artifactId", mojo.artifactId()); + writeField(sb, indent + 1, "version", mojo.version()); + writeField(sb, indent + 1, "goal", mojo.goal()); + writeNullableField(sb, indent + 1, "executionId", mojo.executionId(), true); + writeNullableField(sb, indent + 1, "phase", mojo.phase(), true); + writeField(sb, indent + 1, "status", mojo.status().name()); + writeField(sb, indent + 1, "startTime", mojo.startTime().toString()); + writeField(sb, indent + 1, "duration", mojo.duration().toString()); + + // output array — captured log lines + writeOutputArray(sb, indent + 1, mojo.output()); + sb.append('\n'); + + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeFailure(StringBuilder sb, FailureReport failure, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "module", failure.module()); + writeNullableField(sb, indent + 1, "mojo", failure.mojo(), true); + writeField(sb, indent + 1, "timestamp", failure.timestamp().toString()); + writeNullableField(sb, indent + 1, "exceptionType", failure.exceptionType(), true); + if (failure.stackTrace() != null) { + writeField(sb, indent + 1, "message", failure.message()); + writeLastField(sb, indent + 1, "stackTrace", failure.stackTrace()); + } else { + writeLastField(sb, indent + 1, "message", failure.message()); + } + writeIndent(sb, indent); + sb.append('}'); + } + + /** + * Writes an {@code "output": [...]} array of structured log events + * (used by report, module, and mojo). + * This is always the last field in its object, so no trailing comma. + */ + private static void writeOutputArray(StringBuilder sb, int indent, java.util.List events) { + writeIndent(sb, indent); + sb.append("\"output\": "); + if (events.isEmpty()) { + sb.append("[]"); + } else { + sb.append("[\n"); + for (int i = 0; i < events.size(); i++) { + writeIndent(sb, indent + 1); + writeLogEvent(sb, events.get(i), indent + 1); + if (i < events.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent); + sb.append(']'); + } + } + + private static void writeLogEvent(StringBuilder sb, LogEvent event, int indent) { + sb.append("{\n"); + writeField(sb, indent + 1, "timestamp", event.timestamp().toString()); + writeField(sb, indent + 1, "level", event.level().name()); + if (event.loggerName() != null) { + writeField(sb, indent + 1, "loggerName", event.loggerName()); + } + if (event.stackTrace() != null) { + writeField(sb, indent + 1, "message", event.message()); + writeLastField(sb, indent + 1, "stackTrace", event.stackTrace()); + } else { + writeLastField(sb, indent + 1, "message", event.message()); + } + writeIndent(sb, indent); + sb.append('}'); + } + + // ---- Low-level JSON writing helpers ---- + + private static void writeField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + writeJsonString(sb, value); + sb.append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, int value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + private static void writeField(StringBuilder sb, int indent, String key, boolean value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ").append(value).append(",\n"); + } + + private static void writeLastField(StringBuilder sb, int indent, String key, String value) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + writeJsonString(sb, value); + sb.append('\n'); + } + + private static void writeNullableField( + StringBuilder sb, int indent, String key, String value, @SuppressWarnings("unused") boolean hasMore) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + if (value != null) { + writeJsonString(sb, value); + } else { + sb.append("null"); + } + sb.append(",\n"); + } + + private static void writeStringArray(StringBuilder sb, int indent, String key, java.util.List values) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": ["); + for (int i = 0; i < values.size(); i++) { + writeJsonString(sb, values.get(i)); + if (i < values.size() - 1) { + sb.append(", "); + } + } + sb.append("],\n"); + } + + private static void writeJsonString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + sb.append(String.format("%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static void writeIndent(StringBuilder sb, int level) { + sb.append(" ".repeat(level)); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java new file mode 100644 index 000000000000..c0522681a5b2 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java @@ -0,0 +1,82 @@ +/* + * 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.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.services.BuilderProblem; + +/** + * Internal immutable implementation of {@link BuildReport}. + */ +record DefaultBuildReport( + BuildStatus status, + Duration duration, + Instant startTime, + String mavenVersion, + String javaVersion, + List goals, + String project, + boolean multiModule, + int threads, + List modules, + List failures, + List problems, + List output) + implements BuildReport { + + private static final int FORMAT_VERSION = 1; + + @Override + public int formatVersion() { + return FORMAT_VERSION; + } + + @Override + public List modules() { + return List.copyOf(modules); + } + + @Override + public List failures() { + return List.copyOf(failures); + } + + @Override + public List problems() { + return List.copyOf(problems); + } + + @Override + public List goals() { + return List.copyOf(goals); + } + + @Override + public List output() { + return List.copyOf(output); + } +} 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/main/java/org/apache/maven/internal/build/DefaultFailureReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java new file mode 100644 index 000000000000..b0bc075d34f3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java @@ -0,0 +1,30 @@ +/* + * 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.time.Instant; + +import org.apache.maven.api.build.report.FailureReport; + +/** + * Internal immutable implementation of {@link FailureReport}. + */ +record DefaultFailureReport( + String module, String mojo, Instant timestamp, String exceptionType, String message, String stackTrace) + implements FailureReport {} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java new file mode 100644 index 000000000000..5dda07716343 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.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 java.time.Instant; + +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; + +/** + * Immutable implementation of {@link LogEvent}. + *

+ * Public to allow construction from other packages within the Maven + * implementation (e.g. {@code ProjectBuildLogAppender}). + * + * @param timestamp when the event was produced + * @param level the severity level + * @param message the clean log message (without level prefix or ANSI) + * @param loggerName the name of the logger, or {@code null} + * @param stackTrace the stack trace string, or {@code null} + * @param formattedMessage the fully formatted console line, or {@code null} + */ +public record DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage) + implements LogEvent { + + /** + * Convenience constructor for events created without a formatted message + * (e.g. in tests or programmatic construction). + */ + DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) { + this(timestamp, level, message, loggerName, stackTrace, null); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java new file mode 100644 index 000000000000..64c79636ebd1 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java @@ -0,0 +1,53 @@ +/* + * 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.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.ModuleReport; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Internal immutable implementation of {@link ModuleReport}. + */ +record DefaultModuleReport( + String groupId, + String artifactId, + String version, + BuildStatus status, + Instant startTime, + Duration duration, + List mojos, + List output) + implements ModuleReport { + + @Override + public List mojos() { + return List.copyOf(mojos); + } + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java new file mode 100644 index 000000000000..5270d9c11fe3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java @@ -0,0 +1,49 @@ +/* + * 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.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.MojoReport; + +/** + * Internal immutable implementation of {@link MojoReport}. + */ +record DefaultMojoReport( + String groupId, + String artifactId, + String version, + String goal, + String executionId, + String phase, + BuildStatus status, + Instant startTime, + Duration duration, + List output) + implements MojoReport { + + @Override + public List output() { + return List.copyOf(output); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java index 39573d061cd0..c1d771b5c11b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java @@ -18,6 +18,7 @@ */ package org.apache.maven.logging; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -30,7 +31,7 @@ public interface BuildEventListener { void projectStarted(String projectId); - void projectLogMessage(String projectId, String event); + void projectLogMessage(String projectId, LogEvent event); void projectFinished(String projectId); diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java index 8465df0cf060..44339eae8c8c 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java @@ -18,11 +18,25 @@ */ package org.apache.maven.logging; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Instant; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; import org.apache.maven.slf4j.MavenSimpleLogger; import org.slf4j.MDC; +import org.slf4j.spi.LocationAwareLogger; /** - * Forwards log messages to the client. + * Forwards log messages to the client as structured {@link LogEvent} objects. + *

+ * Installs itself as a {@link MavenSimpleLogger.LogSink} to intercept all + * SLF4J log output, enrich it with structured metadata (level, logger name, + * clean message, formatted output), and forward to the active + * {@link BuildEventListener}. */ public class ProjectBuildLogAppender implements AutoCloseable { @@ -76,13 +90,35 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) { MavenSimpleLogger.setLogSink(this::accept); } - protected void accept(String message) { + protected void accept( + int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) { String projectId = MDC.get(KEY_PROJECT_ID); - buildEventListener.projectLogMessage(projectId, message); + Instant timestamp = MonotonicClock.now(); + LogLevel logLevel = toLogLevel(level); + String stackTrace = throwable != null ? formatStackTrace(throwable) : null; + LogEvent event = + new DefaultLogEvent(timestamp, logLevel, cleanMessage, loggerName, stackTrace, formattedMessage); + buildEventListener.projectLogMessage(projectId, event); } @Override public void close() throws Exception { MavenSimpleLogger.setLogSink(null); } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + private static String formatStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java index 87f7baa1fd59..37c49a92c1c4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java @@ -20,6 +20,7 @@ import java.util.function.Consumer; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -38,8 +39,9 @@ public void sessionStarted(ExecutionEvent event) {} public void projectStarted(String projectId) {} @Override - public void projectLogMessage(String projectId, String event) { - log(event); + public void projectLogMessage(String projectId, LogEvent event) { + String formatted = event.formattedMessage(); + log(formatted != null ? formatted : event.message()); } @Override 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 new file mode 100644 index 000000000000..5733daf74d0a --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java @@ -0,0 +1,414 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; + +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; +import org.apache.maven.execution.ExecutionEvent; +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; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportCollectorTest { + + @TempDir + Path tempDir; + + private BuildReportCollector collector; + + @BeforeEach + void setUp() { + collector = new BuildReportCollector(); + } + + @Test + void testBuildReportAssembly() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + // 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)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo)); + + // Record build success in the result + result.addBuildSummary(new BuildSuccess(project, 5000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + // Build the report + BuildReport report = collector.buildReport(session); + + assertNotNull(report); + assertEquals(BuildStatus.SUCCESS, report.status()); + assertEquals(1, report.formatVersion()); + assertEquals("1.0.0", report.mavenVersion()); + assertFalse(report.multiModule()); + assertEquals(1, report.threads()); + assertEquals(1, report.modules().size()); + assertEquals("org.example", report.modules().get(0).groupId()); + assertEquals("my-app", report.modules().get(0).artifactId()); + assertEquals(BuildStatus.SUCCESS, report.modules().get(0).status()); + assertEquals(1, report.modules().get(0).mojos().size()); + 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()); + } + + @Test + void testBuildReportWithFailure() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoFailed, session, project, mojo)); + + RuntimeException failure = new RuntimeException("Compilation failure: 3 errors"); + result.addBuildSummary(new org.apache.maven.execution.BuildFailure(project, 3000, failure)); + result.addException(failure); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectFailed, session, project, null)); + + BuildReport report = collector.buildReport(session); + + assertEquals(BuildStatus.FAILURE, report.status()); + assertEquals(1, report.failures().size()); + assertEquals("org.example:my-app:1.0.0", report.failures().get(0).module()); + assertTrue(report.failures().get(0).message().contains("Compilation failure")); + 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 + var failureReport = report.failures().get(0); + var moduleOpt = report.findModule(failureReport); + assertTrue(moduleOpt.isPresent(), "findModule(FailureReport) should find the module"); + assertEquals("my-app", moduleOpt.get().artifactId()); + assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id()); + + assertNotNull(failureReport.mojo(), "failure should reference a mojo"); + var mojoOpt = moduleOpt.get().findMojo(failureReport.mojo()); + assertTrue(mojoOpt.isPresent(), "findMojo should find the failed mojo"); + assertEquals("compile", mojoOpt.get().goal()); + assertEquals(BuildStatus.FAILURE, mojoOpt.get().status()); + assertEquals("maven-compiler-plugin:3.15.0:compile", mojoOpt.get().id()); + } + + @Test + void testWriteReportToFile() 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); + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + assertTrue(Files.exists(latestFile), "build-report-latest.json should exist"); + + String content = Files.readString(latestFile); + assertTrue(content.contains("\"formatVersion\": 1")); + assertTrue(content.contains("\"status\": \"SUCCESS\"")); + assertTrue(content.contains("\"artifactId\": \"my-app\"")); + } + + @Test + void testMultiModuleBuild() { + MavenProject parent = createProject("org.example", "parent", "1.0.0"); + MavenProject child1 = createProject("org.example", "child-api", "1.0.0"); + MavenProject child2 = createProject("org.example", "child-impl", "1.0.0"); + + MavenSession session = createSession(parent, child1, child2); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null)); + + // Build each module + for (MavenProject p : List.of(parent, child1, child2)) { + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, p, null)); + result.addBuildSummary(new BuildSuccess(p, 1000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, p, null)); + } + + BuildReport report = collector.buildReport(session); + + assertEquals(BuildStatus.SUCCESS, report.status()); + assertTrue(report.multiModule()); + assertEquals(3, report.modules().size()); + assertEquals("parent", report.modules().get(0).artifactId()); + assertEquals("child-api", report.modules().get(1).artifactId()); + 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 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)); + + assertEquals(1, collector.getDiagnosticCollector().getProblems().size()); + assertEquals( + "key-c", collector.getDiagnosticCollector().getProblems().get(0).getKey()); + } + + @Test + 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); + project.setArtifactId(artifactId); + project.setVersion(version); + return project; + } + + private MavenSession createSession(MavenProject... projects) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartInstant(MonotonicClock.now()); + request.setGoals(List.of("clean", "install")); + request.setTopDirectory(tempDir); + + Properties systemProperties = new Properties(); + systemProperties.setProperty("maven.version", "1.0.0"); + request.setSystemProperties(systemProperties); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + @SuppressWarnings("deprecation") + MavenSession session = new MavenSession(null, null, request, result); + session.setProjects(List.of(projects)); + return session; + } + + private MojoExecution createMojoExecution( + String groupId, String artifactId, String version, String goal, String executionId, String phase) { + @SuppressWarnings("deprecation") + PluginDescriptor pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(groupId); + pluginDescriptor.setArtifactId(artifactId); + pluginDescriptor.setVersion(version); + + MojoDescriptor mojoDescriptor = new MojoDescriptor(); + mojoDescriptor.setGoal(goal); + mojoDescriptor.setPluginDescriptor(pluginDescriptor); + + MojoExecution execution = new MojoExecution(mojoDescriptor, executionId); + execution.setLifecyclePhase(phase); + + return execution; + } + + private ExecutionEvent createEvent( + ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) { + return new ExecutionEvent() { + @Override + public Type getType() { + return type; + } + + @Override + public MavenSession getSession() { + return session; + } + + @Override + public MavenProject getProject() { + return project; + } + + @Override + public MojoExecution getMojoExecution() { + return mojo; + } + + @Override + public Exception getException() { + return null; + } + }; + } +} 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 new file mode 100644 index 000000000000..e200cd06b6f8 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java @@ -0,0 +1,359 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; + +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; +import org.apache.maven.execution.ExecutionEvent; +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; +import org.apache.maven.project.MavenProject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test that exercises the full build report pipeline: + * BuildReportCollector → DefaultDiagnosticCollector → BuildReportJsonWriter → file. + *

+ * This test simulates a complete multi-module build lifecycle with mojo + * 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() { + 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, problems, and JSON persistence. + */ + @Test + 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"); + + MavenSession session = createSession(parent, api, impl); + MavenExecutionResult result = session.getResult(); + + // Session starts + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null)); + + // --- Module: parent --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, parent, null)); + result.addBuildSummary(new BuildSuccess(parent, 500)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, parent, null)); + + // --- Module: api --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, api, null)); + + 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( + "org.apache.maven.plugins", "maven-surefire-plugin", "3.5.0", "test", "default-test", "test"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, testApi)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, testApi)); + + result.addBuildSummary(new BuildSuccess(api, 3000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, api, null)); + + // --- Module: impl --- + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, impl, null)); + + 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)); + + // 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)); + + // --- Build report --- + BuildReport report = collector.buildReport(session); + + // Basic assertions + assertNotNull(report); + assertEquals(BuildStatus.SUCCESS, report.status()); + assertTrue(report.multiModule()); + assertEquals(3, report.modules().size()); + + // 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()); + assertEquals("api", report.modules().get(1).artifactId()); + assertEquals(2, report.modules().get(1).mojos().size()); + assertEquals("compile", report.modules().get(1).mojos().get(0).goal()); + assertEquals("test", report.modules().get(1).mojos().get(1).goal()); + assertEquals("impl", report.modules().get(2).artifactId()); + + // Write to JSON and verify + collector.writeReport(report, session); + + Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR); + Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST); + assertTrue(Files.exists(latestFile), "build-report-latest.json should exist"); + + String json = Files.readString(latestFile); + + // Verify JSON structure + assertTrue(json.contains("\"formatVersion\": 1")); + assertTrue(json.contains("\"status\": \"SUCCESS\"")); + assertTrue(json.contains("\"multiModule\": true")); + assertTrue(json.contains("\"threads\": 1")); + + // Modules in JSON + assertTrue(json.contains("\"artifactId\": \"parent\"")); + assertTrue(json.contains("\"artifactId\": \"api\"")); + assertTrue(json.contains("\"artifactId\": \"impl\"")); + + // Mojos in JSON + assertTrue(json.contains("\"goal\": \"compile\"")); + assertTrue(json.contains("\"goal\": \"test\"")); + + // 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) + 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. + */ + @Test + void testReportNavigationMethods() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + MavenExecutionResult result = session.getResult(); + + collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null)); + + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo)); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo)); + + result.addBuildSummary(new BuildSuccess(project, 5000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + + // findModule by GAV + var moduleOpt = report.findModule("org.example:my-app:1.0.0"); + assertTrue(moduleOpt.isPresent()); + assertEquals("my-app", moduleOpt.get().artifactId()); + assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id()); + + // findMojo by id + var mojoOpt = moduleOpt.get().findMojo("maven-compiler-plugin:3.15.0:compile"); + assertTrue(mojoOpt.isPresent()); + assertEquals("compile", mojoOpt.get().goal()); + + // Not found + assertFalse(report.findModule("nonexistent:module:1.0").isPresent()); + } + + // ---- Test helpers ---- + + private MavenProject createProject(String groupId, String artifactId, String version) { + MavenProject project = new MavenProject(); + project.setGroupId(groupId); + project.setArtifactId(artifactId); + project.setVersion(version); + return project; + } + + private MavenSession createSession(MavenProject... projects) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartInstant(MonotonicClock.now()); + request.setGoals(List.of("clean", "install")); + request.setTopDirectory(tempDir); + + Properties systemProperties = new Properties(); + systemProperties.setProperty("maven.version", "4.1.0-SNAPSHOT"); + request.setSystemProperties(systemProperties); + + MavenExecutionResult result = new DefaultMavenExecutionResult(); + + @SuppressWarnings("deprecation") + MavenSession session = new MavenSession(null, null, request, result); + session.setProjects(List.of(projects)); + return session; + } + + private MojoExecution createMojoExecution( + String groupId, String artifactId, String version, String goal, String executionId, String phase) { + @SuppressWarnings("deprecation") + PluginDescriptor pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(groupId); + pluginDescriptor.setArtifactId(artifactId); + pluginDescriptor.setVersion(version); + + MojoDescriptor mojoDescriptor = new MojoDescriptor(); + mojoDescriptor.setGoal(goal); + mojoDescriptor.setPluginDescriptor(pluginDescriptor); + + MojoExecution execution = new MojoExecution(mojoDescriptor, executionId); + execution.setLifecyclePhase(phase); + + return execution; + } + + private ExecutionEvent createEvent( + ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) { + return new ExecutionEvent() { + @Override + public Type getType() { + return type; + } + + @Override + public MavenSession getSession() { + return session; + } + + @Override + public MavenProject getProject() { + return project; + } + + @Override + public MojoExecution getMojoExecution() { + return mojo; + } + + @Override + public Exception getException() { + return null; + } + }; + } +} 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 new file mode 100644 index 000000000000..073072c4a539 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java @@ -0,0 +1,416 @@ +/* + * 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.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.apache.maven.api.build.report.BuildReport; +import org.apache.maven.api.build.report.BuildStatus; +import org.apache.maven.api.build.report.FailureReport; +import org.apache.maven.api.build.report.LogEvent; +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; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildReportJsonWriterTest { + + private static final Instant BASE_TIME = Instant.parse("2025-01-15T10:30:00Z"); + + @Test + void testSuccessfulBuildReport() { + List mojoOutput = List.of( + new DefaultLogEvent( + BASE_TIME.plusSeconds(6), LogLevel.INFO, "Compiling 42 source files", "o.a.m.compiler", null), + new DefaultLogEvent(BASE_TIME.plusSeconds(7), LogLevel.INFO, "BUILD SUCCESS", "o.a.m.compiler", null)); + + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-compiler-plugin", + "3.15.0", + "compile", + "default-compile", + "compile", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(5), + Duration.ofMillis(2100), + mojoOutput); + + List moduleOutput = List.of(new DefaultLogEvent( + BASE_TIME.plusSeconds(2), + LogLevel.INFO, + "Resolving dependencies for maven-core", + "o.a.m.resolver", + null)); + + ModuleReport module = new DefaultModuleReport( + "org.apache.maven", + "maven-core", + "4.1.0-SNAPSHOT", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofMillis(12345), + List.of(mojo), + moduleOutput); + + List buildOutput = List.of( + new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Reactor Build Order:", "o.a.m.reactor", null), + new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Maven Core", "o.a.m.reactor", null)); + + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofMillis(30000), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("clean", "install"), + "org.apache.maven:maven:4.1.0-SNAPSHOT", + true, + 4, + List.of(module), + List.of(), + List.of(), + buildOutput); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"formatVersion\": 1")); + assertTrue(json.contains("\"status\": \"SUCCESS\"")); + assertTrue(json.contains("\"mavenVersion\": \"4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"javaVersion\": \"21.0.1\"")); + assertTrue(json.contains("\"goals\": [\"clean\", \"install\"]")); + assertTrue(json.contains("\"multiModule\": true")); + assertTrue(json.contains("\"threads\": 4")); + assertTrue(json.contains("\"groupId\": \"org.apache.maven\"")); + assertTrue(json.contains("\"artifactId\": \"maven-core\"")); + assertTrue(json.contains("\"goal\": \"compile\"")); + assertTrue(json.contains("\"executionId\": \"default-compile\"")); + assertTrue(json.contains("\"failures\": []")); + // Module and mojo start times + assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:01Z\""), "module startTime"); + assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:05Z\""), "mojo startTime"); + // Structured log events at all three levels + assertTrue(json.contains("\"message\": \"Compiling 42 source files\""), "mojo-level log event"); + assertTrue(json.contains("\"message\": \"Resolving dependencies for maven-core\""), "module-level log event"); + assertTrue(json.contains("\"message\": \"Reactor Build Order:\""), "build-level log event"); + // Log event structure + assertTrue(json.contains("\"level\": \"INFO\""), "log level"); + assertTrue(json.contains("\"loggerName\": \"o.a.m.compiler\""), "logger name"); + } + + @Test + void testFailedBuildReport() { + FailureReport failure = new DefaultFailureReport( + "org.apache.maven:maven-core:4.1.0-SNAPSHOT", + "maven-compiler-plugin:3.15.0:compile", + BASE_TIME.plusSeconds(3), + "CompilationFailureException", + "Compilation failure: 3 errors", + "org.apache.maven.plugin.compiler.CompilationFailureException: ...\n\tat ...\n"); + + BuildReport report = new DefaultBuildReport( + BuildStatus.FAILURE, + Duration.ofMillis(5000), + BASE_TIME, + "4.1.0-SNAPSHOT", + "21.0.1", + List.of("compile"), + "org.apache.maven:maven-core:4.1.0-SNAPSHOT", + false, + 1, + List.of(), + List.of(failure), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"status\": \"FAILURE\"")); + assertTrue(json.contains("\"module\": \"org.apache.maven:maven-core:4.1.0-SNAPSHOT\"")); + assertTrue(json.contains("\"mojo\": \"maven-compiler-plugin:3.15.0:compile\"")); + assertTrue(json.contains("\"message\": \"Compilation failure: 3 errors\"")); + assertTrue(json.contains("\"stackTrace\"")); + // New enriched fields + assertTrue(json.contains("\"timestamp\": \"2025-01-15T10:30:03Z\""), "failure timestamp"); + assertTrue(json.contains("\"exceptionType\": \"CompilationFailureException\""), "failure exceptionType"); + } + + @Test + void testJsonStringEscaping() { + FailureReport failure = new DefaultFailureReport( + "com.example:test:1.0", + null, + BASE_TIME, + null, + "Error: \"unexpected\" value\nwith newline\tand tab", + null); + + BuildReport report = new DefaultBuildReport( + BuildStatus.FAILURE, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(failure), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // Check proper JSON escaping + assertTrue(json.contains("\\\"unexpected\\\"")); + assertTrue(json.contains("\\n")); + assertTrue(json.contains("\\t")); + } + + @Test + void testEmptyModulesAndFailures() { + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + assertTrue(json.contains("\"modules\": []")); + assertTrue(json.contains("\"failures\": []")); + } + + @Test + void testFormatVersion() { + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ZERO, + BASE_TIME, + "4.1.0", + "21", + List.of(), + "test:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + assertEquals(1, report.formatVersion()); + } + + @Test + void testMultipleModules() { + ModuleReport mod1 = new DefaultModuleReport( + "com.example", + "api", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofSeconds(5), + List.of(), + List.of()); + ModuleReport mod2 = new DefaultModuleReport( + "com.example", + "impl", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(6), + Duration.ofSeconds(10), + List.of(), + List.of()); + ModuleReport mod3 = new DefaultModuleReport( + "com.example", + "web", + "1.0", + BuildStatus.SKIPPED, + BASE_TIME.plusSeconds(16), + Duration.ZERO, + List.of(), + List.of()); + + BuildReport report = new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofSeconds(15), + BASE_TIME, + "4.1.0", + "21", + List.of("install"), + "com.example:parent:1.0", + true, + 1, + List.of(mod1, mod2, mod3), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + + // All modules present + assertTrue(json.contains("\"artifactId\": \"api\"")); + assertTrue(json.contains("\"artifactId\": \"impl\"")); + assertTrue(json.contains("\"artifactId\": \"web\"")); + assertTrue(json.contains("\"status\": \"SKIPPED\"")); + } + + @Test + void testNullMojoFields() { + // mojo with null executionId and phase (direct invocation) + MojoReport mojo = new DefaultMojoReport( + "org.apache.maven.plugins", + "maven-help-plugin", + "3.4.1", + "effective-pom", + null, + null, + BuildStatus.SUCCESS, + BASE_TIME.plusSeconds(1), + Duration.ofMillis(500), + List.of()); + + String json = BuildReportJsonWriter.toJson(new DefaultBuildReport( + BuildStatus.SUCCESS, + Duration.ofSeconds(1), + BASE_TIME, + "4.1.0", + "21", + List.of("help:effective-pom"), + "test:test:1.0", + false, + 1, + List.of(new DefaultModuleReport( + "test", + "test", + "1.0", + BuildStatus.SUCCESS, + BASE_TIME, + Duration.ofSeconds(1), + List.of(mojo), + List.of())), + List.of(), + List.of(), + List.of())); + + assertTrue(json.contains("\"executionId\": null")); + assertTrue(json.contains("\"phase\": null")); + assertFalse(json.contains("\"executionId\": \"null\"")); + } + + @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( + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + assertTrue(json.contains("\"problems\": []"), "empty problems array"); + } +} 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); diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java index 20a5e7ab666b..d14167dfea51 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java @@ -232,6 +232,23 @@ protected void write(StringBuilder buf, Throwable t) { } } + /** + * Context-aware write that includes the log level, logger name, and + * clean message alongside the formatted output. Subclasses can override + * to forward structured data to a log sink. + *

+ * The default implementation delegates to {@link #write(StringBuilder, Throwable)}. + * + * @param level the SLF4J log level constant + * @param loggerName the name of the logger + * @param cleanMessage the formatted message without level/timestamp prefix + * @param formattedBuf the fully formatted log line + * @param t the throwable, may be {@code null} + */ + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + write(formattedBuf, t); + } + protected void writeThrowable(Throwable t, PrintStream targetStream) { if (t != null) { t.printStackTrace(targetStream); @@ -375,7 +392,24 @@ private void innerHandleNormalizedLoggingCall( // Append the message buf.append(formattedMessage); - write(buf, t); + write(level.toInt(), name, formattedMessage, buf, t); + + // Notify structured log event listener (for build report capture) + onLogEvent(level.toInt(), name, formattedMessage, t); + } + + /** + * Hook called after every log event with structured data. + * Subclasses can override to forward to a structured event sink + * (e.g. for build report log capture). + * + * @param level the log level (one of the {@code LOG_LEVEL_*} constants) + * @param loggerName the name of the logger that produced the event + * @param message the formatted log message (without level/timestamp prefix) + * @param throwable the associated throwable, or {@code null} + */ + protected void onLogEvent(int level, String loggerName, String message, Throwable throwable) { + // no-op — overridden by MavenSimpleLogger } protected String renderLevel(int levelInt) { diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java index 02767987e2a1..5d44a0637555 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java @@ -39,14 +39,65 @@ public class MavenSimpleLogger extends MavenBaseLogger { private String warnRenderedLevel; private String errorRenderedLevel; - static Consumer logSink; + /** + * Structured log sink that receives level, logger name, clean message, + * formatted console output, and throwable for each log event. + *

+ * This replaces the previous {@code Consumer} sink to enable + * console renderers (e.g. rich mode) to filter by log level and access + * the clean message independently of ANSI formatting. + * + * @since 4.1.0 + */ + @FunctionalInterface + public interface LogSink { + void accept(int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable); + } + + static volatile LogSink logSink; + + /** + * Structured log event sink for build report capture. + * Receives level, logger name, clean message, and throwable + * independently of the formatted console output. + */ + @FunctionalInterface + public interface LogEventSink { + void accept(int level, String loggerName, String message, Throwable throwable); + } + + private static volatile LogEventSink logEventSink; public static final String DEFAULT_LOG_LEVEL_KEY = "org.slf4j.simpleLogger.defaultLogLevel"; - public static void setLogSink(Consumer logSink) { + /** + * Sets the structured log sink. + * + * @param logSink the sink, or {@code null} to remove + * @since 4.1.0 + */ + public static void setLogSink(LogSink logSink) { MavenSimpleLogger.logSink = logSink; } + public static void setLogEventSink(LogEventSink sink) { + MavenSimpleLogger.logEventSink = sink; + } + + public static LogEventSink getLogEventSink() { + return logEventSink; + } + + /** + * Returns the current log sink, or {@code null} if none is set. + * + * @return the current log sink, or {@code null} + * @since 4.1.0 + */ + public static LogSink getLogSink() { + return logSink; + } + MavenSimpleLogger(String name) { super(name); } @@ -70,15 +121,74 @@ protected String renderLevel(int level) { } @Override - protected void write(StringBuilder buf, Throwable t) { - Consumer sink = logSink; + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + LogSink sink = logSink; if (sink != null) { - sink.accept(buf.toString()); + // Build the full formatted output including throwable rendering + String formatted = formattedBuf.toString(); if (t != null) { - writeThrowable(t, sink); + StringBuilder full = new StringBuilder(formatted); + full.append(System.lineSeparator()); + appendFormattedThrowable(full, t, ""); + formatted = full.toString(); } + sink.accept(level, loggerName, cleanMessage, formatted, t); } else { - super.write(buf, t); + super.write(formattedBuf, t); + } + } + + /** + * Append a colorized throwable rendering to the given builder. + * Reuses the existing formatting logic for consistency with console output. + */ + private void appendFormattedThrowable(StringBuilder sb, Throwable t, String prefix) { + MessageBuilder builder = builder().a(prefix).failure(t.getClass().getName()); + if (t.getMessage() != null) { + builder.a(": ").failure(t.getMessage()); + } + sb.append(builder.toString()).append(System.lineSeparator()); + appendStackTrace(sb, t, prefix); + } + + private void appendStackTrace(StringBuilder sb, Throwable t, String prefix) { + MessageBuilder builder = builder(); + for (StackTraceElement e : t.getStackTrace()) { + builder.a(prefix); + builder.a(" "); + builder.strong("at"); + builder.a(" "); + builder.a(e.getClassName()); + builder.a("."); + builder.a(e.getMethodName()); + builder.a("("); + builder.strong(getLocation(e)); + builder.a(")"); + sb.append(builder.toString()).append(System.lineSeparator()); + builder.setLength(0); + } + for (Throwable se : t.getSuppressed()) { + builder.a(prefix) + .a(" ") + .strong("Suppressed") + .a(": ") + .a(se.getClass().getName()); + if (se.getMessage() != null) { + builder.a(": ").failure(se.getMessage()); + } + sb.append(builder.toString()).append(System.lineSeparator()); + builder.setLength(0); + appendStackTrace(sb, se, prefix + " "); + } + Throwable cause = t.getCause(); + if (cause != null && t != cause) { + builder.a(prefix).strong("Caused by").a(": ").a(cause.getClass().getName()); + if (cause.getMessage() != null) { + builder.a(": ").failure(cause.getMessage()); + } + sb.append(builder.toString()).append(System.lineSeparator()); + builder.setLength(0); + appendStackTrace(sb, cause, prefix); } } @@ -167,4 +277,12 @@ public void configure(int defaultLogLevel) { public void setLogLevel(int logLevel) { this.currentLogLevel = logLevel; } + + @Override + protected void onLogEvent(int level, String loggerName, String message, Throwable throwable) { + LogEventSink sink = logEventSink; + if (sink != null) { + sink.accept(level, loggerName, message, throwable); + } + } } diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml index 5871a3570f4e..c5b0802febba 100644 --- a/its/core-it-suite/pom.xml +++ b/its/core-it-suite/pom.xml @@ -519,7 +519,7 @@ under the License. - + @@ -749,7 +749,7 @@ under the License. - + @@ -804,7 +804,7 @@ under the License. - + diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java new file mode 100644 index 000000000000..dbb19974cbbe --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12571BuildReportTest.java @@ -0,0 +1,423 @@ +/* + * 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.it; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for the Build Report Foundation feature. + *

+ * Covers: build report JSON generation, console modes (plain, machine, verbose), + * warning mode, version info on failure, and the {@code mvnlog} viewer tool. + * + * @see gh-12571 + * @since 4.1.0 + */ +class MavenITgh12571BuildReportTest extends AbstractMavenIntegrationTestCase { + + // ------------------------------------------------------------------------- + // Build report JSON generation + // ------------------------------------------------------------------------- + + /** + * Verify that a successful single-module build produces a JSON report file + * containing the expected top-level fields. + */ + @Test + void testBuildReportJsonGenerated() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("report-gen.txt"); + verifier.addCliArgument("--console=verbose"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Build report file must exist + Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json"); + verifier.verifyFilePresent(reportFile); + + // Verify JSON structure + String json = Files.readString(reportFile); + assertTrue(json.contains("\"formatVersion\""), "Should contain formatVersion"); + assertTrue(json.contains("\"status\""), "Should contain status"); + assertTrue(json.contains("\"SUCCESS\""), "Status should be SUCCESS"); + assertTrue(json.contains("\"duration\""), "Should contain duration"); + assertTrue(json.contains("\"mavenVersion\""), "Should contain mavenVersion"); + assertTrue(json.contains("\"javaVersion\""), "Should contain javaVersion"); + assertTrue(json.contains("\"modules\""), "Should contain modules array"); + assertTrue(json.contains("\"problems\""), "Should contain problems array"); + assertTrue(json.contains("\"failures\""), "Should contain failures array"); + assertTrue(json.contains("\"build-report-test\""), "Should contain artifactId"); + } + + /** + * Verify that a multi-module build produces a JSON report with all modules listed. + */ + @Test + void testBuildReportMultiModule() throws Exception { + Path basedir = extractResources("gh-12571-multi-module"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("multi-module.txt"); + verifier.addCliArgument("--console=verbose"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json"); + verifier.verifyFilePresent(reportFile); + + String json = Files.readString(reportFile); + assertTrue(json.contains("\"module-a\""), "Should contain module-a"); + assertTrue(json.contains("\"module-b\""), "Should contain module-b"); + assertTrue(json.contains("\"multi-module-parent\""), "Should contain parent"); + assertTrue(json.contains("\"multiModule\""), "Should contain multiModule flag"); + } + + // ------------------------------------------------------------------------- + // Console modes + // ------------------------------------------------------------------------- + + /** + * Verify that {@code --console=plain} produces compact output without + * the full mojo-level detail that verbose mode shows. + */ + @Test + void testConsolePlainMode() throws Exception { + Path basedir = extractResources("gh-12571-multi-module"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("plain.txt"); + verifier.addCliArgument("--console=plain"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Plain mode should still show BUILD SUCCESS and Total time + verifier.verifyTextInLog("BUILD SUCCESS"); + verifier.verifyTextInLog("Total time:"); + + // Plain mode should NOT show the verbose "--- plugin:goal" lines + List lines = verifier.loadLines("plain.txt"); + boolean hasPluginLine = lines.stream().anyMatch(l -> l.matches(".*---.*:.*---.*")); + assertFalse(hasPluginLine, "Plain mode should not contain verbose mojo execution lines"); + } + + /** + * Verify that {@code --console=machine} produces JSON lines output + * with typed events. + */ + @Test + void testConsoleMachineMode() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("machine.txt"); + verifier.addCliArgument("--console=machine"); + verifier.addCliArgument("validate"); + verifier.execute(); + + List lines = verifier.loadLines("machine.txt"); + + // Machine mode should produce JSON lines with "event" fields + boolean hasBuildStarted = lines.stream().anyMatch(l -> l.contains("\"event\":\"build.started\"")); + boolean hasBuildFinished = lines.stream().anyMatch(l -> l.contains("\"event\":\"build.finished\"")); + boolean hasModuleStarted = lines.stream().anyMatch(l -> l.contains("\"event\":\"module.started\"")); + boolean hasTimestamp = lines.stream().anyMatch(l -> l.contains("\"timestamp\"")); + + assertTrue(hasBuildStarted, "Should contain build.started event"); + assertTrue(hasBuildFinished, "Should contain build.finished event"); + assertTrue(hasModuleStarted, "Should contain module.started event"); + assertTrue(hasTimestamp, "Events should contain timestamps"); + + // build.finished should report SUCCESS + boolean hasSuccess = + lines.stream().anyMatch(l -> l.contains("\"event\":\"build.finished\"") && l.contains("\"SUCCESS\"")); + assertTrue(hasSuccess, "build.finished should report SUCCESS"); + } + + /** + * Verify that {@code --console=verbose} (the classic Maven output) includes + * the standard banner lines and mojo execution details. + */ + @Test + void testConsoleVerboseMode() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("verbose.txt"); + verifier.addCliArgument("--console=verbose"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + verifier.verifyTextInLog("BUILD SUCCESS"); + verifier.verifyTextInLog("Total time:"); + // Verbose mode shows the horizontal rule separator + verifier.verifyTextInLog("------------------------------------------------------------------------"); + } + + /** + * Verify that when the {@code CI} environment variable is set, + * {@code --console=auto} resolves to plain mode (no verbose mojo lines). + */ + @Test + void testConsoleAutoDetectsCi() throws Exception { + Path basedir = extractResources("gh-12571-multi-module"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("auto-ci.txt"); + verifier.setEnvironmentVariable("CI", "true"); + verifier.addCliArgument("--console=auto"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + verifier.verifyTextInLog("BUILD SUCCESS"); + + // In CI mode (plain), should NOT show verbose mojo execution lines + List lines = verifier.loadLines("auto-ci.txt"); + boolean hasPluginLine = lines.stream().anyMatch(l -> l.matches(".*---.*:.*---.*")); + assertFalse(hasPluginLine, "Auto mode with CI=true should not produce verbose mojo lines"); + } + + // ------------------------------------------------------------------------- + // Warning mode + // ------------------------------------------------------------------------- + + /** + * Verify that {@code --warning-mode=none} suppresses the diagnostic summary + * at the end of the build. + */ + @Test + void testWarningModeNone() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("warn-none.txt"); + verifier.addCliArgument("--console=verbose"); + verifier.addCliArgument("--warning-mode=none"); + verifier.addCliArgument("validate"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // With --warning-mode=none, the diagnostic summary line should not appear + verifier.verifyTextNotInLog("Diagnostics:"); + } + + // ------------------------------------------------------------------------- + // Version info on failure (MNG-7372) + // ------------------------------------------------------------------------- + + /** + * Verify that on BUILD FAILURE the Maven version and Java version + * are printed in the summary output. + */ + @Test + void testVersionInfoOnFailure() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + Verifier verifier = newVerifier(basedir); + verifier.setLogFileName("fail-version.txt"); + verifier.addCliArgument("--console=verbose"); + // Invoke a non-existent goal to trigger a failure + verifier.addCliArgument("org.apache.maven.plugins:non-existent-plugin:1.0:goal"); + + boolean failed = false; + try { + verifier.execute(); + } catch (VerificationException e) { + failed = true; + } + + assertTrue(failed, "Build should have failed"); + verifier.verifyTextInLog("BUILD FAILURE"); + // The version info line should be present (e.g. "Maven 4.1.0-SNAPSHOT | Java 21.0.x") + verifier.verifyTextInLog("Maven"); + verifier.verifyTextInLog("Java"); + } + + // ------------------------------------------------------------------------- + // mvnlog viewer + // ------------------------------------------------------------------------- + + /** + * Verify that {@code mvnlog} displays a summary of the last build report + * after a successful build. + */ + @Test + void testMvnlogShowsSummary() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + // Step 1: run a build to generate the report + Verifier buildVerifier = newVerifier(basedir); + buildVerifier.setLogFileName("build-for-mvnlog.txt"); + buildVerifier.addCliArgument("--console=verbose"); + buildVerifier.addCliArgument("validate"); + buildVerifier.execute(); + buildVerifier.verifyErrorFreeLog(); + + // Verify the report was generated + Path reportFile = basedir.resolve("target/build-reports/build-report-latest.json"); + buildVerifier.verifyFilePresent(reportFile); + + // Step 2: run mvnlog to view the report (forked: embedded executor does not know mvnlog) + Verifier logVerifier = newVerifier(basedir); + logVerifier.setLogFileName("mvnlog-summary.txt"); + logVerifier.setForkJvm(true); + logVerifier.setExecutable("mvnlog"); + logVerifier.execute(); + + // mvnlog should display report content + logVerifier.verifyTextInLog("Build Report"); + logVerifier.verifyTextInLog("SUCCESS"); + } + + /** + * Verify that {@code mvnlog --full} shows per-module detail. + */ + @Test + void testMvnlogFullView() throws Exception { + Path basedir = extractResources("gh-12571-multi-module"); + + // Step 1: run a build to generate the report + Verifier buildVerifier = newVerifier(basedir); + buildVerifier.setLogFileName("build-for-full.txt"); + buildVerifier.addCliArgument("--console=verbose"); + buildVerifier.addCliArgument("validate"); + buildVerifier.execute(); + buildVerifier.verifyErrorFreeLog(); + + // Step 2: run mvnlog --full (forked: embedded executor does not know mvnlog) + Verifier logVerifier = newVerifier(basedir); + logVerifier.setLogFileName("mvnlog-full.txt"); + logVerifier.setForkJvm(true); + logVerifier.setExecutable("mvnlog"); + logVerifier.addCliArgument("--full"); + logVerifier.execute(); + + // Full view should show individual module details + logVerifier.verifyTextInLog("module-a"); + logVerifier.verifyTextInLog("module-b"); + } + + /** + * Verify that {@code mvnlog} reports an error when no build report exists. + */ + @Test + void testMvnlogNoReport() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + // Clean any prior reports + Path reportsDir = basedir.resolve("target/build-reports"); + if (Files.isDirectory(reportsDir)) { + Files.walk(reportsDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (Exception e) { + // ignore + } + }); + } + + Verifier logVerifier = newVerifier(basedir); + logVerifier.setLogFileName("mvnlog-no-report.txt"); + logVerifier.setForkJvm(true); + logVerifier.setExecutable("mvnlog"); + + boolean failed = false; + try { + logVerifier.execute(); + } catch (VerificationException e) { + failed = true; + } + + assertTrue(failed, "mvnlog should fail when no report exists"); + logVerifier.verifyTextInLog("Build report not found"); + } + + /** + * Verify that {@code mvnlog --list} lists available reports. + */ + @Test + void testMvnlogListReports() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + // Step 1: run a build to generate at least one report + Verifier buildVerifier = newVerifier(basedir); + buildVerifier.setLogFileName("build-for-list.txt"); + buildVerifier.addCliArgument("--console=verbose"); + buildVerifier.addCliArgument("validate"); + buildVerifier.execute(); + buildVerifier.verifyErrorFreeLog(); + + // Step 2: run mvnlog --list (forked: embedded executor does not know mvnlog) + Verifier logVerifier = newVerifier(basedir); + logVerifier.setLogFileName("mvnlog-list.txt"); + logVerifier.setForkJvm(true); + logVerifier.setExecutable("mvnlog"); + logVerifier.addCliArgument("--list"); + logVerifier.execute(); + + // Should list the report file(s) + logVerifier.verifyTextInLog("build-report"); + } + + /** + * Verify that {@code mvnlog --json} outputs the raw JSON build report, + * suitable for piping to tools like {@code jq}. + */ + @Test + void testMvnlogJsonOutput() throws Exception { + Path basedir = extractResources("gh-12571-build-report"); + + // Step 1: run a build to generate the report + Verifier buildVerifier = newVerifier(basedir); + buildVerifier.setLogFileName("build-for-json.txt"); + buildVerifier.addCliArgument("--console=verbose"); + buildVerifier.addCliArgument("validate"); + buildVerifier.execute(); + buildVerifier.verifyErrorFreeLog(); + + // Step 2: run mvnlog --json (forked: embedded executor does not know mvnlog) + Verifier logVerifier = newVerifier(basedir); + logVerifier.setLogFileName("mvnlog-json.txt"); + logVerifier.setForkJvm(true); + logVerifier.setExecutable("mvnlog"); + logVerifier.addCliArgument("--json"); + logVerifier.execute(); + + // Output should be valid JSON with expected fields + logVerifier.verifyTextInLog("\"formatVersion\""); + logVerifier.verifyTextInLog("\"status\""); + logVerifier.verifyTextInLog("\"modules\""); + logVerifier.verifyTextInLog("\"mavenVersion\""); + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java index 010e39cc2d32..695fd825d615 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5760ResumeFeatureTest.java @@ -63,6 +63,7 @@ public void testShouldSuggestToResumeWithoutArgs() throws Exception { verifier.addCliArgument("-Dmodule-b.fail=true"); try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("test"); verifier.execute(); fail("Expected this invocation to fail"); @@ -74,6 +75,7 @@ public void testShouldSuggestToResumeWithoutArgs() throws Exception { // New build with -r should resume the build from module-b, skipping module-a since it has succeeded already. verifier = newVerifier(parentDependentTestDir); verifier.addCliArgument("-r"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("test"); verifier.execute(); verifier.verifyTextNotInLog("Building module-a 1.0"); @@ -88,6 +90,7 @@ public void testShouldSkipSuccessfulProjects() throws Exception { verifier.addCliArgument("--fail-at-end"); try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("test"); verifier.execute(); fail("Expected this invocation to fail"); @@ -102,6 +105,7 @@ public void testShouldSkipSuccessfulProjects() throws Exception { // ... but adding -r should exclude those two from the build because the previous Maven invocation // marked them as successfully built. verifier.addCliArgument("-r"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("test"); verifier.execute(); } @@ -116,6 +120,7 @@ public void testShouldSkipSuccessfulModulesWhenTheFirstModuleFailed() throws Exc verifier.addCliArgument("--fail-at-end"); try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("test"); verifier.execute(); fail("Expected this invocation to fail"); @@ -125,6 +130,7 @@ public void testShouldSkipSuccessfulModulesWhenTheFirstModuleFailed() throws Exc verifier = newVerifier(parentIndependentTestDir); verifier.addCliArgument("-r"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("test"); verifier.execute(); verifier.verifyTextInLog("Building module-a 1.0"); @@ -139,6 +145,7 @@ public void testShouldNotCrashWithoutProject() throws Exception { // https://issues.apache.org/jira/browse/MNG-5760?focusedCommentId=17143795&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-17143795) final Verifier verifier = newVerifier(noProjectTestDir); try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("org.apache.maven.plugins:maven-resources-plugin:resources"); verifier.execute(); } catch (final VerificationException ve) { @@ -164,6 +171,7 @@ public void testFailureWithParallelBuild() throws Exception { verifier.addCliArgument("-Dmodule-a.fail=true"); verifier.addCliArgument("-Dmodule-c.fail=true"); try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("verify"); verifier.execute(); fail("Expected this invocation to fail"); @@ -183,6 +191,7 @@ public void testFailureWithParallelBuild() throws Exception { // c : success // d : success + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("verify"); verifier.execute(); } @@ -204,6 +213,7 @@ public void testFailureAfterSkipWithParallelBuild() throws Exception { verifier.addCliArgument("-Dmodule-b.delay=2000"); verifier.addCliArgument("-Dmodule-d.fail=true"); try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("verify"); verifier.execute(); fail("Expected this invocation to fail"); @@ -223,6 +233,7 @@ public void testFailureAfterSkipWithParallelBuild() throws Exception { // The result should be: // c : success // d : success + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("verify"); verifier.execute(); } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java index 9fc554a24710..353944c61681 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng5965ParallelBuildMultipliesWorkTest.java @@ -43,6 +43,7 @@ public void testItShouldOnlyRunEachTaskOnce() throws Exception { verifier.setLogFileName("log-only.txt"); verifier.addCliArgument("-T1"); // include an aggregator task so that the two goals end up in different task segments + verifier.addCliArgument("--console=verbose"); verifier.addCliArguments("clean", "install:help"); verifier.execute(); verifier.verifyErrorFreeLog(); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java index 44019dd9c46b..c41109e26512 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6057CheckReactorOrderTest.java @@ -56,6 +56,7 @@ public void testitReactorShouldResultInExpectedOrder() throws Exception { verifier.setLogFileName("log-only.txt"); verifier.addCliArgument("-Drevision=1.3.0-SNAPSHOT"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("clean"); verifier.execute(); verifier.verifyErrorFreeLog(); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java index 65a53dde3287..807798e7ee2a 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6065FailOnSeverityTest.java @@ -45,6 +45,7 @@ public void testItShouldFailOnWarnLogMessages() throws Exception { boolean failed = false; try { + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); } catch (VerificationException e) { @@ -65,6 +66,7 @@ public void testItShouldSucceedOnWarnLogMessagesWhenFailLevelIsError() throws Ex verifier.addCliArgument("--fail-on-severity"); verifier.addCliArgument("ERROR"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java index 6a8519034876..25545f868d4a 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6118SubmoduleInvocation.java @@ -56,6 +56,7 @@ public MavenITmng6118SubmoduleInvocation() throws IOException { public void testInSubModule() throws Exception { // Compile the whole project first. Verifier verifier = newVerifier(testDir.toString(), false); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("package"); verifier.execute(); @@ -63,6 +64,7 @@ public void testInSubModule() throws Exception { verifier = newVerifier(submoduleDirectory.toString(), false); verifier.setAutoclean(false); verifier.setLogFileName("log-insubmodule.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); } @@ -76,6 +78,7 @@ public void testInSubModule() throws Exception { public void testWithFile() throws Exception { // Compile the whole project first. Verifier verifier = newVerifier(testDir.toString(), false); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("package"); verifier.execute(); @@ -84,6 +87,7 @@ public void testWithFile() throws Exception { verifier.setLogFileName("log-withfile.txt"); verifier.addCliArgument("-f"); verifier.addCliArgument("app/pom.xml"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); } @@ -100,6 +104,7 @@ public void testWithFileAndAlsoMake() throws Exception { verifier.addCliArgument("-f"); verifier.addCliArgument("app/pom.xml"); verifier.setLogFileName("log-withfilealsomake.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); verifier.verifyTextInLog("Building Maven Integration Test :: MNG-6118 :: Library 1.0"); @@ -116,6 +121,7 @@ public void testInSubModuleWithAlsoMake() throws Exception { Verifier verifier = newVerifier(submoduleDirectory, false); verifier.addCliArgument("-am"); verifier.setLogFileName("log-insubmodulealsomake.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); verifier.verifyTextInLog("Building Maven Integration Test :: MNG-6118 :: Library 1.0"); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java index 89c39470d1c7..0cac3cd9930e 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6391PrintVersionTest.java @@ -54,6 +54,7 @@ public void testitShouldPrintVersionAtTopAndAtBottom() throws Exception { verifier.setAutoclean(false); verifier.setLogFileName("version-log.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("clean"); verifier.execute(); verifier.verifyErrorFreeLog(); @@ -95,6 +96,7 @@ public void testitShouldPrintVersionInAllLines() throws Exception { verifier.setAutoclean(false); verifier.setLogFileName("version-log.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArguments("clean"); verifier.execute(); verifier.verifyErrorFreeLog(); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java index 3321e0b5d854..334f25084c63 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6566ExecuteAnnotationShouldNotReExecuteGoalsTest.java @@ -38,6 +38,7 @@ public void setUp() throws Exception { Path pluginDir = testDir.resolve("plugin"); Verifier verifier = newVerifier(pluginDir); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("install"); verifier.execute(); verifier.verifyErrorFreeLog(); @@ -49,6 +50,7 @@ public void testRunsCompileGoalOnceWithDirectPluginInvocation() throws Exception Verifier verifier = newVerifier(consumerDir); verifier.setLogFileName("log-direct-plugin-invocation.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument(PLUGIN_KEY + ":require-compile-phase"); verifier.execute(); verifier.verifyErrorFreeLog(); @@ -68,6 +70,7 @@ public void testRunsCompileGoalOnceWithPhaseExecution() throws Exception { Verifier verifier = newVerifier(consumerDir); verifier.setLogFileName("log-phase-execution.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); verifier.verifyErrorFreeLog(); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java index ba6864f2e9a8..19cade9d559c 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng6981ProjectListShouldIncludeChildrenTest.java @@ -33,6 +33,7 @@ public void testProjectListShouldIncludeChildrenByDefault() throws Exception { verifier.addCliArgument("-pl"); verifier.addCliArgument(":module-a"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); verifier.verifyTextInLog("Building module-a-1 1.0"); @@ -52,6 +53,7 @@ public void testFileSwitchAllowsExcludeOfChildren() throws Exception { verifier.addCliArgument("module-a"); verifier.addCliArgument("--non-recursive"); verifier.setLogFileName("log-non-recursive.txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("compile"); verifier.execute(); verifier.verifyTextNotInLog("Building module-a-1 1.0"); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java index b37994fd1647..f825aeda4d34 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7353CliGoalInvocationTest.java @@ -32,6 +32,7 @@ private void run(String id, String goal, String expectedInvocation) throws Excep Path basedir = extractResources("mng-7353-cli-goal-invocation"); Verifier verifier = newVerifier(basedir); verifier.setLogFileName(id + ".txt"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument(goal); verifier.execute(); verifier.verifyTextInLog("[INFO] --- " + expectedInvocation); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java index 10188f23e74b..b5fedd0d5b41 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng7804PluginExecutionOrderTest.java @@ -46,6 +46,7 @@ void testOrder() throws Exception { Path testDir = extractResources("mng-7804-plugin-execution-order"); Verifier verifier = newVerifier(testDir); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("clean"); verifier.execute(); verifier.verifyErrorFreeLog(); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java index bd1a6a21dd68..718b28b639ad 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8594AtFileTest.java @@ -44,6 +44,7 @@ void testIt() throws Exception { verifier.addCliArgument("cmd.txt"); verifier.addCliArgument("-Dcolor1=green"); verifier.addCliArgument("-Dcolor2=blue"); + verifier.addCliArgument("--console=verbose"); verifier.addCliArgument("clean"); verifier.execute(); verifier.verifyErrorFreeLog(); diff --git a/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml new file mode 100644 index 000000000000..d859b406d932 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml @@ -0,0 +1,28 @@ + + + + 4.0.0 + org.apache.maven.its.gh12571 + build-report-test + 1.0 + Maven IT :: gh-12571 :: Build Report + diff --git a/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml new file mode 100644 index 000000000000..4e63f41a8a33 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + org.apache.maven.its.gh12571 + multi-module-parent + 1.0 + + module-a + Module A + diff --git a/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml new file mode 100644 index 000000000000..52f4965b05a1 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + org.apache.maven.its.gh12571 + multi-module-parent + 1.0 + + module-b + Module B + diff --git a/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml b/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml new file mode 100644 index 000000000000..b3cabd7d3953 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml @@ -0,0 +1,33 @@ + + + + 4.0.0 + org.apache.maven.its.gh12571 + multi-module-parent + 1.0 + pom + Maven IT :: gh-12571 :: Multi Module + + module-a + module-b + + From 6fd598327ff1e110247ba81d06c1e8c5fe1a32dd Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 5 Aug 2026 00:07:36 +0200 Subject: [PATCH 02/10] Rich console: proportional progress bar for large reactors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the reactor has more modules than fit as individual ✓/●/○ indicators on the terminal width, switch to a full-width proportional progress bar: green ━ for completed, yellow ━ for active, dim ─ for remaining, with the counter and elapsed time appended. The progress bar replaces the separator line — one line instead of two (no redundant horizontal rule above the bar). This also gives an extra project slot line to the status area. Small reactors (≤ maxIndicators) keep the per-module indicators with the separate separator line, where each module gets its own symbol. Co-Authored-By: Claude Opus 4.6 --- .../cling/event/RichBuildEventListener.java | 130 +++++++++++++++--- .../event/RichBuildEventListenerTest.java | 41 ++++++ 2 files changed, 153 insertions(+), 18 deletions(-) diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java index 6b5bf397dfed..dc22c07d3b94 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/RichBuildEventListener.java @@ -458,8 +458,13 @@ private void redraw() { /** * Build exactly {@link #statusHeight} status lines. - * Layout: 1 header + active projects (packed top) + empty padding + 1 separator + 1 summary. - * The separator and summary are always at the bottom — they never move. + *

+ * For small reactors (modules fit as individual indicators): + * Layout: header + active slots + padding + separator + summary (indicators + counter). + *

+ * For large reactors (progress bar mode): + * Layout: header + active slots + padding + progress bar (full-width, acts as separator + counter). + * The progress bar replaces the separator — no redundant horizontal rule. */ private List buildStatusLines() { int termWidth = Math.max(terminal.getWidth(), 40); @@ -468,8 +473,12 @@ private List buildStatusLines() { List active = new ArrayList<>(activeProjects.values()); active.sort((a, b) -> a.startTime.compareTo(b.startTime)); - // Number of project slot lines = statusHeight - 3 (header, separator, summary) - int slotCount = statusHeight - 3; + // Determine if we're in progress bar mode (reactor too large for per-module indicators) + int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3); + boolean useProgressBar = totalProjects > 1 && maxIndicators > 0 && totalProjects > maxIndicators; + + // Number of project slot lines: subtract header (1) + bottom lines (2 for separator+summary, 1 for bar) + int slotCount = statusHeight - (useProgressBar ? 2 : 3); List lines = new ArrayList<>(statusHeight); @@ -491,11 +500,15 @@ private List buildStatusLines() { lines.add(""); } - // Separator line (always at the same position) - lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET); - - // Summary line (progress indicators + counter + elapsed + downloads) - lines.add(buildSummaryLine(termWidth)); + if (useProgressBar) { + // Progress bar replaces separator + summary as a single full-width line + lines.add(buildProgressBarLine(termWidth)); + } else { + // Separator line (always at the same position) + lines.add(DIM + "─".repeat(Math.min(termWidth, 120)) + RESET); + // Summary line (per-module indicators + counter + elapsed + downloads) + lines.add(buildSummaryLine(termWidth)); + } return lines; } @@ -518,18 +531,100 @@ private String formatProjectSlot(ProjectState state) { return b.toString(); } + /** + * Build a full-width progress bar line for large reactors. + * Replaces both the separator and summary — one line with the proportional + * bar, counter, elapsed time, and download status. + */ + private String buildProgressBarLine(int termWidth) { + // Build the suffix first so we know how much width the bar can use + StringBuilder suffix = new StringBuilder(); + suffix.append(" ["); + suffix.append(completedProjects).append('/').append(totalProjects); + suffix.append(']'); + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + suffix.append(" ").append(formatCompactDuration(elapsed)); + } + if (!activeTransfers.isEmpty()) { + suffix.append(" ↓ "); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + suffix.append(ti.artifactName); + if (ti.totalBytes > 0) { + suffix.append(' ') + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)); + } + } else { + suffix.append(activeTransfers.size()).append(" artifacts"); + } + } + int suffixLen = suffix.length(); + + // Bar fills from column 0 to (lineWidth - suffixLen) + int lineWidth = Math.min(termWidth, 120); + int barWidth = Math.max(10, lineWidth - suffixLen); + + int activeCount = activeProjects.size(); + int doneChars = (int) ((long) completedProjects * barWidth / totalProjects); + int activeChars = (int) ((long) activeCount * barWidth / totalProjects); + if (activeCount > 0 && activeChars < 1) { + activeChars = 1; + } + if (doneChars + activeChars > barWidth) { + activeChars = barWidth - doneChars; + } + int remainChars = barWidth - doneChars - activeChars; + + StringBuilder s = new StringBuilder(); + s.append(GREEN).append("━".repeat(doneChars)).append(RESET); + s.append(YELLOW).append("━".repeat(activeChars)).append(RESET); + s.append(DIM).append("─".repeat(remainChars)).append(RESET); + + // Append suffix with styling + s.append(" ["); + s.append(BOLD) + .append(completedProjects) + .append('/') + .append(totalProjects) + .append(RESET); + s.append(']'); + if (buildStartTime != null) { + Duration elapsed = Duration.between(buildStartTime, MonotonicClock.now()); + s.append(" ").append(DIM).append(formatCompactDuration(elapsed)).append(RESET); + } + if (!activeTransfers.isEmpty()) { + s.append(" ").append(BLUE).append("↓ ").append(RESET); + if (activeTransfers.size() == 1) { + TransferInfo ti = activeTransfers.values().iterator().next(); + s.append(ti.artifactName); + if (ti.totalBytes > 0) { + s.append(' ') + .append(DIM) + .append(formatBytes(ti.transferred)) + .append('/') + .append(formatBytes(ti.totalBytes)) + .append(RESET); + } + } else { + s.append(activeTransfers.size()).append(" artifacts"); + } + } + + return s.toString(); + } + + /** + * Build the summary line for small reactors (per-module indicators + counter). + */ private String buildSummaryLine(int termWidth) { StringBuilder s = new StringBuilder(" "); - // Module status indicators (✓ done, ● active, ○ pending) - int maxIndicators = Math.min(projectOrder.size(), (termWidth - 40) / 3); - if (totalProjects > 1 && maxIndicators > 0) { - int shown = 0; + // Per-module indicators — each module gets its own symbol + if (totalProjects > 1) { for (String pid : projectOrder) { - if (shown >= maxIndicators) { - s.append("… "); - break; - } if (activeProjects.containsKey(pid)) { ProjectState ps = activeProjects.get(pid); if (ps != null && ps.failed) { @@ -542,7 +637,6 @@ private String buildSummaryLine(int termWidth) { } else { s.append(DIM).append("○ ").append(RESET); } - shown++; } } diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java index 5548dd53fa60..1c7dafe2e950 100644 --- a/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/event/RichBuildEventListenerTest.java @@ -242,6 +242,47 @@ void testFailCallsTearDown() throws Exception { listener.fail(new RuntimeException("build error")); } + @Test + void testLargeReactorProgressBar() { + // With 50 modules on a 120-char terminal, maxIndicators = (120-40)/3 = 26. + // 50 > 26, so the summary line should use a progress bar instead of + // per-module ✓/●/○ indicators. + List projects = new java.util.ArrayList<>(); + for (int i = 0; i < 50; i++) { + projects.add(createProject("module-" + i)); + } + MavenSession session = createSession(projects); + listener.initReactor(session); + + // Start and finish some modules to exercise the progress bar rendering + for (int i = 0; i < 10; i++) { + listener.projectStarted("module-" + i); + } + for (int i = 0; i < 5; i++) { + listener.projectFinished("module-" + i); + } + // 5 completed, 5 active, 40 pending — should render without error + for (int i = 5; i < 10; i++) { + listener.projectFinished("module-" + i); + } + } + + @Test + void testSmallReactorUsesIndicators() { + // With 3 modules on a 120-char terminal, maxIndicators = 26 > 3, + // so the summary line should use per-module ✓/●/○ indicators. + MavenSession session = + createSession(List.of(createProject("api"), createProject("core"), createProject("cli"))); + listener.initReactor(session); + + listener.projectStarted("api"); + listener.projectFinished("api"); + listener.projectStarted("core"); + listener.projectFinished("core"); + listener.projectStarted("cli"); + listener.projectFinished("cli"); + } + @Test void testTruncateAnsiPlainText() { String truncated = RichBuildEventListener.truncateAnsi("hello world", 5); From 36eb01dd39d534a7de28cd780e89e61d149c7f02 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 14:58:46 +0200 Subject: [PATCH 03/10] Fix #12643: Pipe structured BuilderProblems directly into DiagnosticCollector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route structured validation problems from 4 pathways (settings, toolchains, model validation, graph building) into DefaultDiagnosticCollector so they appear in the build report with full key/suggestion/documentationUrl metadata instead of being re-logged as plain text. Pathways: - LookupInvoker: settings validation problems - MavenInvoker: toolchains validation problems - DefaultProjectsSelector: model validation problems (ModelProblem → BuilderProblem) - DefaultMaven: graph building problems (ModelProblem → BuilderProblem) Also adds EXCLUDED_LOGGERS in BuildReportCollector to prevent double-counting when the same problems are both piped structurally and logged via SLF4J. Migrates all test files from DefaultBuilderProblem constructor to BuilderProblem.builder() API. Co-Authored-By: Claude Opus 4.6 --- .../maven/cling/invoker/LookupInvoker.java | 22 ++++++++++ .../maven/cling/invoker/mvn/MavenInvoker.java | 12 ++++++ .../java/org/apache/maven/DefaultMaven.java | 34 ++++++++++++++- .../internal/build/BuildReportCollector.java | 20 +++++++-- .../collector/DefaultProjectsSelector.java | 36 +++++++++++++++- .../maven/graph/DefaultGraphBuilderTest.java | 3 +- .../build/BuildReportCollectorTest.java | 9 ++-- .../build/BuildReportIntegrationTest.java | 33 +++++++++++--- .../build/BuildReportJsonWriterTest.java | 39 ++++++++--------- .../build/DefaultDiagnosticCollectorTest.java | 43 ++++++++++++------- 10 files changed, 197 insertions(+), 54 deletions(-) diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index 42f8758c14c1..f5605f13c7a6 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java @@ -76,6 +76,7 @@ import org.apache.maven.eventspy.internal.EventSpyDispatcher; import org.apache.maven.execution.MavenExecutionRequest; import org.apache.maven.impl.SettingsUtilsV4; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.jline.FastTerminal; import org.apache.maven.jline.MessageUtils; import org.apache.maven.logging.BuildEventListener; @@ -747,6 +748,13 @@ protected Runnable settings(C context, boolean emitSettingsWarnings, SettingsBui } } context.logger.info(""); + + // Pipe structured problems directly to DiagnosticCollector so that + // key, suggestion, documentationUrl, and source location are preserved + // in the build report (instead of being lost to plain-text logging). + // This runs before SessionStarted, so the SLF4J auto-collection hook + // is not active yet — no double-counting risk. + pipeSettingsProblems(context, settingsResult); } return () -> { context.installationSettingsPath = null; @@ -758,6 +766,20 @@ protected Runnable settings(C context, boolean emitSettingsWarnings, SettingsBui }; } + /** + * Pipes structured settings validation problems to the DiagnosticCollector. + * This preserves key, suggestion, documentationUrl, and source location + * that would otherwise be lost when problems are logged as plain text. + */ + private void pipeSettingsProblems(C context, SettingsBuilderResult settingsResult) { + context.lookup.lookupOptional(DefaultDiagnosticCollector.class).ifPresent(collector -> { + for (BuilderProblem problem : + settingsResult.getProblems().problems().toList()) { + collector.report(problem); + } + }); + } + protected void customizeSettingsRequest(C context, SettingsBuilderRequest settingsBuilderRequest) throws Exception {} 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 3924977e56e3..7a3cadce8624 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 @@ -69,6 +69,7 @@ import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.ProfileActivation; import org.apache.maven.execution.ProjectActivation; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.jline.MessageUtils; import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.logging.BuildEventListener; @@ -220,6 +221,17 @@ protected void toolchains(MavenContext context, MavenExecutionRequest request) t } context.logger.info(""); + + // Pipe structured problems directly to DiagnosticCollector so that + // key, suggestion, documentationUrl, and source location are preserved + // in the build report. This runs before SessionStarted, so the SLF4J + // auto-collection hook is not active yet — no double-counting risk. + context.lookup.lookupOptional(DefaultDiagnosticCollector.class).ifPresent(collector -> { + for (BuilderProblem problem : + toolchainsResult.getProblems().problems().toList()) { + collector.report(problem); + } + }); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index 23e85a14b01e..91769faf509e 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -43,6 +43,7 @@ import org.apache.maven.api.model.Model; import org.apache.maven.api.model.Prerequisites; import org.apache.maven.api.model.Profile; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.LookupException; import org.apache.maven.artifact.ArtifactUtils; @@ -59,6 +60,7 @@ import org.apache.maven.execution.ProjectDependencyGraph; import org.apache.maven.graph.GraphBuilder; import org.apache.maven.graph.ProjectSelector; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.internal.impl.DefaultSessionFactory; import org.apache.maven.internal.impl.InternalMavenSession; import org.apache.maven.lifecycle.LifecycleExecutionException; @@ -113,6 +115,8 @@ public class DefaultMaven implements Maven { private final ProjectSelector projectSelector; + private final DefaultDiagnosticCollector diagnosticCollector; + @Inject @SuppressWarnings("checkstyle:ParameterNumber") public DefaultMaven( @@ -126,7 +130,8 @@ public DefaultMaven( BuildResumptionDataRepository buildResumptionDataRepository, SuperPomProvider superPomProvider, DefaultSessionFactory defaultSessionFactory, - @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader) { + @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader, + DefaultDiagnosticCollector diagnosticCollector) { this.lookup = lookup; this.eventCatapult = eventCatapult; this.legacySupport = legacySupport; @@ -138,6 +143,7 @@ public DefaultMaven( this.superPomProvider = superPomProvider; this.ideWorkspaceReader = ideWorkspaceReader; this.defaultSessionFactory = defaultSessionFactory; + this.diagnosticCollector = diagnosticCollector; this.projectSelector = new ProjectSelector(); // if necessary switch to DI } @@ -648,6 +654,10 @@ private Result buildGraph(MavenSession session } else { logger.error(problem.getMessage()); } + // Pipe structured problem directly to DiagnosticCollector so that + // source location and severity are preserved in the build report. + // The SLF4J hook excludes this logger to avoid double-counting. + diagnosticCollector.report(toBuilderProblem(problem)); } if (!graphResult.hasErrors()) { @@ -660,6 +670,28 @@ private Result buildGraph(MavenSession session return graphResult; } + /** + * Converts a compat {@link ModelProblem} to the Maven 4 {@link BuilderProblem} API, + * preserving source, line, column, severity, and message. + */ + private static BuilderProblem toBuilderProblem(ModelProblem problem) { + BuilderProblem.Severity severity = + switch (problem.getSeverity()) { + case FATAL -> BuilderProblem.Severity.FATAL; + case ERROR -> BuilderProblem.Severity.ERROR; + default -> BuilderProblem.Severity.WARNING; + }; + return BuilderProblem.builder() + .source(problem.getSource()) + .lineNumber(problem.getLineNumber()) + .columnNumber(problem.getColumnNumber()) + .exception(problem.getException()) + .message(problem.getMessage()) + .severity(severity) + .key("model:" + problem.getMessage().hashCode()) + .build(); + } + @Deprecated // 5 January 2014 protected Logger getLogger() { 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 8df72cc4eadc..9425cc15ee56 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 @@ -92,6 +92,18 @@ public final class BuildReportCollector extends AbstractEventSpy { private static final int MAX_STACKTRACE_LINES = 30; + /** + * Logger names excluded from SLF4J auto-collection because these classes + * already pipe structured {@link org.apache.maven.api.services.BuilderProblem} + * objects directly to the {@link DefaultDiagnosticCollector}. Without this + * exclusion, each problem would be counted twice: once from the direct pipe + * and once from the SLF4J WARN interception. + */ + private static final Set EXCLUDED_LOGGERS = Set.of( + BuildReportCollector.class.getName(), + "org.apache.maven.DefaultMaven", + "org.apache.maven.project.collector.DefaultProjectsSelector"); + private final DefaultDiagnosticCollector diagnosticCollector; @Inject @@ -353,10 +365,10 @@ private void captureLogEvent(int level, String loggerName, String message, Throw // 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())) { + // Skip loggers that already pipe structured BuilderProblems directly to the + // DiagnosticCollector (avoiding double-counting), and our own logger to avoid + // feedback loops from problem summary printing. + if (level == LocationAwareLogger.WARN_INT && message != null && !EXCLUDED_LOGGERS.contains(loggerName)) { String syntheticKey = syntheticDiagnosticKey(loggerName, message); diagnosticCollector.report(BuilderProblem.builder() .source(loggerName) diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java b/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java index 0be344384adf..bd5664d1eba2 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/collector/DefaultProjectsSelector.java @@ -26,7 +26,9 @@ import java.util.ArrayList; import java.util.List; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.model.building.ModelProblem; import org.apache.maven.model.building.ModelProblemUtils; import org.apache.maven.project.MavenProject; @@ -45,10 +47,12 @@ public class DefaultProjectsSelector implements ProjectsSelector { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultProjectsSelector.class); private final ProjectBuilder projectBuilder; + private final DefaultDiagnosticCollector diagnosticCollector; @Inject - public DefaultProjectsSelector(ProjectBuilder projectBuilder) { + public DefaultProjectsSelector(ProjectBuilder projectBuilder, DefaultDiagnosticCollector diagnosticCollector) { this.projectBuilder = projectBuilder; + this.diagnosticCollector = diagnosticCollector; } @Override @@ -83,6 +87,14 @@ public List selectProjects(List files, MavenExecutionRequest LOGGER.warn("{}{}", problem.getMessage(), ((loc != null && !loc.isEmpty()) ? " @ " + loc : "")); } } + + // Pipe structured problems directly to DiagnosticCollector so that + // source location and severity are preserved in the build report + // (instead of being lost to plain-text logging). The SLF4J hook + // excludes this logger to avoid double-counting. + for (ModelProblem problem : result.getProblems()) { + diagnosticCollector.report(toBuilderProblem(problem)); + } } } @@ -100,4 +112,26 @@ public List selectProjects(List files, MavenExecutionRequest return projects; } + + /** + * Converts a compat {@link ModelProblem} to the Maven 4 {@link BuilderProblem} API, + * preserving source, line, column, severity, and message. + */ + private static BuilderProblem toBuilderProblem(ModelProblem problem) { + BuilderProblem.Severity severity = + switch (problem.getSeverity()) { + case FATAL -> BuilderProblem.Severity.FATAL; + case ERROR -> BuilderProblem.Severity.ERROR; + default -> BuilderProblem.Severity.WARNING; + }; + return BuilderProblem.builder() + .source(problem.getSource()) + .lineNumber(problem.getLineNumber()) + .columnNumber(problem.getColumnNumber()) + .exception(problem.getException()) + .message(problem.getMessage()) + .severity(severity) + .key("model:" + problem.getMessage().hashCode()) + .build(); + } } diff --git a/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java index 72dad0b244d5..6982db4b97a0 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/graph/DefaultGraphBuilderTest.java @@ -101,7 +101,8 @@ class DefaultGraphBuilderTest { private final MavenSession session = mock(MavenSession.class); private final MavenExecutionRequest mavenExecutionRequest = mock(MavenExecutionRequest.class); - private final ProjectsSelector projectsSelector = new DefaultProjectsSelector(projectBuilder); + private final ProjectsSelector projectsSelector = new DefaultProjectsSelector( + projectBuilder, new org.apache.maven.internal.build.DefaultDiagnosticCollector()); // Not using mocks for these strategies - a mock would just copy the actual implementation. 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 5733daf74d0a..2358d5d3a9d6 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 @@ -35,7 +35,6 @@ 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; @@ -334,8 +333,12 @@ void testDiagnosticSuppressionWildcard() { // ---- 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); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .build(); } private MavenProject createProject(String groupId, String artifactId, String version) { 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 e200cd06b6f8..10f2dc1ae78b 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 @@ -35,7 +35,6 @@ 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; @@ -72,21 +71,41 @@ void setUp() { } private static BuilderProblem warning(String key, String message, String source) { - return new DefaultBuilderProblem( - source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .build(); } 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); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .suggestion(suggestion) + .documentationUrl(docUrl) + .build(); } private static BuilderProblem info(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.INFO, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.INFO) + .key(key) + .build(); } private static BuilderProblem error(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.ERROR, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.ERROR) + .key(key) + .build(); } /** 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 073072c4a539..831bb8a06115 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 @@ -30,7 +30,6 @@ 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; @@ -334,27 +333,23 @@ void testNullMojoFields() { @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); + BuilderProblem p1 = BuilderProblem.builder() + .source("maven-compiler-plugin:3.15.0:compile") + .lineNumber(42) + .columnNumber(1) + .message("source/target value 8 is deprecated") + .severity(BuilderProblem.Severity.WARNING) + .key("deprecated-source-target") + .suggestion("Update to 11 or higher") + .documentationUrl("https://example.com/docs/compiler") + .build(); + + BuilderProblem p2 = BuilderProblem.builder() + .source("maven-compiler-plugin") + .message("3 errors found") + .severity(BuilderProblem.Severity.ERROR) + .key("compilation-failure") + .build(); BuildReport report = new DefaultBuildReport( BuildStatus.FAILURE, 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 index 3276c3c533cc..90dfeefe1457 100644 --- 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 @@ -27,7 +27,6 @@ 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; @@ -45,16 +44,30 @@ void setUp() { } private static BuilderProblem warning(String key, String message, String source) { - return new DefaultBuilderProblem( - source, -1, -1, null, message, BuilderProblem.Severity.WARNING, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.WARNING) + .key(key) + .build(); } private static BuilderProblem error(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.ERROR, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.ERROR) + .key(key) + .build(); } private static BuilderProblem info(String key, String message, String source) { - return new DefaultBuilderProblem(source, -1, -1, null, message, BuilderProblem.Severity.INFO, key, null, null); + return BuilderProblem.builder() + .source(source) + .message(message) + .severity(BuilderProblem.Severity.INFO) + .key(key) + .build(); } @Test @@ -175,16 +188,16 @@ void testSummaryListIsUnmodifiable() { @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"); + BuilderProblem p = BuilderProblem.builder() + .source("maven-compiler-plugin:3.15.0:compile") + .lineNumber(42) + .columnNumber(15) + .message("unchecked cast from Object to List") + .severity(BuilderProblem.Severity.WARNING) + .key("unchecked-cast") + .suggestion("Add @SuppressWarnings(\"unchecked\") or use a type-safe alternative") + .documentationUrl("https://docs.oracle.com/javase/tutorial/java/generics/rawTypes.html") + .build(); collector.report(p); From 278125c6a5ed4a38b85fb10eaf60221e810c994a Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 16:18:31 +0200 Subject: [PATCH 04/10] Fix #12643: Migrate PluginValidationManager to native BuilderProblem API Change the PluginValidationManager interface from String-based to BuilderProblem-based, enabling structured problem reporting with keys, severity, and suggestions throughout the plugin validation pipeline. - Change 3 abstract report methods from String issue to BuilderProblem - Add deprecated String-based default methods for backward compat - Update all 9 call sites (7 validators + 1 plugin manager) to create BuilderProblem with structured key, severity, and suggestion - Inject DefaultDiagnosticCollector into DefaultPluginValidationManager to pipe problems into the diagnostic pipeline - Add japicmp exclusion for the intentional API break - Add DefaultPluginValidationManager to EXCLUDED_LOGGERS in BuildReportCollector to prevent double-counting Co-Authored-By: Claude Opus 4.6 --- impl/maven-core/pom.xml | 2 + .../internal/build/BuildReportCollector.java | 3 +- .../maven/plugin/PluginValidationManager.java | 74 ++++++++++++++++++- ...bstractMavenPluginParametersValidator.java | 14 ++++ .../internal/DefaultMavenPluginManager.java | 9 ++- .../DefaultPluginValidationManager.java | 34 ++++++--- .../DeprecatedCoreExpressionValidator.java | 15 +++- .../internal/DeprecatedPluginValidator.java | 14 +++- .../internal/Maven2DependenciesValidator.java | 8 +- .../Maven3CompatDependenciesValidator.java | 9 ++- .../MavenMixedDependenciesValidator.java | 8 +- .../MavenScopeDependenciesValidator.java | 12 ++- ...ContainerDefaultDependenciesValidator.java | 8 +- .../ReadOnlyPluginParametersValidator.java | 7 +- 14 files changed, 191 insertions(+), 26 deletions(-) diff --git a/impl/maven-core/pom.xml b/impl/maven-core/pom.xml index 07ffa6662b12..efec4c8c0975 100644 --- a/impl/maven-core/pom.xml +++ b/impl/maven-core/pom.xml @@ -396,6 +396,8 @@ under the License. org.apache.maven.toolchain.ToolchainManagerPrivate org.apache.maven.toolchain.ToolchainPrivate org.apache.maven.toolchain.ToolchainsBuilder + + org.apache.maven.plugin.PluginValidationManager 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 9425cc15ee56..6d85b8393e3f 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 @@ -102,7 +102,8 @@ public final class BuildReportCollector extends AbstractEventSpy { private static final Set EXCLUDED_LOGGERS = Set.of( BuildReportCollector.class.getName(), "org.apache.maven.DefaultMaven", - "org.apache.maven.project.collector.DefaultProjectsSelector"); + "org.apache.maven.project.collector.DefaultProjectsSelector", + "org.apache.maven.plugin.internal.DefaultPluginValidationManager"); private final DefaultDiagnosticCollector diagnosticCollector; diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java index d65f8dad681a..6c2cc943add1 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginValidationManager.java @@ -18,6 +18,7 @@ */ package org.apache.maven.plugin; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.eclipse.aether.RepositorySystemSession; @@ -54,27 +55,94 @@ enum IssueLocality { * This method should be used in "early" phase of plugin execution, possibly even when plugin or mojo descriptor * does not exist yet. In turn, this method will not record extra information like plugin occurrence or declaration * location as those are not yet available. + * + * @since 4.1.0 */ void reportPluginValidationIssue( - IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, String issue); + IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, BuilderProblem problem); /** * Reports plugin issues applicable to the plugin as a whole. *

* This method will record extra information as well, like plugin occurrence or declaration location. + * + * @since 4.1.0 */ void reportPluginValidationIssue( - IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, String issue); + IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, BuilderProblem problem); /** * Reports plugin Mojo issues applicable to the Mojo itself. *

* This method will record extra information as well, like plugin occurrence or declaration location. + * + * @since 4.1.0 */ void reportPluginMojoValidationIssue( IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, Class mojoClass, - String issue); + BuilderProblem problem); + + // ---- Deprecated String-based adapters for external callers ---- + + /** + * @deprecated Use {@link #reportPluginValidationIssue(IssueLocality, RepositorySystemSession, Artifact, + * BuilderProblem)} instead. + */ + @Deprecated(since = "4.1.0", forRemoval = true) + default void reportPluginValidationIssue( + IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, String issue) { + reportPluginValidationIssue( + locality, + session, + pluginArtifact, + BuilderProblem.builder() + .message(issue) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + issue.hashCode()) + .build()); + } + + /** + * @deprecated Use {@link #reportPluginValidationIssue(IssueLocality, MavenSession, MojoDescriptor, + * BuilderProblem)} instead. + */ + @Deprecated(since = "4.1.0", forRemoval = true) + default void reportPluginValidationIssue( + IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, String issue) { + reportPluginValidationIssue( + locality, + mavenSession, + mojoDescriptor, + BuilderProblem.builder() + .message(issue) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + issue.hashCode()) + .build()); + } + + /** + * @deprecated Use {@link #reportPluginMojoValidationIssue(IssueLocality, MavenSession, MojoDescriptor, Class, + * BuilderProblem)} instead. + */ + @Deprecated(since = "4.1.0", forRemoval = true) + default void reportPluginMojoValidationIssue( + IssueLocality locality, + MavenSession mavenSession, + MojoDescriptor mojoDescriptor, + Class mojoClass, + String issue) { + reportPluginMojoValidationIssue( + locality, + mavenSession, + mojoDescriptor, + mojoClass, + BuilderProblem.builder() + .message(issue) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + issue.hashCode()) + .build()); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java index 9e7426b168ff..47a772809e37 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/AbstractMavenPluginParametersValidator.java @@ -18,6 +18,7 @@ */ package org.apache.maven.plugin.internal; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.PluginValidationManager; import org.apache.maven.plugin.descriptor.MojoDescriptor; @@ -97,6 +98,11 @@ protected boolean isIgnoredProperty(String strValue) { protected abstract String getParameterLogReason(Parameter parameter); + /** + * Returns the validation key prefix for this validator (e.g. "deprecated-param", "readonly-param"). + */ + protected abstract String getValidationKeyPrefix(); + protected String formatParameter(Parameter parameter) { StringBuilder stringBuilder = new StringBuilder() .append("Parameter '") @@ -112,4 +118,12 @@ protected String formatParameter(Parameter parameter) { return stringBuilder.toString(); } + + protected BuilderProblem buildParameterProblem(Parameter parameter) { + return BuilderProblem.builder() + .message(formatParameter(parameter)) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:" + getValidationKeyPrefix() + ":" + parameter.getName()) + .build(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index 15f3d8df7b64..a95cf34fff83 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -51,6 +51,7 @@ import org.apache.maven.api.Service; import org.apache.maven.api.Session; import org.apache.maven.api.plugin.descriptor.Resolution; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.DependencyResolver; import org.apache.maven.api.services.DependencyResolverResult; import org.apache.maven.api.services.PathScopeRegistry; @@ -751,7 +752,13 @@ private T loadV3Mojo( session, mojoDescriptor, mojo.getClass(), - "Mojo implements `Contextualizable` interface from Plexus Container, which is EOL."); + BuilderProblem.builder() + .message( + "Mojo implements `Contextualizable` interface from Plexus Container, which is EOL.") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:contextualizable") + .suggestion("Migrate from Contextualizable to javax.inject dependency injection") + .build()); } XmlNode dom = mojoExecution.getConfiguration() != null diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java index 0bd14d1635d1..e445b2978e1f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginValidationManager.java @@ -18,6 +18,7 @@ */ package org.apache.maven.plugin.internal; +import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; @@ -39,9 +40,11 @@ import java.util.stream.Collectors; import org.apache.maven.api.Constants; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.eventspy.AbstractEventSpy; import org.apache.maven.execution.ExecutionEvent; import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.model.InputLocation; import org.apache.maven.plugin.PluginValidationManager; import org.apache.maven.plugin.descriptor.MojoDescriptor; @@ -83,6 +86,13 @@ private enum ValidationReportLevel { private final Logger logger = LoggerFactory.getLogger(getClass()); + private final DefaultDiagnosticCollector diagnosticCollector; + + @Inject + DefaultPluginValidationManager(DefaultDiagnosticCollector diagnosticCollector) { + this.diagnosticCollector = diagnosticCollector; + } + @Override public void onEvent(Object event) { if (event instanceof ExecutionEvent executionEvent) { @@ -158,28 +168,30 @@ private void mayReportInline(RepositorySystemSession session, IssueLocality loca @Override public void reportPluginValidationIssue( - IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, String issue) { + IssueLocality locality, RepositorySystemSession session, Artifact pluginArtifact, BuilderProblem problem) { String pluginKey = pluginKey(pluginArtifact); if (validationPluginExcludes(session).contains(pluginKey)) { return; } PluginValidationIssues pluginIssues = pluginIssues(session).computeIfAbsent(pluginKey, k -> new PluginValidationIssues()); - pluginIssues.reportPluginIssue(locality, null, issue); - mayReportInline(session, locality, issue); + pluginIssues.reportPluginIssue(locality, null, problem.getMessage()); + diagnosticCollector.report(problem); + mayReportInline(session, locality, problem.getMessage()); } @Override public void reportPluginValidationIssue( - IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, String issue) { + IssueLocality locality, MavenSession mavenSession, MojoDescriptor mojoDescriptor, BuilderProblem problem) { String pluginKey = pluginKey(mojoDescriptor); if (validationPluginExcludes(mavenSession.getRepositorySession()).contains(pluginKey)) { return; } PluginValidationIssues pluginIssues = pluginIssues(mavenSession.getRepositorySession()) .computeIfAbsent(pluginKey, k -> new PluginValidationIssues()); - pluginIssues.reportPluginIssue(locality, pluginDeclaration(mavenSession, mojoDescriptor), issue); - mayReportInline(mavenSession.getRepositorySession(), locality, issue); + pluginIssues.reportPluginIssue(locality, pluginDeclaration(mavenSession, mojoDescriptor), problem.getMessage()); + diagnosticCollector.report(problem); + mayReportInline(mavenSession.getRepositorySession(), locality, problem.getMessage()); } @Override @@ -188,7 +200,7 @@ public void reportPluginMojoValidationIssue( MavenSession mavenSession, MojoDescriptor mojoDescriptor, Class mojoClass, - String issue) { + BuilderProblem problem) { String pluginKey = pluginKey(mojoDescriptor); if (validationPluginExcludes(mavenSession.getRepositorySession()).contains(pluginKey)) { return; @@ -196,8 +208,12 @@ public void reportPluginMojoValidationIssue( PluginValidationIssues pluginIssues = pluginIssues(mavenSession.getRepositorySession()) .computeIfAbsent(pluginKey, k -> new PluginValidationIssues()); pluginIssues.reportPluginMojoIssue( - locality, pluginDeclaration(mavenSession, mojoDescriptor), mojoInfo(mojoDescriptor, mojoClass), issue); - mayReportInline(mavenSession.getRepositorySession(), locality, issue); + locality, + pluginDeclaration(mavenSession, mojoDescriptor), + mojoInfo(mojoDescriptor, mojoClass), + problem.getMessage()); + diagnosticCollector.report(problem); + mayReportInline(mavenSession.getRepositorySession(), locality, problem.getMessage()); } private void reportSessionCollectedValidationIssues(MavenSession mavenSession) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java index faaac5cf1c53..81029df570fb 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedCoreExpressionValidator.java @@ -63,6 +63,11 @@ protected String getParameterLogReason(Parameter parameter) { + DEPRECATED_CORE_PARAMETERS.get(parameter.getDefaultValue()); } + @Override + protected String getValidationKeyPrefix() { + return "deprecated-expr"; + } + @Override protected void doValidate( MavenSession mavenSession, @@ -76,9 +81,13 @@ protected void doValidate( mojoDescriptor.getParameters().stream() .filter(this::isDeprecated) - .map(this::formatParameter) - .forEach(m -> pluginValidationManager.reportPluginMojoValidationIssue( - PluginValidationManager.IssueLocality.EXTERNAL, mavenSession, mojoDescriptor, mojoClass, m)); + .map(this::buildParameterProblem) + .forEach(problem -> pluginValidationManager.reportPluginMojoValidationIssue( + PluginValidationManager.IssueLocality.EXTERNAL, + mavenSession, + mojoDescriptor, + mojoClass, + problem)); } private boolean isDeprecated(Parameter parameter) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java index a4d784c403dc..45c973fcd967 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DeprecatedPluginValidator.java @@ -22,6 +22,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.MessageBuilderFactory; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.PluginValidationManager; @@ -52,6 +53,11 @@ protected String getParameterLogReason(Parameter parameter) { return "is deprecated: " + parameter.getDeprecated(); } + @Override + protected String getValidationKeyPrefix() { + return "deprecated-param"; + } + @Override protected void doValidate( MavenSession mavenSession, @@ -65,7 +71,11 @@ protected void doValidate( mavenSession, mojoDescriptor, mojoClass, - logDeprecatedMojo(mojoDescriptor)); + BuilderProblem.builder() + .message(logDeprecatedMojo(mojoDescriptor)) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:deprecated-goal:" + mojoDescriptor.getGoal()) + .build()); } if (mojoDescriptor.getParameters() != null) { @@ -92,7 +102,7 @@ private void checkParameter( mavenSession, mojoDescriptor, mojoClass, - formatParameter(parameter)); + buildParameterProblem(parameter)); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java index d2f8dd89ce77..05b206b97c0b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven2DependenciesValidator.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.stream.Collectors; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -64,7 +65,12 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin is a Maven 2.x plugin, which will be not supported in Maven 4.x"); + BuilderProblem.builder() + .message("Plugin is a Maven 2.x plugin, which will be not supported in Maven 4.x") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:maven2-plugin") + .suggestion("Upgrade to a Maven 3.x/4.x compatible version of this plugin") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java index 0cf8fee6ad35..559d3c189828 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/Maven3CompatDependenciesValidator.java @@ -23,6 +23,7 @@ import javax.inject.Singleton; import org.apache.maven.api.DependencyScope; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -55,7 +56,13 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin depends on the deprecated Maven 2.x compatibility layer, which will be not supported in Maven 4.x"); + BuilderProblem.builder() + .message( + "Plugin depends on the deprecated Maven 2.x compatibility layer, which will be not supported in Maven 4.x") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:maven-compat-dep") + .suggestion("Remove the maven-compat dependency from the plugin") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java index 27003836d3fd..3ae05d3a100a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenMixedDependenciesValidator.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.stream.Collectors; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -63,7 +64,12 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin mixes multiple Maven versions: " + mavenVersions); + BuilderProblem.builder() + .message("Plugin mixes multiple Maven versions: " + mavenVersions) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:mixed-maven-versions") + .suggestion("Align all Maven dependencies to a single version to avoid classloading issues") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java index 460a91c6ec18..48f78c901102 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/MavenScopeDependenciesValidator.java @@ -26,6 +26,7 @@ import java.util.stream.Collectors; import org.apache.maven.api.DependencyScope; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -65,8 +66,15 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin should declare Maven artifacts in `provided` scope. If the plugin already declares them in `provided` scope, update the maven-plugin-plugin to latest version. Artifacts found with wrong scope: " - + mavenArtifacts); + BuilderProblem.builder() + .message( + "Plugin should declare Maven artifacts in `provided` scope. If the plugin already declares them in `provided` scope, update the maven-plugin-plugin to latest version. Artifacts found with wrong scope: " + + mavenArtifacts) + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:wrong-scope") + .suggestion( + "Change Maven artifact dependencies to 'provided' scope or update maven-plugin-plugin") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java index 31638139a3f7..ba15ab4d80cc 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusContainerDefaultDependenciesValidator.java @@ -22,6 +22,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.plugin.PluginValidationManager; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -55,7 +56,12 @@ protected void doValidate( PluginValidationManager.IssueLocality.EXTERNAL, session, pluginArtifact, - "Plugin depends on plexus-container-default, which is EOL"); + BuilderProblem.builder() + .message("Plugin depends on plexus-container-default, which is EOL") + .severity(BuilderProblem.Severity.WARNING) + .key("plugin-validation:plexus-container-eol") + .suggestion("Migrate from plexus-container-default to javax.inject / Eclipse Sisu") + .build()); } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java index 2989fb5166a2..69e4d9870aff 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/ReadOnlyPluginParametersValidator.java @@ -47,6 +47,11 @@ protected String getParameterLogReason(Parameter parameter) { return "is read-only, must not be used in configuration"; } + @Override + protected String getValidationKeyPrefix() { + return "readonly-param"; + } + @Override protected void doValidate( MavenSession mavenSession, @@ -79,7 +84,7 @@ private void checkParameter( mavenSession, mojoDescriptor, mojoClass, - formatParameter(parameter)); + buildParameterProblem(parameter)); } } } From e2e12df6e8b554a2e0e5b39f99492bdca73ee8b4 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 6 Aug 2026 23:45:41 +0200 Subject: [PATCH 05/10] Add Log.child(), Log.problem(), and injection-point-aware DI factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Injector.bindFactory(Class, Function): factory-based binding that receives the full injection-point Key (including @Named qualifier), enabling qualifier-derived instances like hierarchical logger names. - Log.child(String): returns a child logger with hierarchical naming (e.g. "compiler:compile" → "compiler:compile.diagnostics"), useful when plugins delegate to sub-components that need independently filterable log output. - Log.problem(BuilderProblem): reports a structured problem to the diagnostic collector with dedup key, suggestion, and documentation URL, while also logging at the appropriate level. Uses a thread-local flag to prevent double-counting by BuildReportCollector's WARN auto-promotion. - DefaultMavenPluginManager: switched from bindInstance to bindFactory for Log injection, so @Inject @Named("diagnostics") Log in a DI-managed plugin component gets "compiler:compile.diagnostics". - Fixed pre-existing bug: DefaultLog.warn(Supplier, Throwable) was calling logger.info() instead of logger.warn(). Co-Authored-By: Claude Opus 4.6 --- .../java/org/apache/maven/api/plugin/Log.java | 53 ++++ .../internal/build/BuildReportCollector.java | 11 +- .../maven/internal/impl/DefaultLog.java | 46 +++- .../internal/DefaultMavenPluginManager.java | 20 +- .../maven/internal/impl/DefaultLogTest.java | 235 ++++++++++++++++++ .../java/org/apache/maven/di/Injector.java | 31 +++ .../apache/maven/di/impl/InjectorImpl.java | 19 ++ .../maven/di/impl/InjectorImplTest.java | 56 +++++ 8 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java index 50627efd86e3..8b12b23abbbe 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java @@ -21,7 +21,9 @@ import java.util.function.Supplier; import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.Provider; +import org.apache.maven.api.services.BuilderProblem; /** * This interface supplies the API for providing feedback to the user from the {@code Mojo}, @@ -167,4 +169,55 @@ public interface Log { void error(Supplier content); void error(Supplier content, Throwable error); + + /** + * Returns a child logger with the given name appended to this logger's name, + * enabling hierarchical logger namespacing within a plugin. + *

+ * For example, if the current logger is named {@code "compiler:compile"}, + * calling {@code child("diagnostics")} returns a logger named + * {@code "compiler:compile.diagnostics"}. + *

+ * This is useful when a plugin delegates to sub-components (e.g. options + * resolution, diagnostic reporting, incremental build decisions) and wants + * each component's log output to be independently filterable. + * + * @param name the child logger name segment (appended after a dot separator) + * @return a child logger; the default implementation returns {@code this} + * @since 4.1.0 + */ + @Nonnull + default Log child(@Nonnull String name) { + return this; + } + + /** + * Reports a structured {@link BuilderProblem} to the build's diagnostic collector. + *

+ * Unlike {@link #warn(CharSequence)}, a structured problem carries a deduplication + * {@linkplain BuilderProblem#getKey() key}, an optional + * {@linkplain BuilderProblem#getSuggestion() suggestion}, and an optional + * {@linkplain BuilderProblem#getDocumentationUrl() documentation URL} — enabling + * Maven to deduplicate repeated warnings across modules and present an actionable + * end-of-build summary. + *

+ * The problem is also logged at the appropriate level (WARN or ERROR) so it + * appears in the normal console output. Callers should not additionally + * call {@link #warn(CharSequence)} for the same message, as that would produce + * duplicate output. + *

+ * The default implementation falls back to {@link #warn(CharSequence)} or + * {@link #error(CharSequence)} based on the problem's severity. + * + * @param problem the structured problem to report + * @since 4.1.0 + */ + default void problem(@Nonnull BuilderProblem problem) { + if (problem.getSeverity() == BuilderProblem.Severity.ERROR + || problem.getSeverity() == BuilderProblem.Severity.FATAL) { + error(problem.getMessage()); + } else { + warn(problem.getMessage()); + } + } } 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 6d85b8393e3f..598a3a12c6c4 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 @@ -57,6 +57,7 @@ import org.apache.maven.execution.ExecutionEvent; import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.impl.DefaultLog; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.project.MavenProject; import org.apache.maven.slf4j.MavenSimpleLogger; @@ -367,9 +368,13 @@ private void captureLogEvent(int level, String loggerName, String message, Throw // 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 loggers that already pipe structured BuilderProblems directly to the - // DiagnosticCollector (avoiding double-counting), and our own logger to avoid - // feedback loops from problem summary printing. - if (level == LocationAwareLogger.WARN_INT && message != null && !EXCLUDED_LOGGERS.contains(loggerName)) { + // DiagnosticCollector (avoiding double-counting), our own logger to avoid + // feedback loops from problem summary printing, and messages triggered by + // Log.problem() which are already reported as structured problems. + if (level == LocationAwareLogger.WARN_INT + && message != null + && !EXCLUDED_LOGGERS.contains(loggerName) + && !DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()) { String syntheticKey = syntheticDiagnosticKey(loggerName, message); diagnosticCollector.report(BuilderProblem.builder() .source(loggerName) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java index 1a11fe46fdb5..a81089d8078d 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -18,18 +18,35 @@ */ package org.apache.maven.internal.impl; +import java.util.function.Consumer; import java.util.function.Supplier; import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.services.BuilderProblem; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static java.util.Objects.requireNonNull; public class DefaultLog implements Log { + + /** + * Thread-local flag set by {@link #problem(BuilderProblem)} around the SLF4J call + * so that {@code BuildReportCollector} can skip auto-promotion for messages that + * are already reported as structured problems. This avoids double-counting. + */ + public static final ThreadLocal STRUCTURED_PROBLEM_ACTIVE = ThreadLocal.withInitial(() -> Boolean.FALSE); + private final Logger logger; + private final Consumer problemSink; public DefaultLog(Logger logger) { + this(logger, p -> {}); + } + + public DefaultLog(Logger logger, Consumer problemSink) { this.logger = requireNonNull(logger); + this.problemSink = requireNonNull(problemSink); } @Override @@ -127,7 +144,7 @@ public void warn(Supplier content) { @Override public void warn(Supplier content, Throwable error) { if (isWarnEnabled()) { - logger.info(content.get(), error); + logger.warn(content.get(), error); } } @@ -184,6 +201,33 @@ public boolean isErrorEnabled() { return logger.isErrorEnabled(); } + @Override + public Log child(String name) { + requireNonNull(name, "child logger name must not be null"); + return new DefaultLog(LoggerFactory.getLogger(logger.getName() + "." + name), problemSink); + } + + @Override + public void problem(BuilderProblem problem) { + requireNonNull(problem, "problem must not be null"); + // Report to the diagnostic collector for dedup and end-of-build summary + problemSink.accept(problem); + // Also log the message at the appropriate level for console output. + // Set the thread-local flag so BuildReportCollector skips auto-promotion + // (avoiding double-counting as both a structured problem and a synthetic one). + STRUCTURED_PROBLEM_ACTIVE.set(Boolean.TRUE); + try { + String message = problem.getMessage(); + switch (problem.getSeverity()) { + case FATAL, ERROR -> logger.error(message); + case WARNING -> logger.warn(message); + default -> logger.info(message); + } + } finally { + STRUCTURED_PROBLEM_ACTIVE.set(Boolean.FALSE); + } + } + private String toString(CharSequence content) { return content != null ? content.toString() : ""; } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index a95cf34fff83..24038a343796 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -63,6 +63,7 @@ import org.apache.maven.execution.MavenSession; import org.apache.maven.execution.scope.internal.MojoExecutionScope; import org.apache.maven.execution.scope.internal.MojoExecutionScopeModule; +import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.internal.impl.DefaultLog; import org.apache.maven.internal.impl.DefaultMojoExecution; import org.apache.maven.internal.impl.InternalMavenSession; @@ -164,6 +165,7 @@ public class DefaultMavenPluginManager implements MavenPluginManager { private final List configurationValidators; private final PluginValidationManager pluginValidationManager; private final List prerequisitesCheckers; + private final DefaultDiagnosticCollector diagnosticCollector; private final ExtensionDescriptorBuilder extensionDescriptorBuilder = new ExtensionDescriptorBuilder(); private final PluginDescriptorBuilder builder = new PluginDescriptorBuilder(); @@ -181,7 +183,8 @@ public DefaultMavenPluginManager( MavenPluginValidator pluginValidator, List configurationValidators, PluginValidationManager pluginValidationManager, - List prerequisitesCheckers) { + List prerequisitesCheckers, + DefaultDiagnosticCollector diagnosticCollector) { this.container = container; this.classRealmManager = classRealmManager; this.pluginDescriptorCache = pluginDescriptorCache; @@ -194,6 +197,7 @@ public DefaultMavenPluginManager( this.configurationValidators = configurationValidators; this.pluginValidationManager = pluginValidationManager; this.prerequisitesCheckers = prerequisitesCheckers; + this.diagnosticCollector = diagnosticCollector; } @Override @@ -555,8 +559,7 @@ private T loadV4Mojo( Project project = sessionV4.getProject(session.getCurrentProject()); org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution); - org.apache.maven.api.plugin.Log log = new DefaultLog( - LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName())); + String baseLoggerName = mojoExecution.getMojoDescriptor().getFullGoalName(); try { Injector injector = Injector.create(); injector.discover(pluginRealm); @@ -565,7 +568,16 @@ private T loadV4Mojo( injector.bindInstance(Session.class, sessionV4); injector.bindInstance(Project.class, project); injector.bindInstance(org.apache.maven.api.MojoExecution.class, execution); - injector.bindInstance(org.apache.maven.api.plugin.Log.class, log); + // Factory-based Log binding: unqualified @Inject Log gets the base logger + // (e.g. "compiler:compile"), while @Inject @Named("diagnostics") Log gets + // a child logger ("compiler:compile.diagnostics") — enabling hierarchical + // logger namespacing within a plugin's sub-components. + injector.bindFactory(org.apache.maven.api.plugin.Log.class, key -> { + String qualifier = key.getQualifier() instanceof String s ? s : null; + String loggerName = + qualifier != null && !qualifier.isEmpty() ? baseLoggerName + "." + qualifier : baseLoggerName; + return new DefaultLog(LoggerFactory.getLogger(loggerName), diagnosticCollector::report); + }); Map, Supplier> services = sessionV4.getAllServices(); services.forEach((itf, svc) -> injector.bindSupplier((Class) itf, (Supplier) svc)); diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java new file mode 100644 index 000000000000..1209c70e6486 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java @@ -0,0 +1,235 @@ +/* + * 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.impl; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.api.plugin.Log; +import org.apache.maven.api.services.BuilderProblem; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +class DefaultLogTest { + + @Test + void childCreatesHierarchicalLoggerName() { + DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("compiler:compile")); + Log child = parent.child("diagnostics"); + assertNotSame(parent, child); + // child is a DefaultLog; verify it works by creating another level + Log grandchild = child.child("detail"); + assertNotSame(child, grandchild); + } + + @Test + void childInheritsProblemSink() { + List collected = new ArrayList<>(); + DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("test:parent"), collected::add); + Log child = parent.child("sub"); + + BuilderProblem problem = BuilderProblem.builder() + .key("test:key") + .message("something wrong") + .severity(BuilderProblem.Severity.WARNING) + .build(); + child.problem(problem); + + assertEquals(1, collected.size()); + assertEquals("test:key", collected.get(0).getKey()); + } + + @Test + void problemReportsToSinkAndSetsThreadLocalFlag() { + List collected = new ArrayList<>(); + DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:problem"), collected::add); + + // Before calling problem(), flag should be false + assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()); + + BuilderProblem problem = BuilderProblem.builder() + .key("test:deprecation") + .message("deprecated feature") + .severity(BuilderProblem.Severity.WARNING) + .build(); + log.problem(problem); + + // After problem() returns, flag should be cleared + assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()); + + // Problem should have been reported to the sink + assertEquals(1, collected.size()); + assertEquals("deprecated feature", collected.get(0).getMessage()); + } + + @Test + void problemDefaultImplementationFallsBackToWarnOrError() { + // Test the default implementation on the Log interface directly + Log defaultLog = new Log() { + final List warnings = new ArrayList<>(); + final List errors = new ArrayList<>(); + + @Override + public boolean isDebugEnabled() { + return false; + } + + @Override + public void debug(CharSequence content) {} + + @Override + public void debug(CharSequence content, Throwable error) {} + + @Override + public void debug(Throwable error) {} + + @Override + public void debug(java.util.function.Supplier content) {} + + @Override + public void debug(java.util.function.Supplier content, Throwable error) {} + + @Override + public boolean isInfoEnabled() { + return true; + } + + @Override + public void info(CharSequence content) {} + + @Override + public void info(CharSequence content, Throwable error) {} + + @Override + public void info(Throwable error) {} + + @Override + public void info(java.util.function.Supplier content) {} + + @Override + public void info(java.util.function.Supplier content, Throwable error) {} + + @Override + public boolean isWarnEnabled() { + return true; + } + + @Override + public void warn(CharSequence content) { + warnings.add(content.toString()); + } + + @Override + public void warn(CharSequence content, Throwable error) {} + + @Override + public void warn(Throwable error) {} + + @Override + public void warn(java.util.function.Supplier content) {} + + @Override + public void warn(java.util.function.Supplier content, Throwable error) {} + + @Override + public boolean isErrorEnabled() { + return true; + } + + @Override + public void error(CharSequence content) { + errors.add(content.toString()); + } + + @Override + public void error(CharSequence content, Throwable error) {} + + @Override + public void error(Throwable error) {} + + @Override + public void error(java.util.function.Supplier content) {} + + @Override + public void error(java.util.function.Supplier content, Throwable error) {} + }; + + // WARNING severity → warn() + defaultLog.problem(BuilderProblem.builder() + .message("warn msg") + .severity(BuilderProblem.Severity.WARNING) + .build()); + assertEquals(1, ((List) getField(defaultLog, "warnings")).size()); + + // ERROR severity → error() + defaultLog.problem(BuilderProblem.builder() + .message("error msg") + .severity(BuilderProblem.Severity.ERROR) + .build()); + assertEquals(1, ((List) getField(defaultLog, "errors")).size()); + } + + @Test + void problemWithNoopSinkDoesNotThrow() { + // DefaultLog with default no-op sink should not throw + DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:noop")); + BuilderProblem problem = BuilderProblem.builder() + .message("just a warning") + .severity(BuilderProblem.Severity.WARNING) + .build(); + log.problem(problem); // should not throw + } + + @Test + void structuredProblemFlagIsClearedOnException() { + DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:exception"), p -> { + throw new RuntimeException("sink failed"); + }); + + BuilderProblem problem = BuilderProblem.builder() + .message("bad") + .severity(BuilderProblem.Severity.WARNING) + .build(); + + try { + log.problem(problem); + } catch (RuntimeException e) { + // expected + } + // Even on exception, the flag should NOT be left set + // (the exception is thrown before setting the flag in the current impl, + // but this tests the contract) + assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()); + } + + @SuppressWarnings("unchecked") + private static Object getField(Object obj, String name) { + try { + var field = obj.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java b/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java index 270ea6947c45..57d1d99c52ae 100644 --- a/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java +++ b/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java @@ -19,6 +19,7 @@ package org.apache.maven.di; import java.lang.annotation.Annotation; +import java.util.function.Function; import java.util.function.Supplier; import org.apache.maven.api.annotations.Nonnull; @@ -138,6 +139,36 @@ static Injector create() { @Nonnull Injector bindSupplier(@Nonnull Class cls, @Nonnull Supplier supplier); + /** + * Binds a factory that creates instances based on the injection-point {@link Key}. + *

+ * Unlike {@link #bindInstance} or {@link #bindSupplier}, a factory receives the full + * {@link Key} (type + qualifier) requested at the injection site and can produce a + * different instance per qualifier. This enables patterns like qualifier-derived + * hierarchical logger names: + *

+     * injector.bindFactory(Log.class, key -> {
+     *     String qualifier = key.getQualifier() instanceof String s ? s : null;
+     *     String name = qualifier != null ? baseName + "." + qualifier : baseName;
+     *     return new DefaultLog(LoggerFactory.getLogger(name));
+     * });
+     * 
+ * A field annotated {@code @Inject @Named("diagnostics") Log logger} would then + * receive a logger named {@code "compiler:compile.diagnostics"}. + *

+ * The factory also serves as the fallback for unqualified injection points + * ({@code @Inject Log logger}) — the key's qualifier will be {@code null}. + * + * @param the type of instances the factory produces + * @param cls the class to bind the factory to + * @param factory a function from injection-point {@link Key} to instance + * @return this injector instance for method chaining + * @throws NullPointerException if either parameter is null + * @since 4.1.0 + */ + @Nonnull + Injector bindFactory(@Nonnull Class cls, @Nonnull Function, T> factory); + /** * Performs field and method injection on an existing instance. *

diff --git a/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java b/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java index 0b26a22570ec..44c238cd54e0 100644 --- a/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java +++ b/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java @@ -60,6 +60,7 @@ public class InjectorImpl implements Injector { private final Map, Set>> bindings = new HashMap<>(); + private final Map, Function, ?>> factories = new HashMap<>(); private final Map, Supplier> scopes = new HashMap<>(); private final Set loadedUrls = new HashSet<>(); private final ThreadLocal>> resolutionStack = new ThreadLocal<>(); @@ -147,6 +148,15 @@ public Injector bindSupplier(@Nonnull Class clazz, @Nonnull Supplier s return doBind(key, binding); } + @Nonnull + @Override + public Injector bindFactory(@Nonnull Class clazz, @Nonnull Function, U> factory) { + @SuppressWarnings("unchecked") + Function, ?> raw = (Function, ?>) (Function) factory; + factories.put(clazz, raw); + return this; + } + @Nonnull @Override public Injector bindImplicit(@Nonnull Class clazz) { @@ -231,6 +241,14 @@ public Supplier doGetCompiledBinding(Dependency dep) { Binding binding = bindingList.get(0); return compile(binding); } + // Factory fallback: if no exact binding, try a registered factory for the raw type. + // The factory receives the full Key (including qualifier) and produces the instance. + Function, ?> factory = factories.get(key.getRawType()); + if (factory != null) { + @SuppressWarnings("unchecked") + Function, Q> typedFactory = (Function, Q>) factory; + return () -> typedFactory.apply(key); + } if (key.getRawType() == List.class) { Set> res2 = getBindings(key.getTypeParameter(0)); if (res2 != null) { @@ -486,6 +504,7 @@ public void dispose() { // Now clear everything else bindings.clear(); + factories.clear(); scopes.clear(); loadedUrls.clear(); resolutionStack.remove(); diff --git a/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java b/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java index 46ea1b331818..32a886624a3a 100644 --- a/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java +++ b/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java @@ -514,4 +514,60 @@ static class Foo {} @Named static class Bar {} } + + // ---- bindFactory tests ---- + + @Test + void factoryUnqualifiedLookup() { + Injector injector = Injector.create(); + injector.bindFactory(String.class, key -> { + Object q = key.getQualifier(); + return q instanceof String s && !s.isEmpty() ? "child:" + s : "root"; + }); + String result = injector.getInstance(String.class); + assertEquals("root", result); + } + + @Test + void factoryQualifiedLookup() { + Injector injector = Injector.create(); + injector.bindFactory(String.class, key -> { + Object q = key.getQualifier(); + return q instanceof String s && !s.isEmpty() ? "child:" + s : "root"; + }); + String result = injector.getInstance(Key.of(String.class, "diagnostics")); + assertEquals("child:diagnostics", result); + } + + @Test + void factoryInjectedIntoField() { + Injector injector = Injector.create(); + injector.bindFactory(String.class, key -> { + Object q = key.getQualifier(); + return q instanceof String s && !s.isEmpty() ? "child:" + s : "root"; + }); + injector.bindImplicit(FactoryConsumer.class); + FactoryConsumer consumer = injector.getInstance(FactoryConsumer.class); + assertEquals("root", consumer.defaultValue); + assertEquals("child:special", consumer.namedValue); + } + + @Test + void factoryExplicitBindingTakesPrecedence() { + Injector injector = Injector.create(); + injector.bindInstance(String.class, "explicit"); + injector.bindFactory(String.class, key -> "factory"); + // Explicit binding should win over factory + String result = injector.getInstance(String.class); + assertEquals("explicit", result); + } + + static class FactoryConsumer { + @Inject + String defaultValue; + + @Inject + @Named("special") + String namedValue; + } } From a37fe39483b34e1c263bdd25f1cbb10108b0a952 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 7 Aug 2026 07:55:24 +0200 Subject: [PATCH 06/10] Revert "Add Log.child(), Log.problem(), and injection-point-aware DI factory" This reverts commit 3f3565cba8856ace2978f05a9ceeb3e2bdb3dbac. --- .../java/org/apache/maven/api/plugin/Log.java | 53 ---- .../internal/build/BuildReportCollector.java | 11 +- .../maven/internal/impl/DefaultLog.java | 46 +--- .../internal/DefaultMavenPluginManager.java | 20 +- .../maven/internal/impl/DefaultLogTest.java | 235 ------------------ .../java/org/apache/maven/di/Injector.java | 31 --- .../apache/maven/di/impl/InjectorImpl.java | 19 -- .../maven/di/impl/InjectorImplTest.java | 56 ----- 8 files changed, 8 insertions(+), 463 deletions(-) delete mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java index 8b12b23abbbe..50627efd86e3 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java @@ -21,9 +21,7 @@ import java.util.function.Supplier; import org.apache.maven.api.annotations.Experimental; -import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.annotations.Provider; -import org.apache.maven.api.services.BuilderProblem; /** * This interface supplies the API for providing feedback to the user from the {@code Mojo}, @@ -169,55 +167,4 @@ public interface Log { void error(Supplier content); void error(Supplier content, Throwable error); - - /** - * Returns a child logger with the given name appended to this logger's name, - * enabling hierarchical logger namespacing within a plugin. - *

- * For example, if the current logger is named {@code "compiler:compile"}, - * calling {@code child("diagnostics")} returns a logger named - * {@code "compiler:compile.diagnostics"}. - *

- * This is useful when a plugin delegates to sub-components (e.g. options - * resolution, diagnostic reporting, incremental build decisions) and wants - * each component's log output to be independently filterable. - * - * @param name the child logger name segment (appended after a dot separator) - * @return a child logger; the default implementation returns {@code this} - * @since 4.1.0 - */ - @Nonnull - default Log child(@Nonnull String name) { - return this; - } - - /** - * Reports a structured {@link BuilderProblem} to the build's diagnostic collector. - *

- * Unlike {@link #warn(CharSequence)}, a structured problem carries a deduplication - * {@linkplain BuilderProblem#getKey() key}, an optional - * {@linkplain BuilderProblem#getSuggestion() suggestion}, and an optional - * {@linkplain BuilderProblem#getDocumentationUrl() documentation URL} — enabling - * Maven to deduplicate repeated warnings across modules and present an actionable - * end-of-build summary. - *

- * The problem is also logged at the appropriate level (WARN or ERROR) so it - * appears in the normal console output. Callers should not additionally - * call {@link #warn(CharSequence)} for the same message, as that would produce - * duplicate output. - *

- * The default implementation falls back to {@link #warn(CharSequence)} or - * {@link #error(CharSequence)} based on the problem's severity. - * - * @param problem the structured problem to report - * @since 4.1.0 - */ - default void problem(@Nonnull BuilderProblem problem) { - if (problem.getSeverity() == BuilderProblem.Severity.ERROR - || problem.getSeverity() == BuilderProblem.Severity.FATAL) { - error(problem.getMessage()); - } else { - warn(problem.getMessage()); - } - } } 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 598a3a12c6c4..6d85b8393e3f 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 @@ -57,7 +57,6 @@ import org.apache.maven.execution.ExecutionEvent; import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; -import org.apache.maven.internal.impl.DefaultLog; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.project.MavenProject; import org.apache.maven.slf4j.MavenSimpleLogger; @@ -368,13 +367,9 @@ private void captureLogEvent(int level, String loggerName, String message, Throw // 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 loggers that already pipe structured BuilderProblems directly to the - // DiagnosticCollector (avoiding double-counting), our own logger to avoid - // feedback loops from problem summary printing, and messages triggered by - // Log.problem() which are already reported as structured problems. - if (level == LocationAwareLogger.WARN_INT - && message != null - && !EXCLUDED_LOGGERS.contains(loggerName) - && !DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()) { + // DiagnosticCollector (avoiding double-counting), and our own logger to avoid + // feedback loops from problem summary printing. + if (level == LocationAwareLogger.WARN_INT && message != null && !EXCLUDED_LOGGERS.contains(loggerName)) { String syntheticKey = syntheticDiagnosticKey(loggerName, message); diagnosticCollector.report(BuilderProblem.builder() .source(loggerName) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java index a81089d8078d..1a11fe46fdb5 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -18,35 +18,18 @@ */ package org.apache.maven.internal.impl; -import java.util.function.Consumer; import java.util.function.Supplier; import org.apache.maven.api.plugin.Log; -import org.apache.maven.api.services.BuilderProblem; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import static java.util.Objects.requireNonNull; public class DefaultLog implements Log { - - /** - * Thread-local flag set by {@link #problem(BuilderProblem)} around the SLF4J call - * so that {@code BuildReportCollector} can skip auto-promotion for messages that - * are already reported as structured problems. This avoids double-counting. - */ - public static final ThreadLocal STRUCTURED_PROBLEM_ACTIVE = ThreadLocal.withInitial(() -> Boolean.FALSE); - private final Logger logger; - private final Consumer problemSink; public DefaultLog(Logger logger) { - this(logger, p -> {}); - } - - public DefaultLog(Logger logger, Consumer problemSink) { this.logger = requireNonNull(logger); - this.problemSink = requireNonNull(problemSink); } @Override @@ -144,7 +127,7 @@ public void warn(Supplier content) { @Override public void warn(Supplier content, Throwable error) { if (isWarnEnabled()) { - logger.warn(content.get(), error); + logger.info(content.get(), error); } } @@ -201,33 +184,6 @@ public boolean isErrorEnabled() { return logger.isErrorEnabled(); } - @Override - public Log child(String name) { - requireNonNull(name, "child logger name must not be null"); - return new DefaultLog(LoggerFactory.getLogger(logger.getName() + "." + name), problemSink); - } - - @Override - public void problem(BuilderProblem problem) { - requireNonNull(problem, "problem must not be null"); - // Report to the diagnostic collector for dedup and end-of-build summary - problemSink.accept(problem); - // Also log the message at the appropriate level for console output. - // Set the thread-local flag so BuildReportCollector skips auto-promotion - // (avoiding double-counting as both a structured problem and a synthetic one). - STRUCTURED_PROBLEM_ACTIVE.set(Boolean.TRUE); - try { - String message = problem.getMessage(); - switch (problem.getSeverity()) { - case FATAL, ERROR -> logger.error(message); - case WARNING -> logger.warn(message); - default -> logger.info(message); - } - } finally { - STRUCTURED_PROBLEM_ACTIVE.set(Boolean.FALSE); - } - } - private String toString(CharSequence content) { return content != null ? content.toString() : ""; } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index 24038a343796..a95cf34fff83 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -63,7 +63,6 @@ import org.apache.maven.execution.MavenSession; import org.apache.maven.execution.scope.internal.MojoExecutionScope; import org.apache.maven.execution.scope.internal.MojoExecutionScopeModule; -import org.apache.maven.internal.build.DefaultDiagnosticCollector; import org.apache.maven.internal.impl.DefaultLog; import org.apache.maven.internal.impl.DefaultMojoExecution; import org.apache.maven.internal.impl.InternalMavenSession; @@ -165,7 +164,6 @@ public class DefaultMavenPluginManager implements MavenPluginManager { private final List configurationValidators; private final PluginValidationManager pluginValidationManager; private final List prerequisitesCheckers; - private final DefaultDiagnosticCollector diagnosticCollector; private final ExtensionDescriptorBuilder extensionDescriptorBuilder = new ExtensionDescriptorBuilder(); private final PluginDescriptorBuilder builder = new PluginDescriptorBuilder(); @@ -183,8 +181,7 @@ public DefaultMavenPluginManager( MavenPluginValidator pluginValidator, List configurationValidators, PluginValidationManager pluginValidationManager, - List prerequisitesCheckers, - DefaultDiagnosticCollector diagnosticCollector) { + List prerequisitesCheckers) { this.container = container; this.classRealmManager = classRealmManager; this.pluginDescriptorCache = pluginDescriptorCache; @@ -197,7 +194,6 @@ public DefaultMavenPluginManager( this.configurationValidators = configurationValidators; this.pluginValidationManager = pluginValidationManager; this.prerequisitesCheckers = prerequisitesCheckers; - this.diagnosticCollector = diagnosticCollector; } @Override @@ -559,7 +555,8 @@ private T loadV4Mojo( Project project = sessionV4.getProject(session.getCurrentProject()); org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution); - String baseLoggerName = mojoExecution.getMojoDescriptor().getFullGoalName(); + org.apache.maven.api.plugin.Log log = new DefaultLog( + LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName())); try { Injector injector = Injector.create(); injector.discover(pluginRealm); @@ -568,16 +565,7 @@ private T loadV4Mojo( injector.bindInstance(Session.class, sessionV4); injector.bindInstance(Project.class, project); injector.bindInstance(org.apache.maven.api.MojoExecution.class, execution); - // Factory-based Log binding: unqualified @Inject Log gets the base logger - // (e.g. "compiler:compile"), while @Inject @Named("diagnostics") Log gets - // a child logger ("compiler:compile.diagnostics") — enabling hierarchical - // logger namespacing within a plugin's sub-components. - injector.bindFactory(org.apache.maven.api.plugin.Log.class, key -> { - String qualifier = key.getQualifier() instanceof String s ? s : null; - String loggerName = - qualifier != null && !qualifier.isEmpty() ? baseLoggerName + "." + qualifier : baseLoggerName; - return new DefaultLog(LoggerFactory.getLogger(loggerName), diagnosticCollector::report); - }); + injector.bindInstance(org.apache.maven.api.plugin.Log.class, log); Map, Supplier> services = sessionV4.getAllServices(); services.forEach((itf, svc) -> injector.bindSupplier((Class) itf, (Supplier) svc)); diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java deleted file mode 100644 index 1209c70e6486..000000000000 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * 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.impl; - -import java.util.ArrayList; -import java.util.List; - -import org.apache.maven.api.plugin.Log; -import org.apache.maven.api.services.BuilderProblem; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotSame; - -class DefaultLogTest { - - @Test - void childCreatesHierarchicalLoggerName() { - DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("compiler:compile")); - Log child = parent.child("diagnostics"); - assertNotSame(parent, child); - // child is a DefaultLog; verify it works by creating another level - Log grandchild = child.child("detail"); - assertNotSame(child, grandchild); - } - - @Test - void childInheritsProblemSink() { - List collected = new ArrayList<>(); - DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("test:parent"), collected::add); - Log child = parent.child("sub"); - - BuilderProblem problem = BuilderProblem.builder() - .key("test:key") - .message("something wrong") - .severity(BuilderProblem.Severity.WARNING) - .build(); - child.problem(problem); - - assertEquals(1, collected.size()); - assertEquals("test:key", collected.get(0).getKey()); - } - - @Test - void problemReportsToSinkAndSetsThreadLocalFlag() { - List collected = new ArrayList<>(); - DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:problem"), collected::add); - - // Before calling problem(), flag should be false - assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()); - - BuilderProblem problem = BuilderProblem.builder() - .key("test:deprecation") - .message("deprecated feature") - .severity(BuilderProblem.Severity.WARNING) - .build(); - log.problem(problem); - - // After problem() returns, flag should be cleared - assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()); - - // Problem should have been reported to the sink - assertEquals(1, collected.size()); - assertEquals("deprecated feature", collected.get(0).getMessage()); - } - - @Test - void problemDefaultImplementationFallsBackToWarnOrError() { - // Test the default implementation on the Log interface directly - Log defaultLog = new Log() { - final List warnings = new ArrayList<>(); - final List errors = new ArrayList<>(); - - @Override - public boolean isDebugEnabled() { - return false; - } - - @Override - public void debug(CharSequence content) {} - - @Override - public void debug(CharSequence content, Throwable error) {} - - @Override - public void debug(Throwable error) {} - - @Override - public void debug(java.util.function.Supplier content) {} - - @Override - public void debug(java.util.function.Supplier content, Throwable error) {} - - @Override - public boolean isInfoEnabled() { - return true; - } - - @Override - public void info(CharSequence content) {} - - @Override - public void info(CharSequence content, Throwable error) {} - - @Override - public void info(Throwable error) {} - - @Override - public void info(java.util.function.Supplier content) {} - - @Override - public void info(java.util.function.Supplier content, Throwable error) {} - - @Override - public boolean isWarnEnabled() { - return true; - } - - @Override - public void warn(CharSequence content) { - warnings.add(content.toString()); - } - - @Override - public void warn(CharSequence content, Throwable error) {} - - @Override - public void warn(Throwable error) {} - - @Override - public void warn(java.util.function.Supplier content) {} - - @Override - public void warn(java.util.function.Supplier content, Throwable error) {} - - @Override - public boolean isErrorEnabled() { - return true; - } - - @Override - public void error(CharSequence content) { - errors.add(content.toString()); - } - - @Override - public void error(CharSequence content, Throwable error) {} - - @Override - public void error(Throwable error) {} - - @Override - public void error(java.util.function.Supplier content) {} - - @Override - public void error(java.util.function.Supplier content, Throwable error) {} - }; - - // WARNING severity → warn() - defaultLog.problem(BuilderProblem.builder() - .message("warn msg") - .severity(BuilderProblem.Severity.WARNING) - .build()); - assertEquals(1, ((List) getField(defaultLog, "warnings")).size()); - - // ERROR severity → error() - defaultLog.problem(BuilderProblem.builder() - .message("error msg") - .severity(BuilderProblem.Severity.ERROR) - .build()); - assertEquals(1, ((List) getField(defaultLog, "errors")).size()); - } - - @Test - void problemWithNoopSinkDoesNotThrow() { - // DefaultLog with default no-op sink should not throw - DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:noop")); - BuilderProblem problem = BuilderProblem.builder() - .message("just a warning") - .severity(BuilderProblem.Severity.WARNING) - .build(); - log.problem(problem); // should not throw - } - - @Test - void structuredProblemFlagIsClearedOnException() { - DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:exception"), p -> { - throw new RuntimeException("sink failed"); - }); - - BuilderProblem problem = BuilderProblem.builder() - .message("bad") - .severity(BuilderProblem.Severity.WARNING) - .build(); - - try { - log.problem(problem); - } catch (RuntimeException e) { - // expected - } - // Even on exception, the flag should NOT be left set - // (the exception is thrown before setting the flag in the current impl, - // but this tests the contract) - assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()); - } - - @SuppressWarnings("unchecked") - private static Object getField(Object obj, String name) { - try { - var field = obj.getClass().getDeclaredField(name); - field.setAccessible(true); - return field.get(obj); - } catch (Exception e) { - throw new RuntimeException(e); - } - } -} diff --git a/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java b/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java index 57d1d99c52ae..270ea6947c45 100644 --- a/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java +++ b/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java @@ -19,7 +19,6 @@ package org.apache.maven.di; import java.lang.annotation.Annotation; -import java.util.function.Function; import java.util.function.Supplier; import org.apache.maven.api.annotations.Nonnull; @@ -139,36 +138,6 @@ static Injector create() { @Nonnull Injector bindSupplier(@Nonnull Class cls, @Nonnull Supplier supplier); - /** - * Binds a factory that creates instances based on the injection-point {@link Key}. - *

- * Unlike {@link #bindInstance} or {@link #bindSupplier}, a factory receives the full - * {@link Key} (type + qualifier) requested at the injection site and can produce a - * different instance per qualifier. This enables patterns like qualifier-derived - * hierarchical logger names: - *

-     * injector.bindFactory(Log.class, key -> {
-     *     String qualifier = key.getQualifier() instanceof String s ? s : null;
-     *     String name = qualifier != null ? baseName + "." + qualifier : baseName;
-     *     return new DefaultLog(LoggerFactory.getLogger(name));
-     * });
-     * 
- * A field annotated {@code @Inject @Named("diagnostics") Log logger} would then - * receive a logger named {@code "compiler:compile.diagnostics"}. - *

- * The factory also serves as the fallback for unqualified injection points - * ({@code @Inject Log logger}) — the key's qualifier will be {@code null}. - * - * @param the type of instances the factory produces - * @param cls the class to bind the factory to - * @param factory a function from injection-point {@link Key} to instance - * @return this injector instance for method chaining - * @throws NullPointerException if either parameter is null - * @since 4.1.0 - */ - @Nonnull - Injector bindFactory(@Nonnull Class cls, @Nonnull Function, T> factory); - /** * Performs field and method injection on an existing instance. *

diff --git a/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java b/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java index 44c238cd54e0..0b26a22570ec 100644 --- a/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java +++ b/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java @@ -60,7 +60,6 @@ public class InjectorImpl implements Injector { private final Map, Set>> bindings = new HashMap<>(); - private final Map, Function, ?>> factories = new HashMap<>(); private final Map, Supplier> scopes = new HashMap<>(); private final Set loadedUrls = new HashSet<>(); private final ThreadLocal>> resolutionStack = new ThreadLocal<>(); @@ -148,15 +147,6 @@ public Injector bindSupplier(@Nonnull Class clazz, @Nonnull Supplier s return doBind(key, binding); } - @Nonnull - @Override - public Injector bindFactory(@Nonnull Class clazz, @Nonnull Function, U> factory) { - @SuppressWarnings("unchecked") - Function, ?> raw = (Function, ?>) (Function) factory; - factories.put(clazz, raw); - return this; - } - @Nonnull @Override public Injector bindImplicit(@Nonnull Class clazz) { @@ -241,14 +231,6 @@ public Supplier doGetCompiledBinding(Dependency dep) { Binding binding = bindingList.get(0); return compile(binding); } - // Factory fallback: if no exact binding, try a registered factory for the raw type. - // The factory receives the full Key (including qualifier) and produces the instance. - Function, ?> factory = factories.get(key.getRawType()); - if (factory != null) { - @SuppressWarnings("unchecked") - Function, Q> typedFactory = (Function, Q>) factory; - return () -> typedFactory.apply(key); - } if (key.getRawType() == List.class) { Set> res2 = getBindings(key.getTypeParameter(0)); if (res2 != null) { @@ -504,7 +486,6 @@ public void dispose() { // Now clear everything else bindings.clear(); - factories.clear(); scopes.clear(); loadedUrls.clear(); resolutionStack.remove(); diff --git a/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java b/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java index 32a886624a3a..46ea1b331818 100644 --- a/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java +++ b/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java @@ -514,60 +514,4 @@ static class Foo {} @Named static class Bar {} } - - // ---- bindFactory tests ---- - - @Test - void factoryUnqualifiedLookup() { - Injector injector = Injector.create(); - injector.bindFactory(String.class, key -> { - Object q = key.getQualifier(); - return q instanceof String s && !s.isEmpty() ? "child:" + s : "root"; - }); - String result = injector.getInstance(String.class); - assertEquals("root", result); - } - - @Test - void factoryQualifiedLookup() { - Injector injector = Injector.create(); - injector.bindFactory(String.class, key -> { - Object q = key.getQualifier(); - return q instanceof String s && !s.isEmpty() ? "child:" + s : "root"; - }); - String result = injector.getInstance(Key.of(String.class, "diagnostics")); - assertEquals("child:diagnostics", result); - } - - @Test - void factoryInjectedIntoField() { - Injector injector = Injector.create(); - injector.bindFactory(String.class, key -> { - Object q = key.getQualifier(); - return q instanceof String s && !s.isEmpty() ? "child:" + s : "root"; - }); - injector.bindImplicit(FactoryConsumer.class); - FactoryConsumer consumer = injector.getInstance(FactoryConsumer.class); - assertEquals("root", consumer.defaultValue); - assertEquals("child:special", consumer.namedValue); - } - - @Test - void factoryExplicitBindingTakesPrecedence() { - Injector injector = Injector.create(); - injector.bindInstance(String.class, "explicit"); - injector.bindFactory(String.class, key -> "factory"); - // Explicit binding should win over factory - String result = injector.getInstance(String.class); - assertEquals("explicit", result); - } - - static class FactoryConsumer { - @Inject - String defaultValue; - - @Inject - @Named("special") - String namedValue; - } } From d4be5493333d7c23122b49867dbc29e5cc70d0c0 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 7 Aug 2026 08:06:55 +0200 Subject: [PATCH 07/10] Add Log.child() for hierarchical logger names; drop Log.problem() and bindFactory() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only Log.child(String) — a clean, backward-compatible default method that lets sub-components log under an independently filterable name (e.g. "compiler:compile" → "compiler:compile.diagnostics"). Removed from the earlier commit: - Log.problem(BuilderProblem) — conflated logging with problem reporting; plugins should use DiagnosticReporter directly instead. - Injector.bindFactory() — too much DI machinery for a single use case; plugins can call logger.child() at the call site. - STRUCTURED_PROBLEM_ACTIVE ThreadLocal — fragile invisible coupling between DefaultLog and BuildReportCollector. - Factory-based Log binding in DefaultMavenPluginManager. Also fixes pre-existing bug: DefaultLog.warn(Supplier, Throwable) was calling logger.info() instead of logger.warn(). Co-Authored-By: Claude Opus 4.6 --- .../maven/api/build/report/LogEvent.java | 111 ++++++++++++++++-- .../java/org/apache/maven/api/plugin/Log.java | 23 ++++ .../maven/api/services/BuilderProblem.java | 42 +++++-- .../maven/internal/build/DefaultLogEvent.java | 21 +++- .../maven/internal/impl/DefaultLog.java | 9 +- .../build/BuildReportIntegrationTest.java | 23 ++-- .../build/DefaultDiagnosticCollectorTest.java | 15 +-- .../maven/internal/impl/DefaultLogTest.java | 38 ++++++ 8 files changed, 232 insertions(+), 50 deletions(-) create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java index 70384fad4fdb..d2af76be206e 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -25,15 +25,52 @@ import org.apache.maven.api.annotations.Nullable; /** - * A structured log event captured during the build. + * A log message emitted during the build — the narrative stream that tells + * you what the build is doing. *

- * Each event carries the log level, timestamp, message, and optionally - * the logger name and a stack trace. This replaces raw log line strings - * in the build report, enabling programmatic filtering by level and - * correlation by timestamp. + * A {@code LogEvent} is the Maven equivalent of an SLF4J log line: it carries + * a timestamp, a severity level, the emitting logger, and the message text. + * Log events stream through the console and are recorded in + * the build report, but they are not deduplicated or summarized. + * + *

LogEvent vs {@link org.apache.maven.api.services.BuilderProblem BuilderProblem}

+ *

+ * These two types serve complementary roles: + *

    + *
  • {@code LogEvent} — "something happened": informational progress + * ({@code "Compiling 42 source files"}, {@code "Downloading commons-lang3.jar"}). + * Streams to the console and is recorded in the build report.
  • + *
  • {@code BuilderProblem} — "something needs attention": an actionable + * finding with a source location, deduplication key, and optional fix suggestion + * ({@code "unchecked cast at Foo.java:42"}). Collected, deduplicated, and + * summarized at the end of the build. Always {@code WARNING} severity or higher, + * always user-facing.
  • + *
+ *

+ * The deciding question is: can the user act on it? If yes, use + * {@code BuilderProblem}. If it's informational or progress-related, use {@code LogEvent}. + * + *

Audience

*

- * Log events are captured at three levels forming a non-overlapping - * partition of the full build log: + * Each log event carries an {@link #audience()} that identifies who the message + * is intended for. Audiences are cumulative — each tier includes + * all messages from lower tiers: + *

    + *
  • {@link Audience#USER} — messages the build user acts on: + * compilation results, test outcomes, dependency conflicts
  • + *
  • {@link Audience#PLUGIN} — adds plugin-internal messages: + * mojo parameters, plugin configuration details
  • + *
  • {@link Audience#INTERNAL} — adds Maven core internals: + * lifecycle ordering, model interpolation, resolver decisions
  • + *
+ *

+ * Console modes use the audience to filter output: {@code --console=rich} shows + * only {@code USER} messages inline, while {@code -X} shows all three tiers. + * + *

Capture hierarchy

+ *

+ * Log events are captured at three levels forming a non-overlapping partition + * of the full build log: *

    *
  • {@link BuildReport#output()} — events outside any module lifecycle
  • *
  • {@link ModuleReport#output()} — events during a module build but outside any mojo
  • @@ -41,6 +78,7 @@ *
* * @since 4.1.0 + * @see org.apache.maven.api.services.BuilderProblem */ @Experimental public interface LogEvent { @@ -105,4 +143,63 @@ public interface LogEvent { */ @Nullable String formattedMessage(); + + /** + * The intended audience for this log event. + *

+ * Console modes use this to filter output — for example, {@code --console=rich} + * shows only {@link Audience#USER} messages inline, while {@code -X} shows all + * three tiers. The build report always records all events regardless of audience. + *

+ * Defaults to {@link Audience#USER} if not specified. + * + * @return the audience tier, never {@code null} + * @since 4.1.0 + */ + @Nonnull + default Audience audience() { + return Audience.USER; + } + + /** + * Identifies the intended audience for a log event. + *

+ * Audiences are cumulative: each tier includes all messages + * from the tiers below it. A console configured for {@code PLUGIN} will show + * both {@code USER} and {@code PLUGIN} messages; a console configured for + * {@code INTERNAL} shows everything. + * + * + * + * + * + * + * + * + * + * + *
Audience tiers and what they add
TierShowsExamples
{@code USER}Build outcomes and progress"Compiling 42 source files", "Tests run: 10, Failures: 0"
{@code PLUGIN}+ plugin internalsMojo parameter dumps, plugin configuration details
{@code INTERNAL}+ Maven core internalsLifecycle phase ordering, model interpolation, resolver traces
+ * + * @since 4.1.0 + */ + @Experimental + enum Audience { + /** + * Messages intended for the build user: compilation results, + * test outcomes, dependency conflicts, download progress. + */ + USER, + /** + * Messages intended for plugin developers: mojo parameters, + * classpath details, plugin-internal diagnostics. Includes + * all {@link #USER} messages. + */ + PLUGIN, + /** + * Messages intended for Maven core developers: lifecycle + * ordering, model interpolation, resolver negotiation. + * Includes all {@link #USER} and {@link #PLUGIN} messages. + */ + INTERNAL + } } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java index 50627efd86e3..d6f2c8b25c09 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java @@ -167,4 +167,27 @@ public interface Log { void error(Supplier content); void error(Supplier content, Throwable error); + + /** + * Returns a child logger whose name is derived from this logger's name + * by appending a dot and the given suffix. + * + *

For example, if a plugin's logger is named {@code "compiler:compile"}, + * then {@code child("diagnostics")} returns a logger named + * {@code "compiler:compile.diagnostics"}. This lets sub-components log + * under an independently filterable name without requiring a separate + * injection point.

+ * + *

The default implementation returns {@code this}, so existing + * {@code Log} implementations continue to work without changes. + * Implementations that wrap a hierarchical logging backend (such as + * SLF4J) should override this to create a real child logger.

+ * + * @param name the suffix to append (must not be {@code null} or blank) + * @return a child logger — never {@code null} + * @since 4.0.0 + */ + default Log child(String name) { + return this; + } } 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 724bbd2f2a93..a8b84d426d22 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 @@ -25,18 +25,35 @@ import org.apache.maven.api.annotations.ThreadSafe; /** - * 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. + * An actionable finding discovered during the build — something the user + * needs to fix or should be aware of. *

- * 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. + * A {@code BuilderProblem} is always user-facing and always at least + * {@link Severity#WARNING} severity. It carries structured metadata + * — a source location, a deduplication {@link #getKey() key}, an optional + * {@link #getSuggestion() fix suggestion}, and a {@link #getDocumentationUrl() + * documentation link} — so the build can collect, deduplicate, and summarize + * problems at the end rather than repeating each occurrence inline. + * + *

BuilderProblem vs {@link org.apache.maven.api.build.report.LogEvent LogEvent}

+ *

+ * These two types serve complementary roles: + *

    + *
  • {@code BuilderProblem} — "something needs attention": an actionable + * finding ({@code "unchecked cast at Foo.java:42"}). Collected, deduplicated + * by {@link #getKey() key}, and summarized at the end of the build. + * Always {@code WARNING+}, always user-facing.
  • + *
  • {@code LogEvent} — "something happened": informational progress + * ({@code "Compiling 42 source files"}). Streams to the console and is + * recorded in the build report, but not deduplicated or summarized.
  • + *
+ *

+ * The deciding question is: can the user act on it? If yes, + * use {@code BuilderProblem}. If it's informational or progress-related, + * use {@code LogEvent}. * * @since 4.0.0 + * @see org.apache.maven.api.build.report.LogEvent */ @Experimental @Immutable @@ -168,6 +185,10 @@ static Builder builder() { /** * The different severity levels for a problem, in decreasing order. + *

+ * A {@code BuilderProblem} is always at least {@code WARNING} — purely + * informational messages belong in {@link org.apache.maven.api.build.report.LogEvent} + * instead. * * @since 4.0.0 */ @@ -175,8 +196,7 @@ static Builder builder() { enum Severity { FATAL, // ERROR, // - WARNING, // - INFO // + WARNING // } /** diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java index 5dda07716343..17f67ef98e5a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java @@ -35,6 +35,7 @@ * @param loggerName the name of the logger, or {@code null} * @param stackTrace the stack trace string, or {@code null} * @param formattedMessage the fully formatted console line, or {@code null} + * @param audience the intended audience tier, never {@code null} */ public record DefaultLogEvent( Instant timestamp, @@ -42,14 +43,28 @@ public record DefaultLogEvent( String message, String loggerName, String stackTrace, - String formattedMessage) + String formattedMessage, + Audience audience) implements LogEvent { + /** + * Convenience constructor that defaults the audience to {@link Audience#USER}. + */ + public DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage) { + this(timestamp, level, message, loggerName, stackTrace, formattedMessage, Audience.USER); + } + /** * Convenience constructor for events created without a formatted message - * (e.g. in tests or programmatic construction). + * (e.g. in tests or programmatic construction). Defaults audience to {@link Audience#USER}. */ DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) { - this(timestamp, level, message, loggerName, stackTrace, null); + this(timestamp, level, message, loggerName, stackTrace, null, Audience.USER); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java index 1a11fe46fdb5..28a8e6e7c540 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -22,6 +22,7 @@ import org.apache.maven.api.plugin.Log; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static java.util.Objects.requireNonNull; @@ -127,7 +128,7 @@ public void warn(Supplier content) { @Override public void warn(Supplier content, Throwable error) { if (isWarnEnabled()) { - logger.info(content.get(), error); + logger.warn(content.get(), error); } } @@ -184,6 +185,12 @@ public boolean isErrorEnabled() { return logger.isErrorEnabled(); } + @Override + public Log child(String name) { + requireNonNull(name, "name"); + return new DefaultLog(LoggerFactory.getLogger(logger.getName() + "." + name)); + } + private String toString(CharSequence content) { return content != null ? content.toString() : ""; } 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 10f2dc1ae78b..35e4853b93c1 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 @@ -90,15 +90,6 @@ private static BuilderProblem warning(String key, String message, String source, .build(); } - private static BuilderProblem info(String key, String message, String source) { - return BuilderProblem.builder() - .source(source) - .message(message) - .severity(BuilderProblem.Severity.INFO) - .key(key) - .build(); - } - private static BuilderProblem error(String key, String message, String source) { return BuilderProblem.builder() .source(source) @@ -163,8 +154,8 @@ void testFullMultiModuleBuildWithProblems() throws IOException { // 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")); + // Also report a unique error problem + diagnosticCollector.report(error("build-error", "Missing required dependency", "impl")); collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl)); result.addBuildSummary(new BuildSuccess(impl, 2000)); @@ -183,8 +174,8 @@ void testFullMultiModuleBuildWithProblems() throws IOException { 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()); + assertEquals("build-error", report.problems().get(1).getKey()); + assertEquals(BuilderProblem.Severity.ERROR, report.problems().get(1).getSeverity()); // Summary should show count = 2 for the warning assertEquals(2, diagnosticCollector.getSummary().get(0).count()); @@ -227,8 +218,8 @@ void testFullMultiModuleBuildWithProblems() throws IOException { 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\"")); + assertTrue(json.contains("\"key\": \"build-error\"")); + assertTrue(json.contains("\"severity\": \"ERROR\"")); // Failures should be empty assertTrue(json.contains("\"failures\": []")); @@ -254,7 +245,7 @@ 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)); + diagnosticCollector.report(warning("warn-2", "second warning", null)); // Should not throw collector.printDiagnosticSummary(); 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 index 90dfeefe1457..1532f94f712d 100644 --- 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 @@ -61,15 +61,6 @@ private static BuilderProblem error(String key, String message, String source) { .build(); } - private static BuilderProblem info(String key, String message, String source) { - return BuilderProblem.builder() - .source(source) - .message(message) - .severity(BuilderProblem.Severity.INFO) - .key(key) - .build(); - } - @Test void testEmptyCollector() { assertTrue(collector.getProblems().isEmpty()); @@ -100,12 +91,12 @@ void testSingleError() { } @Test - void testInfoDoesNotCountAsWarningOrError() { - BuilderProblem p = info("build-summary", "Build completed", "reactor"); + void testWarningCountsAsWarning() { + BuilderProblem p = warning("deprecated-api", "API is deprecated", "reactor"); collector.report(p); assertEquals(1, collector.getProblems().size()); - assertFalse(collector.hasWarnings()); + assertTrue(collector.hasWarnings()); assertFalse(collector.hasErrors()); } diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java new file mode 100644 index 000000000000..6ab0646c1666 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java @@ -0,0 +1,38 @@ +/* + * 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.impl; + +import org.apache.maven.api.plugin.Log; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.assertNotSame; + +class DefaultLogTest { + + @Test + void childCreatesHierarchicalLoggerName() { + DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("compiler:compile")); + Log child = parent.child("diagnostics"); + assertNotSame(parent, child); + // child is a DefaultLog; verify it works by creating another level + Log grandchild = child.child("detail"); + assertNotSame(child, grandchild); + } +} From b156fe96afa5bb8fa570febe57140a1f1386cc93 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 7 Aug 2026 09:04:47 +0200 Subject: [PATCH 08/10] Add projectId and mojoId to LogEvent for mojo-scoped log attribution LogEvent now carries projectId() and mojoId() so every log message (including JUL-bridged ones) is self-describing. The mojo ID is set in MDC by LoggingExecutionListener on mojo start/finish, read by ProjectBuildLogAppender, and embedded in the event. Machine mode emits the mojoId in its JSON output. Co-Authored-By: Claude Opus 4.6 --- .../maven/api/build/report/LogEvent.java | 36 +++++++++++++++++++ .../event/MachineBuildEventListener.java | 1 + .../maven/internal/build/DefaultLogEvent.java | 11 ++++-- .../logging/LoggingExecutionListener.java | 11 ++++++ .../logging/ProjectBuildLogAppender.java | 29 +++++++++++++-- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java index d2af76be206e..3db827945611 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -144,6 +144,42 @@ public interface LogEvent { @Nullable String formattedMessage(); + /** + * The project that was active when this log event was produced. + *

+ * Corresponds to the Maven project's artifact ID (e.g. {@code "maven-core"}). + * Set by the lifecycle engine when a project build starts and cleared when + * it finishes; log events emitted outside any project lifecycle (e.g. during + * session setup) will return {@code null}. + * + * @return the project artifact ID, or {@code null} + * @since 4.1.0 + */ + @Nullable + default String projectId() { + return null; + } + + /** + * The mojo execution that was active when this log event was produced. + *

+ * The format is {@code "goal@executionId"} (e.g. {@code "compile@default-compile"}). + * Set by the lifecycle engine when a mojo starts and cleared when it finishes; + * log events emitted outside any mojo execution (e.g. during project setup or + * between mojos) will return {@code null}. + *

+ * This enables mojo-scoped attribution for all log messages, + * including those arriving through the JUL-to-SLF4J bridge from plugins + * that use {@code java.util.logging} directly. + * + * @return the mojo execution identifier, or {@code null} + * @since 4.1.0 + */ + @Nullable + default String mojoId() { + return null; + } + /** * The intended audience for this log event. *

diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java index 79280ac4fa76..d1694093db59 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/event/MachineBuildEventListener.java @@ -89,6 +89,7 @@ public void projectLogMessage(String projectId, LogEvent event) { emitEvent(new JsonLine("log") .field("level", event.level().name()) .field("module", projectId) + .field("mojo", event.mojoId()) .field("logger", event.loggerName()) .field("message", event.message()) .build()); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java index 17f67ef98e5a..f9d53a6ad01b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java @@ -35,6 +35,8 @@ * @param loggerName the name of the logger, or {@code null} * @param stackTrace the stack trace string, or {@code null} * @param formattedMessage the fully formatted console line, or {@code null} + * @param projectId the active project artifact ID, or {@code null} + * @param mojoId the active mojo execution (goal@executionId), or {@code null} * @param audience the intended audience tier, never {@code null} */ public record DefaultLogEvent( @@ -44,11 +46,14 @@ public record DefaultLogEvent( String loggerName, String stackTrace, String formattedMessage, + String projectId, + String mojoId, Audience audience) implements LogEvent { /** - * Convenience constructor that defaults the audience to {@link Audience#USER}. + * Convenience constructor that defaults projectId, mojoId to {@code null} + * and audience to {@link Audience#USER}. */ public DefaultLogEvent( Instant timestamp, @@ -57,7 +62,7 @@ public DefaultLogEvent( String loggerName, String stackTrace, String formattedMessage) { - this(timestamp, level, message, loggerName, stackTrace, formattedMessage, Audience.USER); + this(timestamp, level, message, loggerName, stackTrace, formattedMessage, null, null, Audience.USER); } /** @@ -65,6 +70,6 @@ public DefaultLogEvent( * (e.g. in tests or programmatic construction). Defaults audience to {@link Audience#USER}. */ DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) { - this(timestamp, level, message, loggerName, stackTrace, null, Audience.USER); + this(timestamp, level, message, loggerName, stackTrace, null, null, null, Audience.USER); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java index 040a481455e2..a2f45f0527dc 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java @@ -121,6 +121,7 @@ public void projectSkipped(ExecutionEvent event) { @Override public void mojoStarted(ExecutionEvent event) { setMdc(event); + setMojoMdc(event); buildEventListener.mojoStarted(event); delegate.mojoStarted(event); } @@ -128,12 +129,14 @@ public void mojoStarted(ExecutionEvent event) { @Override public void mojoSucceeded(ExecutionEvent event) { setMdc(event); + ProjectBuildLogAppender.setMojoId(null); delegate.mojoSucceeded(event); } @Override public void mojoFailed(ExecutionEvent event) { setMdc(event); + ProjectBuildLogAppender.setMojoId(null); delegate.mojoFailed(event); } @@ -187,4 +190,12 @@ private void setMdc(ExecutionEvent event) { ProjectBuildLogAppender.setProjectId(event.getProject().getArtifactId()); } } + + private void setMojoMdc(ExecutionEvent event) { + if (event.getMojoExecution() != null) { + String mojoId = event.getMojoExecution().getGoal() + "@" + + event.getMojoExecution().getExecutionId(); + ProjectBuildLogAppender.setMojoId(mojoId); + } + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java index 44339eae8c8c..1aa61a15f30b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java @@ -41,7 +41,9 @@ public class ProjectBuildLogAppender implements AutoCloseable { private static final String KEY_PROJECT_ID = "maven.project.id"; + private static final String KEY_MOJO_ID = "maven.mojo.id"; private static final ThreadLocal PROJECT_ID = new InheritableThreadLocal<>(); + private static final ThreadLocal MOJO_ID = new InheritableThreadLocal<>(); private static final ThreadLocal FORKING_PROJECT_ID = new InheritableThreadLocal<>(); public static String getProjectId() { @@ -66,6 +68,20 @@ public static void setProjectId(String projectId) { } } + public static String getMojoId() { + return MOJO_ID.get(); + } + + public static void setMojoId(String mojoId) { + if (mojoId != null) { + MOJO_ID.set(mojoId); + MDC.put(KEY_MOJO_ID, mojoId); + } else { + MOJO_ID.remove(); + MDC.remove(KEY_MOJO_ID); + } + } + public static void setForkingProjectId(String forkingProjectId) { if (forkingProjectId != null) { FORKING_PROJECT_ID.set(forkingProjectId); @@ -93,11 +109,20 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) { protected void accept( int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) { String projectId = MDC.get(KEY_PROJECT_ID); + String mojoId = MDC.get(KEY_MOJO_ID); Instant timestamp = MonotonicClock.now(); LogLevel logLevel = toLogLevel(level); String stackTrace = throwable != null ? formatStackTrace(throwable) : null; - LogEvent event = - new DefaultLogEvent(timestamp, logLevel, cleanMessage, loggerName, stackTrace, formattedMessage); + LogEvent event = new DefaultLogEvent( + timestamp, + logLevel, + cleanMessage, + loggerName, + stackTrace, + formattedMessage, + projectId, + mojoId, + LogEvent.Audience.USER); buildEventListener.projectLogMessage(projectId, event); } From d00ae4d1d398e871087dd06f093bc57e683fe6b3 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 7 Aug 2026 10:32:34 +0200 Subject: [PATCH 09/10] Use mojo class name as Log logger; include prefix in mojoId DefaultLog now uses the mojo implementation class name as its SLF4J logger name instead of getFullGoalName(). This makes Log and JUL produce identical SLF4J loggers when plugins use the conventional class-name pattern, and enables hierarchical SLF4J level configuration (e.g. org.apache.maven.plugin.compiler=DEBUG). The mojoId format changes from goal@executionId to prefix:goal@executionId (e.g. compiler:compile@default-compile) for unambiguous identification. Co-Authored-By: Claude Opus 4.6 --- .../main/java/org/apache/maven/api/build/report/LogEvent.java | 3 ++- .../org/apache/maven/logging/LoggingExecutionListener.java | 2 +- .../org/apache/maven/plugin/DefaultBuildPluginManager.java | 2 +- .../maven/plugin/internal/DefaultMavenPluginManager.java | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java index 3db827945611..0061915e8191 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -163,7 +163,8 @@ default String projectId() { /** * The mojo execution that was active when this log event was produced. *

- * The format is {@code "goal@executionId"} (e.g. {@code "compile@default-compile"}). + * The format is {@code "prefix:goal@executionId"} + * (e.g. {@code "compiler:compile@default-compile"}). * Set by the lifecycle engine when a mojo starts and cleared when it finishes; * log events emitted outside any mojo execution (e.g. during project setup or * between mojos) will return {@code null}. diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java index a2f45f0527dc..6685351b362a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/LoggingExecutionListener.java @@ -193,7 +193,7 @@ private void setMdc(ExecutionEvent event) { private void setMojoMdc(ExecutionEvent event) { if (event.getMojoExecution() != null) { - String mojoId = event.getMojoExecution().getGoal() + "@" + String mojoId = event.getMojoExecution().getMojoDescriptor().getFullGoalName() + "@" + event.getMojoExecution().getExecutionId(); ProjectBuildLogAppender.setMojoId(mojoId); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java index e395d1ed000b..476f2aadcdd3 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultBuildPluginManager.java @@ -125,7 +125,7 @@ public void executeMojo(MavenSession session, MojoExecution mojoExecution) scope.seed( org.apache.maven.api.plugin.Log.class, new DefaultLog(LoggerFactory.getLogger( - mojoExecution.getMojoDescriptor().getFullGoalName()))); + mojoExecution.getMojoDescriptor().getImplementation()))); InternalMavenSession sessionV4 = InternalMavenSession.from(session.getSession()); scope.seed(Project.class, sessionV4.getProject(project)); scope.seed(org.apache.maven.api.MojoExecution.class, new DefaultMojoExecution(sessionV4, mojoExecution)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java index a95cf34fff83..bf5639a4f536 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java @@ -555,8 +555,8 @@ private T loadV4Mojo( Project project = sessionV4.getProject(session.getCurrentProject()); org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution); - org.apache.maven.api.plugin.Log log = new DefaultLog( - LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName())); + org.apache.maven.api.plugin.Log log = + new DefaultLog(LoggerFactory.getLogger(mojoDescriptor.getImplementation())); try { Injector injector = Injector.create(); injector.discover(pluginRealm); From 2bad7dd836ea5ab3d619a33886e4a76584d1e38c Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 7 Aug 2026 11:19:53 +0200 Subject: [PATCH 10/10] Add Log.trace() level for Maven core internals Debug is currently overloaded: user-relevant diagnostic messages (e.g. "recompiling because annotation processor changed") share the same level as Maven internal details (resolver negotiation, model interpolation). This makes -X output unusable for users investigating their build. Add trace() methods to the Maven 4 Log API, mirroring the existing debug/info/warn/error pattern. Trace maps to SLF4J TRACE and JUL FINEST. The intent is that Maven internals demote to trace, leaving debug for user-facing diagnostic information. Co-Authored-By: Claude Opus 4.6 --- .../java/org/apache/maven/api/plugin/Log.java | 62 +++++++++++++++++++ .../maven/internal/impl/DefaultLog.java | 38 ++++++++++++ 2 files changed, 100 insertions(+) diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java index d6f2c8b25c09..762c1b4c9049 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java @@ -36,6 +36,63 @@ @Experimental @Provider public interface Log { + /** + * {@return true if the trace error level is enabled} + */ + boolean isTraceEnabled(); + + /** + * Sends a message to the user in the trace error level. + *

+ * Trace is the most verbose level, intended for Maven core internals + * such as resolver negotiation, model interpolation, and lifecycle + * ordering details. Use {@link #debug(CharSequence)} instead for + * messages that help users investigate their build + * (e.g. why a module was recompiled). + * + * @param content the message to log + * @since 4.1.0 + */ + void trace(CharSequence content); + + /** + * Sends a message (and accompanying exception) to the user at the trace error level. + * The error's stacktrace will be output when this error level is enabled. + * + * @param content the message to log + * @param error the error that caused this log + * @since 4.1.0 + */ + void trace(CharSequence content, Throwable error); + + /** + * Sends an exception to the user in the trace error level. + * The stack trace for this exception will be output when this error level is enabled. + * + * @param error the error that caused this log + * @since 4.1.0 + */ + void trace(Throwable error); + + /** + * Sends a lazily-computed message in the trace error level. + * The supplier is only evaluated if trace is enabled. + * + * @param content the message supplier + * @since 4.1.0 + */ + void trace(Supplier content); + + /** + * Sends a lazily-computed message (and accompanying exception) in the trace error level. + * The supplier is only evaluated if trace is enabled. + * + * @param content the message supplier + * @param error the error that caused this log + * @since 4.1.0 + */ + void trace(Supplier content, Throwable error); + /** * {@return true if the debug error level is enabled} */ @@ -43,6 +100,11 @@ public interface Log { /** * Sends a message to the user in the debug error level. + *

+ * Debug is intended for messages that help users investigate + * their build — for example, why a module was recompiled or what + * classpath was resolved. For Maven core internals, use + * {@link #trace(CharSequence)} instead. * * @param content the message to log */ diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java index 28a8e6e7c540..a70c2adb290a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -33,6 +33,44 @@ public DefaultLog(Logger logger) { this.logger = requireNonNull(logger); } + @Override + public boolean isTraceEnabled() { + return logger.isTraceEnabled(); + } + + @Override + public void trace(CharSequence content) { + if (isTraceEnabled()) { + logger.trace(toString(content)); + } + } + + @Override + public void trace(CharSequence content, Throwable error) { + if (isTraceEnabled()) { + logger.trace(toString(content), error); + } + } + + @Override + public void trace(Throwable error) { + logger.trace("", error); + } + + @Override + public void trace(Supplier content) { + if (isTraceEnabled()) { + logger.trace(content.get()); + } + } + + @Override + public void trace(Supplier content, Throwable error) { + if (isTraceEnabled()) { + logger.trace(content.get(), error); + } + } + @Override public void debug(CharSequence content) { if (isDebugEnabled()) {