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

luigidemasi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit ce30f3d3cc5ba43cc603bba0a3310c0199859972
Author: Luigi De Masi <[email protected]>
AuthorDate: Wed Sep 30 10:25:01 2026 +0200

    CAMEL-25138: Discover semantic declarations in ordinary XML resources
    
    Automatically install XML declaration support through the existing context
    service plugin. Detect declarations or their namespace, delegate ordinary
    XML to the standard loader, and retain application loader precedence.
    Preserve resource ownership and manage loader startup, shutdown and restart.
    
    Use ordinary XML filenames throughout the documentation and cover both
    layouts, Camel Main, custom registries, namespace handling and reloads.
    
    Co-authored-by: Codex <[email protected]>
    Signed-off-by: Luigi De Masi <[email protected]>
---
 .../camel/catalog/docs/semantic-language.adoc      |  25 ++-
 .../src/main/docs/semantic-language.adoc           |  25 ++-
 .../camel/semantic/SemanticReloadPlugin.java       |  58 ++++++-
 .../apache/camel/semantic/SemanticXmlLoader.java   | 157 ++++++++++++++++++
 .../semantic/SemanticXmlRoutesBuilderLoader.java   |   9 +-
 .../camel/semantic/SemanticDeclarationDslTest.java |  65 +++++---
 .../semantic/SemanticXmlAutoDiscoveryTest.java     | 181 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   7 +
 8 files changed, 480 insertions(+), 47 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
index cc9d87d92481..4f37730d403b 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
@@ -142,12 +142,14 @@ key and an empty map to `replace` removes the resource's 
questions.
 
 === XML declarations
 
-The optional XML extension belongs to `camel-semantic`. Include 
`camel-semantic` and
-xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`), and give 
declaration files the
-extension `*.semantic.xml`. This selects the component's loader without 
changing the standard
-XML loader or Camel core model.
+The XML extension belongs to `camel-semantic`. Include `camel-semantic` and
+xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`). Camel 
discovers the extension
+automatically at startup: use ordinary `*.xml` files without registering a 
loader or changing
+the Camel core model. XML documents without semantic declarations are handled 
by the standard
+XML loader, including its bean and route configuration support. XML loaders 
registered by the
+application before route loading retain precedence over automatic discovery.
 
-Both layouts are supported. To keep declarations alongside routes, use 
`tickets.semantic.xml`:
+Both layouts are supported. To keep declarations alongside routes, use 
`tickets.xml`:
 
 [source,xml]
 ----
@@ -170,7 +172,7 @@ Both layouts are supported. To keep declarations alongside 
routes, use `tickets.
 </routes>
 ----
 
-To share declarations across route files, use a standalone 
`questions.semantic.xml`:
+To share declarations across route files, use a standalone `questions.xml`:
 
 [source,xml]
 ----
@@ -186,7 +188,7 @@ To share declarations across route files, use a standalone 
`questions.semantic.x
 
 Load this together with ordinary `*.xml`, Java or YAML route resources that 
use `ref:department`.
 For example, with Camel Main, set
-`camel.main.routes-include-pattern=classpath:questions.semantic.xml,classpath:routes.xml`.
+`camel.main.routes-include-pattern=classpath:questions.xml,classpath:routes.xml`.
 Declarations are registered before consuming routes are configured. Reload 
replaces the source's
 questions; removing the `semantic` block, using an empty block, or deleting 
the resource removes
 obsolete definitions. XML source keys are the resource locations.
@@ -195,8 +197,13 @@ The combined format supports a `routes` root with one 
optional `semantic` block
 `route` elements. The standalone format uses a `semantic` root. Namespace-free 
documents are
 supported, as are documents consistently using 
`http://camel.apache.org/schema/semantic`,
 `http://camel.apache.org/schema/xml-io`, or 
`http://camel.apache.org/schema/spring`.
-These extensions do not validate against Camel's standard core XSDs. Ordinary 
`*.xml` files
-and the JAXB loader do not accept embedded semantic declarations.
+A `semantic` block can also declare 
`xmlns="http://camel.apache.org/schema/semantic"` inside a
+standard `routes` document; its question elements inherit that namespace. The 
extension detects
+the declaration block or semantic root namespace automatically.
+
+These extensions do not validate against Camel's standard core XSDs. Automatic 
discovery applies
+to Camel's route resource loader; it does not extend Spring's XML 
application-context parser
+or direct JAXB unmarshalling.
 
 For boolean questions, `threshold`, `uncertainty` and `uncertaintyPolicy` are 
optional question
 attributes. For score questions, replace the named `criterion` elements with 
ordered `level`
diff --git 
a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc 
b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
index cc9d87d92481..4f37730d403b 100644
--- a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
+++ b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
@@ -142,12 +142,14 @@ key and an empty map to `replace` removes the resource's 
questions.
 
 === XML declarations
 
-The optional XML extension belongs to `camel-semantic`. Include 
`camel-semantic` and
-xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`), and give 
declaration files the
-extension `*.semantic.xml`. This selects the component's loader without 
changing the standard
-XML loader or Camel core model.
+The XML extension belongs to `camel-semantic`. Include `camel-semantic` and
+xref:others:java-xml-io-dsl.adoc[XML DSL] (`camel-xml-io-dsl`). Camel 
discovers the extension
+automatically at startup: use ordinary `*.xml` files without registering a 
loader or changing
+the Camel core model. XML documents without semantic declarations are handled 
by the standard
+XML loader, including its bean and route configuration support. XML loaders 
registered by the
+application before route loading retain precedence over automatic discovery.
 
-Both layouts are supported. To keep declarations alongside routes, use 
`tickets.semantic.xml`:
+Both layouts are supported. To keep declarations alongside routes, use 
`tickets.xml`:
 
 [source,xml]
 ----
@@ -170,7 +172,7 @@ Both layouts are supported. To keep declarations alongside 
routes, use `tickets.
 </routes>
 ----
 
-To share declarations across route files, use a standalone 
`questions.semantic.xml`:
+To share declarations across route files, use a standalone `questions.xml`:
 
 [source,xml]
 ----
@@ -186,7 +188,7 @@ To share declarations across route files, use a standalone 
`questions.semantic.x
 
 Load this together with ordinary `*.xml`, Java or YAML route resources that 
use `ref:department`.
 For example, with Camel Main, set
-`camel.main.routes-include-pattern=classpath:questions.semantic.xml,classpath:routes.xml`.
+`camel.main.routes-include-pattern=classpath:questions.xml,classpath:routes.xml`.
 Declarations are registered before consuming routes are configured. Reload 
replaces the source's
 questions; removing the `semantic` block, using an empty block, or deleting 
the resource removes
 obsolete definitions. XML source keys are the resource locations.
@@ -195,8 +197,13 @@ The combined format supports a `routes` root with one 
optional `semantic` block
 `route` elements. The standalone format uses a `semantic` root. Namespace-free 
documents are
 supported, as are documents consistently using 
`http://camel.apache.org/schema/semantic`,
 `http://camel.apache.org/schema/xml-io`, or 
`http://camel.apache.org/schema/spring`.
-These extensions do not validate against Camel's standard core XSDs. Ordinary 
`*.xml` files
-and the JAXB loader do not accept embedded semantic declarations.
+A `semantic` block can also declare 
`xmlns="http://camel.apache.org/schema/semantic"` inside a
+standard `routes` document; its question elements inherit that namespace. The 
extension detects
+the declaration block or semantic root namespace automatically.
+
+These extensions do not validate against Camel's standard core XSDs. Automatic 
discovery applies
+to Camel's route resource loader; it does not extend Spring's XML 
application-context parser
+or direct JAXB unmarshalling.
 
 For boolean questions, `threshold`, `uncertainty` and `uncertaintyPolicy` are 
optional question
 attributes. For score questions, replace the named `criterion` elements with 
ordered `level`
diff --git 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
index 95a4ba3d1342..1fb38ad3e502 100644
--- 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
+++ 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticReloadPlugin.java
@@ -17,13 +17,67 @@
 package org.apache.camel.semantic;
 
 import org.apache.camel.CamelContext;
+import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.spi.ContextServicePlugin;
+import org.apache.camel.spi.LifecycleStrategy;
+import org.apache.camel.spi.RoutesBuilderLoader;
+import org.apache.camel.support.LifecycleStrategySupport;
+import org.apache.camel.support.service.ServiceHelper;
 
-/** Removes definitions from deleted files before the route watcher loads 
their replacements. */
+/** Installs optional XML declaration support and removes definitions from 
deleted files before route reload. */
 public class SemanticReloadPlugin implements ContextServicePlugin {
+    private RoutesBuilderLoader xmlLoader;
+    private LifecycleStrategy lifecycle;
+
     @Override
     public void load(CamelContext context) {
-        // Questions are registered by the route loader or the application.
+        installXmlLoader(context);
+        lifecycle = new LifecycleStrategySupport() {
+            @Override
+            public void onContextInitializing(CamelContext camelContext) {
+                // Applications can replace the registry after the context's 
eager build phase.
+                installXmlLoader(camelContext);
+            }
+        };
+        context.addLifecycleStrategy(lifecycle);
+    }
+
+    private void installXmlLoader(CamelContext context) {
+        // Keep the language usable without the optional XML/model 
dependencies, and preserve application overrides.
+        if 
(context.getClassResolver().resolveClass("org.apache.camel.xml.in.ModelParser") 
== null
+                || 
context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY) != null
+                || 
context.getRegistry().findByType(RoutesBuilderLoader.class).stream()
+                        .anyMatch(loader -> 
loader.isSupportedExtension("xml"))) {
+            return;
+        }
+        if (xmlLoader == null) {
+            xmlLoader = new SemanticXmlLoader();
+            xmlLoader.setCamelContext(context);
+        }
+        try {
+            ServiceHelper.startService(xmlLoader);
+            context.getRegistry().bind(SemanticXmlLoader.REGISTRY_KEY, 
xmlLoader);
+        } catch (Exception e) {
+            throw RuntimeCamelException.wrapRuntimeException(e);
+        }
+    }
+
+    @Override
+    public void unload(CamelContext context) {
+        context.getLifecycleStrategies().remove(lifecycle);
+        lifecycle = null;
+        if (xmlLoader != null) {
+            if 
(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY) == 
xmlLoader) {
+                context.getRegistry().unbind(SemanticXmlLoader.REGISTRY_KEY);
+            }
+            try {
+                ServiceHelper.stopAndShutdownService(xmlLoader);
+            } catch (Exception e) {
+                throw RuntimeCamelException.wrapRuntimeException(e);
+            } finally {
+                xmlLoader = null;
+            }
+        }
     }
 
     @Override
diff --git 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
new file mode 100644
index 000000000000..dd2d06e2e723
--- /dev/null
+++ 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
@@ -0,0 +1,157 @@
+/*
+ * 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.camel.semantic;
+
+import java.io.InputStream;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamReader;
+
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.builder.RouteBuilderLifecycleStrategy;
+import org.apache.camel.spi.Resource;
+import org.apache.camel.spi.RoutesBuilderLoader;
+import org.apache.camel.support.CachedResource;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResolverHelper;
+import org.apache.camel.support.RoutesBuilderLoaderSupport;
+import org.apache.camel.support.service.ServiceHelper;
+
+/** Detects semantic declarations in ordinary XML route resources and 
delegates other XML to the configured loader. */
+final class SemanticXmlLoader extends RoutesBuilderLoaderSupport {
+    static final String REGISTRY_KEY = "semantic-xml-routes-loader";
+    private RoutesBuilderLoader delegate;
+    private final SemanticXmlRoutesBuilderLoader semantic = new 
SemanticXmlRoutesBuilderLoader();
+
+    @Override
+    public String getSupportedExtension() {
+        return "xml";
+    }
+
+    @Override
+    public boolean isSupportedExtension(String extension) {
+        // DefaultCamelContext may build before application beans are bound. 
Give later custom loaders precedence too.
+        return ("xml".equals(extension) || "camel.xml".equals(extension))
+                && 
getCamelContext().getRegistry().findByType(RoutesBuilderLoader.class).stream()
+                        .noneMatch(loader -> loader != this && 
loader.isSupportedExtension(extension));
+    }
+
+    @Override
+    public void preParseRoute(Resource resource) throws Exception {
+        Resource snapshot = snapshot(resource);
+        if (hasDeclarations(snapshot)) {
+            semantic.setCamelContext(getCamelContext());
+            semantic.preParseRoute(snapshot);
+        } else {
+            delegate().preParseRoute(snapshot);
+        }
+    }
+
+    @Override
+    public RoutesBuilder loadRoutesBuilder(Resource resource) throws Exception 
{
+        Resource snapshot = snapshot(resource);
+        if (hasDeclarations(snapshot)) {
+            semantic.setCamelContext(getCamelContext());
+            return semantic.loadRoutesBuilder(snapshot);
+        }
+        RoutesBuilder answer = delegate().loadRoutesBuilder(snapshot);
+        if (answer instanceof RouteBuilder builder) {
+            // Declarations removed from a resource are discarded only after 
its replacement configures successfully.
+            builder.addLifecycleInterceptor(new 
RouteBuilderLifecycleStrategy() {
+                @Override
+                public void afterConfigure(RouteBuilder routeBuilder) {
+                    SemanticQuestions questions
+                            = 
getCamelContext().getCamelContextExtension().getContextPlugin(SemanticQuestions.class);
+                    if (questions != null) {
+                        questions.remove(resource.getLocation());
+                    }
+                }
+            });
+        }
+        return answer;
+    }
+
+    private synchronized RoutesBuilderLoader delegate() throws Exception {
+        if (delegate == null) {
+            // Resolve the service directly: looking up "xml" through 
RoutesLoader would find this wrapper again.
+            var finder
+                    = 
getCamelContext().getCamelContextExtension().getBootstrapFactoryFinder(RoutesBuilderLoader.FACTORY_PATH);
+            RoutesBuilderLoader loader = ResolverHelper
+                    .resolveService(getCamelContext(), finder, "xml", 
RoutesBuilderLoader.class)
+                    .orElseThrow(() -> new IllegalStateException("An XML 
routes loader such as camel-xml-io-dsl is required"));
+            loader.setCamelContext(getCamelContext());
+            
PluginHelper.getRoutesLoader(getCamelContext()).initRoutesBuilderLoader(loader);
+            ServiceHelper.startService(loader);
+            delegate = loader;
+        }
+        return delegate;
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        ServiceHelper.stopAndShutdownServices(delegate, semantic);
+        delegate = null;
+        super.doStop();
+    }
+
+    private static Resource snapshot(Resource resource) {
+        // Share bytes only within one call. Preserve the original 
scheme/existence for deletion tracking.
+        return new CachedResource(resource) {
+            @Override
+            public String getScheme() {
+                return resource.getScheme();
+            }
+        };
+    }
+
+    private static boolean hasDeclarations(Resource resource) throws Exception 
{
+        XMLInputFactory factory = XMLInputFactory.newFactory();
+        factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+        factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, 
false);
+        factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, 
false);
+        try (InputStream stream = resource.getInputStream()) {
+            XMLStreamReader reader = factory.createXMLStreamReader(stream);
+            try {
+                int depth = 0;
+                while (reader.hasNext()) {
+                    int event = reader.next();
+                    if (event == XMLStreamConstants.START_ELEMENT) {
+                        depth++;
+                        if (depth == 1) {
+                            if ("semantic".equals(reader.getLocalName())
+                                    || 
SemanticXmlRoutesBuilderLoader.NAMESPACE.equals(reader.getNamespaceURI())) {
+                                return true;
+                            }
+                            if (!"routes".equals(reader.getLocalName())) {
+                                return false;
+                            }
+                        } else if (depth == 2 && 
"semantic".equals(reader.getLocalName())) {
+                            return true;
+                        }
+                    } else if (event == XMLStreamConstants.END_ELEMENT) {
+                        depth--;
+                    }
+                }
+                return false;
+            } finally {
+                reader.close();
+            }
+        }
+    }
+}
diff --git 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
index a4c6b0774d50..7be5c330fa00 100644
--- 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
+++ 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
@@ -41,10 +41,11 @@ import org.apache.camel.spi.annotations.RoutesLoader;
 import org.apache.camel.support.RoutesBuilderLoaderSupport;
 import org.apache.camel.xml.in.ModelParser;
 
-/** Loads standalone declarations or declarations alongside routes from {@code 
*.semantic.xml} resources. */
+/** Parses standalone semantic declarations or declarations alongside XML 
routes. */
 @RoutesLoader("semantic.xml")
 public class SemanticXmlRoutesBuilderLoader extends RoutesBuilderLoaderSupport 
{
-    private static final Set<String> NAMESPACES = Set.of("", 
"http://camel.apache.org/schema/semantic";,
+    static final String NAMESPACE = "http://camel.apache.org/schema/semantic";;
+    private static final Set<String> NAMESPACES = Set.of("", NAMESPACE,
             "http://camel.apache.org/schema/xml-io";, 
"http://camel.apache.org/schema/spring";);
 
     @Override
@@ -188,7 +189,9 @@ public class SemanticXmlRoutesBuilderLoader extends 
RoutesBuilderLoaderSupport {
         List<Element> answer = new ArrayList<>();
         for (Node child = parent.getFirstChild(); child != null; child = 
child.getNextSibling()) {
             if (child instanceof Element element) {
-                if (!namespace(parent).equals(namespace(element))) {
+                boolean semanticBlock = "routes".equals(parent.getLocalName())
+                        && "semantic".equals(element.getLocalName()) && 
NAMESPACE.equals(namespace(element));
+                if (!semanticBlock && 
!namespace(parent).equals(namespace(element))) {
                     throw new IllegalArgumentException("Unexpected namespace 
on " + element.getTagName());
                 }
                 answer.add(element);
diff --git 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java
 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java
index 8a396b98c76f..9d56c50ff1d2 100644
--- 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java
+++ 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticDeclarationDslTest.java
@@ -114,7 +114,8 @@ class SemanticDeclarationDslTest {
     }
 
     @ParameterizedTest
-    @ValueSource(strings = { "java", "routes", "standalone", "no-namespace", 
"component-namespace", "xml-io-namespace" })
+    @ValueSource(strings = {
+            "java", "routes", "standalone", "no-namespace", 
"component-namespace", "xml-io-namespace", "semantic-extension" })
     void componentDeclarationsEvaluateSingleQuestionsAndMixedBatches(String 
dsl) throws Exception {
         if (dsl.equals("java")) {
             context.addRoutes(new RouteBuilder() {
@@ -139,7 +140,7 @@ class SemanticDeclarationDslTest {
             if (dsl.equals("standalone")) {
                 PluginHelper.getRoutesLoader(context).loadRoutes(List.of(
                         ResourceHelper.fromString("use.xml", 
document("routes", route)),
-                        ResourceHelper.fromString("questions.semantic.xml", 
xmlQuestions())));
+                        ResourceHelper.fromString("questions.xml", 
xmlQuestions())));
             } else {
                 String xml = document("routes", route + xmlQuestions());
                 if (dsl.equals("no-namespace")) {
@@ -149,7 +150,7 @@ class SemanticDeclarationDslTest {
                 } else if (dsl.equals("xml-io-namespace")) {
                     xml = xml.replace("schema/spring", "schema/xml-io");
                 }
-                load("questions.semantic.xml", xml);
+                load(dsl.equals("semantic-extension") ? 
"questions.semantic.xml" : "questions.xml", xml);
             }
         }
         assertThat(states).isEmpty();
@@ -180,15 +181,15 @@ class SemanticDeclarationDslTest {
     @ParameterizedTest
     @MethodSource("invalidXml")
     void invalidDeclarationsLeaveThePreviousDefinitionsIntact(String 
declaration, String message) throws Exception {
-        load("questions.semantic.xml", document("routes", xmlQuestions()));
+        load("questions.xml", document("routes", xmlQuestions()));
         SemanticQuestion previous = 
SemanticQuestions.get(context).get("department");
         assertThatThrownBy(() -> 
PluginHelper.getRoutesLoader(context).updateRoutes(
-                ResourceHelper.fromString("questions.semantic.xml",
+                ResourceHelper.fromString("questions.xml",
                         document("routes", "<semantic>" + declaration + 
"</semantic>"))))
                 .hasMessageContaining(message);
         
assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous);
         assertThat(states).isEmpty();
-        
PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.semantic.xml",
+        
PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.xml",
                 document("routes", xmlQuestions().replace("header.myState", 
"header.corrected"))));
         
assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.corrected}");
     }
@@ -289,7 +290,7 @@ class SemanticDeclarationDslTest {
                 questions(semanticQuestions(this));
             }
         });
-        assertThatThrownBy(() -> load("duplicate.semantic.xml", 
document("routes", xmlQuestions())))
+        assertThatThrownBy(() -> load("duplicate.xml", document("routes", 
xmlQuestions())))
                 .hasMessageContaining("Duplicate semantic question");
         assertThat(states).isEmpty();
     }
@@ -306,13 +307,13 @@ class SemanticDeclarationDslTest {
                 }
             });
         } else {
-            load("questions.semantic.xml", document("routes", xmlQuestions()
+            load("questions.xml", document("routes", xmlQuestions()
                     .replace("threshold=\"0.8\"", 
"threshold=\"{{threshold:0.8}}\"")
                     .replace("uncertainty=\"0.1\"", 
"uncertainty=\"{{uncertainty:0.1}}\"")));
         }
         
assertThat(SemanticQuestions.get(context).get("urgent").getThreshold()).isEqualTo(0.8);
         
assertThat(SemanticQuestions.get(context).get("urgent").getUncertainty()).isEqualTo(0.1);
-        assertThatThrownBy(() -> load("invalid.semantic.xml", 
document("routes", """
+        assertThatThrownBy(() -> load("invalid.xml", document("routes", """
                 <semantic><question name="invalid" type="boolean" 
threshold="{{threshold:abc}}">
                   <instructions>Urgent?</instructions>
                 </question></semantic>
@@ -327,13 +328,13 @@ class SemanticDeclarationDslTest {
                         + 
"<instructions>Valid?</instructions></question></semantic>";
         var loader = PluginHelper.getRoutesLoader(context);
         assertThatThrownBy(() -> loader.loadRoutes(List.of(
-                ResourceHelper.fromString("first.semantic.xml", 
document("routes", xmlQuestions())),
-                ResourceHelper.fromString("second.semantic.xml", 
document("routes", second.replace("boolean", "unknown"))))))
+                ResourceHelper.fromString("first.xml", document("routes", 
xmlQuestions())),
+                ResourceHelper.fromString("second.xml", document("routes", 
second.replace("boolean", "unknown"))))))
                 .hasMessageContaining("Invalid semantic question");
         loader.loadRoutes(List.of(
-                ResourceHelper.fromString("first.semantic.xml",
+                ResourceHelper.fromString("first.xml",
                         document("routes", 
xmlQuestions().replace("header.myState", "header.new"))),
-                ResourceHelper.fromString("second.semantic.xml", 
document("routes", second))));
+                ResourceHelper.fromString("second.xml", document("routes", 
second))));
         
assertThat(SemanticQuestions.get(context).get("department").getState()).isEqualTo("${header.new}");
         assertThat(SemanticQuestions.get(context).get("second")).isNotNull();
         assertThat(states).isEmpty();
@@ -347,7 +348,7 @@ class SemanticDeclarationDslTest {
                           <setBody><language 
language="semantic">ref:department</language></setBody>
                         </route>
                         """)),
-                ResourceHelper.fromString("definitions.semantic.xml", 
document("routes", xmlQuestions()))));
+                ResourceHelper.fromString("definitions.xml", 
document("routes", xmlQuestions()))));
         assertThat(states).isEmpty();
         try (var template = context.createProducerTemplate()) {
             assertThat(template.requestBodyAndHeader("direct:use", "original", 
"myState", "invoice")).isEqualTo("billing");
@@ -366,30 +367,30 @@ class SemanticDeclarationDslTest {
 
     @Test
     void reloadingReplacesAndRemovesDeclarationsUsedByExistingExpressions() 
throws Exception {
-        load("questions.semantic.xml", document("routes", xmlQuestions()));
+        load("questions.xml", document("routes", xmlQuestions()));
         var expression = 
context.resolveLanguage("semantic").createExpression("ref:department");
         var exchange = new DefaultExchange(context);
         exchange.getMessage().setHeader("myState", "old");
         assertThat(expression.evaluate(exchange, 
String.class)).isEqualTo("billing");
-        
PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.semantic.xml",
+        
PluginHelper.getRoutesLoader(context).updateRoutes(ResourceHelper.fromString("questions.xml",
                 document("routes", xmlQuestions().replace("header.myState", 
"header.updated"))));
         exchange.getMessage().setHeader("updated", "new");
         assertThat(expression.evaluate(exchange, 
String.class)).isEqualTo("billing");
         assertThat(states).containsExactly("old", "new");
         PluginHelper.getRoutesLoader(context)
-                
.updateRoutes(ResourceHelper.fromString("questions.semantic.xml", 
document("routes", "")));
+                .updateRoutes(ResourceHelper.fromString("questions.xml", 
document("routes", "")));
         assertThatThrownBy(() -> expression.evaluate(exchange, 
Object.class)).hasMessageContaining("Unknown semantic question");
     }
 
     @Test
     void watcherRemovesDeletedAndRenamedDeclarationResources() throws 
Exception {
-        Path original = directory.resolve("questions.semantic.xml");
+        Path original = directory.resolve("questions.xml");
         Files.writeString(original, document("routes", xmlQuestions()));
         Resource source = ResourceHelper.resolveResource(context, 
original.toUri().toString());
         PluginHelper.getRoutesLoader(context).loadRoutes(source);
         TestWatcher watcher = new TestWatcher();
         watcher.setCamelContext(context);
-        Path renamed = Files.move(original, 
directory.resolve("renamed.semantic.xml"));
+        Path renamed = Files.move(original, directory.resolve("renamed.xml"));
         watcher.reload(source);
         assertThatThrownBy(() -> 
SemanticQuestions.get(context).get("department")).hasMessageContaining("Unknown");
         Resource replacement = ResourceHelper.resolveResource(context, 
renamed.toUri().toString());
@@ -404,9 +405,9 @@ class SemanticDeclarationDslTest {
     @ParameterizedTest
     @ValueSource(strings = { "", "http://camel.apache.org/schema/semantic";, 
"http://camel.apache.org/schema/xml-io"; })
     void standaloneXmlSupportsOptionalNamespace(String namespace) throws 
Exception {
-        load("questions.semantic.xml", xmlQuestions().replace("<semantic>", 
"<semantic xmlns=\"" + namespace + "\">"));
+        load("questions.xml", xmlQuestions().replace("<semantic>", "<semantic 
xmlns=\"" + namespace + "\">"));
         
assertThat(SemanticQuestions.get(context).get("department").getCriteria()).containsKey("billing");
-        load("questions.semantic.xml", "<semantic/>");
+        load("questions.xml", "<semantic/>");
         assertThat(SemanticQuestions.get(context).isEmpty()).isTrue();
     }
 
@@ -442,16 +443,16 @@ class SemanticDeclarationDslTest {
             "<routes><semantic><question name='q' 
type='boolean'><instructions>Hi</instructions></question></semantic><route><wrong/></route></routes>"
     })
     void malformedXmlDoesNotReplacePreviousDefinitions(String xml) throws 
Exception {
-        load("questions.semantic.xml", xmlQuestions());
+        load("questions.xml", xmlQuestions());
         var previous = SemanticQuestions.get(context).get("department");
-        assertThatThrownBy(() -> load("questions.semantic.xml", 
xml)).isInstanceOf(Exception.class);
+        assertThatThrownBy(() -> load("questions.xml", 
xml)).isInstanceOf(Exception.class);
         
assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous);
     }
 
     @ParameterizedTest
     @ValueSource(strings = { "java", "xml" })
     void loadingPlainBuilderDiscardsDeletedDeclarationResources(String dsl) 
throws Exception {
-        Path file = directory.resolve(dsl.equals("java") ? "Questions.java" : 
"questions.semantic.xml");
+        Path file = directory.resolve(dsl.equals("java") ? "Questions.java" : 
"questions.xml");
         Files.writeString(file, xmlQuestions());
         Resource resource = ResourceHelper.resolveResource(context, 
file.toUri().toString());
         if (dsl.equals("java")) {
@@ -476,6 +477,22 @@ class SemanticDeclarationDslTest {
         assertThat(SemanticQuestions.get(context).isEmpty()).isTrue();
     }
 
+    @Test
+    void semanticNamespaceCanBeUsedInsideStandardXmlRoutes() throws Exception {
+        load("questions.xml", document("routes", 
xmlQuestions().replace("<semantic>",
+                "<semantic 
xmlns=\"http://camel.apache.org/schema/semantic\";>")));
+        
assertThat(SemanticQuestions.get(context).get("department").getCriteria()).containsKey("billing");
+    }
+
+    @Test
+    void failedOrdinaryReplacementKeepsPreviousDeclarations() throws Exception 
{
+        load("questions.xml", xmlQuestions());
+        var previous = SemanticQuestions.get(context).get("department");
+        assertThatThrownBy(() -> load("questions.xml", 
"<routes><route><wrong/></route></routes>"))
+                .isInstanceOf(Exception.class);
+        
assertThat(SemanticQuestions.get(context).get("department")).isSameAs(previous);
+    }
+
     private void load(String location, String xml) throws Exception {
         
PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString(location,
 xml));
     }
diff --git 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
new file mode 100644
index 000000000000..ee784482f1fb
--- /dev/null
+++ 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.camel.semantic;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.dsl.xml.io.XmlRoutesBuilderLoader;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.impl.engine.DefaultClassResolver;
+import org.apache.camel.impl.engine.DefaultRoutesLoader;
+import org.apache.camel.main.Main;
+import org.apache.camel.spi.RoutesBuilderLoader;
+import org.apache.camel.spi.RoutesLoader;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.support.SimpleRegistry;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SemanticXmlAutoDiscoveryTest {
+    @TempDir
+    Path directory;
+
+    @ParameterizedTest
+    @CsvSource({ "false,routes.xml", "true,routes.xml", 
"false,routes.camel.xml", "true,routes.camel.xml" })
+    void mainLoadsOrdinaryXmlWithoutLoaderRegistration(boolean standalone, 
String filename) throws Exception {
+        String declarations = """
+                <semantic xmlns="http://camel.apache.org/schema/semantic";>
+                  <question name="department" type="choice">
+                    <instructions>Which department?</instructions>
+                    <criterion key="billing" value="Invoices"/>
+                  </question>
+                </semantic>
+                """;
+        String route = """
+                <route id="classify"><from uri="direct:tickets"/>
+                  <setBody><language 
language="semantic">ref:department</language></setBody>
+                </route>
+                """;
+        Path routes = directory.resolve(filename);
+        String files;
+        if (standalone) {
+            Path questions = directory.resolve("questions.xml");
+            Files.writeString(questions, declarations);
+            Files.writeString(routes, "<routes>" + route + "</routes>");
+            // The consumer is deliberately listed first.
+            files = routes.toUri() + "," + questions.toUri();
+        } else {
+            Files.writeString(routes, "<routes>" + declarations + route + 
"</routes>");
+            files = routes.toUri().toString();
+        }
+        Main main = new Main();
+        main.bind("classifier", new SemanticLanguageTest.LabelAdapter());
+        main.addProperty("camel.language.semantic.adapter", "classifier");
+        main.configure().setRoutesIncludePattern(files);
+        try {
+            main.start();
+            try (var template = 
main.getCamelContext().createProducerTemplate()) {
+                assertThat(template.requestBody("direct:tickets", "invoice", 
String.class)).isEqualTo("billing");
+            }
+        } finally {
+            main.stop();
+        }
+    }
+
+    @Test
+    void ordinaryXmlRetainsBeansRouteConfigurationsAndDelegateLifecycle() 
throws Exception {
+        AtomicReference<RoutesBuilderLoader> delegate = new 
AtomicReference<>();
+        try (var context = new DefaultCamelContext()) {
+            
context.getCamelContextExtension().addContextPlugin(RoutesLoader.class, new 
DefaultRoutesLoader(context) {
+                @Override
+                public void initRoutesBuilderLoader(RoutesBuilderLoader 
loader) {
+                    delegate.set(loader);
+                }
+            });
+            context.start();
+            
PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("ordinary.xml",
 """
+                    <camel xmlns="http://camel.apache.org/schema/xml-io";>
+                      <bean name="counter" 
type="java.util.concurrent.atomic.AtomicInteger"/>
+                      <routeConfiguration id="config">
+                        
<onException><exception>java.lang.IllegalArgumentException</exception>
+                          <handled><constant>true</constant></handled>
+                          <setBody><constant>handled</constant></setBody>
+                        </onException>
+                      </routeConfiguration>
+                      <route id="ordinary" routeConfigurationId="config">
+                        <from uri="direct:ordinary"/>
+                        <bean ref="counter" method="incrementAndGet"/>
+                      </route>
+                    </camel>
+                    """));
+            assertThat(context.getRouteConfigurationDefinitions()).hasSize(1);
+            
assertThat(context.getRegistry().lookupByName("counter")).isInstanceOf(AtomicInteger.class);
+            
assertThat(context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class)).isNull();
+            try (var template = context.createProducerTemplate()) {
+                assertThat(template.requestBody("direct:ordinary", "test", 
Integer.class)).isEqualTo(1);
+            }
+            
assertThat(delegate.get()).isInstanceOf(XmlRoutesBuilderLoader.class);
+            assertThat(((ServiceSupport) delegate.get()).isStarted()).isTrue();
+        }
+        assertThat(((ServiceSupport) delegate.get()).isStopped()).isTrue();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "routes-builder-loader-xml", "customXmlLoader" })
+    void applicationRegisteredXmlLoaderTakesPrecedence(String name) throws 
Exception {
+        XmlRoutesBuilderLoader custom = new XmlRoutesBuilderLoader();
+        try (var context = new DefaultCamelContext()) {
+            context.getRegistry().bind(name, custom);
+            context.build();
+            
assertThat(PluginHelper.getRoutesLoader(context).getRoutesLoader("xml")).isSameAs(custom);
+            
assertThat(context.getRegistry().findByType(SemanticXmlLoader.class))
+                    .allMatch(loader -> !loader.isSupportedExtension("xml"));
+        }
+    }
+
+    @Test
+    void contextWithoutOptionalXmlSupportDoesNotInstallWrapper() throws 
Exception {
+        try (var context = new DefaultCamelContext(false)) {
+            context.setClassResolver(new DefaultClassResolver(context) {
+                @Override
+                public Class<?> resolveClass(String name) {
+                    return "org.apache.camel.xml.in.ModelParser".equals(name) 
? null : super.resolveClass(name);
+                }
+            });
+            context.build();
+            
assertThat(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY)).isNull();
+            assertThat(context.resolveLanguage("semantic")).isNotNull();
+        }
+    }
+
+    @Test
+    void contextWithApplicationRegistryDiscoversXmlDeclarations() throws 
Exception {
+        try (var context = new DefaultCamelContext(new SimpleRegistry())) {
+            context.start();
+            
PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("questions.xml",
 """
+                    <semantic><question name="urgent" 
type="boolean"><instructions>Urgent?</instructions></question></semantic>
+                    """));
+            
assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?");
+        }
+    }
+
+    @Test
+    void contextRestartReinstallsAutomaticLoader() throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            for (int i = 0; i < 2; i++) {
+                context.start();
+                
PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("questions.xml",
+                        """
+                                <semantic><question name="urgent" 
type="boolean"><instructions>Urgent?</instructions></question></semantic>
+                                """));
+                
assertThat(SemanticQuestions.get(context).get("urgent").getInstructions()).isEqualTo("Urgent?");
+                context.stop();
+                
assertThat(context.getRegistry().lookupByName(SemanticXmlLoader.REGISTRY_KEY)).isNull();
+            }
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index b62a9a431e19..bdef0ae31380 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -3978,6 +3978,13 @@ JAXP external-access model and remain resolvable. 
Stylesheet `xsl:include` / `xs
 are resolved at compile time (they are part of the route definition, authored 
by the route author)
 and are unaffected by this change.
 
+=== camel-semantic - automatic XML route loading
+
+When `camel-semantic` and the XML DSL are on the classpath, Camel now 
automatically detects
+semantic declarations in ordinary XML route resources. XML documents without 
declarations
+continue through the standard XML loader. Explicitly registered XML loaders 
retain precedence.
+This changes route resource discovery only; Spring XML application-context 
parsing is unaffected.
+
 == ThrottlingExceptionRoutePolicy
 
 `ThrottlingExceptionRoutePolicy.setKeepOpen(true)` now opens the circuit 
immediately and synchronously (the consumer

Reply via email to