You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/08/28 10:22:00 UTC

(maven) 05/07: mvnlog: build log viewer, integration tests, script routing

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch feature/mvnlog-web
in repository https://gitbox.apache.org/repos/asf/maven.git

commit d76d6c1de60c89e67b460bb965fc66c2afd2377d
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Sun Aug 9 10:10:32 2026 +0200

    mvnlog: build log viewer, integration tests, script routing
    
    - mvnlog / mvnlog --json CLI viewer for build-report JSON files
    - BuildReportRenderer with ANSI colors, timing, failure details
    - SimpleJsonReader dependency-free streaming JSON parser
    - mvn --log routes to MavenLogCling, mvnlog shell scripts
    - MavenITgh12571BuildReportTest: 8 ITs covering build report, console modes,
      warning mode, version info on failure, and mvnlog viewer
---
 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 +
 apache-maven/src/assembly/maven/bin/mvnlog.cmd     |  39 ++
 .../org/apache/maven/api/cli/ParserRequest.java    |  24 +
 .../main/java/org/apache/maven/api/cli/Tools.java  |   3 +
 .../apache/maven/api/cli/mvnlog/LogOptions.java    | 115 ++++
 .../java/org/apache/maven/cling/MavenLogCling.java |  95 +++
 .../maven/cling/invoker/CommonsCliOptions.java     |   5 +
 .../cling/invoker/mvnlog/BuildReportFilter.java    | 306 ++++++++++
 .../cling/invoker/mvnlog/BuildReportRenderer.java  | 636 +++++++++++++++++++++
 .../cling/invoker/mvnlog/CommonsCliLogOptions.java | 217 +++++++
 .../maven/cling/invoker/mvnlog/LogContext.java     |  35 +-
 .../maven/cling/invoker/mvnlog/LogInvoker.java     | 199 +++++++
 .../maven/cling/invoker/mvnlog/LogParser.java      |  36 +-
 .../cling/invoker/mvnlog/SimpleJsonReader.java     | 276 +++++++++
 .../cling/invoker/mvnlog/SimpleJsonWriter.java     | 154 +++++
 .../BuiltinShellCommandRegistryFactory.java        |  26 +
 .../invoker/mvnlog/BuildReportFilterTest.java      | 242 ++++++++
 .../invoker/mvnlog/BuildReportRendererTest.java    | 250 ++++++++
 .../cling/invoker/mvnlog/SimpleJsonReaderTest.java | 141 +++++
 .../cling/invoker/mvnlog/SimpleJsonWriterTest.java | 116 ++++
 its/core-it-suite/pom.xml                          |   6 +-
 .../maven/it/MavenITgh12571BuildReportTest.java    | 423 ++++++++++++++
 .../maven/it/MavenITmng5760ResumeFeatureTest.java  |  11 +
 ...enITmng5965ParallelBuildMultipliesWorkTest.java |   1 +
 .../it/MavenITmng6057CheckReactorOrderTest.java    |   1 +
 .../maven/it/MavenITmng6065FailOnSeverityTest.java |   2 +
 .../it/MavenITmng6118SubmoduleInvocation.java      |   6 +
 .../maven/it/MavenITmng6391PrintVersionTest.java   |   2 +
 ...ecuteAnnotationShouldNotReExecuteGoalsTest.java |   3 +
 ...ng6981ProjectListShouldIncludeChildrenTest.java |   2 +
 .../it/MavenITmng7353CliGoalInvocationTest.java    |   1 +
 .../it/MavenITmng7804PluginExecutionOrderTest.java |   1 +
 .../apache/maven/it/MavenITmng8594AtFileTest.java  |   1 +
 .../test/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 +
 .../test/resources/gh-12571-multi-module/pom.xml   |  33 ++
 40 files changed, 3503 insertions(+), 46 deletions(-)

diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml
index 5f55a310c8..ce303ea496 100644
--- a/apache-maven/src/assembly/component.xml
+++ b/apache-maven/src/assembly/component.xml
@@ -87,6 +87,7 @@ under the License.
       <includes>
         <include>mvn</include>
         <include>mvnenc</include>
+        <include>mvnlog</include>
         <include>mvnsh</include>
         <include>mvnup</include>
         <include>mvnDebug</include>
diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn
index 0adc4eabeb..93127f8d8e 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 74d4a5a984..7e3c8bdae8 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 100755
index 0000000000..8170bdb167
--- /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 0000000000..7069255cd8
--- /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/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
index ee25ec63da..848151bc25 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<String> 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<String> 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 7559d7ffee..136268657a 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 0000000000..9d7a07c0aa
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnlog/LogOptions.java
@@ -0,0 +1,115 @@
+/*
+ * 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<Boolean> 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<Boolean> 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<Boolean> 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<Boolean> 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<Boolean> 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<String> reportFile();
+
+    /**
+     * Filter output to a specific module (by artifactId substring or glob pattern).
+     * When specified, only modules whose {@code artifactId} contains the given string
+     * (case-insensitive) are shown. With {@code --json}, only matching modules
+     * are included in the output.
+     *
+     * @return an {@link Optional} containing the module filter pattern
+     */
+    Optional<String> module();
+
+    /**
+     * Filter output to a specific mojo (by goal substring or glob pattern).
+     * When specified, only mojos whose {@code goal} contains the given string
+     * (case-insensitive) are shown.
+     *
+     * @return an {@link Optional} containing the mojo filter pattern
+     */
+    Optional<String> mojo();
+
+    /**
+     * Filter log events by minimum level ({@code TRACE}, {@code DEBUG}, {@code INFO},
+     * {@code WARN}, {@code ERROR}). Only events at or above the given severity are shown.
+     * In text mode, renders matching log lines from the build report.
+     * With {@code --json}, filters the log event arrays in the output.
+     *
+     * @return an {@link Optional} containing the minimum log level
+     */
+    Optional<String> level();
+
+    /**
+     * Search through log messages for a substring (case-insensitive).
+     * In text mode, renders matching log lines from the build report.
+     * With {@code --json}, filters the log event arrays in the output.
+     *
+     * @return an {@link Optional} containing the grep pattern
+     */
+    Optional<String> grep();
+}
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 0000000000..baeda01257
--- /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").
+ * <p>
+ * 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/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
index 77d5faac61..d75f07b81a 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
@@ -349,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
@@ -498,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/mvnlog/BuildReportFilter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.java
new file mode 100644
index 0000000000..d48215ece3
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilter.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.invoker.mvnlog;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Applies structural filters to a parsed build report (JSON as {@code Map<String, Object>}).
+ * <p>
+ * Filters are applied in order:
+ * <ol>
+ *   <li>{@code --module}: keep only modules whose {@code artifactId} contains the pattern</li>
+ *   <li>{@code --mojo}: within each module, keep only mojos whose {@code goal} contains the pattern</li>
+ *   <li>{@code --level}: within each mojo and module, keep only log events at or above the level</li>
+ *   <li>{@code --grep}: within each mojo and module, keep only log events whose message matches</li>
+ * </ol>
+ * All string matching is case-insensitive.
+ *
+ * @since 4.1.0
+ */
+final class BuildReportFilter {
+
+    /**
+     * Log level ordinals for severity comparison.
+     * Higher value = more severe.
+     */
+    private static final Map<String, Integer> LEVEL_ORDINALS = Map.of(
+            "TRACE", 0,
+            "DEBUG", 1,
+            "INFO", 2,
+            "WARN", 3,
+            "WARNING", 3,
+            "ERROR", 4);
+
+    private final String modulePattern;
+    private final String mojoPattern;
+    private final String levelFilter;
+    private final String grepPattern;
+
+    BuildReportFilter(String modulePattern, String mojoPattern, String levelFilter, String grepPattern) {
+        this.modulePattern = modulePattern != null ? modulePattern.toLowerCase(Locale.ROOT) : null;
+        this.mojoPattern = mojoPattern != null ? mojoPattern.toLowerCase(Locale.ROOT) : null;
+        this.levelFilter = levelFilter != null ? levelFilter.toUpperCase(Locale.ROOT) : null;
+        this.grepPattern = grepPattern != null ? grepPattern.toLowerCase(Locale.ROOT) : null;
+    }
+
+    /**
+     * Returns {@code true} if any filter is active.
+     */
+    boolean hasFilters() {
+        return modulePattern != null || mojoPattern != null || levelFilter != null || grepPattern != null;
+    }
+
+    /**
+     * Returns {@code true} if log-event-level filters are active
+     * ({@code --level} or {@code --grep}).
+     */
+    boolean hasLogFilters() {
+        return levelFilter != null || grepPattern != null;
+    }
+
+    /**
+     * Apply all active filters to the report, returning a new report map
+     * with only the matching entries. The original map is not modified.
+     */
+    @SuppressWarnings("unchecked")
+    Map<String, Object> apply(Map<String, Object> report) {
+        if (!hasFilters()) {
+            return report;
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>(report);
+
+        // Filter modules
+        Object modulesObj = result.get("modules");
+        if (modulesObj instanceof List) {
+            List<Map<String, Object>> modules = (List<Map<String, Object>>) modulesObj;
+            List<Map<String, Object>> filtered = new ArrayList<>();
+
+            for (Map<String, Object> module : modules) {
+                // --module filter: match on artifactId
+                if (modulePattern != null) {
+                    String artifactId = getString(module, "artifactId");
+                    if (artifactId == null
+                            || !artifactId.toLowerCase(Locale.ROOT).contains(modulePattern)) {
+                        continue;
+                    }
+                }
+
+                Map<String, Object> filteredModule = new LinkedHashMap<>(module);
+
+                // --mojo filter: keep only matching mojos
+                if (mojoPattern != null) {
+                    Object mojosObj = filteredModule.get("mojos");
+                    if (mojosObj instanceof List) {
+                        List<Map<String, Object>> mojos = (List<Map<String, Object>>) mojosObj;
+                        List<Map<String, Object>> filteredMojos = new ArrayList<>();
+                        for (Map<String, Object> mojo : mojos) {
+                            String goal = getString(mojo, "goal");
+                            if (goal != null && goal.toLowerCase(Locale.ROOT).contains(mojoPattern)) {
+                                filteredMojos.add(filterMojoLogEvents(mojo));
+                            }
+                        }
+                        filteredModule.put("mojos", filteredMojos);
+                    }
+                } else if (hasLogFilters()) {
+                    // Apply log filters to mojos even without --mojo
+                    Object mojosObj = filteredModule.get("mojos");
+                    if (mojosObj instanceof List) {
+                        List<Map<String, Object>> mojos = (List<Map<String, Object>>) mojosObj;
+                        List<Map<String, Object>> filteredMojos = new ArrayList<>();
+                        for (Map<String, Object> mojo : mojos) {
+                            filteredMojos.add(filterMojoLogEvents(mojo));
+                        }
+                        filteredModule.put("mojos", filteredMojos);
+                    }
+                }
+
+                // Apply log filters to module-level output
+                if (hasLogFilters()) {
+                    filteredModule.put("output", filterLogEvents(getLogEvents(filteredModule, "output")));
+                }
+
+                filtered.add(filteredModule);
+            }
+
+            result.put("modules", filtered);
+        }
+
+        // Apply log filters to build-level output
+        if (hasLogFilters()) {
+            result.put("output", filterLogEvents(getLogEvents(result, "output")));
+        }
+
+        return result;
+    }
+
+    /**
+     * Collect all log events matching the current filters from a report.
+     * Returns a flat list of log event maps, each annotated with a
+     * {@code "context"} field indicating where the event came from
+     * (module name, mojo goal, or build-level).
+     */
+    @SuppressWarnings("unchecked")
+    List<Map<String, Object>> collectMatchingLogEvents(Map<String, Object> report) {
+        List<Map<String, Object>> results = new ArrayList<>();
+
+        // Build-level log events (skip when filtering by module — build-level
+        // events don't belong to any module)
+        if (modulePattern == null) {
+            for (Map<String, Object> event : getLogEvents(report, "output")) {
+                if (matchesLogFilters(event)) {
+                    Map<String, Object> annotated = new LinkedHashMap<>(event);
+                    annotated.put("context", "build");
+                    results.add(annotated);
+                }
+            }
+        }
+
+        // Module and mojo-level log events
+        Object modulesObj = report.get("modules");
+        if (modulesObj instanceof List) {
+            for (Map<String, Object> module : (List<Map<String, Object>>) modulesObj) {
+                String artifactId = getString(module, "artifactId");
+
+                // Check module filter
+                if (modulePattern != null) {
+                    if (artifactId == null
+                            || !artifactId.toLowerCase(Locale.ROOT).contains(modulePattern)) {
+                        continue;
+                    }
+                }
+
+                // Module-level output
+                for (Map<String, Object> event : getLogEvents(module, "output")) {
+                    if (matchesLogFilters(event)) {
+                        Map<String, Object> annotated = new LinkedHashMap<>(event);
+                        annotated.put("context", artifactId != null ? artifactId : "unknown");
+                        results.add(annotated);
+                    }
+                }
+
+                // Mojo-level output
+                Object mojosObj = module.get("mojos");
+                if (mojosObj instanceof List) {
+                    for (Map<String, Object> mojo : (List<Map<String, Object>>) mojosObj) {
+                        String goal = getString(mojo, "goal");
+
+                        // Check mojo filter
+                        if (mojoPattern != null) {
+                            if (goal == null || !goal.toLowerCase(Locale.ROOT).contains(mojoPattern)) {
+                                continue;
+                            }
+                        }
+
+                        String mojoLabel = (artifactId != null ? artifactId : "") + ":" + (goal != null ? goal : "");
+                        for (Map<String, Object> event : getLogEvents(mojo, "output")) {
+                            if (matchesLogFilters(event)) {
+                                Map<String, Object> annotated = new LinkedHashMap<>(event);
+                                annotated.put("context", mojoLabel);
+                                results.add(annotated);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        return results;
+    }
+
+    /**
+     * Filter the log events within a mojo entry.
+     */
+    private Map<String, Object> filterMojoLogEvents(Map<String, Object> mojo) {
+        if (!hasLogFilters()) {
+            return mojo;
+        }
+        Map<String, Object> filtered = new LinkedHashMap<>(mojo);
+        filtered.put("output", filterLogEvents(getLogEvents(mojo, "output")));
+        return filtered;
+    }
+
+    /**
+     * Filter a list of log events by level and grep pattern.
+     */
+    private List<Map<String, Object>> filterLogEvents(List<Map<String, Object>> events) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map<String, Object> event : events) {
+            if (matchesLogFilters(event)) {
+                result.add(event);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Check if a single log event matches the active level and grep filters.
+     */
+    private boolean matchesLogFilters(Map<String, Object> event) {
+        // Level filter
+        if (levelFilter != null) {
+            String eventLevel = getString(event, "level");
+            if (eventLevel == null || !isAtOrAbove(eventLevel, levelFilter)) {
+                return false;
+            }
+        }
+
+        // Grep filter
+        if (grepPattern != null) {
+            String message = getString(event, "message");
+            if (message == null || !message.toLowerCase(Locale.ROOT).contains(grepPattern)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Returns {@code true} if {@code eventLevel} is at or above {@code minLevel}
+     * in severity.
+     */
+    static boolean isAtOrAbove(String eventLevel, String minLevel) {
+        Integer eventOrd = LEVEL_ORDINALS.get(eventLevel.toUpperCase(Locale.ROOT));
+        Integer minOrd = LEVEL_ORDINALS.get(minLevel.toUpperCase(Locale.ROOT));
+        if (eventOrd == null || minOrd == null) {
+            return true; // unknown levels pass through
+        }
+        return eventOrd >= minOrd;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> getLogEvents(Map<String, Object> container, String key) {
+        Object value = container.get(key);
+        if (value instanceof List) {
+            return (List<Map<String, Object>>) value;
+        }
+        return List.of();
+    }
+
+    private static String getString(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        return value != null ? value.toString() : 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 0000000000..dc9e22c067
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/BuildReportRenderer.java
@@ -0,0 +1,636 @@
+/*
+ * 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<String, Object>})
+ * as formatted terminal output.
+ * <p>
+ * 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<String> output;
+
+    public BuildReportRenderer(MessageBuilderFactory messageBuilderFactory, Consumer<String> output) {
+        this.messageBuilderFactory = messageBuilderFactory;
+        this.output = output;
+    }
+
+    /**
+     * Render the default summary view of a build report.
+     */
+    @SuppressWarnings("unchecked")
+    public void renderSummary(Map<String, Object> report) {
+        renderHeader(report);
+
+        // Module summary
+        List<Map<String, Object>> modules = getList(report, "modules");
+        if (!modules.isEmpty()) {
+            for (Map<String, Object> module : modules) {
+                renderModuleLine(module);
+            }
+            output.accept("");
+
+            // Stats line
+            int passed = 0, failed = 0, skipped = 0;
+            for (Map<String, Object> 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<Map<String, Object>> 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<String, Object> p : problems) {
+                    renderProblemCompact(p);
+                }
+            }
+        }
+
+        // Failures count
+        List<Map<String, Object>> 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<String, Object> report) {
+        renderHeader(report);
+
+        List<Map<String, Object>> 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<String> 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<String, Object> problem : problems) {
+            renderProblemDetailed(problem);
+        }
+    }
+
+    /**
+     * Render the detailed failures view.
+     */
+    @SuppressWarnings("unchecked")
+    public void renderFailures(Map<String, Object> report) {
+        renderHeader(report);
+
+        List<Map<String, Object>> failures = getList(report, "failures");
+        if (failures.isEmpty()) {
+            output.accept(messageBuilderFactory
+                    .builder()
+                    .success("No failures recorded.")
+                    .toString());
+            return;
+        }
+
+        output.accept("Failures (" + failures.size() + "):");
+        for (Map<String, Object> 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<String, Object> report) {
+        renderHeader(report);
+
+        List<Map<String, Object>> modules = getList(report, "modules");
+        if (modules.isEmpty()) {
+            output.accept("No modules recorded.");
+            return;
+        }
+
+        for (Map<String, Object> 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<Map<String, Object>> mojos = getList(module, "mojos");
+            for (Map<String, Object> 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<Path> reports = new ArrayList<>();
+        try (DirectoryStream<Path> 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());
+        }
+    }
+
+    // ---- Log event rendering ----
+
+    /**
+     * Render a list of log events (from filter results) as a log view.
+     * Each event shows the level, context (module/mojo), and message.
+     */
+    public void renderLogEvents(List<Map<String, Object>> events) {
+        if (events.isEmpty()) {
+            output.accept(messageBuilderFactory
+                    .builder()
+                    .warning("No matching log events found.")
+                    .toString());
+            return;
+        }
+
+        output.accept(messageBuilderFactory
+                .builder()
+                .strong(events.size() + " matching log event" + (events.size() > 1 ? "s" : "") + ":")
+                .toString());
+        output.accept("");
+
+        for (Map<String, Object> event : events) {
+            String level = getString(event, "level");
+            String message = getString(event, "message");
+            String context = getString(event, "context");
+            String loggerName = getString(event, "loggerName");
+
+            MessageBuilder mb = messageBuilderFactory.builder();
+
+            // Level tag with color
+            if ("ERROR".equals(level)) {
+                mb.failure("[ERROR]");
+            } else if ("WARN".equals(level)) {
+                mb.warning("[WARN] ");
+            } else if ("DEBUG".equals(level)) {
+                mb.debug("[DEBUG]");
+            } else if ("TRACE".equals(level)) {
+                mb.a("[TRACE]");
+            } else {
+                mb.info("[INFO] ");
+            }
+
+            // Context (module:goal or just module)
+            if (context != null && !"build".equals(context)) {
+                mb.a(" ");
+                mb.strong(context);
+            }
+
+            // Logger name (shortened)
+            if (loggerName != null) {
+                mb.a(" ");
+                mb.a(shortenLoggerName(loggerName));
+            }
+
+            // Message
+            if (message != null) {
+                mb.a(" - ").a(message);
+            }
+
+            output.accept(mb.toString());
+        }
+    }
+
+    /**
+     * Shorten a fully-qualified logger name to its simple class name.
+     */
+    private static String shortenLoggerName(String loggerName) {
+        int lastDot = loggerName.lastIndexOf('.');
+        return lastDot >= 0 ? loggerName.substring(lastDot + 1) : loggerName;
+    }
+
+    // ---- Problem rendering ----
+
+    /**
+     * Compact problem rendering for the default summary view.
+     * Shows severity, message, source, and suggestion on two lines.
+     */
+    private void renderProblemCompact(Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> getList(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value instanceof List) {
+            return (List<Map<String, Object>>) value;
+        }
+        return List.of();
+    }
+
+    private static String getString(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        return value != null ? value.toString() : null;
+    }
+
+    private static Number getNumber(Map<String, Object> 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 0000000000..a80192bf47
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/CommonsCliLogOptions.java
@@ -0,0 +1,217 @@
+/*
+ * 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<Boolean> diagnostics() {
+        if (commandLine.hasOption(CLIManager.DIAGNOSTICS)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> failures() {
+        if (commandLine.hasOption(CLIManager.FAILURES)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> full() {
+        if (commandLine.hasOption(CLIManager.FULL)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> list() {
+        if (commandLine.hasOption(CLIManager.LIST)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> json() {
+        if (commandLine.hasOption(CLIManager.JSON)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> reportFile() {
+        List<String> args = commandLine.getArgList();
+        if (!args.isEmpty()) {
+            return Optional.of(args.get(0));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> module() {
+        if (commandLine.hasOption(CLIManager.MODULE)) {
+            return Optional.of(commandLine.getOptionValue(CLIManager.MODULE));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> mojo() {
+        if (commandLine.hasOption(CLIManager.MOJO)) {
+            return Optional.of(commandLine.getOptionValue(CLIManager.MOJO));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> level() {
+        if (commandLine.hasOption(CLIManager.LEVEL)) {
+            return Optional.of(commandLine.getOptionValue(CLIManager.LEVEL));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> grep() {
+        if (commandLine.hasOption(CLIManager.GREP)) {
+            return Optional.of(commandLine.getOptionValue(CLIManager.GREP));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public void displayHelp(ParserRequest request, Consumer<String> 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("Filters:");
+        printStream.accept("  mvnlog --module api               Show only modules matching 'api'");
+        printStream.accept("  mvnlog --level WARN               Show only WARN and ERROR log events");
+        printStream.accept("  mvnlog --grep 'deprecated'        Search log messages for 'deprecated'");
+        printStream.accept("  mvnlog --mojo compile             Show only 'compile' mojo executions");
+        printStream.accept("  mvnlog --module api --level WARN  Combine filters (all must match)");
+        printStream.accept("");
+        printStream.accept("JSON output:");
+        printStream.accept("  mvnlog --json                     Output full JSON report");
+        printStream.accept("  mvnlog --json --module api        Output filtered JSON");
+        printStream.accept("  mvnlog --json | jq '.modules'     Pipe to jq for complex queries");
+        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";
+        public static final String MODULE = "m";
+        public static final String MOJO = "M";
+        public static final String LEVEL = "l";
+        public static final String GREP = "g";
+
+        @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());
+            options.addOption(Option.builder(MODULE)
+                    .longOpt("module")
+                    .hasArg()
+                    .argName("pattern")
+                    .desc("Filter to modules whose artifactId contains <pattern> (case-insensitive)")
+                    .get());
+            options.addOption(Option.builder(MOJO)
+                    .longOpt("mojo")
+                    .hasArg()
+                    .argName("pattern")
+                    .desc("Filter to mojos whose goal contains <pattern> (case-insensitive)")
+                    .get());
+            options.addOption(Option.builder(LEVEL)
+                    .longOpt("level")
+                    .hasArg()
+                    .argName("LEVEL")
+                    .desc("Filter log events by minimum level (TRACE, DEBUG, INFO, WARN, ERROR)")
+                    .get());
+            options.addOption(Option.builder(GREP)
+                    .longOpt("grep")
+                    .hasArg()
+                    .argName("pattern")
+                    .desc("Search log messages for <pattern> (case-insensitive)")
+                    .get());
+        }
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
similarity index 51%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
index 7559d7ffee..cf6193e19a 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogContext.java
@@ -16,30 +16,23 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnlog;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnlog.LogOptions;
+import org.apache.maven.cling.invoker.LookupContext;
 
 /**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * Context for the {@code mvnlog} build log viewer.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+@SuppressWarnings("VisibilityModifier")
+public class LogContext extends LookupContext {
+    public LogContext(InvokerRequest invokerRequest, LogOptions logOptions) {
+        super(invokerRequest, true, logOptions);
+    }
 
-    public static final String MVNUP_CMD = "mvnup";
-    public static final String MVNUP_NAME = "Maven Upgrade Tool";
+    @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 0000000000..bc194888fa
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogInvoker.java
@@ -0,0 +1,199 @@
+/*
+ * 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.
+ * <p>
+ * 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<LogContext> {
+
+    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<LookupContext> 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<String> 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;
+        }
+
+        Map<String, Object> report;
+        try {
+            report = SimpleJsonReader.parse(json);
+        } catch (IllegalArgumentException e) {
+            context.logger.error("Failed to parse report file: " + e.getMessage());
+            return ERROR;
+        }
+
+        // Build filter from options
+        BuildReportFilter filter = buildFilter(options);
+
+        // Handle --json: output JSON (filtered if applicable) and exit
+        if (options != null && options.json().orElse(false)) {
+            if (filter.hasFilters()) {
+                Map<String, Object> filtered = filter.apply(report);
+                output.accept(SimpleJsonWriter.toJson(filtered));
+            } else {
+                output.accept(json);
+            }
+            return OK;
+        }
+
+        // If log-level filters are active (--level, --grep), render a log view
+        // instead of the default summary.
+        if (filter.hasLogFilters()) {
+            java.util.List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+            renderer.renderLogEvents(events);
+            return OK;
+        }
+
+        // Apply structural filters (--module, --mojo) to the report
+        Map<String, Object> filteredReport = filter.apply(report);
+
+        // Render based on flags
+        if (options != null && options.full().orElse(false)) {
+            renderer.renderFull(filteredReport);
+        } else if (options != null && options.failures().orElse(false)) {
+            renderer.renderFailures(filteredReport);
+        } else if (options != null && options.diagnostics().orElse(false)) {
+            renderer.renderDiagnostics(filteredReport);
+        } else {
+            renderer.renderSummary(filteredReport);
+        }
+
+        return OK;
+    }
+
+    private static BuildReportFilter buildFilter(LogOptions options) {
+        if (options == null) {
+            return new BuildReportFilter(null, null, null, null);
+        }
+        return new BuildReportFilter(
+                options.module().orElse(null),
+                options.mojo().orElse(null),
+                options.level().orElse(null),
+                options.grep().orElse(null));
+    }
+
+    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/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java
similarity index 51%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java
index 7559d7ffee..a26219ec8a 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/LogParser.java
@@ -16,30 +16,22 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnlog;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.cling.invoker.BaseParser;
 
 /**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * Parser for the {@code mvnlog} command-line arguments.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
-
-    public static final String MVNUP_CMD = "mvnup";
-    public static final String MVNUP_NAME = "Maven Upgrade Tool";
+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 0000000000..818ad1cad1
--- /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<String, Object>} / {@code List<Object>} / {@code String} / {@code Number} / {@code Boolean} / null.
+ * <p>
+ * No external dependencies. Companion to {@code BuildReportJsonWriter} which writes
+ * JSON without any library; this reader follows the same zero-dependency principle.
+ * <p>
+ * 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<String, Object>} for a JSON object)
+     * @throws IllegalArgumentException if the JSON is malformed
+     */
+    @SuppressWarnings("unchecked")
+    static Map<String, Object> 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<String, Object>) 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<String, Object> parseObject() {
+        expect('{');
+        Map<String, Object> 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<Object> parseArray() {
+        expect('[');
+        List<Object> 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/mvnlog/SimpleJsonWriter.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java
new file mode 100644
index 0000000000..3626d2631b
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriter.java
@@ -0,0 +1,154 @@
+/*
+ * 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.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal JSON writer for serializing {@code Map<String, Object>} structures
+ * back to well-formatted JSON. Used to output filtered build reports when
+ * {@code --json} is combined with filters.
+ * <p>
+ * Supports: {@code Map}, {@code List}, {@code String}, {@code Number},
+ * {@code Boolean}, and {@code null}.
+ *
+ * @since 4.1.0
+ */
+final class SimpleJsonWriter {
+
+    private static final String INDENT = "  ";
+
+    private SimpleJsonWriter() {}
+
+    /**
+     * Serialize a value to a pretty-printed JSON string.
+     */
+    static String toJson(Object value) {
+        StringBuilder sb = new StringBuilder(4096);
+        writeValue(sb, value, 0);
+        return sb.toString();
+    }
+
+    @SuppressWarnings("unchecked")
+    private static void writeValue(StringBuilder sb, Object value, int depth) {
+        if (value == null) {
+            sb.append("null");
+        } else if (value instanceof Map) {
+            writeObject(sb, (Map<String, Object>) value, depth);
+        } else if (value instanceof List) {
+            writeArray(sb, (List<Object>) value, depth);
+        } else if (value instanceof String) {
+            writeString(sb, (String) value);
+        } else if (value instanceof Number) {
+            sb.append(value);
+        } else if (value instanceof Boolean) {
+            sb.append(value);
+        } else {
+            writeString(sb, value.toString());
+        }
+    }
+
+    private static void writeObject(StringBuilder sb, Map<String, Object> map, int depth) {
+        if (map.isEmpty()) {
+            sb.append("{}");
+            return;
+        }
+
+        sb.append("{\n");
+        Iterator<Map.Entry<String, Object>> it = map.entrySet().iterator();
+        while (it.hasNext()) {
+            Map.Entry<String, Object> entry = it.next();
+            indent(sb, depth + 1);
+            writeString(sb, entry.getKey());
+            sb.append(": ");
+            writeValue(sb, entry.getValue(), depth + 1);
+            if (it.hasNext()) {
+                sb.append(',');
+            }
+            sb.append('\n');
+        }
+        indent(sb, depth);
+        sb.append('}');
+    }
+
+    private static void writeArray(StringBuilder sb, List<Object> list, int depth) {
+        if (list.isEmpty()) {
+            sb.append("[]");
+            return;
+        }
+
+        sb.append("[\n");
+        Iterator<Object> it = list.iterator();
+        while (it.hasNext()) {
+            indent(sb, depth + 1);
+            writeValue(sb, it.next(), depth + 1);
+            if (it.hasNext()) {
+                sb.append(',');
+            }
+            sb.append('\n');
+        }
+        indent(sb, depth);
+        sb.append(']');
+    }
+
+    private static void writeString(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(String.format("\\u%04x", (int) c));
+                    } else {
+                        sb.append(c);
+                    }
+            }
+        }
+        sb.append('"');
+    }
+
+    private static void indent(StringBuilder sb, int depth) {
+        for (int i = 0; i < depth; i++) {
+            sb.append(INDENT);
+        }
+    }
+}
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 a221e1b5b1..18ee72f9d0 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<String, CommandMethods> 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<LookupContext> contextCopier() {
         public void close() throws Exception {
             shellMavenInvoker.close();
             shellEncryptInvoker.close();
+            shellLogInvoker.close();
             shellUpgradeInvoker.close();
         }
 
@@ -251,6 +259,24 @@ private List<Completer> 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<Completer> 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/invoker/mvnlog/BuildReportFilterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java
new file mode 100644
index 0000000000..db118b003d
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/BuildReportFilterTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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.LinkedHashMap;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportFilterTest {
+
+    @Test
+    void noFiltersPassesThrough() {
+        BuildReportFilter filter = new BuildReportFilter(null, null, null, null);
+        assertFalse(filter.hasFilters());
+
+        Map<String, Object> report = sampleReport();
+        Map<String, Object> result = filter.apply(report);
+        assertEquals(report, result);
+    }
+
+    @Test
+    void moduleFilterKeepsMatchingModules() {
+        BuildReportFilter filter = new BuildReportFilter("api", null, null, null);
+        assertTrue(filter.hasFilters());
+
+        Map<String, Object> result = filter.apply(sampleReport());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+        assertEquals(1, modules.size());
+        assertEquals("maven-api-core", modules.get(0).get("artifactId"));
+    }
+
+    @Test
+    void moduleFilterIsCaseInsensitive() {
+        BuildReportFilter filter = new BuildReportFilter("API", null, null, null);
+        Map<String, Object> result = filter.apply(sampleReport());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+        assertEquals(1, modules.size());
+    }
+
+    @Test
+    void mojoFilterKeepsMatchingMojos() {
+        BuildReportFilter filter = new BuildReportFilter(null, "resources", null, null);
+        Map<String, Object> result = filter.apply(sampleReport());
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+        // Both modules should remain
+        assertEquals(2, modules.size());
+
+        // Module 1 (api) has no resources mojo
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> mojos0 =
+                (List<Map<String, Object>>) modules.get(0).get("mojos");
+        assertEquals(0, mojos0.size());
+
+        // Module 2 (cli) has a resources mojo
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> mojos1 =
+                (List<Map<String, Object>>) modules.get(1).get("mojos");
+        assertEquals(1, mojos1.size());
+        assertEquals("resources", mojos1.get(0).get("goal"));
+    }
+
+    @Test
+    void levelFilterKeepsEventsAtOrAbove() {
+        BuildReportFilter filter = new BuildReportFilter(null, null, "WARN", null);
+        assertTrue(filter.hasLogFilters());
+
+        Map<String, Object> report = sampleReport();
+        List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+        // Should only include WARN and ERROR events
+        for (Map<String, Object> event : events) {
+            String level = (String) event.get("level");
+            assertTrue("WARN".equals(level) || "ERROR".equals(level), "Unexpected level: " + level);
+        }
+        assertFalse(events.isEmpty(), "Should find at least one WARN/ERROR event");
+    }
+
+    @Test
+    void grepFilterMatchesMessages() {
+        BuildReportFilter filter = new BuildReportFilter(null, null, null, "deprecated");
+        assertTrue(filter.hasLogFilters());
+
+        Map<String, Object> report = sampleReport();
+        List<Map<String, Object>> events = filter.collectMatchingLogEvents(report);
+        assertEquals(1, events.size());
+        assertTrue(((String) events.get(0).get("message")).contains("deprecated"));
+    }
+
+    @Test
+    void grepFilterIsCaseInsensitive() {
+        BuildReportFilter filter = new BuildReportFilter(null, null, null, "DEPRECATED");
+        List<Map<String, Object>> events = filter.collectMatchingLogEvents(sampleReport());
+        assertEquals(1, events.size());
+    }
+
+    @Test
+    void combinedModuleAndLevelFilter() {
+        BuildReportFilter filter = new BuildReportFilter("cli", null, "WARN", null);
+        List<Map<String, Object>> events = filter.collectMatchingLogEvents(sampleReport());
+        // Should only include WARN+ events from maven-cli module
+        for (Map<String, Object> event : events) {
+            String context = (String) event.get("context");
+            assertTrue(context.contains("cli"), "Event context should contain 'cli': " + context);
+        }
+    }
+
+    @Test
+    void isAtOrAboveLevelComparison() {
+        assertTrue(BuildReportFilter.isAtOrAbove("ERROR", "WARN"));
+        assertTrue(BuildReportFilter.isAtOrAbove("WARN", "WARN"));
+        assertFalse(BuildReportFilter.isAtOrAbove("INFO", "WARN"));
+        assertFalse(BuildReportFilter.isAtOrAbove("DEBUG", "WARN"));
+        assertTrue(BuildReportFilter.isAtOrAbove("INFO", "INFO"));
+        assertTrue(BuildReportFilter.isAtOrAbove("ERROR", "TRACE"));
+    }
+
+    @Test
+    void jsonFilteredOutputRemovesNonMatchingModules() {
+        BuildReportFilter filter = new BuildReportFilter("api", null, null, null);
+        Map<String, Object> result = filter.apply(sampleReport());
+        // The result should still have all top-level keys
+        assertTrue(result.containsKey("status"));
+        assertTrue(result.containsKey("modules"));
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+        assertEquals(1, modules.size());
+    }
+
+    @Test
+    void applyWithLevelFilterFiltersModuleOutput() {
+        BuildReportFilter filter = new BuildReportFilter(null, null, "ERROR", null);
+        Map<String, Object> result = filter.apply(sampleReport());
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> modules = (List<Map<String, Object>>) result.get("modules");
+        for (Map<String, Object> module : modules) {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> output = (List<Map<String, Object>>) module.get("output");
+            for (Map<String, Object> event : output) {
+                assertEquals("ERROR", event.get("level"));
+            }
+        }
+    }
+
+    // ---- Test data ----
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> sampleReport() {
+        Map<String, Object> report = new LinkedHashMap<>();
+        report.put("status", "SUCCESS");
+        report.put("mavenVersion", "4.1.0-SNAPSHOT");
+        report.put("duration", "PT10.5S");
+        report.put(
+                "output",
+                List.of(
+                        logEvent("INFO", "Building reactor..."),
+                        logEvent("WARN", "Reactor build order could be optimized")));
+
+        // Module 1: maven-api-core
+        Map<String, Object> apiModule = new LinkedHashMap<>();
+        apiModule.put("artifactId", "maven-api-core");
+        apiModule.put("status", "SUCCESS");
+        apiModule.put("duration", "PT3.2S");
+        apiModule.put(
+                "output",
+                List.of(
+                        logEvent("INFO", "Compiling 42 source files"),
+                        logEvent("WARN", "Using deprecated API method")));
+        apiModule.put(
+                "mojos",
+                List.of(
+                        mojo("compile", List.of(logEvent("INFO", "Compiling sources"))),
+                        mojo("test-compile", List.of(logEvent("INFO", "Compiling test sources")))));
+
+        // Module 2: maven-cli
+        Map<String, Object> cliModule = new LinkedHashMap<>();
+        cliModule.put("artifactId", "maven-cli");
+        cliModule.put("status", "SUCCESS");
+        cliModule.put("duration", "PT4.1S");
+        cliModule.put(
+                "output",
+                List.of(
+                        logEvent("INFO", "Compiling 30 source files"),
+                        logEvent("WARN", "Some warning in CLI"),
+                        logEvent("ERROR", "Critical error in module")));
+        cliModule.put(
+                "mojos",
+                List.of(
+                        mojo("compile", List.of(logEvent("INFO", "Compiling sources"))),
+                        mojo("resources", List.of(logEvent("DEBUG", "Copying resources")))));
+
+        report.put("modules", List.of(apiModule, cliModule));
+        report.put("problems", List.of());
+        report.put("failures", List.of());
+        return report;
+    }
+
+    private static Map<String, Object> logEvent(String level, String message) {
+        Map<String, Object> event = new LinkedHashMap<>();
+        event.put("level", level);
+        event.put("message", message);
+        event.put("loggerName", "org.apache.maven.TestLogger");
+        event.put("timestamp", "2026-08-08T10:00:00Z");
+        return event;
+    }
+
+    private static Map<String, Object> mojo(String goal, List<Map<String, Object>> output) {
+        Map<String, Object> mojo = new LinkedHashMap<>();
+        mojo.put("goal", goal);
+        mojo.put("artifactId", "maven-compiler-plugin");
+        mojo.put("status", "SUCCESS");
+        mojo.put("duration", "PT1.5S");
+        mojo.put("output", output);
+        return mojo;
+    }
+}
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 0000000000..8e72867189
--- /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<String, Object> 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<String, Object> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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 0000000000..451151387c
--- /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<String, Object> result = SimpleJsonReader.parse("{}");
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    void testParseSimpleObject() {
+        Map<String, Object> result = SimpleJsonReader.parse("""
+                {"name": "test", "version": "1.0"}""");
+        assertEquals("test", result.get("name"));
+        assertEquals("1.0", result.get("version"));
+    }
+
+    @Test
+    void testParseNumbers() {
+        Map<String, Object> 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<String, Object> 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<String, Object> result = SimpleJsonReader.parse("""
+                {"goals": ["clean", "install"]}""");
+        List<Object> goals = (List<Object>) result.get("goals");
+        assertEquals(2, goals.size());
+        assertEquals("clean", goals.get(0));
+        assertEquals("install", goals.get(1));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void testParseNestedObject() {
+        Map<String, Object> result = SimpleJsonReader.parse("""
+                {"module": {"artifactId": "core", "status": "SUCCESS"}}""");
+        Map<String, Object> module = (Map<String, Object>) result.get("module");
+        assertEquals("core", module.get("artifactId"));
+        assertEquals("SUCCESS", module.get("status"));
+    }
+
+    @Test
+    void testParseStringEscapes() {
+        Map<String, Object> 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<String, Object> report = SimpleJsonReader.parse(json);
+        assertEquals("1.0", report.get("formatVersion"));
+        assertEquals("SUCCESS", report.get("status"));
+        assertEquals("PT6.7S", report.get("duration"));
+
+        List<Map<String, Object>> modules = (List<Map<String, Object>>) (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-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java
new file mode 100644
index 0000000000..fdadf81b71
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnlog/SimpleJsonWriterTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.LinkedHashMap;
+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.assertTrue;
+
+class SimpleJsonWriterTest {
+
+    @Test
+    void emptyObject() {
+        assertEquals("{}", SimpleJsonWriter.toJson(Map.of()));
+    }
+
+    @Test
+    void emptyArray() {
+        assertEquals("[]", SimpleJsonWriter.toJson(List.of()));
+    }
+
+    @Test
+    void simpleString() {
+        assertEquals("\"hello\"", SimpleJsonWriter.toJson("hello"));
+    }
+
+    @Test
+    void stringWithEscapes() {
+        String json = SimpleJsonWriter.toJson("line1\nline2\t\"quoted\"");
+        assertEquals("\"line1\\nline2\\t\\\"quoted\\\"\"", json);
+    }
+
+    @Test
+    void numbers() {
+        assertEquals("42", SimpleJsonWriter.toJson(42));
+        assertEquals("3.14", SimpleJsonWriter.toJson(3.14));
+    }
+
+    @Test
+    void booleans() {
+        assertEquals("true", SimpleJsonWriter.toJson(true));
+        assertEquals("false", SimpleJsonWriter.toJson(false));
+    }
+
+    @Test
+    void nullValue() {
+        assertEquals("null", SimpleJsonWriter.toJson(null));
+    }
+
+    @Test
+    void simpleObject() {
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("name", "test");
+        map.put("count", 42);
+
+        String json = SimpleJsonWriter.toJson(map);
+        assertTrue(json.contains("\"name\": \"test\""));
+        assertTrue(json.contains("\"count\": 42"));
+    }
+
+    @Test
+    void nestedObject() {
+        Map<String, Object> inner = new LinkedHashMap<>();
+        inner.put("key", "value");
+
+        Map<String, Object> outer = new LinkedHashMap<>();
+        outer.put("nested", inner);
+
+        String json = SimpleJsonWriter.toJson(outer);
+        assertTrue(json.contains("\"nested\": {"));
+        assertTrue(json.contains("\"key\": \"value\""));
+    }
+
+    @Test
+    void arrayOfStrings() {
+        String json = SimpleJsonWriter.toJson(List.of("a", "b", "c"));
+        assertTrue(json.contains("\"a\""));
+        assertTrue(json.contains("\"b\""));
+        assertTrue(json.contains("\"c\""));
+    }
+
+    @Test
+    void roundTripWithSimpleJsonReader() {
+        Map<String, Object> original = new LinkedHashMap<>();
+        original.put("status", "SUCCESS");
+        original.put("count", 42);
+        original.put("items", List.of("a", "b"));
+
+        String json = SimpleJsonWriter.toJson(original);
+        Map<String, Object> parsed = SimpleJsonReader.parse(json);
+
+        assertEquals("SUCCESS", parsed.get("status"));
+        // SimpleJsonReader parses small integers as Integer
+        assertEquals(42, ((Number) parsed.get("count")).intValue());
+    }
+}
diff --git a/its/core-it-suite/pom.xml b/its/core-it-suite/pom.xml
index 233f88d348..50e468d077 100644
--- a/its/core-it-suite/pom.xml
+++ b/its/core-it-suite/pom.xml
@@ -519,7 +519,7 @@ under the License.
                 <unzip dest="${mavenHome}" src="${sessionRootDirectory}/apache-maven/target/apache-maven-${project.version}-bin.zip">
                   <regexpmapper from="^([^/]+)/(.*)$" handledirsep="true" to="\2" />
                 </unzip>
-                <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc" perm="ugo+rx" />
+                <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnlog" perm="ugo+rx" />
               </target>
             </configuration>
           </execution>
@@ -749,7 +749,7 @@ under the License.
                     <unzip dest="${mavenHome}" src="${project.build.directory}/maven-bin.zip">
                       <globmapper from="apache-maven-${mavenVersion}/*" handledirsep="true" to="*" />
                     </unzip>
-                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc" perm="ugo+rx" />
+                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnlog" perm="ugo+rx" />
                   </target>
                 </configuration>
               </execution>
@@ -804,7 +804,7 @@ under the License.
                     <unzip dest="${mavenHome}" src="${mavenDistro}">
                       <regexpmapper from="^([^/]+)/(.*)$" handledirsep="true" to="\2" />
                     </unzip>
-                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc" perm="ugo+rx" />
+                    <chmod dir="${mavenHome}/bin" includes="mvn,mvnDebug,mvnenc,mvnlog" perm="ugo+rx" />
                   </target>
                 </configuration>
               </execution>
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 0000000000..dbb19974cb
--- /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.
+ * <p>
+ * Covers: build report JSON generation, console modes (plain, machine, verbose),
+ * warning mode, version info on failure, and the {@code mvnlog} viewer tool.
+ *
+ * @see <a href="https://github.com/apache/maven/issues/12571">gh-12571</a>
+ * @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<String> 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<String> 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<String> 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 010e39cc2d..695fd825d6 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 9fc554a247..353944c616 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 44019dd9c4..c41109e265 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 65a53dde32..807798e7ee 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 6a85190348..25545f868d 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 89c39470d1..0cac3cd993 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 3321e0b5d8..334f25084c 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 ba6864f2e9..19cade9d55 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 b37994fd16..f825aeda4d 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 10188f23e7..b5fedd0d5b 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 bd1a6a21dd..718b28b639 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 0000000000..d859b406d9
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-build-report/pom.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.maven.its.gh12571</groupId>
+  <artifactId>build-report-test</artifactId>
+  <version>1.0</version>
+  <name>Maven IT :: gh-12571 :: Build Report</name>
+</project>
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 0000000000..4e63f41a8a
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-a/pom.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.maven.its.gh12571</groupId>
+    <artifactId>multi-module-parent</artifactId>
+    <version>1.0</version>
+  </parent>
+  <artifactId>module-a</artifactId>
+  <name>Module A</name>
+</project>
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 0000000000..52f4965b05
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/module-b/pom.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.maven.its.gh12571</groupId>
+    <artifactId>multi-module-parent</artifactId>
+    <version>1.0</version>
+  </parent>
+  <artifactId>module-b</artifactId>
+  <name>Module B</name>
+</project>
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 0000000000..b3cabd7d39
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12571-multi-module/pom.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.apache.maven.its.gh12571</groupId>
+  <artifactId>multi-module-parent</artifactId>
+  <version>1.0</version>
+  <packaging>pom</packaging>
+  <name>Maven IT :: gh-12571 :: Multi Module</name>
+  <modules>
+    <module>module-a</module>
+    <module>module-b</module>
+  </modules>
+</project>