diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java
new file mode 100644
index 000000000000..7cd7975b8871
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.services.BuilderProblem;
+
+/**
+ * A structured report of a Maven build execution, persisted to
+ * {@code target/build-report.json} at the end of every build.
+ *
+ * The report captures metadata, per-module results (including mojo execution
+ * timings), and any failures. It is intended to be consumed by tools, IDEs,
+ * CI systems, and LLM agents without having to re-run the build or parse
+ * console output.
+ *
+ * @since 4.1.0
+ * @see ModuleReport
+ * @see FailureReport
+ */
+@Experimental
+public interface BuildReport {
+
+ /**
+ * Schema version of the report format. Consumers should check this
+ * to handle forward compatibility.
+ *
+ * @return the format version, currently {@code 1}
+ */
+ int formatVersion();
+
+ /**
+ * The overall build status.
+ *
+ * @return the build outcome, never {@code null}
+ */
+ @Nonnull
+ BuildStatus status();
+
+ /**
+ * Wall-clock duration of the entire build.
+ *
+ * @return the total duration, never {@code null}
+ */
+ @Nonnull
+ Duration duration();
+
+ /**
+ * When the build started (wall-clock time).
+ *
+ * @return the start instant, never {@code null}
+ */
+ @Nonnull
+ Instant startTime();
+
+ /**
+ * The Maven version that produced this report.
+ *
+ * @return the Maven version string, never {@code null}
+ */
+ @Nonnull
+ String mavenVersion();
+
+ /**
+ * The Java version used for the build.
+ *
+ * @return the Java version string, never {@code null}
+ */
+ @Nonnull
+ String javaVersion();
+
+ /**
+ * The goals or phases that were requested.
+ *
+ * @return the list of goals, never {@code null}
+ */
+ @Nonnull
+ List goals();
+
+ /**
+ * The GAV of the top-level project ({@code groupId:artifactId:version}).
+ *
+ * @return the project identifier, never {@code null}
+ */
+ @Nonnull
+ String project();
+
+ /**
+ * Whether this was a multi-module (reactor) build.
+ *
+ * @return {@code true} for multi-module builds
+ */
+ boolean multiModule();
+
+ /**
+ * The degree of concurrency ({@code -T} flag), or 1 for sequential builds.
+ *
+ * @return the thread count
+ */
+ int threads();
+
+ /**
+ * Per-module build results, in reactor execution order.
+ *
+ * @return the module reports, never {@code null}
+ */
+ @Nonnull
+ List modules();
+
+ /**
+ * Failures that occurred during the build, if any.
+ *
+ * @return the failure reports, never {@code null}; empty if the build succeeded
+ */
+ @Nonnull
+ List failures();
+
+ /**
+ * Structured problems (warnings, errors) reported during the build by
+ * Maven itself or by plugins.
+ *
+ * @return the problems, never {@code null}; empty if none were reported
+ * @since 4.1.0
+ */
+ @Nonnull
+ List problems();
+
+ /**
+ * Structured log events captured outside of any module's lifecycle —
+ * Maven startup messages, reactor ordering, and the final reactor summary.
+ *
+ * For per-module events see {@link ModuleReport#output()}, and for
+ * per-mojo events see {@link MojoReport#output()}.
+ *
+ * Together, {@code BuildReport.output()}, {@code ModuleReport.output()},
+ * and {@code MojoReport.output()} form a non-overlapping partition of
+ * the full build log.
+ *
+ * @return the captured log events, never {@code null}; may be empty
+ */
+ @Nonnull
+ List output();
+
+ /**
+ * Find a module report by its GAV identifier.
+ *
+ * The identifier format is {@code "groupId:artifactId:version"}, matching
+ * the format returned by {@link ModuleReport#id()} and used in
+ * {@link FailureReport#module()}.
+ *
+ * @param moduleId the module GAV string
+ * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"})
+ * @return the matching module report, or empty if not found
+ */
+ @Nonnull
+ default Optional findModule(String moduleId) {
+ Objects.requireNonNull(moduleId);
+ return modules().stream().filter(m -> moduleId.equals(m.id())).findFirst();
+ }
+
+ /**
+ * Find the module report that corresponds to a given failure.
+ *
+ * @param failure the failure report
+ * @return the matching module report, or empty if not found
+ */
+ @Nonnull
+ default Optional findModule(FailureReport failure) {
+ Objects.requireNonNull(failure);
+ return findModule(failure.module());
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
new file mode 100644
index 000000000000..1ee25f8cb3a6
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import org.apache.maven.api.annotations.Experimental;
+
+/**
+ * The outcome of a build, module, or mojo execution.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+public enum BuildStatus {
+ /**
+ * Completed successfully.
+ */
+ SUCCESS,
+
+ /**
+ * Failed with an error.
+ */
+ FAILURE,
+
+ /**
+ * Skipped (e.g. because a dependency failed).
+ */
+ SKIPPED
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java
new file mode 100644
index 000000000000..20e5d95b8aea
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import java.time.Instant;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * Details about a build failure.
+ *
+ * @since 4.1.0
+ * @see BuildReport#failures()
+ */
+@Experimental
+public interface FailureReport {
+
+ /**
+ * The GAV of the module where the failure occurred
+ * ({@code groupId:artifactId:version}).
+ *
+ * @return the module identifier, never {@code null}
+ */
+ @Nonnull
+ String module();
+
+ /**
+ * The mojo that failed, formatted as {@code artifactId:version:goal}
+ * (e.g. {@code "maven-compiler-plugin:3.15.0:compile"}).
+ *
+ * @return the mojo identifier, or {@code null} if the failure was not mojo-specific
+ */
+ @Nullable
+ String mojo();
+
+ /**
+ * When the failure occurred (wall-clock time).
+ *
+ * @return the failure instant, never {@code null}
+ */
+ @Nonnull
+ Instant timestamp();
+
+ /**
+ * The simple class name of the root cause exception
+ * (e.g. {@code "MojoFailureException"}, {@code "LifecycleExecutionException"}).
+ *
+ * Useful for programmatic triage — tools can pattern-match on known
+ * exception types without parsing the message.
+ *
+ * @return the exception type name, or {@code null} if unavailable
+ */
+ @Nullable
+ String exceptionType();
+
+ /**
+ * The exception message.
+ *
+ * @return the error message, never {@code null}
+ */
+ @Nonnull
+ String message();
+
+ /**
+ * The exception stack trace, truncated to a reasonable length.
+ *
+ * @return the stack trace string, or {@code null} if unavailable
+ */
+ @Nullable
+ String stackTrace();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java
new file mode 100644
index 000000000000..7746545a4217
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Build results for a single module in a reactor build.
+ *
+ * @since 4.1.0
+ * @see BuildReport#modules()
+ */
+@Experimental
+public interface ModuleReport {
+
+ /**
+ * The module's group ID.
+ *
+ * @return the group ID, never {@code null}
+ */
+ @Nonnull
+ String groupId();
+
+ /**
+ * The module's artifact ID.
+ *
+ * @return the artifact ID, never {@code null}
+ */
+ @Nonnull
+ String artifactId();
+
+ /**
+ * The module's version.
+ *
+ * @return the version string, never {@code null}
+ */
+ @Nonnull
+ String version();
+
+ /**
+ * The build outcome for this module.
+ *
+ * @return the status, never {@code null}
+ */
+ @Nonnull
+ BuildStatus status();
+
+ /**
+ * When this module started building (wall-clock time).
+ *
+ * @return the start instant, never {@code null}
+ */
+ @Nonnull
+ Instant startTime();
+
+ /**
+ * How long this module took to build.
+ *
+ * @return the duration, never {@code null}
+ */
+ @Nonnull
+ Duration duration();
+
+ /**
+ * The mojo executions that ran within this module, in execution order.
+ *
+ * @return the mojo reports, never {@code null}
+ */
+ @Nonnull
+ List mojos();
+
+ /**
+ * Structured log events captured during this module's build lifecycle
+ * but outside any mojo execution — dependency resolution messages,
+ * resource copying, and other Maven infrastructure output.
+ *
+ * For per-mojo events see {@link MojoReport#output()}.
+ *
+ * @return the captured log events, never {@code null}; may be empty
+ */
+ @Nonnull
+ List output();
+
+ /**
+ * The module identifier formatted as {@code "groupId:artifactId:version"}.
+ *
+ * This matches the format used by {@link FailureReport#module()}, allowing
+ * direct lookup from a failure report.
+ *
+ * @return the GAV string, never {@code null}
+ */
+ @Nonnull
+ default String id() {
+ return groupId() + ":" + artifactId() + ":" + version();
+ }
+
+ /**
+ * Find a mojo execution by its identifier string.
+ *
+ * The identifier format is {@code "artifactId:version:goal"}, matching
+ * the format used by {@link FailureReport#mojo()}.
+ *
+ * @param mojoId the mojo identifier (e.g. {@code "maven-compiler-plugin:3.15.0:compile"})
+ * @return the matching mojo report, or empty if not found
+ */
+ @Nonnull
+ default Optional findMojo(String mojoId) {
+ Objects.requireNonNull(mojoId);
+ return mojos().stream().filter(m -> mojoId.equals(m.id())).findFirst();
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java
new file mode 100644
index 000000000000..76001babbd6b
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api.build.report;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+
+/**
+ * Report for a single mojo (plugin goal) execution within a module.
+ *
+ * @since 4.1.0
+ * @see ModuleReport#mojos()
+ */
+@Experimental
+public interface MojoReport {
+
+ /**
+ * The plugin's group ID.
+ *
+ * @return the group ID, never {@code null}
+ */
+ @Nonnull
+ String groupId();
+
+ /**
+ * The plugin's artifact ID.
+ *
+ * @return the artifact ID, never {@code null}
+ */
+ @Nonnull
+ String artifactId();
+
+ /**
+ * The plugin version.
+ *
+ * @return the version string, never {@code null}
+ */
+ @Nonnull
+ String version();
+
+ /**
+ * The goal that was executed (e.g. {@code "compile"}, {@code "test"}).
+ *
+ * @return the goal name, never {@code null}
+ */
+ @Nonnull
+ String goal();
+
+ /**
+ * The execution ID (e.g. {@code "default-compile"}).
+ *
+ * @return the execution ID, or {@code null} if not set
+ */
+ @Nullable
+ String executionId();
+
+ /**
+ * The lifecycle phase this mojo was bound to (e.g. {@code "compile"}, {@code "test"}).
+ *
+ * @return the phase name, or {@code null} if invoked directly
+ */
+ @Nullable
+ String phase();
+
+ /**
+ * The outcome of this mojo execution.
+ *
+ * @return the status, never {@code null}
+ */
+ @Nonnull
+ BuildStatus status();
+
+ /**
+ * When this mojo execution started (wall-clock time).
+ *
+ * @return the start instant, never {@code null}
+ */
+ @Nonnull
+ Instant startTime();
+
+ /**
+ * How long this mojo execution took.
+ *
+ * @return the duration, never {@code null}
+ */
+ @Nonnull
+ Duration duration();
+
+ /**
+ * Structured log events captured during this mojo's execution.
+ *
+ * The list may be truncated if the mojo produced excessive output.
+ *
+ * This captures all SLF4J output that occurred on the mojo's execution
+ * thread between the mojo's start and finish events, regardless of
+ * whether the mojo used the legacy {@code Mojo.getLog()}, the Maven 4
+ * injected {@code Log}, or plain SLF4J.
+ *
+ * @return the captured log events, never {@code null}; may be empty
+ * @since 4.1.0
+ */
+ @Nonnull
+ List output();
+
+ /**
+ * The mojo identifier formatted as {@code "artifactId:version:goal"}.
+ *
+ * This matches the format used by {@link FailureReport#mojo()}, allowing
+ * direct lookup via {@link ModuleReport#findMojo(String)}.
+ *
+ * @return the mojo identifier string, never {@code null}
+ */
+ @Nonnull
+ default String id() {
+ return artifactId() + ":" + version() + ":" + goal();
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
index 8aee68ff901e..dd7d0572dd40 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java
@@ -20,10 +20,15 @@
/**
* Structured build report data model.
*
- * This package provides structured representations of build execution
- * data, including log events and (in future) full build reports.
- * {@link org.apache.maven.api.build.report.LogEvent} is the foundational
- * type representing a single structured log entry captured during the build.
+ * The {@link org.apache.maven.api.build.report.BuildReport} is the root of a structured
+ * representation of a Maven build execution. It is persisted to
+ * {@code target/build-report.json} at the end of every build and can be consumed
+ * by tools, CI systems, IDEs, and LLM agents without re-running the build or
+ * parsing console output.
+ *
+ * Build problems (warnings, errors) are represented as
+ * {@link org.apache.maven.api.services.BuilderProblem} instances and included
+ * in the report for downstream analysis.
*
* @since 4.1.0
*/
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
new file mode 100644
index 000000000000..35934531f61e
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
@@ -0,0 +1,575 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.LogLevel;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+import org.apache.maven.eventspy.AbstractEventSpy;
+import org.apache.maven.execution.BuildFailure;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.BuildSummary;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.slf4j.MavenSimpleLogger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LocationAwareLogger;
+
+/**
+ * Collects build lifecycle events and produces a structured {@link BuildReport}
+ * at the end of the session.
+ *
+ * Registered as an {@link org.apache.maven.eventspy.EventSpy} via {@code @Named}/{@code @Singleton},
+ * following the same pattern as {@code DefaultPluginValidationManager}.
+ *
+ * Thread-safe: concurrent module builds (with {@code -T}) each write to their
+ * own entry in a {@link ConcurrentHashMap}.
+ *
+ * Log capture: installs a structured {@link MavenSimpleLogger.LogEventSink}
+ * that receives level, logger name, message, and throwable independently of
+ * the formatted console output. Uses thread-based tracking to associate events
+ * with the currently-executing mojo or module.
+ *
+ * @since 4.1.0
+ */
+@Singleton
+@Named
+public final class BuildReportCollector extends AbstractEventSpy {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(BuildReportCollector.class);
+
+ static final String REPORT_DIR = "build-reports";
+ static final String REPORT_LATEST = "build-report-latest.json";
+
+ private static final int MAX_STACKTRACE_LINES = 30;
+
+ /**
+ * Maximum number of log events captured per scope (mojo, module, or build).
+ * Beyond this, events are dropped and a truncation notice is appended.
+ */
+ static final int MAX_LOG_EVENTS_PER_SCOPE = 500;
+
+ // ---- Mutable state, populated during the build ----
+
+ /** Per-project mojo tracking: project key -> list of in-flight/completed mojos. */
+ private final Map> mojoTimings = new ConcurrentHashMap<>();
+
+ /** Per-project start instants for duration computation. */
+ private final Map projectStartTimes = new ConcurrentHashMap<>();
+
+ /** Per-mojo start instants for duration computation. */
+ private final Map mojoStartTimes = new ConcurrentHashMap<>();
+
+ /** Session-level state - set once on SessionStarted. */
+ private volatile MavenSession session;
+
+ // ---- Log capture state ----
+
+ /**
+ * Maps thread ID -> mojo key for the currently-executing mojo on that thread.
+ * Lifecycle events and mojo execution run on the same thread, so this is safe
+ * for parallel builds with {@code -T}.
+ */
+ private final Map currentMojoByThread = new ConcurrentHashMap<>();
+
+ /** Per-mojo log buffers: mojo key -> captured log events. */
+ private final Map> mojoLogBuffers = new ConcurrentHashMap<>();
+
+ /**
+ * Maps thread ID -> project key for the currently-building project on that thread.
+ * Used to route log events that occur between mojo executions to the module-level buffer.
+ */
+ private final Map currentProjectByThread = new ConcurrentHashMap<>();
+
+ /** Per-module log buffers: project key -> events captured outside any mojo. */
+ private final Map> moduleLogBuffers = new ConcurrentHashMap<>();
+
+ /** Build-level log buffer: events captured outside any module lifecycle. */
+ private final List buildLogBuffer = Collections.synchronizedList(new ArrayList<>());
+
+ @Override
+ public void onEvent(Object event) {
+ if (event instanceof ExecutionEvent executionEvent) {
+ switch (executionEvent.getType()) {
+ case SessionStarted:
+ onSessionStarted(executionEvent);
+ break;
+ case SessionEnded:
+ onSessionEnded(executionEvent);
+ break;
+ case ProjectStarted:
+ onProjectStarted(executionEvent);
+ break;
+ case ProjectSucceeded:
+ case ProjectFailed:
+ case ProjectSkipped:
+ onProjectFinished(executionEvent);
+ break;
+ case MojoStarted:
+ onMojoStarted(executionEvent);
+ break;
+ case MojoSucceeded:
+ case MojoFailed:
+ onMojoFinished(executionEvent);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ // ---- Event handlers ----
+
+ private void onSessionStarted(ExecutionEvent event) {
+ this.session = event.getSession();
+ installLogCapture();
+ }
+
+ private void onSessionEnded(ExecutionEvent event) {
+ removeLogCapture();
+
+ MavenSession endSession = event.getSession();
+ if (endSession == null) {
+ return;
+ }
+
+ try {
+ BuildReport report = buildReport(endSession);
+ writeReport(report, endSession);
+ } catch (Exception e) {
+ // Never let the report collector crash the build
+ LOGGER.debug("Failed to produce build report: {}", e.getMessage(), e);
+ }
+ }
+
+ private void onProjectStarted(ExecutionEvent event) {
+ String key = projectKey(event.getProject());
+ projectStartTimes.put(key, MonotonicClock.now());
+ mojoTimings.putIfAbsent(key, Collections.synchronizedList(new ArrayList<>()));
+ moduleLogBuffers.put(key, Collections.synchronizedList(new ArrayList<>()));
+ currentProjectByThread.put(Thread.currentThread().getId(), key);
+ }
+
+ private void onProjectFinished(ExecutionEvent event) {
+ // Unregister the project from this thread so subsequent log events
+ // fall through to the build-level buffer
+ currentProjectByThread.remove(Thread.currentThread().getId());
+ }
+
+ private void onMojoStarted(ExecutionEvent event) {
+ String mKey = mojoKey(event.getProject(), event.getMojoExecution());
+ mojoStartTimes.put(mKey, MonotonicClock.now());
+
+ // Register the current mojo for this thread so the log event sink
+ // can associate events with this mojo execution
+ currentMojoByThread.put(Thread.currentThread().getId(), mKey);
+ mojoLogBuffers.put(mKey, Collections.synchronizedList(new ArrayList<>()));
+ }
+
+ private void onMojoFinished(ExecutionEvent event) {
+ MojoExecution mojo = event.getMojoExecution();
+ MavenProject project = event.getProject();
+ String mKey = mojoKey(project, mojo);
+ String pKey = projectKey(project);
+
+ // Unregister the mojo from this thread
+ currentMojoByThread.remove(Thread.currentThread().getId());
+
+ Instant now = MonotonicClock.now();
+ Instant startInstant = mojoStartTimes.remove(mKey);
+ if (startInstant == null) {
+ startInstant = now;
+ }
+ Duration duration = Duration.between(startInstant, now);
+
+ BuildStatus status =
+ event.getType() == ExecutionEvent.Type.MojoSucceeded ? BuildStatus.SUCCESS : BuildStatus.FAILURE;
+
+ // Drain the log buffer for this mojo
+ List logBuffer = mojoLogBuffers.remove(mKey);
+ List output = logBuffer != null ? List.copyOf(logBuffer) : List.of();
+
+ MojoTiming timing = new MojoTiming(
+ mojo.getGroupId(),
+ mojo.getArtifactId(),
+ mojo.getVersion(),
+ mojo.getGoal(),
+ mojo.getExecutionId(),
+ mojo.getLifecyclePhase(),
+ status,
+ startInstant,
+ duration,
+ output);
+
+ mojoTimings
+ .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>()))
+ .add(timing);
+ }
+
+ // ---- Structured log capture ----
+
+ /**
+ * Installs a structured {@link MavenSimpleLogger.LogEventSink} to capture
+ * log events with level, logger name, message, and throwable. This runs
+ * in parallel to the existing console output pipeline - no wrapping or
+ * forwarding needed.
+ */
+ private void installLogCapture() {
+ MavenSimpleLogger.setLogEventSink(this::captureLogEvent);
+ }
+
+ private void removeLogCapture() {
+ MavenSimpleLogger.setLogEventSink(null);
+ }
+
+ private void captureLogEvent(int level, String loggerName, String message, Throwable throwable) {
+ long threadId = Thread.currentThread().getId();
+
+ LogLevel logLevel = toLogLevel(level);
+ Instant timestamp = MonotonicClock.now();
+ String stackTrace = throwable != null ? truncateStackTrace(throwable) : null;
+ LogEvent logEvent = new DefaultLogEvent(timestamp, logLevel, message, loggerName, stackTrace);
+
+ // 1. Mojo-level: event belongs to the currently-executing mojo on this thread
+ String mKey = currentMojoByThread.get(threadId);
+ if (mKey != null) {
+ List buffer = mojoLogBuffers.get(mKey);
+ if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) {
+ buffer.add(logEvent);
+ }
+ return;
+ }
+
+ // 2. Module-level: project is active but no mojo is running
+ String pKey = currentProjectByThread.get(threadId);
+ if (pKey != null) {
+ List buffer = moduleLogBuffers.get(pKey);
+ if (buffer != null && buffer.size() < MAX_LOG_EVENTS_PER_SCOPE) {
+ buffer.add(logEvent);
+ }
+ return;
+ }
+
+ // 3. Build-level: no project active (startup, reactor summary, post-build)
+ if (buildLogBuffer.size() < MAX_LOG_EVENTS_PER_SCOPE) {
+ buildLogBuffer.add(logEvent);
+ }
+ }
+
+ private static LogLevel toLogLevel(int level) {
+ return switch (level) {
+ case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE;
+ case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG;
+ case LocationAwareLogger.INFO_INT -> LogLevel.INFO;
+ case LocationAwareLogger.WARN_INT -> LogLevel.WARN;
+ default -> LogLevel.ERROR;
+ };
+ }
+
+ // ---- Report assembly ----
+
+ BuildReport buildReport(MavenSession endSession) {
+ Instant now = MonotonicClock.now();
+ Instant startInstant = endSession.getRequest().getStartInstant();
+ if (startInstant == null) {
+ startInstant = now;
+ }
+ Duration totalDuration = Duration.between(startInstant, now);
+
+ MavenExecutionResult result = endSession.getResult();
+ boolean hasFailures = result != null && result.hasExceptions();
+ BuildStatus overallStatus = hasFailures ? BuildStatus.FAILURE : BuildStatus.SUCCESS;
+
+ // Collect module reports
+ List moduleReports = new ArrayList<>();
+ for (MavenProject project : endSession.getProjects()) {
+ moduleReports.add(buildModuleReport(project, endSession));
+ }
+
+ // Collect failures
+ List failureReports = new ArrayList<>();
+ if (result != null) {
+ for (MavenProject project : endSession.getProjects()) {
+ BuildSummary summary = result.getBuildSummary(project);
+ if (summary instanceof BuildFailure buildFailure) {
+ failureReports.add(buildFailureReport(project, buildFailure));
+ }
+ }
+ }
+
+ // Metadata
+ String mavenVersion = endSession.getSystemProperties().getProperty("maven.version", "unknown");
+ String javaVersion = System.getProperty("java.version", "unknown");
+ List goals = endSession.getGoals();
+ MavenProject topProject = endSession.getTopLevelProject();
+ String projectId = topProject != null
+ ? topProject.getGroupId() + ":" + topProject.getArtifactId() + ":" + topProject.getVersion()
+ : "unknown";
+ boolean multiModule = endSession.getProjects().size() > 1;
+ int threads = endSession.getRequest().getDegreeOfConcurrency();
+
+ // Build-level log events (outside any module lifecycle)
+ List buildOutput = List.copyOf(buildLogBuffer);
+
+ return new DefaultBuildReport(
+ overallStatus,
+ totalDuration,
+ startInstant,
+ mavenVersion,
+ javaVersion,
+ goals,
+ projectId,
+ multiModule,
+ threads,
+ moduleReports,
+ failureReports,
+ List.of(),
+ buildOutput);
+ }
+
+ private ModuleReport buildModuleReport(MavenProject project, MavenSession endSession) {
+ String key = projectKey(project);
+
+ // Duration from BuildSummary (preferred) or fallback to our own tracking
+ MavenExecutionResult result = endSession.getResult();
+ Duration duration = Duration.ZERO;
+ BuildStatus status = BuildStatus.SKIPPED;
+ Instant moduleStartTime =
+ projectStartTimes.getOrDefault(key, endSession.getRequest().getStartInstant());
+
+ if (result != null) {
+ BuildSummary summary = result.getBuildSummary(project);
+ if (summary instanceof BuildSuccess) {
+ status = BuildStatus.SUCCESS;
+ duration = summary.getExecTime();
+ } else if (summary instanceof BuildFailure) {
+ status = BuildStatus.FAILURE;
+ duration = summary.getExecTime();
+ } else if (summary != null) {
+ // Unknown summary type - use its timing
+ duration = summary.getExecTime();
+ } else {
+ // No summary means skipped
+ Instant start = projectStartTimes.get(key);
+ if (start != null) {
+ duration = Duration.between(start, MonotonicClock.now());
+ }
+ }
+ }
+
+ // Mojo reports
+ List timings = mojoTimings.getOrDefault(key, Collections.emptyList());
+ List mojoReports;
+ synchronized (timings) {
+ mojoReports = timings.stream()
+ .map(t -> (MojoReport) new DefaultMojoReport(
+ t.groupId,
+ t.artifactId,
+ t.version,
+ t.goal,
+ t.executionId,
+ t.phase,
+ t.status,
+ t.startTime,
+ t.duration,
+ t.output))
+ .toList();
+ }
+
+ // Module-level log events (between mojos)
+ List moduleLogBuffer = moduleLogBuffers.getOrDefault(key, Collections.emptyList());
+ List moduleOutput;
+ synchronized (moduleLogBuffer) {
+ moduleOutput = List.copyOf(moduleLogBuffer);
+ }
+
+ return new DefaultModuleReport(
+ project.getGroupId(),
+ project.getArtifactId(),
+ project.getVersion(),
+ status,
+ moduleStartTime,
+ duration,
+ mojoReports,
+ moduleOutput);
+ }
+
+ private FailureReport buildFailureReport(MavenProject project, BuildFailure buildFailure) {
+ String module = project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion();
+
+ // Try to find which mojo failed
+ String mojoId = null;
+ List timings = mojoTimings.getOrDefault(projectKey(project), Collections.emptyList());
+ synchronized (timings) {
+ for (MojoTiming t : timings) {
+ if (t.status == BuildStatus.FAILURE) {
+ mojoId = t.artifactId + ":" + t.version + ":" + t.goal;
+ break;
+ }
+ }
+ }
+
+ Throwable cause = buildFailure.getCause();
+ String message = cause != null ? cause.getMessage() : "Unknown error";
+ String stackTrace = cause != null ? truncateStackTrace(cause) : null;
+
+ Instant failureTimestamp = MonotonicClock.now();
+ String exceptionType = cause != null ? cause.getClass().getSimpleName() : null;
+
+ return new DefaultFailureReport(
+ module,
+ mojoId,
+ failureTimestamp,
+ exceptionType,
+ message != null ? message : "Unknown error",
+ stackTrace);
+ }
+
+ // ---- JSON persistence ----
+
+ void writeReport(BuildReport report, MavenSession endSession) {
+ Path topDirectory = endSession.getTopDirectory();
+ if (topDirectory == null) {
+ LOGGER.debug("No top directory available, skipping build report");
+ return;
+ }
+
+ Path reportsDir = topDirectory.resolve("target").resolve(REPORT_DIR);
+
+ try {
+ Files.createDirectories(reportsDir);
+ String json = BuildReportJsonWriter.toJson(report);
+
+ // Timestamped file: build-report-20250729T143000Z.json
+ String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
+ .withZone(ZoneOffset.UTC)
+ .format(report.startTime());
+ Path timestampedFile = reportsDir.resolve("build-report-" + timestamp + ".json");
+
+ // Write to a temp file, then atomic-move into place so a crash
+ // never leaves a half-written report on disk.
+ Path tmpFile = Files.createTempFile(reportsDir, ".build-report-", ".tmp");
+ try {
+ Files.writeString(tmpFile, json);
+ atomicMove(tmpFile, timestampedFile);
+ } catch (IOException e) {
+ Files.deleteIfExists(tmpFile);
+ throw e;
+ }
+
+ // Latest symlink (or copy on filesystems that don't support symlinks)
+ Path latestFile = reportsDir.resolve(REPORT_LATEST);
+ try {
+ // Atomic symlink swap: create new link, then rename over the old one
+ Path tmpLink = Files.createTempFile(reportsDir, ".latest-", ".tmp");
+ Files.delete(tmpLink); // createTempFile creates a regular file
+ Files.createSymbolicLink(tmpLink, timestampedFile.getFileName());
+ atomicMove(tmpLink, latestFile);
+ } catch (UnsupportedOperationException | IOException symEx) {
+ // Windows or restricted filesystem - fall back to a plain copy
+ Files.writeString(latestFile, json);
+ }
+
+ LOGGER.debug("Build report written to {}", timestampedFile);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to write build report to {}: {}", reportsDir, e.getMessage());
+ }
+ }
+
+ /**
+ * Attempts an atomic move; falls back to a plain move if the filesystem
+ * does not support {@code ATOMIC_MOVE}.
+ */
+ private static void atomicMove(Path source, Path target) throws IOException {
+ try {
+ Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ // ---- Utility methods ----
+
+ private static String projectKey(MavenProject project) {
+ return project.getGroupId() + ":" + project.getArtifactId();
+ }
+
+ private static String mojoKey(MavenProject project, MojoExecution mojo) {
+ return projectKey(project) + "#" + mojo.getGoal() + "@" + mojo.getExecutionId();
+ }
+
+ static String truncateStackTrace(Throwable t) {
+ StringWriter sw = new StringWriter();
+ t.printStackTrace(new PrintWriter(sw));
+ String full = sw.toString();
+ String[] lines = full.split("\n");
+ if (lines.length <= MAX_STACKTRACE_LINES) {
+ return full;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < MAX_STACKTRACE_LINES; i++) {
+ sb.append(lines[i]).append('\n');
+ }
+ sb.append("... ").append(lines.length - MAX_STACKTRACE_LINES).append(" more lines truncated\n");
+ return sb.toString();
+ }
+
+ // ---- Internal records ----
+
+ record MojoTiming(
+ String groupId,
+ String artifactId,
+ String version,
+ String goal,
+ String executionId,
+ String phase,
+ BuildStatus status,
+ Instant startTime,
+ Duration duration,
+ List output) {}
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java
new file mode 100644
index 000000000000..03add8318ab0
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java
@@ -0,0 +1,384 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+import org.apache.maven.api.services.BuilderProblem;
+
+/**
+ * Serializes a {@link BuildReport} to JSON without any external library dependency.
+ *
+ * The output is human-readable (indented with 2 spaces) and designed to be
+ * stable across Maven versions — field order is fixed, and new fields are
+ * appended at the end of each object.
+ */
+final class BuildReportJsonWriter {
+
+ private BuildReportJsonWriter() {}
+
+ /**
+ * Serialize the given report to a pretty-printed JSON string.
+ */
+ static String toJson(BuildReport report) {
+ StringBuilder sb = new StringBuilder(4096);
+ writeReport(sb, report, 0);
+ sb.append('\n');
+ return sb.toString();
+ }
+
+ private static void writeReport(StringBuilder sb, BuildReport report, int indent) {
+ sb.append("{\n");
+ writeField(sb, indent + 1, "formatVersion", report.formatVersion());
+ writeField(sb, indent + 1, "status", report.status().name());
+ writeField(sb, indent + 1, "duration", report.duration().toString());
+ writeField(sb, indent + 1, "startTime", report.startTime().toString());
+ writeField(sb, indent + 1, "mavenVersion", report.mavenVersion());
+ writeField(sb, indent + 1, "javaVersion", report.javaVersion());
+ writeStringArray(sb, indent + 1, "goals", report.goals());
+ writeField(sb, indent + 1, "project", report.project());
+ writeField(sb, indent + 1, "multiModule", report.multiModule());
+ writeField(sb, indent + 1, "threads", report.threads());
+
+ // modules array
+ writeIndent(sb, indent + 1);
+ sb.append("\"modules\": ");
+ if (report.modules().isEmpty()) {
+ sb.append("[]");
+ } else {
+ sb.append("[\n");
+ for (int i = 0; i < report.modules().size(); i++) {
+ writeIndent(sb, indent + 2);
+ writeModule(sb, report.modules().get(i), indent + 2);
+ if (i < report.modules().size() - 1) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ writeIndent(sb, indent + 1);
+ sb.append(']');
+ }
+ sb.append(",\n");
+
+ // failures array
+ writeIndent(sb, indent + 1);
+ sb.append("\"failures\": ");
+ if (report.failures().isEmpty()) {
+ sb.append("[]");
+ } else {
+ sb.append("[\n");
+ for (int i = 0; i < report.failures().size(); i++) {
+ writeIndent(sb, indent + 2);
+ writeFailure(sb, report.failures().get(i), indent + 2);
+ if (i < report.failures().size() - 1) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ writeIndent(sb, indent + 1);
+ sb.append(']');
+ }
+ sb.append(",\n");
+
+ // problems array
+ writeIndent(sb, indent + 1);
+ sb.append("\"problems\": ");
+ if (report.problems().isEmpty()) {
+ sb.append("[]");
+ } else {
+ sb.append("[\n");
+ for (int i = 0; i < report.problems().size(); i++) {
+ writeIndent(sb, indent + 2);
+ writeProblem(sb, report.problems().get(i), indent + 2);
+ if (i < report.problems().size() - 1) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ writeIndent(sb, indent + 1);
+ sb.append(']');
+ }
+ sb.append(",\n");
+
+ // output array — build-level log lines (outside any module)
+ writeOutputArray(sb, indent + 1, report.output());
+ sb.append('\n');
+
+ writeIndent(sb, indent);
+ sb.append('}');
+ }
+
+ private static void writeProblem(StringBuilder sb, BuilderProblem problem, int indent) {
+ sb.append("{\n");
+ writeField(sb, indent + 1, "severity", problem.getSeverity().name());
+ writeField(sb, indent + 1, "message", problem.getMessage());
+ String source = problem.getSource();
+ if (source != null && !source.isEmpty()) {
+ writeField(sb, indent + 1, "source", source);
+ }
+ if (problem.getLineNumber() > 0) {
+ writeField(sb, indent + 1, "line", problem.getLineNumber());
+ }
+ if (problem.getColumnNumber() > 0) {
+ writeField(sb, indent + 1, "column", problem.getColumnNumber());
+ }
+ // Remove the trailing comma from the last written field
+ int lastComma = sb.lastIndexOf(",\n");
+ if (lastComma > 0) {
+ sb.replace(lastComma, lastComma + 1, "");
+ }
+ writeIndent(sb, indent);
+ sb.append('}');
+ }
+
+ private static void writeModule(StringBuilder sb, ModuleReport module, int indent) {
+ sb.append("{\n");
+ writeField(sb, indent + 1, "groupId", module.groupId());
+ writeField(sb, indent + 1, "artifactId", module.artifactId());
+ writeField(sb, indent + 1, "version", module.version());
+ writeField(sb, indent + 1, "status", module.status().name());
+ writeField(sb, indent + 1, "startTime", module.startTime().toString());
+ writeField(sb, indent + 1, "duration", module.duration().toString());
+
+ // mojos array
+ writeIndent(sb, indent + 1);
+ sb.append("\"mojos\": ");
+ if (module.mojos().isEmpty()) {
+ sb.append("[]");
+ } else {
+ sb.append("[\n");
+ for (int i = 0; i < module.mojos().size(); i++) {
+ writeIndent(sb, indent + 2);
+ writeMojo(sb, module.mojos().get(i), indent + 2);
+ if (i < module.mojos().size() - 1) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ writeIndent(sb, indent + 1);
+ sb.append(']');
+ }
+ sb.append(",\n");
+
+ // output array — module-level log lines (between mojos)
+ writeOutputArray(sb, indent + 1, module.output());
+ sb.append('\n');
+
+ writeIndent(sb, indent);
+ sb.append('}');
+ }
+
+ private static void writeMojo(StringBuilder sb, MojoReport mojo, int indent) {
+ sb.append("{\n");
+ writeField(sb, indent + 1, "groupId", mojo.groupId());
+ writeField(sb, indent + 1, "artifactId", mojo.artifactId());
+ writeField(sb, indent + 1, "version", mojo.version());
+ writeField(sb, indent + 1, "goal", mojo.goal());
+ writeNullableField(sb, indent + 1, "executionId", mojo.executionId(), true);
+ writeNullableField(sb, indent + 1, "phase", mojo.phase(), true);
+ writeField(sb, indent + 1, "status", mojo.status().name());
+ writeField(sb, indent + 1, "startTime", mojo.startTime().toString());
+ writeField(sb, indent + 1, "duration", mojo.duration().toString());
+
+ // output array — captured log lines
+ writeOutputArray(sb, indent + 1, mojo.output());
+ sb.append('\n');
+
+ writeIndent(sb, indent);
+ sb.append('}');
+ }
+
+ private static void writeFailure(StringBuilder sb, FailureReport failure, int indent) {
+ sb.append("{\n");
+ writeField(sb, indent + 1, "module", failure.module());
+ writeNullableField(sb, indent + 1, "mojo", failure.mojo(), true);
+ writeField(sb, indent + 1, "timestamp", failure.timestamp().toString());
+ writeNullableField(sb, indent + 1, "exceptionType", failure.exceptionType(), true);
+ if (failure.stackTrace() != null) {
+ writeField(sb, indent + 1, "message", failure.message());
+ writeLastField(sb, indent + 1, "stackTrace", failure.stackTrace());
+ } else {
+ writeLastField(sb, indent + 1, "message", failure.message());
+ }
+ writeIndent(sb, indent);
+ sb.append('}');
+ }
+
+ /**
+ * Writes an {@code "output": [...]} array of structured log events
+ * (used by report, module, and mojo).
+ * This is always the last field in its object, so no trailing comma.
+ */
+ private static void writeOutputArray(StringBuilder sb, int indent, java.util.List events) {
+ writeIndent(sb, indent);
+ sb.append("\"output\": ");
+ if (events.isEmpty()) {
+ sb.append("[]");
+ } else {
+ sb.append("[\n");
+ for (int i = 0; i < events.size(); i++) {
+ writeIndent(sb, indent + 1);
+ writeLogEvent(sb, events.get(i), indent + 1);
+ if (i < events.size() - 1) {
+ sb.append(',');
+ }
+ sb.append('\n');
+ }
+ writeIndent(sb, indent);
+ sb.append(']');
+ }
+ }
+
+ private static void writeLogEvent(StringBuilder sb, LogEvent event, int indent) {
+ sb.append("{\n");
+ writeField(sb, indent + 1, "timestamp", event.timestamp().toString());
+ writeField(sb, indent + 1, "level", event.level().name());
+ if (event.loggerName() != null) {
+ writeField(sb, indent + 1, "loggerName", event.loggerName());
+ }
+ writeField(sb, indent + 1, "message", event.message());
+ if (event.stackTrace() != null) {
+ writeField(sb, indent + 1, "stackTrace", event.stackTrace());
+ }
+ // JUL metadata — only present for events from java.util.logging
+ if (event.sourceClassName() != null) {
+ writeField(sb, indent + 1, "sourceClassName", event.sourceClassName());
+ }
+ if (event.sourceMethodName() != null) {
+ writeField(sb, indent + 1, "sourceMethodName", event.sourceMethodName());
+ }
+ if (event.threadId() >= 0) {
+ writeField(sb, indent + 1, "threadId", event.threadId());
+ }
+ removeTrailingComma(sb);
+ writeIndent(sb, indent);
+ sb.append('}');
+ }
+
+ // ---- Low-level JSON writing helpers ----
+
+ private static void writeField(StringBuilder sb, int indent, String key, String value) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": ");
+ writeJsonString(sb, value);
+ sb.append(",\n");
+ }
+
+ private static void writeField(StringBuilder sb, int indent, String key, int value) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": ").append(value).append(",\n");
+ }
+
+ private static void writeField(StringBuilder sb, int indent, String key, long value) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": ").append(value).append(",\n");
+ }
+
+ private static void writeField(StringBuilder sb, int indent, String key, boolean value) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": ").append(value).append(",\n");
+ }
+
+ /**
+ * Removes the trailing comma from the last field in a JSON object.
+ * Turns {@code "field": value,\n} into {@code "field": value\n}.
+ */
+ private static void removeTrailingComma(StringBuilder sb) {
+ int len = sb.length();
+ if (len >= 2 && sb.charAt(len - 2) == ',' && sb.charAt(len - 1) == '\n') {
+ sb.deleteCharAt(len - 2);
+ }
+ }
+
+ private static void writeLastField(StringBuilder sb, int indent, String key, String value) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": ");
+ writeJsonString(sb, value);
+ sb.append('\n');
+ }
+
+ private static void writeNullableField(
+ StringBuilder sb, int indent, String key, String value, @SuppressWarnings("unused") boolean hasMore) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": ");
+ if (value != null) {
+ writeJsonString(sb, value);
+ } else {
+ sb.append("null");
+ }
+ sb.append(",\n");
+ }
+
+ private static void writeStringArray(StringBuilder sb, int indent, String key, java.util.List values) {
+ writeIndent(sb, indent);
+ sb.append('"').append(key).append("\": [");
+ for (int i = 0; i < values.size(); i++) {
+ writeJsonString(sb, values.get(i));
+ if (i < values.size() - 1) {
+ sb.append(", ");
+ }
+ }
+ sb.append("],\n");
+ }
+
+ private static void writeJsonString(StringBuilder sb, String value) {
+ sb.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ case '\b':
+ sb.append("\\b");
+ break;
+ case '\f':
+ sb.append("\\f");
+ break;
+ default:
+ if (c < 0x20) {
+ sb.append("\\u");
+ sb.append(String.format("%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ sb.append('"');
+ }
+
+ private static void writeIndent(StringBuilder sb, int level) {
+ sb.append(" ".repeat(level));
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java
new file mode 100644
index 000000000000..c0522681a5b2
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.services.BuilderProblem;
+
+/**
+ * Internal immutable implementation of {@link BuildReport}.
+ */
+record DefaultBuildReport(
+ BuildStatus status,
+ Duration duration,
+ Instant startTime,
+ String mavenVersion,
+ String javaVersion,
+ List goals,
+ String project,
+ boolean multiModule,
+ int threads,
+ List modules,
+ List failures,
+ List problems,
+ List output)
+ implements BuildReport {
+
+ private static final int FORMAT_VERSION = 1;
+
+ @Override
+ public int formatVersion() {
+ return FORMAT_VERSION;
+ }
+
+ @Override
+ public List modules() {
+ return List.copyOf(modules);
+ }
+
+ @Override
+ public List failures() {
+ return List.copyOf(failures);
+ }
+
+ @Override
+ public List problems() {
+ return List.copyOf(problems);
+ }
+
+ @Override
+ public List goals() {
+ return List.copyOf(goals);
+ }
+
+ @Override
+ public List output() {
+ return List.copyOf(output);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java
new file mode 100644
index 000000000000..b0bc075d34f3
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.time.Instant;
+
+import org.apache.maven.api.build.report.FailureReport;
+
+/**
+ * Internal immutable implementation of {@link FailureReport}.
+ */
+record DefaultFailureReport(
+ String module, String mojo, Instant timestamp, String exceptionType, String message, String stackTrace)
+ implements FailureReport {}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
new file mode 100644
index 000000000000..64c79636ebd1
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+
+/**
+ * Internal immutable implementation of {@link ModuleReport}.
+ */
+record DefaultModuleReport(
+ String groupId,
+ String artifactId,
+ String version,
+ BuildStatus status,
+ Instant startTime,
+ Duration duration,
+ List mojos,
+ List output)
+ implements ModuleReport {
+
+ @Override
+ public List mojos() {
+ return List.copyOf(mojos);
+ }
+
+ @Override
+ public List output() {
+ return List.copyOf(output);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java
new file mode 100644
index 000000000000..5270d9c11fe3
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.MojoReport;
+
+/**
+ * Internal immutable implementation of {@link MojoReport}.
+ */
+record DefaultMojoReport(
+ String groupId,
+ String artifactId,
+ String version,
+ String goal,
+ String executionId,
+ String phase,
+ BuildStatus status,
+ Instant startTime,
+ Duration duration,
+ List output)
+ implements MojoReport {
+
+ @Override
+ public List output() {
+ return List.copyOf(output);
+ }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java
new file mode 100644
index 000000000000..433c166e1e2e
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.DefaultMavenExecutionResult;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportCollectorTest {
+
+ @TempDir
+ Path tempDir;
+
+ private BuildReportCollector collector;
+
+ @BeforeEach
+ void setUp() {
+ collector = new BuildReportCollector();
+ }
+
+ @Test
+ void testBuildReportAssembly() {
+ MavenProject project = createProject("org.example", "my-app", "1.0.0");
+ MavenSession session = createSession(project);
+ MavenExecutionResult result = session.getResult();
+
+ // Simulate: session started -> project started -> mojo started -> mojo succeeded -> project succeeded ->
+ // session
+ // ended
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+
+ MojoExecution mojo = createMojoExecution(
+ "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo));
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo));
+
+ // Record build success in the result
+ result.addBuildSummary(new BuildSuccess(project, 5000));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+ // Build the report
+ BuildReport report = collector.buildReport(session);
+
+ assertNotNull(report);
+ assertEquals(BuildStatus.SUCCESS, report.status());
+ assertEquals(1, report.formatVersion());
+ assertEquals("1.0.0", report.mavenVersion());
+ assertFalse(report.multiModule());
+ assertEquals(1, report.threads());
+ assertEquals(1, report.modules().size());
+ assertEquals("org.example", report.modules().get(0).groupId());
+ assertEquals("my-app", report.modules().get(0).artifactId());
+ assertEquals(BuildStatus.SUCCESS, report.modules().get(0).status());
+ assertEquals(1, report.modules().get(0).mojos().size());
+ assertEquals("compile", report.modules().get(0).mojos().get(0).goal());
+ assertEquals(BuildStatus.SUCCESS, report.modules().get(0).mojos().get(0).status());
+ assertTrue(report.failures().isEmpty());
+ assertTrue(report.problems().isEmpty());
+ }
+
+ @Test
+ void testBuildReportWithFailure() {
+ MavenProject project = createProject("org.example", "my-app", "1.0.0");
+ MavenSession session = createSession(project);
+ MavenExecutionResult result = session.getResult();
+
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+
+ MojoExecution mojo = createMojoExecution(
+ "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo));
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoFailed, session, project, mojo));
+
+ RuntimeException failure = new RuntimeException("Compilation failure: 3 errors");
+ result.addBuildSummary(new org.apache.maven.execution.BuildFailure(project, 3000, failure));
+ result.addException(failure);
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectFailed, session, project, null));
+
+ BuildReport report = collector.buildReport(session);
+
+ assertEquals(BuildStatus.FAILURE, report.status());
+ assertEquals(1, report.failures().size());
+ assertEquals("org.example:my-app:1.0.0", report.failures().get(0).module());
+ assertTrue(report.failures().get(0).message().contains("Compilation failure"));
+ assertNotNull(report.failures().get(0).timestamp(), "failure should have a timestamp");
+ assertEquals("RuntimeException", report.failures().get(0).exceptionType(), "exceptionType from cause");
+
+ // Navigate from failure -> module -> mojo using lookup methods
+ var failureReport = report.failures().get(0);
+ var moduleOpt = report.findModule(failureReport);
+ assertTrue(moduleOpt.isPresent(), "findModule(FailureReport) should find the module");
+ assertEquals("my-app", moduleOpt.get().artifactId());
+ assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id());
+
+ assertNotNull(failureReport.mojo(), "failure should reference a mojo");
+ var mojoOpt = moduleOpt.get().findMojo(failureReport.mojo());
+ assertTrue(mojoOpt.isPresent(), "findMojo should find the failed mojo");
+ assertEquals("compile", mojoOpt.get().goal());
+ assertEquals(BuildStatus.FAILURE, mojoOpt.get().status());
+ assertEquals("maven-compiler-plugin:3.15.0:compile", mojoOpt.get().id());
+ }
+
+ @Test
+ void testWriteReportToFile() throws IOException {
+ MavenProject project = createProject("org.example", "my-app", "1.0.0");
+ MavenSession session = createSession(project);
+ session.getResult().addBuildSummary(new BuildSuccess(project, 1000));
+
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+ BuildReport report = collector.buildReport(session);
+ collector.writeReport(report, session);
+
+ Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR);
+ Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST);
+ assertTrue(Files.exists(latestFile), "build-report-latest.json should exist");
+
+ String content = Files.readString(latestFile);
+ assertTrue(content.contains("\"formatVersion\": 1"));
+ assertTrue(content.contains("\"status\": \"SUCCESS\""));
+ assertTrue(content.contains("\"artifactId\": \"my-app\""));
+ }
+
+ @Test
+ void testMultiModuleBuild() {
+ MavenProject parent = createProject("org.example", "parent", "1.0.0");
+ MavenProject child1 = createProject("org.example", "child-api", "1.0.0");
+ MavenProject child2 = createProject("org.example", "child-impl", "1.0.0");
+
+ MavenSession session = createSession(parent, child1, child2);
+ MavenExecutionResult result = session.getResult();
+
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null));
+
+ // Build each module
+ for (MavenProject p : List.of(parent, child1, child2)) {
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, p, null));
+ result.addBuildSummary(new BuildSuccess(p, 1000));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, p, null));
+ }
+
+ BuildReport report = collector.buildReport(session);
+
+ assertEquals(BuildStatus.SUCCESS, report.status());
+ assertTrue(report.multiModule());
+ assertEquals(3, report.modules().size());
+ assertEquals("parent", report.modules().get(0).artifactId());
+ assertEquals("child-api", report.modules().get(1).artifactId());
+ assertEquals("child-impl", report.modules().get(2).artifactId());
+ }
+
+ @Test
+ void testStackTraceIsTruncated() {
+ // Build a throwable with a deep stack trace
+ RuntimeException deep = createDeepException(50);
+ String truncated = BuildReportCollector.truncateStackTrace(deep);
+
+ // Should contain the truncation notice
+ assertTrue(truncated.contains("more lines truncated"), "deep stack traces should be truncated");
+ }
+
+ @Test
+ void testShortStackTraceIsNotTruncated() {
+ RuntimeException shallow = new RuntimeException("short");
+ // Trim the stack to a known-small size so it's guaranteed under the limit
+ shallow.setStackTrace(
+ new StackTraceElement[] {new StackTraceElement("com.example.Foo", "bar", "Foo.java", 42)});
+ String result = BuildReportCollector.truncateStackTrace(shallow);
+
+ // Short stack traces should NOT contain the truncation notice
+ assertFalse(result.contains("more lines truncated"), "short stack traces should not be truncated");
+ }
+
+ // ---- Test helpers ----
+
+ private MavenProject createProject(String groupId, String artifactId, String version) {
+ MavenProject project = new MavenProject();
+ project.setGroupId(groupId);
+ project.setArtifactId(artifactId);
+ project.setVersion(version);
+ return project;
+ }
+
+ private MavenSession createSession(MavenProject... projects) {
+ MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setStartInstant(MonotonicClock.now());
+ request.setGoals(List.of("clean", "install"));
+ request.setTopDirectory(tempDir);
+
+ Properties systemProperties = new Properties();
+ systemProperties.setProperty("maven.version", "1.0.0");
+ request.setSystemProperties(systemProperties);
+
+ MavenExecutionResult result = new DefaultMavenExecutionResult();
+
+ @SuppressWarnings("deprecation")
+ MavenSession session = new MavenSession(null, null, request, result);
+ session.setProjects(List.of(projects));
+ return session;
+ }
+
+ private MojoExecution createMojoExecution(
+ String groupId, String artifactId, String version, String goal, String executionId, String phase) {
+ @SuppressWarnings("deprecation")
+ PluginDescriptor pluginDescriptor = new PluginDescriptor();
+ pluginDescriptor.setGroupId(groupId);
+ pluginDescriptor.setArtifactId(artifactId);
+ pluginDescriptor.setVersion(version);
+
+ MojoDescriptor mojoDescriptor = new MojoDescriptor();
+ mojoDescriptor.setGoal(goal);
+ mojoDescriptor.setPluginDescriptor(pluginDescriptor);
+
+ MojoExecution execution = new MojoExecution(mojoDescriptor, executionId);
+ execution.setLifecyclePhase(phase);
+
+ return execution;
+ }
+
+ private ExecutionEvent createEvent(
+ ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) {
+ return new ExecutionEvent() {
+ @Override
+ public Type getType() {
+ return type;
+ }
+
+ @Override
+ public MavenSession getSession() {
+ return session;
+ }
+
+ @Override
+ public MavenProject getProject() {
+ return project;
+ }
+
+ @Override
+ public MojoExecution getMojoExecution() {
+ return mojo;
+ }
+
+ @Override
+ public Exception getException() {
+ return null;
+ }
+ };
+ }
+
+ /**
+ * Creates an exception with a stack trace of at least {@code depth} lines.
+ */
+ private static RuntimeException createDeepException(int depth) {
+ try {
+ throwDeep(depth);
+ } catch (RuntimeException e) {
+ return e;
+ }
+ throw new AssertionError("unreachable");
+ }
+
+ private static void throwDeep(int remaining) {
+ if (remaining <= 0) {
+ throw new RuntimeException("deep exception");
+ }
+ throwDeep(remaining - 1);
+ }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java
new file mode 100644
index 000000000000..cc48ed27d7e7
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Properties;
+
+import org.apache.maven.api.MonotonicClock;
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.execution.BuildSuccess;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.DefaultMavenExecutionResult;
+import org.apache.maven.execution.ExecutionEvent;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenExecutionResult;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test that exercises the full build report pipeline:
+ * BuildReportCollector -> BuildReportJsonWriter -> file.
+ *
+ * This test simulates a complete multi-module build lifecycle with mojo
+ * executions and failures, then verifies the resulting JSON report file
+ * contains all expected data.
+ */
+class BuildReportIntegrationTest {
+
+ @TempDir
+ Path tempDir;
+
+ private BuildReportCollector collector;
+
+ @BeforeEach
+ void setUp() {
+ collector = new BuildReportCollector();
+ }
+
+ /**
+ * Full lifecycle: multi-module build with mojos and JSON persistence.
+ */
+ @Test
+ void testFullMultiModuleBuild() throws IOException {
+ MavenProject parent = createProject("com.example", "parent", "2.0.0");
+ MavenProject api = createProject("com.example", "api", "2.0.0");
+ MavenProject impl = createProject("com.example", "impl", "2.0.0");
+
+ MavenSession session = createSession(parent, api, impl);
+ MavenExecutionResult result = session.getResult();
+
+ // Session starts
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, parent, null));
+
+ // --- Module: parent ---
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, parent, null));
+ result.addBuildSummary(new BuildSuccess(parent, 500));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, parent, null));
+
+ // --- Module: api ---
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, api, null));
+
+ MojoExecution compileApi = createMojoExecution(
+ "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, compileApi));
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, compileApi));
+
+ MojoExecution testApi = createMojoExecution(
+ "org.apache.maven.plugins", "maven-surefire-plugin", "3.5.0", "test", "default-test", "test");
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, api, testApi));
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, api, testApi));
+
+ result.addBuildSummary(new BuildSuccess(api, 3000));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, api, null));
+
+ // --- Module: impl ---
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, impl, null));
+
+ MojoExecution compileImpl = createMojoExecution(
+ "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, impl, compileImpl));
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, impl, compileImpl));
+
+ result.addBuildSummary(new BuildSuccess(impl, 2000));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, impl, null));
+
+ // --- Build report ---
+ BuildReport report = collector.buildReport(session);
+
+ // Basic assertions
+ assertNotNull(report);
+ assertEquals(BuildStatus.SUCCESS, report.status());
+ assertTrue(report.multiModule());
+ assertEquals(3, report.modules().size());
+
+ // Problems should always be empty in this simplified collector
+ assertTrue(report.problems().isEmpty());
+
+ // Module reports
+ assertEquals("parent", report.modules().get(0).artifactId());
+ assertEquals("api", report.modules().get(1).artifactId());
+ assertEquals(2, report.modules().get(1).mojos().size());
+ assertEquals("compile", report.modules().get(1).mojos().get(0).goal());
+ assertEquals("test", report.modules().get(1).mojos().get(1).goal());
+ assertEquals("impl", report.modules().get(2).artifactId());
+
+ // Write to JSON and verify
+ collector.writeReport(report, session);
+
+ Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR);
+ Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST);
+ assertTrue(Files.exists(latestFile), "build-report-latest.json should exist");
+
+ String json = Files.readString(latestFile);
+
+ // Verify JSON structure
+ assertTrue(json.contains("\"formatVersion\": 1"));
+ assertTrue(json.contains("\"status\": \"SUCCESS\""));
+ assertTrue(json.contains("\"multiModule\": true"));
+ assertTrue(json.contains("\"threads\": 1"));
+
+ // Modules in JSON
+ assertTrue(json.contains("\"artifactId\": \"parent\""));
+ assertTrue(json.contains("\"artifactId\": \"api\""));
+ assertTrue(json.contains("\"artifactId\": \"impl\""));
+
+ // Mojos in JSON
+ assertTrue(json.contains("\"goal\": \"compile\""));
+ assertTrue(json.contains("\"goal\": \"test\""));
+
+ // Problems and failures should be empty
+ assertTrue(json.contains("\"problems\": []"));
+ assertTrue(json.contains("\"failures\": []"));
+
+ // Output arrays should be present (even if empty in unit test -- no SLF4J sink)
+ assertTrue(json.contains("\"output\": ["));
+ }
+
+ /**
+ * Tests navigation methods on the built report.
+ */
+ @Test
+ void testReportNavigationMethods() {
+ MavenProject project = createProject("org.example", "my-app", "1.0.0");
+ MavenSession session = createSession(project);
+ MavenExecutionResult result = session.getResult();
+
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+
+ MojoExecution mojo = createMojoExecution(
+ "org.apache.maven.plugins", "maven-compiler-plugin", "3.15.0", "compile", "default-compile", "compile");
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoStarted, session, project, mojo));
+ collector.onEvent(createEvent(ExecutionEvent.Type.MojoSucceeded, session, project, mojo));
+
+ result.addBuildSummary(new BuildSuccess(project, 5000));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+ BuildReport report = collector.buildReport(session);
+
+ // findModule by GAV
+ var moduleOpt = report.findModule("org.example:my-app:1.0.0");
+ assertTrue(moduleOpt.isPresent());
+ assertEquals("my-app", moduleOpt.get().artifactId());
+ assertEquals("org.example:my-app:1.0.0", moduleOpt.get().id());
+
+ // findMojo by id
+ var mojoOpt = moduleOpt.get().findMojo("maven-compiler-plugin:3.15.0:compile");
+ assertTrue(mojoOpt.isPresent());
+ assertEquals("compile", mojoOpt.get().goal());
+
+ // Not found
+ assertFalse(report.findModule("nonexistent:module:1.0").isPresent());
+ }
+
+ /**
+ * Tests that timestamped report files are written alongside the latest symlink.
+ */
+ @Test
+ void testTimestampedReportFile() throws IOException {
+ MavenProject project = createProject("org.example", "my-app", "1.0.0");
+ MavenSession session = createSession(project);
+ session.getResult().addBuildSummary(new BuildSuccess(project, 1000));
+
+ collector.onEvent(createEvent(ExecutionEvent.Type.SessionStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectStarted, session, project, null));
+ collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null));
+
+ BuildReport report = collector.buildReport(session);
+ collector.writeReport(report, session);
+
+ Path reportsDir = tempDir.resolve("target").resolve(BuildReportCollector.REPORT_DIR);
+ assertTrue(Files.exists(reportsDir), "reports directory should exist");
+
+ // Should have at least two files: the timestamped one and the latest link/copy
+ long fileCount = Files.list(reportsDir)
+ .filter(p -> p.getFileName().toString().startsWith("build-report-"))
+ .count();
+ assertTrue(fileCount >= 2, "should have both timestamped and latest report files, found " + fileCount);
+
+ // The latest file should contain valid JSON
+ Path latestFile = reportsDir.resolve(BuildReportCollector.REPORT_LATEST);
+ String json = Files.readString(latestFile);
+ assertTrue(json.startsWith("{"), "report should start with JSON object");
+ assertTrue(json.contains("\"formatVersion\": 1"), "report should contain format version");
+ }
+
+ // ---- Test helpers ----
+
+ private MavenProject createProject(String groupId, String artifactId, String version) {
+ MavenProject project = new MavenProject();
+ project.setGroupId(groupId);
+ project.setArtifactId(artifactId);
+ project.setVersion(version);
+ return project;
+ }
+
+ private MavenSession createSession(MavenProject... projects) {
+ MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+ request.setStartInstant(MonotonicClock.now());
+ request.setGoals(List.of("clean", "install"));
+ request.setTopDirectory(tempDir);
+
+ Properties systemProperties = new Properties();
+ systemProperties.setProperty("maven.version", "4.1.0-SNAPSHOT");
+ request.setSystemProperties(systemProperties);
+
+ MavenExecutionResult result = new DefaultMavenExecutionResult();
+
+ @SuppressWarnings("deprecation")
+ MavenSession session = new MavenSession(null, null, request, result);
+ session.setProjects(List.of(projects));
+ return session;
+ }
+
+ private MojoExecution createMojoExecution(
+ String groupId, String artifactId, String version, String goal, String executionId, String phase) {
+ @SuppressWarnings("deprecation")
+ PluginDescriptor pluginDescriptor = new PluginDescriptor();
+ pluginDescriptor.setGroupId(groupId);
+ pluginDescriptor.setArtifactId(artifactId);
+ pluginDescriptor.setVersion(version);
+
+ MojoDescriptor mojoDescriptor = new MojoDescriptor();
+ mojoDescriptor.setGoal(goal);
+ mojoDescriptor.setPluginDescriptor(pluginDescriptor);
+
+ MojoExecution execution = new MojoExecution(mojoDescriptor, executionId);
+ execution.setLifecyclePhase(phase);
+
+ return execution;
+ }
+
+ private ExecutionEvent createEvent(
+ ExecutionEvent.Type type, MavenSession session, MavenProject project, MojoExecution mojo) {
+ return new ExecutionEvent() {
+ @Override
+ public Type getType() {
+ return type;
+ }
+
+ @Override
+ public MavenSession getSession() {
+ return session;
+ }
+
+ @Override
+ public MavenProject getProject() {
+ return project;
+ }
+
+ @Override
+ public MojoExecution getMojoExecution() {
+ return mojo;
+ }
+
+ @Override
+ public Exception getException() {
+ return null;
+ }
+ };
+ }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java
new file mode 100644
index 000000000000..d123cb363efc
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java
@@ -0,0 +1,421 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.build;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.build.report.BuildReport;
+import org.apache.maven.api.build.report.BuildStatus;
+import org.apache.maven.api.build.report.FailureReport;
+import org.apache.maven.api.build.report.LogEvent;
+import org.apache.maven.api.build.report.LogLevel;
+import org.apache.maven.api.build.report.ModuleReport;
+import org.apache.maven.api.build.report.MojoReport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BuildReportJsonWriterTest {
+
+ private static final Instant BASE_TIME = Instant.parse("2025-01-15T10:30:00Z");
+
+ @Test
+ void testSuccessfulBuildReport() {
+ List mojoOutput = List.of(
+ new DefaultLogEvent(
+ BASE_TIME.plusSeconds(6), LogLevel.INFO, "Compiling 42 source files", "o.a.m.compiler", null),
+ new DefaultLogEvent(BASE_TIME.plusSeconds(7), LogLevel.INFO, "BUILD SUCCESS", "o.a.m.compiler", null));
+
+ MojoReport mojo = new DefaultMojoReport(
+ "org.apache.maven.plugins",
+ "maven-compiler-plugin",
+ "3.15.0",
+ "compile",
+ "default-compile",
+ "compile",
+ BuildStatus.SUCCESS,
+ BASE_TIME.plusSeconds(5),
+ Duration.ofMillis(2100),
+ mojoOutput);
+
+ List moduleOutput = List.of(new DefaultLogEvent(
+ BASE_TIME.plusSeconds(2),
+ LogLevel.INFO,
+ "Resolving dependencies for maven-core",
+ "o.a.m.resolver",
+ null));
+
+ ModuleReport module = new DefaultModuleReport(
+ "org.apache.maven",
+ "maven-core",
+ "4.1.0-SNAPSHOT",
+ BuildStatus.SUCCESS,
+ BASE_TIME.plusSeconds(1),
+ Duration.ofMillis(12345),
+ List.of(mojo),
+ moduleOutput);
+
+ List buildOutput = List.of(
+ new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Reactor Build Order:", "o.a.m.reactor", null),
+ new DefaultLogEvent(BASE_TIME, LogLevel.INFO, "Maven Core", "o.a.m.reactor", null));
+
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ofMillis(30000),
+ BASE_TIME,
+ "4.1.0-SNAPSHOT",
+ "21.0.1",
+ List.of("clean", "install"),
+ "org.apache.maven:maven:4.1.0-SNAPSHOT",
+ true,
+ 4,
+ List.of(module),
+ List.of(),
+ List.of(),
+ buildOutput);
+
+ String json = BuildReportJsonWriter.toJson(report);
+
+ assertTrue(json.contains("\"formatVersion\": 1"));
+ assertTrue(json.contains("\"status\": \"SUCCESS\""));
+ assertTrue(json.contains("\"mavenVersion\": \"4.1.0-SNAPSHOT\""));
+ assertTrue(json.contains("\"javaVersion\": \"21.0.1\""));
+ assertTrue(json.contains("\"goals\": [\"clean\", \"install\"]"));
+ assertTrue(json.contains("\"multiModule\": true"));
+ assertTrue(json.contains("\"threads\": 4"));
+ assertTrue(json.contains("\"groupId\": \"org.apache.maven\""));
+ assertTrue(json.contains("\"artifactId\": \"maven-core\""));
+ assertTrue(json.contains("\"goal\": \"compile\""));
+ assertTrue(json.contains("\"executionId\": \"default-compile\""));
+ assertTrue(json.contains("\"failures\": []"));
+ // Module and mojo start times
+ assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:01Z\""), "module startTime");
+ assertTrue(json.contains("\"startTime\": \"2025-01-15T10:30:05Z\""), "mojo startTime");
+ // Structured log events at all three levels
+ assertTrue(json.contains("\"message\": \"Compiling 42 source files\""), "mojo-level log event");
+ assertTrue(json.contains("\"message\": \"Resolving dependencies for maven-core\""), "module-level log event");
+ assertTrue(json.contains("\"message\": \"Reactor Build Order:\""), "build-level log event");
+ // Log event structure
+ assertTrue(json.contains("\"level\": \"INFO\""), "log level");
+ assertTrue(json.contains("\"loggerName\": \"o.a.m.compiler\""), "logger name");
+ }
+
+ @Test
+ void testFailedBuildReport() {
+ FailureReport failure = new DefaultFailureReport(
+ "org.apache.maven:maven-core:4.1.0-SNAPSHOT",
+ "maven-compiler-plugin:3.15.0:compile",
+ BASE_TIME.plusSeconds(3),
+ "CompilationFailureException",
+ "Compilation failure: 3 errors",
+ "org.apache.maven.plugin.compiler.CompilationFailureException: ...\n\tat ...\n");
+
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.FAILURE,
+ Duration.ofMillis(5000),
+ BASE_TIME,
+ "4.1.0-SNAPSHOT",
+ "21.0.1",
+ List.of("compile"),
+ "org.apache.maven:maven-core:4.1.0-SNAPSHOT",
+ false,
+ 1,
+ List.of(),
+ List.of(failure),
+ List.of(),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(report);
+
+ assertTrue(json.contains("\"status\": \"FAILURE\""));
+ assertTrue(json.contains("\"module\": \"org.apache.maven:maven-core:4.1.0-SNAPSHOT\""));
+ assertTrue(json.contains("\"mojo\": \"maven-compiler-plugin:3.15.0:compile\""));
+ assertTrue(json.contains("\"message\": \"Compilation failure: 3 errors\""));
+ assertTrue(json.contains("\"stackTrace\""));
+ // Enriched fields
+ assertTrue(json.contains("\"timestamp\": \"2025-01-15T10:30:03Z\""), "failure timestamp");
+ assertTrue(json.contains("\"exceptionType\": \"CompilationFailureException\""), "failure exceptionType");
+ }
+
+ @Test
+ void testJsonStringEscaping() {
+ FailureReport failure = new DefaultFailureReport(
+ "com.example:test:1.0",
+ null,
+ BASE_TIME,
+ null,
+ "Error: \"unexpected\" value\nwith newline\tand tab",
+ null);
+
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.FAILURE,
+ Duration.ofMillis(100),
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of(),
+ "com.example:test:1.0",
+ false,
+ 1,
+ List.of(),
+ List.of(failure),
+ List.of(),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(report);
+
+ // Check proper JSON escaping
+ assertTrue(json.contains("\\\"unexpected\\\""));
+ assertTrue(json.contains("\\n"));
+ assertTrue(json.contains("\\t"));
+ }
+
+ @Test
+ void testEmptyModulesAndFailures() {
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ofMillis(100),
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of(),
+ "com.example:test:1.0",
+ false,
+ 1,
+ List.of(),
+ List.of(),
+ List.of(),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(report);
+
+ assertTrue(json.contains("\"modules\": []"));
+ assertTrue(json.contains("\"failures\": []"));
+ }
+
+ @Test
+ void testFormatVersion() {
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ZERO,
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of(),
+ "test:test:1.0",
+ false,
+ 1,
+ List.of(),
+ List.of(),
+ List.of(),
+ List.of());
+
+ assertEquals(1, report.formatVersion());
+ }
+
+ @Test
+ void testMultipleModules() {
+ ModuleReport mod1 = new DefaultModuleReport(
+ "com.example",
+ "api",
+ "1.0",
+ BuildStatus.SUCCESS,
+ BASE_TIME.plusSeconds(1),
+ Duration.ofSeconds(5),
+ List.of(),
+ List.of());
+ ModuleReport mod2 = new DefaultModuleReport(
+ "com.example",
+ "impl",
+ "1.0",
+ BuildStatus.SUCCESS,
+ BASE_TIME.plusSeconds(6),
+ Duration.ofSeconds(10),
+ List.of(),
+ List.of());
+ ModuleReport mod3 = new DefaultModuleReport(
+ "com.example",
+ "web",
+ "1.0",
+ BuildStatus.SKIPPED,
+ BASE_TIME.plusSeconds(16),
+ Duration.ZERO,
+ List.of(),
+ List.of());
+
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ofSeconds(15),
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of("install"),
+ "com.example:parent:1.0",
+ true,
+ 1,
+ List.of(mod1, mod2, mod3),
+ List.of(),
+ List.of(),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(report);
+
+ // All modules present
+ assertTrue(json.contains("\"artifactId\": \"api\""));
+ assertTrue(json.contains("\"artifactId\": \"impl\""));
+ assertTrue(json.contains("\"artifactId\": \"web\""));
+ assertTrue(json.contains("\"status\": \"SKIPPED\""));
+ }
+
+ @Test
+ void testNullMojoFields() {
+ // mojo with null executionId and phase (direct invocation)
+ MojoReport mojo = new DefaultMojoReport(
+ "org.apache.maven.plugins",
+ "maven-help-plugin",
+ "3.4.1",
+ "effective-pom",
+ null,
+ null,
+ BuildStatus.SUCCESS,
+ BASE_TIME.plusSeconds(1),
+ Duration.ofMillis(500),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ofSeconds(1),
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of("help:effective-pom"),
+ "test:test:1.0",
+ false,
+ 1,
+ List.of(new DefaultModuleReport(
+ "test",
+ "test",
+ "1.0",
+ BuildStatus.SUCCESS,
+ BASE_TIME,
+ Duration.ofSeconds(1),
+ List.of(mojo),
+ List.of())),
+ List.of(),
+ List.of(),
+ List.of()));
+
+ assertTrue(json.contains("\"executionId\": null"));
+ assertTrue(json.contains("\"phase\": null"));
+ assertFalse(json.contains("\"executionId\": \"null\""));
+ }
+
+ @Test
+ void testLogEventWithJulMetadata() {
+ // LogEvent with JUL source class, method, and thread ID
+ LogEvent julEvent = new DefaultLogEvent(
+ BASE_TIME.plusSeconds(1),
+ LogLevel.WARN,
+ "Unsupported class file major version 65",
+ "org.apache.maven.plugins.compiler",
+ null,
+ null,
+ "com.sun.tools.javac.processing.JavacProcessingEnvironment",
+ "doProcessing",
+ 42L);
+
+ // LogEvent without JUL metadata (from SLF4J)
+ LogEvent slf4jEvent = new DefaultLogEvent(
+ BASE_TIME.plusSeconds(2), LogLevel.INFO, "Compiling 10 files", "o.a.m.compiler", null);
+
+ MojoReport mojo = new DefaultMojoReport(
+ "org.apache.maven.plugins",
+ "maven-compiler-plugin",
+ "3.15.0",
+ "compile",
+ "default-compile",
+ "compile",
+ BuildStatus.SUCCESS,
+ BASE_TIME,
+ Duration.ofMillis(1000),
+ List.of(julEvent, slf4jEvent));
+
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ofSeconds(1),
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of("compile"),
+ "test:test:1.0",
+ false,
+ 1,
+ List.of(new DefaultModuleReport(
+ "test",
+ "test",
+ "1.0",
+ BuildStatus.SUCCESS,
+ BASE_TIME,
+ Duration.ofSeconds(1),
+ List.of(mojo),
+ List.of())),
+ List.of(),
+ List.of(),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(report);
+
+ // JUL event should have sourceClassName, sourceMethodName, and threadId
+ assertTrue(
+ json.contains("\"sourceClassName\": \"com.sun.tools.javac.processing.JavacProcessingEnvironment\""),
+ "sourceClassName");
+ assertTrue(json.contains("\"sourceMethodName\": \"doProcessing\""), "sourceMethodName");
+ assertTrue(json.contains("\"threadId\": 42"), "threadId");
+ // SLF4J event should NOT have JUL fields
+ // (the second log event in the output array has no sourceClassName)
+ assertFalse(
+ json.contains("\"sourceClassName\": \"o.a.m.compiler\""),
+ "SLF4J event should not have sourceClassName");
+ }
+
+ @Test
+ void testEmptyProblems() {
+ BuildReport report = new DefaultBuildReport(
+ BuildStatus.SUCCESS,
+ Duration.ofMillis(100),
+ BASE_TIME,
+ "4.1.0",
+ "21",
+ List.of(),
+ "com.example:test:1.0",
+ false,
+ 1,
+ List.of(),
+ List.of(),
+ List.of(),
+ List.of());
+
+ String json = BuildReportJsonWriter.toJson(report);
+ assertTrue(json.contains("\"problems\": []"), "empty problems array");
+ }
+}