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

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

commit 572ce40d289cd5906e420cb44683c20b535fd988
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue Mar 24 19:37:54 2026 +0100

    Add namespace context to XmlNode for proper prefix resolution on write
    
    XmlNode now carries a namespaces() map (prefix → URI) that captures the
    full namespace context inherited from ancestor elements during parsing.
    This allows the write side to resolve and auto-declare namespace prefixes
    for attributes like mvn:combine.children even when the xmlns:mvn declaration
    was on an ancestor element and not in the local attribute map.
    
    - Add namespaces() default method to XmlNode API (returns Map.of())
    - Add namespaces field to XmlNode.Builder and Impl record
    - Update DefaultXmlService.doBuild() to accumulate namespace context
    - Update writeAttributes() and writeXmlNodeAttributes() (template) to
      resolve prefixes from namespace context and auto-declare namespaces
    - Propagate namespaces through merge operations
    - Add tests for inherited namespace context and orphaned prefix stripping
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../java/org/apache/maven/api/xml/XmlNode.java     | 38 +++++++++++++-
 .../maven/internal/xml/DefaultXmlService.java      | 60 ++++++++++++++++++----
 .../apache/maven/internal/xml/XmlNodeImplTest.java | 55 ++++++++++++++------
 src/mdo/writer-stax.vm                             | 26 ++++++----
 4 files changed, 144 insertions(+), 35 deletions(-)

diff --git 
a/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java 
b/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java
index a78357a091..a9634585d5 100644
--- a/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java
+++ b/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java
@@ -159,6 +159,24 @@ public interface XmlNode {
     @Nullable
     String attribute(@Nonnull String name);
 
+    /**
+     * Returns the namespace context for this node — a map of namespace prefix 
to URI
+     * for all namespace bindings in scope, including those declared on this 
element
+     * and those inherited from ancestor elements.
+     * <p>
+     * This is used by the write side to properly resolve prefixed attributes.
+     * For example, if an attribute {@code mvn:combine.children} exists on a 
child element
+     * but {@code xmlns:mvn} was declared on the root element, this map will 
contain
+     * the {@code mvn → http://maven.apache.org/POM/4.0.0} binding.
+     *
+     * @return map of namespace prefix to URI, never {@code null}
+     * @since 4.1.0
+     */
+    @Nonnull
+    default Map<String, String> namespaces() {
+        return Map.of();
+    }
+
     /**
      * Returns an immutable list of all child nodes.
      *
@@ -358,6 +376,7 @@ class Builder {
         private String namespaceUri;
         private String prefix;
         private Map<String, String> attributes;
+        private Map<String, String> namespaces;
         private List<XmlNode> children;
         private Object inputLocation;
 
@@ -421,6 +440,21 @@ public Builder attributes(Map<String, String> attributes) {
             return this;
         }
 
+        /**
+         * Sets the namespace context for this node.
+         * <p>
+         * This map contains all namespace prefix to URI bindings in scope,
+         * including inherited ones from ancestor elements.
+         *
+         * @param namespaces the map of namespace prefix to URI
+         * @return this builder instance
+         * @since 4.1.0
+         */
+        public Builder namespaces(Map<String, String> namespaces) {
+            this.namespaces = namespaces;
+            return this;
+        }
+
         /**
          * Sets the child nodes of the XML node.
          * <p>
@@ -454,7 +488,7 @@ public Builder inputLocation(Object inputLocation) {
          * @throws NullPointerException if name has not been set
          */
         public XmlNode build() {
-            return new Impl(prefix, namespaceUri, name, value, attributes, 
children, inputLocation);
+            return new Impl(prefix, namespaceUri, name, value, attributes, 
namespaces, children, inputLocation);
         }
 
         private record Impl(
@@ -463,6 +497,7 @@ private record Impl(
                 @Nonnull String name,
                 String value,
                 @Nonnull Map<String, String> attributes,
+                @Nonnull Map<String, String> namespaces,
                 @Nonnull List<XmlNode> children,
                 Object inputLocation)
                 implements XmlNode, Serializable {
@@ -473,6 +508,7 @@ private record Impl(
                 namespaceUri = namespaceUri == null ? "" : namespaceUri;
                 name = Objects.requireNonNull(name);
                 attributes = ImmutableCollections.copy(attributes);
+                namespaces = ImmutableCollections.copy(namespaces);
                 children = ImmutableCollections.copy(children);
             }
 
diff --git 
a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
 
b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
index 434981bca0..326671d1a7 100644
--- 
a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
+++ 
b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
@@ -30,6 +30,7 @@
 import java.io.Writer;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -68,10 +69,14 @@ public XmlNode doRead(Reader reader, @Nullable 
XmlService.InputLocationBuilder l
     @Override
     public XmlNode doRead(XMLStreamReader parser, @Nullable 
XmlService.InputLocationBuilder locationBuilder)
             throws XMLStreamException {
-        return doBuild(parser, DEFAULT_TRIM, locationBuilder);
+        return doBuild(parser, DEFAULT_TRIM, locationBuilder, new HashMap<>());
     }
 
-    private XmlNode doBuild(XMLStreamReader parser, boolean trim, 
InputLocationBuilder locationBuilder)
+    private XmlNode doBuild(
+            XMLStreamReader parser,
+            boolean trim,
+            InputLocationBuilder locationBuilder,
+            Map<String, String> parentNamespaces)
             throws XMLStreamException {
         boolean spacePreserve = false;
         String lPrefix = null;
@@ -80,6 +85,7 @@ private XmlNode doBuild(XMLStreamReader parser, boolean trim, 
InputLocationBuild
         String lValue = null;
         Object location = null;
         Map<String, String> attrs = null;
+        Map<String, String> nsContext = null;
         List<XmlNode> children = null;
         int eventType = parser.getEventType();
         int lastStartTag = -1;
@@ -93,6 +99,15 @@ private XmlNode doBuild(XMLStreamReader parser, boolean 
trim, InputLocationBuild
                     lNamespaceUri = parser.getNamespaceURI();
                     lName = parser.getLocalName();
                     location = locationBuilder != null ? 
locationBuilder.toInputLocation(parser) : null;
+                    // Build the namespace context: start with inherited, add 
local declarations
+                    nsContext = new HashMap<>(parentNamespaces);
+                    for (int i = 0; i < namespacesSize; i++) {
+                        String nsPrefix = parser.getNamespacePrefix(i);
+                        String nsUri = parser.getNamespaceURI(i);
+                        if (nsPrefix != null && !nsPrefix.isEmpty()) {
+                            nsContext.put(nsPrefix, nsUri);
+                        }
+                    }
                     int attributesSize = parser.getAttributeCount();
                     if (attributesSize > 0 || namespacesSize > 0) {
                         attrs = new HashMap<>();
@@ -116,7 +131,7 @@ private XmlNode doBuild(XMLStreamReader parser, boolean 
trim, InputLocationBuild
                     if (children == null) {
                         children = new ArrayList<>();
                     }
-                    XmlNode child = doBuild(parser, trim, locationBuilder);
+                    XmlNode child = doBuild(parser, trim, locationBuilder, 
nsContext != null ? nsContext : Map.of());
                     children.add(child);
                 }
             } else if (eventType == XMLStreamReader.CHARACTERS || eventType == 
XMLStreamReader.CDATA) {
@@ -135,6 +150,7 @@ private XmlNode doBuild(XMLStreamReader parser, boolean 
trim, InputLocationBuild
                         .name(lName)
                         .value(children == null ? (lValue != null ? lValue : 
emptyTag ? null : "") : null)
                         .attributes(attrs)
+                        .namespaces(nsContext)
                         .children(children)
                         .inputLocation(location)
                         .build();
@@ -162,7 +178,7 @@ public void doWrite(XmlNode node, Writer writer) throws 
IOException {
     private void writeNode(XMLStreamWriter xmlWriter, XmlNode node) throws 
XMLStreamException {
         xmlWriter.writeStartElement(node.prefix(), node.name(), 
node.namespaceUri());
 
-        writeAttributes(xmlWriter, node.attributes());
+        writeAttributes(xmlWriter, node.attributes(), node.namespaces());
 
         for (XmlNode child : node.children()) {
             writeNode(xmlWriter, child);
@@ -176,17 +192,35 @@ private void writeNode(XMLStreamWriter xmlWriter, XmlNode 
node) throws XMLStream
         xmlWriter.writeEndElement();
     }
 
-    private static void writeAttributes(XMLStreamWriter xmlWriter, Map<String, 
String> attributes)
+    /**
+     * Writes XmlNode attributes, properly handling namespace declarations
+     * ({@code xmlns:prefix}) and prefixed attributes ({@code 
prefix:localName}).
+     * The namespace context is used to resolve prefixes when the {@code 
xmlns:}
+     * declaration is not present in the attribute map (e.g., it was declared 
on
+     * an ancestor element).
+     *
+     * @param xmlWriter the StAX writer
+     * @param attributes the attribute map (may contain xmlns: entries)
+     * @param namespaces the namespace context (prefix → URI) for resolving 
prefixed attributes
+     */
+    static void writeAttributes(
+            XMLStreamWriter xmlWriter, Map<String, String> attributes, 
Map<String, String> namespaces)
             throws XMLStreamException {
-        // Write namespace declarations first, then regular attributes
+        // Collect which namespace prefixes need to be declared on this 
element:
+        // start with those explicitly in attributes (xmlns:prefix), then add
+        // any prefixes used by attributes that are resolved from the 
namespace context
+        Set<String> declaredPrefixes = new HashSet<>();
         for (Map.Entry<String, String> attribute : attributes.entrySet()) {
             String key = attribute.getKey();
             if ("xmlns".equals(key)) {
                 xmlWriter.writeDefaultNamespace(attribute.getValue());
             } else if (key.startsWith("xmlns:")) {
-                xmlWriter.writeNamespace(key.substring(6), 
attribute.getValue());
+                String prefix = key.substring(6);
+                xmlWriter.writeNamespace(prefix, attribute.getValue());
+                declaredPrefixes.add(prefix);
             }
         }
+        // Write prefixed attributes, declaring their namespace if needed
         for (Map.Entry<String, String> attribute : attributes.entrySet()) {
             String key = attribute.getKey();
             String value = attribute.getValue();
@@ -201,13 +235,20 @@ private static void writeAttributes(XMLStreamWriter 
xmlWriter, Map<String, Strin
                 int colon = key.indexOf(':');
                 String prefix = key.substring(0, colon);
                 String localName = key.substring(colon + 1);
+                // Look up namespace URI: first from local xmlns: 
declarations, then from context
                 String nsUri = attributes.get("xmlns:" + prefix);
+                if (nsUri == null) {
+                    nsUri = namespaces.get(prefix);
+                }
                 if (nsUri != null) {
+                    // Declare the namespace if not already declared on this 
element
+                    if (declaredPrefixes.add(prefix)) {
+                        xmlWriter.writeNamespace(prefix, nsUri);
+                    }
                     xmlWriter.writeAttribute(prefix, nsUri, localName, value);
                 } else {
                     // No namespace declaration found for this prefix; write 
as unprefixed
-                    // to produce valid XML (the namespace declaration may 
have been lost
-                    // during consumer POM transformation)
+                    // to produce valid XML
                     xmlWriter.writeAttribute(localName, value);
                 }
             } else {
@@ -405,6 +446,7 @@ public XmlNode doMerge(XmlNode dominant, XmlNode recessive, 
Boolean childMergeOv
                             .name(dominant.name())
                             .value(value != null ? value : dominant.value())
                             .attributes(attrs)
+                            .namespaces(dominant.namespaces())
                             .children(children)
                             .inputLocation(location)
                             .build();
diff --git 
a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
 
b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
index 293a4db1a4..bf1596b86b 100644
--- 
a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
+++ 
b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
@@ -809,17 +809,12 @@ void testWriteForeignNamespaceAttributeRoundTrip() throws 
Exception {
     }
 
     /**
-     * This test reproduces the consumer POM bug from issue #11760.
-     * In the consumer POM transformation, the model is re-serialized from the
-     * Model object. Namespace declarations like xmlns:custom on the 
&lt;project&gt;
-     * element are lost (they are not part of the Maven model), but prefixed
-     * attributes like custom:myattr survive in plugin configuration XmlNode 
trees.
-     * When these orphaned prefixed attributes are written without their 
namespace
-     * declaration, writing them as-is would produce invalid XML ("Undeclared
-     * namespace prefix"). Instead, the prefix is stripped to produce valid 
XML.
+     * Verifies that when a prefixed attribute's xmlns declaration is on a 
parent
+     * element, the child node's namespace context (inherited from parsing) 
allows
+     * the prefix to be preserved when writing the child alone.
      */
     @Test
-    void testWriteStripsOrphanedPrefixFromChildElement() throws Exception {
+    void testWritePreservesPrefixFromInheritedNamespaceContext() throws 
Exception {
         String xml = """
                 <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:custom="http://example.com/custom";>
                     <compilerArgs custom:myattr="value">
@@ -831,23 +826,53 @@ void testWriteStripsOrphanedPrefixFromChildElement() 
throws Exception {
         XmlNode node = toXmlNode(xml);
 
         // The child element has custom:myattr but NOT xmlns:custom in its own 
attributes
-        // (the namespace declaration is on the parent <project> element)
         XmlNode compilerArgs = node.child("compilerArgs");
         assertNotNull(compilerArgs);
         assertEquals("value", compilerArgs.attribute("custom:myattr"));
         assertNull(compilerArgs.attribute("xmlns:custom"), "xmlns:custom 
should be on parent, not child");
+        // But the namespace context has the binding from the parent
+        assertEquals("http://example.com/custom";, 
compilerArgs.namespaces().get("custom"));
 
-        // Writing the child alone (as happens during consumer POM 
serialization)
-        // must produce valid XML. Since xmlns:custom is not available, the 
prefix
-        // is stripped to avoid an "Undeclared namespace prefix" error.
+        // Writing the child alone should produce valid XML with the namespace 
declared
         StringWriter writer = new StringWriter();
         XmlService.write(compilerArgs, writer);
         String output = writer.toString();
 
         XmlNode reRead = toXmlNode(output);
         assertNotNull(reRead);
-        // Prefix was stripped, so the attribute is now unprefixed
-        assertEquals("value", reRead.attribute("myattr"));
+        // Prefix is preserved because the namespace context provided the URI
+        assertEquals("value", reRead.attribute("custom:myattr"));
+    }
+
+    /**
+     * Verifies that when namespace context is not available (e.g., XmlNode 
built
+     * programmatically without namespaces), orphaned prefixes are stripped on 
write
+     * to produce valid XML.
+     */
+    @Test
+    void testWriteStripsOrphanedPrefixWithoutNamespaceContext() throws 
Exception {
+        // Build a node with a prefixed attribute but no namespace context at 
all
+        XmlNode node = XmlNode.newBuilder()
+                .name("compilerArgs")
+                .attributes(Map.of("mvn:combine.children", "append"))
+                .children(List.of(XmlNode.newBuilder()
+                        .name("arg")
+                        .value("-Xlint:deprecation")
+                        .build()))
+                .build();
+
+        assertTrue(node.namespaces().isEmpty(), "No namespace context");
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        assertFalse(output.contains("mvn:combine"), "Output should not contain 
orphaned mvn: prefix");
+        assertTrue(output.contains("combine.children=\"append\""), "Attribute 
should be written unprefixed");
+
+        XmlNode reRead = toXmlNode(output);
+        assertNotNull(reRead);
+        assertEquals("append", reRead.attribute("combine.children"));
     }
 
     /**
diff --git a/src/mdo/writer-stax.vm b/src/mdo/writer-stax.vm
index 5c8cb58137..fb2aaf7bd3 100644
--- a/src/mdo/writer-stax.vm
+++ b/src/mdo/writer-stax.vm
@@ -366,7 +366,7 @@ public class ${className} {
     private void writeDom(XmlNode dom, XMLStreamWriter serializer) throws 
IOException, XMLStreamException {
         if (dom != null) {
             serializer.writeStartElement(namespace, dom.name());
-            writeXmlNodeAttributes(serializer, dom.attributes());
+            writeXmlNodeAttributes(serializer, dom.attributes(), 
dom.namespaces());
             for (XmlNode child : dom.children()) {
                 writeDom(child, serializer);
             }
@@ -407,27 +407,29 @@ public class ${className} {
     /**
      * Writes XmlNode attributes, properly handling namespace declarations
      * ({@code xmlns:prefix}) and prefixed attributes ({@code 
prefix:localName}).
+     * The namespace context is used to resolve prefixes when the {@code 
xmlns:}
+     * declaration is not present in the attribute map.
      */
-    private static void writeXmlNodeAttributes(XMLStreamWriter serializer, 
Map<String, String> attributes) throws XMLStreamException {
-        // Write namespace declarations first
+    private static void writeXmlNodeAttributes(XMLStreamWriter serializer, 
Map<String, String> attributes, Map<String, String> namespaces) throws 
XMLStreamException {
+        // Collect which namespace prefixes need to be declared on this element
+        Set<String> declaredPrefixes = new HashSet<>();
         for (Map.Entry<String, String> attribute : attributes.entrySet()) {
             String key = attribute.getKey();
             if ("xmlns".equals(key)) {
                 serializer.writeDefaultNamespace(attribute.getValue());
             } else if (key.startsWith("xmlns:")) {
-                serializer.writeNamespace(key.substring(6), 
attribute.getValue());
+                String prefix = key.substring(6);
+                serializer.writeNamespace(prefix, attribute.getValue());
+                declaredPrefixes.add(prefix);
             }
         }
-        // Then write regular attributes
+        // Write prefixed attributes, declaring their namespace if needed
         for (Map.Entry<String, String> attribute : attributes.entrySet()) {
             String key = attribute.getKey();
             String value = attribute.getValue();
             if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
                 continue; // already written above
             } else if (key.startsWith("xml:")) {
-                // The xml: prefix is predefined and bound to the XML 
namespace.
-                // It must not be declared, but attributes like xml:space 
still need
-                // to be written using the proper namespace URI.
                 serializer.writeAttribute(
                         "http://www.w3.org/XML/1998/namespace";, 
key.substring(4), value);
             } else if (key.contains(":")) {
@@ -435,11 +437,15 @@ public class ${className} {
                 String prefix = key.substring(0, colon);
                 String localName = key.substring(colon + 1);
                 String nsUri = attributes.get("xmlns:" + prefix);
+                if (nsUri == null) {
+                    nsUri = namespaces.get(prefix);
+                }
                 if (nsUri != null) {
+                    if (declaredPrefixes.add(prefix)) {
+                        serializer.writeNamespace(prefix, nsUri);
+                    }
                     serializer.writeAttribute(prefix, nsUri, localName, value);
                 } else {
-                    // No namespace declaration for this prefix; write as 
unprefixed
-                    // to produce valid XML
                     serializer.writeAttribute(localName, value);
                 }
             } else {

Reply via email to