From ce628236122c7a85c91447258f67aa96a9b33fae Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 3 Apr 2026 23:09:46 +0200 Subject: [PATCH] Fix #11796: Preserve cross-lifecycle default phase bindings from components.xml When a plugin registers a custom lifecycle via components.xml with binding goals to standard lifecycle phases (e.g. process-sources), these cross-lifecycle bindings were silently dropped during the legacy-to-API Lifecycle conversion. The wrap() method only created Phase objects for the custom lifecycle's own phases, losing any bindings to phases from other lifecycles. The fix separates v3phases() (used by computePhases() for phase ordering) from phases() (used by allPhases() for extracting plugin bindings). Cross-lifecycle phase bindings are now included in phases() so they survive the round-trip conversion back to a legacy Lifecycle, while v3phases() returns only the lifecycle's own phases to avoid polluting the phase-to-lifecycle map. Co-Authored-By: Claude Opus 4.6 --- .../impl/DefaultLifecycleRegistry.java | 138 +++++++++++------- .../lifecycle/DefaultLifecyclesTest.java | 48 ++++++ 2 files changed, 131 insertions(+), 55 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLifecycleRegistry.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLifecycleRegistry.java index a0b9ff8a0aaf..a5532a69a3f4 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLifecycleRegistry.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLifecycleRegistry.java @@ -225,73 +225,101 @@ public String id() { return lifecycle.getId(); } + @Override + public Collection v3phases() { + return buildOwnPhases(); + } + @Override public Collection phases() { + List phases = new ArrayList<>(buildOwnPhases()); + // Include phases from getDefaultLifecyclePhases() that bind to phases + // from other lifecycles (e.g. process-sources from the default lifecycle). + // In Maven 3, could bind plugin goals to standard lifecycle + // phases. These cross-lifecycle bindings must be preserved so they survive + // the round-trip conversion back to a legacy Lifecycle. + Map defaultPhases = lifecycle.getDefaultLifecyclePhases(); + if (defaultPhases != null) { + Set ownPhases = new HashSet<>(lifecycle.getPhases()); + for (String phaseName : defaultPhases.keySet()) { + if (!ownPhases.contains(phaseName)) { + phases.add(createPhase(phaseName, null)); + } + } + } + return phases; + } + + private List buildOwnPhases() { List names = lifecycle.getPhases(); List phases = new ArrayList<>(); for (int i = 0; i < names.size(); i++) { String name = names.get(i); String prev = i > 0 ? names.get(i - 1) : null; - phases.add(new Phase() { - @Override - public String name() { - return name; - } - - @Override - public List phases() { - return List.of(); - } + phases.add(createPhase(name, prev)); + } + return phases; + } - @Override - public Stream allPhases() { - return Stream.concat( - Stream.of(this), phases().stream().flatMap(Lifecycle.Phase::allPhases)); + private Phase createPhase(String name, String prev) { + return new Phase() { + @Override + public String name() { + return name; + } + + @Override + public List phases() { + return List.of(); + } + + @Override + public Stream allPhases() { + return Stream.concat( + Stream.of(this), phases().stream().flatMap(Lifecycle.Phase::allPhases)); + } + + @Override + public List plugins() { + Map lfPhases = lifecycle.getDefaultLifecyclePhases(); + LifecyclePhase phase = lfPhases != null ? lfPhases.get(name) : null; + if (phase != null) { + Map plugins = new LinkedHashMap<>(); + DefaultPackagingRegistry.parseLifecyclePhaseDefinitions(plugins, name, phase); + return plugins.values().stream().toList(); } + return List.of(); + } - @Override - public List plugins() { - Map lfPhases = lifecycle.getDefaultLifecyclePhases(); - LifecyclePhase phase = lfPhases != null ? lfPhases.get(name) : null; - if (phase != null) { - Map plugins = new LinkedHashMap<>(); - DefaultPackagingRegistry.parseLifecyclePhaseDefinitions(plugins, name, phase); - return plugins.values().stream().toList(); - } + @Override + public Collection links() { + if (prev == null) { return List.of(); + } else { + return List.of(new Link() { + @Override + public Kind kind() { + return Kind.AFTER; + } + + @Override + public Pointer pointer() { + return new Pointer() { + @Override + public String phase() { + return prev; + } + + @Override + public Type type() { + return Type.PROJECT; + } + }; + } + }); } - - @Override - public Collection links() { - if (prev == null) { - return List.of(); - } else { - return List.of(new Link() { - @Override - public Kind kind() { - return Kind.AFTER; - } - - @Override - public Pointer pointer() { - return new Pointer() { - @Override - public String phase() { - return prev; - } - - @Override - public Type type() { - return Type.PROJECT; - } - }; - } - }); - } - } - }); - } - return phases; + } + }; } @Override diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/DefaultLifecyclesTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/DefaultLifecyclesTest.java index 0b36378871a7..d5a3b9433ccb 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/DefaultLifecyclesTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/DefaultLifecyclesTest.java @@ -23,18 +23,23 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.apache.maven.internal.impl.DefaultLifecycleRegistry; import org.apache.maven.internal.impl.DefaultLookup; +import org.apache.maven.lifecycle.mapping.LifecyclePhase; import org.codehaus.plexus.PlexusContainer; import org.codehaus.plexus.component.repository.exception.ComponentLookupException; import org.codehaus.plexus.testing.PlexusTest; 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -96,6 +101,49 @@ void testCustomLifecycle() throws ComponentLookupException { assertEquals("etl", dl.getLifeCycles().get(3).getId()); } + @Test + void testCustomLifecycleWithCrossLifecycleDefaultPhases() throws ComponentLookupException { + // Simulates a plugin that registers a custom lifecycle via components.xml + // with binding goals to standard lifecycle phases (e.g. process-sources) + // rather than to phases of the custom lifecycle itself. This is the Maven 3 mechanism + // for extension plugins to bind goals to standard phases without requiring . + Map defaultPhases = new HashMap<>(); + defaultPhases.put("process-sources", new LifecyclePhase("com.example:my-plugin:1.0:touch")); + + Lifecycle customLifecycle = new Lifecycle("my-custom-lifecycle", Arrays.asList("custom-phase"), defaultPhases); + + List myLifecycles = new ArrayList<>(); + myLifecycles.add(customLifecycle); + myLifecycles.addAll(defaultLifeCycles.getLifeCycles()); + + Map lifeCycles = myLifecycles.stream().collect(Collectors.toMap(Lifecycle::getId, l -> l)); + PlexusContainer mockedPlexusContainer = mock(PlexusContainer.class); + when(mockedPlexusContainer.lookupMap(Lifecycle.class)).thenReturn(lifeCycles); + + DefaultLifecycles dl = new DefaultLifecycles( + new DefaultLifecycleRegistry( + List.of(new DefaultLifecycleRegistry.LifecycleWrapperProvider(mockedPlexusContainer))), + new DefaultLookup(mockedPlexusContainer)); + + Lifecycle resolved = dl.getLifeCycles().stream() + .filter(l -> "my-custom-lifecycle".equals(l.getId())) + .findFirst() + .orElseThrow(); + + // Cross-lifecycle default phase bindings must survive the round-trip conversion + Map resolvedDefaultPhases = resolved.getDefaultLifecyclePhases(); + assertNotNull(resolvedDefaultPhases); + assertTrue( + resolvedDefaultPhases.containsKey("process-sources"), + "Cross-lifecycle binding to 'process-sources' should be preserved"); + + // The lifecycle's own phase list should NOT include cross-lifecycle phases + assertFalse( + resolved.getPhases().contains("process-sources"), + "Cross-lifecycle phase should not appear in the lifecycle's own phase list"); + assertTrue(resolved.getPhases().contains("custom-phase"), "Lifecycle's own phase should be present"); + } + private Lifecycle getLifeCycleById(String id) { return defaultLifeCycles.getLifeCycles().stream() .filter(l -> id.equals(l.getId()))