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 e8a65e848aba CAMEL-26568: camel-jbang - AI tools and messages: a JMS 
reply that replaces the message, file as a language, ${file:noext}, parseText 
on bytes, and an unused placeholder (#29147)
e8a65e848aba is described below

commit e8a65e848aba080d07d1e5a18f98ec9cca595b68
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 4 14:12:13 2026 +0100

    CAMEL-26568: camel-jbang - AI tools and messages: a JMS reply that replaces 
the message, file as a language, ${file:noext}, parseText on bytes, and an 
unused placeholder (#29147)
    
    * CAMEL-26568: camel-core / camel-groovy - the file function meant for 
${file:noext}, and how to parse bytes in parseText
    
    From the r26 run of the local-model benchmark (qwen3.6 35B, rounds 1 to 7),
    two runtime errors that did not say what to write:
    
    - connect-sftp-transfer step 3 asks for a receipt named like the batch,
      with .txt in place of .csv. 3 of the 4 failed runs wrote ${file:noext}
      or ${file:name:noext}. The error "Unknown file language syntax: noext"
      listed 4 file functions and said that they do not read a file. That is
      about another mistake. Now it says "did you mean ${file:name.noext}?",
      and lists the usual file functions. For the bare noext it also names
      ${file:onlyname.noext}. The function meant is found by its case, its
      separators (: _ -) and a letter or two. A path, such as
      ${file:data/stock.json}, still gets the text that the file functions do
      not read a file.
    - connect-service-jms-request-reply step 3: the JMS message is bytes, and
      new JsonSlurper().parseText(body) failed with "No signature of method:
      parseText ... ([B)" (2 runs). The Groovy language now adds a hint to
      the first line of this error, as it does for "No such property ... for
      class: [B" (CAMEL-25749): parseText takes text, so parse the bytes with
      new JsonSlurper().parse(body), or unmarshal the body first. The
      exception is still a MissingMethodException. The hint does not suggest
      parseText(body as String): in Groovy that gives the numbers of the
      bytes, not the text.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
    
    * CAMEL-26568: camel-jbang - AI tools: a JMS reply that replaces the 
message, what to do first for an unused placeholder, file as a language, and 
Simple in a fileName option
    
    From the r26 run of the local-model benchmark (qwen3.6 35B, rounds 1 to 7
    of the new set and of the ladder):
    
    - connect-service-jms-request-reply step 2 asks to keep the order as the
      body. In 6 of 7 runs the order was sent with InOut and no
      variableReceive, so the reply replaced it. The log lines were right, and
      no answer said that the body is now the reply. The write and edit
      answers now have a note for a to: on jms, sjms, sjms2, activemq or amqp
      that sends InOut (pattern: InOut, exchangePattern=InOut, or a
      setExchangePattern before it) with no variableReceive. The note says
      that the body is the reply, and that variableReceive keeps the message.
      It is a note, not a refusal: a route may want the reply as its body. A
      route that sets the body itself after the reply gets no note. Over the
      benchmark traces, the note comes on the accepted writes of all 6 failed
      runs of r26 (and of 14 failed runs of this step in r23 to r25). It
      comes on none of the 2425 final files of the passed steps of r23 to r26.
    - run-nightly-report step 4: the note that report.schedule is no longer
      used (#28847) was right in all 3 failed runs, and no run acted on it:
      the model repeated it and ended the step. The answer now starts with
      what to do: "Use {{report.schedule}} again on line 7, and set
      report.schedule=0 0 2 * * ? in application.properties: the property is
      now unused, as line 7 has its own value." The note is as before.
    - connect-sftp-transfer step 3: fileName: "${file:noext}.txt" on a to:
      sftp passed the write, and failed only when the route reloaded. The
      options of the file components that are read as the file language
      (fileName, move, moveFailed, preMove, moveExisting, tempFileName,
      idempotentKey, filterFile, filterDirectory) are now checked as Simple
      when they have ${...}, under parameters and in the uri. The 2 such
      writes of r26 are now refused, with the did-you-mean of the commit
      before. No final file of a passed step gets a new message.
    - connect-service-jms-request-reply step 3: 5 of 7 runs read stock.json
      with expression: file: {...}, and got only "property 'file' is not
      defined in the schema". The YAML DSL validator now says that file is not
      a language, and gives the lines to write: constant with
      resource:file:stock.json, as the usage of the file component says. A
      file name with ${...} gets the pollEnrich form, as constant does not
      evaluate it.
    - connect-sftp-transfer step 2: a Groovy script set the exchange property
      count, and the log read ${property.count}. The hint said "did you mean
      ${variable.count}?", as the route also had a variable count. That
      variable was the 0 the route set first. A property that a script sets
      (exchange.setProperty, exchange.properties[...]) now gets
      ${exchangeProperty.count}, and exchange.setVariable in the script as the
      way to keep it in a variable.
    - contracts-openapi-server step 4 (ladder): the hint for steps: under
      choice said that choice owns a steps: list, and the model wrote it 6
      times. Now choice has its own message: the steps go under a when: or
      under otherwise:.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
    
    * CAMEL-26568: camel-jbang - AI tools: review fixes: the file hints, the 
file option check, two unused keys, and the JMS reply note
    
    From the review of #29147:
    
    - The file name hint suggested a pollEnrich with noop=true and no timeout.
      noop=true makes the file endpoint idempotent, so a second poll of the
      same file found nothing, and pollEnrich waits for ever by default. The
      pollEnrich also replaced the body where the route had a setVariable.
      The hint now writes noop=true&idempotent=false, timeout: 5000, and
      variableReceive with the name of the setVariable. A setBody gets no
      variableReceive, as it sets the body.
    - "file is not a language" suggested constant with resource:file: in
      pollEnrich, enrich, recipientList, routingSlip and dynamicRouter too.
      There the expression is the uri of an endpoint: the text of the file
      became the uri, and a missing file stopped the route from starting. Now
      pollEnrich gets a file: uri with fileName and a timeout, enrich is told
      to read a file with pollEnrich, and the routing EIPs are told that their
      expression gives endpoint uris.
    - The check of the file language options refused routes that run fine: a
      placeholder as an operand of || or &&, a RAW(...) value with & in it, a
      single-quoted YAML value with '' in it, a uri value with a space (the uri
      pattern of the line stopped at the space), and + for a space in a uri.
      The options are now read as Camel reads them (URISupport, RAW aware), a
      quoted YAML value is read as YAML reads it ('' and \"), and a value that
      is right as written or URL-decoded passes. The r26 mistakes are still
      refused.
    - Two placeholders with the same text before them, replaced in one write,
      gave the second key the line and the value of the first. Each key now
      gets its own changed line, in order.
    - The JMS reply note uses the pattern the runtime sends with:
      exchangePattern of the endpoint wins over pattern: of the to: (as in
      SendProcessor), and disableReplyTo sends InOnly, so there is no note. A
      setBody later on the main path hides the note: after the EIP that holds
      the to:, or in a doFinally. One that runs only on a failure (doCatch,
      onException, onFallback) does not.
    
    Over the r23 to r26 benchmark traces (2,934 files) the notes and messages
    are the same as before these fixes: the note still comes on the 7
    accepted writes of the failed r26 runs of connect-service-jms-request-reply
    step 2, and on none of the 2,425 final files of the passed steps. The file
    option check still refuses the 2 r26 writes of connect-sftp-transfer
    step 3.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../camel/language/groovy/GroovyExpression.java    |  54 ++++
 .../groovy/GroovyMissingMethodHintTest.java        |  93 ++++++
 .../camel/language/simple/SimpleSyntaxHints.java   |  65 +++++
 .../simple/ast/SimpleFunctionExpression.java       |   6 +-
 .../language/simple/SimpleSyntaxHintsTest.java     |  22 ++
 .../dsl/jbang/core/commands/ai/AuthoringTools.java |  35 ++-
 .../dsl/jbang/core/commands/ai/EndpointChecks.java |  87 ++++++
 .../core/commands/ai/HeaderPropertyMixups.java     |  26 +-
 .../dsl/jbang/core/commands/ai/WriteNotes.java     | 298 +++++++++++++++++--
 .../dsl/jbang/core/commands/ai/YamlLines.java      |  20 +-
 .../core/commands/ai/AuthoringToolsEditTest.java   |  47 ++-
 .../core/commands/ai/HeaderPropertyMixupsTest.java |  30 ++
 .../commands/ai/SourceValidatorEndpointTest.java   |  83 ++++++
 .../dsl/jbang/core/commands/ai/WriteNotesTest.java | 320 ++++++++++++++++++++-
 .../camel/dsl/yaml/validator/SchemaHints.java      | 164 ++++++++++-
 .../camel/dsl/yaml/validator/YamlValidator.java    |   3 +-
 .../validator/YamlValidatorPropertyHintTest.java   | 150 ++++++++++
 17 files changed, 1454 insertions(+), 49 deletions(-)

diff --git 
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
 
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
index 51ad2c29eb74..d50b70a400c9 100644
--- 
a/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
+++ 
b/components/camel-groovy/src/main/java/org/apache/camel/language/groovy/GroovyExpression.java
@@ -146,6 +146,14 @@ public class GroovyExpression extends ExpressionSupport {
             }
             throw new groovy.lang.MissingPropertyException(
                     e.getMessageWithoutLocationText() + " (" + hint + ")", 
name, e.getType());
+        } catch (groovy.lang.MissingMethodException e) {
+            // new JsonSlurper().parseText(body) on a body of bytes: parseText 
takes text (r26 of the local-model
+            // benchmark, a JMS message read as JSON in 
connect-service-jms-request-reply step 3)
+            String hint = bytesToParseTextHint(exchange, e);
+            if (hint == null) {
+                throw e;
+            }
+            throw new HintedMissingMethodException(e, hint);
         }
 
         return exchange.getContext().getTypeConverter().convertTo(type, 
GroovyLanguage.scriptResult(value));
@@ -384,6 +392,52 @@ public class GroovyExpression extends ExpressionSupport {
         }
     }
 
+    /**
+     * The hint for parseText of a JsonSlurper called with bytes: parseText 
takes text, and JsonSlurper.parse reads the
+     * bytes. For the body, also what it is and the unmarshal that parses it. 
Null for any other missing method.
+     */
+    private static String bytesToParseTextHint(Exchange exchange, 
groovy.lang.MissingMethodException e) {
+        Object[] args = e.getArguments();
+        if (!"parseText".equals(e.getMethod()) || e.getType() == null
+                || !e.getType().getName().startsWith("groovy.json.") || args 
== null || args.length != 1
+                || !(args[0] instanceof byte[])) {
+            return null;
+        }
+        if (args[0] == exchange.getMessage().getBody()) {
+            String unmarshal = BodyFacts.hint(exchange, BodyFacts.Need.DATA);
+            return BodyFacts.describe(exchange) + ": parseText takes text, so 
parse the bytes with"
+                   + " new groovy.json.JsonSlurper().parse(body)" + (unmarshal 
!= null ? ", or " + unmarshal : "");
+        }
+        return "the value is bytes (a byte[]), and parseText takes text: parse 
the bytes with"
+               + " new groovy.json.JsonSlurper().parse(...)";
+    }
+
+    /**
+     * A MissingMethodException with a hint at the end of the first line of 
its message, the line a log shows. It keeps
+     * the type, so an onException for it still applies.
+     */
+    static final class HintedMissingMethodException extends 
groovy.lang.MissingMethodException {
+
+        private static final long serialVersionUID = 1L;
+
+        private final String hint;
+
+        HintedMissingMethodException(groovy.lang.MissingMethodException cause, 
String hint) {
+            super(cause.getMethod(), cause.getType(), cause.getArguments(), 
cause.isStatic());
+            this.hint = hint;
+            setStackTrace(cause.getStackTrace());
+        }
+
+        @Override
+        public String getMessage() {
+            String message = super.getMessage();
+            int eol = message.indexOf('\n');
+            return eol < 0
+                    ? message + " (" + hint + ")"
+                    : message.substring(0, eol) + " (" + hint + ")" + 
message.substring(eol);
+        }
+    }
+
     /** The first header that holds JSON text (a String or byte[] starting 
with { or [), or null. */
     private static String jsonTextHeader(Exchange exchange) {
         for (Map.Entry<String, Object> header : 
exchange.getMessage().getHeaders().entrySet()) {
diff --git 
a/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingMethodHintTest.java
 
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingMethodHintTest.java
new file mode 100644
index 000000000000..5be27e4edead
--- /dev/null
+++ 
b/components/camel-groovy/src/test/java/org/apache/camel/processor/groovy/GroovyMissingMethodHintTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.processor.groovy;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import groovy.lang.MissingMethodException;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * new JsonSlurper().parseText(body) on a body of bytes gets a 
MissingMethodException whose first line says that
+ * parseText takes text, and how to read the bytes (r26 of the local-model 
benchmark: a JMS message read as JSON).
+ */
+public class GroovyMissingMethodHintTest extends CamelTestSupport {
+
+    private static final byte[] ORDER = "{\"orderId\": \"ORD-1001\", 
\"lines\": [{\"sku\": \"A1\"}]}"
+            .getBytes(StandardCharsets.UTF_8);
+
+    @Test
+    public void parseTextOfBytesSaysToParseTheBytes() {
+        Exception e = assertThrows(Exception.class, () -> 
template.sendBody("direct:body", ORDER));
+        MissingMethodException cause = 
assertInstanceOf(MissingMethodException.class, e.getCause());
+        // the hint is on the first line, which is the line a log shows
+        String first = cause.getMessage().lines().findFirst().orElseThrow();
+        assertTrue(first.startsWith("No signature of method: parseText for 
class: groovy.json.JsonSlurper"), first);
+        assertTrue(first.contains("(the body is bytes (a byte[])"), first);
+        assertTrue(first.contains("parseText takes text, so parse the bytes 
with new groovy.json.JsonSlurper().parse(body),"
+                                  + " or unmarshal it first (unmarshal: json"),
+                first);
+    }
+
+    @Test
+    public void parseTextOfBytesInAHeaderSaysToParseTheBytes() {
+        Exception e = assertThrows(Exception.class,
+                () -> template.sendBodyAndHeader("direct:header", "x", 
"stock", ORDER));
+        MissingMethodException cause = 
assertInstanceOf(MissingMethodException.class, e.getCause());
+        assertTrue(cause.getMessage().contains("(the value is bytes (a 
byte[]), and parseText takes text: parse the bytes"
+                                               + " with new 
groovy.json.JsonSlurper().parse(...))"),
+                cause.getMessage());
+        assertFalse(cause.getMessage().contains("the body is"), 
cause.getMessage());
+    }
+
+    @Test
+    public void anotherMissingMethodHasNoHint() {
+        Exception e = assertThrows(Exception.class, () -> 
template.sendBody("direct:other", "x"));
+        MissingMethodException cause = 
assertInstanceOf(MissingMethodException.class, e.getCause());
+        assertFalse(cause.getMessage().contains("parseText takes text"), 
cause.getMessage());
+    }
+
+    @Test
+    public void parseOfBytesWorksAsTheHintSays() {
+        Object out = template.requestBody("direct:parse", ORDER);
+        assertEquals(List.of(Map.of("sku", "A1")), out);
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:body").transform().groovy("new 
groovy.json.JsonSlurper().parseText(body).lines");
+                from("direct:header").transform().groovy("new 
groovy.json.JsonSlurper().parseText(headers.stock)");
+                from("direct:other").transform().groovy("body.noSuchMethod(1, 
2)");
+                from("direct:parse").transform().groovy("new 
groovy.json.JsonSlurper().parse(body).lines");
+            }
+        };
+    }
+}
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
index a6be4d341287..08e5db23e289 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
@@ -81,6 +81,16 @@ public final class SimpleSyntaxHints {
             Map.entry("avg", "average"),
             Map.entry("count", "size"));
 
+    /** The functions of the file language, as written after file:: name.noext 
is ${file:name.noext}. */
+    static final List<String> FILE_FUNCTIONS = List.of("name", "name.noext", 
"name.noext.single", "name.ext",
+            "name.ext.single", "ext", "onlyname", "onlyname.noext", 
"onlyname.noext.single", "parent", "path", "absolute",
+            "absolute.path", "length", "size", "modified");
+
+    /** The file functions a hint lists, after "the" or "The". */
+    private static final String FILE_FUNCTIONS_TEXT = "file: functions 
describe the file being consumed: ${file:name},"
+                                                      + " ${file:name.noext}, 
${file:onlyname}, ${file:onlyname.noext},"
+                                                      + " ${file:ext}, 
${file:parent}, ${file:size} and ${file:modified}";
+
     private static final String[] OPERATOR_WORDS = {
             "==", "!=", ">=", "<=", ">", "<", "=~", "!=~", "~~", "!~~", 
"contains",
             "!contains", "regex", "!regex", "in", "!in", "is", "!is", "range", 
"!range", "startsWith", "!startsWith",
@@ -472,6 +482,61 @@ public final class SimpleSyntaxHints {
         return "the functions are documented on the simple language page 
(functions)";
     }
 
+    /**
+     * The hint for a file: function the file language does not have: the 
function meant, such as ${file:name.noext} for
+     * ${file:noext} or ${file:name:noext}, and the usual ones. A path, such 
as ${file:data/stock.json}, is a file to
+     * read, which the file: functions do not do.
+     *
+     * @param remainder the text after file:, such as noext
+     */
+    public static String unknownFileFunction(String remainder) {
+        String text = remainder != null ? remainder.strip() : "";
+        // name:noext, name_noext, Name.NoExt: the separators and the case of 
a function name written another way
+        String normalized = text.toLowerCase(Locale.ROOT).replaceAll("[:_\\- 
]+", ".").replaceAll("\\.{2,}", ".")
+                .replaceAll("^\\.|\\.$", "");
+        if ("noext".equals(normalized)) {
+            // the name without the extension: ${file:name.noext} has the 
folders below the starting directory too
+            return "did you mean ${file:name.noext}, the file name without its 
extension? ${file:onlyname.noext} also"
+                   + " leaves out its folders. The " + FILE_FUNCTIONS_TEXT;
+        }
+        String meant = closestFileFunction(normalized);
+        if (meant != null) {
+            return "did you mean ${file:" + meant + "}? The " + 
FILE_FUNCTIONS_TEXT;
+        }
+        if (text.contains("/") || text.contains("\\") || 
text.matches(".+\\.[A-Za-z0-9]{1,5}")) {
+            return "the " + FILE_FUNCTIONS_TEXT + "; they do not read a file. 
To read a file into the body use the poll"
+                   + " EIP with a file: endpoint";
+        }
+        return "the " + FILE_FUNCTIONS_TEXT;
+    }
+
+    /** The file function a name written another way means: nameNoExt, 
absolutepath, a letter or two off; or null. */
+    private static String closestFileFunction(String normalized) {
+        if (normalized.isEmpty()) {
+            return null;
+        }
+        if (FILE_FUNCTIONS.contains(normalized)) {
+            return normalized;
+        }
+        String compact = normalized.replace(".", "");
+        for (String f : FILE_FUNCTIONS) {
+            if (f.replace(".", "").equals(compact)) {
+                return f;
+            }
+        }
+        String best = null;
+        int bestDistance = Integer.MAX_VALUE;
+        int threshold = Math.max(2, normalized.length() / 3);
+        for (String f : FILE_FUNCTIONS) {
+            int d = distance(normalized, f);
+            if (d <= threshold && d < bestDistance) {
+                best = f;
+                bestDistance = d;
+            }
+        }
+        return best;
+    }
+
     /** The did-you-mean for a function whose argument was written after a 
colon instead of in parentheses. */
     private static String parentheses(String name, String argument) {
         if ("semantic".equals(name)) {
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
index 7749231374d4..42252da53cb8 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
@@ -286,11 +286,9 @@ public class SimpleFunctionExpression extends 
LiteralExpression {
         } else if (ObjectHelper.equal(remainder, "modified")) {
             return FileExpressionBuilder.fileLastModifiedExpression();
         }
+        // the function meant (${file:noext} is ${file:name.noext}), or that 
the file: functions do not read a file
         throw new SimpleParserException(
-                "Unknown file language syntax: " + remainder + " (the file: 
functions describe the file being consumed:"
-                                        + " ${file:name}, ${file:size}, 
${file:parent}, ${file:absolute.path};"
-                                        + " they do not read a file. To read a 
file into the body use the poll"
-                                        + " EIP with a file: endpoint)",
+                "Unknown file language syntax: " + remainder + " (" + 
SimpleSyntaxHints.unknownFileFunction(remainder) + ")",
                 token.getIndex());
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
index 0c353aa0bbad..359e10a9ffb1 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java
@@ -454,6 +454,28 @@ public class SimpleSyntaxHintsTest extends 
ExchangeTestSupport {
                 .contains("poll EIP");
     }
 
+    @Test
+    public void testFileFunctionWrittenAnotherWaySaysDidYouMean() {
+        // r26 of the local-model benchmark (connect-sftp-transfer step 3): 
the receipt named like the batch, but .txt
+        assertThat(expressionError("${file:noext}.txt"))
+                .contains("Unknown file language syntax: noext (did you mean 
${file:name.noext}, the file name without"
+                          + " its extension? ${file:onlyname.noext} also 
leaves out its folders. The file: functions"
+                          + " describe the file being consumed: ${file:name}, 
${file:name.noext}, ${file:onlyname},")
+                .doesNotContain("they do not read a file");
+        assertThat(expressionError("${file:name:noext}.txt"))
+                .contains("Unknown file language syntax: name:noext (did you 
mean ${file:name.noext}? The file:"
+                          + " functions describe");
+        // the case, the separators and a letter off
+        assertThat(expressionError("${file:onlyName}")).contains("(did you 
mean ${file:onlyname}?");
+        assertThat(expressionError("${file:nameNoExt}")).contains("(did you 
mean ${file:name.noext}?");
+        assertThat(expressionError("${file:absolute_path}")).contains("(did 
you mean ${file:absolute.path}?");
+        assertThat(expressionError("${file:nmae}")).contains("(did you mean 
${file:name}?");
+        // a name that is no function: the functions there are
+        assertThat(expressionError("${file:cheese}"))
+                .contains("Unknown file language syntax: cheese (the file: 
functions describe the file being consumed:")
+                .doesNotContain("did you mean").doesNotContain("they do not 
read a file");
+    }
+
     @Test
     public void testHelpers() {
         assertThat(SimpleSyntaxHints.wordAt("${body} == x", 
12)).isEqualTo("x");
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 a6c5940ed218..440aeb9e49b3 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
@@ -1539,7 +1539,8 @@ public final class AuthoringTools {
     /**
      * What a file does that works but is not right, which does not refuse a 
write: a camel: dependency a Kamelet's
      * template does not use (CAMEL-25403), a {expression} in the text of an 
XSLT element, a JMS reply header set by
-     * hand in a route that sends nothing InOut, a to: that copies each file 
its file consumer then moves to .camel.
+     * hand in a route that sends nothing InOut, a to: that copies each file 
its file consumer then moves to .camel, a
+     * request-reply over JMS whose reply replaces the message that was sent.
      */
     private static void putFileNotes(JsonObject result, String file, String 
content) {
         String name = file != null ? file.toLowerCase(Locale.ROOT) : "";
@@ -1555,9 +1556,11 @@ public final class AuthoringTools {
                 result.put("notes", new JsonArray(notes));
             }
         } else if ((name.endsWith(".yaml") || name.endsWith(".yml")) && 
!SourceValidator.isCitrusTest(name)) {
-            // a JMS reply header set by hand, with nothing sent InOut; a to: 
that copies the file a consumer moves
+            // a JMS reply header set by hand, with nothing sent InOut; a to: 
that copies the file a consumer moves; a
+            // JMS reply that replaces the message that was sent
             List<String> notes = new 
ArrayList<>(HeaderChecks.replyHeaderNotes(content));
             notes.addAll(WriteNotes.fileCopyNotes(content));
+            notes.addAll(WriteNotes.requestReplyNotes(content));
             if (!notes.isEmpty()) {
                 result.put("notes", new JsonArray(notes));
             }
@@ -1844,19 +1847,21 @@ public final class AuthoringTools {
         UnconsumedDirect waiting = null;
         boolean replyByHand = false;
         boolean copies = false;
-        List<String> unusedProperties = List.of();
+        boolean replyReplaces = false;
+        List<WriteNotes.UnusedProperty> unusedProperties = List.of();
         if (validate && !citrusTest) {
             putFileNotes(result, file, content);
             String lower = file.toLowerCase(Locale.ROOT);
             boolean yaml = lower.endsWith(".yaml") || lower.endsWith(".yml");
+            boolean kamelet = lower.endsWith(".kamelet.yaml") || 
lower.endsWith(".kamelet.yml");
             replyByHand = yaml && 
!HeaderChecks.replyHeaderNotes(content).isEmpty();
-            copies = yaml && !lower.endsWith(".kamelet.yaml") && 
!lower.endsWith(".kamelet.yml")
-                    && !WriteNotes.fileCopyNotes(content).isEmpty();
+            copies = yaml && !kamelet && 
!WriteNotes.fileCopyNotes(content).isEmpty();
+            replyReplaces = yaml && !kamelet && 
!WriteNotes.requestReplyNotes(content).isEmpty();
             // a literal written over a {{key}} that leaves the key of 
application.properties unused
-            unusedProperties = WriteNotes.unusedPropertyNotes(dir, file, 
before, content);
+            unusedProperties = WriteNotes.unusedProperties(dir, file, before, 
content);
             if (!unusedProperties.isEmpty()) {
                 JsonArray notes = result.get("notes") instanceof JsonArray 
existing ? existing : new JsonArray();
-                notes.addAll(unusedProperties);
+                unusedProperties.forEach(u -> notes.add(u.note()));
                 result.put("notes", notes);
             }
             waiting = unconsumedDirect(dir, file, content, ctx.catalog());
@@ -1918,18 +1923,22 @@ public final class AuthoringTools {
             result.put("message", result.getString("message") + " Note: a to: 
writes a copy of each file, while the file"
                                   + " consumer still moves the file to .camel 
(see notes).");
         }
-        if (!unusedProperties.isEmpty()) {
-            // the first sentence of each note: "report.schedule in 
application.properties is no longer used by any route"
-            result.put("message", result.getString("message") + " Note: "
-                                  + String.join("; ", unusedProperties.stream()
-                                          .map(n -> n.indexOf(". ") > 0 ? 
n.substring(0, n.indexOf(". ")) : n).toList())
-                                  + " (see notes).");
+        if (replyReplaces) {
+            result.put("message", result.getString("message") + " Note: after 
a request-reply (InOut) the body is the"
+                                  + " reply, not the message that was sent; 
variableReceive on the to: keeps the"
+                                  + " message (see notes).");
         }
         if (!repaired.isEmpty()) {
             result.put("repaired", new JsonArray(repaired));
             result.put("message", "Written with these repairs of the YAML (the 
file has them now): "
                                   + String.join("; ", repaired) + ". " + 
result.getString("message"));
         }
+        if (!unusedProperties.isEmpty()) {
+            // what to do next, first in the answer: "Use {{report.schedule}} 
again on line 7, and set
+            // report.schedule=0 0 2 * * ? in application.properties"
+            result.put("message", String.join(" ", 
unusedProperties.stream().map(WriteNotes.UnusedProperty::instruction)
+                    .toList()) + " " + result.getString("message"));
+        }
         return result;
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index 6924f9fe6cbe..25f230d4e7ce 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -16,6 +16,9 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.ai;
 
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
@@ -29,8 +32,10 @@ import java.util.regex.Pattern;
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.catalog.EndpointValidationResult;
+import org.apache.camel.catalog.LanguageValidationResult;
 import org.apache.camel.catalog.RuntimeProvider;
 import org.apache.camel.util.StringHelper;
+import org.apache.camel.util.URISupport;
 
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.YAML_URI_PATTERN;
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.countLeadingSpaces;
@@ -275,6 +280,7 @@ final class EndpointChecks {
                 collectEndpointErrors(errors, result, scheme, catalog, 
lineIdx, optionLineMap);
             }
             checkRegexOptions(errors, fullUri, lineIdx, optionLineMap);
+            checkFileLanguageOptions(errors, catalog, fullUri, rawLine, 
lineIdx, optionLineMap);
             checkDynamicDirectory(errors, fullUri, lineIdx, eipName);
             checkSimplePlaceholders(errors, fullUri, lineIdx, optionLineMap, 
eipName, uriForm);
             checkRequiredPathOptions(errors, fullUri, catalog, lineIdx, 
eipName, uriForm);
@@ -359,6 +365,87 @@ final class EndpointChecks {
 
     static final Set<String> FILE_SCHEMES = Set.of("file", "ftp", "ftps", 
"sftp", "file-watch", "smb");
 
+    /** The options of the file components that are an expression of the file 
language when they have ${...}. */
+    static final Set<String> FILE_LANGUAGE_OPTIONS = Set.of(
+            "fileName", "move", "moveFailed", "preMove", "moveExisting", 
"tempFileName", "idempotentKey");
+
+    /** The options of the file components that are a predicate of the file 
language. */
+    static final Set<String> FILE_LANGUAGE_PREDICATES = 
Set.of("filterDirectory", "filterFile");
+
+    /**
+     * fileName: "${file:noext}.txt" on a to: sftp: the file components read 
these options as the file language (Simple
+     * with the file: functions) when the value has ${...}, and a mistake in 
it fails only when the route starts. r26 of
+     * the local-model benchmark (connect-sftp-transfer step 3): 2 runs wrote 
such a fileName, which the write accepted,
+     * and the route then failed to reload. Checked here as the simple: values 
of a route are. A value the runtime
+     * accepts is never refused: the options are read as Camel reads them, 
with RAW(...), and a value that is right
+     * either as written or URL-decoded passes.
+     */
+    static void checkFileLanguageOptions(
+            List<String> errors, CamelCatalog catalog, String fullUri, String 
rawLine, int uriLineIdx,
+            Map<String, Integer> optionLineMap) {
+        int colon = fullUri.indexOf(':');
+        int q = fullUri.indexOf('?');
+        if (colon < 0 || q < 0 || !FILE_SCHEMES.contains(fullUri.substring(0, 
colon))) {
+            return;
+        }
+        // the uri pattern of a YAML line stops at the first space: a uri with 
a space is read from the line itself
+        String uri = fullUri;
+        String text = rawLine != null ? rawLine.strip() : "";
+        text = text.startsWith("- ") ? text.substring(2) : text;
+        int key = text.indexOf(':');
+        if (key > 0) {
+            String value = 
YamlLines.unquote(YamlLines.stripComment(text.substring(key + 1).strip()));
+            uri = value.length() > fullUri.length() && 
value.startsWith(fullUri) ? value : fullUri;
+        }
+        // RAW(${file:name} contains 'R&D') is one value, with the & in it
+        Map<String, Object> options;
+        try {
+            options = URISupport.parseQuery(uri.substring(q + 1), true, true);
+            URISupport.resolveRawParameterValues(options);
+        } catch (URISyntaxException | RuntimeException e) {
+            return;
+        }
+        for (Map.Entry<String, Object> option : options.entrySet()) {
+            String name = option.getKey();
+            boolean predicate = FILE_LANGUAGE_PREDICATES.contains(name);
+            // a backslash is a path separator to the file language, not an 
escape as in simple; a property key in
+            // Simple's syntax (${dir.name}) is reported by 
checkSimplePlaceholders; a placeholder as an operand of && or
+            // || is a predicate when the endpoint is created (${file:size} > 
1000 || {{extra.filter}})
+            if (!predicate && !FILE_LANGUAGE_OPTIONS.contains(name) || 
!(option.getValue() instanceof String value)
+                    || !value.contains("${") || value.contains("\\") || 
YamlLines.isPropertyKeyInSimpleSyntax(value)
+                    || SimpleChecks.hasPlaceholderAsLogicalOperand(value)) {
+                continue;
+            }
+            String error = simpleError(catalog, predicate, value);
+            if (error != null && (value.contains("+") || value.contains("%"))
+                    && simpleError(catalog, predicate, urlDecoded(value)) == 
null) {
+                // Camel decodes the value of a uri (a + is a space), and not 
the one under parameters:
+                error = null;
+            }
+            if (error != null && !SimpleChecks.isMissingDependency(error) && 
!SimpleChecks.isOfTheApplication(error)) {
+                errors.add(linePrefix(optionLineMap.getOrDefault(name, 
uriLineIdx)) + fullUri.substring(0, colon) + ": "
+                           + name + "=" + value + " has a Simple syntax error: 
" + error);
+            }
+        }
+    }
+
+    /** The error of a Simple expression or predicate, or null when it has 
none. */
+    private static String simpleError(CamelCatalog catalog, boolean predicate, 
String text) {
+        LanguageValidationResult result = predicate
+                ? catalog.validateLanguagePredicate(null, "simple", text)
+                : catalog.validateLanguageExpression(null, "simple", text);
+        return result.isSuccess() ? null : result.getShortError() != null ? 
result.getShortError() : result.getError();
+    }
+
+    /** The value of a uri option as Camel reads it: a + is a space, %20 too; 
as written when it is not encoded. */
+    private static String urlDecoded(String value) {
+        try {
+            return URLDecoder.decode(value, StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException e) {
+            return value;
+        }
+    }
+
     /**
      * A doubled backslash before a character that a single backslash would 
escape in a regex (\\. \\d \\( ...): the
      * user meant the escape. A doubled backslash before any other character 
(\\myfile) is left alone: \myfile is not a
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
index c5dd5bf46279..9b49c39627c3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixups.java
@@ -82,7 +82,10 @@ final class HeaderPropertyMixups {
      * model wrote setProperty and ${property.orderId}, followed the hint to 
${exchangeProperty.orderId}, and failed (3
      * runs of 8, and in r24 and r23 too). A value the route sets with 
setVariable is read with ${variable.x}; for one
      * it sets with setProperty, ${exchangeProperty.x} is right, and a 
variable is offered as what Camel uses for a
-     * value of the route. The did-you-mean stays the first suggestion, as the 
quick fix reads it.
+     * value of the route. A value a script sets as an exchange property is 
read with ${exchangeProperty.x}, also when
+     * the route has a variable of that name: r26 connect-sftp-transfer step 2 
set the variable count to 0 first, and
+     * the script set the exchange property count; the variable was the 0. The 
did-you-mean stays the first suggestion,
+     * as the quick fix reads it.
      */
     static List<String> withWhereKept(List<String> errors, String content) {
         if (errors.stream().noneMatch(e -> e.contains("Unknown function: 
prop"))) {
@@ -106,7 +109,12 @@ final class HeaderPropertyMixups {
             Set<Kind> kept = keptIn(doc, name);
             String unknown = m.group().substring(0, m.group().indexOf(" (did 
you mean"));
             String hint;
-            if (kept.contains(Kind.VARIABLE)) {
+            if (setInScript(content, name)) {
+                hint = m.group() + ", which reads the exchange property that 
the script sets (exchange.setProperty); or"
+                       + " keep " + name + " in a variable instead, which is 
what Camel uses for a value of the route:"
+                       + " set it in the script with exchange.setVariable('" + 
name + "', ...) and read it with"
+                       + " ${variable." + name + "}";
+            } else if (kept.contains(Kind.VARIABLE)) {
                 hint = unknown + " (did you mean ${variable." + name + "}?): 
the route keeps " + name + " in a variable"
                        + " (setVariable)"
                        + (kept.contains(Kind.PROPERTY)
@@ -131,6 +139,20 @@ final class HeaderPropertyMixups {
         return answer;
     }
 
+    /**
+     * Whether a script of the route sets the exchange property: 
exchange.setProperty('x', ...),
+     * exchange.properties['x'] = ..., exchange.properties.x = ... or 
exchange.properties.put('x', ...).
+     */
+    private static boolean setInScript(String content, String name) {
+        if (content == null || !content.contains("exchange.")) {
+            return false;
+        }
+        String quoted = "\\s*['\"]" + Pattern.quote(name) + "['\"]";
+        return Pattern.compile("exchange\\.(?:setProperty\\(" + quoted + 
"|properties(?:\\[" + quoted + "\\s*]|\\."
+                               + Pattern.quote(name) + 
")\\s*=(?!=)|properties\\.put\\(" + quoted + ")")
+                .matcher(content).find();
+    }
+
     /**
      * Where the route keeps the name: the set steps that name it, also as 
written by mistake (propertyName: x, or x as
      * the key), the items of setHeaders and setVariables, and a 
variableReceive.
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotes.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotes.java
index bbf5ee7e7fbb..ff967339a61f 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotes.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotes.java
@@ -38,8 +38,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
 
 /**
  * What a written route does that works but is probably not what was asked, 
said as a note of the write: the route can
- * be right, so the write is not refused. A to: that copies each file its file 
consumer then moves to .camel, and a
- * literal written over a {{key}} placeholder that leaves the key in 
application.properties unused.
+ * be right, so the write is not refused. A to: that copies each file its file 
consumer then moves to .camel, a
+ * request-reply over JMS whose reply replaces the message that was sent, and 
a literal written over a {{key}}
+ * placeholder that leaves the key in application.properties unused.
  */
 final class WriteNotes {
 
@@ -55,6 +56,20 @@ final class WriteNotes {
             "log", "setHeader", "setHeaders", "setProperty", "setVariable", 
"setVariables", "removeHeader",
             "removeHeaders", "removeProperty", "removeProperties", 
"removeVariable", "delay");
 
+    /**
+     * The components of a message broker over JMS: a to: with InOut on them 
sends a request and waits for the reply.
+     */
+    private static final Set<String> JMS_SCHEMES = Set.of("jms", "sjms", 
"sjms2", "activemq", "activemq6", "amqp");
+
+    /** The steps that set the body themselves: after them the route has 
chosen what the body is. */
+    private static final Set<String> BODY_STEPS = Set.of("setBody", 
"transform");
+
+    /** The steps that run only when something fails: a body they set is not 
the body of the main path. */
+    private static final Set<String> EXCEPTION_PATHS = Set.of("doCatch", 
"onException", "onFallback");
+
+    /** A destination name that can be the start of a variable name: stock 
gives stockReply. */
+    private static final Pattern WORD = 
Pattern.compile("[A-Za-z][A-Za-z0-9]*");
+
     /** A {{key}} property placeholder, with an optional ? in front and a 
default value after a colon. */
     private static final Pattern PLACEHOLDER = 
Pattern.compile("\\{\\{\\??([\\w.-]+)(?::[^}]*)?}}");
 
@@ -258,6 +273,230 @@ final class WriteNotes {
         return text.contains("{{") || text.contains("${");
     }
 
+    /**
+     * A request-reply over JMS whose reply replaces the body: a to: on a JMS 
endpoint with InOut (exchangePattern=InOut
+     * on the endpoint, pattern: InOut on the to:, or a setExchangePattern 
before it) and no variableReceive. After it
+     * the body is the reply, and the message that was sent is gone. r23 to 
r26 of the local-model benchmark,
+     * connect-service-jms-request-reply step 2 ("keep the order as the 
body"): every failed run logged the reply right
+     * and lost the order, and no answer said that the body is now the reply. 
A route that sets the body itself after
+     * the reply, on the main path, has chosen what the body is, and gets no 
note. Each note is "Line N: ..." for the
+     * line of the to:.
+     */
+    static List<String> requestReplyNotes(String content) {
+        List<String> notes = new ArrayList<>();
+        if (content == null || !content.contains("InOut")) {
+            return notes;
+        }
+        Object doc;
+        try {
+            doc = new Yaml(new SafeConstructor(new 
LoaderOptions())).load(content);
+        } catch (Exception e) {
+            return notes;
+        }
+        if (!(doc instanceof List<?> items)) {
+            return notes;
+        }
+        try {
+            // the pointer of each such to:, with the destination it sends to
+            Map<String, String> replies = new LinkedHashMap<>();
+            for (int i = 0; i < items.size(); i++) {
+                if (items.get(i) instanceof Map<?, ?> m && m.get("route") 
instanceof Map<?, ?> route
+                        && route.get("from") instanceof Map<?, ?> from && 
from.get("steps") instanceof List<?> steps) {
+                    requestReplies(steps, "/" + i + "/route/from/steps", 
false, false, replies);
+                } else if (items.get(i) instanceof Map<?, ?> m && 
m.get("from") instanceof Map<?, ?> from
+                        && from.get("steps") instanceof List<?> steps) {
+                    requestReplies(steps, "/" + i + "/from/steps", false, 
false, replies);
+                }
+            }
+            Node root = replies.isEmpty() ? null : 
YamlPointerLines.root(content);
+            for (Map.Entry<String, String> e : replies.entrySet()) {
+                int line = YamlPointerLines.line(root, e.getKey(), null);
+                String destination = e.getValue();
+                String variable = WORD.matcher(destination).matches() ? 
destination + "Reply" : "reply";
+                notes.add((line > 0 ? "Line " + line + ": " : "") + "after 
this request-reply (InOut) the body is the"
+                          + " reply" + (destination.isEmpty() ? "" : " from " 
+ destination) + ", and the message that"
+                          + " was sent is gone. To keep the message as the 
body, add variableReceive: " + variable
+                          + " to this to: and read the reply as ${variable." + 
variable + "}.");
+            }
+        } catch (RuntimeException e) {
+            // a shape the walk does not know: a note must never fail the write
+        }
+        return notes;
+    }
+
+    /**
+     * Adds the pointer and the destination of each to: or toD: of the steps 
that is a request-reply over JMS whose
+     * reply replaces the body. inOut is whether a setExchangePattern before 
the steps made the exchange InOut, and
+     * bodySetAfter whether a step after them on the main path sets the body 
(a step after the EIP that holds them, in a
+     * list above). The steps of the EIPs among them (filter, split, the when: 
of a choice, doTry) are walked too.
+     */
+    private static void requestReplies(
+            List<?> steps, String at, boolean inOut, boolean bodySetAfter, 
Map<String, String> replies) {
+        boolean exchangeInOut = inOut;
+        for (int j = 0; j < steps.size(); j++) {
+            if (!(steps.get(j) instanceof Map<?, ?> step) || step.size() != 1) 
{
+                continue;
+            }
+            String eip = String.valueOf(step.keySet().iterator().next());
+            Object value = step.values().iterator().next();
+            // a step after this one sets the body: also a setBody in one 
branch of a choice, as the note only shows
+            // when it is clearly right
+            boolean bodySet = bodySetAfter || setsBody(steps.subList(j + 1, 
steps.size()));
+            if ("setExchangePattern".equals(eip)) {
+                // setExchangePattern: InOut, or with pattern: InOut; InOnly 
turns it off again
+                exchangeInOut = isInOut(value instanceof Map<?, ?> m ? 
m.get("pattern") : value);
+            } else if ("to".equals(eip) || "toD".equals(eip)) {
+                String destination = jmsRequestReply(value, exchangeInOut);
+                if (destination != null && !bodySet) {
+                    replies.put(at + "/" + j, destination);
+                }
+            } else {
+                nestedSteps(value, at + "/" + j + "/" + eip, exchangeInOut, 
bodySet, replies);
+            }
+        }
+    }
+
+    /**
+     * Walks the steps: lists inside an EIP, such as the when: items of a 
choice. The doFinally of a doTry runs after
+     * its steps and its doCatch, as the steps after the doTry do.
+     */
+    private static void nestedSteps(
+            Object node, String at, boolean inOut, boolean bodySetAfter, 
Map<String, String> replies) {
+        if (node instanceof Map<?, ?> m) {
+            boolean bodySet = bodySetAfter || setsBody(m.get("doFinally"));
+            for (Map.Entry<?, ?> e : m.entrySet()) {
+                String key = String.valueOf(e.getKey());
+                boolean after = "doFinally".equals(key) ? bodySetAfter : 
bodySet;
+                if ("steps".equals(key) && e.getValue() instanceof List<?> 
steps) {
+                    requestReplies(steps, at + "/steps", inOut, after, 
replies);
+                } else {
+                    nestedSteps(e.getValue(), at + "/" + key, inOut, after, 
replies);
+                }
+            }
+        } else if (node instanceof List<?> list) {
+            for (int i = 0; i < list.size(); i++) {
+                nestedSteps(list.get(i), at + "/" + i, inOut, bodySetAfter, 
replies);
+            }
+        }
+    }
+
+    /**
+     * The destination of a to: on a JMS endpoint that sends InOut and has no 
variableReceive, such as stock for
+     * jms:queue:stock (the empty text when it cannot be told); null for any 
other to:. The pattern is the one the
+     * runtime sends with: exchangePattern of the endpoint wins over pattern: 
of the to: (as in SendProcessor), then
+     * comes the pattern of the exchange; and disableReplyTo sends InOnly 
whatever the pattern.
+     */
+    private static String jmsRequestReply(Object endpoint, boolean inOut) {
+        String uri;
+        Map<?, ?> parameters = null;
+        Object pattern = null;
+        if (endpoint instanceof String s) {
+            uri = s;
+        } else if (endpoint instanceof Map<?, ?> m && m.get("uri") instanceof 
String s) {
+            if (m.get("variableReceive") != null) {
+                return null;
+            }
+            uri = s;
+            parameters = m.get("parameters") instanceof Map<?, ?> p ? p : null;
+            pattern = m.get("pattern");
+        } else {
+            return null;
+        }
+        uri = uri.strip();
+        int colon = uri.indexOf(':');
+        if (!JMS_SCHEMES.contains(colon > 0 ? uri.substring(0, colon) : uri)) {
+            return null;
+        }
+        String path = colon > 0 ? uri.substring(colon + 1) : "";
+        // the options of the endpoint, in the uri or under parameters:
+        Map<String, Object> options = new LinkedHashMap<>();
+        int question = path.indexOf('?');
+        if (question >= 0) {
+            for (String pair : path.substring(question + 1).split("&")) {
+                int eq = pair.indexOf('=');
+                if (eq > 0) {
+                    options.put(pair.substring(0, eq), pair.substring(eq + 1));
+                }
+            }
+            path = path.substring(0, question);
+        }
+        if (parameters != null) {
+            parameters.forEach((k, v) -> options.put(String.valueOf(k), v));
+        }
+        Object exchangePattern = options.get("exchangePattern");
+        boolean sendsInOut = exchangePattern != null ? 
isInOut(exchangePattern) : pattern != null ? isInOut(pattern) : inOut;
+        Object disableReplyTo = options.get("disableReplyTo");
+        if (!sendsInOut || disableReplyTo != null && 
!"false".equals(String.valueOf(disableReplyTo).strip())) {
+            return null;
+        }
+        String destination = path.startsWith("//") ? path.substring(2) : path;
+        for (String type : List.of("queue:", "topic:", "temp-queue:", 
"temp-topic:")) {
+            if (destination.startsWith(type)) {
+                destination = destination.substring(type.length());
+            }
+        }
+        if (destination.isBlank() && parameters != null && 
parameters.get("destinationName") != null) {
+            destination = String.valueOf(parameters.get("destinationName"));
+        }
+        return destination.strip();
+    }
+
+    private static boolean isInOut(Object pattern) {
+        return pattern != null && 
"InOut".equals(String.valueOf(pattern).strip());
+    }
+
+    /**
+     * Whether a step of these, or of the EIPs among them, sets the body 
itself (setBody, transform); not a step that
+     * runs only when something fails (doCatch).
+     */
+    private static boolean setsBody(Object node) {
+        if (node instanceof Map<?, ?> m) {
+            for (Map.Entry<?, ?> e : m.entrySet()) {
+                String key = String.valueOf(e.getKey());
+                if (BODY_STEPS.contains(key) || !EXCEPTION_PATHS.contains(key) 
&& setsBody(e.getValue())) {
+                    return true;
+                }
+            }
+        } else if (node instanceof List<?> list) {
+            for (Object o : list) {
+                if (setsBody(o)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * A key of application.properties that a literal written over its {{key}} 
left unused: the option it was the value
+     * of, the line of the route that has the literal now, and the literal 
(null when it cannot be told).
+     */
+    record UnusedProperty(String key, String properties, String option, int 
line, String value) {
+
+        /** The note of the write: what is unused, and how the value is 
changed instead. */
+        String note() {
+            return key + " in " + properties + " is no longer used by any 
route. To change the " + option + ", change "
+                   + key + " in " + properties + " and keep {{" + key + "}} in 
the route.";
+        }
+
+        /**
+         * What to do next, for the start of the answer: as a note at its end, 
a model took it for information (r26 of
+         * the local-model benchmark, run-nightly-report step 4: the 3 failed 
runs ended the step with the note repeated
+         * in their own words).
+         */
+        String instruction() {
+            String where = line > 0 ? "line " + line : "the route";
+            return "Use {{" + key + "}} again on " + where + ", and set "
+                   + (value != null ? key + "=" + value : "the new value as " 
+ key) + " in " + properties
+                   + ": the property is now unused, as " + where + " has its 
own value.";
+        }
+    }
+
+    /** The notes of {@link #unusedProperties(Path, String, String, String)}. 
*/
+    static List<String> unusedPropertyNotes(Path dir, String file, String 
before, String after) {
+        return unusedProperties(dir, file, before, 
after).stream().map(UnusedProperty::note).toList();
+    }
+
     /**
      * A literal written over a {{key}} placeholder of the route that leaves 
the key of application.properties unused,
      * as the change of a value that the properties hold was meant (r24 and 
r25 of the local-model benchmark: asked to
@@ -265,15 +504,15 @@ final class WriteNotes {
      * replaced in its line (the text before it is still there), a key no 
other file of the project uses, and a key the
      * runtime does not read itself (camel.*, quarkus.*).
      */
-    static List<String> unusedPropertyNotes(Path dir, String file, String 
before, String after) {
-        List<String> notes = new ArrayList<>();
+    static List<UnusedProperty> unusedProperties(Path dir, String file, String 
before, String after) {
+        List<UnusedProperty> answer = new ArrayList<>();
         String name = file != null ? file.toLowerCase(Locale.ROOT) : "";
         if (dir == null || before == null || after == null || 
!before.contains("{{")
                 || !(name.endsWith(".yaml") || name.endsWith(".yml") || 
name.endsWith(".xml") || name.endsWith(".java"))) {
-            return notes;
+            return answer;
         }
-        // the keys the change removed, each with the text before its 
placeholder on its line
-        Map<String, String> removed = new LinkedHashMap<>();
+        // the keys the change removed, each with the text before and after 
its placeholder on its line
+        Map<String, String[]> removed = new LinkedHashMap<>();
         Set<String> lines = new HashSet<>();
         for (String line : before.split("\n", -1)) {
             lines.add(lead(line));
@@ -281,31 +520,58 @@ final class WriteNotes {
             while (m.find()) {
                 String key = m.group(1);
                 if (!uses(after, key)) {
-                    removed.putIfAbsent(key, lead(line.substring(0, 
m.start())));
+                    removed.putIfAbsent(key,
+                            new String[] { lead(line.substring(0, m.start())), 
line.substring(m.end()).strip() });
                 }
             }
         }
+        String[] afterLines = after.split("\n", -1);
+        // the changed lines that belong to a key: two keys with the same text 
before them (schedule: " in two routes)
+        // get the changed lines in their order
+        Set<Integer> taken = new HashSet<>();
         try {
-            for (Map.Entry<String, String> e : removed.entrySet()) {
+            for (Map.Entry<String, String[]> e : removed.entrySet()) {
                 String key = e.getKey();
-                String lead = e.getValue();
+                String lead = e.getValue()[0];
+                if (lead.isEmpty()) {
+                    continue;
+                }
                 // replaced in its line: a changed line still has the text 
before the placeholder (a route removed as a
                 // whole has none)
-                if (RUNTIME_KEYS.stream().anyMatch(key::startsWith) || 
lead.isEmpty()
-                        || after.lines().map(WriteNotes::lead).noneMatch(l -> 
l.startsWith(lead) && !lines.contains(l))) {
+                int changed = -1;
+                for (int i = 0; i < afterLines.length && changed < 0; i++) {
+                    String l = lead(afterLines[i]);
+                    if (l.startsWith(lead) && !lines.contains(l) && 
taken.add(i)) {
+                        changed = i;
+                    }
+                }
+                if (RUNTIME_KEYS.stream().anyMatch(key::startsWith) || changed 
< 0) {
                     continue;
                 }
                 String properties = definedIn(dir, key);
                 if (properties != null && !usedElsewhere(dir, key)) {
-                    notes.add(key + " in " + properties + " is no longer used 
by any route. To change the "
-                              + option(lead) + ", change " + key + " in " + 
properties + " and keep {{" + key
-                              + "}} in the route.");
+                    answer.add(new UnusedProperty(
+                            key, properties, option(lead), changed + 1,
+                            literal(lead(afterLines[changed]), lead, 
e.getValue()[1])));
                 }
             }
         } catch (RuntimeException e) {
             // the project cannot be listed: a note must never fail the write
         }
-        return notes;
+        return answer;
+    }
+
+    /**
+     * The literal written in place of a placeholder: the text of the changed 
line between the text that was before the
+     * placeholder and the text that was after it; null when the line changed 
elsewhere too.
+     */
+    private static String literal(String line, String lead, String tail) {
+        String rest = line.substring(lead.length());
+        if (!rest.endsWith(tail)) {
+            return null;
+        }
+        String value = rest.substring(0, rest.length() - 
tail.length()).strip();
+        return value.isEmpty() || value.contains("{{") ? null : value;
     }
 
     /** A line without its indentation and list dash: the same text wherever 
the line moved. */
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
index 5d96b6713188..80c1bc5978a9 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
@@ -211,7 +211,8 @@ final class YamlLines {
 
     /**
      * A scalar without the YAML comment that may follow it: {@code 30000 # 
the default is 300000} is 30000, and a
-     * quoted scalar keeps a # inside its quotes.
+     * quoted scalar keeps a # inside its quotes. A quoted scalar ends at its 
closing quote, as YAML reads it: '' inside
+     * single quotes and \" inside double quotes are a quote of the text.
      */
     static String stripComment(String val) {
         if (val.isEmpty()) {
@@ -219,8 +220,17 @@ final class YamlLines {
         }
         char quote = val.charAt(0);
         if (quote == '"' || quote == '\'') {
-            int close = val.indexOf(quote, 1);
-            return close > 0 ? val.substring(0, close + 1) : val;
+            for (int i = 1; i < val.length(); i++) {
+                char ch = val.charAt(i);
+                if (quote == '"' && ch == '\\') {
+                    i++;
+                } else if (ch == quote && quote == '\'' && i + 1 < 
val.length() && val.charAt(i + 1) == '\'') {
+                    i++;
+                } else if (ch == quote) {
+                    return val.substring(0, i + 1);
+                }
+            }
+            return val;
         }
         for (int i = 1; i < val.length(); i++) {
             if (val.charAt(i) == '#' && Character.isWhitespace(val.charAt(i - 
1))) {
@@ -238,7 +248,7 @@ final class YamlLines {
 
     /**
      * The value of a quoted scalar: inside double quotes YAML reads \\ as one 
backslash and \" as a quote (so
-     * ".*\\.pdf" is the regex .*\.pdf), inside single quotes a backslash is a 
backslash.
+     * ".*\\.pdf" is the regex .*\.pdf), inside single quotes a backslash is a 
backslash and '' is a quote.
      */
     static String unquote(String val) {
         if (val.length() >= 2 && val.startsWith("\"") && val.endsWith("\"")) {
@@ -255,7 +265,7 @@ final class YamlLines {
             }
         }
         if (val.length() >= 2 && val.startsWith("'") && val.endsWith("'")) {
-            return val.substring(1, val.length() - 1);
+            return val.substring(1, val.length() - 1).replace("''", "'");
         }
         return val;
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
index 8a5df0cecaea..9dd11e2a5d14 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
@@ -389,6 +389,46 @@ class AuthoringToolsEditTest {
                 .endsWith("Note: a JMS reply header is set by hand, and 
nothing is sent with pattern: InOut (see notes).");
     }
 
+    /**
+     * connect-service-jms-request-reply step 2 of the local-model benchmark 
(r23 to r26): asked to keep the order as
+     * the body, the model sent it with pattern: InOut and logged the reply, 
and the order was gone. The edit goes
+     * through, with a note that says how to keep the message.
+     */
+    @Test
+    void aRequestReplyWhoseReplyReplacesTheMessageIsANoteOfTheEdit(@TempDir 
Path dir) throws IOException {
+        Files.writeString(dir.resolve("jms-request-reply.camel.yaml"), """
+                - route:
+                    id: reserve-order
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - log:
+                            message: "Reserving ${header.orderId}"
+                """);
+
+        JsonObject result = AuthoringTools.editFile(new ToolContext(), dir, 
"jms-request-reply.camel.yaml",
+                "            message: \"Reserving ${header.orderId}\"",
+                "            message: \"Reserving ${header.orderId}\"\n        
- to:\n            uri: jms:queue:stock\n"
+                                                                        + "    
        pattern: InOut\n        - log:\n"
+                                                                        + "    
        message: \"Stock service answered for ${header.orderId}: ${body}\"");
+
+        
assertThat(result.getString("status")).as(result.toJson()).isEqualTo("edited");
+        assertThat(((JsonArray) 
result.get("notes"))).singleElement().isEqualTo(
+                "Line 8: after this request-reply (InOut) the body is the 
reply from stock, and the message that was sent"
+                                                                               
 + " is gone. To keep the message as the body, add variableReceive: stockReply"
+                                                                               
 + " to this to: and read the reply as ${variable.stockReply}.");
+        assertThat(result.getString("message"))
+                .endsWith("Note: after a request-reply (InOut) the body is the 
reply, not the message that was sent;"
+                          + " variableReceive on the to: keeps the message 
(see notes).");
+
+        // the reply in a variable, as the jms sample shows: no note
+        JsonObject kept = AuthoringTools.editFile(new ToolContext(), dir, 
"jms-request-reply.camel.yaml",
+                "            pattern: InOut", "            pattern: InOut\n    
        variableReceive: stockReply");
+        
assertThat(kept.getString("status")).as(kept.toJson()).isEqualTo("edited");
+        assertThat(kept.get("notes")).as(kept.toJson()).isNull();
+        assertThat(kept.getString("message")).doesNotContain("Note:");
+    }
+
     /**
      * connect-file-processing step 1 of the local-model benchmark (r24, r25): 
asked to move the processed files to
      * done, the model added a to: done, which copies each file while the 
consumer still moves it to .camel. The edit
@@ -459,8 +499,13 @@ class AuthoringToolsEditTest {
                 "report.schedule in application.properties is no longer used 
by any route. To change the schedule,"
                                                                                
 + " change report.schedule in application.properties and keep"
                                                                                
 + " {{report.schedule}} in the route.");
+        // r26: a note at the end of the answer was repeated by the model, 
which then ended the step; what to do comes
+        // first now
         assertThat(result.getString("message"))
-                .endsWith("Note: report.schedule in application.properties is 
no longer used by any route (see notes).");
+                .startsWith("Use {{report.schedule}} again on line 7, and set 
report.schedule=0 0 2 * * ? in"
+                            + " application.properties: the property is now 
unused, as line 7 has its own value. An"
+                            + " integration running the file")
+                .doesNotContain("Note:");
 
         // the right change is in application.properties: the route keeps its 
placeholder, and has no note
         JsonObject back = AuthoringTools.editFile(new ToolContext(), dir, 
"nightly-report.camel.yaml",
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
index a92445ca7a8c..2ba9804ead94 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderPropertyMixupsTest.java
@@ -103,6 +103,36 @@ class HeaderPropertyMixupsTest {
         assertThat(msgs).anyMatch(m -> m.contains("${header.sku} reads a 
header sku"));
     }
 
+    @Test
+    void aPropertyThatAScriptSetsIsNotPointedToTheVariable() {
+        // r26 connect-sftp-transfer step 2: the route set the variable 
orderId to 0 first, then a Groovy script set the
+        // exchange property; "did you mean ${variable.orderId}?" made the log 
read the 0
+        String script = """
+                - setVariable:
+                    name: orderId
+                    constant: "0"
+                - setBody:
+                    groovy: |
+                      exchange.setProperty('orderId', 'ORD-' + 
exchange.getProperty('CamelTimerCounter'))
+                      body
+                """;
+        String msg = propertyFunction(generator(script));
+        assertThat(msg).endsWith("Unknown function: property.orderId (did you 
mean ${exchangeProperty.orderId}?), which"
+                                 + " reads the exchange property that the 
script sets (exchange.setProperty); or keep"
+                                 + " orderId in a variable instead, which is 
what Camel uses for a value of the route:"
+                                 + " set it in the script with 
exchange.setVariable('orderId', ...) and read it with"
+                                 + " ${variable.orderId}");
+        assertThat(QuickFixes.fixFor(msg, "            message: \"New order 
${property.orderId}\"").newText())
+                .isEqualTo("${exchangeProperty.orderId}");
+        // the other ways a script sets it, and a script that only reads it
+        
assertThat(propertyFunction(generator(script.replace("exchange.setProperty('orderId',
 ",
+                "exchange.properties['orderId'] = (")))).contains("that the 
script sets");
+        
assertThat(propertyFunction(generator(script.replace("exchange.setProperty('orderId',
 ",
+                "exchange.properties.orderId = (")))).contains("that the 
script sets");
+        
assertThat(propertyFunction(generator(script.replace("exchange.setProperty('orderId',
 ",
+                "exchange.properties['orderId'] == (")))).contains("(did you 
mean ${variable.orderId}?)");
+    }
+
     /** The order generator with the set step given, and a log of 
${property.orderId}, which Camel does not have. */
     private static String generator(String set) {
         return """
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
index dee07617fbbc..9a700cb5f6b3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
@@ -425,6 +425,89 @@ class SourceValidatorEndpointTest {
                 .contains("write 
include=.*\\.txt").contains("antInclude=*.txt");
     }
 
+    @Test
+    void aSimpleMistakeInAFileNameOptionIsReportedAtWriteTime() {
+        // r26 connect-sftp-transfer step 3, a receipt named like the batch: 
the write was accepted, and the route then
+        // failed to reload on "Unknown file language syntax: noext"
+        String yaml = """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to:
+                            uri: sftp:localhost:2222/receipts
+                            parameters:
+                              username: admin
+                              fileName: "${file:noext}.txt"
+                        - to:
+                            uri: 
"file:receipts?fileName=${file:name:noext}.txt"
+                """;
+        List<String> msgs = SourceValidator.validateYamlEndpoints(yaml, 
catalog);
+        assertThat(msgs).hasSize(2);
+        assertThat(msgs.get(0)).startsWith("Line 9: sftp: 
fileName=${file:noext}.txt has a Simple syntax error: Unknown"
+                                           + " file language syntax: noext 
(did you mean ${file:name.noext}, the file"
+                                           + " name without its extension?");
+        assertThat(msgs.get(1)).startsWith("Line 11: file: 
fileName=${file:name:noext}.txt has a Simple syntax error:"
+                                           + " Unknown file language syntax: 
name:noext (did you mean"
+                                           + " ${file:name.noext}?");
+        // the functions as written in the file component's doc, with a date, 
a header and a property placeholder
+        String right = yaml.replace("${file:noext}.txt", 
"{{receipt.prefix}}${file:name.noext}-${date:now:yyyyMMdd}.txt")
+                .replace("${file:name:noext}.txt", 
"${header.CamelFileName}.done&tempFileName=${file:onlyname}.tmp");
+        assertThat(SourceValidator.validateYamlEndpoints(right, 
catalog)).isEmpty();
+    }
+
+    @Test
+    void aFileOptionTheRuntimeAcceptsIsNotRefused() {
+        // review of #29147: the check of the file language options refused 
routes that run fine: a placeholder as an
+        // operand of ||, a RAW value with & in it (in the uri and under 
parameters:), a single-quoted value with '' in
+        // it, and + for a space in a uri
+        String yaml = """
+                - route:
+                    from:
+                      uri: file
+                      parameters:
+                        directoryName: inbox
+                        filterFile: "${file:size} > 1000 || {{extra.filter}}"
+                      steps:
+                        - log: "${file:name}"
+                - route:
+                    from:
+                      uri: "file:archive?filterFile=RAW(${file:name} contains 
'R&D')"
+                      steps:
+                        - log: "${file:name}"
+                - route:
+                    from:
+                      uri: file
+                      parameters:
+                        directoryName: research
+                        filterFile: "RAW(${file:onlyname} contains 'R&D')"
+                      steps:
+                        - log: "${file:name}"
+                - route:
+                    from:
+                      uri: file
+                      parameters:
+                        directoryName: plans
+                        filterFile: '${file:name} contains ''plan'''
+                      steps:
+                        - log: "${file:name}"
+                - route:
+                    from:
+                      uri: "file:big?filterFile=${file:size}+>+1000"
+                      steps:
+                        - log: "${file:name}"
+                """;
+        assertThat(SourceValidator.validateYamlEndpoints(yaml, 
catalog)).isEmpty();
+        // a mistake is still reported, in RAW and in single quotes
+        String wrong = yaml.replace("RAW(${file:name} contains 'R&D')", 
"RAW(${file:noext} contains 'R&D')")
+                .replace("'${file:name} contains ''plan'''", 
"'${file:name:noext} contains ''plan'''");
+        assertThat(SourceValidator.validateYamlEndpoints(wrong, 
catalog)).satisfiesExactly(
+                m -> assertThat(m).startsWith("Line 11: file: 
filterFile=${file:noext} contains 'R&D' has a Simple syntax"
+                                              + " error: Unknown file language 
syntax: noext"),
+                m -> assertThat(m).startsWith("Line 27: file: 
filterFile=${file:name:noext} contains 'plan' has a Simple"
+                                              + " syntax error: Unknown file 
language syntax: name:noext"));
+    }
+
     @Test
     void severalEndpointsInOneToAreNamed() {
         List<String> msgs = SourceValidator.validateYamlEndpoints("""
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotesTest.java
index 694b4d9da5c7..03e5a1e2177b 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotesTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/WriteNotesTest.java
@@ -26,7 +26,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 /**
  * The notes of a write for what works but is probably not what was asked: a 
to: that copies the files a file consumer
- * moves to .camel, and a literal over a {{key}} placeholder that leaves the 
key unused.
+ * moves to .camel, a request-reply over JMS whose reply replaces the message, 
and a literal over a {{key}} placeholder
+ * that leaves the key unused.
  */
 class WriteNotesTest {
 
@@ -123,6 +124,269 @@ class WriteNotesTest {
                 .isEmpty();
     }
 
+    /**
+     * connect-service-jms-request-reply step 2 of the local-model benchmark 
("keep the order as the body"), r23 to r26.
+     */
+    private static final String REQUEST_REPLY = """
+            - route:
+                id: reserve-order
+                from:
+                  uri: jms
+                  parameters:
+                    destinationType: queue
+                    destinationName: orders
+                  steps:
+                    - log:
+                        message: "Reserving ${header.orderId} for customer 
${header.customer}"
+                    - to:
+                        uri: jms
+                        parameters:
+                          destinationType: queue
+                          destinationName: stock
+                        pattern: InOut
+                    - log:
+                        message: "Stock service answered for 
${header.orderId}: ${body}"
+            """;
+
+    @Test
+    void aRequestReplyWhoseReplyReplacesTheMessage() {
+        
assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY)).singleElement().isEqualTo(
+                "Line 11: after this request-reply (InOut) the body is the 
reply from stock, and the message that was"
+                                                                               
           + " sent is gone. To keep the message as the body, add 
variableReceive:"
+                                                                               
           + " stockReply to this to: and read the reply as 
${variable.stockReply}.");
+    }
+
+    @Test
+    void theWaysToSendInOut() {
+        // exchangePattern on the endpoint, under parameters: or in the uri, 
and the to: as a uri alone
+        assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY.replace("        
    pattern: InOut",
+                "              exchangePattern: 
InOut"))).singleElement().asString()
+                .startsWith("Line 11: after this request-reply (InOut) the 
body is the reply from stock");
+        String uri = REQUEST_REPLY.replace("""
+                            uri: jms
+                            parameters:
+                              destinationType: queue
+                              destinationName: stock
+                            pattern: InOut
+                """, """
+                            uri: "activemq:queue:stock?exchangePattern=InOut"
+                """);
+        
assertThat(WriteNotes.requestReplyNotes(uri)).singleElement().asString()
+                .startsWith("Line 11: after this request-reply (InOut) the 
body is the reply from stock");
+        assertThat(WriteNotes.requestReplyNotes(uri.replace("        - to:\n   
         uri: ", "        - to: ")))
+                .singleElement().asString().contains("add variableReceive: 
stockReply to this to:");
+        // a setExchangePattern before it, as r23n-8 wrote it, and the to: 
inside a filter
+        String exchange = REQUEST_REPLY.replace("""
+                        - to:
+                            uri: jms
+                            parameters:
+                              destinationType: queue
+                              destinationName: stock
+                            pattern: InOut
+                """, """
+                        - setExchangePattern:
+                            pattern: InOut
+                        - filter:
+                            expression:
+                              simple:
+                                expression: "${header.orderId} != null"
+                            steps:
+                              - to: jms:queue:stock
+                """);
+        
assertThat(WriteNotes.requestReplyNotes(exchange)).singleElement().asString()
+                .startsWith("Line 18: after this request-reply (InOut) the 
body is the reply from stock");
+    }
+
+    @Test
+    void aRequestReplyThatKeepsTheMessageHasNoNote() {
+        // the reply in a variable, as the jms sample shows
+        
assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY.replace("pattern: InOut",
+                "pattern: InOut\n            variableReceive: 
stockReply"))).isEmpty();
+        // the body set after the reply: the route has chosen what the body is
+        assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY + """
+                        - setBody:
+                            expression:
+                              simple:
+                                expression: "${variable.order}"
+                """)).isEmpty();
+        // sent InOnly, InOut on another component, and InOnly on the to: 
after a setExchangePattern
+        
assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY.replace("pattern: InOut", 
"pattern: InOnly"))).isEmpty();
+        assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY.replace("uri: 
jms\n            parameters:",
+                "uri: direct:stock\n            parameters:"))).isEmpty();
+        
assertThat(WriteNotes.requestReplyNotes(REQUEST_REPLY.replace("pattern: InOut", 
"pattern: InOnly").replace("""
+                        - to:
+                """, """
+                        - setExchangePattern: InOut
+                        - to:
+                """))).isEmpty();
+    }
+
+    /** The request-reply of the benchmark with one more option of the 
endpoint under parameters:. */
+    private static String requestReplyWith(String option) {
+        return REQUEST_REPLY.replace("destinationName: stock", 
"destinationName: stock\n              " + option);
+    }
+
+    @Test
+    void thePatternIsTheOneTheRuntimeSendsWith() {
+        // review of #29147: exchangePattern of the endpoint wins over 
pattern: of the to: (as in SendProcessor)
+        
assertThat(WriteNotes.requestReplyNotes(requestReplyWith("exchangePattern: 
InOnly"))).isEmpty();
+        String uri = """
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - to:
+                            uri: "jms:queue:stock?exchangePattern=InOnly"
+                            pattern: InOut
+                """;
+        assertThat(WriteNotes.requestReplyNotes(uri)).isEmpty();
+        assertThat(WriteNotes.requestReplyNotes("""
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - to:
+                            uri: "jms:queue:stock?exchangePattern=InOut"
+                            pattern: InOnly
+                """)).singleElement().asString()
+                .startsWith("Line 5: after this request-reply (InOut) the body 
is the reply from stock");
+        // disableReplyTo sends InOnly whatever the pattern: the body is kept
+        
assertThat(WriteNotes.requestReplyNotes(uri.replace("exchangePattern=InOnly", 
"disableReplyTo=true"))).isEmpty();
+        
assertThat(WriteNotes.requestReplyNotes(requestReplyWith("disableReplyTo: 
true"))).isEmpty();
+        
assertThat(WriteNotes.requestReplyNotes(requestReplyWith("disableReplyTo: 
false"))).hasSize(1);
+    }
+
+    @Test
+    void aBodySetLaterOnTheMainPathHidesTheNote() {
+        // review of #29147: a setBody after the filter that holds the to: is 
on the main path
+        String filter = """
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - filter:
+                            expression:
+                              simple:
+                                expression: "${header.orderId} != null"
+                            steps:
+                              - to:
+                                  uri: jms:queue:stock
+                                  pattern: InOut
+                """;
+        String setBody = """
+                        - setBody:
+                            expression:
+                              simple:
+                                expression: "${variable.order}"
+                """;
+        
assertThat(WriteNotes.requestReplyNotes(filter)).singleElement().asString().startsWith("Line
 10: after this");
+        assertThat(WriteNotes.requestReplyNotes(filter + setBody)).isEmpty();
+        // a doFinally runs on the main path too: after the doTry, and after 
the steps of its own doTry
+        assertThat(WriteNotes.requestReplyNotes("""
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - to:
+                            uri: jms:queue:stock
+                            pattern: InOut
+                        - doTry:
+                            steps:
+                              - log: "${body}"
+                            doFinally:
+                              steps:
+                                - setBody:
+                                    expression:
+                                      simple:
+                                        expression: "${variable.order}"
+                """)).isEmpty();
+        assertThat(WriteNotes.requestReplyNotes("""
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - doTry:
+                            steps:
+                              - to:
+                                  uri: jms:queue:stock
+                                  pattern: InOut
+                            doFinally:
+                              steps:
+                                - setBody:
+                                    expression:
+                                      simple:
+                                        expression: "${variable.order}"
+                """)).isEmpty();
+        // a setBody in one branch of a choice after the to: hides the note, 
as the note only shows when it is clearly
+        // right
+        assertThat(WriteNotes.requestReplyNotes("""
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - to:
+                            uri: jms:queue:stock
+                            pattern: InOut
+                        - choice:
+                            when:
+                              - expression:
+                                  simple:
+                                    expression: "${header.keep}"
+                                steps:
+                                  - setBody:
+                                      expression:
+                                        simple:
+                                          expression: "${variable.order}"
+                            otherwise:
+                              steps:
+                                - log: "${body}"
+                """)).isEmpty();
+    }
+
+    @Test
+    void aBodySetOnlyOnAFailureKeepsTheNote() {
+        // review of #29147: a setBody only in a doCatch runs on a failure, 
and the body of the main path is the reply
+        assertThat(WriteNotes.requestReplyNotes("""
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - to:
+                            uri: jms:queue:stock
+                            pattern: InOut
+                        - doTry:
+                            steps:
+                              - log: "${body}"
+                            doCatch:
+                              - exception:
+                                  - java.lang.Exception
+                                steps:
+                                  - setBody:
+                                      expression:
+                                        simple:
+                                          expression: "${variable.order}"
+                """)).singleElement().asString().startsWith("Line 5: after 
this request-reply");
+        // the fallback of a circuit breaker runs on a failure too
+        assertThat(WriteNotes.requestReplyNotes("""
+                - route:
+                    from:
+                      uri: jms:queue:orders
+                      steps:
+                        - to:
+                            uri: jms:queue:stock
+                            pattern: InOut
+                        - circuitBreaker:
+                            steps:
+                              - log: "${body}"
+                            onFallback:
+                              steps:
+                                - setBody:
+                                    expression:
+                                      simple:
+                                        expression: "${variable.order}"
+                """)).singleElement().asString().startsWith("Line 5: after 
this request-reply");
+    }
+
     @Test
     void aLiteralOverAPlaceholderLeavesThePropertyUnused(@TempDir Path dir) 
throws Exception {
         // run-nightly-report step 4 of the local-model benchmark: asked to 
change the schedule, 4 runs wrote the cron
@@ -161,6 +425,60 @@ class WriteNotesTest {
                             + " value, change inventory.mug");
     }
 
+    @Test
+    void whatToDoInsteadNamesTheLineAndTheValue(@TempDir Path dir) throws 
Exception {
+        // r26 run-nightly-report step 4: the note was repeated in the model's 
answer, and the step ended
+        String before = "- route:\n    from:\n      uri: cron\n      
parameters:\n        name: report\n"
+                        + "        schedule: \"{{report.schedule}}\"\n      
steps:\n        - log: hi\n";
+        Files.writeString(dir.resolve("application.properties"), 
"report.schedule=0/10 * * * * ?\n");
+        assertThat(WriteNotes.unusedProperties(dir, "a.camel.yaml", before, 
before.replace("{{report.schedule}}",
+                "0 0 2 * * 
?"))).singleElement().extracting(WriteNotes.UnusedProperty::instruction).isEqualTo(
+                        "Use {{report.schedule}} again on line 6, and set 
report.schedule=0 0 2 * * ? in"
+                                                                               
                               + " application.properties: the property is now 
unused, as line 6 has"
+                                                                               
                               + " its own value.");
+        // the line changed elsewhere too: the value is not told
+        String other = before.replace("schedule: \"{{report.schedule}}\"", 
"schedule: \"0 0 2 * * ?\" # nightly");
+        assertThat(WriteNotes.unusedProperties(dir, "a.camel.yaml", before, 
other)).singleElement()
+                .extracting(WriteNotes.UnusedProperty::instruction).asString()
+                .startsWith("Use {{report.schedule}} again on line 6, and set 
the new value as report.schedule in"
+                            + " application.properties");
+    }
+
+    @Test
+    void twoKeysEachGetTheirOwnLineAndValue(@TempDir Path dir) throws 
Exception {
+        // review of #29147: two placeholders with the same text before them, 
replaced in one write; the second key got
+        // the line and the value of the first
+        String before = """
+                - route:
+                    id: nightly-report
+                    from:
+                      uri: cron
+                      parameters:
+                        name: report
+                        schedule: "{{report.schedule}}"
+                      steps:
+                        - log: report
+                - route:
+                    id: nightly-cleanup
+                    from:
+                      uri: cron
+                      parameters:
+                        name: cleanup
+                        schedule: "{{cleanup.schedule}}"
+                      steps:
+                        - log: cleanup
+                """;
+        String after = before.replace("{{report.schedule}}", "0 0 2 * * 
?").replace("{{cleanup.schedule}}", "0 0 3 * * ?");
+        Files.writeString(dir.resolve("application.properties"),
+                "report.schedule=0/10 * * * * ?\ncleanup.schedule=0/20 * * * * 
?\n");
+        String report = "Use {{report.schedule}} again on line 7, and set 
report.schedule=0 0 2 * * ? in"
+                        + " application.properties: the property is now 
unused, as line 7 has its own value.";
+        String cleanup = "Use {{cleanup.schedule}} again on line 16, and set 
cleanup.schedule=0 0 3 * * ? in"
+                         + " application.properties: the property is now 
unused, as line 16 has its own value.";
+        assertThat(WriteNotes.unusedProperties(dir, "nightly.camel.yaml", 
before, after))
+                
.extracting(WriteNotes.UnusedProperty::instruction).containsExactly(report, 
cleanup);
+    }
+
     @Test
     void aKeyThatIsStillUsedHasNoNote(@TempDir Path dir) throws Exception {
         String before = "- from:\n    uri: cron:report\n    parameters:\n      
schedule: \"{{report.schedule}}\"\n"
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
index b4c096d61721..55637a4f9bc4 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
@@ -39,17 +39,25 @@ import com.networknt.schema.Error;
  */
 final class SchemaHints {
 
-    /** What a row's condition and text see: the error and the pieces every 
row would otherwise recompute. */
-    record Match(Error error, String location, String name, String message, 
String unknown, YamlValidator validator) {
+    /**
+     * What a row's condition and text see: the error and the pieces every row 
would otherwise recompute. root is the
+     * document the location points into, or null when it is not known.
+     */
+    record Match(Error error, String location, String name, String message, 
String unknown, YamlValidator validator,
+            JsonNode root) {
 
         static Match of(Error error, YamlValidator validator) {
+            return of(error, validator, null);
+        }
+
+        static Match of(Error error, YamlValidator validator, JsonNode root) {
             String location = String.valueOf(error.getInstanceLocation());
             String message = error.getMessage();
             return new Match(
                     error, location, 
location.substring(location.lastIndexOf('/') + 1), message,
                     message != null && 
"additionalProperties".equals(error.getKeyword())
                             ? between(message, "property '", "'") : null,
-                    validator);
+                    validator, root);
         }
 
         /** The key before the last segment: the EIP a when/0 or doCatch/0 
item belongs to. */
@@ -58,6 +66,13 @@ final class SchemaHints {
             return parent.substring(parent.lastIndexOf('/') + 1);
         }
 
+        /** The node that holds the error's node, such as the setVariable of 
its expression:, or null. */
+        JsonNode parentNode() {
+            int slash = location.lastIndexOf('/');
+            JsonNode parent = root != null && location.startsWith("/") ? 
root.at(location.substring(0, slash)) : null;
+            return parent == null || parent.isMissingNode() ? null : parent;
+        }
+
         boolean nameIsIndex() {
             return name.matches("\\d+");
         }
@@ -166,6 +181,121 @@ final class SchemaHints {
                + " lines under it as:\n" + language + ":\n  expression: " + 
YamlValidator.yamlQuoted(text);
     }
 
+    /** The keys a model writes the file under, in file: {filePath: 
stock.json}. */
+    private static final List<String> FILE_KEYS = List.of(
+            "filePath", "path", "fileName", "file", "name", "resource", "uri", 
"expression");
+
+    /** The EIPs whose expression is the uri of an endpoint: there the text of 
a file would be the uri. */
+    private static final Set<String> ENDPOINT_EIPS
+            = Set.of("pollEnrich", "enrich", "recipientList", "routingSlip", 
"dynamicRouter");
+
+    /**
+     * Why a pollEnrich of a file has these options and a timeout: noop=true 
alone makes the endpoint idempotent, so a
+     * second poll of the same file finds nothing, and pollEnrich waits for 
ever by default (timeout -1).
+     */
+    private static final String POLL_FILE_OPTIONS = "idempotent=false reads 
the file again for the next message (noop=true"
+                                                    + " alone reads each file 
once), and the timeout ends the wait when there"
+                                                    + " is no file";
+
+    /**
+     * file written as the language of an expression, to read a file: there is 
no such language. A data file the route
+     * needs, such as a lookup table, is read with the constant language and 
resource:file:, as the usage of the file
+     * component says. The lines to write hold the file that was written under 
file:, when there is one; a file name
+     * with ${...} in it is read with pollEnrich, as constant does not 
evaluate it. In pollEnrich, enrich and the
+     * routing EIPs the expression is the uri of an endpoint: the text of the 
file would be the uri there, and a missing
+     * file would stop the route from starting.
+     */
+    static String fileAsALanguageHint(Match m) {
+        boolean expression = m.locationEndsWith("/expression");
+        String eip = expression ? m.parentName() : m.name();
+        JsonNode instance = m.error().getInstanceNode();
+        JsonNode file = instance != null ? instance.get("file") : null;
+        String path = null;
+        if (file != null && file.isValueNode() && !file.isNull()) {
+            path = file.asText();
+        } else if (file != null && file.isObject()) {
+            for (String key : FILE_KEYS) {
+                JsonNode value = file.get(key);
+                if (value != null && value.isValueNode() && !value.isNull() && 
!value.asText().isBlank()) {
+                    path = value.asText();
+                    break;
+                }
+            }
+        }
+        String location = path != null ? path.strip() : "data.json";
+        location = location.startsWith("resource:") ? 
location.substring("resource:".length()) : location;
+        location = location.startsWith("file:") || 
location.startsWith("classpath:") ? location : "file:" + location;
+        if (ENDPOINT_EIPS.contains(eip)) {
+            return fileInAnEndpointHint(eip, location);
+        }
+        String hint = "file is not a language. To read a data file the route 
needs, such as a lookup table, use the"
+                      + " constant language with resource:file: (the value is 
the text of the file).";
+        if (location.startsWith("file:") && location.contains("${")) {
+            JsonNode node = expression ? m.parentNode() : instance;
+            String name = node != null && node.path("name").isTextual() ? 
node.path("name").asText() : null;
+            String poll = "pollEnrich: {expression: {simple: {expression: \"" 
+ pollUri(location) + "\"}}, timeout: 5000";
+            String read;
+            if ("setBody".equals(eip) || "transform".equals(eip)) {
+                // the pollEnrich sets the body, as setBody does
+                read = "Write this " + eip + " as: " + poll + "}. " + 
POLL_FILE_OPTIONS + " (the body is then null).";
+            } else {
+                // variableReceive keeps the body, as setVariable does
+                read = ("setVariable".equals(eip) && name != null
+                        ? "Write this setVariable as: " + poll + ", 
variableReceive: " + name + "}."
+                        : "Read the file in a step before this one: " + poll + 
", variableReceive: data}, and use"
+                          + " ${variable.data} here.")
+                       + " variableReceive keeps the body as it is, " + 
POLL_FILE_OPTIONS + " (the variable is then null).";
+            }
+            return hint + " A file name with ${...} is read with pollEnrich 
instead, as constant does not evaluate it. "
+                   + read;
+        }
+        String constant = "constant:\n  expression: " + 
YamlValidator.yamlQuoted("resource:" + location);
+        return hint + " Write file: and the lines under it as:\n"
+               + (expression ? constant : "expression:\n" + 
constant.indent(2).stripTrailing());
+    }
+
+    /**
+     * file as the language of the expression of pollEnrich, enrich or a 
routing EIP, where the expression is the uri of
+     * an endpoint: pollEnrich reads a file with a file: uri, enrich and the 
routing EIPs send to the uri.
+     */
+    private static String fileInAnEndpointHint(String eip, String location) {
+        String hint = "file is not a language. ";
+        boolean file = location.startsWith("file:");
+        String language = location.contains("${") ? "simple" : "constant";
+        return switch (eip) {
+            case "pollEnrich" -> hint + "The expression of pollEnrich is the 
uri of the endpoint to poll, such as a file:"
+                                 + " uri with fileName."
+                                 + (file
+                                         ? " Set timeout: 5000 on the 
pollEnrich too: " + POLL_FILE_OPTIONS + ". Write"
+                                           + " file: and the lines under it 
as:\n" + language + ":\n  expression: "
+                                           + 
YamlValidator.yamlQuoted(pollUri(location))
+                                         : "");
+            case "enrich" -> hint + "The expression of enrich is the uri of 
the endpoint to call, and a file: uri there"
+                             + " writes the message to a file."
+                             + (file
+                                     ? " To read a file, use pollEnrich in 
place of enrich: pollEnrich: {expression: {"
+                                       + language + ": {expression: \"" + 
pollUri(location) + "\"}}, timeout: 5000}. "
+                                       + POLL_FILE_OPTIONS + "."
+                                     : "");
+            case "dynamicRouter" -> hint + "The expression of dynamicRouter 
gives the uri of the next endpoint each time"
+                                    + " it is called, and null when the 
routing is done: a file: uri there writes the"
+                                    + " message to a file, and does not read 
one.";
+            default -> hint + "The expression of " + eip + " gives the uris of 
the endpoints to send to, as text"
+                       + " (constant, or simple when it has ${...}): a file: 
uri there writes the message to a file, and"
+                       + " does not read one.";
+        };
+    }
+
+    /**
+     * The file: uri that a pollEnrich reads the file with each time, such as 
file:orders?fileName=${header.id}.json.
+     */
+    private static String pollUri(String location) {
+        String name = location.substring("file:".length());
+        int slash = name.lastIndexOf('/');
+        String dir = slash > 0 ? name.substring(0, slash) : slash == 0 ? "/" : 
".";
+        return "file:" + dir + "?fileName=" + name.substring(slash + 1) + 
"&noop=true&idempotent=false";
+    }
+
     /** The text of a scalar property of the error's node, or the fallback 
when it is not a scalar. */
     private static String valueOf(Match m, String property, String fallback) {
         JsonNode instance = m.error().getInstanceNode();
@@ -268,10 +398,15 @@ final class SchemaHints {
      * same location (the branches of an anyOf, once the hint no longer names 
the branch) are reported once.
      */
     static List<Error> apply(List<Hint> table, List<Error> errors, 
YamlValidator validator) {
+        return apply(table, errors, validator, null);
+    }
+
+    /** As {@link #apply(List, List, YamlValidator)}, for rows that read the 
nodes around the error's node in root. */
+    static List<Error> apply(List<Hint> table, List<Error> errors, 
YamlValidator validator, JsonNode root) {
         List<Error> answer = new ArrayList<>(errors.size());
         Set<String> seen = new LinkedHashSet<>();
         for (Error error : errors) {
-            Error hinted = apply(table, error, validator);
+            Error hinted = apply(table, error, validator, root);
             if (hinted == error || seen.add(hinted.getInstanceLocation() + " " 
+ hinted.getMessage())) {
                 answer.add(hinted);
             }
@@ -280,10 +415,14 @@ final class SchemaHints {
     }
 
     static Error apply(List<Hint> table, Error error, YamlValidator validator) 
{
+        return apply(table, error, validator, null);
+    }
+
+    static Error apply(List<Hint> table, Error error, YamlValidator validator, 
JsonNode root) {
         if (error.getMessage() == null) {
             return error;
         }
-        Match m = Match.of(error, validator);
+        Match m = Match.of(error, validator, root);
         for (Hint row : table) {
             if (row.matches(m)) {
                 return row.rewrite(m);
@@ -679,6 +818,13 @@ final class SchemaHints {
                     m -> "json".equals(m.unknown())
                             && (m.locationEndsWith("/expression") || 
YamlValidator.EXPRESSION_REQUIRED.contains(m.name())),
                     SchemaHints::jsonAsALanguageHint, "additionalProperties", 
"additionalProperties"),
+            // expression: {file: {filePath: stock.json}}: file is not a 
language of an expression, and "not defined in
+            // the schema" said nothing more (r20 to r26 
connect-service-jms-request-reply step 3: 5 of 7 runs in r26).
+            // Not for the script EIP, where the file is a script to run 
rather than data
+            replace("additionalProperties", null,
+                    m -> "file".equals(m.unknown()) && 
!m.location().matches(".*/script(/expression)?")
+                            && (m.locationEndsWith("/expression") || 
YamlValidator.EXPRESSION_REQUIRED.contains(m.name())),
+                    SchemaHints::fileAsALanguageHint, "additionalProperties", 
"additionalProperties"),
             // dataFormatProperty: [- prettyPrint: "true"]: an item of a 
key/value property list is a key and a value
             
unknownProperty(".*/restConfiguration/(dataFormatProperty|componentProperty|endpointProperty|consumerProperty"
                             + "|apiProperty|corsHeaders)/\\d+",
@@ -707,10 +853,16 @@ final class SchemaHints {
                         return "the endpoint of " + m.name() + " is an 
expression: write " + m.name()
                                + ": {expression: {constant: {expression: \"" + 
uri + "\"}}}";
                     }),
+            // choice: {steps: [...]}: the steps of a choice are under each 
when: and under otherwise: (r26
+            // contracts-openapi-server step 4: told that choice owns a steps: 
list, a model wrote it 6 times)
+            unknownProperty(".*/choice", m -> m.unknown().equals("steps"),
+                    m -> "choice has no steps: of its own: write the steps 
under a when: (next to its expression:) or"
+                         + " under otherwise:, as in choice: {when: 
[{expression: {simple: {expression: \"...\"}},"
+                         + " steps: [...]}], otherwise: {steps: [...]}}"),
             // - steps: [...] as an item of a steps list, or steps: on an EIP 
without a pipeline: there is no group item
             // (and no "did you mean 'step'?", which leads to the Step EIP 
with the EIPs as its keys)
             unknownProperty(null, m -> m.unknown().equals("steps"),
-                    m -> "steps: is the list of a route or of an EIP that owns 
a pipeline (filter, split, choice, step);"
+                    m -> "steps: is the list of a route or of an EIP that owns 
a pipeline (filter, split, loop, step);"
                          + " an EIP is an item of that list, not a group 
inside it: move the items up one level, or use"
                          + " step: {id: ..., steps: [...]} for a named group"),
             // step: {setHeader: ..., split: ...}: step is the Step EIP; one 
message for the whole item, not one per EIP
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
index 7e913eac2d38..9f91299994ef 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
@@ -977,7 +977,8 @@ public class YamlValidator {
             errors = SchemaHints.apply(SchemaHints.COMPACT, errors, this);
         }
         errors = SchemaHints.apply(SchemaHints.EXPRESSION, errors, this);
-        errors = SchemaHints.apply(SchemaHints.PROPERTY, errors, this);
+        // file: as a language reads the setVariable around its expression:
+        errors = SchemaHints.apply(SchemaHints.PROPERTY, errors, this, target);
         errors = SchemaHints.apply(SchemaHints.LIST, errors, this);
         // before the step hints, which rewrite the error without its instance 
node
         errors = SchemaHints.dropMisplacedOptionRepeats(errors, this);
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
index 98c82941932d..08736e81cdaf 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
@@ -1428,6 +1428,156 @@ public class YamlValidatorPropertyHintTest {
                         .endsWith(" as:\nconstant:\n  expression: '{\"error\": 
\"unknown order\"}'"));
     }
 
+    @Test
+    public void testFileAsALanguageSaysToReadTheFileWithConstant() throws 
Exception {
+        // r20 to r26 connect-service-jms-request-reply step 3: stock.json 
read with file: under expression (5 of 7 runs
+        // in r26), told only that file is not defined in the schema
+        String yaml = """
+                - route:
+                    from:
+                      uri: jms:queue:stock
+                      steps:
+                        - setVariable:
+                            name: stock
+                            expression:
+                              file:
+                                filePath: stock.json
+                """;
+        for (YamlValidator v : bothModes()) {
+            assertThat(v.validate(yaml)).singleElement().satisfies(e -> 
assertThat(e.getMessage())
+                    .isEqualTo("file is not a language. To read a data file 
the route needs, such as a lookup table, use"
+                               + " the constant language with resource:file: 
(the value is the text of the file). Write"
+                               + " file: and the lines under it 
as:\nconstant:\n  expression: \"resource:file:stock.json\""));
+        }
+        // the file as the value of file:, and file: as the language key of 
the EIP itself
+        assertThat(validator.validate(yaml.replace("file:\n                
filePath: stock.json",
+                "file: stock.json"))).singleElement().satisfies(e -> 
assertThat(e.getMessage())
+                        .endsWith("as:\nconstant:\n  expression: 
\"resource:file:stock.json\""));
+        assertThat(validator.validate("""
+                - route:
+                    from:
+                      uri: jms:queue:stock
+                      steps:
+                        - setBody:
+                            file: "stock.json"
+                """)).anySatisfy(e -> assertThat(e.getMessage())
+                .endsWith("as:\nexpression:\n  constant:\n    expression: 
\"resource:file:stock.json\""));
+        // the file of the script EIP is a script to run, not data
+        assertThat(validator.validate(yaml.replace("- setVariable:\n           
 name: stock\n", "- script:\n")))
+                .isNotEmpty().allSatisfy(e -> 
assertThat(e.getMessage()).doesNotContain("file is not a language"));
+    }
+
+    @Test
+    public void testFileNameWithSimpleIsReadWithAPollEnrichThatReturns() 
throws Exception {
+        // a file name with ${...}, which constant does not evaluate. The 
pollEnrich polls the file again for the next
+        // message (noop=true alone makes the endpoint idempotent), ends the 
wait for a missing file, and keeps the body
+        // as setVariable does (review of #29147: the variable was null, and 
the second call never returned)
+        String yaml = """
+                - route:
+                    from:
+                      uri: jms:queue:stock
+                      steps:
+                        - setVariable:
+                            name: stock
+                            expression:
+                              file:
+                                filePath: orders/${header.orderId}.json
+                """;
+        for (YamlValidator v : bothModes()) {
+            assertThat(v.validate(yaml)).singleElement().satisfies(e -> 
assertThat(e.getMessage())
+                    .endsWith("A file name with ${...} is read with pollEnrich 
instead, as constant does not evaluate it."
+                              + " Write this setVariable as: pollEnrich: 
{expression: {simple: {expression:"
+                              + " 
\"file:orders?fileName=${header.orderId}.json&noop=true&idempotent=false\"}},"
+                              + " timeout: 5000, variableReceive: stock}. 
variableReceive keeps the body as it is,"
+                              + " idempotent=false reads the file again for 
the next message (noop=true alone reads each"
+                              + " file once), and the timeout ends the wait 
when there is no file (the variable is then"
+                              + " null)."));
+        }
+        // setBody sets the body with the file
+        assertThat(validator.validate(yaml.replace("- setVariable:\n           
 name: stock\n", "- setBody:\n")))
+                .singleElement().satisfies(e -> assertThat(e.getMessage())
+                        .contains("Write this setBody as: pollEnrich: 
{expression: {simple: {expression:"
+                                  + " 
\"file:orders?fileName=${header.orderId}.json&noop=true&idempotent=false\"}},"
+                                  + " timeout: 5000}.")
+                        .endsWith("(the body is then 
null).").doesNotContain("variableReceive"));
+        // another EIP reads the file in a variable before it
+        assertThat(validator
+                .validate(yaml.replace("setVariable:\n            name: 
stock", "setHeader:\n            name: stock")))
+                .singleElement().satisfies(e -> assertThat(e.getMessage())
+                        .contains("Read the file in a step before this one: 
pollEnrich: {expression: {simple: {expression:"
+                                  + " 
\"file:orders?fileName=${header.orderId}.json&noop=true&idempotent=false\"}},"
+                                  + " timeout: 5000, variableReceive: data}, 
and use ${variable.data} here."));
+    }
+
+    @Test
+    public void testFileInTheExpressionOfAnEndpointIsNoResource() throws 
Exception {
+        // review of #29147: there the expression is the uri, so the text of 
resource:file:stock.json would be the uri
+        // ("Failed to resolve endpoint: {"apples": 3}"), and a missing file 
would stop the route from starting
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:reserve
+                      steps:
+                        - pollEnrich:
+                            expression:
+                              file:
+                                filePath: stock.json
+                """;
+        for (YamlValidator v : bothModes()) {
+            assertThat(v.validate(yaml)).singleElement().satisfies(e -> 
assertThat(e.getMessage())
+                    .isEqualTo("file is not a language. The expression of 
pollEnrich is the uri of the endpoint to poll,"
+                               + " such as a file: uri with fileName. Set 
timeout: 5000 on the pollEnrich too:"
+                               + " idempotent=false reads the file again for 
the next message (noop=true alone reads each"
+                               + " file once), and the timeout ends the wait 
when there is no file. Write file: and the"
+                               + " lines under it as:\nconstant:\n  
expression:"
+                               + " 
\"file:.?fileName=stock.json&noop=true&idempotent=false\""));
+        }
+        assertThat(validator.validate(yaml.replace("filePath: stock.json", 
"filePath: data/${header.sku}.json")))
+                .singleElement().satisfies(e -> assertThat(e.getMessage())
+                        .endsWith("as:\nsimple:\n  expression: 
\"file:data?fileName=${header.sku}.json&noop=true"
+                                  + "&idempotent=false\""));
+        assertThat(validator.validate(yaml.replace("pollEnrich:", 
"enrich:"))).singleElement()
+                .satisfies(e -> assertThat(e.getMessage())
+                        .startsWith("file is not a language. The expression of 
enrich is the uri of the endpoint to call,"
+                                    + " and a file: uri there writes the 
message to a file. To read a file, use pollEnrich"
+                                    + " in place of enrich: pollEnrich: 
{expression: {constant: {expression:"
+                                    + " 
\"file:.?fileName=stock.json&noop=true&idempotent=false\"}}, timeout: 5000}.")
+                        .doesNotContain("resource:"));
+        for (String eip : List.of("recipientList", "routingSlip")) {
+            String expected = "file is not a language. The expression of " + 
eip + " gives the uris of the endpoints to"
+                              + " send to, as text (constant, or simple when 
it has ${...}): a file: uri there writes the"
+                              + " message to a file, and does not read one.";
+            assertThat(validator.validate(yaml.replace("pollEnrich:", eip + 
":"))).singleElement()
+                    .satisfies(e -> 
assertThat(e.getMessage()).isEqualTo(expected));
+        }
+        assertThat(validator.validate(yaml.replace("pollEnrich:", 
"dynamicRouter:"))).singleElement()
+                .satisfies(e -> assertThat(e.getMessage())
+                        .isEqualTo("file is not a language. The expression of 
dynamicRouter gives the uri of the next"
+                                   + " endpoint each time it is called, and 
null when the routing is done: a file: uri"
+                                   + " there writes the message to a file, and 
does not read one."));
+    }
+
+    @Test
+    public void testStepsUnderChoiceSaysWhenAndOtherwise() throws Exception {
+        // r26 contracts-openapi-server step 4: told that choice is an EIP 
that owns steps:, a model wrote it 6 times
+        for (YamlValidator v : bothModes()) {
+            List<Error> errors = v.validate("""
+                    - route:
+                        from:
+                          uri: direct:reserve
+                          steps:
+                            - choice:
+                                steps:
+                                  - log:
+                                      message: hi
+                    """);
+            assertThat(errors).singleElement().satisfies(e -> 
assertThat(e.getMessage())
+                    .contains("choice has no steps: of its own: write the 
steps under a when: (next to its expression:) or"
+                              + " under otherwise:")
+                    .doesNotContain("owns a pipeline"));
+        }
+    }
+
     @Test
     public void testLanguageAsAPropertySaysToUseItAsTheKey() throws Exception {
         List<Error> errors = validator.validate("""

Reply via email to