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

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


The following commit(s) were added to refs/heads/main by this push:
     new 948aaf5702fa CAMEL-24955: camel-yaml-dsl-validator - report a direct: 
or seda: endpoint that no route consumes (#26920)
948aaf5702fa is described below

commit 948aaf5702fae6611703752e6ebb9920ff9aaf9e
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Sep 29 05:10:42 2026 -0400

    CAMEL-24955: camel-yaml-dsl-validator - report a direct: or seda: endpoint 
that no route consumes (#26920)
    
    * CAMEL-24955: camel-yaml-dsl-validator - report a direct: or seda: 
endpoint that no route consumes
    * CAMEL-24955: camel-yaml-dsl-validator - address review of the 
direct:/seda: consumer check
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-devtools.adoc   |   6 +
 .../dsl/jbang/core/commands/ai/AuthoringTools.java |  10 +-
 .../core/commands/ai/EndpointConsumerChecks.java   | 217 ++++++++++++++++++++
 .../jbang/core/commands/ai/SourceValidator.java    |  15 ++
 .../jbang/core/commands/ai/AuthoringToolsTest.java |  22 +++
 .../ai/SourceValidatorEndpointConsumersTest.java   | 220 +++++++++++++++++++++
 .../dsl/yaml/validator/EndpointConsumers.java      | 177 +++++++++++++++++
 .../dsl/yaml/validator/EndpointConsumersTest.java  | 183 +++++++++++++++++
 8 files changed, 846 insertions(+), 4 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
index 574f18cfc282..44a580ed4062 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-devtools.adoc
@@ -426,6 +426,12 @@ Validation success (files:2)
 against the Camel catalog (`--catalog=false` for the schema only); 
`--canonical` validates against the canonical
 schema and reports the deprecated compact notation. `camel validate source` 
validates any file `camel run` would
 load: YAML routes, `.properties` files (the `camel.*` keys), Java (compiles 
it), XSLT and XML.
+As the routes of an application are spread over the files of its directory, 
`camel validate source` also reports a
+`direct:` or `seda:` endpoint a YAML route sends to that no route consumes, in 
that file or in the YAML, Java and
+XML route files of its directory and its subdirectories: with `direct:` the 
route would fail to start, with `seda:`
+the messages would be queued and never read. The check stays quiet when it 
cannot be certain: an endpoint only known
+at runtime (a property placeholder, an expression), route templates and 
Kamelets, and a directory that is only a part
+of a Maven or Gradle project, such as `src/main/resources/camel`.
 
 === Validating against another Camel version or runtime
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 478fbcd3062c..0c3d23d2d65b 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -65,10 +65,10 @@ public final class AuthoringTools {
     static final String FILE_PATH_DESC = "File path relative to the directory, 
e.g. src/main/resources/camel/foo.camel.yaml";
     private static final int MAX_FILES = 99;
     /** How many files a listing looks at before it stops; the route and 
configuration files are found among them. */
-    private static final int SCAN_LIMIT = 2000;
-    private static final int MAX_DEPTH = 8;
+    static final int SCAN_LIMIT = 2000;
+    static final int MAX_DEPTH = 8;
     /** Build output, tooling and VCS directories: never sources. */
-    private static final Set<String> SKIPPED_DIRS = Set.of(
+    static final Set<String> SKIPPED_DIRS = Set.of(
             "target", "build", "out", "node_modules", ".git", ".mvn", ".idea", 
".vscode", ".gradle", ".settings",
             ".camel-jbang");
     private static final Pattern YAML_ROUTE = Pattern.compile(
@@ -703,7 +703,9 @@ public final class AuthoringTools {
             throw new ToolExecutionException(file + " is not a regular file");
         }
         if (validate && SourceValidator.isValidatableFile(file)) {
-            List<String> errors = SourceValidator.validate(file, content, 
ctx.catalog(), ctx.propertyLineValidator(), dir);
+            // a missing consumer of a direct: endpoint does not refuse the 
write: it is often a file not written yet
+            List<String> errors = SourceValidator.validate(file, content, 
ctx.catalog(), ctx.propertyLineValidator(), dir,
+                    null, false);
             if (!errors.isEmpty()) {
                 JsonObject result = new JsonObject();
                 result.put("status", "invalid");
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
new file mode 100644
index 000000000000..ed9c848ff39f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
@@ -0,0 +1,217 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.dsl.yaml.validator.EndpointConsumers;
+
+/**
+ * A {@code direct:} or {@code seda:} endpoint a YAML route sends to, and no 
route of the application consumes
+ * (CAMEL-24955). The routes of an application are spread over the files of 
its directory, so the endpoints the other
+ * route files consume - YAML, Java and XML - are read from them and handed to 
the check.
+ */
+public final class EndpointConsumerChecks {
+
+    /**
+     * A Java DSL route input: from( not called on something else, so not 
Instant.from( or List.from(; its endpoint is
+     * read only when a string literal is the whole argument, not the start of 
from("direct:" + NAME).
+     */
+    private static final Pattern JAVA_FROM = 
Pattern.compile("(?<![.\\w])from\\s*\\(\\s*(\"([^\"]*)\"\\s*(?=[),]))?");
+    /** Route inputs the scan cannot read: a route template's from( and fromF( 
with a format. */
+    private static final Pattern JAVA_UNREADABLE = 
Pattern.compile("\\b(routeTemplate|fromF)\\s*\\(");
+    /** The build files of a project whose routes are spread over 
src/main/java and src/main/resources. */
+    private static final List<String> BUILD_FILES = List.of("pom.xml", 
"build.gradle", "build.gradle.kts");
+    private static final Pattern XML_FROM = 
Pattern.compile("<from\\s[^>]*?\\buri\\s*=\\s*[\"']([^\"']*)[\"']");
+
+    private EndpointConsumerChecks() {
+    }
+
+    /**
+     * @param  content     the YAML route file
+     * @param  directory   the directory of the application's route files; 
null says nothing
+     * @param  excludeFile the file being validated, whose routes come from 
the content
+     * @return             the messages, one per endpoint no route consumes
+     */
+    public static List<String> validateYamlConsumers(String content, Path 
directory, String excludeFile) {
+        return EndpointConsumers.check(content, consumed(directory, 
excludeFile));
+    }
+
+    /**
+     * The {@code direct:} and {@code seda:} endpoints the route files under 
the directory consume, leaving out the file
+     * being validated; null when they cannot be known: no directory, a 
directory that is only a part of a Maven or
+     * Gradle project, more files than the scan looks at, or a route input the 
scan cannot read (a Java {@code from(}
+     * with no literal, a placeholder, a route template), which could be any 
endpoint.
+     */
+    static Set<String> consumed(Path directory, String excludeFile) {
+        if (directory == null || !Files.isDirectory(directory)) {
+            return null;
+        }
+        Path root = directory.toAbsolutePath().normalize();
+        if (isPartOfAProject(root)) {
+            return null;
+        }
+        List<Path> files = routeFiles(root, excludeFile != null ? 
root.resolve(excludeFile).normalize() : null);
+        if (files == null) {
+            return null;
+        }
+        Set<String> answer = new HashSet<>();
+        try {
+            for (Path p : files) {
+                String lower = 
p.getFileName().toString().toLowerCase(Locale.ROOT);
+                Set<String> found;
+                if (lower.endsWith(".yaml") || lower.endsWith(".yml")) {
+                    found = EndpointConsumers.consumed(Files.readString(p));
+                } else if (lower.endsWith(".java")) {
+                    found = javaConsumed(Files.readString(p));
+                } else {
+                    found = xmlConsumed(Files.readString(p));
+                }
+                if (found == null) {
+                    return null;
+                }
+                answer.addAll(found);
+            }
+        } catch (IOException e) {
+            // an unreadable file: what it consumes is not known
+            return null;
+        }
+        return answer;
+    }
+
+    /**
+     * Whether the directory is inside a Maven or Gradle project without being 
its root, as the directory of
+     * src/main/resources/camel is: the RouteBuilders under src/main/java are 
not under it.
+     */
+    private static boolean isPartOfAProject(Path root) {
+        if (BUILD_FILES.stream().anyMatch(f -> 
Files.isRegularFile(root.resolve(f)))) {
+            return false;
+        }
+        for (Path p = root.getParent(); p != null; p = p.getParent()) {
+            Path dir = p;
+            if (BUILD_FILES.stream().anyMatch(f -> 
Files.isRegularFile(dir.resolve(f)))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * The YAML, Java and XML files under the directory, the way the file 
tools look at a project: build and tooling
+     * directories skipped. Null when the scan does not see them all: a 
directory deeper than it goes, more files than
+     * it looks at, or one it cannot read.
+     */
+    private static List<Path> routeFiles(Path root, Path exclude) {
+        List<Path> files = new ArrayList<>();
+        boolean[] partial = { false };
+        try {
+            Files.walkFileTree(root, EnumSet.noneOf(FileVisitOption.class), 
AuthoringTools.MAX_DEPTH,
+                    new SimpleFileVisitor<>() {
+                        @Override
+                        public FileVisitResult preVisitDirectory(Path d, 
BasicFileAttributes attrs) {
+                            return d.equals(root) || !isSkipped(d)
+                                    ? FileVisitResult.CONTINUE : 
FileVisitResult.SKIP_SUBTREE;
+                        }
+
+                        @Override
+                        public FileVisitResult visitFile(Path f, 
BasicFileAttributes attrs) {
+                            if (attrs.isDirectory()) {
+                                // a directory at the depth limit: its files 
are not seen
+                                partial[0] = !isSkipped(f);
+                            } else if (attrs.isRegularFile() && 
!f.equals(exclude) && isRouteFile(f)) {
+                                files.add(f);
+                                partial[0] = files.size() >= 
AuthoringTools.SCAN_LIMIT;
+                            }
+                            return partial[0] ? FileVisitResult.TERMINATE : 
FileVisitResult.CONTINUE;
+                        }
+
+                        @Override
+                        public FileVisitResult visitFileFailed(Path f, 
IOException e) {
+                            partial[0] = true;
+                            return FileVisitResult.TERMINATE;
+                        }
+                    });
+        } catch (IOException e) {
+            return null;
+        }
+        return partial[0] ? null : files;
+    }
+
+    private static boolean isSkipped(Path dir) {
+        String name = dir.getFileName().toString();
+        return AuthoringTools.SKIPPED_DIRS.contains(name) || 
name.startsWith(".");
+    }
+
+    private static boolean isRouteFile(Path f) {
+        String fn = f.getFileName().toString();
+        String lower = fn.toLowerCase(Locale.ROOT);
+        return !fn.startsWith(".")
+                && (lower.endsWith(".yaml") || lower.endsWith(".yml") || 
lower.endsWith(".java") || lower.endsWith(".xml"));
+    }
+
+    static Set<String> javaConsumed(String src) {
+        if (JAVA_UNREADABLE.matcher(src).find()) {
+            return null;
+        }
+        Set<String> answer = new HashSet<>();
+        Matcher m = JAVA_FROM.matcher(src);
+        while (m.find()) {
+            // from(someConstant) or from(direct("x")): the endpoint is not in 
the source as a literal
+            if (m.group(2) == null || !add(m.group(2), answer)) {
+                return null;
+            }
+        }
+        return answer;
+    }
+
+    static Set<String> xmlConsumed(String src) {
+        Set<String> answer = new HashSet<>();
+        Matcher m = XML_FROM.matcher(src);
+        while (m.find()) {
+            if (!add(m.group(1), answer)) {
+                return null;
+            }
+        }
+        return answer;
+    }
+
+    /** Adds the endpoint when it is a direct: or seda: one; false when it is 
only known at runtime. */
+    private static boolean add(String uri, Set<String> answer) {
+        if (EndpointConsumers.isDynamic(uri)) {
+            return false;
+        }
+        String endpoint = EndpointConsumers.endpoint(uri);
+        if (endpoint != null) {
+            answer.add(endpoint);
+        }
+        return true;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index b840f3f61a7c..459be17c7fe7 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -101,6 +101,17 @@ public final class SourceValidator {
     public static List<String> validate(
             String fileName, String content, CamelCatalog catalog, 
Function<String, String> extraPropertyLine,
             Path directory, YamlValidator schemaValidator) {
+        return validate(fileName, content, catalog, extraPropertyLine, 
directory, schemaValidator, true);
+    }
+
+    /**
+     * As {@link #validate(String, String, CamelCatalog, Function, Path, 
YamlValidator)}, telling whether to check that
+     * the direct: and seda: endpoints the YAML sends to are consumed. A write 
leaves it out: the consuming route is
+     * often a file not written yet, and two files that call each other could 
never be written (CAMEL-24955).
+     */
+    public static List<String> validate(
+            String fileName, String content, CamelCatalog catalog, 
Function<String, String> extraPropertyLine,
+            Path directory, YamlValidator schemaValidator, boolean 
checkConsumers) {
         Objects.requireNonNull(catalog, "catalog");
         String name = fileName == null ? "" : 
fileName.toLowerCase(Locale.ROOT);
         if (name.endsWith(".yaml") || name.endsWith(".yml")) {
@@ -114,6 +125,10 @@ public final class SourceValidator {
                 msgs.addAll(validateYamlBeanRefs(content, declarations, 
catalog));
                 msgs.addAll(validateResourceRefs(content, directory));
                 
msgs.addAll(GroovyImportChecks.validateYamlGroovyImports(content, null, 
declarations.javaClasses()));
+                if (checkConsumers) {
+                    // a direct: or seda: endpoint no route of the application 
consumes (CAMEL-24955)
+                    
msgs.addAll(EndpointConsumerChecks.validateYamlConsumers(content, directory, 
fileName));
+                }
             }
             return msgs;
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index d4ed18b57354..176b779e1e11 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -170,6 +170,28 @@ class AuthoringToolsTest {
                 .getString("status"));
     }
 
+    @Test
+    void aMissingConsumerIsReportedButDoesNotRefuseTheWrite(@TempDir Path dir) 
throws IOException {
+        // the route that consumes direct:lookup is often the next file the 
agent writes (CAMEL-24955)
+        String caller = """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to: direct:lookup
+                """;
+        JsonObject validated = call("camel_validate_source", new ToolContext(),
+                Map.of("directory", dir.toString(), "file", "main.camel.yaml", 
"content", caller));
+        assertFalse(validated.getBoolean("valid"), validated.toJson());
+        assertTrue(validated.getCollection("errors").stream().anyMatch(m -> 
m.toString().contains("direct:lookup")),
+                validated.toJson());
+
+        JsonObject written = call("camel_write_file", new ToolContext(),
+                Map.of("directory", dir.toString(), "file", "main.camel.yaml", 
"content", caller));
+        assertEquals("created", written.getString("status"), written.toJson());
+        assertEquals(caller, Files.readString(dir.resolve("main.camel.yaml"), 
StandardCharsets.UTF_8));
+    }
+
     @Test
     void filePathsStayInsideTheDirectoryButMayNameASubdirectory(@TempDir Path 
dir) throws IOException {
         for (String bad : List.of("../etc/passwd", "/tmp/x.yaml", 
"sub/../../x.yaml")) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
new file mode 100644
index 000000000000..c752979d5f63
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24955: a direct: or seda: endpoint a route sends to, checked against 
the routes of every route file in the
+ * directory - YAML, Java and XML - since the routes of an application are 
spread over files.
+ */
+class SourceValidatorEndpointConsumersTest {
+
+    private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
+    private static final String ROUTE = """
+            - route:
+                id: tick
+                from:
+                  uri: timer:tick
+                  steps:
+                    - to:
+                        uri: direct:lookup
+            """;
+
+    @TempDir
+    Path dir;
+
+    @Test
+    void anEndpointNoRouteFileConsumesIsReported() throws Exception {
+        Files.writeString(dir.resolve("other.camel.yaml"), """
+                - route:
+                    from:
+                      uri: direct:other
+                      steps:
+                        - log: other
+                """);
+        List<String> msgs = SourceValidator.validate("r.camel.yaml", ROUTE, 
CATALOG, null, dir);
+        assertThat(msgs).singleElement().asString()
+                .startsWith("route tick: sends to direct:lookup, and no route 
consumes it")
+                .contains("the route fails to start");
+    }
+
+    @Test
+    void aSiblingYamlFileConsumesIt() throws Exception {
+        Files.writeString(dir.resolve("lookup.camel.yaml"), """
+                - route:
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - log: found
+                """);
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, dir)).isEmpty();
+    }
+
+    @Test
+    void aSiblingJavaRouteConsumesIt() throws Exception {
+        Files.writeString(dir.resolve("Lookup.java"), """
+                import org.apache.camel.builder.RouteBuilder;
+
+                public class Lookup extends RouteBuilder {
+                    @Override
+                    public void configure() {
+                        from("direct://lookup?timeout=1000").log("found");
+                    }
+                }
+                """);
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, dir)).isEmpty();
+    }
+
+    @Test
+    void aSiblingXmlRouteConsumesIt() throws Exception {
+        Files.writeString(dir.resolve("lookup.camel.xml"), """
+                <routes>
+                    <route>
+                        <from uri="direct:lookup"/>
+                        <log message="found"/>
+                    </route>
+                </routes>
+                """);
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, dir)).isEmpty();
+    }
+
+    @Test
+    void aJavaRouteInputThatIsNotALiteralKeepsTheCheckQuiet() throws Exception 
{
+        // from(LOOKUP) may well be direct:lookup: the check cannot be 
certain, so it says nothing
+        Files.writeString(dir.resolve("Lookup.java"), """
+                import org.apache.camel.builder.RouteBuilder;
+
+                public class Lookup extends RouteBuilder {
+                    static final String LOOKUP = "direct:lookup";
+
+                    @Override
+                    public void configure() {
+                        from(LOOKUP).log("found");
+                    }
+                }
+                """);
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, dir)).isEmpty();
+    }
+
+    @Test
+    void javaRouteInputsTheScanCannotReadKeepTheCheckQuiet() throws Exception {
+        // each may well consume direct:lookup: a route template's from(, 
fromF( and a concatenated uri
+        for (String configure : List.of(
+                
"routeTemplate(\"t\").templateParameter(\"n\").from(\"direct:{{n}}\").log(\"found\");",
+                "fromF(\"direct:%s\", \"lookup\").log(\"found\");",
+                "from(\"direct:\" + NAME).log(\"found\");")) {
+            Files.writeString(dir.resolve("Lookup.java"), """
+                    import org.apache.camel.builder.RouteBuilder;
+
+                    public class Lookup extends RouteBuilder {
+                        static final String NAME = "lookup";
+
+                        @Override
+                        public void configure() {
+                            %s
+                        }
+                    }
+                    """.formatted(configure));
+            assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, 
CATALOG, null, dir)).as(configure).isEmpty();
+        }
+    }
+
+    @Test
+    void aRouteFileInASubdirectoryConsumesIt() throws Exception {
+        Files.createDirectories(dir.resolve("routes"));
+        Files.writeString(dir.resolve("routes/lookup.camel.yaml"), """
+                - route:
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - log: found
+                """);
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, dir)).isEmpty();
+    }
+
+    @Test
+    void theFileBeingValidatedIsLeftOutByItsPathUnderTheDirectory() throws 
Exception {
+        // camel_validate_source passes the project directory and the file's 
path relative to it
+        Files.createDirectories(dir.resolve("routes"));
+        Files.writeString(dir.resolve("routes/r.camel.yaml"), ROUTE + """
+                - route:
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - log: found
+                """);
+        assertThat(SourceValidator.validate("routes/r.camel.yaml", ROUTE, 
CATALOG, null, dir))
+                .singleElement().asString().contains("sends to direct:lookup");
+    }
+
+    @Test
+    void aMavenProjectIsScannedFromItsRootOnly() throws Exception {
+        Path camel = 
Files.createDirectories(dir.resolve("src/main/resources/camel"));
+        Path java = 
Files.createDirectories(dir.resolve("src/main/java/com/acme"));
+        Files.writeString(dir.resolve("pom.xml"), "<project/>");
+        Files.writeString(java.resolve("Lookup.java"), """
+                package com.acme;
+
+                import org.apache.camel.builder.RouteBuilder;
+
+                public class Lookup extends RouteBuilder {
+                    @Override
+                    public void configure() {
+                        from("direct:lookup").log("found");
+                    }
+                }
+                """);
+        // from the project root, the RouteBuilder under src/main/java is found
+        
assertThat(SourceValidator.validate("src/main/resources/camel/r.camel.yaml", 
ROUTE, CATALOG, null, dir))
+                .isEmpty();
+        // from the directory of the YAML file, the rest of the project is not 
seen: the check says nothing
+        Files.delete(java.resolve("Lookup.java"));
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, camel)).isEmpty();
+        
assertThat(SourceValidator.validate("src/main/resources/camel/r.camel.yaml", 
ROUTE, CATALOG, null, dir))
+                .singleElement().asString().contains("sends to direct:lookup");
+    }
+
+    @Test
+    void theFileBeingValidatedIsReadFromItsContentNotFromDisk() throws 
Exception {
+        // the editor's buffer is newer than the file on disk, which still 
consumes direct:lookup
+        Files.writeString(dir.resolve("r.camel.yaml"), ROUTE + """
+                - route:
+                    from:
+                      uri: direct:lookup
+                      steps:
+                        - log: found
+                """);
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null, dir))
+                .singleElement().asString().contains("sends to direct:lookup");
+    }
+
+    @Test
+    void withoutADirectoryTheCheckSaysNothing() {
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null)).isEmpty();
+    }
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointConsumers.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointConsumers.java
new file mode 100644
index 000000000000..c43f3fe140ad
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/EndpointConsumers.java
@@ -0,0 +1,177 @@
+/*
+ * 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.dsl.yaml.validator;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.Route;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.normalize;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.routes;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.scheme;
+import static org.apache.camel.dsl.yaml.validator.RouteGraph.sendsTo;
+
+/**
+ * A {@code direct:} or {@code seda:} endpoint a route sends to, and no route 
of the application consumes (CAMEL-24955).
+ * With {@code direct:} the route fails to start - <i>No consumers available 
on endpoint</i>; with {@code seda:} nothing
+ * fails, and the message is queued and never read.
+ * <p/>
+ * The routes of an application are spread over files, so a file on its own 
cannot answer: the caller scans the other
+ * route files of the directory and passes what they consume. Without them the 
check says nothing.
+ */
+public final class EndpointConsumers {
+
+    /** The components whose consumer is a route of the same application. */
+    private static final Set<String> CHECKED = Set.of("direct", "seda");
+    /** The route templates and the routes made from them, whose endpoints are 
only known once instantiated. */
+    private static final Set<String> TEMPLATES = Set.of(
+            "routeTemplate", "route-template", "templatedRoute", 
"templated-route");
+
+    private static final ObjectMapper MAPPER = new ObjectMapper(new 
YAMLFactory());
+
+    private EndpointConsumers() {
+    }
+
+    /**
+     * The {@code direct:} and {@code seda:} endpoints the routes of a YAML 
file consume, without their options. Empty
+     * when the file is not YAML the routes can be read from; null when a 
route consumes an endpoint only known at
+     * runtime ({@code from: direct:{{name}}}), or the file has route 
templates or is a Kamelet, which could consume any
+     * of them.
+     */
+    public static Set<String> consumed(String yaml) {
+        JsonNode target = read(yaml);
+        if (target == null) {
+            return Set.of();
+        }
+        return hasTemplates(target) ? null : consumed(routes(target));
+    }
+
+    /**
+     * @param  yaml              the YAML DSL source
+     * @param  consumedElsewhere the endpoints the other route files of the 
application consume, as returned by
+     *                           {@link #consumed(String)}; null when they are 
not known, which keeps the check quiet
+     * @return                   a message for each endpoint a route sends to 
and no route consumes
+     */
+    public static List<String> check(String yaml, Set<String> 
consumedElsewhere) {
+        if (consumedElsewhere == null) {
+            return List.of();
+        }
+        JsonNode target = read(yaml);
+        if (target == null || hasTemplates(target)) {
+            return List.of();
+        }
+        List<Route> routes = routes(target);
+        Set<String> own = consumed(routes);
+        if (own == null) {
+            return List.of();
+        }
+        Set<String> consumed = new HashSet<>(consumedElsewhere);
+        consumed.addAll(own);
+        Set<String> messages = new LinkedHashSet<>();
+        for (Route r : routes) {
+            for (String uri : sendsTo(r.steps())) {
+                String endpoint = endpoint(uri);
+                if (endpoint == null || consumed.contains(endpoint)) {
+                    continue;
+                }
+                messages.add((r.id() != null ? "route " + r.id() + ": " : "")
+                             + "sends to " + endpoint + ", and no route 
consumes it - not in this file, nor in the"
+                             + " other route files of the directory: "
+                             + ("direct".equals(scheme(endpoint))
+                                     ? "the route fails to start with No 
consumers available on endpoint"
+                                     : "nothing fails, the messages are queued 
and never read")
+                             + "; add a route with from: " + endpoint + ", or 
correct the name");
+            }
+        }
+        return new ArrayList<>(messages);
+    }
+
+    private static Set<String> consumed(List<Route> routes) {
+        Set<String> answer = new HashSet<>();
+        for (Route r : routes) {
+            if (isDynamic(r.fromUri())) {
+                return null;
+            }
+            String endpoint = endpoint(r.fromUri());
+            if (endpoint != null) {
+                answer.add(endpoint);
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * The endpoint in the form the check compares, {@code direct:lookup} for 
{@code direct://lookup?timeout=1000}; null
+     * for an endpoint of another component, or one only known at runtime (a 
placeholder, an expression).
+     */
+    public static String endpoint(String uri) {
+        String s = normalize(uri);
+        String scheme = scheme(s);
+        if (scheme == null || !CHECKED.contains(scheme) || s.length() == 
scheme.length() || isDynamic(s)) {
+            return null;
+        }
+        String name = s.substring(scheme.length() + 1);
+        if (name.startsWith("//")) {
+            name = name.substring(2);
+        }
+        return name.isEmpty() ? null : scheme + ":" + name;
+    }
+
+    /** Whether the endpoint is only known at runtime: a property placeholder 
or an expression. */
+    public static boolean isDynamic(String uri) {
+        return uri != null && (uri.contains("{{") || uri.contains("${"));
+    }
+
+    /**
+     * Whether the file has a route template, a route made from one, or is a 
Kamelet: the routes they create are not
+     * read here, so what they consume is not known.
+     */
+    private static boolean hasTemplates(JsonNode target) {
+        if (target.isObject()) {
+            return "Kamelet".equals(target.path("kind").asText());
+        }
+        for (JsonNode entry : target) {
+            if (entry.isObject()) {
+                for (String key : TEMPLATES) {
+                    if (entry.has(key)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    private static JsonNode read(String yaml) {
+        if (yaml == null || yaml.isBlank()) {
+            return null;
+        }
+        try {
+            return MAPPER.readTree(yaml);
+        } catch (Exception e) {
+            // not YAML the routes can be read from: nothing to learn from it
+            return null;
+        }
+    }
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EndpointConsumersTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EndpointConsumersTest.java
new file mode 100644
index 000000000000..49c01d68e2aa
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/EndpointConsumersTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.dsl.yaml.validator;
+
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24955: a direct: or seda: endpoint a route sends to, and no route of 
the application consumes. With direct: the
+ * route fails to start; with seda: nothing fails and the message is never 
read.
+ */
+public class EndpointConsumersTest {
+
+    private static final String SENDS_TO = """
+            - route:
+                id: tick
+                from:
+                  uri: timer:tick
+                  steps:
+                    - to:
+                        uri: %s
+            """;
+
+    @Test
+    public void testNoRouteConsumesTheDirectEndpoint() {
+        List<String> messages = 
EndpointConsumers.check(SENDS_TO.formatted("direct:lookup"), Set.of());
+        assertThat(messages).containsExactly(
+                "route tick: sends to direct:lookup, and no route consumes it 
- not in this file, nor in the other route"
+                                             + " files of the directory: the 
route fails to start with No consumers available"
+                                             + " on endpoint; add a route with 
from: direct:lookup, or correct the name");
+    }
+
+    @Test
+    public void testAnotherFileConsumesIt() {
+        // the routes of an application are spread over files: what the 
siblings consume is passed in
+        
assertThat(EndpointConsumers.check(SENDS_TO.formatted("direct:lookup?timeout=1000"),
 Set.of("direct:lookup")))
+                .isEmpty();
+    }
+
+    @Test
+    public void testWithoutADirectoryTheCheckSaysNothing() {
+        // a file on its own cannot know what the rest of the application 
consumes
+        
assertThat(EndpointConsumers.check(SENDS_TO.formatted("direct:lookup"), 
null)).isEmpty();
+    }
+
+    @Test
+    public void testTheSameFileConsumesIt() {
+        // both spellings of the endpoint: direct://lookup, and uri: direct 
with parameters: {name: lookup}
+        String yaml = SENDS_TO.formatted("direct://lookup") + """
+                - route:
+                    from:
+                      uri: direct
+                      parameters:
+                        name: lookup
+                      steps:
+                        - log: found
+                """;
+        assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty();
+    }
+
+    @Test
+    public void testSedaSaysTheMessageIsNeverRead() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - choice:
+                            when:
+                              - simple: "${header.urgent}"
+                                steps:
+                                  - wireTap:
+                                      uri: seda:audit
+                """;
+        assertThat(EndpointConsumers.check(yaml, Set.of())).containsExactly(
+                "sends to seda:audit, and no route consumes it - not in this 
file, nor in the other route files of the"
+                                                                            + 
" directory: nothing fails, the messages are queued and never read;"
+                                                                            + 
" add a route with from: seda:audit, or correct the name");
+    }
+
+    @Test
+    public void testAnExternalEndpointIsNotItsBusiness() {
+        // kafka:, vm: (another context) and an endpoint only known at runtime 
are not the routes of this application
+        assertThat(EndpointConsumers.check(SENDS_TO.formatted("kafka:orders"), 
Set.of())).isEmpty();
+        assertThat(EndpointConsumers.check(SENDS_TO.formatted("vm:orders"), 
Set.of())).isEmpty();
+        
assertThat(EndpointConsumers.check(SENDS_TO.formatted("direct:{{target}}"), 
Set.of())).isEmpty();
+        
assertThat(EndpointConsumers.check(SENDS_TO.formatted("\"direct:${header.target}\""),
 Set.of())).isEmpty();
+    }
+
+    @Test
+    public void testConsumedListsTheDirectAndSedaRoutesOfAFile() {
+        String yaml = """
+                - from:
+                    uri: seda:orders?concurrentConsumers=4
+                    steps:
+                      - log: order
+                - route:
+                    from:
+                      uri: direct://lookup
+                      steps:
+                        - log: found
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - log: tick
+                """;
+        
assertThat(EndpointConsumers.consumed(yaml)).containsExactlyInAnyOrder("seda:orders",
 "direct:lookup");
+        assertThat(EndpointConsumers.consumed("not: [valid")).isEmpty();
+    }
+
+    @Test
+    public void testAConsumerOnlyKnownAtRuntimeKeepsTheCheckQuiet() {
+        // from: direct:{{name}} may well be direct:lookup: the check cannot 
be certain, so it says nothing
+        String yaml = SENDS_TO.formatted("direct:lookup") + """
+                - route:
+                    from:
+                      uri: "direct:{{name}}"
+                      steps:
+                        - log: found
+                """;
+        assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty();
+        assertThat(EndpointConsumers.consumed(yaml)).isNull();
+    }
+
+    @Test
+    public void testARouteTemplateKeepsTheCheckQuiet() {
+        // the templated route lookup is made from the template, and consumes 
direct:lookup once the template is
+        // instantiated: the routes a template creates are not read, so the 
check says nothing
+        String yaml = SENDS_TO.formatted("direct:lookup") + """
+                - routeTemplate:
+                    id: lookup-template
+                    parameters:
+                      - name: name
+                    from:
+                      uri: "direct:{{name}}"
+                      steps:
+                        - log: found
+                - templatedRoute:
+                    routeTemplateRef: lookup-template
+                    parameters:
+                      - name: name
+                        value: lookup
+                """;
+        assertThat(EndpointConsumers.check(yaml, Set.of())).isEmpty();
+        assertThat(EndpointConsumers.consumed(yaml)).isNull();
+    }
+
+    @Test
+    public void testAKameletConsumesWhatIsNotKnown() {
+        String kamelet = """
+                apiVersion: camel.apache.org/v1
+                kind: Kamelet
+                metadata:
+                  name: lookup-action
+                spec:
+                  template:
+                    from:
+                      uri: kamelet:source
+                      steps:
+                        - to: direct:lookup
+                """;
+        assertThat(EndpointConsumers.consumed(kamelet)).isNull();
+    }
+}

Reply via email to