Skip to content

Fix XML namespace handling for prefixed attributes in consumer POM (fixes #11760)#11823

Open
gnodet wants to merge 6 commits intomasterfrom
fix/11760-namespace-prefix-normalization
Open

Fix XML namespace handling for prefixed attributes in consumer POM (fixes #11760)#11823
gnodet wants to merge 6 commits intomasterfrom
fix/11760-namespace-prefix-normalization

Conversation

@gnodet
Copy link
Copy Markdown
Contributor

@gnodet gnodet commented Mar 23, 2026

Summary

Fix XML namespace handling so that prefixed attributes (like mvn:combine.children) survive consumer POM transformation without producing invalid XML. This adds proper namespace context tracking to XmlNode and fixes both the StAX and XPP3 writer templates.

Fixes #11760

Problem

The jdbi project uses mvn:combine.children="append" with xmlns:mvn="http://maven.apache.org/POM/4.0.0" declared on <project>. During consumer POM transformation, the xmlns:mvn declaration (on <project>) is lost because it's not part of the Maven model, but the prefixed attributes on plugin configuration XmlNode trees survive. When the consumer POM is serialized, this produces invalid XML with undeclared namespace prefixes.

Important finding: mvn:combine.children never actually triggered merge/append behavior — not in Maven 3, not in Maven 4. Per the XML namespace spec (Section 6.2), default namespace declarations do NOT apply to attributes. Unprefixed attributes are in no namespace, so combine.children and mvn:combine.children are different attributes. Maven's merge logic looks up the unprefixed form only. This was verified by testing with Maven 3.9.11.

Changes

1. XmlNode API — namespace context (api/maven-api-xml)

  • Add namespaces() method returning Map<String, String> (prefix → URI) for all namespace bindings in scope, including inherited ones from ancestor elements
  • Add corresponding namespaces field to Builder and Impl record
  • Default method returns Map.of() for backwards compatibility

2. Parser — namespace context accumulation (impl/maven-xml)

  • DefaultXmlService.doBuild() accumulates namespace declarations during parsing and propagates them to child nodes
  • Each XmlNode now carries the full namespace context from its position in the document tree

3. StAX writer — proper namespace handling (impl/maven-xml, src/mdo/writer-stax.vm)

  • xmlns:prefix entries are written as proper namespace declarations (via writeNamespace())
  • Prefixed attributes are resolved against local xmlns: declarations first, then the namespace context
  • Namespaces are auto-declared on write when the prefix is resolved from context but not locally declared
  • xml: prefixed attributes use the predefined XML namespace URI
  • Orphaned prefixes (no declaration, no context) are stripped to produce valid XML

4. XPP3 writer — same fix (src/mdo/writer.vm)

  • Apply the same namespace handling to the deprecated XPP3 writer template using setPrefix() / attribute() API

5. Merge — namespace propagation (impl/maven-xml)

  • Merged nodes preserve the dominant node's namespace context

Test plan

28 namespace-specific tests organized by category:

Parsing (8 tests): single/multiple prefixes, inheritance across 3 levels, prefix shadowing, default namespace exclusion, empty map, immutability

Writing (9 tests): round-trip with declarations, orphaned prefix stripping, foreign namespace round-trip, inherited context preservation, multiple prefixed attributes from different namespaces, local xmlns precedence over context, xml:space round-trip, unprefixed attributes unchanged, no duplicate declarations

Merging (4 tests): dominant namespace preservation, combine.children/self with namespaces, merged node standalone write

Builder (4 tests): explicit namespaces, null defaults, immutability, default method

Round-trip fidelity (3 tests): full tree, deep nesting, overridden namespace

Consumer POM simulation (2 tests): context-based prefix resolution, fallback stripping

Merge directive interaction (3 tests): prefixed combine.children does NOT merge, prefixed combine.self does NOT override, unprefixed directives still work

All existing tests pass: 70 in maven-xml, 172 in maven-model.

🤖 Generated with Claude Code

Claude Code on behalf of Guillaume Nodet

@gnodet gnodet added this to the 4.0.0-rc-6 milestone Mar 23, 2026
Copy link
Copy Markdown
Contributor

@elharo elharo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be based on a common misunderstanding of how namespaces work in attributes. Unprefixed attributes are never in any namespace. Not the default namespace of a document, not the namespace of the parent element. The only way an attribute has a namespace name is if it has a prefix.

…ixed attributes (fixes #11760)

The consumer POM transformation was producing invalid XML when a POM used
namespace-prefixed attributes like mvn:combine.children with
xmlns:mvn="http://maven.apache.org/POM/4.0.0". The xmlns:mvn declaration
was lost during model transformation, but the prefixed attributes remained,
resulting in undeclared namespace prefix errors.

Fix the write side (DefaultXmlService.writeNode and generated
MavenStaxWriter.writeDom) to:
- Write xmlns:prefix entries as proper namespace declarations via writeNamespace()
- Write prefixed attributes with their namespace URI via writeAttribute(prefix, nsUri, localName, value)
- Strip the prefix from orphaned prefixed attributes (where the xmlns declaration is missing) to produce valid XML

Note: mvn:combine.children does NOT trigger merge behavior, and never did
(even in Maven 3). Per the XML namespace spec, unprefixed attributes are in
no namespace, so combine.children and mvn:combine.children are different
attributes. Only the unprefixed form works as a merge directive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet gnodet force-pushed the fix/11760-namespace-prefix-normalization branch from f1a365a to ef7d06c Compare March 24, 2026 12:03
@gnodet gnodet changed the title Normalize redundant namespace prefixes on XML attributes (fixes #11760) Fix XML write side to handle namespace declarations and prefixed attributes (fixes #11760) Mar 24, 2026
Copy link
Copy Markdown
Contributor Author

@gnodet gnodet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

@elharo You're absolutely right — thanks for catching this. The previous version of the PR was based on the incorrect assumption that mvn:combine.children and combine.children are semantically identical when the prefix maps to the same namespace. Per the XML Namespaces spec, unprefixed attributes are in no namespace, so they are indeed different attributes.

We verified this with a test project on Maven 3.9.11:

  • combine.children="append" (unprefixed) → works correctly, both parent and child compiler args are present
  • mvn:combine.children="append" (prefixed) → silently ignored, only child args are present (default merge replaces by name)

So mvn:combine.children never worked in Maven 3 either. The Maven 4 regression is narrower: the consumer POM transformation drops the xmlns:mvn declaration (which lives on <project> and is lost during model serialization) but the prefixed attributes survive in plugin configuration XmlNode trees, producing invalid XML with undeclared namespace prefixes.

The PR has been reworked to be a write-side-only fix:

  • No read-side normalization (the XML is preserved as-is)
  • xmlns:prefix entries are written as proper namespace declarations via writeNamespace()
  • Prefixed attributes with no corresponding xmlns: declaration have their prefix stripped to produce valid XML
  • A test explicitly confirms that mvn:combine.children does NOT trigger merge behavior (correct per the XML spec)

xmlWriter.writeEndElement();
}

static void writeAttributes(XMLStreamWriter xmlWriter, Map<String, String> attributes) throws XMLStreamException {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it can be private

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Good catch — made it private. It's not called from the generated template (which has its own copy of the logic).


static void writeAttributes(XMLStreamWriter xmlWriter, Map<String, String> attributes) throws XMLStreamException {
// Write namespace declarations first, then regular attributes
for (Map.Entry<String, String> attr : attributes.entrySet()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about what XML library you're using here. Some do not include namespace declarations in their attribute map. If you're using DOM I think it depends on how the parser is configured, though I'd really need to review to make remember exactly how that works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Good point. Maven uses StAX (XMLStreamReader) for parsing. The namespace declarations are reported separately via getNamespaceCount()/getNamespacePrefix()/getNamespaceURI() — they are NOT included in the attribute list (getAttributeCount()). However, during parsing in doBuild(), we explicitly store them as xmlns:prefix entries in the attributes map (see line 99-103). So by the time we write, they are reliably present in the map if they were on the original element.

xmlWriter.writeNamespace(key.substring(6), attr.getValue());
}
}
for (Map.Entry<String, String> attr : attributes.entrySet()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attr --> attribute

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Fixed — renamed to attribute.

.build()))
.build();

java.io.StringWriter writer = new java.io.StringWriter();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just import this class instead of using the fully package qualified name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Fixed — added proper imports for StringWriter, Map, and List.

}

/**
* Verifies that when a prefixed attribute's xmlns declaration is on a parent
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this is trying to test but this doesn't sound right. I don't think the prefix should be removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

This test reproduces the exact bug from issue #11760. Here's what happens in the consumer POM transformation:

  1. The original POM has xmlns:mvn="http://maven.apache.org/POM/4.0.0" on the <project> element
  2. Plugin configuration (e.g., <compilerArgs mvn:combine.children="append">) is stored as XmlNode trees — the attribute mvn:combine.children survives
  3. During consumer POM transformation, the model is re-serialized from the Model object. The xmlns:mvn declaration on <project> is lost (it's not part of the Maven model)
  4. When the XmlNode is written, mvn:combine.children has no corresponding namespace declaration → "Undeclared namespace prefix" error

The choice is: produce invalid XML (crash) or strip the orphaned prefix. We chose to strip the prefix since it's the only way to produce valid XML when the namespace declaration has been lost. Updated the test javadoc to explain this context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

You were right — the prefix should not be removed when the namespace URI is known. We've now addressed this properly in the latest commit by adding a namespaces() method to XmlNode that captures the full namespace context (prefix → URI) inherited from ancestor elements during parsing.

When writing, if a prefixed attribute like mvn:combine.children appears but xmlns:mvn is not in the local attribute map, the writer now looks up the prefix in the namespace context, auto-declares the namespace, and writes the attribute with its proper prefix and namespace URI.

The prefix is only stripped as a last resort when no namespace context is available at all (e.g., a programmatically built XmlNode with no namespace bindings). This produces valid XML in all cases.

gnodet and others added 2 commits March 24, 2026 16:01
- Make writeAttributes private (not called from generated code)
- Rename attr -> attribute in iteration variables
- Add comment explaining xml: prefix handling (predefined, must not be
  declared, but xml:space attributes still need to be written)
- Use proper imports instead of FQCNs in tests (StringWriter, Map, List)
- Improve test javadoc to explain the consumer POM scenario

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
for (int i = 0; i < namespacesSize; i++) {
String nsPrefix = parser.getNamespacePrefix(i);
String nsUri = parser.getNamespaceURI(i);
if (nsPrefix != null && !nsPrefix.isEmpty()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so you're not putting the default namespace in the map? That's fine as long as the calling code expects that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Correct — the default namespace (empty prefix) is intentionally excluded from namespaces(). Per the XML namespace spec (Section 6.2), default namespace declarations do NOT apply to attributes, so it would never be useful for resolving prefixed attributes. Added a comment explaining this. We also have testParseDefaultNamespaceNotInNamespacesMap that verifies this behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why you check lName == null here. It might be fine but naively I'd expect this to be set unconditionally on a startElement

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Good question — the elementName == null check is how this method distinguishes the first START_ELEMENT event (which is "this" element being parsed) from subsequent START_ELEMENT events (which are children, handled in the else branch). The method is called recursively for each child, and elementName starts as null — once the first start tag is seen, it gets set, so all further start tags within the same call are children. Added a comment to clarify this.

Map<String, String> parentNamespaces)
throws XMLStreamException {
boolean spacePreserve = false;
String lPrefix = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what all these l's stand for. local? location? something else? Unabbreviated names for the win.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of Guillaume Nodet

Agreed — renamed lPrefixelementPrefix, lNameelementName, lNamespaceUrielementNamespaceUri, lValueelementValue. Also renamed aName/aValue/aPrefixattrName/attrValue/attrPrefix in the attribute loop.

gnodet and others added 2 commits March 24, 2026 22:35
Add 28 tests covering all aspects of the namespaces() feature:
- Parsing: single/multiple prefixes, inheritance across levels,
  prefix shadowing, default namespace exclusion, immutability
- Writing: round-trip, orphaned prefix stripping, multiple prefixes,
  local xmlns precedence, xml:space, no duplicate declarations
- Merging: namespace propagation, combine.children/self preservation
- Builder: explicit namespaces, null defaults, immutability
- Round-trip fidelity: deep nesting, overridden namespaces
- Consumer POM simulation: context-based and fallback scenarios
- Merge directive interaction: prefixed directives ignored

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d attributes

Apply the same namespace handling to writer.vm (deprecated XPP3 writer)
as was done for writer-stax.vm: xmlns: entries are written as namespace
declarations via setPrefix(), prefixed attributes are resolved from the
namespace context, and orphaned prefixes are stripped to produce valid XML.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet gnodet changed the title Fix XML write side to handle namespace declarations and prefixed attributes (fixes #11760) Fix XML namespace handling for prefixed attributes in consumer POM (fixes #11760) Mar 24, 2026
- Rename l-prefixed variables (lPrefix, lName, etc.) to descriptive
  names (elementPrefix, elementName, etc.)
- Add comment explaining why elementName == null distinguishes the
  current element's START_ELEMENT from child START_ELEMENTs
- Add comment explaining why default namespace is excluded from the
  namespace context (XML spec Section 6.2)
- Combine duplicate namespace iteration into a single loop
- Remove unnecessary null check on nsContext (always non-null)
- Make writeAttributes private (template has its own copy)
- Rename aName/aValue/aPrefix to attrName/attrValue/attrPrefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Maven 4 consumer pom does not carry over mvn xml namespace

2 participants