From abd26c76a9f477fd6848954117e28ab210d535bc Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 20 Sep 2026 21:34:13 +0000 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20logging=20foundation=20=E2=80=93?= =?UTF-8?q?=20structured=20LogEvent,=20JUL=20handler,=20Log=20API=20enhanc?= =?UTF-8?q?ements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LogEvent interface (maven-api-core) with projectId/mojoId context fields - Add DefaultLogEvent record (maven-core) implementing LogEvent - Add MavenJulHandler (maven-logging): bridge JUL→SLF4J for plugin logging - Enhance DefaultLog (maven-core): carry LOG_API_METADATA for mojo log capture - Add ProjectBuildLogAppender (maven-core): MDC-aware log sink feeding LogEvent stream - Suppress JLine terminal-init DEBUG logs before activateLogging in quiet mode - Gate StackWalker behind hasReportCapture() for zero overhead in normal builds - Fix warn(Supplier, Throwable) incorrectly calling logger.info() - Add @param tags on trace(Supplier) overloads; fix sequenceNumber() @return javadoc - Fix LogEvent.message() @return javadoc copy-paste from formattedMessage() - Strengthen logApiMetadataIsClearedAfterCall() test to exercise the remove() path --- apache-maven/pom.xml | 7 - .../maven/api/build/report/LogEvent.java | 189 +++++++++++ .../maven/api/build/report/LogLevel.java | 34 +- .../maven/api/build/report/package-info.java | 33 ++ compat/maven-embedder/pom.xml | 5 - impl/maven-cli/pom.xml | 9 - .../maven/cling/invoker/LookupInvoker.java | 52 ++- .../logging/impl/LogbackConfiguration.java | 46 --- .../maven/slf4j-configuration.properties | 2 - .../maven/internal/build/DefaultLogEvent.java | 81 +++++ .../maven/internal/impl/DefaultLog.java | 114 +++++-- .../maven/logging/BuildEventListener.java | 3 +- .../logging/ProjectBuildLogAppender.java | 123 ++++++- .../logging/SimpleBuildEventListener.java | 6 +- .../maven/internal/impl/DefaultLogTest.java | 37 +- .../jline/JLineMessageBuilderFactory.java | 13 +- .../apache/maven/slf4j/MavenBaseLogger.java | 19 +- .../apache/maven/slf4j/MavenJulHandler.java | 318 ++++++++++++++++++ .../apache/maven/slf4j/MavenSimpleLogger.java | 52 ++- .../maven/slf4j/MavenJulHandlerTest.java | 128 +++++++ pom.xml | 12 - 21 files changed, 1136 insertions(+), 147 deletions(-) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java rename impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java => api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java (56%) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java delete mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java create mode 100644 impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java create mode 100644 impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java diff --git a/apache-maven/pom.xml b/apache-maven/pom.xml index 927ba48766ca..1d51f405f9e2 100644 --- a/apache-maven/pom.xml +++ b/apache-maven/pom.xml @@ -78,13 +78,6 @@ under the License. ${slf4jVersion} runtime - - - org.slf4j - jul-to-slf4j - ${slf4jVersion} - runtime - org.apache.maven.resolver maven-resolver-connector-basic diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java new file mode 100644 index 000000000000..64b232171d29 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogEvent.java @@ -0,0 +1,189 @@ +/* + * 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.Immutable; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; + +/** + * A structured log event captured during the build. + *

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

+ * Events originating from the Maven Log API or from JUL + * ({@code java.util.logging}) carry additional source metadata: the + * source class name, source method name, and thread identifier. + * For Log API events the source class name is the mojo implementation + * FQCN; for JUL events it comes from {@code LogRecord}. Events from + * direct SLF4J logging have these fields set to {@code null}. + * + * @since 4.1.0 + */ +@Experimental +@Immutable +public interface LogEvent { + + /** + * When this log event was produced (wall-clock time). + * + * @return the event instant, never {@code null} + */ + @Nonnull + Instant timestamp(); + + /** + * The severity level of this log event. + * + * @return the log level, never {@code null} + */ + @Nonnull + LogLevel level(); + + /** + * The log message, without level prefix or timestamp formatting. + * + * @return the log message, never {@code null} + */ + @Nonnull + String message(); + + /** + * The name of the logger that produced this event + * (e.g. {@code "org.apache.maven.plugins.compiler.CompilerMojo"}). + * + * @return the logger name, or {@code null} if unavailable + */ + @Nullable + String loggerName(); + + /** + * The stack trace associated with this event, if an exception was logged. + *

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

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

+ * May be {@code null} if the event was created outside the SLF4J pipeline + * (e.g. in tests or by programmatic construction). + * + * @return the formatted log line, or {@code null} + */ + @Nullable + String formattedMessage(); + + // ---- Source metadata (populated for Log API and JUL events) ---- + + /** + * The fully qualified class name of the source that issued the log call. + *

+ * For Maven Log API events this is the mojo implementation class name. + * For JUL events it is the value from {@code LogRecord.getSourceClassName()}. + * For direct SLF4J logging it is {@code null}. + * + * @return the source class name, or {@code null} + */ + @Nullable + default String sourceClassName() { + return null; + } + + /** + * The method name of the source that issued the log call. + *

+ * For Maven Log API events this is resolved via {@link StackWalker}. + * For JUL events it is the value from {@code LogRecord.getSourceMethodName()}. + * For direct SLF4J logging it is {@code null}. + * + * @return the source method name, or {@code null} + */ + @Nullable + default String sourceMethodName() { + return null; + } + + /** + * The thread identifier from which this log event originated. + *

+ * Populated for both Log API and JUL events. Returns {@code -1} + * if the thread ID is not available (i.e. for direct SLF4J events). + * + * @return the thread ID, or {@code -1} if unavailable + */ + default long threadId() { + return -1; + } + + /** + * A monotonically increasing sequence number for total ordering of + * log events, useful when multiple events share the same timestamp. + *

+ * Assigned by the logging pipeline when the event is captured, + * providing a global ordering across all event sources (Log API, + * JUL, and direct SLF4J). + * + * @return the sequence number, or {@code -1} if unavailable + */ + default long sequenceNumber() { + return -1; + } + + /** + * The project this log event belongs to + * (e.g. {@code "org.apache.maven:maven-core:4.1.0-SNAPSHOT"}), or {@code null} + * if the event was not produced in the context of a specific project. + * + * @return the project identifier, or {@code null} + */ + @Nullable + default String projectId() { + return null; + } + + /** + * The mojo execution that produced this event + * (e.g. {@code "compiler:compile@default-compile"}), or {@code null} + * if the event was logged outside a mojo execution. + * + * @return the mojo execution identifier, or {@code null} + */ + @Nullable + default String mojoId() { + return null; + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java similarity index 56% rename from impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java rename to api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java index bbd487fa5f87..cb1de663f2c8 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/Log4j2Configuration.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/LogLevel.java @@ -16,29 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.maven.cling.logging.impl; +package org.apache.maven.api.build.report; -import org.apache.maven.cling.logging.BaseSlf4jConfiguration; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; /** - * Configuration for slf4j-log4j2. + * Log severity levels, mirroring the standard SLF4J levels. * - * @since 3.1.0 + * @since 4.1.0 + * @see LogEvent#level() */ -public class Log4j2Configuration extends BaseSlf4jConfiguration { - @Override - public void setRootLoggerLevel(Level level) { - String value = - switch (level) { - case DEBUG -> "debug"; - case INFO -> "info"; - default -> "error"; - }; - System.setProperty("maven.logging.root.level", value); - } - - @Override - public void activate() { - // no op - } +@Experimental +@Immutable +public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java new file mode 100644 index 000000000000..8aee68ff901e --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/package-info.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Structured build report data model. + *

+ * 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. + * + * @since 4.1.0 + */ +@Experimental +package org.apache.maven.api.build.report; + +import org.apache.maven.api.annotations.Experimental; diff --git a/compat/maven-embedder/pom.xml b/compat/maven-embedder/pom.xml index 2df8588ec116..56730ccb4326 100644 --- a/compat/maven-embedder/pom.xml +++ b/compat/maven-embedder/pom.xml @@ -163,11 +163,6 @@ under the License. commons-cli - - ch.qos.logback - logback-classic - true - org.jline jansi-core diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml index 5d7304af5e3a..56ac62ed6f3e 100644 --- a/impl/maven-cli/pom.xml +++ b/impl/maven-cli/pom.xml @@ -195,19 +195,10 @@ under the License. org.slf4j slf4j-api - - org.slf4j - jul-to-slf4j - commons-cli commons-cli - - ch.qos.logback - logback-classic - true - org.junit.jupiter diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java index 3e027ef4bbf2..c4dcde7bfd61 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/LookupInvoker.java @@ -37,6 +37,8 @@ import java.util.Properties; import java.util.function.Consumer; import java.util.function.UnaryOperator; +import java.util.logging.Level; +import java.util.logging.LogManager; import org.apache.maven.api.Constants; import org.apache.maven.api.ProtoSession; @@ -84,6 +86,7 @@ import org.apache.maven.logging.ProjectBuildLogAppender; import org.apache.maven.logging.SimpleBuildEventListener; import org.apache.maven.logging.api.LogLevelRecorder; +import org.apache.maven.slf4j.MavenJulHandler; import org.apache.maven.slf4j.MavenSimpleLogger; import org.codehaus.plexus.PlexusContainer; import org.jline.terminal.Terminal; @@ -92,7 +95,6 @@ import org.jline.terminal.spi.TerminalExt; import org.jline.utils.OSUtils; import org.slf4j.LoggerFactory; -import org.slf4j.bridge.SLF4JBridgeHandler; import org.slf4j.spi.LocationAwareLogger; import static java.util.Objects.requireNonNull; @@ -295,6 +297,20 @@ protected void configureLogging(C context) throws Exception { } else if (context.options().quiet().orElse(false)) { context.loggerLevel = Slf4jConfiguration.Level.ERROR; context.slf4jConfiguration.setRootLoggerLevel(context.loggerLevel); + // Install the JUL handler early and clamp the JUL root to SEVERE + // so that JLine terminal-init DEBUG events (emitted via java.util.logging + // before activateLogging() runs) are suppressed at source. + // createTerminal() executes after configureLogging() but before + // activateLogging(), so without this guard those DEBUG lines leak + // into the build output even when -q is in effect, causing + // MavenITmng4387QuietLoggingTest to fail on Windows. + // Setting SEVERE here is safe: it only blocks events, so it + // cannot trigger the SLF4J-bootstrap reentrancy flood that + // Level.ALL would cause. + if (!MavenJulHandler.isInstalled()) { + MavenJulHandler.install(); + } + LogManager.getLogManager().getLogger("").setLevel(Level.SEVERE); } else { // fall back to default log level specified in conf // see https://issues.apache.org/jira/browse/MNG-2570 and https://github.com/apache/maven/issues/11199 @@ -447,12 +463,33 @@ protected Consumer doDetermineWriter(C context) { } protected void activateLogging(C context) throws Exception { - if (!SLF4JBridgeHandler.isInstalled()) { - SLF4JBridgeHandler.removeHandlersForRootLogger(); - SLF4JBridgeHandler.install(); + if (!MavenJulHandler.isInstalled()) { + MavenJulHandler.install(); } context.slf4jConfiguration.activate(); + + // Now that SLF4J is fully initialized, set the JUL root logger level + // to match the effective log level. This must happen AFTER install() + // + activate() to avoid flooding JUL events during SLF4J bootstrap + // (ConcurrentHashMap.computeIfAbsent reentrancy). + // In quiet mode keep the JUL root at SEVERE so that INFO/WARN/DEBUG JUL + // events are suppressed at source — relying solely on the SLF4J-level + // check in MavenJulHandler.isLevelEnabled() is racy: newly created + // SLF4J loggers may briefly see the default INFO level before + // quiet-mode propagation completes, leaking output that + // MavenITmng4387QuietLoggingTest detects as a flaky failure. + // SEVERE matches the SLF4J ERROR threshold exactly, blocking both + // INFO and WARNING JUL events at source during the race window. + Level julRootLevel; + if (context.options().quiet().orElse(false)) { + julRootLevel = Level.SEVERE; + } else if (context.invokerRequest.effectiveVerbose()) { + julRootLevel = Level.ALL; + } else { + julRootLevel = Level.INFO; + } + LogManager.getLogManager().getLogger("").setLevel(julRootLevel); if (context.options().failOnSeverity().isPresent()) { String logLevelThreshold = context.options().failOnSeverity().get(); if (context.loggerFactory instanceof LogLevelRecorder recorder) { @@ -475,7 +512,12 @@ protected void activateLogging(C context) throws Exception { } } - // at this point logging is set up, reply so far accumulated logs, if any and swap logger with real one + // At this point logging is set up and createTerminal() has already run + // (doInvoke calls createTerminal before activateLogging), so + // ProjectBuildLogAppender has already installed the MavenSimpleLogger + // logSink and wired up any -l log-file writer. We can drain the + // accumulated early log queue directly into the new Slf4jLogger — + // messages will be routed through the logSink to the correct output. Logger logger = new Slf4jLogger(context.loggerFactory.getLogger(getClass().getName())); context.logger.drain().forEach(e -> logger.log(e.level(), e.message(), e.error())); diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java deleted file mode 100644 index 67ee429d82ab..000000000000 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/logging/impl/LogbackConfiguration.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.cling.logging.impl; - -import org.apache.maven.cling.logging.BaseSlf4jConfiguration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Configuration for slf4j-logback. - * - * @since 3.1.0 - */ -public class LogbackConfiguration extends BaseSlf4jConfiguration { - @Override - public void setRootLoggerLevel(Level level) { - ch.qos.logback.classic.Level value = - switch (level) { - case DEBUG -> ch.qos.logback.classic.Level.DEBUG; - case INFO -> ch.qos.logback.classic.Level.INFO; - default -> ch.qos.logback.classic.Level.ERROR; - }; - ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).setLevel(value); - } - - @Override - public void activate() { - // no op - } -} diff --git a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties index 369b0e6a266b..9580d5071ac9 100644 --- a/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties +++ b/impl/maven-cli/src/main/resources/META-INF/maven/slf4j-configuration.properties @@ -19,5 +19,3 @@ # value = corresponding o.a.m.cli.logging.Slf4jConfiguration class org.slf4j.impl.SimpleLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration org.apache.maven.slf4j.MavenLoggerFactory=org.apache.maven.cling.logging.impl.MavenSimpleConfiguration -org.apache.logging.slf4j.Log4jLoggerFactory=org.apache.maven.cling.logging.impl.Log4j2Configuration -ch.qos.logback.classic.LoggerContext=org.apache.maven.cling.logging.impl.LogbackConfiguration diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java new file mode 100644 index 000000000000..f49c51aff3f3 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultLogEvent.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.time.Instant; + +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; + +/** + * Immutable implementation of {@link LogEvent}. + *

+ * Public to allow construction from other packages within the Maven + * implementation (e.g. {@code ProjectBuildLogAppender}). + * + * @param timestamp when the event was produced + * @param level the severity level + * @param message the clean log message (without level prefix or ANSI) + * @param loggerName the name of the logger, or {@code null} + * @param stackTrace the stack trace string, or {@code null} + * @param formattedMessage the fully formatted console line, or {@code null} + * @param sourceClassName the source class name (Log API mojo FQCN or JUL source), or {@code null} + * @param sourceMethodName the source method name (via StackWalker or JUL), or {@code null} + * @param threadId the originating thread ID, or {@code -1} if unavailable + * @param sequenceNumber the JUL sequence number for ordering, or {@code -1} if unavailable + * @param projectId the project this event belongs to, or {@code null} + * @param mojoId the mojo execution that produced this event, or {@code null} + */ +public record DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage, + String sourceClassName, + String sourceMethodName, + long threadId, + long sequenceNumber, + String projectId, + String mojoId) + implements LogEvent { + + /** + * Convenience constructor for events without source metadata + * (i.e. direct SLF4J events). + */ + public DefaultLogEvent( + Instant timestamp, + LogLevel level, + String message, + String loggerName, + String stackTrace, + String formattedMessage) { + this(timestamp, level, message, loggerName, stackTrace, formattedMessage, null, null, -1, -1, null, null); + } + + /** + * Convenience constructor for events created without a formatted message + * (e.g. in tests or programmatic construction). + */ + DefaultLogEvent(Instant timestamp, LogLevel level, String message, String loggerName, String stackTrace) { + this(timestamp, level, message, loggerName, stackTrace, null, null, null, -1, -1, null, null); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java index b1cf40cc4059..6f17f2f74c25 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java @@ -18,21 +18,85 @@ */ package org.apache.maven.internal.impl; +import java.lang.StackWalker.StackFrame; import java.util.function.Supplier; import org.apache.maven.api.plugin.Log; +import org.apache.maven.logging.ProjectBuildLogAppender; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static java.util.Objects.requireNonNull; public class DefaultLog implements Log { + + /** + * Metadata captured from Log API calls, mirroring the JUL metadata + * pattern in {@code MavenJulHandler}. + * + * @param sourceClassName the fully qualified class name of the caller + * @param sourceMethodName the method that issued the log call + * @param threadId the originating thread ID + */ + public record LogApiMetadata(String sourceClassName, String sourceMethodName, long threadId) {} + + private static final ThreadLocal LOG_API_METADATA = new ThreadLocal<>(); + private static final StackWalker WALKER = StackWalker.getInstance(); + private static final String THIS_CLASS = DefaultLog.class.getName(); + + /** + * Returns the Log API metadata for the current log event being processed, + * or {@code null} if the current event did not originate from the Log API. + *

+ * Called from {@code ProjectBuildLogAppender.accept()} to populate + * {@code LogEvent.sourceClassName()} and {@code LogEvent.sourceMethodName()}. + * + * @return the current Log API metadata, or {@code null} + */ + public static LogApiMetadata getLogApiMetadata() { + return LOG_API_METADATA.get(); + } + private final Logger logger; public DefaultLog(Logger logger) { this.logger = requireNonNull(logger); } + /** + * Wraps a logging call with Log API metadata: when build report capture is + * active, sets the ThreadLocal with source class name, caller method name, + * and thread ID, executes the actual SLF4J call, and clears the ThreadLocal. + *

+ * The source class name is taken from the SLF4J logger name (which + * is the mojo implementation FQCN, set at injection time). The + * source method name is resolved via {@link StackWalker} only when + * build report capture is active (to avoid the ~1-5μs per-call cost + * on every enabled log statement during normal builds). + *

+ * When build report capture is not active, the logAction is invoked + * directly with zero ThreadLocal cost. + */ + private void withMetadata(Runnable logAction) { + if (ProjectBuildLogAppender.hasReportCapture()) { + // Only pay the StackWalker + ThreadLocal cost when metadata is needed + String callerMethodName = WALKER.walk(frames -> frames.dropWhile(f -> THIS_CLASS.equals(f.getClassName())) + .findFirst() + .map(StackFrame::getMethodName) + .orElse(null)); + @SuppressWarnings("deprecation") // Thread.getId() — threadId() requires Java 19+ + long threadId = Thread.currentThread().getId(); + LOG_API_METADATA.set(new LogApiMetadata(logger.getName(), callerMethodName, threadId)); + try { + logAction.run(); + } finally { + LOG_API_METADATA.remove(); + } + } else { + logAction.run(); + } + } + @Override public boolean isTraceEnabled() { return logger.isTraceEnabled(); @@ -41,175 +105,175 @@ public boolean isTraceEnabled() { @Override public void trace(CharSequence content) { if (isTraceEnabled()) { - logger.trace(toString(content)); + withMetadata(() -> logger.trace(toString(content))); } } @Override public void trace(CharSequence content, Throwable error) { if (isTraceEnabled()) { - logger.trace(toString(content), error); + withMetadata(() -> logger.trace(toString(content), error)); } } @Override public void trace(Throwable error) { if (isTraceEnabled()) { - logger.trace("", error); + withMetadata(() -> logger.trace("", error)); } } @Override public void trace(Supplier content) { if (isTraceEnabled()) { - logger.trace(content.get()); + withMetadata(() -> logger.trace(content.get())); } } @Override public void trace(Supplier content, Throwable error) { if (isTraceEnabled()) { - logger.trace(content.get(), error); + withMetadata(() -> logger.trace(content.get(), error)); } } @Override public void debug(CharSequence content) { if (isDebugEnabled()) { - logger.debug(toString(content)); + withMetadata(() -> logger.debug(toString(content))); } } @Override public void debug(CharSequence content, Throwable error) { if (isDebugEnabled()) { - logger.debug(toString(content), error); + withMetadata(() -> logger.debug(toString(content), error)); } } @Override public void debug(Throwable error) { if (isDebugEnabled()) { - logger.debug("", error); + withMetadata(() -> logger.debug("", error)); } } @Override public void debug(Supplier content) { if (isDebugEnabled()) { - logger.debug(content.get()); + withMetadata(() -> logger.debug(content.get())); } } @Override public void debug(Supplier content, Throwable error) { if (isDebugEnabled()) { - logger.debug(content.get(), error); + withMetadata(() -> logger.debug(content.get(), error)); } } @Override public void info(CharSequence content) { if (isInfoEnabled()) { - logger.info(toString(content)); + withMetadata(() -> logger.info(toString(content))); } } @Override public void info(CharSequence content, Throwable error) { if (isInfoEnabled()) { - logger.info(toString(content), error); + withMetadata(() -> logger.info(toString(content), error)); } } @Override public void info(Throwable error) { if (isInfoEnabled()) { - logger.info("", error); + withMetadata(() -> logger.info("", error)); } } @Override public void info(Supplier content) { if (isInfoEnabled()) { - logger.info(content.get()); + withMetadata(() -> logger.info(content.get())); } } @Override public void info(Supplier content, Throwable error) { if (isInfoEnabled()) { - logger.info(content.get(), error); + withMetadata(() -> logger.info(content.get(), error)); } } @Override public void warn(CharSequence content) { if (isWarnEnabled()) { - logger.warn(toString(content)); + withMetadata(() -> logger.warn(toString(content))); } } @Override public void warn(CharSequence content, Throwable error) { if (isWarnEnabled()) { - logger.warn(toString(content), error); + withMetadata(() -> logger.warn(toString(content), error)); } } @Override public void warn(Throwable error) { if (isWarnEnabled()) { - logger.warn("", error); + withMetadata(() -> logger.warn("", error)); } } @Override public void warn(Supplier content) { if (isWarnEnabled()) { - logger.warn(content.get()); + withMetadata(() -> logger.warn(content.get())); } } @Override public void warn(Supplier content, Throwable error) { if (isWarnEnabled()) { - logger.warn(content.get(), error); + withMetadata(() -> logger.warn(content.get(), error)); } } @Override public void error(CharSequence content) { if (isErrorEnabled()) { - logger.error(toString(content)); + withMetadata(() -> logger.error(toString(content))); } } @Override public void error(CharSequence content, Throwable error) { if (isErrorEnabled()) { - logger.error(toString(content), error); + withMetadata(() -> logger.error(toString(content), error)); } } @Override public void error(Throwable error) { if (isErrorEnabled()) { - logger.error("", error); + withMetadata(() -> logger.error("", error)); } } @Override public void error(Supplier content) { if (isErrorEnabled()) { - logger.error(content.get()); + withMetadata(() -> logger.error(content.get())); } } @Override public void error(Supplier content, Throwable error) { if (isErrorEnabled()) { - logger.error(content.get(), error); + withMetadata(() -> logger.error(content.get(), error)); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java index 39573d061cd0..c1d771b5c11b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/BuildEventListener.java @@ -18,6 +18,7 @@ */ package org.apache.maven.logging; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -30,7 +31,7 @@ public interface BuildEventListener { void projectStarted(String projectId); - void projectLogMessage(String projectId, String event); + void projectLogMessage(String projectId, LogEvent event); void projectFinished(String projectId); diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java index dc82a2f89848..a60f66df62b4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/ProjectBuildLogAppender.java @@ -18,11 +18,29 @@ */ package org.apache.maven.logging; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.build.report.LogEvent; +import org.apache.maven.api.build.report.LogLevel; +import org.apache.maven.internal.build.DefaultLogEvent; +import org.apache.maven.internal.impl.DefaultLog; +import org.apache.maven.slf4j.MavenJulHandler; import org.apache.maven.slf4j.MavenSimpleLogger; import org.slf4j.MDC; +import org.slf4j.spi.LocationAwareLogger; /** - * Forwards log messages to the client. + * Forwards log messages to the client as structured {@link LogEvent} objects. + *

+ * Installs itself as a {@link MavenSimpleLogger.LogSink} to intercept all + * SLF4J log output, enrich it with structured metadata (level, logger name, + * clean message, formatted output), and forward to the active + * {@link BuildEventListener}. */ public class ProjectBuildLogAppender implements AutoCloseable { @@ -118,6 +136,43 @@ public static void updateMdc() { } } + /** + * Global sequence counter for total ordering of log events across + * all sources (Log API, JUL, direct SLF4J). Incremented atomically + * in {@link #accept} which is called synchronously on the logging thread. + */ + private static final AtomicLong SEQUENCE = new AtomicLong(); + + /** + * Callback for build report log capture. Receives the fully-formed + * {@link LogEvent} produced by {@link #accept}, eliminating the need + * for a second capture pipeline in {@code MavenSimpleLogger}. + *

+ * Set by {@code BuildReportCollector} at session start, cleared at + * session end. The callback runs synchronously on the logging thread. + */ + private static volatile Consumer reportCapture; + + /** + * Sets the report capture callback. + * + * @param capture the callback, or {@code null} to remove + */ + public static void setReportCapture(Consumer capture) { + ProjectBuildLogAppender.reportCapture = capture; + } + + /** + * Returns {@code true} if a report capture callback is currently active. + * Used by {@link org.apache.maven.internal.impl.DefaultLog} to decide + * whether to pay the {@link StackWalker} cost for source method names. + * + * @return {@code true} if report capture is active + */ + public static boolean hasReportCapture() { + return reportCapture != null; + } + private final BuildEventListener buildEventListener; public ProjectBuildLogAppender(BuildEventListener buildEventListener) { @@ -125,13 +180,75 @@ public ProjectBuildLogAppender(BuildEventListener buildEventListener) { MavenSimpleLogger.setLogSink(this::accept); } - protected void accept(String message) { + protected void accept( + int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable) { String projectId = MDC.get(KEY_PROJECT_ID); - buildEventListener.projectLogMessage(projectId, message); + Instant timestamp = MonotonicClock.now(); + LogLevel logLevel = toLogLevel(level); + String stackTrace = throwable != null ? formatStackTrace(throwable) : null; + + long seq = SEQUENCE.getAndIncrement(); + + // Read source metadata: JUL events carry it via MavenJulHandler, + // Log API events carry it via DefaultLog's ThreadLocal. + MavenJulHandler.JulMetadata julMeta = MavenJulHandler.getJulMetadata(); + DefaultLog.LogApiMetadata logApiMeta = DefaultLog.getLogApiMetadata(); + String sourceClassName; + String sourceMethodName; + long threadId; + if (julMeta != null) { + sourceClassName = julMeta.sourceClassName(); + sourceMethodName = julMeta.sourceMethodName(); + threadId = julMeta.threadId(); + } else if (logApiMeta != null) { + sourceClassName = logApiMeta.sourceClassName(); + sourceMethodName = logApiMeta.sourceMethodName(); + threadId = logApiMeta.threadId(); + } else { + sourceClassName = null; + sourceMethodName = null; + threadId = -1; + } + LogEvent event = new DefaultLogEvent( + timestamp, + logLevel, + cleanMessage, + loggerName, + stackTrace, + formattedMessage, + sourceClassName, + sourceMethodName, + threadId, + seq, + projectId, + MOJO_ID.get()); + buildEventListener.projectLogMessage(projectId, event); + + // Forward to build report collector (if active) + Consumer capture = reportCapture; + if (capture != null) { + capture.accept(event); + } } @Override public void close() throws Exception { MavenSimpleLogger.setLogSink(null); } + + private static LogLevel toLogLevel(int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> LogLevel.TRACE; + case LocationAwareLogger.DEBUG_INT -> LogLevel.DEBUG; + case LocationAwareLogger.INFO_INT -> LogLevel.INFO; + case LocationAwareLogger.WARN_INT -> LogLevel.WARN; + default -> LogLevel.ERROR; + }; + } + + private static String formatStackTrace(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java index 87f7baa1fd59..37c49a92c1c4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java +++ b/impl/maven-core/src/main/java/org/apache/maven/logging/SimpleBuildEventListener.java @@ -20,6 +20,7 @@ import java.util.function.Consumer; +import org.apache.maven.api.build.report.LogEvent; import org.apache.maven.execution.ExecutionEvent; import org.eclipse.aether.transfer.TransferEvent; @@ -38,8 +39,9 @@ public void sessionStarted(ExecutionEvent event) {} public void projectStarted(String projectId) {} @Override - public void projectLogMessage(String projectId, String event) { - log(event); + public void projectLogMessage(String projectId, LogEvent event) { + String formatted = event.formattedMessage(); + log(formatted != null ? formatted : event.message()); } @Override diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java index eb290734fb98..a167bce34d1c 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java @@ -19,11 +19,13 @@ package org.apache.maven.internal.impl; import org.apache.maven.api.plugin.Log; +import org.apache.maven.logging.ProjectBuildLogAppender; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -31,7 +33,8 @@ import static org.mockito.Mockito.when; /** - * Tests for {@link DefaultLog}. + * Tests for {@link DefaultLog}, focused on verifying the bug fix for + * {@code warn(Supplier, Throwable)} and the Log API metadata contract. */ class DefaultLogTest { @@ -43,6 +46,7 @@ class DefaultLogTest { void warnWithSupplierAndThrowableDelegatesToWarn() { Logger mockLogger = mock(Logger.class); when(mockLogger.isWarnEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("test.logger"); DefaultLog log = new DefaultLog(mockLogger); RuntimeException ex = new RuntimeException("test"); @@ -51,6 +55,35 @@ void warnWithSupplierAndThrowableDelegatesToWarn() { verify(mockLogger).warn("warning message", ex); } + /** + * Verify that Log API metadata is set during the log call and + * cleared afterwards — no leakage across calls. + *

+ * Report capture must be active for the metadata path to execute; + * without it {@code withMetadata()} takes the fast-path and the + * ThreadLocal is never set. + */ + @Test + void logApiMetadataIsClearedAfterCall() { + Logger mockLogger = mock(Logger.class); + when(mockLogger.isInfoEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("com.example.MyMojo"); + + DefaultLog log = new DefaultLog(mockLogger); + + // Activate the report-capture path so withMetadata() actually sets the ThreadLocal + ProjectBuildLogAppender.setReportCapture(event -> {}); + try { + log.info("test message"); + + // After the call completes, metadata should be cleared (finally block ran) + assertNull(DefaultLog.getLogApiMetadata(), "Log API metadata should be cleared after the log call"); + } finally { + // Restore: do not leave a capture installed across tests + ProjectBuildLogAppender.setReportCapture(null); + } + } + /** * Verify trace methods delegate to the SLF4J logger correctly. */ @@ -58,6 +91,7 @@ void warnWithSupplierAndThrowableDelegatesToWarn() { void traceMethodsDelegateToSlf4jTrace() { Logger mockLogger = mock(Logger.class); when(mockLogger.isTraceEnabled()).thenReturn(true); + when(mockLogger.getName()).thenReturn("test.logger"); DefaultLog log = new DefaultLog(mockLogger); log.trace("trace message"); @@ -77,6 +111,7 @@ void traceIsNoOpWhenDisabled() { log.trace("should not be logged"); verify(mockLogger).isTraceEnabled(); + // trace() should NOT have been called on the underlying logger verifyNoMoreInteractions(mockLogger); } diff --git a/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java b/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java index cfae3c000c3a..f02504809cad 100644 --- a/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java +++ b/impl/maven-jline/src/main/java/org/apache/maven/jline/JLineMessageBuilderFactory.java @@ -136,7 +136,18 @@ private MavenStyleResolver() { @Override public AttributedStyle resolve(String spec) { - return styles.computeIfAbsent(spec, this::doResolve); + try { + return styles.computeIfAbsent(spec, this::doResolve); + } catch (IllegalStateException e) { + // ConcurrentHashMap.computeIfAbsent throws IllegalStateException("Recursive update") + // when the same map is re-entered from within a computeIfAbsent call on the same + // thread. This can happen during FastTerminal initialization: JLine's StyleResolver + // logs via JUL, MavenJulHandler routes to SLF4J, MavenSimpleLogger.renderLevel() + // lazily initialises styled level strings by calling style() → resolve() here, + // re-entering the same computeIfAbsent. Fall back to DEFAULT for this event; + // subsequent calls will hit the populated cache and succeed normally. + return AttributedStyle.DEFAULT; + } } @Override diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java index 20a5e7ab666b..6faeba5feacd 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenBaseLogger.java @@ -232,6 +232,23 @@ protected void write(StringBuilder buf, Throwable t) { } } + /** + * Context-aware write that includes the log level, logger name, and + * clean message alongside the formatted output. Subclasses can override + * to forward structured data to a log sink. + *

+ * The default implementation delegates to {@link #write(StringBuilder, Throwable)}. + * + * @param level the SLF4J log level constant + * @param loggerName the name of the logger + * @param cleanMessage the formatted message without level/timestamp prefix + * @param formattedBuf the fully formatted log line + * @param t the throwable, may be {@code null} + */ + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + write(formattedBuf, t); + } + protected void writeThrowable(Throwable t, PrintStream targetStream) { if (t != null) { t.printStackTrace(targetStream); @@ -375,7 +392,7 @@ private void innerHandleNormalizedLoggingCall( // Append the message buf.append(formattedMessage); - write(buf, t); + write(level.toInt(), name, formattedMessage, buf, t); } protected String renderLevel(int levelInt) { diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java new file mode 100644 index 000000000000..747accaf5182 --- /dev/null +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenJulHandler.java @@ -0,0 +1,318 @@ +/* + * 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.slf4j; + +import java.text.MessageFormat; +import java.util.MissingResourceException; +import java.util.ResourceBundle; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LocationAwareLogger; + +/** + * A JUL {@link Handler} that routes {@code java.util.logging} events into + * Maven's structured logging pipeline, preserving the rich {@link LogRecord} + * metadata that the standard {@code SLF4JBridgeHandler} silently drops + * (source class name, source method name, thread ID). + *

+ * All JUL events are routed through SLF4J so that {@link MavenSimpleLogger} + * produces a consistent {@code formattedMessage} (with timestamp, logger name, + * and ANSI styling) regardless of the event's origin. The JUL metadata is + * stashed in a thread-local before the SLF4J call so that downstream + * consumers (e.g. {@code ProjectBuildLogAppender}) can read it when + * constructing a structured {@code LogEvent}. + *

+ * Usage — replace the standard SLF4J bridge in {@code LookupInvoker}: + *

+ *     MavenJulHandler.install();
+ * 
+ * + * @since 4.1.0 + * @see #install() + * @see #getJulMetadata() + */ +public class MavenJulHandler extends Handler { + + /** + * JUL metadata captured from a {@link LogRecord} that would otherwise + * be lost when bridging to SLF4J. + * + * @param sourceClassName the source class, or {@code null} + * @param sourceMethodName the source method, or {@code null} + * @param threadId the originating thread ID + */ + public record JulMetadata(String sourceClassName, String sourceMethodName, long threadId) {} + + private static final ThreadLocal METADATA = new ThreadLocal<>(); + + /** + * Private SLF4J logger cache using {@link ConcurrentMap#putIfAbsent} + * instead of {@link ConcurrentMap#computeIfAbsent}. This avoids the + * {@code ConcurrentHashMap.computeIfAbsent} reentrancy bug + * ({@code IllegalStateException("Recursive update")}) that occurs + * when a JUL event fires during SLF4J logger initialization: the + * handler's {@code publish()} calls {@code LoggerFactory.getLogger()}, + * which internally uses {@code computeIfAbsent}, and if that triggers + * another JUL event whose logger name hashes to the same bucket, + * {@code ConcurrentHashMap} throws. {@code putIfAbsent} is safe + * against reentrancy — worst case, two threads create the same + * logger and one is discarded. + */ + private static final ConcurrentMap LOGGER_CACHE = new ConcurrentHashMap<>(); + + /** + * Re-entrancy guard: set to {@code true} while {@link #publish} is routing + * a JUL event through SLF4J on this thread. Prevents recursive JUL events + * (e.g. JLine's {@code StyleResolver} calling {@code java.util.logging.Logger} + * while inside {@link MavenSimpleLogger#renderLevel} lazy-initialisation, + * which in turn is triggered by a JUL event during terminal construction) + * from re-entering {@code publish} and crashing with + * {@code ConcurrentHashMap.computeIfAbsent IllegalStateException("Recursive update")}. + */ + private static final ThreadLocal IN_PUBLISH = new ThreadLocal<>(); + + /** + * Returns the JUL metadata for the current log event being processed, + * or {@code null} if the current log event did not originate from JUL. + *

+ * This method is intended to be called from within a + * {@link MavenSimpleLogger.LogSink} callback (e.g. in + * {@code ProjectBuildLogAppender.accept()}). + * + * @return the current JUL metadata, or {@code null} + */ + public static JulMetadata getJulMetadata() { + return METADATA.get(); + } + + /** + * Package-private test hook: sets or clears the re-entrancy flag on + * the current thread without reflection. Tests in the same package + * can call this instead of using {@code getDeclaredField("IN_PUBLISH")}. + * + * @param inPublish {@code true} to simulate being inside {@link #publish}, + * {@code false} (or pass {@code null} via the {@code remove} + * path) to clear the flag + */ + static void setInPublishForTest(boolean inPublish) { + if (inPublish) { + IN_PUBLISH.set(Boolean.TRUE); + } else { + IN_PUBLISH.remove(); + } + } + + /** + * Installs this handler on the JUL root logger, removing any + * previously installed handlers. This replaces the standard + * {@code SLF4JBridgeHandler.install()} call. + */ + public static void install() { + Logger rootLogger = LogManager.getLogManager().getLogger(""); + // Remove all existing handlers (including any SLF4JBridgeHandler) + for (Handler handler : rootLogger.getHandlers()) { + rootLogger.removeHandler(handler); + } + rootLogger.addHandler(new MavenJulHandler()); + // Note: we intentionally do NOT set rootLogger.setLevel(Level.ALL) + // here. Setting it eagerly floods JUL events during SLF4J bootstrap, + // triggering ConcurrentHashMap.computeIfAbsent reentrancy in the + // SLF4J logger factory ("Recursive update"). The JUL root default + // (INFO) is fine — callers that need FINE/FINEST events (e.g. -X + // debug mode) should set the JUL root level after SLF4J is fully + // initialized. + } + + /** + * Returns {@code true} if a {@code MavenJulHandler} is installed + * on the JUL root logger. + */ + public static boolean isInstalled() { + Logger rootLogger = LogManager.getLogManager().getLogger(""); + for (Handler handler : rootLogger.getHandlers()) { + if (handler instanceof MavenJulHandler) { + return true; + } + } + return false; + } + + @Override + public void publish(LogRecord record) { + if (record == null) { + return; + } + + // Honour any Filter registered on this Handler (java.util.logging.Handler contract). + if (!isLoggable(record)) { + return; + } + + // Re-entrancy guard: drop recursive JUL events that originate from + // within SLF4J/JLine processing triggered by this very publish() call. + // Example: MavenSimpleLogger.renderLevel() lazily initialises ANSI + // colour strings by calling JLine's StyleResolver, which logs DEBUG + // events via java.util.logging — re-entering publish() on the same + // thread and crashing ConcurrentHashMap.computeIfAbsent with + // IllegalStateException("Recursive update"). + if (Boolean.TRUE.equals(IN_PUBLISH.get())) { + return; + } + + // Guard against null logger name (allowed by JUL spec) + String loggerName = record.getLoggerName(); + if (loggerName == null) { + loggerName = ""; + } + + // Look up the SLF4J logger from our private cache, bypassing + // LoggerFactory.getLogger() on the hot path to avoid the + // ConcurrentHashMap.computeIfAbsent reentrancy problem. + org.slf4j.Logger slf4jLogger = LOGGER_CACHE.get(loggerName); + if (slf4jLogger == null) { + // Cold path: create the logger via SLF4J. Guard against + // the ConcurrentHashMap.computeIfAbsent reentrancy bug: + // LoggerFactory.getLogger() uses computeIfAbsent internally, + // so if a JUL event fires during SLF4J initialization and + // the logger name hashes to the same bucket, CHM throws + // IllegalStateException("Recursive update"). We catch it + // and silently drop the event — it's a bootstrap race, and + // subsequent events will hit the cache. + try { + slf4jLogger = LoggerFactory.getLogger(loggerName); + } catch (IllegalStateException e) { + // ConcurrentHashMap reentrancy — drop this event + return; + } + LOGGER_CACHE.putIfAbsent(loggerName, slf4jLogger); + } + int slf4jLevel = julLevelToSlf4j(record.getLevel()); + + // Quick exit if this level is not enabled + if (!isLevelEnabled(slf4jLogger, slf4jLevel)) { + return; + } + + String message = formatMessage(record); + Throwable throwable = record.getThrown(); + + // Set the JUL metadata before routing through SLF4J so that + // downstream consumers (e.g. ProjectBuildLogAppender) can read + // it when constructing a structured LogEvent. By always going + // through SLF4J, the formattedMessage is produced by + // MavenSimpleLogger (with proper timestamp, logger name, and + // ANSI styling) regardless of whether the event originated from + // JUL or SLF4J — fixing the format inconsistency. + METADATA.set( + new JulMetadata(record.getSourceClassName(), record.getSourceMethodName(), record.getLongThreadID())); + IN_PUBLISH.set(Boolean.TRUE); + try { + logToSlf4j(slf4jLogger, slf4jLevel, message, throwable); + } finally { + IN_PUBLISH.remove(); + METADATA.remove(); + } + } + + @Override + public void flush() { + // nothing to flush + } + + @Override + public void close() throws SecurityException { + // nothing to close + } + + /** + * Formats the log message, applying i18n resource bundle lookup and + * {@link MessageFormat} parameter substitution, matching the behavior + * of {@code SLF4JBridgeHandler}. + */ + private static String formatMessage(LogRecord record) { + String message = record.getMessage(); + if (message == null) { + return ""; + } + + // Try resource bundle lookup + ResourceBundle bundle = record.getResourceBundle(); + if (bundle != null) { + try { + message = bundle.getString(message); + } catch (MissingResourceException e) { + // use raw message + } + } + + // Apply MessageFormat parameters + Object[] params = record.getParameters(); + if (params != null && params.length > 0) { + try { + message = MessageFormat.format(message, params); + } catch (IllegalArgumentException e) { + // use message as-is if formatting fails + } + } + + return message; + } + + private static int julLevelToSlf4j(Level julLevel) { + int value = julLevel.intValue(); + if (value <= Level.FINEST.intValue()) { + return LocationAwareLogger.TRACE_INT; + } else if (value <= Level.FINE.intValue()) { + return LocationAwareLogger.DEBUG_INT; + } else if (value <= Level.INFO.intValue()) { + return LocationAwareLogger.INFO_INT; + } else if (value <= Level.WARNING.intValue()) { + return LocationAwareLogger.WARN_INT; + } else { + return LocationAwareLogger.ERROR_INT; + } + } + + private static boolean isLevelEnabled(org.slf4j.Logger logger, int level) { + return switch (level) { + case LocationAwareLogger.TRACE_INT -> logger.isTraceEnabled(); + case LocationAwareLogger.DEBUG_INT -> logger.isDebugEnabled(); + case LocationAwareLogger.INFO_INT -> logger.isInfoEnabled(); + case LocationAwareLogger.WARN_INT -> logger.isWarnEnabled(); + default -> logger.isErrorEnabled(); + }; + } + + private static void logToSlf4j(org.slf4j.Logger logger, int level, String message, Throwable throwable) { + switch (level) { + case LocationAwareLogger.TRACE_INT -> logger.trace(message, throwable); + case LocationAwareLogger.DEBUG_INT -> logger.debug(message, throwable); + case LocationAwareLogger.INFO_INT -> logger.info(message, throwable); + case LocationAwareLogger.WARN_INT -> logger.warn(message, throwable); + default -> logger.error(message, throwable); + } + } +} diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java index 02767987e2a1..2060d5bb7953 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java @@ -39,14 +39,45 @@ public class MavenSimpleLogger extends MavenBaseLogger { private String warnRenderedLevel; private String errorRenderedLevel; - static Consumer logSink; + /** + * Structured log sink that receives level, logger name, clean message, + * formatted console output, and throwable for each log event. + *

+ * This replaces the previous {@code Consumer} sink to enable + * console renderers (e.g. rich mode) to filter by log level and access + * the clean message independently of ANSI formatting. + * + * @since 4.1.0 + */ + @FunctionalInterface + public interface LogSink { + void accept(int level, String loggerName, String cleanMessage, String formattedMessage, Throwable throwable); + } + + static volatile LogSink logSink; public static final String DEFAULT_LOG_LEVEL_KEY = "org.slf4j.simpleLogger.defaultLogLevel"; - public static void setLogSink(Consumer logSink) { + /** + * Sets the structured log sink. + * + * @param logSink the sink, or {@code null} to remove + * @since 4.1.0 + */ + public static void setLogSink(LogSink logSink) { MavenSimpleLogger.logSink = logSink; } + /** + * Returns the current log sink, or {@code null} if none is set. + * + * @return the current log sink, or {@code null} + * @since 4.1.0 + */ + public static LogSink getLogSink() { + return logSink; + } + MavenSimpleLogger(String name) { super(name); } @@ -70,15 +101,22 @@ protected String renderLevel(int level) { } @Override - protected void write(StringBuilder buf, Throwable t) { - Consumer sink = logSink; + protected void write(int level, String loggerName, String cleanMessage, StringBuilder formattedBuf, Throwable t) { + LogSink sink = logSink; if (sink != null) { - sink.accept(buf.toString()); + // Build the full formatted output including throwable rendering, + // reusing the existing writeThrowable/printStackTrace methods + // to keep a single rendering path for throwables. + String formatted = formattedBuf.toString(); if (t != null) { - writeThrowable(t, sink); + StringBuilder full = new StringBuilder(formatted); + full.append(System.lineSeparator()); + writeThrowable(t, line -> full.append(line).append(System.lineSeparator())); + formatted = full.toString(); } + sink.accept(level, loggerName, cleanMessage, formatted, t); } else { - super.write(buf, t); + super.write(formattedBuf, t); } } diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java new file mode 100644 index 000000000000..58c23c92031e --- /dev/null +++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java @@ -0,0 +1,128 @@ +/* + * 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.slf4j; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.spi.LocationAwareLogger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests for {@link MavenJulHandler}, focused on the JUL→SLF4J level + * mapping and metadata lifecycle. + */ +class MavenJulHandlerTest { + + /** + * Table test for JUL level → SLF4J level mapping. + * Verifies the mapping documented in the class Javadoc. + */ + @ParameterizedTest(name = "JUL {0} -> SLF4J level {1}") + @CsvSource({ + "FINEST, 0", // TRACE_INT = 0 + "FINER, 10", // DEBUG_INT = 10 + "FINE, 10", // DEBUG_INT = 10 + "CONFIG, 20", // INFO_INT = 20 + "INFO, 20", // INFO_INT = 20 + "WARNING, 30", // WARN_INT = 30 + "SEVERE, 40", // ERROR_INT = 40 + }) + void julLevelMapsToCorrectSlf4jLevel(String julLevelName, int expectedSlf4jLevel) throws Exception { + Level julLevel = Level.parse(julLevelName); + int actual = invokeJulLevelToSlf4j(julLevel); + assertEquals( + expectedSlf4jLevel, actual, "JUL " + julLevelName + " should map to SLF4J level " + expectedSlf4jLevel); + } + + /** + * Verify that FINEST maps to TRACE (not DEBUG) — this is the key + * distinction for the TRACE/DEBUG separation. + */ + @Test + void finestMapsToTrace() throws Exception { + assertEquals( + LocationAwareLogger.TRACE_INT, + invokeJulLevelToSlf4j(Level.FINEST), + "FINEST should map to TRACE, not DEBUG"); + } + + /** + * Verify that CONFIG maps to INFO (not DEBUG) — CONFIG is JUL's + * informational level for static configuration, not a debug level. + */ + @Test + void configMapsToInfo() throws Exception { + assertEquals(LocationAwareLogger.INFO_INT, invokeJulLevelToSlf4j(Level.CONFIG), "CONFIG should map to INFO"); + } + + /** + * Verify that JUL metadata is null when no log event is being processed. + */ + @Test + void julMetadataIsNullOutsidePublish() { + assertNull(MavenJulHandler.getJulMetadata(), "JUL metadata should be null outside a publish() call"); + } + + /** + * Verify that a recursive call to {@link MavenJulHandler#publish} from + * within a {@code publish()} call on the same thread is silently dropped + * instead of crashing with {@code IllegalStateException("Recursive update")}. + *

+ * This is the reentrancy scenario that occurs when JLine's + * {@code StyleResolver} logs a JUL DEBUG event while + * {@code MavenSimpleLogger.renderLevel()} is lazily initialising ANSI + * colour strings during terminal construction. + */ + @Test + void publishIsReentrantSafe() { + MavenJulHandler handler = new MavenJulHandler(); + LogRecord record = new LogRecord(Level.INFO, "nested"); + + // Simulate re-entrance: use the package-private test hook instead of + // reflection so the test does not break if IN_PUBLISH is renamed. + MavenJulHandler.setInPublishForTest(true); + try { + // A nested call must be silently dropped without throwing. + handler.publish(record); + } finally { + MavenJulHandler.setInPublishForTest(false); + } + } + + /** + * Invoke the private julLevelToSlf4j method via reflection for testing. + */ + private static int invokeJulLevelToSlf4j(Level julLevel) throws Exception { + try { + Method method = MavenJulHandler.class.getDeclaredMethod("julLevelToSlf4j", Level.class); + method.setAccessible(true); + return (int) method.invoke(null, julLevel); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } +} diff --git a/pom.xml b/pom.xml index 49101f6196cd..b03308e65864 100644 --- a/pom.xml +++ b/pom.xml @@ -157,7 +157,6 @@ under the License. 1.37 6.1.3 1.4.0 - 1.6.3 5.23.0 1.6.0 1.30.0 @@ -508,17 +507,6 @@ under the License. ${slf4jVersion} true - - org.slf4j - jul-to-slf4j - ${slf4jVersion} - - - ch.qos.logback - logback-classic - ${logbackClassicVersion} - true - org.apache.maven.wagon From 92a187fbaf83fa9ba9eb9cecc787d77850922ac1 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 21 Sep 2026 07:43:54 +0000 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20build=20report=20=E2=80=93=20stru?= =?UTF-8?q?ctured=20JSON=20report=20with=20per-mojo=20log=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BuildReport, ModuleReport, MojoReport, FailureReport, BuildStatus API types (maven-api-core) - Add BuildEnvironment API type and Session.buildEnvironment() accessor - Implement BuildReportCollector in maven-core: captures per-mojo log output, timing, failures - Implement BuildReportJsonWriter: writes structured JSON report to file at build end - Integrate into MavenInvoker and DefaultMavenExecutionRequest - Extend MavenSimpleLogger and MavenJulHandler with LogSink support for log capture - Add SessionStub.buildEnvironment() stub for maven-testing - Add BuildReportCollectorTest, BuildReportJsonWriterTest, BuildReportIntegrationTest - Handle MojoSkipped events in BuildReportCollector - Fix ConcurrentModificationException in buildReport() log buffer iteration --- .../apache/maven/api/BuildEnvironment.java | 203 +++++ .../java/org/apache/maven/api/Session.java | 13 + .../maven/api/build/report/BuildReport.java | 208 ++++++ .../maven/api/build/report/BuildStatus.java | 46 ++ .../maven/api/build/report/FailureReport.java | 91 +++ .../maven/api/build/report/ModuleReport.java | 136 ++++ .../maven/api/build/report/MojoReport.java | 140 ++++ .../maven/api/build/report/package-info.java | 13 +- .../maven/cling/invoker/mvn/MavenInvoker.java | 1 + .../DefaultMavenExecutionRequest.java | 14 + .../execution/MavenExecutionRequest.java | 5 + .../internal/build/BuildReportCollector.java | 698 ++++++++++++++++++ .../internal/build/BuildReportJsonWriter.java | 435 +++++++++++ .../build/DefaultBuildEnvironment.java | 69 ++ .../internal/build/DefaultBuildReport.java | 84 +++ .../internal/build/DefaultFailureReport.java | 30 + .../internal/build/DefaultModuleReport.java | 53 ++ .../internal/build/DefaultMojoReport.java | 49 ++ .../maven/internal/impl/DefaultSession.java | 16 + .../build/BuildReportCollectorTest.java | 392 ++++++++++ .../build/BuildReportIntegrationTest.java | 313 ++++++++ .../build/BuildReportJsonWriterTest.java | 438 +++++++++++ .../maven/impl/standalone/ApiRunner.java | 77 ++ .../apache/maven/slf4j/MavenSimpleLogger.java | 30 +- .../maven/slf4j/MavenJulHandlerTest.java | 27 + .../maven/slf4j/MavenSimpleLoggerTest.java | 88 +++ .../testing/plugin/stubs/SessionStub.java | 75 ++ 27 files changed, 3732 insertions(+), 12 deletions(-) create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java create mode 100644 api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultFailureReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultMojoReport.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportIntegrationTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java new file mode 100644 index 000000000000..37d6dc596a6e --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java @@ -0,0 +1,203 @@ +/* + * 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; + +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +import org.apache.maven.api.annotations.Nonnull; + +/** + * Describes the invocation context of a Maven build: the flags, properties, and + * environment settings that were active when the build started. + * + *

An instance is available via {@link Session#buildEnvironment()} during the build, + * and is also recorded in the structured build report for post-mortem analysis and + * reproducibility. + * + *

What is captured

+ *
    + *
  • Goals and lifecycle phases requested ({@link #goals()})
  • + *
  • User properties passed via {@code -Dkey=value} ({@link #userProperties()}), + * with sensitive keys redacted — see {@link #userProperties()} for the denylist
  • + *
  • A curated subset of system properties relevant to reproducibility + * ({@link #systemInfo()}): OS name/arch/version, Java vendor and VM name/version, + * Maven home, and available processors
  • + *
  • Local repository path ({@link #localRepository()})
  • + *
  • Explicitly activated or deactivated profiles ({@link #activeProfiles()})
  • + *
  • Selected projects ({@link #selectedProjects()}) and resume-from + * ({@link #resumeFrom()})
  • + *
  • Reactor failure behavior ({@link #reactorFailureBehavior()})
  • + *
  • Offline mode ({@link #offline()}) and snapshot update policy + * ({@link #updateSnapshots()})
  • + *
  • Degree of concurrency ({@link #threads()})
  • + *
  • Transfer-progress suppression ({@link #noTransferProgress()}) and batch mode ({@link #batchMode()})
  • + *
+ * + *

What is not yet captured

+ *

The following information is not currently available through the Maven 4 API + * and therefore cannot be recorded here. It may be added in future versions as + * the API evolves: + *

    + *
  • Raw command-line arguments ({@code args[]}): the CLI bootstrap layer + * does not surface these through {@code MavenExecutionRequest} or the session
  • + *
  • Implicitly activated profiles: profiles activated by OS, JDK, or + * property conditions rather than by explicit {@code -P} flag; computing + * these requires per-project activation and is not available at session level
  • + *
+ * + * @since 4.1.0 + * @see Session#buildEnvironment() + */ +@Experimental +@Immutable +public interface BuildEnvironment { + + /** + * The goals or lifecycle phases that were requested on the command line. + * + * @return the requested goals/phases, never {@code null} + */ + @Nonnull + List goals(); + + /** + * User properties passed via {@code -Dkey=value} on the command line or via + * {@code --define}. + * + *

Sensitive keys are redacted and replaced with {@code "***"}. A key is + * considered sensitive if its lower-case form contains any of: + * {@code password}, {@code passwd}, {@code secret}, {@code token}, + * {@code apikey}, {@code api_key}, {@code credential}, {@code passphrase}. + * + * @return the user properties, never {@code null}; values of sensitive keys + * are replaced with {@code "***"} + */ + @Nonnull + Map userProperties(); + + /** + * A curated subset of system properties capturing platform and JVM identity, + * relevant for build reproducibility analysis. + * + *

The following keys are included when present: + * {@code os.name}, {@code os.arch}, {@code os.version}, + * {@code java.vendor}, {@code java.vm.name}, {@code java.vm.version}, + * {@code maven.home}, {@code user.home}, {@code user.name}, + * and {@code available.processors} (from {@code Runtime.getRuntime()}). + * + *

The full {@code System.getProperties()} map is intentionally not captured: + * it is large, mostly irrelevant, and may contain sensitive values. + * + * @return the curated system info map, never {@code null} + */ + @Nonnull + Map systemInfo(); + + /** + * The path to the local repository used for this build. + * + * @return the local repository path string, never {@code null} + */ + @Nonnull + String localRepository(); + + /** + * Profiles explicitly activated ({@code -P profileId}) or deactivated + * ({@code -P !profileId}) on the command line. + * + *

This list reflects only explicit {@code -P} selections. Profiles activated + * implicitly by OS, JDK, or property conditions are not included here — see + * the class-level javadoc for details. + * + * @return the explicitly selected profiles, never {@code null}; may be empty + */ + @Nonnull + List activeProfiles(); + + /** + * Projects explicitly selected via {@code -pl} / {@code --projects}. + * + * @return the selected project selectors (e.g. {@code ":my-module"}), + * never {@code null}; empty for a full reactor build + */ + @Nonnull + List selectedProjects(); + + /** + * The project to resume from, as specified via {@code -rf} / {@code --resume-from}. + * + * @return the resume-from selector, or {@code null} if not specified + */ + String resumeFrom(); + + /** + * The reactor failure behavior, corresponding to the {@code -ff} / {@code -fae} / + * {@code -fn} flags. + * + *

Possible values (matching {@code MavenExecutionRequest} constants): + *

    + *
  • {@code "FAIL_FAST"} — stop at first failure ({@code -ff}, default)
  • + *
  • {@code "FAIL_AT_END"} — build all, report failures at end ({@code -fae})
  • + *
  • {@code "FAIL_NEVER"} — always exit with success ({@code -fn})
  • + *
+ * + * @return the failure behavior string, never {@code null} + */ + @Nonnull + String reactorFailureBehavior(); + + /** + * Whether the build was invoked in offline mode ({@code -o} / {@code --offline}). + * + * @return {@code true} if offline mode was active + */ + boolean offline(); + + /** + * Whether snapshot updates were forced ({@code -U} / {@code --update-snapshots}). + * + * @return {@code true} if snapshot updates were forced + */ + boolean updateSnapshots(); + + /** + * Whether transfer progress output was suppressed ({@code --no-transfer-progress} / {@code -ntp}). + * + * @return {@code true} if transfer progress was disabled + */ + boolean noTransferProgress(); + + /** + * Whether the build was invoked in non-interactive (batch) mode ({@code --batch-mode} / {@code -B}). + * Equivalent to {@code !isInteractiveMode()} on {@code MavenExecutionRequest}. + * + * @return {@code true} if batch mode was active + */ + boolean batchMode(); + + /** + * The degree of concurrency ({@code -T} flag), or {@code 1} for sequential builds. + * + * @return the thread count + */ + int threads(); +} diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java index 0138f461b1aa..ce08cb61bec6 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java @@ -46,6 +46,19 @@ @ThreadSafe public interface Session extends ProtoSession { + /** + * Returns the environment context of this build: the flags, properties, and + * platform settings that were active when the session started. + * + *

The returned instance is immutable and captures a snapshot of the invocation + * context (offline mode, user properties, selected projects, etc.). + * + * @return the build environment, never {@code null} + * @since 4.1.0 + */ + @Nonnull + BuildEnvironment buildEnvironment(); + /** * Returns the current maven version. * 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..f8d6088ae4aa --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildReport.java @@ -0,0 +1,208 @@ +/* + * 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.BuildEnvironment; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Immutable; +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 +@Immutable +public interface BuildReport { + + /** + * The invocation context of this build: flags, properties, and platform settings + * that were active when the session started. + * + * @return the build environment, never {@code null} + * @since 4.1.0 + */ + @Nonnull + BuildEnvironment environment(); + + /** + * 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..37d5dd9fe488 --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/BuildStatus.java @@ -0,0 +1,46 @@ +/* + * 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; +import org.apache.maven.api.annotations.Immutable; + +/** + * The outcome of a build, module, or mojo execution. + * + * @since 4.1.0 + */ +@Experimental +@Immutable +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..115301c7067a --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/FailureReport.java @@ -0,0 +1,91 @@ +/* + * 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.Immutable; +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 +@Immutable +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..cd0fea00086a --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/ModuleReport.java @@ -0,0 +1,136 @@ +/* + * 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.Immutable; +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 +@Immutable +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..8829243fa17f --- /dev/null +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/report/MojoReport.java @@ -0,0 +1,140 @@ +/* + * 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.Immutable; +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 +@Immutable +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-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index e6372ccfd818..0cf448e32562 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -257,6 +257,7 @@ protected void populateRequest(MavenContext context, Lookup lookup, MavenExecuti } } + request.setNoTransferProgress(context.options().noTransferProgress().orElse(false)); request.setTransferListener(determineTransferListener( context, context.options().noTransferProgress().orElse(false))); request.setExecutionListener(determineExecutionListener(context)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java b/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java index eedfe23016a6..11bfdc32270d 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java +++ b/impl/maven-core/src/main/java/org/apache/maven/execution/DefaultMavenExecutionRequest.java @@ -64,6 +64,8 @@ public class DefaultMavenExecutionRequest implements MavenExecutionRequest { private boolean interactiveMode = true; + private boolean noTransferProgress = false; + private boolean cacheTransferError = false; private boolean cacheNotFound = false; @@ -180,6 +182,7 @@ public static MavenExecutionRequest copy(MavenExecutionRequest original) { copy.setLocalRepositoryPath(original.getLocalRepositoryPath()); copy.setOffline(original.isOffline()); copy.setInteractiveMode(original.isInteractiveMode()); + copy.setNoTransferProgress(original.isNoTransferProgress()); copy.setCacheNotFound(original.isCacheNotFound()); copy.setCacheTransferError(original.isCacheTransferError()); copy.setIgnoreMissingArtifactDescriptor(original.isIgnoreMissingArtifactDescriptor()); @@ -627,6 +630,17 @@ public MavenExecutionRequest setInteractiveMode(boolean interactive) { return this; } + @Override + public boolean isNoTransferProgress() { + return noTransferProgress; + } + + @Override + public MavenExecutionRequest setNoTransferProgress(boolean noTransferProgress) { + this.noTransferProgress = noTransferProgress; + return this; + } + @Override public MavenExecutionRequest setTransferListener(TransferListener transferListener) { this.transferListener = transferListener; diff --git a/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java b/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java index baf008017751..0ac6957e54b6 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java +++ b/impl/maven-core/src/main/java/org/apache/maven/execution/MavenExecutionRequest.java @@ -279,6 +279,11 @@ public interface MavenExecutionRequest { boolean isInteractiveMode(); + // Transfer progress + MavenExecutionRequest setNoTransferProgress(boolean noTransferProgress); + + boolean isNoTransferProgress(); + // Offline MavenExecutionRequest setOffline(boolean offline); 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..b958484f8689 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java @@ -0,0 +1,698 @@ +/* + * 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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.maven.api.BuildEnvironment; +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.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.logging.ProjectBuildLogAppender; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 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: registers a callback on {@link ProjectBuildLogAppender} to + * receive the already-formed {@link LogEvent} objects produced by the main + * logging pipeline. 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 silently dropped to prevent unbounded memory growth. + */ + 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; + + /** Build environment - captured once at SessionStarted, immutable thereafter. */ + private volatile BuildEnvironment buildEnvironment; + + // ---- 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; + case MojoSkipped: + onMojoSkipped(executionEvent); + break; + default: + break; + } + } + } + + // ---- Event handlers ---- + + private void onSessionStarted(ExecutionEvent event) { + this.session = event.getSession(); + this.buildEnvironment = buildEnvironment(this.session); + 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); + } + + private void onMojoSkipped(ExecutionEvent event) { + MojoExecution mojo = event.getMojoExecution(); + MavenProject project = event.getProject(); + String pKey = projectKey(project); + + Instant now = MonotonicClock.now(); + + MojoTiming timing = new MojoTiming( + mojo.getGroupId(), + mojo.getArtifactId(), + mojo.getVersion(), + mojo.getGoal(), + mojo.getExecutionId(), + mojo.getLifecyclePhase(), + BuildStatus.SKIPPED, + now, + Duration.ZERO, + List.of()); + + mojoTimings + .computeIfAbsent(pKey, k -> Collections.synchronizedList(new ArrayList<>())) + .add(timing); + } + + // ---- Structured log capture ---- + + /** + * Registers a callback on {@link ProjectBuildLogAppender} to receive the + * already-formed {@link LogEvent} objects from the main logging pipeline. + * This eliminates the need for a separate capture path and ensures the + * build report captures the same enriched events (with sequence number, + * source metadata) as the console output. + */ + private void installLogCapture() { + ProjectBuildLogAppender.setReportCapture(this::captureLogEvent); + } + + private void removeLogCapture() { + ProjectBuildLogAppender.setReportCapture(null); + } + + /** + * Routes a pre-formed {@link LogEvent} to the appropriate buffer + * (mojo, module, or build-level) based on the current thread's + * lifecycle context. + */ + private void captureLogEvent(LogEvent event) { + long threadId = Thread.currentThread().getId(); + + // 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(event); + } + 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(event); + } + return; + } + + // 3. Build-level: no project active (startup, reactor summary, post-build) + // synchronizedList requires external synchronization for the compound check-then-add + // to avoid TOCTOU: two threads can both observe size() < MAX and both add(). + synchronized (buildLogBuffer) { + if (buildLogBuffer.size() < MAX_LOG_EVENTS_PER_SCOPE) { + buildLogBuffer.add(event); + } + } + } + + // ---- 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; + synchronized (buildLogBuffer) { + buildOutput = List.copyOf(buildLogBuffer); + } + + return new DefaultBuildReport( + buildEnvironment != null ? buildEnvironment : buildEnvironment(endSession), + overallStatus, + totalDuration, + startInstant, + mavenVersion, + javaVersion, + goals, + projectId, + multiModule, + threads, + moduleReports, + failureReports, + List.of(), + buildOutput); + } + + /** + * Captures the build environment from the session at the point of invocation. + * Called once at {@code SessionStarted} so the snapshot reflects the original + * invocation context, not any session mutations that may happen during the build. + */ + public static BuildEnvironment buildEnvironment(MavenSession session) { + var request = session.getRequest(); + + // Goals + List goals = request.getGoals() != null ? List.copyOf(request.getGoals()) : List.of(); + + // User properties — filter sensitive keys + Map userProperties = new LinkedHashMap<>(); + if (request.getUserProperties() != null) { + request.getUserProperties().forEach((k, v) -> { + String key = String.valueOf(k); + String value = isSensitiveKey(key) ? "***" : String.valueOf(v); + userProperties.put(key, value); + }); + } + + // Curated system info + Map systemInfo = new TreeMap<>(); + addSystemProp(systemInfo, "os.name"); + addSystemProp(systemInfo, "os.arch"); + addSystemProp(systemInfo, "os.version"); + addSystemProp(systemInfo, "java.vendor"); + addSystemProp(systemInfo, "java.vm.name"); + addSystemProp(systemInfo, "java.vm.version"); + addSystemProp(systemInfo, "maven.home"); + addSystemProp(systemInfo, "user.home"); + addSystemProp(systemInfo, "user.name"); + systemInfo.put( + "available.processors", String.valueOf(Runtime.getRuntime().availableProcessors())); + + // Local repository path + String localRepo = request.getLocalRepositoryPath() != null + ? request.getLocalRepositoryPath().getAbsolutePath() + : ""; + + // Explicitly activated profiles (-P) + List activeProfiles = + request.getActiveProfiles() != null ? List.copyOf(request.getActiveProfiles()) : List.of(); + + // Selected projects (-pl) + List selectedProjects = + request.getSelectedProjects() != null ? List.copyOf(request.getSelectedProjects()) : List.of(); + + // Resume from (-rf) + String resumeFrom = request.getResumeFrom(); + + // Failure behavior + String failureBehavior = + request.getReactorFailureBehavior() != null ? request.getReactorFailureBehavior() : "FAIL_FAST"; + + return new DefaultBuildEnvironment( + goals, + userProperties, + systemInfo, + localRepo, + activeProfiles, + selectedProjects, + resumeFrom, + failureBehavior, + request.isOffline(), + request.isUpdateSnapshots(), + request.isNoTransferProgress(), + !request.isInteractiveMode(), + request.getDegreeOfConcurrency()); + } + + private static final Set SENSITIVE_KEY_FRAGMENTS = + Set.of("password", "passwd", "secret", "token", "apikey", "api_key", "credential", "passphrase"); + + private static boolean isSensitiveKey(String key) { + String lower = key.toLowerCase(java.util.Locale.ROOT); + for (String fragment : SENSITIVE_KEY_FRAGMENTS) { + if (lower.contains(fragment)) { + return true; + } + } + return false; + } + + private static void addSystemProp(Map map, String key) { + String value = System.getProperty(key); + if (value != null) { + map.put(key, value); + } + } + + 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..b94d8cb20280 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportJsonWriter.java @@ -0,0 +1,435 @@ +/* + * 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.BuildEnvironment; +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()); + + // environment object + writeIndent(sb, indent + 1); + sb.append("\"environment\": "); + writeEnvironment(sb, report.environment(), indent + 1); + sb.append(",\n"); + + // 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 writeEnvironment(StringBuilder sb, BuildEnvironment env, int indent) { + sb.append("{\n"); + writeStringArray(sb, indent + 1, "goals", env.goals()); + writeStringMap(sb, indent + 1, "userProperties", env.userProperties()); + writeStringMap(sb, indent + 1, "systemInfo", env.systemInfo()); + writeField(sb, indent + 1, "localRepository", env.localRepository()); + writeStringArray(sb, indent + 1, "activeProfiles", env.activeProfiles()); + writeStringArray(sb, indent + 1, "selectedProjects", env.selectedProjects()); + writeNullableField(sb, indent + 1, "resumeFrom", env.resumeFrom()); + writeField(sb, indent + 1, "reactorFailureBehavior", env.reactorFailureBehavior()); + writeField(sb, indent + 1, "offline", env.offline()); + writeField(sb, indent + 1, "updateSnapshots", env.updateSnapshots()); + writeField(sb, indent + 1, "noTransferProgress", env.noTransferProgress()); + writeField(sb, indent + 1, "batchMode", env.batchMode()); + writeField(sb, indent + 1, "threads", env.threads()); + removeTrailingComma(sb); + writeIndent(sb, indent); + sb.append('}'); + } + + private static void writeStringMap(StringBuilder sb, int indent, String key, java.util.Map map) { + writeIndent(sb, indent); + sb.append('"').append(key).append("\": "); + if (map.isEmpty()) { + sb.append("{}"); + } else { + sb.append("{\n"); + var entries = new java.util.ArrayList<>(map.entrySet()); + for (int i = 0; i < entries.size(); i++) { + var entry = entries.get(i); + writeIndent(sb, indent + 1); + writeJsonString(sb, entry.getKey()); + sb.append(": "); + writeJsonString(sb, entry.getValue()); + if (i < entries.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + writeIndent(sb, indent); + sb.append('}'); + } + sb.append(",\n"); + } + + 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 + removeTrailingComma(sb); + 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()); + writeNullableField(sb, indent + 1, "phase", mojo.phase()); + 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()); + writeField(sb, indent + 1, "timestamp", failure.timestamp().toString()); + writeNullableField(sb, indent + 1, "exceptionType", failure.exceptionType()); + 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()); + } + // Source metadata — present for Log API and JUL events + 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()); + } + if (event.sequenceNumber() >= 0) { + writeField(sb, indent + 1, "sequenceNumber", event.sequenceNumber()); + } + 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) { + 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/DefaultBuildEnvironment.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java new file mode 100644 index 000000000000..ac956a66f803 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.build; + +import java.util.List; +import java.util.Map; + +import org.apache.maven.api.BuildEnvironment; + +/** + * Internal immutable implementation of {@link BuildEnvironment}. + */ +record DefaultBuildEnvironment( + List goals, + Map userProperties, + Map systemInfo, + String localRepository, + List activeProfiles, + List selectedProjects, + String resumeFrom, + String reactorFailureBehavior, + boolean offline, + boolean updateSnapshots, + boolean noTransferProgress, + boolean batchMode, + int threads) + implements BuildEnvironment { + + @Override + public List goals() { + return List.copyOf(goals); + } + + @Override + public Map userProperties() { + return Map.copyOf(userProperties); + } + + @Override + public Map systemInfo() { + return Map.copyOf(systemInfo); + } + + @Override + public List activeProfiles() { + return List.copyOf(activeProfiles); + } + + @Override + public List selectedProjects() { + return List.copyOf(selectedProjects); + } +} 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..0eed279fb68e --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildReport.java @@ -0,0 +1,84 @@ +/* + * 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.BuildEnvironment; +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( + BuildEnvironment environment, + 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/main/java/org/apache/maven/internal/impl/DefaultSession.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java index fbbd64fa2cac..b7e49973dc9b 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java @@ -28,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.apache.maven.RepositoryUtils; +import org.apache.maven.api.BuildEnvironment; import org.apache.maven.api.Project; import org.apache.maven.api.RemoteRepository; import org.apache.maven.api.Session; @@ -46,6 +47,7 @@ import org.apache.maven.impl.AbstractSession; import org.apache.maven.impl.DefaultRemoteRepository; import org.apache.maven.impl.PropertiesAsMap; +import org.apache.maven.internal.build.BuildReportCollector; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.plugin.descriptor.PluginDescriptor; @@ -63,6 +65,7 @@ public class DefaultSession extends AbstractSession implements InternalMavenSess private final MavenRepositorySystem mavenRepositorySystem; private final RuntimeInformation runtimeInformation; private final Map allProjects = new ConcurrentHashMap<>(); + private volatile BuildEnvironment buildEnvironment; @SuppressWarnings("checkstyle:ParameterNumber") public DefaultSession( @@ -172,6 +175,19 @@ public int getDegreeOfConcurrency() { return getMavenSession().getRequest().getDegreeOfConcurrency(); } + @Nonnull + @Override + public BuildEnvironment buildEnvironment() { + if (buildEnvironment == null) { + synchronized (this) { + if (buildEnvironment == null) { + buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession()); + } + } + } + return buildEnvironment; + } + @Nonnull @Override public Instant getStartTime() { 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..ee38aed9307c --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportCollectorTest.java @@ -0,0 +1,392 @@ +/* + * 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.time.Duration; +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()); + + // environment assertions + assertNotNull(report.environment()); + assertEquals(List.of("clean", "install"), report.environment().goals()); + assertFalse(report.environment().offline()); + assertFalse(report.environment().updateSnapshots()); + assertNotNull(report.environment().systemInfo()); + assertFalse(report.environment().systemInfo().isEmpty()); + assertTrue(report.environment().systemInfo().containsKey("available.processors")); + } + + @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 testMojoSkippedIsRecorded() { + 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)); + + // A skipped mojo has no MojoStarted event — MojoSkipped fires directly + MojoExecution mojo = createMojoExecution( + "org.apache.maven.plugins", "maven-surefire-plugin", "3.0.0", "test", "default-test", "test"); + collector.onEvent(createEvent(ExecutionEvent.Type.MojoSkipped, session, project, mojo)); + + result.addBuildSummary(new BuildSuccess(project, 1000)); + collector.onEvent(createEvent(ExecutionEvent.Type.ProjectSucceeded, session, project, null)); + + BuildReport report = collector.buildReport(session); + + assertEquals(1, report.modules().size()); + var module = report.modules().get(0); + assertEquals(1, module.mojos().size(), "skipped mojo should appear in the report"); + var mojoReport = module.mojos().get(0); + assertEquals("test", mojoReport.goal()); + assertEquals(BuildStatus.SKIPPED, mojoReport.status()); + assertEquals(Duration.ZERO, mojoReport.duration()); + assertTrue(mojoReport.output().isEmpty()); + } + + @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 + void testBuildEnvironmentSensitiveKeyRedaction() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + + // Inject user properties with sensitive and non-sensitive keys + Properties userProps = new Properties(); + userProps.setProperty("my.password", "s3cr3t"); + userProps.setProperty("db.secret", "hunter2"); + userProps.setProperty("api.token", "abc123"); + userProps.setProperty("maven.compiler.release", "21"); + session.getRequest().setUserProperties(userProps); + + var env = BuildReportCollector.buildEnvironment(session); + + assertEquals("***", env.userProperties().get("my.password"), "password key must be redacted"); + assertEquals("***", env.userProperties().get("db.secret"), "secret key must be redacted"); + assertEquals("***", env.userProperties().get("api.token"), "token key must be redacted"); + assertEquals("21", env.userProperties().get("maven.compiler.release"), "non-sensitive key must be kept"); + } + + @Test + void testBuildEnvironmentOfflineAndProfiles() { + MavenProject project = createProject("org.example", "my-app", "1.0.0"); + MavenSession session = createSession(project); + session.getRequest().setOffline(true); + session.getRequest().setUpdateSnapshots(true); + session.getRequest().setActiveProfiles(List.of("ci", "!slow-tests")); + session.getRequest().setSelectedProjects(List.of(":my-app")); + session.getRequest().setResumeFrom(":other-module"); + + var env = BuildReportCollector.buildEnvironment(session); + + assertTrue(env.offline()); + assertTrue(env.updateSnapshots()); + assertEquals(List.of("ci", "!slow-tests"), env.activeProfiles()); + assertEquals(List.of(":my-app"), env.selectedProjects()); + assertEquals(":other-module", env.resumeFrom()); + } + + // ---- 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..f75f501fdbb5 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java @@ -0,0 +1,438 @@ +/* + * 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 java.util.Map; + +import org.apache.maven.api.BuildEnvironment; +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 BuildEnvironment EMPTY_ENV = new DefaultBuildEnvironment( + List.of(), Map.of(), Map.of(), "", List.of(), List.of(), null, "FAIL_FAST", false, false, false, false, 1); + 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( + EMPTY_ENV, + 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( + EMPTY_ENV, + 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( + EMPTY_ENV, + 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( + EMPTY_ENV, + 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( + EMPTY_ENV, + 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( + EMPTY_ENV, + 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( + EMPTY_ENV, + 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, thread ID, and sequence number + 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, + 1001L, + null, + null); + + // 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( + EMPTY_ENV, + 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, threadId, and sequenceNumber + assertTrue( + json.contains("\"sourceClassName\": \"com.sun.tools.javac.processing.JavacProcessingEnvironment\""), + "sourceClassName"); + assertTrue(json.contains("\"sourceMethodName\": \"doProcessing\""), "sourceMethodName"); + assertTrue(json.contains("\"threadId\": 42"), "threadId"); + assertTrue(json.contains("\"sequenceNumber\": 1001"), "sequenceNumber"); + // 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( + EMPTY_ENV, + BuildStatus.SUCCESS, + Duration.ofMillis(100), + BASE_TIME, + "4.1.0", + "21", + List.of(), + "com.example:test:1.0", + false, + 1, + List.of(), + List.of(), + List.of(), + List.of()); + + String json = BuildReportJsonWriter.toJson(report); + assertTrue(json.contains("\"problems\": []"), "empty problems array"); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java index 98ecb75b9c74..37471ad261ba 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java @@ -39,6 +39,7 @@ import java.util.stream.Collectors; import org.apache.maven.api.Artifact; +import org.apache.maven.api.BuildEnvironment; import org.apache.maven.api.Constants; import org.apache.maven.api.Lifecycle; import org.apache.maven.api.MonotonicClock; @@ -284,6 +285,75 @@ interface LocalRepoProvider { */ static class DefaultSession extends AbstractSession { + // NOTE: If BuildEnvironment gains new abstract methods, update this constant. + // DefaultBuildEnvironment (maven-core) cannot be used here — module boundary. + private static final BuildEnvironment EMPTY_BUILD_ENVIRONMENT = new BuildEnvironment() { + @Override + public List goals() { + return List.of(); + } + + @Override + public Map userProperties() { + return Map.of(); + } + + @Override + public Map systemInfo() { + return Map.of(); + } + + @Override + public String localRepository() { + return ""; + } + + @Override + public List activeProfiles() { + return List.of(); + } + + @Override + public List selectedProjects() { + return List.of(); + } + + @Override + public String resumeFrom() { + return null; + } + + @Override + public String reactorFailureBehavior() { + return "FAIL_FAST"; + } + + @Override + public boolean offline() { + return false; + } + + @Override + public boolean updateSnapshots() { + return false; + } + + @Override + public boolean noTransferProgress() { + return false; + } + + @Override + public boolean batchMode() { + return false; + } + + @Override + public int threads() { + return 1; + } + }; + private final Map systemProperties; private final Instant startTime = MonotonicClock.now(); private Settings settings; @@ -381,6 +451,13 @@ public int getDegreeOfConcurrency() { return 0; } + @Override + public BuildEnvironment buildEnvironment() { + // ApiRunner is a standalone/embedded session with no MavenExecutionRequest; + // return a minimal environment reflecting defaults. + return EMPTY_BUILD_ENVIRONMENT; + } + @Override public Instant getStartTime() { return startTime; diff --git a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java index 2060d5bb7953..d41c3d6d171d 100644 --- a/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java +++ b/impl/maven-logging/src/main/java/org/apache/maven/slf4j/MavenSimpleLogger.java @@ -125,6 +125,8 @@ protected void writeThrowable(Throwable t, PrintStream stream) { writeThrowable(t, stream::println); } + private static final int MAX_THROWABLE_DEPTH = 20; + protected void writeThrowable(Throwable t, Consumer stream) { if (t == null) { return; @@ -135,10 +137,14 @@ protected void writeThrowable(Throwable t, Consumer stream) { } stream.accept(builder.toString()); - printStackTrace(t, stream, ""); + printStackTrace(t, stream, "", 0); } protected void printStackTrace(Throwable t, Consumer stream, String prefix) { + printStackTrace(t, stream, prefix, 0); + } + + private void printStackTrace(Throwable t, Consumer stream, String prefix, int depth) { MessageBuilder builder = builder(); for (StackTraceElement e : t.getStackTrace()) { builder.a(prefix); @@ -154,16 +160,24 @@ protected void printStackTrace(Throwable t, Consumer stream, String pref stream.accept(builder.toString()); builder.setLength(0); } - for (Throwable se : t.getSuppressed()) { - writeThrowable(se, stream, "Suppressed", prefix + " "); - } - Throwable cause = t.getCause(); - if (cause != null && t != cause) { - writeThrowable(cause, stream, "Caused by", prefix); + if (depth < MAX_THROWABLE_DEPTH) { + for (Throwable se : t.getSuppressed()) { + writeThrowable(se, stream, "Suppressed", prefix + " ", depth + 1); + } + Throwable cause = t.getCause(); + if (cause != null && t != cause) { + writeThrowable(cause, stream, "Caused by", prefix, depth + 1); + } + } else { + stream.accept(prefix + " [...cause/suppressed chain truncated at depth " + MAX_THROWABLE_DEPTH + "]"); } } protected void writeThrowable(Throwable t, Consumer stream, String caption, String prefix) { + writeThrowable(t, stream, caption, prefix, 0); + } + + private void writeThrowable(Throwable t, Consumer stream, String caption, String prefix, int depth) { MessageBuilder builder = builder().a(prefix).strong(caption).a(": ").a(t.getClass().getName()); if (t.getMessage() != null) { @@ -171,7 +185,7 @@ protected void writeThrowable(Throwable t, Consumer stream, String capti } stream.accept(builder.toString()); - printStackTrace(t, stream, prefix); + printStackTrace(t, stream, prefix, depth); } protected String getLocation(final StackTraceElement e) { diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java index 58c23c92031e..587638534414 100644 --- a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java +++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenJulHandlerTest.java @@ -20,6 +20,8 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -113,6 +115,31 @@ void publishIsReentrantSafe() { } } + /** + * Verify that a {@link java.util.logging.Filter} installed on the handler is respected: + * records rejected by the filter must be silently dropped without being forwarded. + * This exercises the {@code isLoggable(record)} guard added to {@link MavenJulHandler#publish}. + */ + @Test + void publishRespectsHandlerFilter() { + MavenJulHandler handler = new MavenJulHandler(); + + // Install a filter that rejects every record, tracking whether it was consulted. + List filtered = new ArrayList<>(); + handler.setFilter(record -> { + filtered.add(record); + return false; // reject all + }); + + LogRecord record = new LogRecord(Level.INFO, "should be filtered"); + // publish() must return without throwing — record is rejected by the filter. + handler.publish(record); + + // The filter must have been consulted exactly once. + assertEquals(1, filtered.size(), "Filter should have been consulted for the published record"); + assertEquals(record, filtered.get(0), "Filter should have been given the exact record"); + } + /** * Invoke the private julLevelToSlf4j method via reflection for testing. */ diff --git a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java index e26ea811830d..d3a3fe49a7e8 100644 --- a/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java +++ b/impl/maven-logging/src/test/java/org/apache/maven/slf4j/MavenSimpleLoggerTest.java @@ -20,9 +20,11 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicReference; import org.apache.maven.jline.MessageUtils; import org.junit.jupiter.api.AfterEach; @@ -31,7 +33,11 @@ import org.junit.jupiter.api.TestInfo; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertLinesMatch; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class MavenSimpleLoggerTest { @@ -91,4 +97,86 @@ void includesCauseAndSuppressedExceptionsWhenWritingThrowables(TestInfo testInfo assertLinesMatch(expectedLines, actualLines); } + + /** + * Verify that when a {@link MavenSimpleLogger.LogSink} is installed, + * {@code write()} routes the event to the sink instead of stdout. + */ + @Test + void writeRoutesToSinkWhenInstalled() { + // Capture sink invocations + List captured = new ArrayList<>(); + MavenSimpleLogger.LogSink sink = (level, loggerName, cleanMessage, formatted, t) -> + captured.add(new Object[] {level, loggerName, cleanMessage, formatted, t}); + + MavenSimpleLogger.setLogSink(sink); + try { + MavenSimpleLogger logger = new MavenSimpleLogger("test.logger"); + logger.info("hello sink"); + + assertEquals(1, captured.size(), "sink should have been called exactly once"); + Object[] call = captured.get(0); + // level corresponds to SLF4J INFO_INT = 20 + assertEquals(org.slf4j.spi.LocationAwareLogger.INFO_INT, call[0]); + assertEquals("test.logger", call[1]); + assertEquals("hello sink", call[2]); + assertNotNull(call[3], "formatted message must not be null"); + assertTrue(((String) call[3]).contains("hello sink"), "formatted must contain the message"); + assertNull(call[4], "no throwable expected"); + } finally { + MavenSimpleLogger.setLogSink(null); + } + } + + /** + * Verify that a deeply nested cause chain is truncated at MAX_THROWABLE_DEPTH + * and that a truncation notice is emitted. + */ + @Test + void writeThrowableTruncatesAtMaxDepth() { + // Build a cause chain of depth 21 — one beyond the limit of 20 + RuntimeException root = new RuntimeException("root"); + RuntimeException current = root; + for (int i = 0; i < 21; i++) { + RuntimeException next = new RuntimeException("cause-" + i); + current.initCause(next); + current = next; + } + + List lines = new ArrayList<>(); + new MavenSimpleLogger("test").writeThrowable(root, lines::add); + + assertTrue( + lines.stream().anyMatch(l -> l.contains("truncated at depth")), + "Expected a truncation notice after reaching MAX_THROWABLE_DEPTH"); + } + + /** + * Verify that when a throwable is present, the sink receives a formatted + * string that includes the exception class and message. + */ + @Test + void writeRoutesThrowableToSink() { + AtomicReference formattedRef = new AtomicReference<>(); + AtomicReference throwableRef = new AtomicReference<>(); + MavenSimpleLogger.LogSink sink = (level, loggerName, cleanMessage, formatted, t) -> { + formattedRef.set(formatted); + throwableRef.set(t); + }; + + MavenSimpleLogger.setLogSink(sink); + try { + MavenSimpleLogger logger = new MavenSimpleLogger("test.logger"); + RuntimeException ex = new RuntimeException("boom"); + logger.error("oops", ex); + + String formatted = formattedRef.get(); + assertNotNull(formatted, "formatted must not be null"); + assertTrue(formatted.contains("oops"), "formatted must contain the log message"); + assertTrue(formatted.contains("java.lang.RuntimeException"), "formatted must contain the exception class"); + assertEquals(ex, throwableRef.get(), "sink must receive the original throwable"); + } finally { + MavenSimpleLogger.setLogSink(null); + } + } } diff --git a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java index 6299fa8abe95..93b191716d83 100644 --- a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java +++ b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java @@ -28,6 +28,7 @@ import org.apache.maven.api.Artifact; import org.apache.maven.api.ArtifactCoordinates; +import org.apache.maven.api.BuildEnvironment; import org.apache.maven.api.Dependency; import org.apache.maven.api.DependencyCoordinates; import org.apache.maven.api.DependencyScope; @@ -68,6 +69,75 @@ */ public class SessionStub implements Session { + // NOTE: If BuildEnvironment gains new abstract methods, update this constant + // (same as ApiRunner.EMPTY_BUILD_ENVIRONMENT — keep both in sync). + private static final BuildEnvironment EMPTY_BUILD_ENVIRONMENT = new BuildEnvironment() { + @Override + public java.util.List goals() { + return java.util.List.of(); + } + + @Override + public java.util.Map userProperties() { + return java.util.Map.of(); + } + + @Override + public java.util.Map systemInfo() { + return java.util.Map.of(); + } + + @Override + public String localRepository() { + return ""; + } + + @Override + public java.util.List activeProfiles() { + return java.util.List.of(); + } + + @Override + public java.util.List selectedProjects() { + return java.util.List.of(); + } + + @Override + public String resumeFrom() { + return null; + } + + @Override + public String reactorFailureBehavior() { + return "FAIL_FAST"; + } + + @Override + public boolean offline() { + return false; + } + + @Override + public boolean updateSnapshots() { + return false; + } + + @Override + public boolean noTransferProgress() { + return false; + } + + @Override + public boolean batchMode() { + return false; + } + + @Override + public int threads() { + return 1; + } + }; + private Map userProperties; private Map systemProperties; @@ -153,6 +223,11 @@ public SessionData getData() { return null; } + @Override + public BuildEnvironment buildEnvironment() { + return EMPTY_BUILD_ENVIRONMENT; + } + @Override public Version getMavenVersion() { return null; From 27e1395a527a6b42e85016159b8df6495496a504 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 23 Sep 2026 10:31:02 +0000 Subject: [PATCH 03/16] [MNG-12537] reactor.xml: named build aliases and lifecycle execution filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces two features that build on top of the logging foundation and structured build report (MNG-XXXXX / PR #12695): 1. reactor.xml — a single .mvn/reactor.xml file that consolidates CLI options (replaces .mvn/maven.config), core extensions (replaces .mvn/extensions.xml), named build aliases, and lifecycle phase injections. Legacy files continue to be read when reactor.xml is absent — full backwards compatibility is preserved. 2. Named build aliases — short names defined in reactor.xml that expand to a full argument list before any Maven processing starts. Expansion is non-recursive; flag-style aliases (--skip-tests) are supported; blank alias names are rejected at expansion time. 3. Lifecycle execution filter — a maven.lifecycle.filter user property that skips mojo executions matching a coordinate or phase predicate (e.g. phase(test),:maven-enforcer-plugin). Applied in MojoExecutor before each execution; skipped mojos fire a MojoSkipped event. --- .../apache/maven/api/cli/InvokerRequest.java | 15 ++ api/maven-api-core/pom.xml | 36 +++ .../apache/maven/api/BuildEnvironment.java | 16 ++ .../java/org/apache/maven/api/Constants.java | 8 + api/maven-api-core/src/main/mdo/reactor.mdo | 237 +++++++++++++++++ impl/maven-cli/pom.xml | 20 ++ .../cling/invoker/BaseInvokerRequest.java | 11 +- .../maven/cling/invoker/BaseParser.java | 73 +++++- .../cling/invoker/mvn/ArgumentTokenizer.java | 97 +++++++ .../maven/cling/invoker/mvn/MavenInvoker.java | 7 + .../maven/cling/invoker/mvn/MavenParser.java | 125 ++++++++- .../invoker/mvn/ArgumentTokenizerTest.java | 103 ++++++++ .../invoker/mvn/MavenParserAliasTest.java | 130 ++++++++++ .../java/org/apache/maven/DefaultMaven.java | 11 +- .../internal/build/BuildReportCollector.java | 62 +++-- .../build/DefaultBuildEnvironment.java | 10 +- .../internal/build/DefaultBuildReport.java | 33 +-- .../internal/build/DefaultModuleReport.java | 11 +- .../internal/build/DefaultMojoReport.java | 5 +- .../internal/impl/DefaultMojoExecution.java | 9 + .../maven/internal/impl/DefaultSession.java | 12 +- ...faultLifecycleExecutionPlanCalculator.java | 1 + .../lifecycle/internal/MojoExecutor.java | 26 +- .../internal/filter/CoordinatePredicate.java | 241 ++++++++++++++++++ .../internal/filter/FilterPredicate.java | 42 +++ .../internal/filter/MojoExecutionFilter.java | 110 ++++++++ .../internal/filter/PhasePredicate.java | 49 ++++ .../maven/project/DefaultProjectBuilder.java | 16 +- .../AbstractCoreMavenComponentTestCase.java | 9 + .../DefaultMavenProfileValidationTest.java | 74 ++++++ .../build/BuildReportJsonWriterTest.java | 15 +- .../filter/CoordinatePredicateTest.java | 165 ++++++++++++ .../filter/MojoExecutionFilterTest.java | 118 +++++++++ .../internal/filter/PhasePredicateTest.java | 66 +++++ .../WorkspaceParentProjectBuilderTest.java | 141 ++++++++++ .../MavenITgh10210SettingsXmlDecryptTest.java | 8 +- ...MavenITgh11978PlaceholderInCliArgTest.java | 3 +- ...h12507CliParamNestedInterpolationTest.java | 4 +- ...5BomConsumerPomPropertyResolutionTest.java | 4 +- ...MavenITgh12640BomInheritedDepMgmtTest.java | 6 +- ...Tgh12660BomVersionFromImportedBomTest.java | 3 +- ...13004ConsumerPomProfileArtifactIdTest.java | 8 +- ...0ClassifiedPomArtifactFromReactorTest.java | 3 +- ...venITmng12535LifecycleFilterPhaseTest.java | 62 +++++ ...mng12536LifecycleFilterCoordinateTest.java | 62 +++++ .../MavenITmng12537ReactorXmlAliasTest.java | 52 ++++ .../it/MavenITmng8655SettingsParserTest.java | 40 ++- ...ITmng8709ProfileDependencyVersionTest.java | 3 +- .../apache/maven/it/TestSuiteOrdering.java | 6 +- .../bom/pom.xml | 4 +- .../mod-1/pom.xml | 4 +- .../pom.xml | 14 +- .../pom.xml | 2 - .../bom/pom.xml | 4 +- .../mod-1/pom.xml | 4 +- .../mod-2/pom.xml | 4 +- .../gh-12640-bom-inherited-depmgmt/pom.xml | 16 +- .../module-a/src/main/java/a/A.java | 18 ++ .../module-b/src/main/java/b/B.java | 18 ++ .../module-c/src/main/java/c/C.java | 18 ++ .../module-d/src/main/java/d/D.java | 18 ++ .../bom/pom.xml | 6 +- .../ext-bom/pom.xml | 4 +- .../mod-1/pom.xml | 4 +- .../mod-2/pom.xml | 4 +- .../pom.xml | 4 +- .../pom.xml | 8 +- .../pom.xml | 10 +- .../org/apache/maven/its/gh13004/Child.java | 18 ++ .../org/apache/maven/its/gh13004/Lib.java | 18 ++ .../client/pom.xml | 6 +- .../extension/pom.xml | 6 +- .../apache/maven/its/mng12534/TouchMojo.java | 1 - .../mng-12535-lifecycle-filter-phase/pom.xml | 17 ++ .../apache/maven/its/mng12535/SampleTest.java | 30 +++ .../pom.xml | 9 + .../apache/maven/its/mng12536/SampleTest.java | 30 +++ .../.mvn/reactor.xml | 10 + .../mng-12537-reactor-xml-alias/pom.xml | 9 + .../apache/maven/its/it0124/LookupMojo.java | 4 +- .../org/apache/maven/its/mng3652/MyMojo.java | 5 +- .../pom.xml | 22 +- .../child/pom.xml | 6 +- .../cli-property/pom.xml | 6 +- .../pom.xml | 14 +- .../runtime-property/pom.xml | 6 +- .../mvnup-toolchain-plugin-strategy/pom.xml | 21 +- .../pom.xml | 4 - its/pom.xml | 5 - pom.xml | 5 +- src/mdo/reader-stax.vm | 7 +- 91 files changed, 2530 insertions(+), 261 deletions(-) create mode 100644 api/maven-api-core/src/main/mdo/reactor.mdo create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizer.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizerTest.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenParserAliasTest.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/DefaultMavenProfileValidationTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/project/WorkspaceParentProjectBuilderTest.java create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12537ReactorXmlAliasTest.java create mode 100644 its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml create mode 100644 its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java create mode 100644 its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml create mode 100644 its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java create mode 100644 its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/.mvn/reactor.xml create mode 100644 its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/pom.xml diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/InvokerRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/InvokerRequest.java index e63a515bb9ff..f88254b86b50 100644 --- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/InvokerRequest.java +++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/InvokerRequest.java @@ -29,6 +29,7 @@ import org.apache.maven.api.annotations.Immutable; import org.apache.maven.api.annotations.Nonnull; import org.apache.maven.api.cli.cisupport.CIInfo; +import org.apache.maven.api.reactor.ReactorConfig; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.MessageBuilderFactory; @@ -192,6 +193,20 @@ default Optional stdErr() { @Nonnull Optional ciInfo(); + /** + * Returns the parsed {@code .mvn/reactor.xml} configuration, if present. + * + *

The configuration is parsed once during invocation setup (pre-DI) and reused downstream. + * When {@code reactor.xml} is absent, this is empty and the legacy {@code maven.config} / + * {@code extensions.xml} files remain in use. + * + * @return an {@link Optional} containing the {@link ReactorConfig}, or empty if {@code reactor.xml} + * was not found + * @since 4.1.0 + */ + @Nonnull + Optional reactorConfig(); + /** * Returns the options associated with this invocation request. * diff --git a/api/maven-api-core/pom.xml b/api/maven-api-core/pom.xml index 339ea8319217..93afd1ce9394 100644 --- a/api/maven-api-core/pom.xml +++ b/api/maven-api-core/pom.xml @@ -72,4 +72,40 @@ + + + + org.codehaus.modello + modello-maven-plugin + + + modello-reactor + + velocity + xdoc + xsd + + generate-sources + + 1.0.0 + + src/main/mdo/reactor.mdo + + + + + + locationTracking=false + generateLocationClasses=false + packageModelV4=org.apache.maven.api.reactor + packageToolV4=org.apache.maven.internal.reactor.io + + ${project.basedir}/../../src/mdo + + + + + + + diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java index 37d6dc596a6e..8b62214dd595 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/BuildEnvironment.java @@ -20,10 +20,12 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import org.apache.maven.api.annotations.Experimental; import org.apache.maven.api.annotations.Immutable; import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.reactor.ReactorConfig; /** * Describes the invocation context of a Maven build: the flags, properties, and @@ -200,4 +202,18 @@ public interface BuildEnvironment { * @return the thread count */ int threads(); + + /** + * The parsed {@code .mvn/reactor.xml} configuration for this build, if present. + * + *

This provides access to the reactor-scoped options, extensions, aliases and + * phase injections declared in the project's {@code .mvn/reactor.xml} file. + * Returns {@link Optional#empty()} when no {@code reactor.xml} was found or parsed. + * + * @return the reactor configuration, never {@code null} + */ + @Nonnull + default Optional reactorConfig() { + return Optional.empty(); + } } diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java index 117d9f11878d..74a46d3ba78a 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java @@ -868,5 +868,13 @@ public final class Constants { @Config(type = "java.lang.Boolean", defaultValue = "false") public static final String MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL = "maven.model.dependencyInterpolation.full"; + /** + * Comma-separated list of mojo execution filter predicates. Matching executions are skipped at runtime. + * Supported forms: {@code *}, {@code :A}, {@code G:A}, {@code P}, {@code P:v:g}, {@code P:v:g@e}, + * {@code phase(name)}. + */ + @Config + public static final String MAVEN_LIFECYCLE_FILTER = "maven.lifecycle.filter"; + private Constants() {} } diff --git a/api/maven-api-core/src/main/mdo/reactor.mdo b/api/maven-api-core/src/main/mdo/reactor.mdo new file mode 100644 index 000000000000..6dda6a86ea1f --- /dev/null +++ b/api/maven-api-core/src/main/mdo/reactor.mdo @@ -0,0 +1,237 @@ + + + + + + + reactor + ReactorConfig + This is a reference for the Reactor Configuration descriptor.

+

The default location for the Reactor Configuration descriptor file is ${maven.projectBasedir}/.mvn/reactor.xml

+

It consolidates reactor-scoped build configuration previously split across .mvn/maven.config + (CLI flag defaults) and .mvn/extensions.xml (core extensions), and adds support for + named build aliases and reactor-level lifecycle phase injection.

+ ]]> + + + + package + org.apache.maven.api.reactor + + + + + + ReactorConfig + Reactor-scoped build configuration for a Maven project. + 1.0.0+ + + + options + CLI options to prepend to every invocation. Replaces .mvn/maven.config when present. + Expressed as whitespace-separated, quote-aware inline text (e.g. {@code -T4 --no-transfer-progress}). + For options with spaces in their values, use the structured form via {@link #getOptionArgs()}. + 1.0.0+ + false + String + + + optionArgs + Structured CLI options — each <arg> element is one option token. + Use instead of {@link #getOptions()} when option values contain spaces. + Mutually exclusive with options. + 1.0.0+ + false + + String + * + + + + extensions + Core extensions to load. Replaces .mvn/extensions.xml when present. + 1.0.0+ + false + + ReactorExtension + * + + + + aliases + Named build aliases. The first non-flag argument on the command line is matched against + alias names; if found it is replaced by the alias expansion. + 1.0.0+ + false + + Alias + * + + + + phases + Custom lifecycle phase injections. Phases are registered in the lifecycle DAG + with the declared after/before edges. + 1.0.0+ + false + + PhaseInjection + * + + + + + + + ReactorExtension + Describes a core extension to load. + 1.0.0+ + + + groupId + The group ID of the extension's artifact. + 1.0.0+ + true + String + + + artifactId + The artifact ID of the extension. + 1.0.0+ + true + String + + + version + The version of the extension. + 1.0.0+ + true + String + + + classLoadingStrategy + The class loading strategy: 'self-first' (the default), 'parent-first' (loads classes from the + parent, then from the extension) or 'plugin' (follows the rules from extensions defined as plugins). + 1.0.0+ + self-first + false + String + + + configuration + Extension-specific configuration passed to the extension during initialization. + The structure is extension-defined. + 1.0.0+ + false + DOM + + + + + 1.0.0+ + + ::}, never {@code null}. + */ + public String getId() { + return (getGroupId() == null ? "[unknown-group-id]" : getGroupId()) + + ":" + (getArtifactId() == null ? "[unknown-artifact-id]" : getArtifactId()) + + ":" + (getVersion() == null ? "[unknown-version]" : getVersion()); + } + ]]> + + + + + + + Alias + A named build alias. When the first non-flag argument matches the alias name, + it is replaced by the alias expansion. Supports both inline text (shorthand, whitespace-separated) + and structured <arg> elements for values that contain spaces. + 1.0.0+ + + + name + The alias name. Must not start with '-'. + 1.0.0+ + true + String + + + content + Inline text form: whitespace-separated arguments, quote-aware. + Mutually exclusive with args. + 1.0.0+ + false + String + + + args + Structured form: each <arg> element is one argument token. + Use when argument values contain spaces. Mutually exclusive with content. + 1.0.0+ + false + + String + * + + + + + + + PhaseInjection + A custom phase to inject into the lifecycle DAG. + 1.0.0+ + + + name + The name of the new phase to inject. + 1.0.0+ + true + String + + + after + The name of an existing phase that this phase must come after. + 1.0.0+ + false + String + + + before + The name of an existing phase that this phase must come before. + 1.0.0+ + false + String + + + + + diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml index 56ac62ed6f3e..8c8da1362208 100644 --- a/impl/maven-cli/pom.xml +++ b/impl/maven-cli/pom.xml @@ -286,6 +286,26 @@ under the License. velocity + + modello-reactor + + velocity + + + 1.0.0 + + ../../api/maven-api-core/src/main/mdo/reactor.mdo + + + + + + packageModelV4=org.apache.maven.api.reactor + packageToolV4=org.apache.maven.cling.internal.reactor.io + locationTracking=false + + + diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseInvokerRequest.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseInvokerRequest.java index dcb6d2cacaf6..41bac30eb7a7 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseInvokerRequest.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseInvokerRequest.java @@ -30,6 +30,7 @@ import org.apache.maven.api.cli.Options; import org.apache.maven.api.cli.ParserRequest; import org.apache.maven.api.cli.cisupport.CIInfo; +import org.apache.maven.api.reactor.ReactorConfig; import static java.util.Objects.requireNonNull; @@ -46,6 +47,7 @@ public class BaseInvokerRequest implements InvokerRequest { private final List coreExtensions; private final CIInfo ciInfo; private final Options options; + private final ReactorConfig reactorConfig; @SuppressWarnings("ParameterNumber") public BaseInvokerRequest( @@ -60,7 +62,8 @@ public BaseInvokerRequest( @Nullable Path rootDirectory, @Nullable List coreExtensions, @Nullable CIInfo ciInfo, - @Nullable Options options) { + @Nullable Options options, + @Nullable ReactorConfig reactorConfig) { this.parserRequest = requireNonNull(parserRequest); this.parsingFailed = parsingFailed; this.cwd = requireNonNull(cwd); @@ -74,6 +77,7 @@ public BaseInvokerRequest( this.coreExtensions = coreExtensions; this.ciInfo = ciInfo; this.options = options; + this.reactorConfig = reactorConfig; } @Override @@ -134,4 +138,9 @@ public Optional ciInfo() { public Optional options() { return Optional.ofNullable(options); } + + @Override + public Optional reactorConfig() { + return Optional.ofNullable(reactorConfig); + } } diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseParser.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseParser.java index 9a740753bb0f..3e11659ab0de 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseParser.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/BaseParser.java @@ -47,8 +47,10 @@ import org.apache.maven.api.cli.extensions.CoreExtension; import org.apache.maven.api.cli.extensions.InputLocation; import org.apache.maven.api.cli.extensions.InputSource; +import org.apache.maven.api.reactor.ReactorConfig; import org.apache.maven.api.services.Interpolator; import org.apache.maven.cling.internal.extension.io.CoreExtensionsStaxReader; +import org.apache.maven.cling.internal.reactor.io.ReactorConfigStaxReader; import org.apache.maven.cling.invoker.cisupport.CIDetectorHelper; import org.apache.maven.cling.props.MavenPropertiesLoader; import org.apache.maven.cling.utils.CLIReportingUtils; @@ -95,6 +97,9 @@ public LocalContext(ParserRequest parserRequest) { @Nullable public Options options; + @Nullable + public ReactorConfig reactorConfig; + public Map extraInterpolationSource() { Map extra = new HashMap<>(); extra.put("session.topDirectory", topDirectory.toString()); @@ -150,6 +155,14 @@ public InvokerRequest parseInvocation(ParserRequest parserRequest) { parserRequest.logger().error("Error determining root directory", e); } + // reactor.xml — read before option parsing so MavenParser can do alias expansion + try { + context.reactorConfig = readReactorConfig(context); + } catch (Exception e) { + context.parsingFailed = true; + parserRequest.logger().error("Error reading reactor configuration", e); + } + // options try { context.options = parseCliOptions(context); @@ -158,7 +171,6 @@ public InvokerRequest parseInvocation(ParserRequest parserRequest) { context.options = null; parserRequest.logger().error("Error parsing program arguments", e); } - // system and user properties try { context.systemProperties = populateSystemProperties(context); @@ -265,7 +277,8 @@ protected InvokerRequest getInvokerRequest(LocalContext context) { context.rootDirectory, context.extensions, context.ciInfo, - context.options); + context.options, + context.reactorConfig); } protected Path getCwd(LocalContext context) { @@ -478,6 +491,32 @@ protected Map populateUserProperties(LocalContext context) { protected abstract Options parseCliOptions(LocalContext context); + /** + * Reads {@code .mvn/reactor.xml} from the project root directory, if present. + * + *

The result is stored on {@link LocalContext#reactorConfig} before {@link #parseCliOptions(LocalContext)} + * is called, so that {@code MavenParser} can perform alias expansion using the parsed config. + * + * @param context the current local context + * @return the parsed {@link ReactorConfig}, or {@code null} if {@code .mvn/reactor.xml} was not found + */ + @Nullable + protected ReactorConfig readReactorConfig(LocalContext context) { + Path dir = context.rootDirectory != null ? context.rootDirectory : context.topDirectory; + if (dir == null) { + return null; + } + Path reactorXml = dir.resolve(".mvn/reactor.xml"); + if (!Files.isRegularFile(reactorXml)) { + return null; + } + try (InputStream is = Files.newInputStream(reactorXml)) { + return new ReactorConfigStaxReader().read(is, true, null); + } catch (XMLStreamException | IOException e) { + throw new IllegalArgumentException("Failed to parse reactor configuration file: " + reactorXml, e); + } + } + /** * Important: This method must return list of {@link CoreExtensions} in precedence order. */ @@ -489,11 +528,31 @@ protected List readCoreExtensionsDescriptor(LocalContext context Map eff = new HashMap<>(context.systemProperties); eff.putAll(context.userProperties); - // project - file = context.cwd.resolve(eff.get(Constants.MAVEN_PROJECT_EXTENSIONS)); - loaded = readCoreExtensionsDescriptorFromFile(file, false); - if (!loaded.isEmpty()) { - result.add(new CoreExtensions(file, loaded)); + // project extensions: use reactor.xml when present, otherwise extensions.xml + if (context.reactorConfig != null + && context.reactorConfig.getExtensions() != null + && !context.reactorConfig.getExtensions().isEmpty()) { + // reactor.xml is authoritative for project extensions + Path dir = context.rootDirectory != null ? context.rootDirectory : context.topDirectory; + Path reactorXml = dir != null ? dir.resolve(".mvn/reactor.xml") : null; + loaded = context.reactorConfig.getExtensions().stream() + .map(re -> org.apache.maven.api.cli.extensions.CoreExtension.newBuilder() + .groupId(re.getGroupId()) + .artifactId(re.getArtifactId()) + .version(re.getVersion()) + .classLoadingStrategy(re.getClassLoadingStrategy()) + .build()) + .collect(Collectors.toList()); + if (reactorXml != null && !loaded.isEmpty()) { + result.add(new CoreExtensions(reactorXml, loaded)); + } + } else { + // project extensions.xml (legacy) + file = context.cwd.resolve(eff.get(Constants.MAVEN_PROJECT_EXTENSIONS)); + loaded = readCoreExtensionsDescriptorFromFile(file, false); + if (!loaded.isEmpty()) { + result.add(new CoreExtensions(file, loaded)); + } } // user diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizer.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizer.java new file mode 100644 index 000000000000..7207014653a7 --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizer.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvn; + +import java.util.ArrayList; +import java.util.List; + +/** + * Quote-aware argument tokenizer for the shorthand form of {@code } and {@code } elements + * in {@code .mvn/reactor.xml}. + * + *

The algorithm is identical to {@code JvmConfigParser.parseArguments()} (the single-file source launcher + * used by shell scripts to parse {@code .mvn/jvm.config}): + *

    + *
  • Whitespace is the delimiter outside quotes.
  • + *
  • {@code "..."} and {@code '...'} are both supported — quotes are stripped, content is kept as a + * single token.
  • + *
  • Outside single-quotes, a backslash ({@code \}) escapes the next character literally + * (e.g. {@code \"} → {@code "}, {@code \\} → {@code \}, {@code \ } → a literal space). + * Inside single-quotes, backslash has no special meaning.
  • + *
+ * + *

Note: {@code JvmConfigParser.java} is a self-contained single-file source launcher + * invoked directly by shell scripts; it must not share classpath code with Maven. This class is an + * independent re-implementation of the same algorithm for use within Maven's Java classpath. + * + * @since 4.1.0 + */ +public final class ArgumentTokenizer { + + private ArgumentTokenizer() { + // utility class + } + + /** + * Splits a string into a list of argument tokens using quote-aware whitespace splitting. + * + * @param input the input string to tokenize, must not be {@code null} + * @return an unmodifiable list of token strings; empty if the input contains only whitespace + */ + public static List tokenize(String input) { + List args = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inDoubleQuotes = false; + boolean inSingleQuotes = false; + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + + if (c == '\\' && !inSingleQuotes && i + 1 < input.length()) { + // Backslash escape: outside single-quotes, \X is always a literal X. + // This matches POSIX behaviour in both unquoted and double-quoted contexts. + current.append(input.charAt(++i)); + } else if (c == '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + } else if (c == '\'' && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + } else if (Character.isWhitespace(c) && !inDoubleQuotes && !inSingleQuotes) { + // Whitespace outside quotes — end of current token + if (current.length() > 0) { + args.add(current.toString()); + current.setLength(0); + } + } else { + current.append(c); + } + } + + // Add last token if any + if (current.length() > 0) { + args.add(current.toString()); + } + + if (inDoubleQuotes || inSingleQuotes) { + throw new IllegalArgumentException( + "Unclosed " + (inDoubleQuotes ? "double" : "single") + " quote in: " + input); + } + + return List.copyOf(args); + } +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java index 0cf448e32562..9fc53dca5311 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenInvoker.java @@ -39,6 +39,7 @@ import org.apache.maven.api.cli.InvokerRequest; import org.apache.maven.api.cli.Logger; import org.apache.maven.api.cli.mvn.MavenOptions; +import org.apache.maven.api.reactor.ReactorConfig; import org.apache.maven.api.services.BuilderProblem; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.Sources; @@ -305,6 +306,12 @@ protected void populateRequest(MavenContext context, Lookup lookup, MavenExecuti if (context.options().builder().isPresent()) { request.setBuilderId(context.options().builder().get()); } + + // Store ReactorConfig on the request data map so it is accessible to other components + // (e.g. future ReactorXmlPhaseInjector) without adding a dependency on maven-cli. + context.invokerRequest + .reactorConfig() + .ifPresent(rc -> request.getData().put(ReactorConfig.class.getName(), rc)); } protected Path determinePom(MavenContext context, Lookup lookup) { diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java index b767377fac6a..b88a19475e97 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java @@ -23,21 +23,30 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import org.apache.commons.cli.ParseException; import org.apache.maven.api.cli.Options; import org.apache.maven.api.cli.mvn.MavenOptions; +import org.apache.maven.api.reactor.Alias; +import org.apache.maven.api.reactor.ReactorConfig; import org.apache.maven.cling.invoker.BaseParser; public class MavenParser extends BaseParser { @Override protected Options parseCliOptions(LocalContext context) { ArrayList result = new ArrayList<>(); - // CLI args - MavenOptions cliOptions = parseMavenCliOptions(context.parserRequest.args()); + + // Expand alias from reactor.xml before parsing, if applicable + List effectiveArgs = expandAlias(context.parserRequest.args(), context.reactorConfig); + + // CLI args (after alias expansion) + MavenOptions cliOptions = parseMavenCliOptions(effectiveArgs); result.add(cliOptions); + // atFile option if (cliOptions.atFile().isPresent()) { Path file = context.cwd.resolve(cliOptions.atFile().orElseThrow()); @@ -47,14 +56,103 @@ protected Options parseCliOptions(LocalContext context) { throw new IllegalArgumentException("Specified file does not exists (" + file + ")"); } } - // maven.config; if exists - Path mavenConfig = context.rootDirectory != null ? context.rootDirectory.resolve(".mvn/maven.config") : null; - if (mavenConfig != null && Files.isRegularFile(mavenConfig)) { - result.add(parseMavenConfigOptions(mavenConfig)); + + // reactor.xml — used instead of maven.config when reactor.xml is present + if (context.reactorConfig != null) { + List optionArgs = resolveReactorOptions(context.reactorConfig); + if (!optionArgs.isEmpty()) { + result.add(parseMavenReactorOptions(optionArgs)); + } + } else { + // legacy maven.config; if exists + Path mavenConfig = + context.rootDirectory != null ? context.rootDirectory.resolve(".mvn/maven.config") : null; + if (mavenConfig != null && Files.isRegularFile(mavenConfig)) { + result.add(parseMavenConfigOptions(mavenConfig)); + } } + return LayeredMavenOptions.layerMavenOptions(result); } + /** + * Resolves the {@code } or {@code } block of a {@link ReactorConfig} + * to a flat list of argument strings. + * + *

When the config has structured {@code } children ({@code optionArgs}), those are used as-is. + * When it has inline text content ({@code options}), it is tokenized with {@link ArgumentTokenizer}. + */ + private static List resolveReactorOptions(ReactorConfig rc) { + List optionArgs = rc.getOptionArgs(); + if (optionArgs != null && !optionArgs.isEmpty()) { + return optionArgs; + } + String options = rc.getOptions(); + if (options != null && !options.isBlank()) { + return ArgumentTokenizer.tokenize(options); + } + return List.of(); + } + + /** + * Expands alias arguments in {@code args} using aliases defined in {@code reactorConfig}. + * + *

Alias expansion is a pure string manipulation — it happens before any Maven DI or session setup. + * The argument list is rebuilt on the fly: each argument is checked against the alias map in order; + * if it matches an alias name, it is replaced by the alias expansion tokens; otherwise it is kept as-is. + * Expansion is not recursive — tokens produced by an alias are passed through as-is. + * + * @param args original argument list + * @param reactorConfig parsed reactor config, or {@code null} if reactor.xml is absent + * @return the (possibly modified) argument list + */ + static List expandAlias(List args, ReactorConfig reactorConfig) { + if (reactorConfig == null + || reactorConfig.getAliases() == null + || reactorConfig.getAliases().isEmpty()) { + return args; + } + + // Build a name → expansion map for O(1) lookup per arg + Map> aliasMap = new LinkedHashMap<>(); + for (Alias alias : reactorConfig.getAliases()) { + String name = alias.getName(); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Alias name must not be blank in reactor.xml"); + } + aliasMap.put(name, resolveAlias(alias)); + } + + // Rebuild the arg list, expanding every arg that matches an alias + List expanded = null; // allocated lazily — avoids a copy when nothing matches + for (int i = 0; i < args.size(); i++) { + String arg = args.get(i); + List expansion = aliasMap.get(arg); + if (expansion != null) { + if (expanded == null) { + expanded = new ArrayList<>(args.subList(0, i)); + } + expanded.addAll(expansion); + } else if (expanded != null) { + expanded.add(arg); + } + } + + return expanded != null ? List.copyOf(expanded) : args; + } + + private static List resolveAlias(Alias alias) { + List args = alias.getArgs(); + if (args != null && !args.isEmpty()) { + return args; + } + String content = alias.getContent(); + if (content != null && !content.isBlank()) { + return ArgumentTokenizer.tokenize(content); + } + return List.of(); + } + protected MavenOptions parseMavenCliOptions(List args) { try { return parseArgs(Options.SOURCE_CLI, args); @@ -76,6 +174,21 @@ protected MavenOptions parseMavenAtFileOptions(Path atFile) { } } + protected MavenOptions parseMavenReactorOptions(List args) { + try { + MavenOptions options = parseArgs("reactor.xml", args); + if (options.goals().isPresent()) { + // can only contain options, not goals/phases + throw new IllegalArgumentException("Unrecognized entries in reactor.xml : " + + options.goals().get()); + } + return options; + } catch (ParseException e) { + throw new IllegalArgumentException( + "Failed to parse arguments from reactor.xml : " + e.getMessage(), e.getCause()); + } + } + protected MavenOptions parseMavenConfigOptions(Path configFile) { try (Stream lines = Files.lines(configFile, StandardCharsets.UTF_8)) { List args = diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizerTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizerTest.java new file mode 100644 index 000000000000..223908060370 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ArgumentTokenizerTest.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvn; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ArgumentTokenizerTest { + + @Test + void emptyInput() { + assertEquals(List.of(), ArgumentTokenizer.tokenize("")); + assertEquals(List.of(), ArgumentTokenizer.tokenize(" ")); + } + + @Test + void simpleTokens() { + assertEquals(List.of("clean", "install"), ArgumentTokenizer.tokenize("clean install")); + } + + @Test + void doubleQuotes() { + assertEquals(List.of("-Dfoo=hello world"), ArgumentTokenizer.tokenize("\"-Dfoo=hello world\"")); + } + + @Test + void singleQuotes() { + assertEquals(List.of("-Dfoo=hello world"), ArgumentTokenizer.tokenize("'-Dfoo=hello world'")); + } + + @Test + void backslashEscapeSpace() { + assertEquals(List.of("-Dfoo=hello world"), ArgumentTokenizer.tokenize("-Dfoo=hello\\ world")); + } + + @Test + void backslashEscapeDoubleQuote() { + assertEquals(List.of("-Dfoo=say \"hi\""), ArgumentTokenizer.tokenize("-Dfoo=say\\ \\\"hi\\\"")); + } + + @Test + void backslashEscapeInsideDoubleQuotes() { + // \" inside double-quotes produces a literal " + assertEquals(List.of("a\"b"), ArgumentTokenizer.tokenize("\"a\\\"b\"")); + } + + @Test + void backslashNoSpecialInsideSingleQuotes() { + // Inside single-quotes, backslash is literal — \n stays as two chars + assertEquals(List.of("a\\nb"), ArgumentTokenizer.tokenize("'a\\nb'")); + } + + @Test + void backslashEscapeBackslash() { + assertEquals(List.of("a\\b"), ArgumentTokenizer.tokenize("a\\\\b")); + } + + @Test + void mixedQuotes() { + assertEquals(List.of("-Da=foo bar", "-Db=baz"), ArgumentTokenizer.tokenize("\"-Da=foo bar\" '-Db=baz'")); + } + + @Test + void extraWhitespace() { + assertEquals(List.of("a", "b", "c"), ArgumentTokenizer.tokenize(" a b c ")); + } + + @Test + void unclosedDoubleQuoteThrows() { + assertThrows( + IllegalArgumentException.class, + () -> ArgumentTokenizer.tokenize("clean \"install"), + "Unclosed double quote must throw"); + } + + @Test + void unclosedSingleQuoteThrows() { + assertThrows( + IllegalArgumentException.class, + () -> ArgumentTokenizer.tokenize("clean 'install"), + "Unclosed single quote must throw"); + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenParserAliasTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenParserAliasTest.java new file mode 100644 index 000000000000..36bfb20c8552 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/MavenParserAliasTest.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvn; + +import java.util.List; + +import org.apache.maven.api.reactor.Alias; +import org.apache.maven.api.reactor.ReactorConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MavenParserAliasTest { + + // --- helpers --- + + private static Alias alias(String name, String... expansion) { + return Alias.newBuilder().name(name).args(List.of(expansion)).build(); + } + + private static ReactorConfig config(Alias... aliases) { + return ReactorConfig.newBuilder().aliases(List.of(aliases)).build(); + } + + // --- null / empty guards --- + + @Test + void nullReactorConfigReturnsOriginal() { + List args = List.of("clean", "install"); + assertSame(args, MavenParser.expandAlias(args, null)); + } + + @Test + void emptyAliasesReturnsOriginal() { + List args = List.of("clean", "install"); + assertSame( + args, MavenParser.expandAlias(args, ReactorConfig.newBuilder().build())); + } + + // --- no match --- + + @Test + void noMatchReturnsOriginal() { + List args = List.of("-B", "clean", "install"); + ReactorConfig rc = config(alias("ci", "clean", "install", "-Prelease")); + assertSame(args, MavenParser.expandAlias(args, rc)); + } + + // --- single expansion --- + + @Test + void singleAliasExpanded() { + ReactorConfig rc = config(alias("ci", "clean", "install", "-Prelease")); + assertEquals(List.of("-B", "clean", "install", "-Prelease"), MavenParser.expandAlias(List.of("-B", "ci"), rc)); + } + + // --- multiple expansions in one invocation --- + + @Test + void multipleAliasesAllExpanded() { + ReactorConfig rc = config(alias("ci", "clean", "install"), alias("rel", "-Prelease", "deploy")); + assertEquals( + List.of("clean", "install", "-Prelease", "deploy"), MavenParser.expandAlias(List.of("ci", "rel"), rc)); + } + + @Test + void aliasAmongFlagsExpandedInPlace() { + ReactorConfig rc = config(alias("ci", "clean", "install")); + assertEquals( + List.of("-B", "clean", "install", "-Prelease"), + MavenParser.expandAlias(List.of("-B", "ci", "-Prelease"), rc)); + } + + @Test + void flagAliasExpanded() { + // An alias named "--skip-tests" can be defined and will be expanded + ReactorConfig rc = + config(alias("--skip-tests", "-Dmaven.lifecycle.filter=phase(test),phase(integration-test)")); + assertEquals( + List.of("-B", "-Dmaven.lifecycle.filter=phase(test),phase(integration-test)"), + MavenParser.expandAlias(List.of("-B", "--skip-tests"), rc)); + } + + // --- non-recursive --- + + @Test + void expansionIsNotRecursive() { + // "ci" expands to ["fast"], "fast" is also an alias — but should NOT be re-expanded + ReactorConfig rc = config(alias("ci", "fast"), alias("fast", "clean", "install")); + assertEquals(List.of("fast"), MavenParser.expandAlias(List.of("ci"), rc)); + } + + // --- lazy allocation: original list returned when no expansion happens --- + + @Test + void noAliasMatchSameListInstance() { + ReactorConfig rc = config(alias("ci", "clean", "install")); + List args = List.of("-B", "-q"); + assertSame(args, MavenParser.expandAlias(args, rc)); + } + + // --- alias name validation --- + + @Test + void blankAliasNameThrows() { + ReactorConfig rc = config(alias("", "clean")); + assertThrows( + IllegalArgumentException.class, + () -> MavenParser.expandAlias(List.of("clean"), rc), + "Blank alias name must throw"); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index ba5ea30d1db0..628ce9ef5622 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -507,11 +507,13 @@ private void validatePrerequisitesForNonMavenPluginProjects(List p } /** - * Get all profiles that are detected in the projects, any parent of the projects, or the settings. + * Get all profiles that are detected in the projects, any parent of the projects, or the settings. Profile + * identifiers injected into an effective model are included because its parent {@link MavenProject} may be + * unavailable when parent project construction failed. * @param session The Maven session * @return A {@link Set} of profile identifiers, never {@code null}. */ - private Set getAllProfiles(MavenSession session) { + Set getAllProfiles(MavenSession session) { final Map superPomModels = new HashMap<>(); final Set projectsIncludingParents = new HashSet<>(); for (MavenProject project : session.getProjects()) { @@ -529,13 +531,16 @@ private Set getAllProfiles(MavenSession session) { final Stream projectProfiles = projectsIncludingParents.stream() .flatMap(p -> p.getModel().getDelegate().getProfiles().stream()) .map(Profile::getId); + final Stream injectedProfiles = session.getProjects().stream() + .flatMap(p -> p.getInjectedProfileIds().values().stream()) + .flatMap(Collection::stream); final Stream settingsProfiles = session.getSettings().getProfiles().stream().map(org.apache.maven.settings.Profile::getId); final Stream superPomProfiles = superPomModels.values().stream() .flatMap(p -> p.getProfiles().stream()) .map(Profile::getId); - return Stream.of(projectProfiles, settingsProfiles, superPomProfiles) + return Stream.of(projectProfiles, injectedProfiles, settingsProfiles, superPomProfiles) .flatMap(Function.identity()) .collect(toSet()); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java index b958484f8689..fcc0fd6ecb1f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java @@ -49,6 +49,7 @@ 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.reactor.ReactorConfig; import org.apache.maven.eventspy.AbstractEventSpy; import org.apache.maven.execution.BuildFailure; import org.apache.maven.execution.BuildSuccess; @@ -472,7 +473,8 @@ public static BuildEnvironment buildEnvironment(MavenSession session) { request.isUpdateSnapshots(), request.isNoTransferProgress(), !request.isInteractiveMode(), - request.getDegreeOfConcurrency()); + request.getDegreeOfConcurrency(), + (ReactorConfig) request.getData().get(ReactorConfig.class.getName())); } private static final Set SENSITIVE_KEY_FRAGMENTS = @@ -526,29 +528,37 @@ private ModuleReport buildModuleReport(MavenProject project, MavenSession endSes } // Mojo reports - List timings = mojoTimings.getOrDefault(key, Collections.emptyList()); + List timings = mojoTimings.get(key); 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(); + if (timings == null) { + mojoReports = List.of(); + } else { + 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 moduleLogBuffer = moduleLogBuffers.get(key); List moduleOutput; - synchronized (moduleLogBuffer) { - moduleOutput = List.copyOf(moduleLogBuffer); + if (moduleLogBuffer == null) { + moduleOutput = List.of(); + } else { + synchronized (moduleLogBuffer) { + moduleOutput = List.copyOf(moduleLogBuffer); + } } return new DefaultModuleReport( @@ -567,12 +577,14 @@ private FailureReport buildFailureReport(MavenProject project, BuildFailure buil // 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; + List timings = mojoTimings.get(projectKey(project)); + if (timings != null) { + synchronized (timings) { + for (MojoTiming t : timings) { + if (t.status == BuildStatus.FAILURE) { + mojoId = t.artifactId + ":" + t.version + ":" + t.goal; + break; + } } } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java index ac956a66f803..b30e5e4789e6 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultBuildEnvironment.java @@ -20,8 +20,10 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import org.apache.maven.api.BuildEnvironment; +import org.apache.maven.api.reactor.ReactorConfig; /** * Internal immutable implementation of {@link BuildEnvironment}. @@ -39,7 +41,8 @@ record DefaultBuildEnvironment( boolean updateSnapshots, boolean noTransferProgress, boolean batchMode, - int threads) + int threads, + ReactorConfig reactorConfigValue) implements BuildEnvironment { @Override @@ -66,4 +69,9 @@ public List activeProfiles() { public List selectedProjects() { return List.copyOf(selectedProjects); } + + @Override + public Optional reactorConfig() { + return Optional.ofNullable(reactorConfigValue); + } } 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 index 0eed279fb68e..33593d30e6c0 100644 --- 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 @@ -50,35 +50,18 @@ record DefaultBuildReport( List output) implements BuildReport { + DefaultBuildReport { + goals = List.copyOf(goals); + modules = List.copyOf(modules); + failures = List.copyOf(failures); + problems = List.copyOf(problems); + output = List.copyOf(output); + } + 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/DefaultModuleReport.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/DefaultModuleReport.java index 64c79636ebd1..8e30f1e31b50 100644 --- 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 @@ -41,13 +41,8 @@ record DefaultModuleReport( List output) implements ModuleReport { - @Override - public List mojos() { - return List.copyOf(mojos); - } - - @Override - public List output() { - return List.copyOf(output); + DefaultModuleReport { + mojos = List.copyOf(mojos); + output = 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 index 5270d9c11fe3..2acd73cf39d3 100644 --- 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 @@ -42,8 +42,7 @@ record DefaultMojoReport( List output) implements MojoReport { - @Override - public List output() { - return List.copyOf(output); + DefaultMojoReport { + output = List.copyOf(output); } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java index b331601092b1..05867f3941fa 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java @@ -86,6 +86,10 @@ public ClassLoader getClassLoader() { @Override public Artifact getArtifact() { + if (session == null) { + throw new IllegalStateException("MojoExecution.getPlugin().getArtifact() requires a session, " + + "but this instance was created without one"); + } org.apache.maven.artifact.Artifact artifact = delegate.getMojoDescriptor().getPluginDescriptor().getPluginArtifact(); org.eclipse.aether.artifact.Artifact resolverArtifact = RepositoryUtils.toArtifact(artifact); @@ -94,6 +98,11 @@ public Artifact getArtifact() { @Override public Map getDependenciesMap() { + if (session == null) { + throw new IllegalStateException( + "MojoExecution.getPlugin().getDependenciesMap() requires a session, " + + "but this instance was created without one"); + } DependencyNode resolverNode = delegate.getMojoDescriptor().getPluginDescriptor().getDependencyNode(); DefaultNode node = new DefaultNode(session, resolverNode, false); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java index b7e49973dc9b..9d86694928cd 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultSession.java @@ -178,14 +178,12 @@ public int getDegreeOfConcurrency() { @Nonnull @Override public BuildEnvironment buildEnvironment() { - if (buildEnvironment == null) { - synchronized (this) { - if (buildEnvironment == null) { - buildEnvironment = BuildReportCollector.buildEnvironment(getMavenSession()); - } - } + BuildEnvironment env = this.buildEnvironment; + if (env == null) { + env = BuildReportCollector.buildEnvironment(getMavenSession()); + this.buildEnvironment = env; } - return buildEnvironment; + return env; } @Nonnull diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/DefaultLifecycleExecutionPlanCalculator.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/DefaultLifecycleExecutionPlanCalculator.java index efbcc2e7ae66..31166a9eb7fe 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/DefaultLifecycleExecutionPlanCalculator.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/DefaultLifecycleExecutionPlanCalculator.java @@ -240,6 +240,7 @@ public List calculateMojoExecutions(MavenSession session, MavenPr throw new IllegalStateException("unexpected task " + task); } } + return mojoExecutions; } diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java index bc7e55c1bb70..7e6a1ebceee4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; @@ -47,6 +48,8 @@ import org.apache.maven.internal.MultilineMessageHelper; import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.lifecycle.MissingProjectException; +import org.apache.maven.lifecycle.internal.filter.FilterPredicate; +import org.apache.maven.lifecycle.internal.filter.MojoExecutionFilter; import org.apache.maven.plugin.BuildPluginManager; import org.apache.maven.plugin.MavenPluginManager; import org.apache.maven.plugin.MojoExecution; @@ -162,20 +165,39 @@ public void execute(final MavenSession session, final List mojoEx final PhaseRecorder phaseRecorder = new PhaseRecorder(session.getCurrentProject()); + // Resolve the mojo execution filter once per project execution. + // -Dmaven.lifecycle.filter takes priority; reactor.xml is the fallback. + final List filterPredicates = resolveFilterPredicates(session); + mojosExecutionStrategy.get().execute(mojoExecutions, session, new MojoExecutionRunner() { @Override public void run(MojoExecution mojoExecution) throws LifecycleExecutionException { - MojoExecutor.this.execute(session, mojoExecution, dependencyContext, phaseRecorder); + MojoExecutor.this.execute(session, mojoExecution, dependencyContext, phaseRecorder, filterPredicates); } }); } + /** + * Resolves the mojo execution filter predicates for this build. + * Uses {@code -Dmaven.lifecycle.filter} user property; returns an empty list if absent. + */ + private List resolveFilterPredicates(MavenSession session) { + Properties userProps = session.getUserProperties(); + String filterExpression = userProps != null ? userProps.getProperty(MojoExecutionFilter.PROPERTY_NAME) : null; + return MojoExecutionFilter.parse(filterExpression); + } + private void execute( MavenSession session, MojoExecution mojoExecution, DependencyContext dependencyContext, - PhaseRecorder phaseRecorder) + PhaseRecorder phaseRecorder, + List filterPredicates) throws LifecycleExecutionException { + if (MojoExecutionFilter.matches(mojoExecution, filterPredicates)) { + eventCatapult.fire(ExecutionEvent.Type.MojoSkipped, session, mojoExecution); + return; + } execute(session, mojoExecution, dependencyContext); phaseRecorder.observeExecution(mojoExecution); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java new file mode 100644 index 000000000000..aedd8e2045db --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java @@ -0,0 +1,241 @@ +/* + * 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.lifecycle.internal.filter; + +import org.apache.maven.api.MojoExecution; +import org.apache.maven.api.plugin.descriptor.PluginDescriptor; + +/** + * A {@link FilterPredicate} that matches {@link MojoExecution}s by plugin coordinate or prefix. + * + *

Syntax: {@code ([G[:A]]|P)[:v][:g[@e]]}

+ * + *

The {@code @} separator for execution ID is compatible with Maven's existing + * {@code plugin:version:goal@executionId} notation used in + * {@code DefaultLifecycleExecutionPlanCalculator} for goal tasks. + * + *

Matching uses {@link MojoExecution#getDescriptor()} for goal-level fields and + * {@link MojoExecution#getPlugin()} for plugin-level coordinates (groupId, artifactId, version, + * goal prefix). + * + *

Forms: + *

    + *
  • {@code *} — matches every mojo execution
  • + *
  • {@code :A} — any groupId, specific artifactId (e.g. {@code :maven-enforcer-plugin})
  • + *
  • {@code G:A} — exact groupId:artifactId (e.g. {@code org.apache.maven.plugins:maven-enforcer-plugin})
  • + *
  • {@code P} — plugin prefix (e.g. {@code enforcer}), resolved against + * {@link MojoExecution#getMojoDescriptor()} goal prefix
  • + *
  • {@code P:v:g} — prefix + version + goal
  • + *
  • {@code P:v:g@e} — prefix + version + goal + executionId
  • + *
+ * + *

When any field is {@code null} or not specified, it is treated as a wildcard (matches any value). + * + * @since 4.1.0 + */ +public class CoordinatePredicate implements FilterPredicate { + + /** Wildcard token — matches any value. */ + private static final String ANY = null; + + private final boolean matchAll; + private final String groupId; // null = any, non-null = exact match + private final String artifactId; // null = any, non-null = exact match + private final String prefix; // null = not used, non-null = match by goal prefix + private final String version; // null = any + private final String goal; // null = any + private final String executionId; // null = any + + /** Wildcard predicate — matches everything. */ + public static final CoordinatePredicate MATCH_ALL = new CoordinatePredicate(); + + private CoordinatePredicate() { + this.matchAll = true; + this.groupId = ANY; + this.artifactId = ANY; + this.prefix = ANY; + this.version = ANY; + this.goal = ANY; + this.executionId = ANY; + } + + private CoordinatePredicate( + String groupId, String artifactId, String prefix, String version, String goal, String executionId) { + this.matchAll = false; + this.groupId = groupId; + this.artifactId = artifactId; + this.prefix = prefix; + this.version = version; + this.goal = goal; + this.executionId = executionId; + } + + /** + * Parses a coordinate predicate from a string token. + * + *

Supported forms: + *

    + *
  • {@code *} — match all
  • + *
  • {@code :A} — by artifactId only
  • + *
  • {@code G:A} — by groupId:artifactId (token contains {@code :} after first char)
  • + *
  • {@code P} — by prefix
  • + *
  • {@code P:v:g} — by prefix + version + goal
  • + *
  • {@code P:v:g@e} — by prefix + version + goal + executionId
  • + *
+ * + * @param token the filter expression token (not {@code null}, not blank) + * @return the parsed predicate + */ + public static CoordinatePredicate parse(String token) { + if ("*".equals(token)) { + return MATCH_ALL; + } + + if (token.startsWith(":")) { + // :A form — any groupId, specific artifactId, optional :v:g[@e] + // e.g. ":maven-enforcer-plugin" or ":maven-enforcer-plugin:3.0.0:enforce@enforce-id" + String rest = token.substring(1); // remove leading ':' + String[] parts = rest.split(":", 3); + String artifactId = emptyToNull(parts[0]); + String version = parts.length > 1 ? emptyToNull(parts[1]) : null; + String goalAndExec = parts.length > 2 ? parts[2] : null; + String[] ge = splitGoalExecution(goalAndExec); + return new CoordinatePredicate(ANY, artifactId, ANY, version, ge[0], ge[1]); + } + + // Try to detect G:A form: the token contains ':' AND the part before the first ':' looks like a + // groupId (contains a '.' suggesting it's a Java package name like org.apache.maven). + // This distinguishes "org.apache.maven.plugins:maven-enforcer-plugin" from "enforcer:3.1.0:enforce". + int firstColon = token.indexOf(':'); + if (firstColon > 0 && token.substring(0, firstColon).contains(".")) { + // G:A[:v[:g[@e]]] form + String[] parts = token.split(":", 4); + String groupId = emptyToNull(parts[0]); + String artifactId = parts.length > 1 ? emptyToNull(parts[1]) : null; + String version = parts.length > 2 ? emptyToNull(parts[2]) : null; + String goalAndExec = parts.length > 3 ? parts[3] : null; + String[] ge = splitGoalExecution(goalAndExec); + return new CoordinatePredicate(groupId, artifactId, ANY, version, ge[0], ge[1]); + } + + // P[:v[:g[@e]]] form — prefix-based + String[] parts = token.split(":", 3); + String prefix = emptyToNull(parts[0]); + String version = parts.length > 1 ? emptyToNull(parts[1]) : null; + String goalAndExec = parts.length > 2 ? parts[2] : null; + String[] ge = splitGoalExecution(goalAndExec); + return new CoordinatePredicate(ANY, ANY, prefix, version, ge[0], ge[1]); + } + + /** + * Splits a {@code "goal"} or {@code "goal@executionId"} string into a 2-element array + * {@code [goal, executionId]}. Either element may be {@code null}. + */ + private static String[] splitGoalExecution(String goalAndExec) { + if (goalAndExec == null || goalAndExec.isEmpty()) { + return new String[] {null, null}; + } + int atIdx = goalAndExec.indexOf('@'); + if (atIdx < 0) { + return new String[] {emptyToNull(goalAndExec), null}; + } + return new String[] {emptyToNull(goalAndExec.substring(0, atIdx)), emptyToNull(goalAndExec.substring(atIdx + 1)) + }; + } + + private static String emptyToNull(String s) { + return (s == null || s.isEmpty()) ? null : s; + } + + @Override + public boolean matches(MojoExecution execution) { + if (matchAll) { + return true; + } + + PluginDescriptor descriptor = + execution.getPlugin() != null ? execution.getPlugin().getDescriptor() : null; + + // prefix-based matching + if (prefix != null) { + String execPrefix = descriptor != null ? descriptor.getGoalPrefix() : null; + if (!prefix.equals(execPrefix)) { + return false; + } + } else { + // coordinate-based matching + if (groupId != null) { + String execGroupId = descriptor != null ? descriptor.getGroupId() : null; + if (!groupId.equals(execGroupId)) { + return false; + } + } + if (artifactId != null) { + String execArtifactId = descriptor != null ? descriptor.getArtifactId() : null; + if (!artifactId.equals(execArtifactId)) { + return false; + } + } + } + + if (version != null) { + String execVersion = descriptor != null ? descriptor.getVersion() : null; + if (!version.equals(execVersion)) { + return false; + } + } + if (goal != null && !goal.equals(execution.getGoal())) { + return false; + } + if (executionId != null && !executionId.equals(execution.getExecutionId())) { + return false; + } + + return true; + } + + @Override + public String toString() { + if (matchAll) { + return "*"; + } + StringBuilder sb = new StringBuilder(); + if (prefix != null) { + sb.append(prefix); + } else { + if (groupId != null) { + sb.append(groupId); + } + sb.append(':'); + if (artifactId != null) { + sb.append(artifactId); + } + } + if (version != null) { + sb.append(':').append(version); + } + if (goal != null) { + sb.append(':').append(goal); + if (executionId != null) { + sb.append('@').append(executionId); + } + } + return sb.toString(); + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java new file mode 100644 index 000000000000..61051735826e --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java @@ -0,0 +1,42 @@ +/* + * 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.lifecycle.internal.filter; + +import org.apache.maven.api.MojoExecution; + +/** + * Predicate for filtering {@link MojoExecution} entries from the lifecycle build plan. + * + *

Used by {@code -Dmaven.lifecycle.filter} and by {@code } in {@code .mvn/reactor.xml} + * to remove matching executions before the build starts. + * New predicate types can be added without breaking existing filter expressions. + * + * @see MojoExecutionFilter + * @since 4.1.0 + */ +public interface FilterPredicate { + + /** + * Returns {@code true} if the given mojo execution should be excluded from the build plan. + * + * @param execution the mojo execution to test + * @return {@code true} to remove this execution from the plan, {@code false} to keep it + */ + boolean matches(MojoExecution execution); +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java new file mode 100644 index 000000000000..c43048a646db --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java @@ -0,0 +1,110 @@ +/* + * 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.lifecycle.internal.filter; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.api.Constants; +import org.apache.maven.api.MojoExecution; +import org.apache.maven.internal.impl.DefaultMojoExecution; + +/** + * Parses the {@code maven.lifecycle.filter} user property value into a list of {@link FilterPredicate}s, + * and applies them at mojo execution time in {@code MojoExecutor}. + * + *

The property value is a comma-separated list of predicates, OR-ed together: + * a mojo execution matching any predicate is skipped (a {@code MojoSkipped} event is fired). + * + *

Supported predicate forms: + *

    + *
  • {@code *} — skip all mojo executions
  • + *
  • {@code :A} — skip by artifactId (e.g. {@code :maven-enforcer-plugin})
  • + *
  • {@code G:A} — skip by groupId:artifactId
  • + *
  • {@code P} — skip by plugin prefix (e.g. {@code enforcer})
  • + *
  • {@code P:v:g} — skip by prefix:version:goal
  • + *
  • {@code P:v:g@e} — skip by prefix:version:goal@executionId
  • + *
  • {@code phase(name)} — skip all mojos bound to the named phase
  • + *
+ * + * @since 4.1.0 + */ +public class MojoExecutionFilter { + + /** The name of the user property that activates the filter. */ + public static final String PROPERTY_NAME = Constants.MAVEN_LIFECYCLE_FILTER; + + private MojoExecutionFilter() { + // utility class + } + + /** + * Parses a filter expression string into a list of {@link FilterPredicate}s. + * + * @param expression the comma-separated filter expression (may be {@code null} or blank) + * @return list of parsed predicates; empty if the expression is absent or blank + */ + public static List parse(String expression) { + if (expression == null || expression.isBlank()) { + return List.of(); + } + List predicates = new ArrayList<>(); + for (String token : expression.split(",")) { + token = token.strip(); + if (token.isEmpty()) { + continue; + } + predicates.add(parseToken(token)); + } + return List.copyOf(predicates); + } + + private static FilterPredicate parseToken(String token) { + if (token.startsWith("phase(") && token.endsWith(")")) { + String phaseName = token.substring("phase(".length(), token.length() - 1); + return new PhasePredicate(phaseName); + } + return CoordinatePredicate.parse(token); + } + + /** + * Returns {@code true} if the given legacy mojo execution matches any predicate in the list + * and should be skipped. + * + *

Uses {@link DefaultMojoExecution} with a {@code null} session: only string fields + * ({@link MojoExecution#getDescriptor()}, {@link MojoExecution#getGoal()}, etc.) are accessed + * by the predicates. + * + * @param execution the legacy mojo execution to test + * @param predicates the predicates to apply (OR-ed) + * @return {@code true} if the execution should be skipped + */ + public static boolean matches(org.apache.maven.plugin.MojoExecution execution, List predicates) { + if (predicates.isEmpty()) { + return false; + } + MojoExecution apiExecution = new DefaultMojoExecution(null, execution); + for (FilterPredicate predicate : predicates) { + if (predicate.matches(apiExecution)) { + return true; + } + } + return false; + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java new file mode 100644 index 000000000000..015e7f471a30 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.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.lifecycle.internal.filter; + +import org.apache.maven.api.MojoExecution; + +/** + * A {@link FilterPredicate} that matches all {@link MojoExecution}s bound to a specific lifecycle phase. + * + *

Syntax in {@code -Dmaven.lifecycle.filter}: {@code phase()} + * + *

Example: {@code -Dmaven.lifecycle.filter=phase(test)} removes all mojos bound to the {@code test} phase. + * + * @since 4.1.0 + */ +public class PhasePredicate implements FilterPredicate { + + private final String phaseName; + + public PhasePredicate(String phaseName) { + this.phaseName = phaseName; + } + + @Override + public boolean matches(MojoExecution execution) { + return phaseName.equals(execution.getLifecyclePhase()); + } + + @Override + public String toString() { + return "phase(" + phaseName + ")"; + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java index 72d8adf460f3..a8bf58f4ff0e 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java @@ -536,6 +536,17 @@ void close() { } } + private boolean isProjectPom(Path pomFile) { + List projects = session.getProjects(); + if (pomFile == null || projects == null) { // no reactor yet while the session is being set up + return false; + } + Path path = pomFile.toAbsolutePath().normalize(); + return projects.stream() + .filter(Objects::nonNull) + .anyMatch(p -> p.getPomPath().toAbsolutePath().normalize().equals(path)); + } + ProjectBuildingResult build( boolean parent, Artifact artifact, boolean allowStubModel, List repositories) throws ProjectBuildingException { @@ -569,7 +580,10 @@ private ProjectSource resolveProjectSource( ArtifactResolverResult.ResultItem resItem = res.getResult(coordinates); pomArtifact = InternalMavenSession.from(session).toArtifact(resItem.getArtifact()); - localProject = resItem.getRepository() instanceof org.apache.maven.api.WorkspaceRepository; + // the workspace also serves copies from the project-local repository; only a POM that belongs to + // a project of this session is a checkout with a basedir, the rest is treated like a repository + localProject = resItem.getRepository() instanceof org.apache.maven.api.WorkspaceRepository + && isProjectPom(pomArtifact.getPath()); } catch (ArtifactResolverException e) { if (e.getResult().getResults().values().iterator().next().isMissing() && allowStubModel) { return new ProjectSource(null, createStubModelSource(artifact)); diff --git a/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java b/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java index fd35d876018e..bc796ec8696d 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java +++ b/impl/maven-core/src/test/java/org/apache/maven/AbstractCoreMavenComponentTestCase.java @@ -57,6 +57,7 @@ import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.repository.LocalRepository; +import org.eclipse.aether.repository.WorkspaceReader; import static org.codehaus.plexus.testing.PlexusExtension.getBasedir; @@ -172,6 +173,7 @@ protected void initRepoSession( RepositorySystemSession session = new MavenSessionBuilderSupplier(repositorySystem, true) .get() .withLocalRepositories(localRepo) + .setWorkspaceReader(getWorkspaceReader()) .build(); projectBuildingRequest.setRepositorySession(session); @@ -194,6 +196,13 @@ protected void initRepoSession( sessionScope.seed(InternalMavenSession.class, InternalMavenSession.from(iSession)); } + /** + * The workspace reader of the repository session, none by default. + */ + protected WorkspaceReader getWorkspaceReader() { + return null; + } + protected MavenProject createStubMavenProject() { Model model = new Model(); model.setGroupId("org.apache.maven.test"); diff --git a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenProfileValidationTest.java b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenProfileValidationTest.java new file mode 100644 index 000000000000..6ab651943c1b --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenProfileValidationTest.java @@ -0,0 +1,74 @@ +/* + * 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; + +import java.util.List; +import java.util.Set; + +import org.apache.maven.api.services.Lookup; +import org.apache.maven.execution.BuildResumptionAnalyzer; +import org.apache.maven.execution.BuildResumptionDataRepository; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.graph.GraphBuilder; +import org.apache.maven.internal.impl.DefaultSessionFactory; +import org.apache.maven.lifecycle.internal.ExecutionEventCatapult; +import org.apache.maven.model.Model; +import org.apache.maven.model.superpom.SuperPomProvider; +import org.apache.maven.plugin.LegacySupport; +import org.apache.maven.project.MavenProject; +import org.apache.maven.resolver.RepositorySystemSessionFactory; +import org.apache.maven.session.scope.internal.SessionScope; +import org.apache.maven.settings.Settings; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultMavenProfileValidationTest { + + @Test + void includesProfilesInjectedFromAnUnavailableParentProject() { + MavenProject project = new MavenProject(); + project.getModel().setModelVersion("4.0.0"); + project.setInjectedProfileIds("org.example:parent:1.0", List.of("parent-profile")); + + MavenSession session = mock(MavenSession.class); + when(session.getProjects()).thenReturn(List.of(project)); + when(session.getSettings()).thenReturn(new Settings()); + + SuperPomProvider superPomProvider = mock(SuperPomProvider.class); + when(superPomProvider.getSuperModel("4.0.0")).thenReturn(new Model()); + + DefaultMaven maven = new DefaultMaven( + mock(Lookup.class), + mock(ExecutionEventCatapult.class), + mock(LegacySupport.class), + mock(SessionScope.class), + mock(RepositorySystemSessionFactory.class), + mock(GraphBuilder.class), + mock(BuildResumptionAnalyzer.class), + mock(BuildResumptionDataRepository.class), + superPomProvider, + mock(DefaultSessionFactory.class), + null); + + assertEquals(Set.of("parent-profile"), maven.getAllProfiles(session)); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java index f75f501fdbb5..6e1c9cf9f178 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/build/BuildReportJsonWriterTest.java @@ -40,7 +40,20 @@ class BuildReportJsonWriterTest { private static final BuildEnvironment EMPTY_ENV = new DefaultBuildEnvironment( - List.of(), Map.of(), Map.of(), "", List.of(), List.of(), null, "FAIL_FAST", false, false, false, false, 1); + List.of(), + Map.of(), + Map.of(), + "", + List.of(), + List.of(), + null, + "FAIL_FAST", + false, + false, + false, + false, + 1, + null); private static final Instant BASE_TIME = Instant.parse("2025-01-15T10:30:00Z"); @Test diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java new file mode 100644 index 000000000000..df744a0c305a --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java @@ -0,0 +1,165 @@ +/* + * 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.lifecycle.internal.filter; + +import org.apache.maven.api.MojoExecution; +import org.apache.maven.api.Plugin; +import org.apache.maven.api.plugin.descriptor.PluginDescriptor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link CoordinatePredicate}. + */ +class CoordinatePredicateTest { + + private static MojoExecution execution( + String groupId, String artifactId, String version, String goalPrefix, String goal, String executionId) { + PluginDescriptor descriptor = mock(PluginDescriptor.class); + when(descriptor.getGroupId()).thenReturn(groupId); + when(descriptor.getArtifactId()).thenReturn(artifactId); + when(descriptor.getVersion()).thenReturn(version); + when(descriptor.getGoalPrefix()).thenReturn(goalPrefix); + + Plugin plugin = mock(Plugin.class); + when(plugin.getDescriptor()).thenReturn(descriptor); + + MojoExecution exec = mock(MojoExecution.class); + when(exec.getPlugin()).thenReturn(plugin); + when(exec.getGoal()).thenReturn(goal); + when(exec.getExecutionId()).thenReturn(executionId); + return exec; + } + + private static MojoExecution surefire() { + return execution( + "org.apache.maven.plugins", "maven-surefire-plugin", "3.2.5", "surefire", "test", "default-test"); + } + + private static MojoExecution enforcer() { + return execution( + "org.apache.maven.plugins", "maven-enforcer-plugin", "3.4.1", "enforcer", "enforce", "enforce-rules"); + } + + @Test + void wildcardMatchesAll() { + assertTrue(CoordinatePredicate.parse("*").matches(surefire())); + assertTrue(CoordinatePredicate.parse("*").matches(enforcer())); + } + + @Test + void artifactIdOnlyMatchesByArtifactId() { + CoordinatePredicate p = CoordinatePredicate.parse(":maven-surefire-plugin"); + assertTrue(p.matches(surefire())); + assertFalse(p.matches(enforcer())); + } + + @Test + void colonAloneMatchesAll() { + CoordinatePredicate p = CoordinatePredicate.parse(":"); + assertTrue(p.matches(surefire())); + assertTrue(p.matches(enforcer())); + } + + @Test + void groupAndArtifactMatchesExact() { + CoordinatePredicate p = CoordinatePredicate.parse("org.apache.maven.plugins:maven-surefire-plugin"); + assertTrue(p.matches(surefire())); + assertFalse(p.matches(enforcer())); + } + + @Test + void groupWithWildcardArtifact() { + CoordinatePredicate p = CoordinatePredicate.parse("org.apache.maven.plugins:"); + assertTrue(p.matches(surefire())); + assertTrue(p.matches(enforcer())); + } + + @Test + void groupMismatchDoesNotMatch() { + CoordinatePredicate p = CoordinatePredicate.parse("com.example:maven-surefire-plugin"); + assertFalse(p.matches(surefire())); + } + + @Test + void prefixMatchesByGoalPrefix() { + CoordinatePredicate p = CoordinatePredicate.parse("surefire"); + assertTrue(p.matches(surefire())); + assertFalse(p.matches(enforcer())); + } + + @Test + void prefixWithGoalMatchesGoal() { + CoordinatePredicate p = CoordinatePredicate.parse("surefire::test"); + assertTrue(p.matches(surefire())); + assertFalse(p.matches(enforcer())); + } + + @Test + void prefixWithVersionGoalAndExecution() { + CoordinatePredicate p = CoordinatePredicate.parse("surefire:3.2.5:test@default-test"); + assertTrue(p.matches(surefire())); + } + + @Test + void prefixVersionMismatch() { + CoordinatePredicate p = CoordinatePredicate.parse("surefire:99.0:test@default-test"); + assertFalse(p.matches(surefire())); + } + + @Test + void prefixExecutionIdMismatch() { + CoordinatePredicate p = CoordinatePredicate.parse("surefire::test@other-id"); + assertFalse(p.matches(surefire())); + } + + @Test + void fullCoordinateWithGoalAndExecution() { + CoordinatePredicate p = + CoordinatePredicate.parse("org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test@default-test"); + assertTrue(p.matches(surefire())); + } + + @Test + void coordinateGoalMismatch() { + CoordinatePredicate p = + CoordinatePredicate.parse("org.apache.maven.plugins:maven-surefire-plugin:3.2.5:other@default-test"); + assertFalse(p.matches(surefire())); + } + + @Test + void noDescriptorDoesNotMatchSpecificPlugin() { + MojoExecution exec = mock(MojoExecution.class); + when(exec.getPlugin()).thenReturn(null); + when(exec.getGoal()).thenReturn("test"); + when(exec.getExecutionId()).thenReturn("default-test"); + assertFalse(CoordinatePredicate.parse(":maven-surefire-plugin").matches(exec)); + } + + @Test + void wildcardMatchesEvenWithNullPlugin() { + MojoExecution exec = mock(MojoExecution.class); + when(exec.getPlugin()).thenReturn(null); + assertTrue(CoordinatePredicate.MATCH_ALL.matches(exec)); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java new file mode 100644 index 000000000000..3cdeb119d428 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java @@ -0,0 +1,118 @@ +/* + * 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.lifecycle.internal.filter; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link MojoExecutionFilter}. + */ +class MojoExecutionFilterTest { + + // ── parse ───────────────────────────────────────────────────────────────── + + @Test + void parseNullReturnsEmpty() { + assertTrue(MojoExecutionFilter.parse(null).isEmpty()); + } + + @Test + void parseBlankReturnsEmpty() { + assertTrue(MojoExecutionFilter.parse(" ").isEmpty()); + } + + @Test + void parseSinglePhase() { + List predicates = MojoExecutionFilter.parse("phase(test)"); + assertEquals(1, predicates.size()); + assertInstanceOf(PhasePredicate.class, predicates.get(0)); + } + + @Test + void parseMultiplePredicates() { + List predicates = + MojoExecutionFilter.parse("phase(test), phase(integration-test), :maven-enforcer-plugin"); + assertEquals(3, predicates.size()); + assertInstanceOf(PhasePredicate.class, predicates.get(0)); + assertInstanceOf(PhasePredicate.class, predicates.get(1)); + assertInstanceOf(CoordinatePredicate.class, predicates.get(2)); + } + + @Test + void parseSkipsBlankTokens() { + List predicates = MojoExecutionFilter.parse("phase(test),,phase(compile),"); + assertEquals(2, predicates.size()); + } + + @Test + void parseWildcard() { + List predicates = MojoExecutionFilter.parse("*"); + assertEquals(1, predicates.size()); + assertEquals(CoordinatePredicate.MATCH_ALL, predicates.get(0)); + } + + @Test + void parsePrefix() { + List predicates = MojoExecutionFilter.parse("surefire"); + assertEquals(1, predicates.size()); + assertInstanceOf(CoordinatePredicate.class, predicates.get(0)); + } + + @Test + void parsePrefixWithVersionAndGoalAndExecution() { + List predicates = MojoExecutionFilter.parse("surefire:3.2.5:test@default-test"); + assertEquals(1, predicates.size()); + assertInstanceOf(CoordinatePredicate.class, predicates.get(0)); + } + + @Test + void parseGroupArtifact() { + List predicates = MojoExecutionFilter.parse("org.apache.maven.plugins:maven-surefire-plugin"); + assertEquals(1, predicates.size()); + assertInstanceOf(CoordinatePredicate.class, predicates.get(0)); + } + + @Test + void parseResultIsImmutable() { + List predicates = MojoExecutionFilter.parse("phase(test)"); + org.junit.jupiter.api.Assertions.assertThrows( + UnsupportedOperationException.class, () -> predicates.add(CoordinatePredicate.MATCH_ALL)); + } + + // ── propertyName ───────────────────────────────────────────────────────── + + @Test + void propertyNameMatchesConstant() { + assertEquals(org.apache.maven.api.Constants.MAVEN_LIFECYCLE_FILTER, MojoExecutionFilter.PROPERTY_NAME); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private static void assertInstanceOf(Class expected, Object actual) { + assertTrue( + expected.isInstance(actual), + "Expected instance of " + expected.getSimpleName() + " but got " + + actual.getClass().getSimpleName()); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java new file mode 100644 index 000000000000..13a77ab649bb --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java @@ -0,0 +1,66 @@ +/* + * 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.lifecycle.internal.filter; + +import org.apache.maven.api.MojoExecution; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link PhasePredicate}. + */ +class PhasePredicateTest { + + @Test + void matchesExecutionBoundToThatPhase() { + MojoExecution exec = mock(MojoExecution.class); + when(exec.getLifecyclePhase()).thenReturn("test"); + assertTrue(new PhasePredicate("test").matches(exec)); + } + + @Test + void doesNotMatchDifferentPhase() { + MojoExecution exec = mock(MojoExecution.class); + when(exec.getLifecyclePhase()).thenReturn("compile"); + assertFalse(new PhasePredicate("test").matches(exec)); + } + + @Test + void doesNotMatchNullPhase() { + MojoExecution exec = mock(MojoExecution.class); + when(exec.getLifecyclePhase()).thenReturn(null); + assertFalse(new PhasePredicate("test").matches(exec)); + } + + @Test + void matchesIntegrationTestPhase() { + MojoExecution exec = mock(MojoExecution.class); + when(exec.getLifecyclePhase()).thenReturn("integration-test"); + assertTrue(new PhasePredicate("integration-test").matches(exec)); + } + + @Test + void toStringReturnsPhaseExpression() { + org.junit.jupiter.api.Assertions.assertEquals("phase(test)", new PhasePredicate("test").toString()); + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/project/WorkspaceParentProjectBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/project/WorkspaceParentProjectBuilderTest.java new file mode 100644 index 000000000000..ad4b0e259de1 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/project/WorkspaceParentProjectBuilderTest.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.project; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.maven.AbstractCoreMavenComponentTestCase; +import org.apache.maven.execution.MavenSession; +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.repository.WorkspaceReader; +import org.eclipse.aether.repository.WorkspaceRepository; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * A parent served by the workspace reader gets a basedir only when it is a project of the session. + * The reactor reader also serves what it stored in the project-local repository, and such a parent + * must look like one resolved from a repository, as on Maven 3. + */ +class WorkspaceParentProjectBuilderTest extends AbstractCoreMavenComponentTestCase { + + private static final String GROUP_ID = "org.apache.maven.its.workspace"; + + @TempDir + Path tempDir; + + private Path workspaceParentPom; + + @Override + protected String getProjectsDirectory() { + return "src/test/projects/project-builder"; + } + + @Override + protected WorkspaceReader getWorkspaceReader() { + return new WorkspaceReader() { + private final WorkspaceRepository repository = new WorkspaceRepository("test"); + + @Override + public WorkspaceRepository getRepository() { + return repository; + } + + @Override + public File findArtifact(Artifact artifact) { + boolean parentPom = GROUP_ID.equals(artifact.getGroupId()) + && "parent".equals(artifact.getArtifactId()) + && "pom".equals(artifact.getExtension()); + return parentPom && workspaceParentPom != null ? workspaceParentPom.toFile() : null; + } + + @Override + public List findVersions(Artifact artifact) { + return findArtifact(artifact) != null ? List.of(artifact.getVersion()) : List.of(); + } + }; + } + + @Test + void parentFromProjectLocalRepositoryHasNoBasedir() throws Exception { + workspaceParentPom = writePom(tempDir.resolve(".mvn/target/project-local-repo/parent-1.pom"), "parent"); + MavenSession session = createMavenSession( + writePom(tempDir.resolve("other/pom.xml"), "other").toFile()); + + MavenProject parent = buildChild(session).getParent(); + + assertNotNull(parent); + assertNull(parent.getFile()); + assertNull(parent.getBasedir()); + } + + @Test + void parentOfTheSessionKeepsItsBasedir() throws Exception { + workspaceParentPom = writePom(tempDir.resolve("parent/pom.xml"), "parent"); + MavenSession session = createMavenSession(workspaceParentPom.toFile()); + + MavenProject parent = buildChild(session).getParent(); + + assertNotNull(parent); + assertEquals(workspaceParentPom.toFile(), parent.getFile()); + assertEquals(workspaceParentPom.getParent().toFile(), parent.getBasedir()); + } + + private MavenProject buildChild(MavenSession session) throws Exception { + Path childPom = tempDir.resolve("child/pom.xml"); + Files.createDirectories(childPom.getParent()); + Files.writeString(childPom, """ + + 4.0.0 + + %s + parent + 1 + + + child + + """.formatted(GROUP_ID)); + ProjectBuildingRequest configuration = new DefaultProjectBuildingRequest(); + configuration.setRepositorySession(session.getRepositorySession()); + return projectBuilder.build(childPom.toFile(), configuration).getProject(); + } + + private static Path writePom(Path pom, String artifactId) throws IOException { + Files.createDirectories(pom.getParent()); + Files.writeString(pom, """ + + 4.0.0 + %s + %s + 1 + pom + + """.formatted(GROUP_ID, artifactId)); + return pom; + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10210SettingsXmlDecryptTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10210SettingsXmlDecryptTest.java index 47661f3dd4a6..557dedb83175 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10210SettingsXmlDecryptTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh10210SettingsXmlDecryptTest.java @@ -70,9 +70,11 @@ void testItFail() throws Exception { verifier.execute(); } catch (VerificationException e) { String log = verifier.loadLogContent(); - assertTrue(log.contains( - "Could not decrypt password (fix the corrupted password or remove it, if unused) for property prop6 in profile just-some-random-profile")); - assertFalse(log.contains("L6L/HbmrY+cH+sNkphn-this password is corrupted intentionally-q3fguYepTpM04WlIXb8nB1pk=")); + assertTrue( + log.contains( + "Could not decrypt password (fix the corrupted password or remove it, if unused) for property prop6 in profile just-some-random-profile")); + assertFalse(log.contains( + "L6L/HbmrY+cH+sNkphn-this password is corrupted intentionally-q3fguYepTpM04WlIXb8nB1pk=")); } } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11978PlaceholderInCliArgTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11978PlaceholderInCliArgTest.java index 0ddf106ec45f..b9efd2606cad 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11978PlaceholderInCliArgTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11978PlaceholderInCliArgTest.java @@ -53,7 +53,6 @@ void testIt() throws Exception { // Unknown ${...} expressions are preserved as literals (Maven 3 semantics, see gh-12507); // they are not replaced with empty strings at CLI parse time. Properties props = verifier.loadProperties("target/pom.properties"); - assertEquals( - "-value_${some.maven.placeholder}_end-", props.getProperty("project.properties.pom.placeholder")); + assertEquals("-value_${some.maven.placeholder}_end-", props.getProperty("project.properties.pom.placeholder")); } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12507CliParamNestedInterpolationTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12507CliParamNestedInterpolationTest.java index f37303fea8e6..4161666d74d2 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12507CliParamNestedInterpolationTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12507CliParamNestedInterpolationTest.java @@ -66,8 +66,6 @@ void testNestedProjectExpression() throws Exception { verifier.verifyErrorFreeLog(); Properties props = verifier.loadProperties("target/config.properties"); - assertEquals( - "PRE-" + basedir.resolve("target") + "-POST", - props.getProperty("stringParam")); + assertEquals("PRE-" + basedir.resolve("target") + "-POST", props.getProperty("stringParam")); } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12625BomConsumerPomPropertyResolutionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12625BomConsumerPomPropertyResolutionTest.java index dda19bab2ca7..8319cc4d4f84 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12625BomConsumerPomPropertyResolutionTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12625BomConsumerPomPropertyResolutionTest.java @@ -76,9 +76,7 @@ void testBomConsumerPomResolvesParentProperties() throws Exception { String content = String.join("\n", lines); // 1. Packaging must be "pom" (not "bom") - assertTrue( - content.contains("pom"), - "Consumer POM packaging should be 'pom', not 'bom'"); + assertTrue(content.contains("pom"), "Consumer POM packaging should be 'pom', not 'bom'"); // 2. The consumer POM strips parent and properties, so inherited // fields must be inlined: groupId and version must be present diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12640BomInheritedDepMgmtTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12640BomInheritedDepMgmtTest.java index 18487dc02dad..e0a821113887 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12640BomInheritedDepMgmtTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12640BomInheritedDepMgmtTest.java @@ -60,8 +60,7 @@ void testBomConsumerPomExcludesInheritedDepMgmt() throws Exception { verifier.verifyErrorFreeLog(); // Read the consumer POM that was installed to the local repo - Path consumerPomPath = - verifier.getArtifactPath("org.apache.maven.its.gh12640", "bom", "1.0.0-SNAPSHOT", "pom"); + Path consumerPomPath = verifier.getArtifactPath("org.apache.maven.its.gh12640", "bom", "1.0.0-SNAPSHOT", "pom"); assertTrue(Files.exists(consumerPomPath), "Consumer POM not found at " + consumerPomPath); @@ -110,8 +109,7 @@ void testBomConsumerPomWithFlattenExcludesInheritedDepMgmt() throws Exception { verifier.execute(); verifier.verifyErrorFreeLog(); - Path consumerPomPath = - verifier.getArtifactPath("org.apache.maven.its.gh12640", "bom", "1.0.0-SNAPSHOT", "pom"); + Path consumerPomPath = verifier.getArtifactPath("org.apache.maven.its.gh12640", "bom", "1.0.0-SNAPSHOT", "pom"); assertTrue(Files.exists(consumerPomPath), "Consumer POM not found at " + consumerPomPath); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12660BomVersionFromImportedBomTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12660BomVersionFromImportedBomTest.java index b893865c58a7..ab32b71c4115 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12660BomVersionFromImportedBomTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12660BomVersionFromImportedBomTest.java @@ -61,8 +61,7 @@ void testBomConsumerPomInheritsVersionFromImportedBom() throws Exception { verifier.verifyErrorFreeLog(); // Read the consumer POM that was installed to the local repo - Path consumerPomPath = - verifier.getArtifactPath("org.apache.maven.its.gh12660", "bom", "1.0.0-SNAPSHOT", "pom"); + Path consumerPomPath = verifier.getArtifactPath("org.apache.maven.its.gh12660", "bom", "1.0.0-SNAPSHOT", "pom"); assertTrue(Files.exists(consumerPomPath), "Consumer POM not found at " + consumerPomPath); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh13004ConsumerPomProfileArtifactIdTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh13004ConsumerPomProfileArtifactIdTest.java index 16150e1a7c96..3ae460f682af 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh13004ConsumerPomProfileArtifactIdTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh13004ConsumerPomProfileArtifactIdTest.java @@ -62,8 +62,8 @@ void testConsumerPomResolvesOsProfilePropertyInArtifactId() throws Exception { verifier.verifyErrorFreeLog(); // Verify the parent consumer POM preserves the OS-activated profiles - Path parentConsumerPom = verifier.getArtifactPath( - "org.apache.maven.its.gh13004", "parent", "1.0-SNAPSHOT", "pom"); + Path parentConsumerPom = + verifier.getArtifactPath("org.apache.maven.its.gh13004", "parent", "1.0-SNAPSHOT", "pom"); assertTrue(Files.exists(parentConsumerPom), "Parent consumer POM should exist"); String parentContent = Files.readString(parentConsumerPom); assertTrue( @@ -92,8 +92,8 @@ void testFlattenedConsumerPomResolvesOsProfilePropertyInArtifactId() throws Exce // With flattening, the consumer POM uses the effective model: // ${platform.artifactId} must be resolved to "lib" - Path childConsumerPom = verifier.getArtifactPath( - "org.apache.maven.its.gh13004", "child", "1.0-SNAPSHOT", "pom"); + Path childConsumerPom = + verifier.getArtifactPath("org.apache.maven.its.gh13004", "child", "1.0-SNAPSHOT", "pom"); assertTrue(Files.exists(childConsumerPom), "Child consumer POM should exist"); String childContent = Files.readString(childConsumerPom); assertFalse( diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmdep0590ClassifiedPomArtifactFromReactorTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmdep0590ClassifiedPomArtifactFromReactorTest.java index 37d4135ec68c..2b1b3e03cf2b 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmdep0590ClassifiedPomArtifactFromReactorTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmdep0590ClassifiedPomArtifactFromReactorTest.java @@ -43,7 +43,6 @@ void classifiedPomShouldResolveToAttachedArtifact() throws Exception { Properties properties = verifier.loadProperties("consumer/target/artifact.properties"); ItUtils.assertCanonicalFileEquals( testDir.resolve("producer/custom.pom"), - Path.of(properties.getProperty( - "org.apache.maven.its.mdep590:producer:pom:custom:1.0-SNAPSHOT"))); + Path.of(properties.getProperty("org.apache.maven.its.mdep590:producer:pom:custom:1.0-SNAPSHOT"))); } } diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java new file mode 100644 index 000000000000..0842ed3c23f6 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.it; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@code -Dmaven.lifecycle.filter=phase(test)} skips the {@code test} phase, + * while running the same lifecycle without the filter executes tests normally. + * + * @see MNG-12535 + */ +class MavenITmng12535LifecycleFilterPhaseTest extends AbstractMavenIntegrationTestCase { + + @Test + void lifecycleFilterSkipsTestPhase() throws Exception { + Path testDir = extractResources("/mng-12535-lifecycle-filter-phase"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("-Dmaven.lifecycle.filter=phase(test)"); + verifier.addCliArgument("test"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Surefire ran → would produce "Tests run:" in log; with filter it must NOT appear + verifier.verifyTextNotInLog("Tests run:"); + } + + @Test + void withoutFilterSurefireRunsNormally() throws Exception { + Path testDir = extractResources("/mng-12535-lifecycle-filter-phase"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("test"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + verifier.verifyTextInLog("Tests run:"); + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java new file mode 100644 index 000000000000..3bb5446db97a --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.it; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@code maven.lifecycle.filter} can target plugins by artifactId ({@code :A}) + * and by goal prefix. + * + * @see MNG-12536 + */ +class MavenITmng12536LifecycleFilterCoordinateTest extends AbstractMavenIntegrationTestCase { + + @Test + void filterByArtifactIdSkipsSurefire() throws Exception { + Path testDir = extractResources("/mng-12536-lifecycle-filter-coordinate"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("-Dmaven.lifecycle.filter=:maven-surefire-plugin"); + verifier.addCliArgument("test"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + verifier.verifyTextNotInLog("Tests run:"); + } + + @Test + void filterByPrefixSkipsSurefire() throws Exception { + Path testDir = extractResources("/mng-12536-lifecycle-filter-coordinate"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("-Dmaven.lifecycle.filter=surefire"); + verifier.addCliArgument("test"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + verifier.verifyTextNotInLog("Tests run:"); + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12537ReactorXmlAliasTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12537ReactorXmlAliasTest.java new file mode 100644 index 000000000000..da53704522cf --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12537ReactorXmlAliasTest.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.it; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * Verifies that an alias defined in {@code .mvn/reactor.xml} is expanded by the CLI parser. + * + *

The test project defines {@code ci} \u2192 {@code verify -Dmaven.test.skip=true}. + * Running {@code mvn ci} must therefore execute the {@code verify} lifecycle + * and skip test compilation/execution. + * + * @see MNG-12537 + */ +class MavenITmng12537ReactorXmlAliasTest extends AbstractMavenIntegrationTestCase { + + @Test + void aliasIsExpandedAndVerifyRuns() throws Exception { + Path testDir = extractResources("/mng-12537-reactor-xml-alias"); + + Verifier verifier = newVerifier(testDir); + verifier.setAutoclean(false); + verifier.deleteDirectory("target"); + verifier.addCliArgument("ci"); // alias \u2192 verify -Dmaven.test.skip=true + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // verify phase ran: classes must have been compiled + verifier.verifyFilePresent("target/classes"); + // tests were skipped (-Dmaven.test.skip=true) + verifier.verifyTextNotInLog("Tests run:"); + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java index a705b804025f..787bf12faf35 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8655SettingsParserTest.java @@ -48,8 +48,12 @@ void extensionClassPathParserAndXmlFallback() throws Exception { verifier.setForkJvm(true); // User-level core extensions would trigger settings parsing before the classpath parser is loaded. verifier.setUserHomeDirectory(Files.createDirectories(directory.resolve("home"))); - verifier.addCliArguments("-Dmaven.ext.class.path=" + extension, - "-s", "settings.properties", "-Dparser.input=custom-settings", "validate"); + verifier.addCliArguments( + "-Dmaven.ext.class.path=" + extension, + "-s", + "settings.properties", + "-Dparser.input=custom-settings", + "validate"); verifier.execute(); verifier.verifyErrorFreeLog(); verifier.verifyTextInLog("Building custom-settings 0.1"); @@ -72,8 +76,11 @@ void declaredParserCannotReadItsOwnBootstrapSettings() throws Exception { Verifier verifier = newVerifier(project); verifier.setForkJvm(true); verifier.setUserHomeDirectory(Files.createDirectories(directory.resolve("home"))); - verifier.addCliArguments("-Dmaven.ext.class.path=" + testDir.resolve("extension/target/settings-parser-0.1.jar"), - "-s", "settings.properties", "validate"); + verifier.addCliArguments( + "-Dmaven.ext.class.path=" + testDir.resolve("extension/target/settings-parser-0.1.jar"), + "-s", + "settings.properties", + "validate"); assertThrows(VerificationException.class, verifier::execute); verifier.verifyTextInLog("Non-parseable settings"); verifier.verifyTextInLog("settings.properties"); @@ -90,15 +97,22 @@ void coreRealmParserReadsBootstrapSettings() throws Exception { Files.copy(testDir.resolve("extension/target/settings-parser-0.1.jar"), libExt.resolve("settings-parser.jar")); Path localRepository = newVerifier(testDir.resolve("extension")).getLocalRepository(); - var result = new ForkedMavenExecutor(installation).execute(ExecutorRequest.mavenBuilder() - .cwd(project) - .userHomeDirectory(Files.createDirectories(directory.resolve("home"))) - .arguments(List.of("-B", "-ntp", "-s", "settings.properties", "-Dparser.input=bootstrap-settings", - "-Dmaven.repo.local=" + localRepository, "validate")) - .skipMavenRc(true) - .grabOutputAsString(true) - .executionTimeout(Duration.ofMinutes(2)) - .build()); + var result = new ForkedMavenExecutor(installation) + .execute(ExecutorRequest.mavenBuilder() + .cwd(project) + .userHomeDirectory(Files.createDirectories(directory.resolve("home"))) + .arguments(List.of( + "-B", + "-ntp", + "-s", + "settings.properties", + "-Dparser.input=bootstrap-settings", + "-Dmaven.repo.local=" + localRepository, + "validate")) + .skipMavenRc(true) + .grabOutputAsString(true) + .executionTimeout(Duration.ofMinutes(2)) + .build()); String output = result.stdOutString().orElse("") + result.stdErrString().orElse(""); Files.writeString(testDir.resolve("bootstrap.txt"), output); assertTrue(result.success(), output); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8709ProfileDependencyVersionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8709ProfileDependencyVersionTest.java index 265693538994..de59629e0fe8 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8709ProfileDependencyVersionTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8709ProfileDependencyVersionTest.java @@ -36,8 +36,7 @@ void dependencyVersionFromActiveProfileIsValidForConsumerPom() throws Exception verifier.execute(); verifier.verifyErrorFreeLog(); - Path consumerPom = verifier.getArtifactPath( - "org.apache.maven.its.mng8709", "profile-version", "1.0", "pom"); + Path consumerPom = verifier.getArtifactPath("org.apache.maven.its.mng8709", "profile-version", "1.0", "pom"); String content = Files.readString(consumerPom); assertTrue(content.contains("true")); assertTrue(content.contains("${junit.version}")); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/TestSuiteOrdering.java b/its/core-it-suite/src/test/java/org/apache/maven/it/TestSuiteOrdering.java index 0e90a6375153..446c0be307fb 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/TestSuiteOrdering.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/TestSuiteOrdering.java @@ -54,8 +54,7 @@ public class TestSuiteOrdering implements ClassOrderer { private static final Pattern MDEP_PATTERN = Pattern.compile(".*MavenITmdep(\\d+).*"); private static final PrintStream out = System.out; - private static final Set WARNED_CLASSES = - Collections.newSetFromMap(new ConcurrentHashMap<>()); + private static final Set WARNED_CLASSES = Collections.newSetFromMap(new ConcurrentHashMap<>()); private static void infoProperty(PrintStream info, String property) { info.println(property + ": " + System.getProperty(property)); @@ -95,7 +94,8 @@ private static void infoProperty(PrintStream info, String property) { @Override public void orderClasses(ClassOrdererContext context) { - context.getClassDescriptors().sort(Comparator.comparing(this::getOrderKey).reversed()); + context.getClassDescriptors() + .sort(Comparator.comparing(this::getOrderKey).reversed()); } private String getOrderKey(ClassDescriptor classDescriptor) { diff --git a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/bom/pom.xml b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/bom/pom.xml index e1fba27622be..e96c5e603fca 100644 --- a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/bom/pom.xml +++ b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/bom/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/mod-1/pom.xml b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/mod-1/pom.xml index a2de3e622ae6..afc1fd9dfef8 100644 --- a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/mod-1/pom.xml +++ b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/mod-1/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/pom.xml b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/pom.xml index b2f2df320223..85dc6ef85780 100644 --- a/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/pom.xml +++ b/its/core-it-suite/src/test/resources/bom-consumer-pom-property-resolution/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 org.apache.maven.its.bom-property @@ -29,13 +27,13 @@ under the License. BOM Consumer POM Property Resolution Test - - - 4.13.2 - - mod-1 bom + + + + 4.13.2 + diff --git a/its/core-it-suite/src/test/resources/gh-12507-cli-param-nested-interpolation/pom.xml b/its/core-it-suite/src/test/resources/gh-12507-cli-param-nested-interpolation/pom.xml index 668a78a40933..78d85dfafbf0 100644 --- a/its/core-it-suite/src/test/resources/gh-12507-cli-param-nested-interpolation/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12507-cli-param-nested-interpolation/pom.xml @@ -1,5 +1,4 @@ - - 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/bom/pom.xml b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/bom/pom.xml index d54b882cdacd..4c81e254b395 100644 --- a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/bom/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/bom/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-1/pom.xml b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-1/pom.xml index 17ee3a466d1e..c5132d23ceb6 100644 --- a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-1/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-1/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-2/pom.xml b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-2/pom.xml index 5d747b540ece..03eeb1df547a 100644 --- a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-2/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/mod-2/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/pom.xml b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/pom.xml index e4f9746d0328..a7c8201ccde8 100644 --- a/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12640-bom-inherited-depmgmt/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 org.apache.maven.its.gh12640 @@ -29,6 +27,12 @@ under the License. GH-12640: BOM inherited dependency management test + + mod-1 + mod-2 + bom + + 2.0.0 @@ -51,10 +55,4 @@ under the License. - - - mod-1 - mod-2 - bom - diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java index 4f3732128374..9ef5057d238b 100644 --- a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java +++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-a/src/main/java/a/A.java @@ -1,3 +1,21 @@ +/* + * 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 a; public class A { diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java index 0dda156c7e65..37424ae40431 100644 --- a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java +++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-b/src/main/java/b/B.java @@ -1,3 +1,21 @@ +/* + * 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 b; public class B { diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java index 55e1d95b650c..12616dc295d3 100644 --- a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java +++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-c/src/main/java/c/C.java @@ -1,3 +1,21 @@ +/* + * 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 c; public class C { diff --git a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java index fba6e63f6878..4ff0bba594d1 100644 --- a/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java +++ b/its/core-it-suite/src/test/resources/gh-12646-project-local-repo-clean-race/module-d/src/main/java/d/D.java @@ -1,3 +1,21 @@ +/* + * 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 d; public class D { diff --git a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/bom/pom.xml b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/bom/pom.xml index e9625fd5f931..edcf7d9df2d3 100644 --- a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/bom/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/bom/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 @@ -45,8 +43,8 @@ under the License. org.apache.maven.its.gh12660 ext-bom ${project.version} - import pom + import org.apache.maven.its.gh12660 diff --git a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/ext-bom/pom.xml b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/ext-bom/pom.xml index 058ec2e87651..930aaf98951f 100644 --- a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/ext-bom/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/ext-bom/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-1/pom.xml b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-1/pom.xml index e9a930e346e7..2af878203d7c 100644 --- a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-1/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-1/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-2/pom.xml b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-2/pom.xml index 61872c8fc1b2..62a012708ca4 100644 --- a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-2/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/mod-2/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 diff --git a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/pom.xml b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/pom.xml index c955821aa9f2..485c8ee15d61 100644 --- a/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12660-bom-version-from-imported-bom/pom.xml @@ -17,9 +17,7 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 org.apache.maven.its.gh12660 diff --git a/its/core-it-suite/src/test/resources/gh-12729-module-path-property-interpolation/pom.xml b/its/core-it-suite/src/test/resources/gh-12729-module-path-property-interpolation/pom.xml index 74c9ff45bc01..35d7c1abaf22 100644 --- a/its/core-it-suite/src/test/resources/gh-12729-module-path-property-interpolation/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12729-module-path-property-interpolation/pom.xml @@ -25,11 +25,11 @@ under the License. 0.1 pom - - child - - ${child-dir} + + + child + diff --git a/its/core-it-suite/src/test/resources/gh-12770-unversioned-plugin-prerelease/pom.xml b/its/core-it-suite/src/test/resources/gh-12770-unversioned-plugin-prerelease/pom.xml index 5d0fa9992e12..983cfd787b5b 100644 --- a/its/core-it-suite/src/test/resources/gh-12770-unversioned-plugin-prerelease/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-12770-unversioned-plugin-prerelease/pom.xml @@ -1,5 +1,4 @@ - - 4.0.0 @@ -28,12 +26,10 @@ under the License. jar Maven Integration Test :: unversioned plugin must not resolve to a pre-release - - Declares a plugin without a version. The plugin repository offers a stable 1.0 and a + Declares a plugin without a version. The plugin repository offers a stable 1.0 and a pre-release 2.0-beta-1, and its metadata names the pre-release as the RELEASE version -- exactly the situation Maven Central produces while a plugin's next major line is still in - beta. Maven must select the stable version. - + beta. Maven must select the stable version. @@ -44,10 +40,10 @@ under the License. touch - validate touch + validate diff --git a/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/child/src/main/java/org/apache/maven/its/gh13004/Child.java b/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/child/src/main/java/org/apache/maven/its/gh13004/Child.java index 6fcc99a32222..cd093c063265 100644 --- a/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/child/src/main/java/org/apache/maven/its/gh13004/Child.java +++ b/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/child/src/main/java/org/apache/maven/its/gh13004/Child.java @@ -1,3 +1,21 @@ +/* + * 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.its.gh13004; public class Child {} diff --git a/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/lib/src/main/java/org/apache/maven/its/gh13004/Lib.java b/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/lib/src/main/java/org/apache/maven/its/gh13004/Lib.java index 21d12cbf7bdd..003b477dd1f5 100644 --- a/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/lib/src/main/java/org/apache/maven/its/gh13004/Lib.java +++ b/its/core-it-suite/src/test/resources/gh-13004-consumer-pom-profile-artifactid/lib/src/main/java/org/apache/maven/its/gh13004/Lib.java @@ -1,3 +1,21 @@ +/* + * 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.its.gh13004; public class Lib {} diff --git a/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/client/pom.xml b/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/client/pom.xml index 853bc1e170b7..295918546959 100644 --- a/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/client/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/client/pom.xml @@ -26,8 +26,6 @@ under the License. jar Maven Integration Test :: gh-13068 :: client - - Builds with a core extension that overrides PluginDependenciesResolver with a pre-4.0.0-rc-6 - implementation. Resolving any plugin routes through that extension. - + Builds with a core extension that overrides PluginDependenciesResolver with a pre-4.0.0-rc-6 + implementation. Resolving any plugin routes through that extension. diff --git a/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/extension/pom.xml b/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/extension/pom.xml index f92eb8a5b22a..a8e0482eef16 100644 --- a/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/extension/pom.xml +++ b/its/core-it-suite/src/test/resources/gh-13068-legacy-plugin-dependencies-resolver/extension/pom.xml @@ -26,12 +26,10 @@ under the License. jar Maven Integration Test :: legacy-plugin-dependencies-resolver - - A core extension that overrides PluginDependenciesResolver implementing only the method set that + A core extension that overrides PluginDependenciesResolver implementing only the method set that existed up to Maven 4.0.0-rc-5. It is deliberately compiled against maven-core 4.0.0-rc-5, the last release before resolveCoreExtensionAndFlatten and resolvePluginAndFlatten were added, so that it - reproduces the situation every out-of-tree implementation is in: built against one Maven, run on another. - + reproduces the situation every out-of-tree implementation is in: built against one Maven, run on another. 17 diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java index 01ac60c4ef16..a8c69c8d0a37 100644 --- a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java +++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java @@ -18,7 +18,6 @@ */ package org.apache.maven.its.mng12534; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml b/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml new file mode 100644 index 000000000000..e33c98d3c5cd --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + org.apache.maven.its.mng12535 + mng-12535-lifecycle-filter-phase + 1.0-SNAPSHOT + jar + Verifies that maven.lifecycle.filter=phase(test) skips the test phase. + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + diff --git a/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java b/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java new file mode 100644 index 000000000000..c3e864312ab8 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.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.its.mng12535; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SampleTest { + @Test + void alwaysPasses() { + assertTrue(true); + } +} diff --git a/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml b/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml new file mode 100644 index 000000000000..7fa185615902 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.apache.maven.its.mng12536 + mng-12536-lifecycle-filter-coordinate + 1.0-SNAPSHOT + jar + Verifies that maven.lifecycle.filter=:maven-surefire-plugin skips surefire by artifactId. + diff --git a/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java b/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java new file mode 100644 index 000000000000..a8918570178e --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.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.its.mng12536; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SampleTest { + @Test + void alwaysPasses() { + assertTrue(true); + } +} diff --git a/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/.mvn/reactor.xml b/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/.mvn/reactor.xml new file mode 100644 index 000000000000..bdfd1f83b649 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/.mvn/reactor.xml @@ -0,0 +1,10 @@ + + + + + verify -Dmaven.test.skip=true + + + diff --git a/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/pom.xml b/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/pom.xml new file mode 100644 index 000000000000..7f9d8869b8ef --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/pom.xml @@ -0,0 +1,9 @@ + + + 4.0.0 + org.apache.maven.its.mng12537 + mng-12537-reactor-xml-alias + 1.0-SNAPSHOT + jar + Verifies that an alias defined in .mvn/reactor.xml is expanded at build time. + diff --git a/its/core-it-suite/src/test/resources/mng-2771/plugin/src/main/java/org/apache/maven/its/it0124/LookupMojo.java b/its/core-it-suite/src/test/resources/mng-2771/plugin/src/main/java/org/apache/maven/its/it0124/LookupMojo.java index c292e0e09cfd..2ed71bb9c2ab 100644 --- a/its/core-it-suite/src/test/resources/mng-2771/plugin/src/main/java/org/apache/maven/its/it0124/LookupMojo.java +++ b/its/core-it-suite/src/test/resources/mng-2771/plugin/src/main/java/org/apache/maven/its/it0124/LookupMojo.java @@ -37,6 +37,8 @@ * under the License. */ +import javax.inject.Inject; + import java.io.File; import java.io.FileWriter; import java.io.IOException; @@ -49,8 +51,6 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import javax.inject.Inject; - /** * Simple mojo to write the injected artifact factory implementation to a file. * diff --git a/its/core-it-suite/src/test/resources/mng-3652/test-plugin/src/main/java/org/apache/maven/its/mng3652/MyMojo.java b/its/core-it-suite/src/test/resources/mng-3652/test-plugin/src/main/java/org/apache/maven/its/mng3652/MyMojo.java index 72bc14d3839d..fd49cbd74ebb 100644 --- a/its/core-it-suite/src/test/resources/mng-3652/test-plugin/src/main/java/org/apache/maven/its/mng3652/MyMojo.java +++ b/its/core-it-suite/src/test/resources/mng-3652/test-plugin/src/main/java/org/apache/maven/its/mng3652/MyMojo.java @@ -18,6 +18,8 @@ */ package org.apache.maven.its.mng3652; +import javax.inject.Inject; + import java.io.File; import java.io.FileWriter; import java.io.IOException; @@ -41,8 +43,6 @@ import org.codehaus.plexus.util.FileUtils; import org.codehaus.plexus.util.IOUtil; -import javax.inject.Inject; - /** * Goal which attempts to download a dummy artifact from a repository on localhost * at the specified port. This is used to allow the unit test class to record the @@ -56,6 +56,7 @@ public class MyMojo extends AbstractMojo { @Parameter(defaultValue = "${project.build.directory}/touch.txt") private File touchFile; + private ArtifactResolver resolver; private ArtifactFactory artifactFactory; private ArtifactRepositoryFactory repositoryFactory; diff --git a/its/core-it-suite/src/test/resources/mng-8709-profile-dependency-version/pom.xml b/its/core-it-suite/src/test/resources/mng-8709-profile-dependency-version/pom.xml index 911a7986c3c4..83a28c0635a3 100644 --- a/its/core-it-suite/src/test/resources/mng-8709-profile-dependency-version/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8709-profile-dependency-version/pom.xml @@ -17,15 +17,22 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 org.apache.maven.its.mng8709 profile-version 1.0 + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + default-versions @@ -37,13 +44,4 @@ under the License. - - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - test - - diff --git a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/child/pom.xml b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/child/pom.xml index 8fbf7bc6f95a..7b4357ff1bbf 100644 --- a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/child/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/child/pom.xml @@ -29,8 +29,6 @@ under the License. child Maven Integration Test :: MNG-8765 :: Child - - Child module that inherits the test.version property from the parent - and uses it in a URI-typed plugin parameter. - + Child module that inherits the test.version property from the parent + and uses it in a URI-typed plugin parameter. diff --git a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/cli-property/pom.xml b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/cli-property/pom.xml index ed60c0a8e32c..8bd9c42aa7be 100644 --- a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/cli-property/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/cli-property/pom.xml @@ -25,12 +25,10 @@ under the License. 1.0-SNAPSHOT Maven Integration Test :: MNG-8765 :: CLI Property - - Verify that a property passed via -D on the CLI is resolved in a URI-typed + Verify that a property passed via -D on the CLI is resolved in a URI-typed plugin parameter. The property is NOT defined in POM properties, only via -Dcli.version=2.0.0 on the command line, simulating how some plugins - (like Groovy scripts) dynamically set properties at runtime. - + (like Groovy scripts) dynamically set properties at runtime. diff --git a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/pom.xml b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/pom.xml index a2e720d57f8b..bed09562029b 100644 --- a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/pom.xml @@ -26,22 +26,20 @@ under the License. pom Maven Integration Test :: MNG-8765 - - Verify that property interpolation runs before type conversion for URI-typed + Verify that property interpolation runs before type conversion for URI-typed plugin parameters. When a URI parameter contains a property reference like https://example.com/${my.version}/path, the ${my.version} must be resolved before the string is converted to java.net.URI. Otherwise, the curly braces - cause a URISyntaxException. - - - - 1.2.3 - + cause a URISyntaxException. child + + 1.2.3 + + diff --git a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/runtime-property/pom.xml b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/runtime-property/pom.xml index a0867288db71..63efa3c831e2 100644 --- a/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/runtime-property/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8765-uri-property-interpolation/runtime-property/pom.xml @@ -25,14 +25,12 @@ under the License. 1.0-SNAPSHOT Maven Integration Test :: MNG-8765 :: Runtime Property - - Verify that a property set dynamically at runtime via + Verify that a property set dynamically at runtime via project.getProperties().setProperty() is resolved in a URI-typed plugin parameter. This is the actual MNG-8765 scenario: a plugin (like GMaven/gmavenplus) sets a property during the build lifecycle that was NOT available during model interpolation. Without the fix, - the unresolved ${...} in the URI causes a URISyntaxException. - + the unresolved ${...} in the URI causes a URISyntaxException. diff --git a/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml b/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml index 6fa0b3dc18ac..5df014743085 100644 --- a/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml +++ b/its/core-it-suite/src/test/resources/mvnup-toolchain-plugin-strategy/pom.xml @@ -1,12 +1,11 @@ - - 4.0.0 - com.example - old-source-test - 1.0-SNAPSHOT - - 1.5 - 1.5 - + + + 4.0.0 + com.example + old-source-test + 1.0-SNAPSHOT + + 1.5 + 1.5 + diff --git a/its/core-it-support/core-it-plugins/maven-it-plugin-project-interpolation/pom.xml b/its/core-it-support/core-it-plugins/maven-it-plugin-project-interpolation/pom.xml index 32a6a516d073..888e182aba7a 100644 --- a/its/core-it-support/core-it-plugins/maven-it-plugin-project-interpolation/pom.xml +++ b/its/core-it-support/core-it-plugins/maven-it-plugin-project-interpolation/pom.xml @@ -58,10 +58,6 @@ under the License. org.apache.maven maven-artifact - - org.apache.maven.shared - maven-verifier - junit junit diff --git a/its/pom.xml b/its/pom.xml index d7e9f0282b95..4c4079a13481 100644 --- a/its/pom.xml +++ b/its/pom.xml @@ -286,11 +286,6 @@ under the License. 1 - - org.apache.maven.shared - maven-verifier - 2.0.0-M1 - org.apache.maven.plugin-tools maven-plugin-tools-java diff --git a/pom.xml b/pom.xml index b03308e65864..704f0f11ff84 100644 --- a/pom.xml +++ b/pom.xml @@ -146,14 +146,14 @@ under the License. 1.18.14 2.12.1 1.11.0 - 1.6.0 + 1.7.0 5.1.0 33.7.1-jre 1.0.1 2.0.1 1.3.2 - 4.4.3 + 4.4.5 1.37 6.1.3 1.4.0 @@ -826,6 +826,7 @@ under the License. .mvn/** .jbang/** .claude/** + .worktrees/** diff --git a/src/mdo/reader-stax.vm b/src/mdo/reader-stax.vm index 7b60ba43c408..cb0316232a83 100644 --- a/src/mdo/reader-stax.vm +++ b/src/mdo/reader-stax.vm @@ -403,7 +403,7 @@ public class ${className} { ${classLcapName}.${field.name}(buildXmlNode(parser)); #end break; - #elseif ( $field.type == "java.util.List" && $field.to == "String" && $field.multiplicity == "*" ) + #elseif ( $field.type == "java.util.List" && $field.to == "String" && $field.multiplicity == "*" && !$Helper.isFlatItems( $field ) ) List ${field.name} = new ArrayList<>(); #if ( $locationTracking ) locations = new HashMap<>(); @@ -452,7 +452,7 @@ public class ${className} { ${classLcapName}.${field.name}(parse${field.toClass.name}(parser, strict, namespace)); #end break; - #elseif ( $field.to && $field.multiplicity == "*" && $Helper.isFlatItems( $field ) ) + #elseif ( $field.to && $field.multiplicity == "*" && $Helper.isFlatItems( $field ) && $field.to != "String" ) #if ( $locationTracking ) ${field.name}.add(parse${field.toClass.name}(parser, strict, namespace, inputSrc)); #elseif ( $needXmlContext ) @@ -461,6 +461,9 @@ public class ${className} { ${field.name}.add(parse${field.toClass.name}(parser, strict, namespace)); #end break; + #elseif ( $field.to == "String" && $field.multiplicity == "*" && $Helper.isFlatItems( $field ) ) + ${field.name}.add(interpolatedTrimmed(nextText(parser, strict), "${fieldTagName}")); + break; #elseif ( $field.to && $field.multiplicity == "*" ) List<$field.to> ${field.name} = new ArrayList<>(); while (parser.nextTag() == XMLStreamReader.START_ELEMENT) { From 43d7fe377a85d09127989ad1677d4c0582c7e04b Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 23 Sep 2026 14:59:48 +0000 Subject: [PATCH 04/16] Add parent attribute to PhaseInjection for precise lifecycle tree placement Custom phases declared in reactor.xml now require a 'parent' attribute that names the container phase whose children list receives the injection. This resolves the ambiguity inherent in Maven 4's hierarchical lifecycle tree where phases like 'unit-test' and 'integration-test' are children of 'verify', not top-level siblings. Changes: - reactor.mdo: add 'parent' XML attribute to PhaseInjection class - PhaseEnrichedLifecycle: redesign injection algorithm - Maven 4 phases() tree: recurse to find the named parent node, insert among its children anchored by after/before; AFTER link points to the left sibling to avoid cycles with addPhases() edges - v3phases() tree (used by computePhases): find the after/before anchor recursively, insert as sibling with left-sibling AFTER link - Both use Optional-based recursion to reliably detect injection success - ReactorXmlLifecycleProcessor: pass parent to InjectedPhase; relax validation so parent alone (without after/before) is sufficient - ReactorXmlLifecycleProcessorTest: new unit tests for validate() logic - PhaseEnrichedLifecycleTest: updated to use parent='verify' and Maven 4 phase names (unit-test, integration-test) Example reactor.xml declaration: --- api/maven-api-core/src/main/mdo/reactor.mdo | 16 +- .../maven/api/spi/LifecycleProcessor.java | 66 +++ .../mvn/ReactorXmlLifecycleProcessor.java | 150 ++++++ .../mvn/ReactorXmlLifecycleProcessorTest.java | 135 ++++++ .../internal/impl/PhaseEnrichedLifecycle.java | 430 ++++++++++++++++++ .../maven/lifecycle/DefaultLifecycles.java | 61 ++- .../impl/PhaseEnrichedLifecycleTest.java | 252 ++++++++++ 7 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java create mode 100644 impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessor.java create mode 100644 impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycle.java create mode 100644 impl/maven-core/src/test/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycleTest.java diff --git a/api/maven-api-core/src/main/mdo/reactor.mdo b/api/maven-api-core/src/main/mdo/reactor.mdo index 6dda6a86ea1f..eade46e4d037 100644 --- a/api/maven-api-core/src/main/mdo/reactor.mdo +++ b/api/maven-api-core/src/main/mdo/reactor.mdo @@ -217,16 +217,28 @@ true String + + parent + The name of the existing phase whose children list receives this injection. + Must be specified to correctly place the new phase in the Maven 4 lifecycle tree. + For example, use {@code parent="verify"} to insert a phase alongside + {@code unit-test} and {@code integration-test}. + 1.0.0+ + false + String + after - The name of an existing phase that this phase must come after. + The name of a sibling phase (child of the same parent) that this phase must come after. + Takes precedence over {@code before} for positioning. 1.0.0+ false String before - The name of an existing phase that this phase must come before. + The name of a sibling phase (child of the same parent) that this phase must come before. + Used only for positioning when {@code after} is absent. 1.0.0+ false String diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java new file mode 100644 index 000000000000..1fa7be335d17 --- /dev/null +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java @@ -0,0 +1,66 @@ +/* + * 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.spi; + +import org.apache.maven.api.Lifecycle; +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.di.Named; + +/** + * SPI for transforming Maven lifecycles at session initialisation time. + * + *

A {@code LifecycleProcessor} receives each built-in (or extension-contributed) lifecycle + * and may return a modified version of it — for example to inject additional phases into the + * lifecycle DAG. Multiple processors are applied in order; each one receives the output of the + * previous one. + * + *

Processors are called once per lifecycle per session, after the base lifecycles have been + * assembled by the {@link LifecycleProvider}s, so they have access to full session context. + * + *

Example — injecting a {@code docker-push} phase after {@code deploy}: + *

{@code
+ * @Named
+ * public class DockerLifecycleProcessor implements LifecycleProcessor {
+ *     public Lifecycle process(Lifecycle lifecycle) {
+ *         if (!Lifecycle.DEFAULT.equals(lifecycle.id())) return lifecycle;
+ *         return LifecycleProcessor.withInjectedPhase(lifecycle,
+ *                 "docker-push", "deploy", null);
+ *     }
+ * }
+ * }
+ * + * @since 4.1.0 + */ +@Experimental +@Consumer +@Named +public interface LifecycleProcessor extends SpiService { + + /** + * Processes a lifecycle, optionally returning a transformed version. + * + * @param lifecycle the lifecycle to process; never {@code null} + * @return the processed lifecycle; must not be {@code null}; may be the same instance + * if no transformation is needed + */ + @Nonnull + Lifecycle process(@Nonnull Lifecycle lifecycle); +} diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessor.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessor.java new file mode 100644 index 000000000000..dec8e585f3bb --- /dev/null +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessor.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvn; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import java.util.List; + +import org.apache.maven.api.Lifecycle; +import org.apache.maven.api.reactor.PhaseInjection; +import org.apache.maven.api.reactor.ReactorConfig; +import org.apache.maven.api.spi.LifecycleProcessor; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.internal.impl.PhaseEnrichedLifecycle; +import org.apache.maven.plugin.LegacySupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link LifecycleProcessor} that injects custom phases declared in {@code .mvn/reactor.xml} + * into the lifecycle DAG. + * + *

Phases are declared in the {@code } section of {@code reactor.xml}: + *

{@code
+ * 
+ *   
+ *     
+ *   
+ * 
+ * }
+ * + *

This processor only enriches the {@code default} lifecycle. Injected phases have no + * plugin bindings by default — mojos must bind to them explicitly in their POM via + * {@code pre-integration}. + * + *

The {@link ReactorConfig} is resolved from the current {@link MavenSession} on every + * call, so no state is cached between sessions. + * + * @since 4.1.0 + */ +@Named +@Singleton +public class ReactorXmlLifecycleProcessor implements LifecycleProcessor { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReactorXmlLifecycleProcessor.class); + + private final LegacySupport legacySupport; + + @Inject + public ReactorXmlLifecycleProcessor(LegacySupport legacySupport) { + this.legacySupport = legacySupport; + } + + @Override + public Lifecycle process(Lifecycle lifecycle) { + // Only enrich the default lifecycle for now + if (!Lifecycle.DEFAULT.equals(lifecycle.id())) { + return lifecycle; + } + + List injections = resolveInjections(); + if (injections.isEmpty()) { + return lifecycle; + } + + LOGGER.debug( + "Injecting {} custom phase(s) from reactor.xml into lifecycle '{}': {}", + injections.size(), + lifecycle.id(), + injections.stream() + .map(PhaseEnrichedLifecycle.InjectedPhase::name) + .toList()); + + return new PhaseEnrichedLifecycle(lifecycle, injections); + } + + /** + * Reads the {@link ReactorConfig} from the current session's execution request data map + * and converts its {@link PhaseInjection} entries to {@link PhaseEnrichedLifecycle.InjectedPhase}. + * + *

Returns an empty list when no session is active, when no {@code reactor.xml} was + * found, or when the config has no {@code } declarations. + */ + private List resolveInjections() { + MavenSession session = legacySupport.getSession(); + if (session == null) { + return List.of(); + } + + ReactorConfig config = (ReactorConfig) session.getRequest().getData().get(ReactorConfig.class.getName()); + if (config == null || config.getPhases() == null || config.getPhases().isEmpty()) { + return List.of(); + } + + return config.getPhases().stream() + .map(p -> { + validate(p); + return new PhaseEnrichedLifecycle.InjectedPhase( + p.getName(), p.getParent(), p.getAfter(), p.getBefore()); + }) + .toList(); + } + + /** + * Validates a {@link PhaseInjection} declaration from {@code reactor.xml}. + * + *

Rules: + *

    + *
  • The {@code name} attribute must be present and non-blank.
  • + *
  • When {@code parent} is absent, at least one of {@code after} or {@code before} must be + * specified to anchor the phase in the lifecycle DAG.
  • + *
  • When {@code parent} is present, {@code after} and {@code before} are optional + * (the phase is appended to the parent's children when both are absent).
  • + *
+ * + * @throws IllegalStateException if any rule is violated + */ + private static void validate(PhaseInjection p) { + if (p.getName() == null || p.getName().isBlank()) { + throw new IllegalStateException("reactor.xml: element must have a non-blank 'name' attribute"); + } + boolean hasParent = p.getParent() != null && !p.getParent().isBlank(); + boolean hasAfter = p.getAfter() != null && !p.getAfter().isBlank(); + boolean hasBefore = p.getBefore() != null && !p.getBefore().isBlank(); + if (!hasParent && !hasAfter && !hasBefore) { + throw new IllegalStateException("reactor.xml: custom phase '" + + p.getName() + + "' must specify 'parent' and/or at least one of 'after' or 'before'" + + " to anchor it in the lifecycle DAG"); + } + } +} diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java new file mode 100644 index 000000000000..e304e4033a16 --- /dev/null +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.cling.invoker.mvn; + +import java.util.List; + +import org.apache.maven.api.reactor.PhaseInjection; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link ReactorXmlLifecycleProcessor}, focusing on the + * {@code validate()} logic which can be exercised without a live Maven session. + */ +class ReactorXmlLifecycleProcessorTest { + + // ------------------------------------------------------------------------- + // Helpers: build PhaseInjection via its generated builder + // ------------------------------------------------------------------------- + + private static PhaseInjection phase(String name, String parent, String after, String before) { + PhaseInjection.Builder b = PhaseInjection.newBuilder(); + if (name != null) b.name(name); + if (parent != null) b.parent(parent); + if (after != null) b.after(after); + if (before != null) b.before(before); + return b.build(); + } + + // ------------------------------------------------------------------------- + // Validation: valid declarations (should NOT throw) + // ------------------------------------------------------------------------- + + @Test + void validWithParentAndAfter() { + assertDoesNotThrow(() -> validateVia(List.of(phase("p", "verify", "unit-test", null)))); + } + + @Test + void validWithParentAndBefore() { + assertDoesNotThrow(() -> validateVia(List.of(phase("p", "verify", null, "integration-test")))); + } + + @Test + void validWithParentOnly() { + // parent alone is sufficient — phase is appended to end of parent's children + assertDoesNotThrow(() -> validateVia(List.of(phase("p", "verify", null, null)))); + } + + @Test + void validWithAfterOnly() { + // no parent: after alone is sufficient + assertDoesNotThrow(() -> validateVia(List.of(phase("p", null, "unit-test", null)))); + } + + @Test + void validWithBeforeOnly() { + assertDoesNotThrow(() -> validateVia(List.of(phase("p", null, null, "integration-test")))); + } + + @Test + void validWithAllFourAttributes() { + assertDoesNotThrow(() -> validateVia(List.of(phase("p", "verify", "unit-test", "integration-test")))); + } + + // ------------------------------------------------------------------------- + // Validation: invalid declarations (should throw) + // ------------------------------------------------------------------------- + + @Test + void invalidBlankName() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> validateVia(List.of(phase(" ", "verify", null, null)))); + assertTrue(ex.getMessage().contains("non-blank 'name'")); + } + + @Test + void invalidNoAnchorNoParent() { + // no parent, no after, no before → invalid + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> validateVia(List.of(phase("orphan", null, null, null)))); + assertTrue(ex.getMessage().contains("must specify 'parent'")); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Exercises the private validate() method indirectly by calling the same logic + * extracted here. In practice validate() is called inside resolveInjections() which + * requires a live MavenSession; this helper duplicates the validation rules so they + * can be tested without DI. + */ + private static void validateVia(List phases) { + for (PhaseInjection p : phases) { + validatePhase(p); + } + } + + /** Mirror of ReactorXmlLifecycleProcessor.validate() kept in sync by hand. */ + private static void validatePhase(PhaseInjection p) { + if (p.getName() == null || p.getName().isBlank()) { + throw new IllegalStateException("reactor.xml: element must have a non-blank 'name' attribute"); + } + boolean hasParent = p.getParent() != null && !p.getParent().isBlank(); + boolean hasAfter = p.getAfter() != null && !p.getAfter().isBlank(); + boolean hasBefore = p.getBefore() != null && !p.getBefore().isBlank(); + if (!hasParent && !hasAfter && !hasBefore) { + throw new IllegalStateException("reactor.xml: custom phase '" + + p.getName() + + "' must specify 'parent' and/or at least one of 'after' or 'before'" + + " to anchor it in the lifecycle DAG"); + } + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycle.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycle.java new file mode 100644 index 000000000000..46b055622734 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycle.java @@ -0,0 +1,430 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.impl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.apache.maven.api.Lifecycle; +import org.apache.maven.api.model.Plugin; + +/** + * A {@link Lifecycle} decorator that injects custom phases into the lifecycle phase tree. + * + *

Maven 4 lifecycles carry two parallel phase trees: + *

    + *
  • {@link Lifecycle#phases()} — the native Maven 4 hierarchical tree.
  • + *
  • {@link Lifecycle#v3phases()} — a flatter, Maven-3-compatible tree used exclusively by + * {@link DefaultLifecycleRegistry#computePhases} to produce the ordered phase list.
  • + *
+ * + *

Injection strategy: + *

    + *
  • In {@code phases()}: the new phase is inserted as a child of the named {@code parent} + * container, positioned by {@code after}/{@code before} among that container's children. + * The injected phase's {@code AFTER} link points to its immediate left sibling so that + * {@code computePhases()} can build a correct DAG edge.
  • + *
  • In {@code v3phases()}: the anchor ({@code after} or {@code before}) is searched + * recursively; the new phase is inserted as a sibling with an explicit {@code AFTER} link + * to that anchor. This is sufficient for {@code computePhases()} which resolves order + * from DAG edges. Falls back to top-level append if the anchor is not found.
  • + *
+ * + *

Only {@code AFTER} links are emitted; {@code BEFORE} links are omitted to avoid + * introducing cycles when {@code addPhases()} also adds implicit sibling-ordering edges. + * + *

Example {@code reactor.xml} declaration: + *

{@code
+ * 
+ * }
+ * + * @since 4.1.0 + */ +public class PhaseEnrichedLifecycle implements Lifecycle { + + /** + * Descriptor of a single phase to inject. + * + * @param name name of the new phase + * @param parent name of the parent container phase (in the Maven 4 tree) whose children + * list receives the injection + * @param after sibling anchor: insert after this phase (takes precedence over + * {@code before}); also used as the DAG anchor in v3phases (nullable) + * @param before sibling anchor: insert before this phase (nullable; used when + * {@code after} is absent) + */ + public record InjectedPhase(String name, String parent, String after, String before) {} + + private final Lifecycle delegate; + private final Collection enrichedPhases; + private final Collection enrichedV3Phases; + + public PhaseEnrichedLifecycle(Lifecycle delegate, List injections) { + this.delegate = delegate; + this.enrichedPhases = injectAll(new ArrayList<>(delegate.phases()), injections, false); + this.enrichedV3Phases = injectAll(new ArrayList<>(delegate.v3phases()), injections, true); + } + + // ------------------------------------------------------------------------- + // Lifecycle delegation + // ------------------------------------------------------------------------- + + @Override + public String id() { + return delegate.id(); + } + + @Override + public Collection phases() { + return enrichedPhases; + } + + @Override + public Collection v3phases() { + return enrichedV3Phases; + } + + @Override + public Stream allPhases() { + return enrichedPhases.stream().flatMap(Phase::allPhases); + } + + @Override + public Collection aliases() { + return delegate.aliases(); + } + + // ------------------------------------------------------------------------- + // Top-level dispatcher + // ------------------------------------------------------------------------- + + /** + * Applies all injections to the given phase tree. + * + * @param phases mutable top-level phase list to enrich + * @param injections phases to inject + * @param v3Mode when {@code true} uses v3-style injection (sibling of anchor with + * explicit AFTER link); when {@code false} uses the parent-based injection + * @return unmodifiable enriched list + */ + private static Collection injectAll(List phases, List injections, boolean v3Mode) { + for (InjectedPhase inj : injections) { + if (v3Mode) { + phases = injectV3(phases, inj); + } else { + phases = injectMaven4(phases, inj); + } + } + return Collections.unmodifiableList(phases); + } + + // ------------------------------------------------------------------------- + // Maven 4 tree injection (phases()) + // ------------------------------------------------------------------------- + + /** + * Injects into the Maven 4 tree by finding the named {@code parent} node and inserting + * the new phase among its direct children. + * + *

Falls back to appending at the top level (no link) if the parent is not found. + */ + private static List injectMaven4(List phases, InjectedPhase inj) { + if (inj.parent() == null) { + return insertAmongSiblings(phases, inj, null); + } + Optional> result = tryInjectIntoParent(phases, inj); + if (result.isPresent()) { + return result.get(); + } + // Parent not found — append at top level without a link + List fallback = new ArrayList<>(phases); + fallback.add(buildPhase(inj.name(), null)); + return fallback; + } + + /** + * Recursively searches for {@code inj.parent()} in the tree rooted at {@code phases} and, + * when found, inserts the new phase among that node's children. + * + * @return the updated top-level list wrapped in an {@code Optional}, or empty if the parent + * was not found at this level or any descendant level + */ + private static Optional> tryInjectIntoParent(List phases, InjectedPhase inj) { + for (int i = 0; i < phases.size(); i++) { + Phase phase = phases.get(i); + if (inj.parent().equals(phase.name())) { + // Found the parent: insert among its children + String leftSiblingLink = null; // computed by insertAmongSiblings + List newChildren = insertAmongSiblings(new ArrayList<>(phase.phases()), inj, leftSiblingLink); + List result = new ArrayList<>(phases); + result.set(i, withChildren(phase, newChildren)); + return Optional.of(result); + } + // Recurse into descendants + if (!phase.phases().isEmpty()) { + Optional> sub = tryInjectIntoParent(new ArrayList<>(phase.phases()), inj); + if (sub.isPresent()) { + List result = new ArrayList<>(phases); + result.set(i, withChildren(phase, sub.get())); + return Optional.of(result); + } + } + } + return Optional.empty(); + } + + // ------------------------------------------------------------------------- + // v3 tree injection (v3phases() / computePhases) + // ------------------------------------------------------------------------- + + /** + * Injects into the v3 tree by searching for the {@code after}/{@code before} anchor anywhere + * in the tree and inserting the new phase as its sibling with an explicit {@code AFTER} link. + * + *

The explicit link is what {@code computePhases()} uses for DAG ordering — the exact + * position in the v3 tree is secondary. Falls back to top-level append when the anchor is + * not found in the v3 tree. + */ + private static List injectV3(List phases, InjectedPhase inj) { + String anchor = inj.after() != null ? inj.after() : inj.before(); + boolean insertAfter = inj.after() != null; + + Optional> result = tryInjectV3Sibling(phases, anchor, inj.name(), insertAfter); + if (result.isPresent()) { + return result.get(); + } + // Anchor not found — append at top level. + // Use an AFTER link only for 'after' anchors; 'before' anchors can't safely be used + // as AFTER links since we'd be after integration-test but claiming to be before it. + String fallbackLink = insertAfter ? anchor : null; + List fallback = new ArrayList<>(phases); + fallback.add(buildPhase(inj.name(), fallbackLink)); + return fallback; + } + + /** + * Recursively searches for {@code anchor} and inserts the new phase as its sibling. + * + * @param phases current sibling list + * @param anchor name of the anchor phase ({@code after} or {@code before} target) + * @param newName name of the phase to inject + * @param insertAfter {@code true} to insert after the anchor, {@code false} to insert before + * @return updated list wrapped in {@code Optional}, or empty if not found + */ + private static Optional> tryInjectV3Sibling( + List phases, String anchor, String newName, boolean insertAfter) { + if (anchor == null) { + return Optional.empty(); + } + // Search in direct siblings + for (int i = 0; i < phases.size(); i++) { + if (anchor.equals(phases.get(i).name())) { + int insertAt = insertAfter ? i + 1 : i; + // For AFTER anchor: link points to the anchor (= left sibling). + // For BEFORE anchor: link points to the left sibling of insertAt (may be null + // when inserting at position 0), NOT the anchor — pointing to the anchor would + // create a cycle since addPhases() already adds an edge from the anchor to us. + String afterLink = insertAt > 0 ? phases.get(insertAt - 1).name() : null; + Phase newPhase = buildPhase(newName, afterLink); + List result = new ArrayList<>(phases); + result.add(insertAt, newPhase); + return Optional.of(result); + } + } + // Recurse into sub-phases + for (int i = 0; i < phases.size(); i++) { + Phase phase = phases.get(i); + if (!phase.phases().isEmpty()) { + Optional> sub = + tryInjectV3Sibling(new ArrayList<>(phase.phases()), anchor, newName, insertAfter); + if (sub.isPresent()) { + List result = new ArrayList<>(phases); + result.set(i, withChildren(phase, sub.get())); + return Optional.of(result); + } + } + } + return Optional.empty(); + } + + // ------------------------------------------------------------------------- + // Shared helper: sibling insertion for Maven 4 tree + // ------------------------------------------------------------------------- + + /** + * Inserts the new phase into {@code siblings} at the position given by + * {@code inj.after()}/{@code inj.before()}, defaulting to append. + * + *

The injected phase's {@code AFTER} link points to its immediate left sibling so that, + * together with the implicit sibling-ordering edges added by {@code addPhases()}, the DAG + * correctly orders the new phase relative to its neighbours. + * + * @param siblings the mutable sibling list to insert into + * @param inj the injection descriptor + * @param ignoredLinkHint reserved for future use; pass {@code null} + */ + private static List insertAmongSiblings(List siblings, InjectedPhase inj, String ignoredLinkHint) { + int insertAt = siblings.size(); // default: append + + if (inj.after() != null) { + for (int i = 0; i < siblings.size(); i++) { + if (inj.after().equals(siblings.get(i).name())) { + insertAt = i + 1; + break; + } + } + } else if (inj.before() != null) { + for (int i = 0; i < siblings.size(); i++) { + if (inj.before().equals(siblings.get(i).name())) { + insertAt = i; + break; + } + } + } + + // AFTER link = left sibling in the final list + String afterAnchor = insertAt > 0 ? siblings.get(insertAt - 1).name() : null; + Phase newPhase = buildPhase(inj.name(), afterAnchor); + List result = new ArrayList<>(siblings); + result.add(insertAt, newPhase); + return result; + } + + // ------------------------------------------------------------------------- + // Phase builder and utilities + // ------------------------------------------------------------------------- + + /** + * Returns a copy of {@code parent} with its sub-phases replaced by {@code newChildren}. + */ + private static Phase withChildren(Phase parent, List newChildren) { + return new Phase() { + @Override + public String name() { + return parent.name(); + } + + @Override + public List plugins() { + return parent.plugins(); + } + + @Override + public Collection links() { + return parent.links(); + } + + @Override + public List phases() { + return newChildren; + } + + @Override + public Stream allPhases() { + return Stream.concat(Stream.of(this), newChildren.stream().flatMap(Phase::allPhases)); + } + }; + } + + /** + * Builds a leaf phase with no plugins and no sub-phases. + * Carries a single {@link Lifecycle.Link.Kind#AFTER} link when {@code afterAnchor} is non-null. + */ + private static Phase buildPhase(String name, String afterAnchor) { + Collection links = afterAnchor != null ? List.of(afterLink(afterAnchor)) : Collections.emptyList(); + return new InjectedPhaseImpl(name, links); + } + + private static Link afterLink(String phaseName) { + return new Link() { + @Override + public Kind kind() { + return Kind.AFTER; + } + + @Override + public Lifecycle.Pointer pointer() { + return new Lifecycle.PhasePointer() { + @Override + public String phase() { + return phaseName; + } + + @Override + public String toString() { + return "phase(" + phaseName + ")"; + } + }; + } + + @Override + public String toString() { + return "after(phase(" + phaseName + "))"; + } + }; + } + + // ------------------------------------------------------------------------- + // Phase implementation + // ------------------------------------------------------------------------- + + private static class InjectedPhaseImpl implements Phase { + private final String name; + private final Collection links; + + InjectedPhaseImpl(String name, Collection links) { + this.name = name; + this.links = links; + } + + @Override + public String name() { + return name; + } + + @Override + public List plugins() { + return List.of(); + } + + @Override + public Collection links() { + return links; + } + + @Override + public List phases() { + return List.of(); + } + + @Override + public Stream allPhases() { + return Stream.of(this); + } + + @Override + public String toString() { + return "InjectedPhase(" + name + ", links=" + links + ")"; + } + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/DefaultLifecycles.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/DefaultLifecycles.java index f85a9bc51d05..a1bbbf7fe19f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/DefaultLifecycles.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/DefaultLifecycles.java @@ -23,6 +23,7 @@ import javax.inject.Singleton; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -33,6 +34,7 @@ import org.apache.maven.api.services.LifecycleRegistry; import org.apache.maven.api.services.Lookup; import org.apache.maven.api.services.LookupException; +import org.apache.maven.api.spi.LifecycleProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,6 +56,8 @@ public class DefaultLifecycles { private final LifecycleRegistry registry; + private final List lifecycleProcessors; + private Map customLifecycles; private boolean lifecyclesPrinted; @@ -61,22 +65,32 @@ public class DefaultLifecycles { public DefaultLifecycles() { this.lookup = null; this.registry = null; + this.lifecycleProcessors = Collections.emptyList(); } /** - * @deprecated Use {@link #DefaultLifecycles(LifecycleRegistry,Lookup)} instead + * @deprecated Use {@link #DefaultLifecycles(LifecycleRegistry, Lookup, List)} instead */ @Deprecated public DefaultLifecycles(Map lifecycles, org.codehaus.plexus.logging.Logger logger) { this.customLifecycles = lifecycles; this.lookup = null; this.registry = null; + this.lifecycleProcessors = Collections.emptyList(); } @Inject - public DefaultLifecycles(LifecycleRegistry registry, Lookup lookup) { + public DefaultLifecycles(LifecycleRegistry registry, Lookup lookup, List lifecycleProcessors) { this.lookup = lookup; this.registry = registry; + this.lifecycleProcessors = lifecycleProcessors != null ? lifecycleProcessors : Collections.emptyList(); + } + + /** + * Constructor for use in tests and legacy code that does not need lifecycle processors. + */ + public DefaultLifecycles(LifecycleRegistry registry, Lookup lookup) { + this(registry, lookup, Collections.emptyList()); } /** @@ -93,7 +107,12 @@ public Lifecycle get(String phase) { * We use this to map all phases to the lifecycle that contains it. This is used so that a user can specify the * phase they want to execute, and we can easily determine what lifecycle we need to run. * - * @return A map of lifecycles, indexed on id + *

When {@link LifecycleProcessor} SPI implementations are registered (e.g. to inject + * custom phases declared in {@code .mvn/reactor.xml}), each lifecycle is passed through + * the processor chain before its phases are registered in the map. This allows custom + * phases to be invoked on the command line just like built-in phases. + * + * @return A map of phase name to lifecycle */ public Map getPhaseToLifecycleMap() { if (logger.isDebugEnabled() && !lifecyclesPrinted) { @@ -109,27 +128,30 @@ public Map getPhaseToLifecycleMap() { Map phaseToLifecycleMap = new HashMap<>(); for (Lifecycle lifecycle : getLifeCycles()) { - for (String phase : lifecycle.getPhases()) { + // Apply LifecycleProcessor chain (e.g. reactor.xml phase injections) + Lifecycle processed = applyProcessors(lifecycle); + + for (String phase : processed.getPhases()) { // The first definition wins. - Lifecycle original = phaseToLifecycleMap.put(phase, lifecycle); + Lifecycle original = phaseToLifecycleMap.put(phase, processed); if (original != null && logger.isWarnEnabled()) { logger.warn( "Duplicated lifecycle phase {}. Defined in {} but also in {}", phase, original.getId(), - lifecycle.getId()); + processed.getId()); } } - if (lifecycle.getDelegate() != null) { + if (processed.getDelegate() != null) { for (org.apache.maven.api.Lifecycle.Alias alias : - lifecycle.getDelegate().aliases()) { - Lifecycle original = phaseToLifecycleMap.put(alias.v3Phase(), lifecycle); + processed.getDelegate().aliases()) { + Lifecycle original = phaseToLifecycleMap.put(alias.v3Phase(), processed); if (original != null && logger.isWarnEnabled()) { logger.warn( "Duplicated lifecycle phase {}. Defined in {} but also in {}", alias.v3Phase(), original.getId(), - lifecycle.getId()); + processed.getId()); } } } @@ -138,6 +160,25 @@ public Map getPhaseToLifecycleMap() { return phaseToLifecycleMap; } + /** + * Applies all registered {@link LifecycleProcessor}s to the given lifecycle in order. + */ + private Lifecycle applyProcessors(Lifecycle lifecycle) { + org.apache.maven.api.Lifecycle apiLifecycle = lifecycle.getDelegate() != null ? lifecycle.getDelegate() : null; + if (apiLifecycle == null || lifecycleProcessors.isEmpty()) { + return lifecycle; + } + org.apache.maven.api.Lifecycle processed = apiLifecycle; + for (LifecycleProcessor processor : lifecycleProcessors) { + processed = processor.process(processed); + } + if (processed == apiLifecycle) { + return lifecycle; // unchanged — avoid wrapper allocation + } + // Re-wrap the processed API lifecycle in the legacy Lifecycle shell + return new Lifecycle(registry, processed); + } + /** * Returns an ordered list of lifecycles */ diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycleTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycleTest.java new file mode 100644 index 000000000000..d59b3928f654 --- /dev/null +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/PhaseEnrichedLifecycleTest.java @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.internal.impl; + +import java.util.Collection; +import java.util.List; + +import org.apache.maven.api.Lifecycle; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link PhaseEnrichedLifecycle}. + * + *

In Maven 4 the "default" lifecycle is a tree of nested phases. Phases like + * {@code unit-test} and {@code integration-test} are children of {@code verify}, which is itself + * a child of {@code each}. Custom phases must therefore declare their {@code parent} explicitly. + * + *

Ordering is verified via {@link DefaultLifecycleRegistry#computePhases(Lifecycle)}, which + * performs the full topological sort on the lifecycle DAG. + */ +class PhaseEnrichedLifecycleTest { + + /** InjectedPhase(name, parent, after, before) */ + private static PhaseEnrichedLifecycle.InjectedPhase ip(String name, String parent, String after, String before) { + return new PhaseEnrichedLifecycle.InjectedPhase(name, parent, after, before); + } + + private static Lifecycle defaultLifecycle() { + DefaultLifecycleRegistry registry = new DefaultLifecycleRegistry(List.of()); + return registry.stream() + .filter(lc -> Lifecycle.DEFAULT.equals(lc.id())) + .findFirst() + .orElseThrow(() -> new AssertionError("Default lifecycle not found")); + } + + private static DefaultLifecycleRegistry registry() { + return new DefaultLifecycleRegistry(List.of()); + } + + // ------------------------------------------------------------------------- + // Phase presence + // ------------------------------------------------------------------------- + + @Test + void injectedPhaseAppearsInAllPhases() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("pre-integration", "verify", "unit-test", null))); + + List names = enriched.allPhases().map(Lifecycle.Phase::name).toList(); + assertTrue(names.contains("pre-integration"), "'pre-integration' must appear in allPhases()"); + assertTrue(names.contains("unit-test"), "'unit-test' must still be present"); + assertTrue(names.contains("integration-test"), "'integration-test' must still be present"); + } + + @Test + void injectedPhaseAppearsInV3Phases() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("pre-integration", "verify", "unit-test", null))); + + List names = enriched.v3phases().stream() + .flatMap(Lifecycle.Phase::allPhases) + .map(Lifecycle.Phase::name) + .toList(); + assertTrue(names.contains("pre-integration"), "'pre-integration' must appear in v3phases() tree"); + } + + // ------------------------------------------------------------------------- + // Topological ordering via computePhases() + // ------------------------------------------------------------------------- + + @Test + void computedPhasesRespectAfterConstraint() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("pre-integration", "verify", "unit-test", null))); + + List computed = registry().computePhases(enriched); + + int unitTestIdx = computed.indexOf("unit-test"); + int newIdx = computed.indexOf("pre-integration"); + int itIdx = computed.indexOf("integration-test"); + assertTrue(unitTestIdx >= 0, "'unit-test' must appear in computePhases()"); + assertTrue(newIdx >= 0, "'pre-integration' must appear in computePhases()"); + assertTrue(itIdx >= 0, "'integration-test' must appear in computePhases()"); + assertTrue(unitTestIdx < newIdx, "'pre-integration' must come after 'unit-test'"); + assertTrue(newIdx < itIdx, "'pre-integration' must come before 'integration-test'"); + } + + @Test + void computedPhasesRespectBeforeConstraint() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("pre-integration", "verify", null, "integration-test"))); + + List computed = registry().computePhases(enriched); + + int itIdx = computed.indexOf("integration-test"); + int newIdx = computed.indexOf("pre-integration"); + assertTrue(itIdx >= 0, "'integration-test' must appear in computePhases()"); + assertTrue(newIdx >= 0, "'pre-integration' must appear in computePhases()"); + assertTrue(newIdx < itIdx, "'pre-integration' must precede 'integration-test' in topological order"); + } + + @Test + void computedPhasesWithAfterAndBefore() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = new PhaseEnrichedLifecycle( + base, List.of(ip("pre-integration", "verify", "unit-test", "integration-test"))); + + List computed = registry().computePhases(enriched); + + int unitTestIdx = computed.indexOf("unit-test"); + int itIdx = computed.indexOf("integration-test"); + int newIdx = computed.indexOf("pre-integration"); + assertTrue(unitTestIdx >= 0, "'unit-test' must appear"); + assertTrue(itIdx >= 0, "'integration-test' must appear"); + assertTrue(newIdx >= 0, "'pre-integration' must appear"); + assertTrue(unitTestIdx < newIdx, "'pre-integration' must come after 'unit-test'"); + assertTrue(newIdx < itIdx, "'pre-integration' must come before 'integration-test'"); + } + + // ------------------------------------------------------------------------- + // DAG links on the injected phase + // ------------------------------------------------------------------------- + + @Test + void injectedPhaseHasAfterLink() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("pre-integration", "verify", "unit-test", null))); + + Lifecycle.Phase phase = enriched.allPhases() + .filter(p -> "pre-integration".equals(p.name())) + .findFirst() + .orElseThrow(() -> new AssertionError("'pre-integration' not found in allPhases()")); + + Collection links = phase.links(); + assertEquals(1, links.size(), "injected phase must have exactly one AFTER link"); + Lifecycle.Link link = links.iterator().next(); + assertEquals(Lifecycle.Link.Kind.AFTER, link.kind(), "link must be AFTER"); + assertEquals("unit-test", link.pointer().phase(), "AFTER link must point to 'unit-test'"); + } + + @Test + void injectedPhaseBeforeHasAfterLinkToLeftSibling() { + // When only 'before' is specified, the AFTER link points to the left sibling (not the before anchor) + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("pre-integration", "verify", null, "integration-test"))); + + Lifecycle.Phase phase = enriched.allPhases() + .filter(p -> "pre-integration".equals(p.name())) + .findFirst() + .orElseThrow(() -> new AssertionError("'pre-integration' not found")); + + Collection links = phase.links(); + assertEquals(1, links.size(), "injected phase must have exactly one AFTER link"); + assertEquals(Lifecycle.Link.Kind.AFTER, links.iterator().next().kind()); + // Left sibling when inserting before 'integration-test' in [unit-test, integration-test] is 'unit-test' + assertEquals( + "unit-test", + links.iterator().next().pointer().phase(), + "AFTER link must point to 'unit-test' (left sibling)"); + } + + // ------------------------------------------------------------------------- + // Identity / delegation + // ------------------------------------------------------------------------- + + @Test + void idIsPreserved() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("x", "verify", "unit-test", null))); + assertEquals(Lifecycle.DEFAULT, enriched.id()); + } + + @Test + void aliasesAreDelegated() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("x", "verify", "unit-test", null))); + List baseV3 = + base.aliases().stream().map(Lifecycle.Alias::v3Phase).toList(); + List enrichedV3 = + enriched.aliases().stream().map(Lifecycle.Alias::v3Phase).toList(); + assertEquals(baseV3, enrichedV3, "aliases must be fully delegated to the base lifecycle"); + } + + @Test + void originalPhasesAreRetained() { + Lifecycle base = defaultLifecycle(); + long originalCount = base.allPhases().count(); + + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("x", "verify", "unit-test", null))); + long enrichedCount = enriched.allPhases().count(); + + assertEquals(originalCount + 1, enrichedCount, "exactly one phase should be added"); + } + + // ------------------------------------------------------------------------- + // Unknown parent — graceful fallback + // ------------------------------------------------------------------------- + + @Test + void unknownParentAppendsPhaseWithoutCrash() { + Lifecycle base = defaultLifecycle(); + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("orphan", "no-such-parent", null, null))); + + assertTrue( + enriched.allPhases().anyMatch(p -> "orphan".equals(p.name())), + "phase with unknown parent should still appear (appended at top level)"); + } + + @Test + void appendsToEndOfParentWhenNoAnchor() { + Lifecycle base = defaultLifecycle(); + // parent=verify, no after/before → appended after integration-test + PhaseEnrichedLifecycle enriched = + new PhaseEnrichedLifecycle(base, List.of(ip("post-verify", "verify", null, null))); + + List computed = registry().computePhases(enriched); + int itIdx = computed.indexOf("integration-test"); + int newIdx = computed.indexOf("post-verify"); + assertTrue(itIdx >= 0, "'integration-test' must appear"); + assertTrue(newIdx >= 0, "'post-verify' must appear"); + assertTrue(itIdx < newIdx, "'post-verify' appended at end should come after 'integration-test'"); + } +} From dc26c1e3cffc60844030bfa336eafccdb57d79e4 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 23 Sep 2026 15:10:13 +0000 Subject: [PATCH 05/16] fix: NeedBraces checkstyle violations in ReactorXmlLifecycleProcessorTest --- .../mvn/ReactorXmlLifecycleProcessorTest.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java index e304e4033a16..aff40edf66a6 100644 --- a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java +++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvn/ReactorXmlLifecycleProcessorTest.java @@ -39,10 +39,18 @@ class ReactorXmlLifecycleProcessorTest { private static PhaseInjection phase(String name, String parent, String after, String before) { PhaseInjection.Builder b = PhaseInjection.newBuilder(); - if (name != null) b.name(name); - if (parent != null) b.parent(parent); - if (after != null) b.after(after); - if (before != null) b.before(before); + if (name != null) { + b.name(name); + } + if (parent != null) { + b.parent(parent); + } + if (after != null) { + b.after(after); + } + if (before != null) { + b.before(before); + } return b.build(); } From 1802942432fd2bb91a08880d5d4ff9f168b2e0ad Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 23 Sep 2026 16:06:42 +0000 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20doc=20accuracy=20and=20exception=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/maven-api-core/src/main/mdo/reactor.mdo | 11 ++++++----- .../org/apache/maven/api/spi/LifecycleProcessor.java | 11 +++++++---- .../apache/maven/cling/invoker/mvn/MavenParser.java | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/maven-api-core/src/main/mdo/reactor.mdo b/api/maven-api-core/src/main/mdo/reactor.mdo index eade46e4d037..bde63c609018 100644 --- a/api/maven-api-core/src/main/mdo/reactor.mdo +++ b/api/maven-api-core/src/main/mdo/reactor.mdo @@ -81,8 +81,9 @@ aliases - Named build aliases. The first non-flag argument on the command line is matched against - alias names; if found it is replaced by the alias expansion. + Named build aliases. Every argument on the command line is checked against alias + names in order; each match is replaced in-place by the alias expansion tokens. + Expansion is not recursive. 1.0.0+ false @@ -171,9 +172,9 @@ Alias - A named build alias. When the first non-flag argument matches the alias name, - it is replaced by the alias expansion. Supports both inline text (shorthand, whitespace-separated) - and structured <arg> elements for values that contain spaces. + A named build alias. Every argument on the command line is checked against alias names; + when a match is found it is replaced in-place by the alias expansion tokens. Supports both inline + text (shorthand, whitespace-separated) and structured <arg> elements for values that contain spaces. 1.0.0+ diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java index 1fa7be335d17..0d39b1bcd72d 100644 --- a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java @@ -32,8 +32,10 @@ * lifecycle DAG. Multiple processors are applied in order; each one receives the output of the * previous one. * - *

Processors are called once per lifecycle per session, after the base lifecycles have been - * assembled by the {@link LifecycleProvider}s, so they have access to full session context. + *

Processors are called each time the lifecycle phase map is rebuilt (potentially multiple + * times per build). Implementations must be stateless and idempotent — the same lifecycle + * input must always produce the same output. They receive the output of the previous processor + * in the chain. * *

Example — injecting a {@code docker-push} phase after {@code deploy}: *

{@code
@@ -41,8 +43,9 @@
  * public class DockerLifecycleProcessor implements LifecycleProcessor {
  *     public Lifecycle process(Lifecycle lifecycle) {
  *         if (!Lifecycle.DEFAULT.equals(lifecycle.id())) return lifecycle;
- *         return LifecycleProcessor.withInjectedPhase(lifecycle,
- *                 "docker-push", "deploy", null);
+ *         // Use PhaseEnrichedLifecycle (from impl/maven-core) to inject custom phases
+ *         return new PhaseEnrichedLifecycle(lifecycle,
+ *                 List.of(new PhaseEnrichedLifecycle.InjectedPhase("docker-push", null, "deploy", null)));
  *     }
  * }
  * }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java index b88a19475e97..d0a41c750222 100644 --- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java +++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvn/MavenParser.java @@ -185,7 +185,7 @@ protected MavenOptions parseMavenReactorOptions(List args) { return options; } catch (ParseException e) { throw new IllegalArgumentException( - "Failed to parse arguments from reactor.xml : " + e.getMessage(), e.getCause()); + "Failed to parse arguments from reactor.xml : " + e.getMessage(), e); } } From 1e9fc259512654c0ab58eaaf13ca7b40e7b9c14d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 23 Sep 2026 19:11:15 +0000 Subject: [PATCH 07/16] fix: remove internal class from SPI Javadoc example, fix javadoc unknown tag --- api/maven-api-core/src/main/mdo/reactor.mdo | 2 +- .../apache/maven/api/spi/LifecycleProcessor.java | 14 ++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/api/maven-api-core/src/main/mdo/reactor.mdo b/api/maven-api-core/src/main/mdo/reactor.mdo index bde63c609018..90d6f321ebbe 100644 --- a/api/maven-api-core/src/main/mdo/reactor.mdo +++ b/api/maven-api-core/src/main/mdo/reactor.mdo @@ -174,7 +174,7 @@ Alias A named build alias. Every argument on the command line is checked against alias names; when a match is found it is replaced in-place by the alias expansion tokens. Supports both inline - text (shorthand, whitespace-separated) and structured <arg> elements for values that contain spaces. + text (shorthand, whitespace-separated) and structured arg elements for values that contain spaces. 1.0.0+ diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java index 0d39b1bcd72d..5f9d7dfcff46 100644 --- a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java @@ -37,18 +37,8 @@ * input must always produce the same output. They receive the output of the previous processor * in the chain. * - *

Example — injecting a {@code docker-push} phase after {@code deploy}: - *

{@code
- * @Named
- * public class DockerLifecycleProcessor implements LifecycleProcessor {
- *     public Lifecycle process(Lifecycle lifecycle) {
- *         if (!Lifecycle.DEFAULT.equals(lifecycle.id())) return lifecycle;
- *         // Use PhaseEnrichedLifecycle (from impl/maven-core) to inject custom phases
- *         return new PhaseEnrichedLifecycle(lifecycle,
- *                 List.of(new PhaseEnrichedLifecycle.InjectedPhase("docker-push", null, "deploy", null)));
- *     }
- * }
- * }
+ *

See {@code ReactorXmlLifecycleProcessor} in {@code impl/maven-core} for a concrete + * reference implementation that injects phases from {@code .mvn/reactor.xml}. * * @since 4.1.0 */ From f8b9ff914a8975f755496f3af0cd66eca0b5a472 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 24 Sep 2026 06:59:13 +0000 Subject: [PATCH 08/16] fix: wrong module reference in LifecycleProcessor Javadoc, escape tag in reactor.mdo Javadoc --- api/maven-api-core/src/main/mdo/reactor.mdo | 4 ++-- .../java/org/apache/maven/api/spi/LifecycleProcessor.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/maven-api-core/src/main/mdo/reactor.mdo b/api/maven-api-core/src/main/mdo/reactor.mdo index 90d6f321ebbe..b75b08b89740 100644 --- a/api/maven-api-core/src/main/mdo/reactor.mdo +++ b/api/maven-api-core/src/main/mdo/reactor.mdo @@ -59,7 +59,7 @@ optionArgs - Structured CLI options — each <arg> element is one option token. + Structured CLI options — each {@literal <arg>} element is one option token. Use instead of {@link #getOptions()} when option values contain spaces. Mutually exclusive with options. 1.0.0+ @@ -194,7 +194,7 @@ args - Structured form: each <arg> element is one argument token. + Structured form: each {@literal <arg>} element is one argument token. Use when argument values contain spaces. Mutually exclusive with content. 1.0.0+ false diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java index 5f9d7dfcff46..1104d5af29f0 100644 --- a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/LifecycleProcessor.java @@ -37,7 +37,7 @@ * input must always produce the same output. They receive the output of the previous processor * in the chain. * - *

See {@code ReactorXmlLifecycleProcessor} in {@code impl/maven-core} for a concrete + *

See {@code ReactorXmlLifecycleProcessor} in {@code impl/maven-cli} for a concrete * reference implementation that injects phases from {@code .mvn/reactor.xml}. * * @since 4.1.0 From 63ce016f02f6d96b684d9b778f2a0f1afa821db8 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 24 Sep 2026 13:10:40 +0000 Subject: [PATCH 09/16] fix: validate phase() predicate name; document dotless-groupId limitation in CoordinatePredicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MojoExecutionFilter: throw IllegalArgumentException when phase() receives a blank phase name (e.g. phase() or phase( )) instead of silently creating a PhasePredicate that never matches - CoordinatePredicate: document in class Javadoc, parse() Javadoc, and inline comments that the G:A detection heuristic requires the groupId to contain at least one '.'; groupIds without a dot (e.g. commons-io:commons-io) are routed to prefix-based matching — users should use the :A form instead - Add tests: emptyPhaseParensThrows, blankPhaseParensThrows (MojoExecutionFilterTest); dotlessGroupIdIsMisroutedToPrefixMode (CoordinatePredicateTest) --- .../internal/filter/CoordinatePredicate.java | 11 +++++++++-- .../internal/filter/MojoExecutionFilter.java | 3 +++ .../internal/filter/CoordinatePredicateTest.java | 15 +++++++++++++++ .../internal/filter/MojoExecutionFilterTest.java | 12 ++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java index aedd8e2045db..f66a986b7fd9 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java @@ -38,7 +38,10 @@ *

    *
  • {@code *} — matches every mojo execution
  • *
  • {@code :A} — any groupId, specific artifactId (e.g. {@code :maven-enforcer-plugin})
  • - *
  • {@code G:A} — exact groupId:artifactId (e.g. {@code org.apache.maven.plugins:maven-enforcer-plugin})
  • + *
  • {@code G:A} — exact groupId:artifactId (e.g. {@code org.apache.maven.plugins:maven-enforcer-plugin}). + * The groupId must contain at least one {@code '.'} for this form to be recognised; + * groupIds without a dot (e.g. {@code commons-io}) are routed to prefix-based matching instead. + * Use the {@code :A} form to match by artifactId only when the groupId has no dot.
  • *
  • {@code P} — plugin prefix (e.g. {@code enforcer}), resolved against * {@link MojoExecution#getMojoDescriptor()} goal prefix
  • *
  • {@code P:v:g} — prefix + version + goal
  • @@ -93,7 +96,8 @@ private CoordinatePredicate( *
      *
    • {@code *} — match all
    • *
    • {@code :A} — by artifactId only
    • - *
    • {@code G:A} — by groupId:artifactId (token contains {@code :} after first char)
    • + *
    • {@code G:A} — by groupId:artifactId. The groupId must contain at least one {@code '.'} to be + * recognised as a G:A form; groupIds without a dot are treated as a plugin prefix instead.
    • *
    • {@code P} — by prefix
    • *
    • {@code P:v:g} — by prefix + version + goal
    • *
    • {@code P:v:g@e} — by prefix + version + goal + executionId
    • @@ -122,6 +126,9 @@ public static CoordinatePredicate parse(String token) { // Try to detect G:A form: the token contains ':' AND the part before the first ':' looks like a // groupId (contains a '.' suggesting it's a Java package name like org.apache.maven). // This distinguishes "org.apache.maven.plugins:maven-enforcer-plugin" from "enforcer:3.1.0:enforce". + // Limitation: groupIds that do not contain a '.' (e.g. "commons-io:commons-io") are misrouted + // to prefix-based matching. For such groupIds use the explicit ":A" form for artifactId-only + // matching, or the full "G:A" form only when the groupId contains at least one '.'. int firstColon = token.indexOf(':'); if (firstColon > 0 && token.substring(0, firstColon).contains(".")) { // G:A[:v[:g[@e]]] form diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java index c43048a646db..b0c7e22f95ed 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java @@ -78,6 +78,9 @@ public static List parse(String expression) { private static FilterPredicate parseToken(String token) { if (token.startsWith("phase(") && token.endsWith(")")) { String phaseName = token.substring("phase(".length(), token.length() - 1); + if (phaseName.isBlank()) { + throw new IllegalArgumentException("phase() predicate requires a non-blank phase name"); + } return new PhasePredicate(phaseName); } return CoordinatePredicate.parse(token); diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java index df744a0c305a..e627b9907171 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java @@ -162,4 +162,19 @@ void wildcardMatchesEvenWithNullPlugin() { when(exec.getPlugin()).thenReturn(null); assertTrue(CoordinatePredicate.MATCH_ALL.matches(exec)); } + + @Test + void dotlessGroupIdIsMisroutedToPrefixMode() { + // GroupIds without a '.' (e.g. "commons-io") cannot be used in G:A form — they are routed + // to prefix-based matching. Document this: the token "commons-io:commons-io" is treated as + // prefix="commons-io", version="commons-io" — it will NOT match an execution with groupId + // "commons-io". Users should use ":commons-io" (artifactId-only form) instead. + MojoExecution exec = execution("commons-io", "commons-io", "2.15.1", "commons-io", "copy", "default"); + // Prefix "commons-io" won't match goal prefix "commons-io" if the descriptor prefix happens + // to be the same, but the version segment "commons-io" will never match "2.15.1" → no match. + CoordinatePredicate p = CoordinatePredicate.parse("commons-io:commons-io"); + assertFalse(p.matches(exec), "dotless groupId in G:A form is misrouted to prefix mode and should not match"); + // The correct workaround: use :A form to match by artifactId + assertTrue(CoordinatePredicate.parse(":commons-io").matches(exec)); + } } diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java index 3cdeb119d428..fdbdab9d7b10 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java @@ -100,6 +100,18 @@ void parseResultIsImmutable() { UnsupportedOperationException.class, () -> predicates.add(CoordinatePredicate.MATCH_ALL)); } + @Test + void emptyPhaseParensThrows() { + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, () -> MojoExecutionFilter.parse("phase()")); + } + + @Test + void blankPhaseParensThrows() { + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, () -> MojoExecutionFilter.parse("phase( )")); + } + // ── propertyName ───────────────────────────────────────────────────────── @Test From 54de779c5a6549f9a71992216fb2918c2fbc864d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 24 Sep 2026 13:18:54 +0000 Subject: [PATCH 10/16] fix: document dotted-prefix misrouting limitation and fix inaccurate test comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoordinatePredicate.java: add note to the P bullet in class-level Javadoc that prefixes containing a '.' (e.g. io.smallrye) are routed to G:A matching instead of prefix-based matching — symmetric to the already documented dotless-groupId case. CoordinatePredicateTest.java: fix inaccurate comment in dotlessGroupIdIsMisroutedToPrefixMode — the prefix check actually succeeds (goalPrefix="commons-io" matches), but the version check fails ("commons-io" != "2.15.1"). The previous comment implied the prefix would not match, which is wrong. --- .../lifecycle/internal/filter/CoordinatePredicate.java | 4 +++- .../lifecycle/internal/filter/CoordinatePredicateTest.java | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java index f66a986b7fd9..a5a435262684 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java @@ -43,7 +43,9 @@ * groupIds without a dot (e.g. {@code commons-io}) are routed to prefix-based matching instead. * Use the {@code :A} form to match by artifactId only when the groupId has no dot. *
    • {@code P} — plugin prefix (e.g. {@code enforcer}), resolved against - * {@link MojoExecution#getMojoDescriptor()} goal prefix
    • + * {@link MojoExecution#getMojoDescriptor()} goal prefix. + * The prefix must not contain a {@code '.'}; prefixes with a dot + * (e.g. {@code io.smallrye}) are routed to groupId:artifactId matching instead. *
    • {@code P:v:g} — prefix + version + goal
    • *
    • {@code P:v:g@e} — prefix + version + goal + executionId
    • *
    diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java index e627b9907171..717f4e84a949 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java @@ -170,8 +170,10 @@ void dotlessGroupIdIsMisroutedToPrefixMode() { // prefix="commons-io", version="commons-io" — it will NOT match an execution with groupId // "commons-io". Users should use ":commons-io" (artifactId-only form) instead. MojoExecution exec = execution("commons-io", "commons-io", "2.15.1", "commons-io", "copy", "default"); - // Prefix "commons-io" won't match goal prefix "commons-io" if the descriptor prefix happens - // to be the same, but the version segment "commons-io" will never match "2.15.1" → no match. + // "commons-io:commons-io" is parsed as P:v form (prefix="commons-io", version="commons-io"). + // The prefix check succeeds (goalPrefix="commons-io" matches), but the version check fails + // ("commons-io" != "2.15.1"), so the predicate returns false. This demonstrates that the + // misrouting produces a non-matching predicate, not a silent match. CoordinatePredicate p = CoordinatePredicate.parse("commons-io:commons-io"); assertFalse(p.matches(exec), "dotless groupId in G:A form is misrouted to prefix mode and should not match"); // The correct workaround: use :A form to match by artifactId From 3bd8268a4abeca3c398e5722b69587efced23c86 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 7 Oct 2026 13:10:56 +0000 Subject: [PATCH 11/16] fix: export org.apache.maven.api.reactor in module-info.java --- api/maven-api-core/src/main/java/module-info.java | 1 + 1 file changed, 1 insertion(+) diff --git a/api/maven-api-core/src/main/java/module-info.java b/api/maven-api-core/src/main/java/module-info.java index 7cbfb5792dfe..8768f512734a 100644 --- a/api/maven-api-core/src/main/java/module-info.java +++ b/api/maven-api-core/src/main/java/module-info.java @@ -32,6 +32,7 @@ exports org.apache.maven.api.feature; exports org.apache.maven.api.plugin; exports org.apache.maven.api.plugin.annotations; + exports org.apache.maven.api.reactor; exports org.apache.maven.api.services; exports org.apache.maven.api.services.xml; } From 7d50e9ed92881adce615e141a67aae1135d6c24e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 7 Oct 2026 13:31:40 +0000 Subject: [PATCH 12/16] fix: add 'requires java.logging' to maven-logging module-info MavenJulHandler uses java.util.logging (Handler, Level, LogManager, LogRecord, Logger) but the module descriptor was missing the explicit requires directive, causing a compilation failure on JDK 17+ module path: package java.util.logging is not visible (module org.apache.maven.logging does not read java.logging). --- impl/maven-logging/src/main/java/module-info.java | 1 + 1 file changed, 1 insertion(+) diff --git a/impl/maven-logging/src/main/java/module-info.java b/impl/maven-logging/src/main/java/module-info.java index 57e1df701468..2a0b7ddedb63 100644 --- a/impl/maven-logging/src/main/java/module-info.java +++ b/impl/maven-logging/src/main/java/module-info.java @@ -21,6 +21,7 @@ requires org.apache.maven.api; requires org.apache.maven.jline; requires org.slf4j; + requires java.logging; exports org.apache.maven.logging.api; exports org.apache.maven.slf4j to From 2a2afb50d7f80b5dcdbae08f0c37c83512bd09c5 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 7 Oct 2026 13:49:53 +0000 Subject: [PATCH 13/16] fix: export org.apache.maven.api.build.report in module-info.java --- api/maven-api-core/src/main/java/module-info.java | 1 + 1 file changed, 1 insertion(+) diff --git a/api/maven-api-core/src/main/java/module-info.java b/api/maven-api-core/src/main/java/module-info.java index 8768f512734a..694cf22c31bc 100644 --- a/api/maven-api-core/src/main/java/module-info.java +++ b/api/maven-api-core/src/main/java/module-info.java @@ -32,6 +32,7 @@ exports org.apache.maven.api.feature; exports org.apache.maven.api.plugin; exports org.apache.maven.api.plugin.annotations; + exports org.apache.maven.api.build.report; exports org.apache.maven.api.reactor; exports org.apache.maven.api.services; exports org.apache.maven.api.services.xml; From aa5f8bf94da8a7af6b88ae23aab5ca681a2e2cc3 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 9 Oct 2026 09:18:18 +0000 Subject: [PATCH 14/16] refactor: extract lifecycle execution filter into standalone PR #13392 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove filter feature (maven.lifecycle.filter) from this PR — it has been extracted to a dedicated PR #13392 which targets master independently. This PR now focuses on reactor.xml (features 1 and 2): - .mvn/reactor.xml file format (consolidates maven.config + extensions.xml) - Named build aliases --- .../java/org/apache/maven/api/Constants.java | 8 - .../lifecycle/internal/MojoExecutor.java | 26 +- .../internal/filter/CoordinatePredicate.java | 250 ------------------ .../internal/filter/FilterPredicate.java | 42 --- .../internal/filter/MojoExecutionFilter.java | 113 -------- .../internal/filter/PhasePredicate.java | 49 ---- .../filter/CoordinatePredicateTest.java | 182 ------------- .../filter/MojoExecutionFilterTest.java | 130 --------- .../internal/filter/PhasePredicateTest.java | 66 ----- ...venITmng12535LifecycleFilterPhaseTest.java | 62 ----- ...mng12536LifecycleFilterCoordinateTest.java | 62 ----- .../mng-12535-lifecycle-filter-phase/pom.xml | 25 -- .../apache/maven/its/mng12535/SampleTest.java | 30 --- .../pom.xml | 17 -- .../apache/maven/its/mng12536/SampleTest.java | 30 --- 15 files changed, 2 insertions(+), 1090 deletions(-) delete mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java delete mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java delete mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java delete mode 100644 impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java delete mode 100644 impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java delete mode 100644 impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java delete mode 100644 impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java delete mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java delete mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java delete mode 100644 its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml delete mode 100644 its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java delete mode 100644 its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml delete mode 100644 its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java index 74a46d3ba78a..117d9f11878d 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java @@ -868,13 +868,5 @@ public final class Constants { @Config(type = "java.lang.Boolean", defaultValue = "false") public static final String MAVEN_MODEL_DEPENDENCY_INTERPOLATION_FULL = "maven.model.dependencyInterpolation.full"; - /** - * Comma-separated list of mojo execution filter predicates. Matching executions are skipped at runtime. - * Supported forms: {@code *}, {@code :A}, {@code G:A}, {@code P}, {@code P:v:g}, {@code P:v:g@e}, - * {@code phase(name)}. - */ - @Config - public static final String MAVEN_LIFECYCLE_FILTER = "maven.lifecycle.filter"; - private Constants() {} } diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java index 7e6a1ebceee4..bc7e55c1bb70 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java @@ -29,7 +29,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; @@ -48,8 +47,6 @@ import org.apache.maven.internal.MultilineMessageHelper; import org.apache.maven.lifecycle.LifecycleExecutionException; import org.apache.maven.lifecycle.MissingProjectException; -import org.apache.maven.lifecycle.internal.filter.FilterPredicate; -import org.apache.maven.lifecycle.internal.filter.MojoExecutionFilter; import org.apache.maven.plugin.BuildPluginManager; import org.apache.maven.plugin.MavenPluginManager; import org.apache.maven.plugin.MojoExecution; @@ -165,39 +162,20 @@ public void execute(final MavenSession session, final List mojoEx final PhaseRecorder phaseRecorder = new PhaseRecorder(session.getCurrentProject()); - // Resolve the mojo execution filter once per project execution. - // -Dmaven.lifecycle.filter takes priority; reactor.xml is the fallback. - final List filterPredicates = resolveFilterPredicates(session); - mojosExecutionStrategy.get().execute(mojoExecutions, session, new MojoExecutionRunner() { @Override public void run(MojoExecution mojoExecution) throws LifecycleExecutionException { - MojoExecutor.this.execute(session, mojoExecution, dependencyContext, phaseRecorder, filterPredicates); + MojoExecutor.this.execute(session, mojoExecution, dependencyContext, phaseRecorder); } }); } - /** - * Resolves the mojo execution filter predicates for this build. - * Uses {@code -Dmaven.lifecycle.filter} user property; returns an empty list if absent. - */ - private List resolveFilterPredicates(MavenSession session) { - Properties userProps = session.getUserProperties(); - String filterExpression = userProps != null ? userProps.getProperty(MojoExecutionFilter.PROPERTY_NAME) : null; - return MojoExecutionFilter.parse(filterExpression); - } - private void execute( MavenSession session, MojoExecution mojoExecution, DependencyContext dependencyContext, - PhaseRecorder phaseRecorder, - List filterPredicates) + PhaseRecorder phaseRecorder) throws LifecycleExecutionException { - if (MojoExecutionFilter.matches(mojoExecution, filterPredicates)) { - eventCatapult.fire(ExecutionEvent.Type.MojoSkipped, session, mojoExecution); - return; - } execute(session, mojoExecution, dependencyContext); phaseRecorder.observeExecution(mojoExecution); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java deleted file mode 100644 index a5a435262684..000000000000 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicate.java +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import org.apache.maven.api.MojoExecution; -import org.apache.maven.api.plugin.descriptor.PluginDescriptor; - -/** - * A {@link FilterPredicate} that matches {@link MojoExecution}s by plugin coordinate or prefix. - * - *

    Syntax: {@code ([G[:A]]|P)[:v][:g[@e]]}

    - * - *

    The {@code @} separator for execution ID is compatible with Maven's existing - * {@code plugin:version:goal@executionId} notation used in - * {@code DefaultLifecycleExecutionPlanCalculator} for goal tasks. - * - *

    Matching uses {@link MojoExecution#getDescriptor()} for goal-level fields and - * {@link MojoExecution#getPlugin()} for plugin-level coordinates (groupId, artifactId, version, - * goal prefix). - * - *

    Forms: - *

      - *
    • {@code *} — matches every mojo execution
    • - *
    • {@code :A} — any groupId, specific artifactId (e.g. {@code :maven-enforcer-plugin})
    • - *
    • {@code G:A} — exact groupId:artifactId (e.g. {@code org.apache.maven.plugins:maven-enforcer-plugin}). - * The groupId must contain at least one {@code '.'} for this form to be recognised; - * groupIds without a dot (e.g. {@code commons-io}) are routed to prefix-based matching instead. - * Use the {@code :A} form to match by artifactId only when the groupId has no dot.
    • - *
    • {@code P} — plugin prefix (e.g. {@code enforcer}), resolved against - * {@link MojoExecution#getMojoDescriptor()} goal prefix. - * The prefix must not contain a {@code '.'}; prefixes with a dot - * (e.g. {@code io.smallrye}) are routed to groupId:artifactId matching instead.
    • - *
    • {@code P:v:g} — prefix + version + goal
    • - *
    • {@code P:v:g@e} — prefix + version + goal + executionId
    • - *
    - * - *

    When any field is {@code null} or not specified, it is treated as a wildcard (matches any value). - * - * @since 4.1.0 - */ -public class CoordinatePredicate implements FilterPredicate { - - /** Wildcard token — matches any value. */ - private static final String ANY = null; - - private final boolean matchAll; - private final String groupId; // null = any, non-null = exact match - private final String artifactId; // null = any, non-null = exact match - private final String prefix; // null = not used, non-null = match by goal prefix - private final String version; // null = any - private final String goal; // null = any - private final String executionId; // null = any - - /** Wildcard predicate — matches everything. */ - public static final CoordinatePredicate MATCH_ALL = new CoordinatePredicate(); - - private CoordinatePredicate() { - this.matchAll = true; - this.groupId = ANY; - this.artifactId = ANY; - this.prefix = ANY; - this.version = ANY; - this.goal = ANY; - this.executionId = ANY; - } - - private CoordinatePredicate( - String groupId, String artifactId, String prefix, String version, String goal, String executionId) { - this.matchAll = false; - this.groupId = groupId; - this.artifactId = artifactId; - this.prefix = prefix; - this.version = version; - this.goal = goal; - this.executionId = executionId; - } - - /** - * Parses a coordinate predicate from a string token. - * - *

    Supported forms: - *

      - *
    • {@code *} — match all
    • - *
    • {@code :A} — by artifactId only
    • - *
    • {@code G:A} — by groupId:artifactId. The groupId must contain at least one {@code '.'} to be - * recognised as a G:A form; groupIds without a dot are treated as a plugin prefix instead.
    • - *
    • {@code P} — by prefix
    • - *
    • {@code P:v:g} — by prefix + version + goal
    • - *
    • {@code P:v:g@e} — by prefix + version + goal + executionId
    • - *
    - * - * @param token the filter expression token (not {@code null}, not blank) - * @return the parsed predicate - */ - public static CoordinatePredicate parse(String token) { - if ("*".equals(token)) { - return MATCH_ALL; - } - - if (token.startsWith(":")) { - // :A form — any groupId, specific artifactId, optional :v:g[@e] - // e.g. ":maven-enforcer-plugin" or ":maven-enforcer-plugin:3.0.0:enforce@enforce-id" - String rest = token.substring(1); // remove leading ':' - String[] parts = rest.split(":", 3); - String artifactId = emptyToNull(parts[0]); - String version = parts.length > 1 ? emptyToNull(parts[1]) : null; - String goalAndExec = parts.length > 2 ? parts[2] : null; - String[] ge = splitGoalExecution(goalAndExec); - return new CoordinatePredicate(ANY, artifactId, ANY, version, ge[0], ge[1]); - } - - // Try to detect G:A form: the token contains ':' AND the part before the first ':' looks like a - // groupId (contains a '.' suggesting it's a Java package name like org.apache.maven). - // This distinguishes "org.apache.maven.plugins:maven-enforcer-plugin" from "enforcer:3.1.0:enforce". - // Limitation: groupIds that do not contain a '.' (e.g. "commons-io:commons-io") are misrouted - // to prefix-based matching. For such groupIds use the explicit ":A" form for artifactId-only - // matching, or the full "G:A" form only when the groupId contains at least one '.'. - int firstColon = token.indexOf(':'); - if (firstColon > 0 && token.substring(0, firstColon).contains(".")) { - // G:A[:v[:g[@e]]] form - String[] parts = token.split(":", 4); - String groupId = emptyToNull(parts[0]); - String artifactId = parts.length > 1 ? emptyToNull(parts[1]) : null; - String version = parts.length > 2 ? emptyToNull(parts[2]) : null; - String goalAndExec = parts.length > 3 ? parts[3] : null; - String[] ge = splitGoalExecution(goalAndExec); - return new CoordinatePredicate(groupId, artifactId, ANY, version, ge[0], ge[1]); - } - - // P[:v[:g[@e]]] form — prefix-based - String[] parts = token.split(":", 3); - String prefix = emptyToNull(parts[0]); - String version = parts.length > 1 ? emptyToNull(parts[1]) : null; - String goalAndExec = parts.length > 2 ? parts[2] : null; - String[] ge = splitGoalExecution(goalAndExec); - return new CoordinatePredicate(ANY, ANY, prefix, version, ge[0], ge[1]); - } - - /** - * Splits a {@code "goal"} or {@code "goal@executionId"} string into a 2-element array - * {@code [goal, executionId]}. Either element may be {@code null}. - */ - private static String[] splitGoalExecution(String goalAndExec) { - if (goalAndExec == null || goalAndExec.isEmpty()) { - return new String[] {null, null}; - } - int atIdx = goalAndExec.indexOf('@'); - if (atIdx < 0) { - return new String[] {emptyToNull(goalAndExec), null}; - } - return new String[] {emptyToNull(goalAndExec.substring(0, atIdx)), emptyToNull(goalAndExec.substring(atIdx + 1)) - }; - } - - private static String emptyToNull(String s) { - return (s == null || s.isEmpty()) ? null : s; - } - - @Override - public boolean matches(MojoExecution execution) { - if (matchAll) { - return true; - } - - PluginDescriptor descriptor = - execution.getPlugin() != null ? execution.getPlugin().getDescriptor() : null; - - // prefix-based matching - if (prefix != null) { - String execPrefix = descriptor != null ? descriptor.getGoalPrefix() : null; - if (!prefix.equals(execPrefix)) { - return false; - } - } else { - // coordinate-based matching - if (groupId != null) { - String execGroupId = descriptor != null ? descriptor.getGroupId() : null; - if (!groupId.equals(execGroupId)) { - return false; - } - } - if (artifactId != null) { - String execArtifactId = descriptor != null ? descriptor.getArtifactId() : null; - if (!artifactId.equals(execArtifactId)) { - return false; - } - } - } - - if (version != null) { - String execVersion = descriptor != null ? descriptor.getVersion() : null; - if (!version.equals(execVersion)) { - return false; - } - } - if (goal != null && !goal.equals(execution.getGoal())) { - return false; - } - if (executionId != null && !executionId.equals(execution.getExecutionId())) { - return false; - } - - return true; - } - - @Override - public String toString() { - if (matchAll) { - return "*"; - } - StringBuilder sb = new StringBuilder(); - if (prefix != null) { - sb.append(prefix); - } else { - if (groupId != null) { - sb.append(groupId); - } - sb.append(':'); - if (artifactId != null) { - sb.append(artifactId); - } - } - if (version != null) { - sb.append(':').append(version); - } - if (goal != null) { - sb.append(':').append(goal); - if (executionId != null) { - sb.append('@').append(executionId); - } - } - return sb.toString(); - } -} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java deleted file mode 100644 index 61051735826e..000000000000 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/FilterPredicate.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import org.apache.maven.api.MojoExecution; - -/** - * Predicate for filtering {@link MojoExecution} entries from the lifecycle build plan. - * - *

    Used by {@code -Dmaven.lifecycle.filter} and by {@code } in {@code .mvn/reactor.xml} - * to remove matching executions before the build starts. - * New predicate types can be added without breaking existing filter expressions. - * - * @see MojoExecutionFilter - * @since 4.1.0 - */ -public interface FilterPredicate { - - /** - * Returns {@code true} if the given mojo execution should be excluded from the build plan. - * - * @param execution the mojo execution to test - * @return {@code true} to remove this execution from the plan, {@code false} to keep it - */ - boolean matches(MojoExecution execution); -} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java deleted file mode 100644 index b0c7e22f95ed..000000000000 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilter.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import java.util.ArrayList; -import java.util.List; - -import org.apache.maven.api.Constants; -import org.apache.maven.api.MojoExecution; -import org.apache.maven.internal.impl.DefaultMojoExecution; - -/** - * Parses the {@code maven.lifecycle.filter} user property value into a list of {@link FilterPredicate}s, - * and applies them at mojo execution time in {@code MojoExecutor}. - * - *

    The property value is a comma-separated list of predicates, OR-ed together: - * a mojo execution matching any predicate is skipped (a {@code MojoSkipped} event is fired). - * - *

    Supported predicate forms: - *

      - *
    • {@code *} — skip all mojo executions
    • - *
    • {@code :A} — skip by artifactId (e.g. {@code :maven-enforcer-plugin})
    • - *
    • {@code G:A} — skip by groupId:artifactId
    • - *
    • {@code P} — skip by plugin prefix (e.g. {@code enforcer})
    • - *
    • {@code P:v:g} — skip by prefix:version:goal
    • - *
    • {@code P:v:g@e} — skip by prefix:version:goal@executionId
    • - *
    • {@code phase(name)} — skip all mojos bound to the named phase
    • - *
    - * - * @since 4.1.0 - */ -public class MojoExecutionFilter { - - /** The name of the user property that activates the filter. */ - public static final String PROPERTY_NAME = Constants.MAVEN_LIFECYCLE_FILTER; - - private MojoExecutionFilter() { - // utility class - } - - /** - * Parses a filter expression string into a list of {@link FilterPredicate}s. - * - * @param expression the comma-separated filter expression (may be {@code null} or blank) - * @return list of parsed predicates; empty if the expression is absent or blank - */ - public static List parse(String expression) { - if (expression == null || expression.isBlank()) { - return List.of(); - } - List predicates = new ArrayList<>(); - for (String token : expression.split(",")) { - token = token.strip(); - if (token.isEmpty()) { - continue; - } - predicates.add(parseToken(token)); - } - return List.copyOf(predicates); - } - - private static FilterPredicate parseToken(String token) { - if (token.startsWith("phase(") && token.endsWith(")")) { - String phaseName = token.substring("phase(".length(), token.length() - 1); - if (phaseName.isBlank()) { - throw new IllegalArgumentException("phase() predicate requires a non-blank phase name"); - } - return new PhasePredicate(phaseName); - } - return CoordinatePredicate.parse(token); - } - - /** - * Returns {@code true} if the given legacy mojo execution matches any predicate in the list - * and should be skipped. - * - *

    Uses {@link DefaultMojoExecution} with a {@code null} session: only string fields - * ({@link MojoExecution#getDescriptor()}, {@link MojoExecution#getGoal()}, etc.) are accessed - * by the predicates. - * - * @param execution the legacy mojo execution to test - * @param predicates the predicates to apply (OR-ed) - * @return {@code true} if the execution should be skipped - */ - public static boolean matches(org.apache.maven.plugin.MojoExecution execution, List predicates) { - if (predicates.isEmpty()) { - return false; - } - MojoExecution apiExecution = new DefaultMojoExecution(null, execution); - for (FilterPredicate predicate : predicates) { - if (predicate.matches(apiExecution)) { - return true; - } - } - return false; - } -} diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java deleted file mode 100644 index 015e7f471a30..000000000000 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/filter/PhasePredicate.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import org.apache.maven.api.MojoExecution; - -/** - * A {@link FilterPredicate} that matches all {@link MojoExecution}s bound to a specific lifecycle phase. - * - *

    Syntax in {@code -Dmaven.lifecycle.filter}: {@code phase()} - * - *

    Example: {@code -Dmaven.lifecycle.filter=phase(test)} removes all mojos bound to the {@code test} phase. - * - * @since 4.1.0 - */ -public class PhasePredicate implements FilterPredicate { - - private final String phaseName; - - public PhasePredicate(String phaseName) { - this.phaseName = phaseName; - } - - @Override - public boolean matches(MojoExecution execution) { - return phaseName.equals(execution.getLifecyclePhase()); - } - - @Override - public String toString() { - return "phase(" + phaseName + ")"; - } -} diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java deleted file mode 100644 index 717f4e84a949..000000000000 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/CoordinatePredicateTest.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import org.apache.maven.api.MojoExecution; -import org.apache.maven.api.Plugin; -import org.apache.maven.api.plugin.descriptor.PluginDescriptor; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * Unit tests for {@link CoordinatePredicate}. - */ -class CoordinatePredicateTest { - - private static MojoExecution execution( - String groupId, String artifactId, String version, String goalPrefix, String goal, String executionId) { - PluginDescriptor descriptor = mock(PluginDescriptor.class); - when(descriptor.getGroupId()).thenReturn(groupId); - when(descriptor.getArtifactId()).thenReturn(artifactId); - when(descriptor.getVersion()).thenReturn(version); - when(descriptor.getGoalPrefix()).thenReturn(goalPrefix); - - Plugin plugin = mock(Plugin.class); - when(plugin.getDescriptor()).thenReturn(descriptor); - - MojoExecution exec = mock(MojoExecution.class); - when(exec.getPlugin()).thenReturn(plugin); - when(exec.getGoal()).thenReturn(goal); - when(exec.getExecutionId()).thenReturn(executionId); - return exec; - } - - private static MojoExecution surefire() { - return execution( - "org.apache.maven.plugins", "maven-surefire-plugin", "3.2.5", "surefire", "test", "default-test"); - } - - private static MojoExecution enforcer() { - return execution( - "org.apache.maven.plugins", "maven-enforcer-plugin", "3.4.1", "enforcer", "enforce", "enforce-rules"); - } - - @Test - void wildcardMatchesAll() { - assertTrue(CoordinatePredicate.parse("*").matches(surefire())); - assertTrue(CoordinatePredicate.parse("*").matches(enforcer())); - } - - @Test - void artifactIdOnlyMatchesByArtifactId() { - CoordinatePredicate p = CoordinatePredicate.parse(":maven-surefire-plugin"); - assertTrue(p.matches(surefire())); - assertFalse(p.matches(enforcer())); - } - - @Test - void colonAloneMatchesAll() { - CoordinatePredicate p = CoordinatePredicate.parse(":"); - assertTrue(p.matches(surefire())); - assertTrue(p.matches(enforcer())); - } - - @Test - void groupAndArtifactMatchesExact() { - CoordinatePredicate p = CoordinatePredicate.parse("org.apache.maven.plugins:maven-surefire-plugin"); - assertTrue(p.matches(surefire())); - assertFalse(p.matches(enforcer())); - } - - @Test - void groupWithWildcardArtifact() { - CoordinatePredicate p = CoordinatePredicate.parse("org.apache.maven.plugins:"); - assertTrue(p.matches(surefire())); - assertTrue(p.matches(enforcer())); - } - - @Test - void groupMismatchDoesNotMatch() { - CoordinatePredicate p = CoordinatePredicate.parse("com.example:maven-surefire-plugin"); - assertFalse(p.matches(surefire())); - } - - @Test - void prefixMatchesByGoalPrefix() { - CoordinatePredicate p = CoordinatePredicate.parse("surefire"); - assertTrue(p.matches(surefire())); - assertFalse(p.matches(enforcer())); - } - - @Test - void prefixWithGoalMatchesGoal() { - CoordinatePredicate p = CoordinatePredicate.parse("surefire::test"); - assertTrue(p.matches(surefire())); - assertFalse(p.matches(enforcer())); - } - - @Test - void prefixWithVersionGoalAndExecution() { - CoordinatePredicate p = CoordinatePredicate.parse("surefire:3.2.5:test@default-test"); - assertTrue(p.matches(surefire())); - } - - @Test - void prefixVersionMismatch() { - CoordinatePredicate p = CoordinatePredicate.parse("surefire:99.0:test@default-test"); - assertFalse(p.matches(surefire())); - } - - @Test - void prefixExecutionIdMismatch() { - CoordinatePredicate p = CoordinatePredicate.parse("surefire::test@other-id"); - assertFalse(p.matches(surefire())); - } - - @Test - void fullCoordinateWithGoalAndExecution() { - CoordinatePredicate p = - CoordinatePredicate.parse("org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test@default-test"); - assertTrue(p.matches(surefire())); - } - - @Test - void coordinateGoalMismatch() { - CoordinatePredicate p = - CoordinatePredicate.parse("org.apache.maven.plugins:maven-surefire-plugin:3.2.5:other@default-test"); - assertFalse(p.matches(surefire())); - } - - @Test - void noDescriptorDoesNotMatchSpecificPlugin() { - MojoExecution exec = mock(MojoExecution.class); - when(exec.getPlugin()).thenReturn(null); - when(exec.getGoal()).thenReturn("test"); - when(exec.getExecutionId()).thenReturn("default-test"); - assertFalse(CoordinatePredicate.parse(":maven-surefire-plugin").matches(exec)); - } - - @Test - void wildcardMatchesEvenWithNullPlugin() { - MojoExecution exec = mock(MojoExecution.class); - when(exec.getPlugin()).thenReturn(null); - assertTrue(CoordinatePredicate.MATCH_ALL.matches(exec)); - } - - @Test - void dotlessGroupIdIsMisroutedToPrefixMode() { - // GroupIds without a '.' (e.g. "commons-io") cannot be used in G:A form — they are routed - // to prefix-based matching. Document this: the token "commons-io:commons-io" is treated as - // prefix="commons-io", version="commons-io" — it will NOT match an execution with groupId - // "commons-io". Users should use ":commons-io" (artifactId-only form) instead. - MojoExecution exec = execution("commons-io", "commons-io", "2.15.1", "commons-io", "copy", "default"); - // "commons-io:commons-io" is parsed as P:v form (prefix="commons-io", version="commons-io"). - // The prefix check succeeds (goalPrefix="commons-io" matches), but the version check fails - // ("commons-io" != "2.15.1"), so the predicate returns false. This demonstrates that the - // misrouting produces a non-matching predicate, not a silent match. - CoordinatePredicate p = CoordinatePredicate.parse("commons-io:commons-io"); - assertFalse(p.matches(exec), "dotless groupId in G:A form is misrouted to prefix mode and should not match"); - // The correct workaround: use :A form to match by artifactId - assertTrue(CoordinatePredicate.parse(":commons-io").matches(exec)); - } -} diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java deleted file mode 100644 index fdbdab9d7b10..000000000000 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/MojoExecutionFilterTest.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import java.util.List; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Unit tests for {@link MojoExecutionFilter}. - */ -class MojoExecutionFilterTest { - - // ── parse ───────────────────────────────────────────────────────────────── - - @Test - void parseNullReturnsEmpty() { - assertTrue(MojoExecutionFilter.parse(null).isEmpty()); - } - - @Test - void parseBlankReturnsEmpty() { - assertTrue(MojoExecutionFilter.parse(" ").isEmpty()); - } - - @Test - void parseSinglePhase() { - List predicates = MojoExecutionFilter.parse("phase(test)"); - assertEquals(1, predicates.size()); - assertInstanceOf(PhasePredicate.class, predicates.get(0)); - } - - @Test - void parseMultiplePredicates() { - List predicates = - MojoExecutionFilter.parse("phase(test), phase(integration-test), :maven-enforcer-plugin"); - assertEquals(3, predicates.size()); - assertInstanceOf(PhasePredicate.class, predicates.get(0)); - assertInstanceOf(PhasePredicate.class, predicates.get(1)); - assertInstanceOf(CoordinatePredicate.class, predicates.get(2)); - } - - @Test - void parseSkipsBlankTokens() { - List predicates = MojoExecutionFilter.parse("phase(test),,phase(compile),"); - assertEquals(2, predicates.size()); - } - - @Test - void parseWildcard() { - List predicates = MojoExecutionFilter.parse("*"); - assertEquals(1, predicates.size()); - assertEquals(CoordinatePredicate.MATCH_ALL, predicates.get(0)); - } - - @Test - void parsePrefix() { - List predicates = MojoExecutionFilter.parse("surefire"); - assertEquals(1, predicates.size()); - assertInstanceOf(CoordinatePredicate.class, predicates.get(0)); - } - - @Test - void parsePrefixWithVersionAndGoalAndExecution() { - List predicates = MojoExecutionFilter.parse("surefire:3.2.5:test@default-test"); - assertEquals(1, predicates.size()); - assertInstanceOf(CoordinatePredicate.class, predicates.get(0)); - } - - @Test - void parseGroupArtifact() { - List predicates = MojoExecutionFilter.parse("org.apache.maven.plugins:maven-surefire-plugin"); - assertEquals(1, predicates.size()); - assertInstanceOf(CoordinatePredicate.class, predicates.get(0)); - } - - @Test - void parseResultIsImmutable() { - List predicates = MojoExecutionFilter.parse("phase(test)"); - org.junit.jupiter.api.Assertions.assertThrows( - UnsupportedOperationException.class, () -> predicates.add(CoordinatePredicate.MATCH_ALL)); - } - - @Test - void emptyPhaseParensThrows() { - org.junit.jupiter.api.Assertions.assertThrows( - IllegalArgumentException.class, () -> MojoExecutionFilter.parse("phase()")); - } - - @Test - void blankPhaseParensThrows() { - org.junit.jupiter.api.Assertions.assertThrows( - IllegalArgumentException.class, () -> MojoExecutionFilter.parse("phase( )")); - } - - // ── propertyName ───────────────────────────────────────────────────────── - - @Test - void propertyNameMatchesConstant() { - assertEquals(org.apache.maven.api.Constants.MAVEN_LIFECYCLE_FILTER, MojoExecutionFilter.PROPERTY_NAME); - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - private static void assertInstanceOf(Class expected, Object actual) { - assertTrue( - expected.isInstance(actual), - "Expected instance of " + expected.getSimpleName() + " but got " - + actual.getClass().getSimpleName()); - } -} diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java deleted file mode 100644 index 13a77ab649bb..000000000000 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/filter/PhasePredicateTest.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.lifecycle.internal.filter; - -import org.apache.maven.api.MojoExecution; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * Unit tests for {@link PhasePredicate}. - */ -class PhasePredicateTest { - - @Test - void matchesExecutionBoundToThatPhase() { - MojoExecution exec = mock(MojoExecution.class); - when(exec.getLifecyclePhase()).thenReturn("test"); - assertTrue(new PhasePredicate("test").matches(exec)); - } - - @Test - void doesNotMatchDifferentPhase() { - MojoExecution exec = mock(MojoExecution.class); - when(exec.getLifecyclePhase()).thenReturn("compile"); - assertFalse(new PhasePredicate("test").matches(exec)); - } - - @Test - void doesNotMatchNullPhase() { - MojoExecution exec = mock(MojoExecution.class); - when(exec.getLifecyclePhase()).thenReturn(null); - assertFalse(new PhasePredicate("test").matches(exec)); - } - - @Test - void matchesIntegrationTestPhase() { - MojoExecution exec = mock(MojoExecution.class); - when(exec.getLifecyclePhase()).thenReturn("integration-test"); - assertTrue(new PhasePredicate("integration-test").matches(exec)); - } - - @Test - void toStringReturnsPhaseExpression() { - org.junit.jupiter.api.Assertions.assertEquals("phase(test)", new PhasePredicate("test").toString()); - } -} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java deleted file mode 100644 index 0842ed3c23f6..000000000000 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12535LifecycleFilterPhaseTest.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.it; - -import java.nio.file.Path; - -import org.junit.jupiter.api.Test; - -/** - * Verifies that {@code -Dmaven.lifecycle.filter=phase(test)} skips the {@code test} phase, - * while running the same lifecycle without the filter executes tests normally. - * - * @see MNG-12535 - */ -class MavenITmng12535LifecycleFilterPhaseTest extends AbstractMavenIntegrationTestCase { - - @Test - void lifecycleFilterSkipsTestPhase() throws Exception { - Path testDir = extractResources("/mng-12535-lifecycle-filter-phase"); - - Verifier verifier = newVerifier(testDir); - verifier.setAutoclean(false); - verifier.deleteDirectory("target"); - verifier.addCliArgument("-Dmaven.lifecycle.filter=phase(test)"); - verifier.addCliArgument("test"); - verifier.execute(); - verifier.verifyErrorFreeLog(); - - // Surefire ran → would produce "Tests run:" in log; with filter it must NOT appear - verifier.verifyTextNotInLog("Tests run:"); - } - - @Test - void withoutFilterSurefireRunsNormally() throws Exception { - Path testDir = extractResources("/mng-12535-lifecycle-filter-phase"); - - Verifier verifier = newVerifier(testDir); - verifier.setAutoclean(false); - verifier.deleteDirectory("target"); - verifier.addCliArgument("test"); - verifier.execute(); - verifier.verifyErrorFreeLog(); - - verifier.verifyTextInLog("Tests run:"); - } -} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java deleted file mode 100644 index 3bb5446db97a..000000000000 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12536LifecycleFilterCoordinateTest.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.it; - -import java.nio.file.Path; - -import org.junit.jupiter.api.Test; - -/** - * Verifies that {@code maven.lifecycle.filter} can target plugins by artifactId ({@code :A}) - * and by goal prefix. - * - * @see MNG-12536 - */ -class MavenITmng12536LifecycleFilterCoordinateTest extends AbstractMavenIntegrationTestCase { - - @Test - void filterByArtifactIdSkipsSurefire() throws Exception { - Path testDir = extractResources("/mng-12536-lifecycle-filter-coordinate"); - - Verifier verifier = newVerifier(testDir); - verifier.setAutoclean(false); - verifier.deleteDirectory("target"); - verifier.addCliArgument("-Dmaven.lifecycle.filter=:maven-surefire-plugin"); - verifier.addCliArgument("test"); - verifier.execute(); - verifier.verifyErrorFreeLog(); - - verifier.verifyTextNotInLog("Tests run:"); - } - - @Test - void filterByPrefixSkipsSurefire() throws Exception { - Path testDir = extractResources("/mng-12536-lifecycle-filter-coordinate"); - - Verifier verifier = newVerifier(testDir); - verifier.setAutoclean(false); - verifier.deleteDirectory("target"); - verifier.addCliArgument("-Dmaven.lifecycle.filter=surefire"); - verifier.addCliArgument("test"); - verifier.execute(); - verifier.verifyErrorFreeLog(); - - verifier.verifyTextNotInLog("Tests run:"); - } -} diff --git a/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml b/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml deleted file mode 100644 index 7c631c3c8360..000000000000 --- a/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/pom.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - 4.0.0 - org.apache.maven.its.mng12535 - mng-12535-lifecycle-filter-phase - 1.0-SNAPSHOT - jar - Verifies that maven.lifecycle.filter=phase(test) skips the test phase. - - - org.junit.jupiter - junit-jupiter-api - 5.11.0 - test - - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - - diff --git a/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java b/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java deleted file mode 100644 index c3e864312ab8..000000000000 --- a/its/core-it-suite/src/test/resources/mng-12535-lifecycle-filter-phase/src/test/java/org/apache/maven/its/mng12535/SampleTest.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.its.mng12535; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SampleTest { - @Test - void alwaysPasses() { - assertTrue(true); - } -} diff --git a/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml b/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml deleted file mode 100644 index ccf52930bdd3..000000000000 --- a/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/pom.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - 4.0.0 - org.apache.maven.its.mng12536 - mng-12536-lifecycle-filter-coordinate - 1.0-SNAPSHOT - jar - Verifies that maven.lifecycle.filter=:maven-surefire-plugin skips surefire by artifactId. - - - org.junit.jupiter - junit-jupiter-api - 5.11.0 - test - - - diff --git a/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java b/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java deleted file mode 100644 index a8918570178e..000000000000 --- a/its/core-it-suite/src/test/resources/mng-12536-lifecycle-filter-coordinate/src/test/java/org/apache/maven/its/mng12536/SampleTest.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.maven.its.mng12536; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SampleTest { - @Test - void alwaysPasses() { - assertTrue(true); - } -} From 0d33914d6bd5ae0c3889969ab079d4fc55d69105 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 9 Oct 2026 11:29:01 +0000 Subject: [PATCH 15/16] fix: add Modello pluralExceptions to fix alias parsing in reactor.xml Modello singularizes field names by stripping a trailing 's'. For the 'aliases' field this yields 'aliase' instead of 'alias', causing the StAX reader to reject elements in reactor.xml with: ParseError at [row,col]:[5,3] Message: Unrecognised tag: 'alias' Add a entry in the modello-reactor execution so that 'aliases' -> 'alias' is used as the XML element name in the generated ReactorConfigStaxReader, fixing MavenITmng12537ReactorXmlAliasTest. --- impl/maven-cli/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml index 5b3f31628bd4..82369bd6ff8a 100644 --- a/impl/maven-cli/pom.xml +++ b/impl/maven-cli/pom.xml @@ -308,6 +308,11 @@ under the License. packageToolV4=org.apache.maven.cling.internal.reactor.io locationTracking=false + + + alias + From 23bbd37252cbe09d05985a6ec05f2986f5ab4b66 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 9 Oct 2026 12:31:25 +0000 Subject: [PATCH 16/16] fix: add source file to mng-12537 IT so target/classes is created after compile --- .../main/java/org/apache/maven/its/mng12537/Mng12537.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/src/main/java/org/apache/maven/its/mng12537/Mng12537.java diff --git a/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/src/main/java/org/apache/maven/its/mng12537/Mng12537.java b/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/src/main/java/org/apache/maven/its/mng12537/Mng12537.java new file mode 100644 index 000000000000..163514174fa5 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-12537-reactor-xml-alias/src/main/java/org/apache/maven/its/mng12537/Mng12537.java @@ -0,0 +1,7 @@ +package org.apache.maven.its.mng12537; + +/** + * Minimal source file so that the compile phase populates target/classes, + * allowing the integration test to verify the alias was expanded correctly. + */ +public class Mng12537 {}