gnodet-bot commented on code in PR #13636:
URL: https://github.com/apache/maven/pull/13636#discussion_r4483189311


##########
impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java:
##########
@@ -0,0 +1,415 @@
+/*
+ * 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.plugin.internal;
+
+import java.io.File;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.RecordComponent;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.plugin.PluginConfigurationException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+
+/** Configures a Maven 4 mojo directly from the public Maven XML model. */
+public final class V4MojoConfigurator {
+    private static final Object NOT_SCALAR = new Object();
+
+    private final MojoExpressionEvaluator evaluator;
+    private final ClassLoader classLoader;
+    private final PluginDescriptor pluginDescriptor;
+
+    public V4MojoConfigurator(
+            MojoExpressionEvaluator evaluator, ClassLoader classLoader, 
PluginDescriptor pluginDescriptor) {
+        this.evaluator = evaluator;
+        this.classLoader = classLoader;
+        this.pluginDescriptor = pluginDescriptor;
+    }
+
+    public void configure(Object mojo, XmlNode configuration) throws 
PluginConfigurationException {
+        if (configuration == null) {
+            return;
+        }
+        for (XmlNode child : configuration.children()) {
+            try {
+                set(mojo, child.name(), child);
+            } catch (Exception e) {
+                throw new PluginConfigurationException(
+                        pluginDescriptor,
+                        "Unable to configure '" + child.name() + "' on "
+                                + mojo.getClass().getName(),
+                        e);
+            }
+        }
+    }
+
+    private void set(Object bean, String name, XmlNode node) throws Exception {
+        if (name.isEmpty()) {
+            throw new NoSuchFieldException("<empty element name>");
+        }
+        String title = Character.toUpperCase(name.charAt(0)) + 
name.substring(1);
+        for (Method method : bean.getClass().getMethods()) {
+            if ((method.getName().equals("set" + title) || 
method.getName().equals("add" + title))
+                    && method.getParameterCount() == 1) {
+                if (!method.canAccess(bean)) {
+                    method.setAccessible(true);
+                }
+                method.invoke(bean, convert(node, 
method.getGenericParameterTypes()[0]));
+                return;
+            }
+        }
+        for (Class<?> type = bean.getClass(); type != null; type = 
type.getSuperclass()) {
+            try {
+                Field field = type.getDeclaredField(name);
+                field.setAccessible(true);
+                field.set(bean, convert(node, field.getGenericType()));
+                return;
+            } catch (NoSuchFieldException ignored) {
+                // Continue through the class hierarchy.
+            }
+        }
+        throw new NoSuchFieldException(name);
+    }
+
+    private Object convert(XmlNode node, Type target) throws Exception {
+        Class<?> type = rawType(target);
+        if (type == XmlNode.class) {
+            return node;
+        }
+        Object scalar = convertScalar(node, type);
+        if (scalar != NOT_SCALAR) {
+            return scalar;
+        }
+        if (type.isArray()) {
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                String[] parts = text != null ? text.split(",", -1) : new 
String[0];
+                Object array = Array.newInstance(type.getComponentType(), 
parts.length);

Review Comment:
   ⚠️ **`split(",", -1)` preserves empty tokens — `NumberFormatException` for 
numeric array types.**
   
   `"a,,b".split(",", -1)` → `["a", "", "b"]`. `.trim()` removes whitespace but 
not empty strings. For `int[]` or `List<Integer>`, the empty-string token 
causes `NumberFormatException` in the downstream `Integer.valueOf()` call.
   
   Same issue on line 139 for collections.
   
   ```suggestion
                   String[] parts = text != null
                           ? java.util.Arrays.stream(text.split(",", -1))
                                   .map(String::trim)
                                   .filter(s -> !s.isEmpty())
                                   .toArray(String[]::new)
                           : new String[0];
   ```



##########
impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java:
##########
@@ -0,0 +1,415 @@
+/*
+ * 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.plugin.internal;
+
+import java.io.File;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.RecordComponent;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.plugin.PluginConfigurationException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+
+/** Configures a Maven 4 mojo directly from the public Maven XML model. */
+public final class V4MojoConfigurator {
+    private static final Object NOT_SCALAR = new Object();
+
+    private final MojoExpressionEvaluator evaluator;
+    private final ClassLoader classLoader;
+    private final PluginDescriptor pluginDescriptor;
+
+    public V4MojoConfigurator(
+            MojoExpressionEvaluator evaluator, ClassLoader classLoader, 
PluginDescriptor pluginDescriptor) {
+        this.evaluator = evaluator;
+        this.classLoader = classLoader;
+        this.pluginDescriptor = pluginDescriptor;
+    }
+
+    public void configure(Object mojo, XmlNode configuration) throws 
PluginConfigurationException {
+        if (configuration == null) {
+            return;
+        }
+        for (XmlNode child : configuration.children()) {
+            try {
+                set(mojo, child.name(), child);
+            } catch (Exception e) {
+                throw new PluginConfigurationException(
+                        pluginDescriptor,
+                        "Unable to configure '" + child.name() + "' on "
+                                + mojo.getClass().getName(),
+                        e);
+            }
+        }
+    }
+
+    private void set(Object bean, String name, XmlNode node) throws Exception {
+        if (name.isEmpty()) {
+            throw new NoSuchFieldException("<empty element name>");
+        }
+        String title = Character.toUpperCase(name.charAt(0)) + 
name.substring(1);
+        for (Method method : bean.getClass().getMethods()) {
+            if ((method.getName().equals("set" + title) || 
method.getName().equals("add" + title))
+                    && method.getParameterCount() == 1) {
+                if (!method.canAccess(bean)) {
+                    method.setAccessible(true);
+                }
+                method.invoke(bean, convert(node, 
method.getGenericParameterTypes()[0]));
+                return;
+            }
+        }
+        for (Class<?> type = bean.getClass(); type != null; type = 
type.getSuperclass()) {
+            try {
+                Field field = type.getDeclaredField(name);
+                field.setAccessible(true);
+                field.set(bean, convert(node, field.getGenericType()));
+                return;
+            } catch (NoSuchFieldException ignored) {
+                // Continue through the class hierarchy.
+            }
+        }
+        throw new NoSuchFieldException(name);
+    }
+
+    private Object convert(XmlNode node, Type target) throws Exception {
+        Class<?> type = rawType(target);
+        if (type == XmlNode.class) {
+            return node;
+        }
+        Object scalar = convertScalar(node, type);
+        if (scalar != NOT_SCALAR) {
+            return scalar;
+        }
+        if (type.isArray()) {
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                String[] parts = text != null ? text.split(",", -1) : new 
String[0];
+                Object array = Array.newInstance(type.getComponentType(), 
parts.length);
+                for (int i = 0; i < parts.length; i++) {
+                    Array.set(array, i, convert(XmlNode.newInstance("item", 
parts[i].trim()), type.getComponentType()));
+                }
+                return array;
+            }
+            Object array = Array.newInstance(type.getComponentType(), 
children.size());
+            for (int i = 0; i < children.size(); i++) {
+                Array.set(array, i, convert(children.get(i), 
type.getComponentType()));
+            }
+            return array;
+        }
+        if (Collection.class.isAssignableFrom(type)) {
+            Type itemType = target instanceof ParameterizedType p ? 
p.getActualTypeArguments()[0] : Object.class;
+            Collection<Object> values =
+                    Set.class.isAssignableFrom(type) ? new 
java.util.LinkedHashSet<>() : new ArrayList<>();
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                if (text != null) {
+                    for (String part : text.split(",", -1)) {
+                        values.add(convert(XmlNode.newInstance("item", 
part.trim()), itemType));
+                    }
+                }
+            } else {
+                for (XmlNode child : children) {
+                    values.add(convert(child, itemType));
+                }
+            }
+            return values;
+        }
+        if (Map.class.isAssignableFrom(type)) {
+            Type valueType = target instanceof ParameterizedType p ? 
p.getActualTypeArguments()[1] : Object.class;
+            if (Properties.class.isAssignableFrom(type)) {
+                Properties properties = new Properties();
+                for (XmlNode child : node.children()) {
+                    // Support Plexus-style: 
<property><name>k</name><value>v</value></property>
+                    XmlNode nameNode = child.child("name");
+                    XmlNode valueNode = child.child("value");
+                    if (nameNode != null && valueNode != null) {
+                        String key = evaluateNodeAsString(nameNode);
+                        Object val = convert(valueNode, valueType);
+                        if (key != null && val != null) {
+                            properties.put(key, val);
+                        }
+                    } else {
+                        // Support key=element-name, value=text: 
<key>value</key>
+                        Object val = convert(child, valueType);
+                        if (val != null) {
+                            properties.put(child.name(), val);
+                        }
+                    }
+                }
+                return properties;
+            }
+            Map<String, Object> values = new LinkedHashMap<>();
+            for (XmlNode child : node.children()) {
+                values.put(child.name(), convert(child, valueType));
+            }
+            return values;
+        }
+        type = resolveImplementation(node, type);
+        String value = evaluateNodeAsString(node);
+        if (value != null && node.children().isEmpty()) {
+            Method setter = findDefaultSetter(type);
+            if (setter != null) {
+                Object instance = instantiate(type);
+                if (!setter.canAccess(instance)) {
+                    setter.setAccessible(true);
+                }
+                setter.invoke(
+                        instance, convert(XmlNode.newInstance("value", value), 
setter.getGenericParameterTypes()[0]));
+                return instance;
+            }
+        }
+        if (type.isRecord()) {
+            return instantiateRecord(node, type);
+        }
+        Object instance = instantiate(type);
+        for (XmlNode child : node.children()) {
+            set(instance, child.name(), child);
+        }
+        return instance;
+    }
+
+    private Object convertScalar(XmlNode node, Class<?> type) throws Exception 
{
+        if (type == String.class || type == Object.class) {
+            return evaluateNode(node, type);
+        }

Review Comment:
   ❌ **`Object.class` still treated as scalar — silently drops XML children.**
   
   `evaluateNode` returns `null` when the node has children but no text 
content. Back in `convert()`, `null != NOT_SCALAR` → returns `null` 
immediately, skipping the array/collection/map/bean branches.
   
   Any `Object`-typed parameter (e.g. `Map<String, Object>`) with nested XML 
children will silently resolve to `null`.
   
   ```suggestion
           if (type == String.class) {
               return evaluateNode(node, type);
           }
           if (type == Object.class) {
               return node.children().isEmpty() ? evaluateNode(node, type) : 
NOT_SCALAR;
           }
   ```



##########
impl/maven-core/src/test/java/org/apache/maven/plugin/internal/V4MojoConfiguratorTest.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.plugin.internal;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.internal.xml.XmlNodeImpl;
+import org.apache.maven.plugin.PluginConfigurationException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class V4MojoConfiguratorTest {
+
+    private MojoExpressionEvaluator evaluator;
+    private PluginDescriptor pluginDescriptor;
+    private V4MojoConfigurator configurator;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        evaluator = mock(MojoExpressionEvaluator.class);
+        pluginDescriptor = new PluginDescriptor();
+        configurator = new V4MojoConfigurator(evaluator, 
getClass().getClassLoader(), pluginDescriptor);
+
+        when(evaluator.evaluate(anyString(), any())).thenAnswer(invocation -> 
invocation.getArgument(0));
+        
when(evaluator.alignToBaseDirectory(any(File.class))).thenAnswer(invocation -> {
+            File f = invocation.getArgument(0);
+            return f.isAbsolute() ? f : new File(new File("base"), 
f.getPath()).getAbsoluteFile();
+        });
+    }
+
+    public enum Status {
+        ACTIVE,
+        INACTIVE
+    }
+
+    public static class ScalarMojo {
+        String str;
+        int primitiveInt;
+        Integer boxedInt;
+        long primitiveLong;
+        Long boxedLong;
+        double primitiveDouble;
+        Double boxedDouble;
+        float primitiveFloat;
+        Float boxedFloat;
+        short primitiveShort;
+        Short boxedShort;
+        byte primitiveByte;
+        Byte boxedByte;
+        boolean primitiveBool;
+        Boolean boxedBool;
+        char primitiveChar;
+        Character boxedChar;
+        Status status;
+        File file;
+        Path path;
+    }
+
+    @Test
+    void testScalarTypesConfiguration() throws Exception {
+        ScalarMojo mojo = new ScalarMojo();
+        XmlNode config = new XmlNodeImpl(
+                "configuration",
+                null,
+                Map.of(),
+                List.of(
+                        new XmlNodeImpl("str", "hello"),
+                        new XmlNodeImpl("primitiveInt", "42"),
+                        new XmlNodeImpl("boxedInt", "43"),
+                        new XmlNodeImpl("primitiveLong", "100"),
+                        new XmlNodeImpl("boxedLong", "101"),
+                        new XmlNodeImpl("primitiveDouble", "3.14"),
+                        new XmlNodeImpl("boxedDouble", "2.71"),
+                        new XmlNodeImpl("primitiveFloat", "1.5"),
+                        new XmlNodeImpl("boxedFloat", "2.5"),
+                        new XmlNodeImpl("primitiveShort", "10"),
+                        new XmlNodeImpl("boxedShort", "11"),
+                        new XmlNodeImpl("primitiveByte", "1"),
+                        new XmlNodeImpl("boxedByte", "2"),
+                        new XmlNodeImpl("primitiveBool", "true"),
+                        new XmlNodeImpl("boxedBool", "false"),
+                        new XmlNodeImpl("primitiveChar", "X"),
+                        new XmlNodeImpl("boxedChar", "Y"),
+                        new XmlNodeImpl("status", "ACTIVE"),
+                        new XmlNodeImpl("file", "relative/file.txt"),
+                        new XmlNodeImpl("path", "relative/path.txt")),
+                null);
+
+        configurator.configure(mojo, config);
+
+        assertEquals("hello", mojo.str);
+        assertEquals(42, mojo.primitiveInt);
+        assertEquals(43, mojo.boxedInt);
+        assertEquals(100L, mojo.primitiveLong);
+        assertEquals(101L, mojo.boxedLong);
+        assertEquals(3.14, mojo.primitiveDouble, 0.001);
+        assertEquals(2.71, mojo.boxedDouble, 0.001);
+        assertEquals(1.5f, mojo.primitiveFloat, 0.001f);
+        assertEquals(2.5f, mojo.boxedFloat, 0.001f);
+        assertEquals((short) 10, mojo.primitiveShort);
+        assertEquals((short) 11, mojo.boxedShort);
+        assertEquals((byte) 1, mojo.primitiveByte);
+        assertEquals((byte) 2, mojo.boxedByte);
+        assertTrue(mojo.primitiveBool);
+        assertFalse(mojo.boxedBool);
+        assertEquals('X', mojo.primitiveChar);
+        assertEquals('Y', mojo.boxedChar);
+        assertEquals(Status.ACTIVE, mojo.status);
+        assertEquals(new File(new File("base"), 
"relative/file.txt").getAbsoluteFile(), mojo.file);
+        assertEquals(Paths.get(new File(new File("base"), 
"relative/path.txt").getAbsolutePath()), mojo.path);
+    }
+
+    @Test
+    void testUnresolvedExpressionsDoNotThrowAndUseDefaults() throws Exception {
+        MojoExpressionEvaluator nullEvaluator = 
mock(MojoExpressionEvaluator.class);
+        when(nullEvaluator.evaluate(anyString(), any())).thenReturn(null);
+        V4MojoConfigurator nullConfigurator =
+                new V4MojoConfigurator(nullEvaluator, 
getClass().getClassLoader(), pluginDescriptor);
+
+        ScalarMojo mojo = new ScalarMojo();
+        XmlNode config = new XmlNodeImpl(
+                "configuration",
+                null,
+                Map.of(),
+                List.of(
+                        new XmlNodeImpl("str", "${unresolved.str}"),
+                        new XmlNodeImpl("boxedInt", "${unresolved.int}"),
+                        new XmlNodeImpl("primitiveInt", "${unresolved.int}"),
+                        new XmlNodeImpl("boxedChar", "${unresolved.char}"),
+                        new XmlNodeImpl("primitiveChar", "${unresolved.char}"),
+                        new XmlNodeImpl("status", "${unresolved.status}"),
+                        new XmlNodeImpl("file", "${unresolved.file}")),
+                null);
+
+        nullConfigurator.configure(mojo, config);
+
+        assertNull(mojo.str);
+        assertNull(mojo.boxedInt);
+        assertEquals(0, mojo.primitiveInt);
+        assertNull(mojo.boxedChar);
+        assertEquals('\0', mojo.primitiveChar);
+        assertNull(mojo.status);
+        assertNull(mojo.file);
+    }
+
+    @Test
+    void testDefaultValueAttributeFallback() throws Exception {
+        MojoExpressionEvaluator nullEvaluator = 
mock(MojoExpressionEvaluator.class);
+        when(nullEvaluator.evaluate(anyString(), any())).thenAnswer(inv -> {
+            String val = inv.getArgument(0);
+            return val.startsWith("${unresolved") ? null : val;
+        });
+        V4MojoConfigurator fallbackConfigurator =
+                new V4MojoConfigurator(nullEvaluator, 
getClass().getClassLoader(), pluginDescriptor);
+
+        ScalarMojo mojo = new ScalarMojo();
+        XmlNode config = new XmlNodeImpl(
+                "configuration",
+                null,
+                Map.of(),
+                List.of(
+                        new XmlNodeImpl("str", null, Map.of("default-value", 
"fallback-str"), List.of(), null),
+                        new XmlNodeImpl(
+                                "primitiveInt", "${unresolved}", 
Map.of("default-value", "99"), List.of(), null),
+                        new XmlNodeImpl("primitiveChar", null, 
Map.of("default-value", "Z"), List.of(), null),
+                        new XmlNodeImpl("primitiveBool", null, 
Map.of("default-value", "true"), List.of(), null)),
+                null);
+
+        fallbackConfigurator.configure(mojo, config);
+
+        assertEquals("fallback-str", mojo.str);
+        assertEquals(99, mojo.primitiveInt);
+        assertEquals('Z', mojo.primitiveChar);
+        assertTrue(mojo.primitiveBool);
+    }
+
+    public static class CollectionsMojo {
+        String[] array;
+        List<String> list;
+        Set<String> set;
+        Map<String, String> map;
+        Properties properties;
+    }
+
+    @Test
+    void testCollectionsAndMaps() throws Exception {

Review Comment:
   📋 **Missing tests for two new code paths added to `testCollectionsAndMaps`.**
   
   `testCollectionsAndMaps` only exercises the child-element XML path. Neither 
of the two new code paths from the previous commit has a direct unit test:
   
   1. **Comma-separated splitting** (`children.isEmpty()` branch): e.g. `new 
XmlNodeImpl("array", "a,b,c")` → `["a", "b", "c"]`. Without this, the fix for 
empty-token filtering (above) would also have no coverage.
   2. **Plexus-style `<property><name>k</name><value>v</value></property>` 
format**: the new Plexus-detection branch in the Properties handling code is 
untested.
   
   Please add targeted test cases for both paths.



##########
impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java:
##########
@@ -0,0 +1,415 @@
+/*
+ * 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.plugin.internal;
+
+import java.io.File;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.RecordComponent;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.plugin.PluginConfigurationException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+
+/** Configures a Maven 4 mojo directly from the public Maven XML model. */
+public final class V4MojoConfigurator {
+    private static final Object NOT_SCALAR = new Object();
+
+    private final MojoExpressionEvaluator evaluator;
+    private final ClassLoader classLoader;
+    private final PluginDescriptor pluginDescriptor;
+
+    public V4MojoConfigurator(
+            MojoExpressionEvaluator evaluator, ClassLoader classLoader, 
PluginDescriptor pluginDescriptor) {
+        this.evaluator = evaluator;
+        this.classLoader = classLoader;
+        this.pluginDescriptor = pluginDescriptor;
+    }
+
+    public void configure(Object mojo, XmlNode configuration) throws 
PluginConfigurationException {
+        if (configuration == null) {
+            return;
+        }
+        for (XmlNode child : configuration.children()) {
+            try {
+                set(mojo, child.name(), child);
+            } catch (Exception e) {
+                throw new PluginConfigurationException(
+                        pluginDescriptor,
+                        "Unable to configure '" + child.name() + "' on "
+                                + mojo.getClass().getName(),
+                        e);
+            }
+        }
+    }
+
+    private void set(Object bean, String name, XmlNode node) throws Exception {
+        if (name.isEmpty()) {
+            throw new NoSuchFieldException("<empty element name>");
+        }
+        String title = Character.toUpperCase(name.charAt(0)) + 
name.substring(1);
+        for (Method method : bean.getClass().getMethods()) {
+            if ((method.getName().equals("set" + title) || 
method.getName().equals("add" + title))
+                    && method.getParameterCount() == 1) {
+                if (!method.canAccess(bean)) {
+                    method.setAccessible(true);
+                }
+                method.invoke(bean, convert(node, 
method.getGenericParameterTypes()[0]));
+                return;
+            }
+        }
+        for (Class<?> type = bean.getClass(); type != null; type = 
type.getSuperclass()) {
+            try {
+                Field field = type.getDeclaredField(name);
+                field.setAccessible(true);
+                field.set(bean, convert(node, field.getGenericType()));
+                return;
+            } catch (NoSuchFieldException ignored) {
+                // Continue through the class hierarchy.
+            }
+        }
+        throw new NoSuchFieldException(name);
+    }
+
+    private Object convert(XmlNode node, Type target) throws Exception {
+        Class<?> type = rawType(target);
+        if (type == XmlNode.class) {
+            return node;
+        }
+        Object scalar = convertScalar(node, type);
+        if (scalar != NOT_SCALAR) {
+            return scalar;
+        }
+        if (type.isArray()) {
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                String[] parts = text != null ? text.split(",", -1) : new 
String[0];
+                Object array = Array.newInstance(type.getComponentType(), 
parts.length);
+                for (int i = 0; i < parts.length; i++) {
+                    Array.set(array, i, convert(XmlNode.newInstance("item", 
parts[i].trim()), type.getComponentType()));
+                }
+                return array;
+            }
+            Object array = Array.newInstance(type.getComponentType(), 
children.size());
+            for (int i = 0; i < children.size(); i++) {
+                Array.set(array, i, convert(children.get(i), 
type.getComponentType()));
+            }
+            return array;
+        }
+        if (Collection.class.isAssignableFrom(type)) {
+            Type itemType = target instanceof ParameterizedType p ? 
p.getActualTypeArguments()[0] : Object.class;
+            Collection<Object> values =
+                    Set.class.isAssignableFrom(type) ? new 
java.util.LinkedHashSet<>() : new ArrayList<>();
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                if (text != null) {
+                    for (String part : text.split(",", -1)) {
+                        values.add(convert(XmlNode.newInstance("item", 
part.trim()), itemType));
+                    }
+                }
+            } else {
+                for (XmlNode child : children) {
+                    values.add(convert(child, itemType));
+                }
+            }
+            return values;
+        }
+        if (Map.class.isAssignableFrom(type)) {
+            Type valueType = target instanceof ParameterizedType p ? 
p.getActualTypeArguments()[1] : Object.class;
+            if (Properties.class.isAssignableFrom(type)) {
+                Properties properties = new Properties();
+                for (XmlNode child : node.children()) {
+                    // Support Plexus-style: 
<property><name>k</name><value>v</value></property>
+                    XmlNode nameNode = child.child("name");
+                    XmlNode valueNode = child.child("value");
+                    if (nameNode != null && valueNode != null) {
+                        String key = evaluateNodeAsString(nameNode);
+                        Object val = convert(valueNode, valueType);
+                        if (key != null && val != null) {
+                            properties.put(key, val);
+                        }
+                    } else {
+                        // Support key=element-name, value=text: 
<key>value</key>
+                        Object val = convert(child, valueType);
+                        if (val != null) {
+                            properties.put(child.name(), val);
+                        }
+                    }

Review Comment:
   ⚠️ **`<property>` with `<name>` but no `<value>` falls into the wrong 
`else`-branch.**
   
   The `else` condition fires when `nameNode != null && valueNode == null` — it 
uses `child.name()` (the literal string `"property"`) as the key instead of the 
`<name>` element's content. The `<name>` text is discarded.
   
   The correct fix is to only run the simple `<key>text</key>` path when 
`nameNode == null` (i.e., not Plexus-style at all):
   
   ```suggestion
                       if (nameNode != null && valueNode != null) {
                           String key = evaluateNodeAsString(nameNode);
                           Object val = convert(valueNode, valueType);
                           if (key != null && val != null) {
                               properties.put(key, val);
                           }
                       } else if (nameNode == null) {
                           // Support key=element-name, value=text: 
<key>value</key>
                           Object val = convert(child, valueType);
                           if (val != null) {
                               properties.put(child.name(), val);
                           }
                       }
                       // nameNode != null && valueNode == null: malformed 
Plexus-style element, skip silently
   ```



##########
impl/maven-core/src/main/java/org/apache/maven/plugin/internal/V4MojoConfigurator.java:
##########
@@ -0,0 +1,415 @@
+/*
+ * 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.plugin.internal;
+
+import java.io.File;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.RecordComponent;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.plugin.PluginConfigurationException;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+
+/** Configures a Maven 4 mojo directly from the public Maven XML model. */
+public final class V4MojoConfigurator {
+    private static final Object NOT_SCALAR = new Object();
+
+    private final MojoExpressionEvaluator evaluator;
+    private final ClassLoader classLoader;
+    private final PluginDescriptor pluginDescriptor;
+
+    public V4MojoConfigurator(
+            MojoExpressionEvaluator evaluator, ClassLoader classLoader, 
PluginDescriptor pluginDescriptor) {
+        this.evaluator = evaluator;
+        this.classLoader = classLoader;
+        this.pluginDescriptor = pluginDescriptor;
+    }
+
+    public void configure(Object mojo, XmlNode configuration) throws 
PluginConfigurationException {
+        if (configuration == null) {
+            return;
+        }
+        for (XmlNode child : configuration.children()) {
+            try {
+                set(mojo, child.name(), child);
+            } catch (Exception e) {
+                throw new PluginConfigurationException(
+                        pluginDescriptor,
+                        "Unable to configure '" + child.name() + "' on "
+                                + mojo.getClass().getName(),
+                        e);
+            }
+        }
+    }
+
+    private void set(Object bean, String name, XmlNode node) throws Exception {
+        if (name.isEmpty()) {
+            throw new NoSuchFieldException("<empty element name>");
+        }
+        String title = Character.toUpperCase(name.charAt(0)) + 
name.substring(1);
+        for (Method method : bean.getClass().getMethods()) {
+            if ((method.getName().equals("set" + title) || 
method.getName().equals("add" + title))
+                    && method.getParameterCount() == 1) {
+                if (!method.canAccess(bean)) {
+                    method.setAccessible(true);
+                }
+                method.invoke(bean, convert(node, 
method.getGenericParameterTypes()[0]));
+                return;
+            }
+        }
+        for (Class<?> type = bean.getClass(); type != null; type = 
type.getSuperclass()) {
+            try {
+                Field field = type.getDeclaredField(name);
+                field.setAccessible(true);
+                field.set(bean, convert(node, field.getGenericType()));
+                return;
+            } catch (NoSuchFieldException ignored) {
+                // Continue through the class hierarchy.
+            }
+        }
+        throw new NoSuchFieldException(name);
+    }
+
+    private Object convert(XmlNode node, Type target) throws Exception {
+        Class<?> type = rawType(target);
+        if (type == XmlNode.class) {
+            return node;
+        }
+        Object scalar = convertScalar(node, type);
+        if (scalar != NOT_SCALAR) {
+            return scalar;
+        }
+        if (type.isArray()) {
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                String[] parts = text != null ? text.split(",", -1) : new 
String[0];
+                Object array = Array.newInstance(type.getComponentType(), 
parts.length);
+                for (int i = 0; i < parts.length; i++) {
+                    Array.set(array, i, convert(XmlNode.newInstance("item", 
parts[i].trim()), type.getComponentType()));
+                }
+                return array;
+            }
+            Object array = Array.newInstance(type.getComponentType(), 
children.size());
+            for (int i = 0; i < children.size(); i++) {
+                Array.set(array, i, convert(children.get(i), 
type.getComponentType()));
+            }
+            return array;
+        }
+        if (Collection.class.isAssignableFrom(type)) {
+            Type itemType = target instanceof ParameterizedType p ? 
p.getActualTypeArguments()[0] : Object.class;
+            Collection<Object> values =
+                    Set.class.isAssignableFrom(type) ? new 
java.util.LinkedHashSet<>() : new ArrayList<>();
+            List<XmlNode> children = node.children();
+            if (children.isEmpty()) {
+                // No child elements: treat the text value as a 
comma-separated list
+                String text = evaluateNodeAsString(node);
+                if (text != null) {
+                    for (String part : text.split(",", -1)) {
+                        values.add(convert(XmlNode.newInstance("item", 
part.trim()), itemType));

Review Comment:
   ⚠️ **Same `split(",", -1)` empty-token issue as the array path above.**
   
   ```suggestion
                       for (String part : text.split(",")) {
                           String trimmed = part.trim();
                           if (!trimmed.isEmpty()) {
                               values.add(convert(XmlNode.newInstance("item", 
trimmed), itemType));
                           }
                       }
   ```



##########
impl/maven-core/src/main/java/org/apache/maven/plugin/internal/PlexusMojoExpressionEvaluatorAdapter.java:
##########
@@ -0,0 +1,50 @@
+/*
+ * 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.plugin.internal;
+
+import java.io.File;
+
+import 
org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator;
+import 
org.codehaus.plexus.component.configurator.expression.TypeAwareExpressionEvaluator;
+
+/**
+ * Adapts a Plexus {@link ExpressionEvaluator} to the internal {@link 
MojoExpressionEvaluator} interface,
+ * isolating {@link V4MojoConfigurator} from the Plexus API.
+ */
+class PlexusMojoExpressionEvaluatorAdapter implements MojoExpressionEvaluator {
+    private final TypeAwareExpressionEvaluator delegate;
+
+    PlexusMojoExpressionEvaluatorAdapter(ExpressionEvaluator evaluator) {
+        this.delegate = (TypeAwareExpressionEvaluator) evaluator;
+    }

Review Comment:
   ⚠️ **Unchecked cast to `TypeAwareExpressionEvaluator` — deferred 
`ClassCastException`.**
   
   The constructor accepts `ExpressionEvaluator` (the base interface) but 
immediately casts it to `TypeAwareExpressionEvaluator`. If a non-`TypeAware` 
evaluator is ever passed, the cast fails silently at construction time and 
throws `ClassCastException` only when `evaluate()` is called — which is harder 
to diagnose.
   
   Since `V4MojoConfiguratorTestHelper` already takes 
`TypeAwareExpressionEvaluator` directly, the simplest fix is to narrow the 
parameter type, eliminating the cast entirely:
   
   ```suggestion
       PlexusMojoExpressionEvaluatorAdapter(TypeAwareExpressionEvaluator 
evaluator) {
           this.delegate = evaluator;
       }
   ```
   
   `DefaultMavenPluginManager` will need to cast at the call site (or narrow 
its local variable), but the cast failure will then be explicit and correctly 
attributed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to