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 3390c0cb4812 CAMEL-26416: camel-jbang - AI tools: a field read is 
checked against the fields of the JSON data files the routes read (#28724)
3390c0cb4812 is described below

commit 3390c0cb4812523c7b378ebd10f3705b2814de5d
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 31 07:47:35 2026 +0100

    CAMEL-26416: camel-jbang - AI tools: a field read is checked against the 
fields of the JSON data files the routes read (#28724)
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../dsl/jbang/core/commands/ai/ValidatorFacts.java | 135 +++++++++++++-
 .../jbang/core/commands/ai/ValidatorFactsTest.java |  47 +++++
 .../camel/dsl/yaml/validator/ProjectFacts.java     |  45 ++++-
 .../camel/dsl/yaml/validator/TextBodyFlow.java     | 197 +++++++++++++++++++--
 .../camel/dsl/yaml/validator/TextBodyFlowTest.java | 115 ++++++++++++
 5 files changed, 525 insertions(+), 14 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
index 5f8fdbb55957..bb4dea6a8c17 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFacts.java
@@ -26,11 +26,18 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 
 import org.apache.camel.dsl.yaml.validator.ProjectFacts;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
 
 /**
  * What the YAML validator cannot read from a route file, read from the files 
beside it (CAMEL-25762): the operations of
@@ -67,7 +74,8 @@ final class ValidatorFacts {
         return new ProjectFacts(
                 OpenApiVerbs.bodylessEndpoints(content, directory), 
OpenApiVerbs.operations(content, directory),
                 restBindingMode(directory), 
OpenApiVerbs.requestFields(content, directory),
-                ObservedBodies.read(fileName, directory), kamelets, 
jsonArrays(content, directory));
+                ObservedBodies.read(fileName, directory), kamelets, 
jsonArrays(content, directory),
+                jsonFields(content, directory));
     }
 
     /** resource:file:orders.json or resource:classpath:data/orders.json in a 
route file. */
@@ -100,6 +108,131 @@ final class ValidatorFacts {
         return answer;
     }
 
+    /** The JSON files of one directory beyond this many are not read. */
+    private static final int MAX_JSON_FILES = 50;
+
+    /**
+     * The fields of the JSON data the routes read (r24 of the local-model 
benchmark: ${body[lineCount]} on an order
+     * that has lines, and ${body[orderId]} inside the split of its lines): of 
the file a resource: loads, and of the
+     * files in the directory a file consumer reads. Data whose files do not 
all have one shape is left out.
+     */
+    static Map<String, ProjectFacts.JsonFields> jsonFields(String content, 
Path directory) {
+        Map<String, ProjectFacts.JsonFields> answer = new HashMap<>();
+        Matcher m = JSON_RESOURCE.matcher(content);
+        while (m.find()) {
+            String resource = m.group(1);
+            String file = resource.substring(resource.indexOf(':') + 1);
+            List<Path> candidates = resource.startsWith("file:")
+                    ? List.of(directory.resolve(file))
+                    : List.of(directory.resolve(file), 
directory.resolve("src/main/resources").resolve(file));
+            for (Path path : candidates) {
+                ProjectFacts.JsonFields fields = jsonFields(List.of(path), 
file);
+                if (fields != null) {
+                    answer.put(resource, fields);
+                    break;
+                }
+            }
+        }
+        for (String consumed : fileConsumerDirectories(content)) {
+            Path dir = directory.resolve(consumed);
+            if (!Files.isDirectory(dir)) {
+                continue;
+            }
+            try (Stream<Path> files = Files.list(dir)) {
+                List<Path> json = files.filter(f -> 
f.getFileName().toString().endsWith(".json")).sorted().toList();
+                // all of them or none: a field may be in a file not read
+                ProjectFacts.JsonFields fields = json.isEmpty() || json.size() 
> MAX_JSON_FILES
+                        ? null : jsonFields(json, "the files in " + 
ProjectFacts.directoryKey(consumed).substring(4) + "/");
+                if (fields != null) {
+                    answer.put(ProjectFacts.directoryKey(consumed), fields);
+                }
+            } catch (IOException e) {
+                // the directory is not readable: nothing is known
+            }
+        }
+        return answer;
+    }
+
+    /** The directories the file consumers of a YAML route file read: from: 
file:orders, or directoryName: orders. */
+    static List<String> fileConsumerDirectories(String content) {
+        List<String> answer = new ArrayList<>();
+        Object doc;
+        try {
+            doc = new Yaml(new SafeConstructor(new 
LoaderOptions())).load(content);
+        } catch (Exception e) {
+            return answer;
+        }
+        if (!(doc instanceof List<?> entries)) {
+            return answer;
+        }
+        for (Object entry : entries) {
+            Object from = entry instanceof Map<?, ?> map
+                    ? map.get("route") instanceof Map<?, ?> route ? 
route.get("from") : map.get("from")
+                    : null;
+            if (!(from instanceof Map<?, ?> f) || !(f.get("uri") instanceof 
String uri)) {
+                continue;
+            }
+            String dir = null;
+            if (uri.startsWith("file:")) {
+                dir = uri.substring("file:".length());
+                dir = dir.indexOf('?') >= 0 ? dir.substring(0, 
dir.indexOf('?')) : dir;
+            } else if ("file".equals(uri) && f.get("parameters") instanceof 
Map<?, ?> parameters
+                    && parameters.get("directoryName") instanceof String name) 
{
+                dir = name;
+            }
+            if (dir != null && !dir.isBlank() && !dir.contains("{{") && 
!dir.contains("${") && !dir.startsWith("/")) {
+                answer.add(dir.trim());
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * The fields of the JSON in these files: the keys of their objects (of 
the elements of a list), and for a field
+     * that holds a list of objects, the keys of those. Null when a file is 
not read, is not JSON, or the files differ
+     * in shape (an object in one, a list in another).
+     */
+    static ProjectFacts.JsonFields jsonFields(List<Path> files, String where) {
+        Set<String> fields = new TreeSet<>();
+        Map<String, Set<String>> elementFields = new HashMap<>();
+        Boolean list = null;
+        for (Path path : files) {
+            Object json;
+            try {
+                if (!Files.isRegularFile(path) || Files.size(path) > 
MAX_JSON_FILE) {
+                    return null;
+                }
+                json = Jsoner.deserialize(Files.readString(path));
+            } catch (Exception e) {
+                return null;
+            }
+            List<JsonObject> objects = new ArrayList<>();
+            if (json instanceof JsonObject o) {
+                objects.add(o);
+            } else if (json instanceof JsonArray a && !a.isEmpty() && 
a.stream().allMatch(JsonObject.class::isInstance)) {
+                a.forEach(e -> objects.add((JsonObject) e));
+            } else {
+                return null;
+            }
+            boolean isList = json instanceof JsonArray;
+            if (list != null && list != isList) {
+                return null;
+            }
+            list = isList;
+            for (JsonObject o : objects) {
+                fields.addAll(o.keySet());
+                for (Map.Entry<String, Object> e : o.entrySet()) {
+                    if (e.getValue() instanceof JsonArray a && !a.isEmpty()
+                            && 
a.stream().allMatch(JsonObject.class::isInstance)) {
+                        Set<String> keys = 
elementFields.computeIfAbsent(e.getKey(), k -> new TreeSet<>());
+                        a.forEach(x -> keys.addAll(((JsonObject) x).keySet()));
+                    }
+                }
+            }
+        }
+        return list == null ? null : new ProjectFacts.JsonFields(where, 
fields, elementFields, list, null);
+    }
+
     private static boolean isJsonArray(Path path) {
         try {
             if (!Files.isRegularFile(path) || Files.size(path) > 
MAX_JSON_FILE) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
index 4c936479575e..efe8f35cccb8 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ValidatorFactsTest.java
@@ -22,6 +22,7 @@ import java.util.List;
 import java.util.Map;
 
 import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.yaml.validator.ProjectFacts;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
@@ -242,6 +243,52 @@ class ValidatorFactsTest {
                 .noneMatch(m -> m.contains("reply to the REST call"));
     }
 
+    @Test
+    void theFieldsOfTheOrderFilesTheRoutesRead(@TempDir Path dir) throws 
Exception {
+        // route-filter-and-multicast and connect-http-client of the 
local-model benchmark (r24): ${body[lineCount]} on
+        // an order that has lines, and ${body[orderId]} inside the split of 
its lines, were only null at runtime
+        String content = """
+                - route:
+                    id: orders
+                    from:
+                      uri: file
+                      parameters:
+                        directoryName: orders
+                        noop: true
+                      steps:
+                        - unmarshal:
+                            json: {}
+                        - log: "Pick ${body[lineCount]} line(s) for 
${body[orderId]}"
+                """;
+        Files.createDirectories(dir.resolve("orders"));
+        Files.writeString(dir.resolve("orders/order-1.json"),
+                "{\"orderId\": \"ORD-1\", \"customer\": \"C-1\", \"lines\": 
[{\"sku\": \"A\", \"qty\": 2}]}");
+        Files.writeString(dir.resolve("orders/order-2.json"),
+                "{\"orderId\": \"ORD-2\", \"status\": \"paid\", \"lines\": 
[{\"sku\": \"B\", \"price\": 9.5}]}");
+        
assertThat(ValidatorFacts.fileConsumerDirectories(content)).containsExactly("orders");
+        ProjectFacts.JsonFields fields = ValidatorFacts.jsonFields(content, 
dir).get("dir:orders");
+        assertThat(fields.where()).isEqualTo("the files in orders/");
+        assertThat(fields.fields()).containsExactlyInAnyOrder("orderId", 
"customer", "status", "lines");
+        
assertThat(fields.elementFields().get("lines")).containsExactlyInAnyOrder("sku",
 "qty", "price");
+        assertThat(fields.list()).isFalse();
+        assertThat(SourceValidator.validate("orders.camel.yaml", content, new 
DefaultCamelCatalog(), null, dir))
+                .singleElement().asString().contains("but the data here has no 
field lineCount");
+        // the uri form of the directory
+        assertThat(ValidatorFacts.fileConsumerDirectories(content.replace("""
+                      uri: file
+                      parameters:
+                        directoryName: orders
+                        noop: true
+                """, """
+                      uri: file:orders?noop=true
+                """))).containsExactly("orders");
+        // a file of another shape among them: nothing is known
+        Files.writeString(dir.resolve("orders/all.json"), "[{\"orderId\": 
\"ORD-3\"}]");
+        assertThat(ValidatorFacts.jsonFields(content, dir)).isEmpty();
+        Files.writeString(dir.resolve("orders/all.json"), "not json");
+        assertThat(ValidatorFacts.jsonFields(content, dir)).isEmpty();
+    }
+
     @Test
     void aJsonArrayFileTheRoutesLoadIsAList(@TempDir Path dir) throws 
Exception {
         // rest-code-first step 1 of the local-model benchmark: 
${body.orderId} on the List of orders.json failed only at
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
index 3a690086d4c4..f266612f0e56 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/ProjectFacts.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.yaml.validator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * What the validator cannot read from a route file, read by the caller from 
the files beside it (CAMEL-25762): the
@@ -41,10 +42,13 @@ import java.util.Set;
  *                          transformers Camel runs at the start and the end 
of their route
  * @param jsonArrays        the files a resource: of the routes loads whose 
JSON is an array, as the resource names them
  *                          ({@code file:orders.json}): unmarshalled, such a 
file is a List, not a Map
+ * @param jsonFields        the fields of the JSON data the routes read: of 
the file a resource: loads, keyed as the
+ *                          resource names it ({@code file:stock.json}), and 
of the files in the directory a file
+ *                          consumer reads, keyed as {@link 
#directoryKey(String)} names it
  */
 public record ProjectFacts(Set<String> bodylessEndpoints, Map<String, 
Map<String, String>> operations,
         String restBindingMode, Map<String, Map<String, List<String>>> 
requestFields, Map<String, Observed> observed,
-        Map<String, KameletDataTypes> kamelets, Set<String> jsonArrays) {
+        Map<String, KameletDataTypes> kamelets, Set<String> jsonArrays, 
Map<String, JsonFields> jsonFields) {
 
     /** Nothing known about the project. */
     public static final ProjectFacts NONE = new ProjectFacts(Set.of(), 
Map.of(), null);
@@ -68,6 +72,37 @@ public record ProjectFacts(Set<String> bodylessEndpoints, 
Map<String, Map<String
     public record KameletDataTypes(String in, String out) {
     }
 
+    /**
+     * The fields of JSON data, as the files of the project have them: the 
keys of its objects (of the elements, for a
+     * list), and of each field that holds a list of objects, the keys of 
those objects. Where says in words where the
+     * data is ("the files in orders/"); outer has the fields of the data an 
element was split from, and is empty
+     * otherwise.
+     */
+    public record JsonFields(String where, Set<String> fields, Map<String, 
Set<String>> elementFields, boolean list,
+            Set<String> outer) {
+
+        public JsonFields {
+            fields = fields != null ? Set.copyOf(fields) : Set.of();
+            elementFields = elementFields != null
+                    ? elementFields.entrySet().stream()
+                            
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, e -> 
Set.copyOf(e.getValue())))
+                    : Map.of();
+            outer = outer != null ? Set.copyOf(outer) : Set.of();
+        }
+    }
+
+    /** The key of the JSON data in the directory a file consumer reads: 
{@code dir:orders}. */
+    public static String directoryKey(String directory) {
+        String d = directory.trim();
+        while (d.startsWith("./")) {
+            d = d.substring(2);
+        }
+        while (d.endsWith("/") && d.length() > 1) {
+            d = d.substring(0, d.length() - 1);
+        }
+        return "dir:" + d;
+    }
+
     public ProjectFacts {
         bodylessEndpoints = bodylessEndpoints != null ? bodylessEndpoints : 
Set.of();
         operations = operations != null ? operations : Map.of();
@@ -75,6 +110,14 @@ public record ProjectFacts(Set<String> bodylessEndpoints, 
Map<String, Map<String
         observed = observed != null ? observed : Map.of();
         kamelets = kamelets != null ? kamelets : Map.of();
         jsonArrays = jsonArrays != null ? jsonArrays : Set.of();
+        jsonFields = jsonFields != null ? jsonFields : Map.of();
+    }
+
+    /** Without the fields of the JSON data of the project. */
+    public ProjectFacts(Set<String> bodylessEndpoints, Map<String, Map<String, 
String>> operations,
+                        String restBindingMode, Map<String, Map<String, 
List<String>>> requestFields,
+                        Map<String, Observed> observed, Map<String, 
KameletDataTypes> kamelets, Set<String> jsonArrays) {
+        this(bodylessEndpoints, operations, restBindingMode, requestFields, 
observed, kamelets, jsonArrays, Map.of());
     }
 
     /** Without the JSON arrays of the project. */
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
index a5bba63c028c..c1b2806ebf05 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/TextBodyFlow.java
@@ -25,6 +25,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.StringJoiner;
+import java.util.TreeSet;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -194,11 +195,18 @@ final class TextBodyFlow {
         String format();
     }
 
-    /** Parsed data: the Map or the List an unmarshal makes of the text; list 
when it is known to be a List. */
-    record Parsed(String origin, String format, boolean list) implements Body {
+    /**
+     * Parsed data: the Map or the List an unmarshal makes of the text; list 
when it is known to be a List; the fields
+     * of the JSON data it was parsed from, when the files of the project say 
them.
+     */
+    record Parsed(String origin, String format, boolean list, 
ProjectFacts.JsonFields fields) implements Body {
 
         Parsed(String origin, String format) {
-            this(origin, format, false);
+            this(origin, format, false, null);
+        }
+
+        Parsed(String origin, String format, boolean list) {
+            this(origin, format, list, null);
         }
     }
 
@@ -254,7 +262,8 @@ final class TextBodyFlow {
     private record Walk(String routeId, Map<String, JsonNode> byFrom, 
List<Error> errors, Set<String> seen, boolean post,
             Map<String, Map<String, String>> operations, Map<String, 
Map<String, List<String>>> requestFields,
             Map<String, ProjectFacts.Observed> observed, CatalogBodyTypes 
bodyTypes,
-            Map<String, ProjectFacts.KameletDataTypes> kamelets, Map<String, 
Body> variables, Set<String> jsonArrays) {
+            Map<String, ProjectFacts.KameletDataTypes> kamelets, Map<String, 
Body> variables, Set<String> jsonArrays,
+            Map<String, ProjectFacts.JsonFields> jsonFields, Map<String, 
List<Body>> sent) {
 
         /**
          * The walk of a route a step calls: for its outcome alone, and the 
variables of the message it changes. The
@@ -263,17 +272,21 @@ final class TextBodyFlow {
         Walk called() {
             return new Walk(
                     null, byFrom, null, seen, false, operations, 
requestFields, observed, bodyTypes, kamelets, variables,
-                    jsonArrays);
+                    jsonArrays, jsonFields, sent);
         }
 
         /** The walk of the steps of a block: what a branch puts in a variable 
stays in the branch. */
         Walk branch() {
             return new Walk(
                     routeId, byFrom, errors, seen, post, operations, 
requestFields, observed, bodyTypes, kamelets,
-                    new HashMap<>(variables), jsonArrays);
+                    new HashMap<>(variables), jsonArrays, jsonFields, sent);
         }
     }
 
+    /** A direct: or seda: route whose body at the start is not known, to walk 
again with what the file sends to it. */
+    private record Unknown(String uri, String routeId, JsonNode steps, 
NodePath at, boolean post) {
+    }
+
     static void check(JsonNode target, NodePath path, List<Error> errors, 
ProjectFacts facts, CatalogBodyTypes bodyTypes) {
         if (target == null || !target.isArray()) {
             return;
@@ -286,6 +299,10 @@ final class TextBodyFlow {
         Map<String, ProjectFacts.Observed> observed = facts != null ? 
facts.observed() : Map.of();
         Map<String, ProjectFacts.KameletDataTypes> kamelets = facts != null ? 
facts.kamelets() : Map.of();
         Set<String> jsonArrays = facts != null ? facts.jsonArrays() : Set.of();
+        Map<String, ProjectFacts.JsonFields> jsonFields = facts != null ? 
facts.jsonFields() : Map.of();
+        // the bodies the routes send to each direct: or seda: endpoint, and 
the routes whose start is not known
+        Map<String, List<Body>> sent = new HashMap<>();
+        List<Unknown> unknown = new ArrayList<>();
         Map<String, JsonNode> byFrom = new HashMap<>();
         for (JsonNode entry : target) {
             JsonNode from = from(entry);
@@ -333,9 +350,12 @@ final class TextBodyFlow {
                 // the message the consumer receives is in the variable
                 put(variables, received, consumerBody(uri, from, bodyTypes, 
"from: " + normalize(uri) + " gives it"));
             }
+            if (start == null && !variable && INTERNAL.contains(consumer)) {
+                unknown.add(new Unknown(normalize(uri), id, steps, at, post));
+            }
             Body end = walk(steps, start, at, new Walk(
                     id, byFrom, errors, new HashSet<>(), post, operations,
-                    requestFields, observed, bodyTypes, kamelets, variables, 
jsonArrays));
+                    requestFields, observed, bodyTypes, kamelets, variables, 
jsonArrays, jsonFields, sent));
             // the body at the end of the route of a consumer that replies 
with text (platform-http), or of the route of
             // a REST call, is the reply
             boolean rest = uri != null && restReplies.contains(normalize(uri));
@@ -351,8 +371,37 @@ final class TextBodyFlow {
                         parsed));
             }
         }
+        // a direct: or seda: route that every call of the file sends the same 
JSON data to: what it reads of that data
+        // is checked against its fields (r24 route-filter-and-multicast step 
2: ${body[lineCount]} in the route the
+        // multicast sends the order to). Only those reports: the route was 
walked already
+        for (Unknown route : unknown) {
+            List<Body> bodies = sent.get(route.uri());
+            if (bodies == null || bodies.isEmpty() || !(bodies.get(0) 
instanceof Parsed first) || first.fields() == null
+                    || first.list()
+                    || bodies.stream().anyMatch(b -> !(b instanceof Parsed p) 
|| !first.fields().equals(p.fields()))) {
+                continue;
+            }
+            List<Error> found = new ArrayList<>();
+            walk(route.steps(), new Parsed(
+                    "every call of " + route.uri() + " in the file sends it", 
first.format(), false,
+                    first.fields()), route.at(),
+                    new Walk(
+                            route.routeId(), byFrom, found, new HashSet<>(), 
route.post(), operations, requestFields,
+                            observed, bodyTypes, kamelets, new HashMap<>(), 
jsonArrays, jsonFields, new HashMap<>()));
+            for (Error e : found) {
+                if (String.valueOf(e.getMessage()).contains(NO_FIELD)) {
+                    errors.add(e);
+                }
+            }
+        }
     }
 
+    /** The components a route of the file is reached through, with the body 
of its caller. */
+    private static final Set<String> INTERNAL = Set.of("direct", "seda", 
"direct-vm");
+
+    /** What a report of a field the JSON data does not have says. */
+    private static final String NO_FIELD = ", but the data here has no field ";
+
     /**
      * Whether the steps set or read an HTTP control header (CamelHttpMethod, 
CamelHttpQuery...), which can make an http
      * producer call with GET and leave the body out. A match anywhere in the 
steps only turns the http check off for
@@ -379,9 +428,9 @@ final class TextBodyFlow {
             return null;
         }
         if (out.payload()) {
-            // the file as read has the format of the files it reads
+            // the file as read has the format of the files it reads, and the 
data of the files in its directory
             return out.file()
-                    ? new Text(consumer, fileFormat(from), true)
+                    ? new Text(consumer, fileFormat(from), true, false, 
directoryKey(uri, from))
                     : new Text(consumer + " as " + out.inWords(), 
out.dataFormat());
         }
         return out.data() ? new Parsed(consumer + " as " + out.inWords(), 
out.dataFormat()) : null;
@@ -451,6 +500,9 @@ final class TextBodyFlow {
             String name = step.fieldNames().next();
             JsonNode value = step.get(name);
             NodePath here = at.append(j).append(name);
+            if (w.errors() != null) {
+                recordSend(name, value, body, w);
+            }
             boolean splitWalked = false;
             if (w.errors() != null && parsesData(name, value, body, here, w)) {
                 // the unmarshal fails at runtime, so what comes after it is 
not run: one report
@@ -482,6 +534,12 @@ final class TextBodyFlow {
                     body = null;
                     continue;
                 } else if (body instanceof Parsed parsed) {
+                    String unknownField = parsed.list() || parsed.fields() == 
null
+                            ? null : unknownField(name, value, 
parsed.fields());
+                    if (unknownField != null) {
+                        // a field that is not there is null, and the route 
goes on
+                        w.errors().add(fieldError(here, w.routeId(), 
unknownField, parsed.fields()));
+                    }
                     Need need = needsText(name, value, w);
                     Parsed element = "split".equals(name) ? 
splitElement(value, parsed, w.bodyTypes()) : null;
                     if (need != null) {
@@ -550,6 +608,11 @@ final class TextBodyFlow {
                 if (read != null) {
                     w.errors().add(listError(at.append(key), w.routeId(), 
read, parsed));
                 }
+            } else if (body instanceof Parsed parsed && parsed.fields() != 
null) {
+                String read = unknownField(key, child, parsed.fields());
+                if (read != null) {
+                    w.errors().add(fieldError(at.append(key), w.routeId(), 
read, parsed.fields()));
+                }
             }
         }
     }
@@ -573,12 +636,16 @@ final class TextBodyFlow {
             }
             case "unmarshal" -> {
                 Parsed parsed = unmarshalled(value, w.bodyTypes());
+                ProjectFacts.JsonFields fields = parsed != null && 
"json".equals(parsed.format())
+                        && body instanceof Text text && text.resource() != null
+                                ? w.jsonFields().get(text.resource()) : null;
                 if (parsed != null && "json".equals(parsed.format()) && body 
instanceof Text text
                         && text.resource() != null && 
w.jsonArrays().contains(text.resource())) {
                     String file = 
text.resource().substring(text.resource().indexOf(':') + 1);
-                    return new Parsed("unmarshal: json turns the JSON array of 
" + file + " into a List", "json", true);
+                    return new Parsed(
+                            "unmarshal: json turns the JSON array of " + file 
+ " into a List", "json", true, fields);
                 }
-                return parsed;
+                return fields != null ? new Parsed(parsed.origin(), "json", 
fields.list(), fields) : parsed;
             }
             case "marshal" -> {
                 return marshalled(value, body);
@@ -1140,7 +1207,7 @@ final class TextBodyFlow {
             if (readIn(step.get(name), TEXT_PROPERTIES) != null || 
readsAFieldWithJsonPath(step.get(name))) {
                 return new Parsed(
                         parsed.origin() + ", and split " + expression.trim() + 
" gives its elements as data",
-                        parsed.format());
+                        parsed.format(), false, "simple".equals(language) ? 
elementFields(parsed, expression) : null);
             }
             if (!bodyTypes.keepsTheBody(name, step.get(name))) {
                 return null;
@@ -1695,6 +1762,112 @@ final class TextBodyFlow {
                 .build();
     }
 
+    /** The key of the JSON data in the directory a file consumer reads, or 
null for any other consumer. */
+    private static String directoryKey(String uri, JsonNode from) {
+        if (!"file".equals(scheme(uri))) {
+            return null;
+        }
+        String path = uri.indexOf(':') > 0 ? 
normalize(uri).substring(uri.indexOf(':') + 1) : null;
+        if ((path == null || path.isBlank()) && from != null && 
from.isObject() && from.get("parameters") != null
+                && from.get("parameters").get("directoryName") != null) {
+            path = from.get("parameters").get("directoryName").asText();
+        }
+        return path != null && !path.isBlank() ? 
ProjectFacts.directoryKey(path) : null;
+    }
+
+    /** Remembers the body a step sends to a direct: or seda: endpoint, for 
the route of the file that consumes it. */
+    private static void recordSend(String name, JsonNode value, Body body, 
Walk w) {
+        if (!SENDS_THE_BODY.contains(name)) {
+            return;
+        }
+        String uri = sendUri(name, value);
+        if (uri != null && INTERNAL.contains(scheme(uri)) && 
!uri.contains("${")) {
+            w.sent().computeIfAbsent(normalize(uri), k -> new 
ArrayList<>()).add(body);
+        }
+    }
+
+    /** ${body.lines}, ${body[lines]} or ${body['lines']}: one field of the 
body, and nothing else. */
+    private static final Pattern ONE_FIELD
+            = 
Pattern.compile("\\$\\{body(?:\\??\\.([A-Za-z_]\\w*)|\\[\\s*['\"]?([A-Za-z_]\\w*)['\"]?\\s*])}");
+
+    /** The names a field read may be that are not fields of the data: what a 
Map or a List has itself. */
+    private static final Set<String> COLLECTION_PROPERTIES = Set.of("size", 
"empty", "class", "length", "isEmpty",
+            "keySet", "values", "entrySet");
+
+    /**
+     * The first read of a field of the body that the JSON data does not have, 
in the simple expressions and templates
+     * of a step (not of its nested steps), as written; null when the data has 
every field read.
+     */
+    private static String unknownField(String name, JsonNode value, 
ProjectFacts.JsonFields fields) {
+        List<String> texts = new ArrayList<>();
+        if ("simple".equals(name)) {
+            texts.add(expressionText(value));
+        } else {
+            simpleExpressions(value, texts);
+            templates(name, value).forEach(t -> texts.add(t.text()));
+        }
+        for (String text : texts) {
+            Matcher m = text != null ? FIELD_READ.matcher(text) : null;
+            while (m != null && m.find()) {
+                String field = m.group(1);
+                boolean call = m.end() < text.length() && text.charAt(m.end()) 
== '(';
+                if (!call && !COLLECTION_PROPERTIES.contains(field) && 
!fields.fields().contains(field)) {
+                    int end = text.indexOf('}', m.start());
+                    return end > 0 ? text.substring(m.start(), end + 1) : 
m.group();
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * The fields of the elements a simple split of a field of the data gives: 
${body[lines]} of an order gives its
+     * lines; ${body} of a list its elements. Null when not known.
+     */
+    private static ProjectFacts.JsonFields elementFields(Parsed parsed, String 
expression) {
+        ProjectFacts.JsonFields fields = parsed.fields();
+        String e = expression.trim();
+        if (fields == null || !THE_BODY.matcher(e).matches()) {
+            return null;
+        }
+        if ("${body}".equals(e)) {
+            return fields.list()
+                    ? new ProjectFacts.JsonFields(
+                            "one element of " + fields.where(), 
fields.fields(), fields.elementFields(), false, null)
+                    : null;
+        }
+        Matcher m = ONE_FIELD.matcher(e);
+        String field = !fields.list() && m.matches() ? m.group(1) != null ? 
m.group(1) : m.group(2) : null;
+        Set<String> element = field != null ? 
fields.elementFields().get(field) : null;
+        return element != null
+                ? new ProjectFacts.JsonFields(
+                        "one of the " + field + " of " + fields.where(), 
element, Map.of(), false, fields.fields())
+                : null;
+    }
+
+    private static Error fieldError(NodePath at, String routeId, String read, 
ProjectFacts.JsonFields fields) {
+        Matcher m = FIELD_READ.matcher(read);
+        String field = m.find() ? m.group(1) : read;
+        String advice;
+        if (fields.outer().contains(field)) {
+            advice = field + " is a field of the data that was split, not of 
its element: read it before the split,"
+                     + " into a variable (setVariable), and write ${variable." 
+ field + "} in the split";
+        } else {
+            String list = 
fields.elementFields().keySet().stream().sorted().findFirst().orElse(null);
+            advice = "read one of those"
+                     + (list != null ? " (the number of " + list + " is 
${body[" + list + "].size()})" : "");
+        }
+        return Error.builder()
+                .keyword("type")
+                .instanceLocation(at)
+                .messageKey("type")
+                .format(new MessageFormat("{0}"))
+                .arguments((routeId != null ? "route " + routeId + ": " : "") 
+ "simple reads " + read + NO_FIELD
+                           + field + " (the fields of " + fields.where() + ": "
+                           + String.join(", ", new TreeSet<>(fields.fields())) 
+ "): " + advice)
+                .build();
+    }
+
     /**
      * An http call that sends empty text as a POST: the http producer sends a 
POST for any body that is not null, and
      * empty text is a body (HttpMethodHelper). r24 connect-http-client: a 
setBody with constant "" before a GET of the
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
index f7be71e5a827..4887f00f528f 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/TextBodyFlowTest.java
@@ -2298,6 +2298,121 @@ public class TextBodyFlowTest {
                 .isEmpty();
     }
 
+    /**
+     * The project has order files in orders/, each an order with lines: the 
facts the AI tools read beside the route.
+     */
+    private static final ProjectFacts ORDER_FILES = new ProjectFacts(
+            Set.of(), Map.of(), null, Map.of(), Map.of(), Map.of(), Set.of(),
+            Map.of("dir:orders", new ProjectFacts.JsonFields(
+                    "the files in orders/", Set.of("orderId", "customer", 
"country", "status", "lines"),
+                    Map.of("lines", Set.of("sku", "qty", "price")), false, 
null)));
+
+    /** A route that reads the order files and unmarshals them, then the steps 
given (written from column 0). */
+    private static String orders(String steps) {
+        return """
+                - route:
+                    id: orders
+                    from:
+                      uri: file
+                      parameters:
+                        directoryName: orders
+                        noop: true
+                      steps:
+                        - unmarshal:
+                            json: {}
+                """ + steps.indent(8);
+    }
+
+    @Test
+    public void testAFieldTheJsonDataDoesNotHave() {
+        // r24 route-filter-and-multicast step 2: ${body[lineCount]} on an 
order, which has lines
+        assertThat(lines(orders("- log: \"Pick ${body[lineCount]} line(s) for 
${body[orderId]}\"\n"), ORDER_FILES))
+                .singleElement().satisfies(m -> {
+                    assertThat(m).contains("route orders: simple reads 
${body[lineCount]}, but the data here has no field"
+                                           + " lineCount (the fields of the 
files in orders/: country, customer, lines,"
+                                           + " orderId, status)");
+                    assertThat(m).contains("read one of those (the number of 
lines is ${body[lines].size()})");
+                });
+        // the fields the data has, a method of the Map, and a predicate
+        assertThat(lines(orders("- log: \"Pick ${body[lines].size()} line(s) 
for ${body.orderId}\"\n"), ORDER_FILES))
+                .isEmpty();
+        assertThat(lines(orders("- log: \"${body.size()} fields\"\n"), 
ORDER_FILES)).isEmpty();
+        assertThat(lines(orders("""
+                - filter:
+                    simple: "${body[state]} == 'paid'"
+                    steps:
+                      - log: "paid"
+                """), ORDER_FILES)).singleElement().asString().contains("has 
no field state");
+        // nothing known without the facts, or after a step that changes the 
body
+        assertThat(lines(orders("- log: \"${body[lineCount]}\"\n"))).isEmpty();
+        assertThat(lines(orders("""
+                - setBody:
+                    groovy: "[lineCount: body.lines.size()]"
+                - log: "${body[lineCount]}"
+                """), ORDER_FILES)).isEmpty();
+    }
+
+    @Test
+    public void testAFieldOfTheOrderReadInTheSplitOfItsLines() {
+        // r24 connect-http-client step 2: ${body[orderId]} inside the split 
of the lines, which have no orderId
+        String yaml = orders("""
+                - split:
+                    simple: "${body[lines]}"
+                    steps:
+                      - setVariable:
+                          name: orderId
+                          simple: "${body[orderId]}"
+                      - log: "${variable.orderId}: ${body[sku]} x ${body[qty]}"
+                """);
+        assertThat(lines(yaml, ORDER_FILES)).singleElement().satisfies(m -> {
+            assertThat(m).contains("simple reads ${body[orderId]}, but the 
data here has no field orderId (the fields of"
+                                   + " one of the lines of the files in 
orders/: price, qty, sku)");
+            assertThat(m).contains("orderId is a field of the data that was 
split, not of its element: read it before"
+                                   + " the split, into a variable 
(setVariable), and write ${variable.orderId} in the"
+                                   + " split");
+        });
+        assertThat(lines(yaml.replace("${body[orderId]}", "${body[sku]}"), 
ORDER_FILES)).isEmpty();
+    }
+
+    @Test
+    public void testADirectRouteThatEveryCallSendsTheData() {
+        // r24 route-filter-and-multicast step 2: the route the multicast 
sends the order to reads ${body[lineCount]}
+        String yaml = orders("""
+                - filter:
+                    simple: "${body[status]} == 'paid'"
+                    steps:
+                      - multicast:
+                          steps:
+                            - to: direct:warehouse
+                            - to: direct:invoicing
+                """) + """
+                - route:
+                    id: warehouse
+                    from:
+                      uri: direct:warehouse
+                      steps:
+                        - log: "Warehouse: pick ${body[lineCount]} line(s) for 
${body[orderId]}"
+                - route:
+                    id: invoicing
+                    from:
+                      uri: direct:invoicing
+                      steps:
+                        - log: "Invoicing: bill customer ${body[customer]} for 
${body[orderId]}"
+                """;
+        assertThat(lines(yaml, ORDER_FILES)).singleElement().satisfies(m -> {
+            assertThat(m).contains("route warehouse: simple reads 
${body[lineCount]}, but the data here has no field"
+                                   + " lineCount");
+        });
+        // a call that sends something else: the route's body is not known
+        assertThat(lines(yaml + """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to: direct:warehouse
+                """, ORDER_FILES)).isEmpty();
+    }
+
     /** The project has orders.json, a JSON array: the facts the AI tools read 
beside the route file. */
     private static final ProjectFacts ORDERS_ARRAY
             = new ProjectFacts(Set.of(), Map.of(), null, Map.of(), Map.of(), 
Map.of(), Set.of("file:orders.json"));

Reply via email to