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 3bc999e99029 CAMEL-26825: camel-jbang - AI tools: the refusal of a 
constant with ${...} gives the lines to write (#29613)
3bc999e99029 is described below

commit 3bc999e99029efa34a5cd800d7aadc4cbc3008fd
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 11 09:57:59 2026 +0100

    CAMEL-26825: camel-jbang - AI tools: the refusal of a constant with ${...} 
gives the lines to write (#29613)
    
    A constant is used as written, so ${header.id} in it is never evaluated. The
    refusal said to use simple, but gave no lines to write. The loop breaker of
    CAMEL-26758 writes the third identical refused content only when every 
problem
    gave its lines. So in r31 of the local-model benchmark, 
contracts-rest-code-first
    step 1 sent the same file 13 times and failed: its jsonpath with [] had a 
line
    to write, its constant had none.
    
    The message now ends with "Write line 7 as:" or "Write lines 57-58 as:" and 
the
    lines with simple: in place of constant:.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
---
 .../dsl/jbang/core/commands/ai/ConstantLines.java  | 139 +++++++++++++++++++++
 .../jbang/core/commands/ai/SourceValidator.java    |   3 +
 .../ai/AuthoringToolsRefusalFixesTest.java         |  34 +++++
 .../jbang/core/commands/ai/ConstantLinesTest.java  | 135 ++++++++++++++++++++
 4 files changed, 311 insertions(+)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLines.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLines.java
new file mode 100644
index 000000000000..a9ca6cac30b4
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLines.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The lines to write for a constant that holds a simple function, such as 
{@code constant: "unknown order
+ * ${header.id}"}: a constant is used as written, and the validator says to 
use simple instead. The lines with simple:
+ * in place of constant: end the message, so a model can copy them, and the 
write tool makes the fix when the same
+ * content comes a third time ({@link RefusalFixes}, CAMEL-26758). In r31 of 
the local-model benchmark,
+ * contracts-rest-code-first step 1 sent the same file 13 times: the refusal 
of its jsonpath with [] gave the line to
+ * write, the refusal of its constant with ${header.id} gave none, so the 
write tool could not fix the file
+ * (CAMEL-26825).
+ */
+final class ConstantLines {
+
+    /**
+     * The validator's message for it: "Line 58: constant: ${header.id} is not 
evaluated, a constant is used as ...".
+     */
+    private static final Pattern CONSTANT_WITH_SIMPLE = Pattern.compile(
+            "^Line (\\d+): (?:\\S*/)?constant: \\$\\{.* is not evaluated, a 
constant is used as written");
+    /** The constant key, with its value or without: constant: "x", constant:, 
- constant: "x". */
+    private static final Pattern CONSTANT_KEY = Pattern.compile("^(- 
+)?constant:(\\s|$)");
+
+    /** How many lines are shown at most; for a longer value only the line of 
the key is given. */
+    private static final int MAX_LINES = 12;
+
+    private ConstantLines() {
+    }
+
+    /** The messages, with the lines to write after each message of a constant 
that holds a simple function. */
+    static List<String> withLines(List<String> messages, String content) {
+        if (messages.isEmpty() || content == null) {
+            return messages;
+        }
+        String[] lines = content.split("\n", -1);
+        List<String> answer = new ArrayList<>(messages.size());
+        for (String message : messages) {
+            Matcher m = CONSTANT_WITH_SIMPLE.matcher(message);
+            String written = m.find() ? linesToWrite(lines, 
Integer.parseInt(m.group(1)) - 1) : null;
+            answer.add(written != null ? message + "." + written : message);
+        }
+        return answer;
+    }
+
+    /**
+     * " Write lines 57-58 as:" and the lines of the constant with simple: in 
place of constant:; null when the line is
+     * not of a constant.
+     */
+    static String linesToWrite(String[] lines, int index) {
+        int key = keyLine(lines, index);
+        if (key < 0) {
+            return null;
+        }
+        String line = lines[key];
+        int at = indent(line);
+        Matcher k = CONSTANT_KEY.matcher(line.substring(at));
+        if (!k.find()) {
+            return null;
+        }
+        // the column of the key: past the dash of a list item
+        int column = at + (k.group(1) != null ? k.group(1).length() : 0);
+        int end = blockEnd(lines, key, column);
+        if (end - key > MAX_LINES) {
+            end = key + 1;
+        }
+        StringBuilder sb
+                = new StringBuilder(end == key + 1 ? " Write line " + (key + 
1) : " Write lines " + (key + 1) + "-" + end)
+                        .append(" as:\n")
+                        .append(line, 0, 
column).append("simple").append(line.substring(column + "constant".length()));
+        for (int i = key + 1; i < end; i++) {
+            sb.append('\n').append(lines[i]);
+        }
+        return sb.toString();
+    }
+
+    /**
+     * The line of the constant key: the line of the message, or for a 
constant whose value is on the lines below it
+     * (constant: and then expression: ...), the key above that the value 
belongs to; -1 when there is none.
+     */
+    private static int keyLine(String[] lines, int index) {
+        if (index < 0 || index >= lines.length || lines[index].isBlank()) {
+            return -1;
+        }
+        if (CONSTANT_KEY.matcher(lines[index].stripLeading()).find()) {
+            return index;
+        }
+        int column = indent(lines[index]);
+        for (int i = index - 1; i >= 0; i--) {
+            String l = lines[i];
+            if (!l.isBlank() && indent(l) < column) {
+                String key = l.strip();
+                return CONSTANT_KEY.matcher(key).find() && 
key.endsWith("constant:") ? i : -1;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * The end (exclusive) of the value of the key at the column: the lines 
below it that are more indented, without the
+     * blank lines at its end.
+     */
+    private static int blockEnd(String[] lines, int start, int column) {
+        int end = start + 1;
+        while (end < lines.length && (lines[end].isBlank() || 
indent(lines[end]) > column)) {
+            end++;
+        }
+        while (end > start + 1 && lines[end - 1].isBlank()) {
+            end--;
+        }
+        return end;
+    }
+
+    private static int indent(String line) {
+        int i = 0;
+        while (i < line.length() && line.charAt(i) == ' ') {
+            i++;
+        }
+        return i;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index f988ea115e90..77815e948712 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -192,6 +192,9 @@ public final class SourceValidator {
             // an option at the column of its EIP: the lines to write, which a 
model copies (the words it does not,
             // CAMEL-25556)
             msgs = IndentLines.withLines(msgs, content);
+            // a constant with ${...}: the lines with simple: in its place, 
which the write tool can also apply when the
+            // same content comes a third time (CAMEL-26825)
+            msgs = ConstantLines.withLines(msgs, content);
             return KameletChecks.withTemplateHints(name, content, msgs);
         }
         if (name.endsWith(".properties")) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
index fb4370cb61fe..307580baada0 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
@@ -136,4 +136,38 @@ class AuthoringToolsRefusalFixesTest {
         
assertThat(third.getString("status")).as(String.valueOf(third)).isEqualTo("created");
         
assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(content.replace("== 
'paid'\n", "== 'paid'\"\n"));
     }
+
+    @Test
+    void aConstantWithSimpleIsWrittenAsTheRefusalsSaid(@TempDir Path dir) 
throws IOException {
+        // CAMEL-26825, r31n-7 contracts-rest-code-first step 1 (shortened): 
sent 13 times with two problems, the []
+        // whose refusal gave the line to write, and a constant with 
${header.id} whose refusal gave none
+        String content = BRACKETS.replace("        - log: \"${body}\"\n", """
+                        - choice:
+                            when:
+                              - simple: "${body.size()} == 0"
+                                steps:
+                                  - setBody:
+                                      expression:
+                                        constant:
+                                          expression: '{"error": "unknown 
order ${header.id}"}'
+                        - log: "${body}"
+                """);
+        String[] lines = content.split("\n");
+        for (int i = 1; i <= 2; i++) {
+            JsonObject refused = write(dir, content);
+            
assertThat(refused.getString("status")).as(String.valueOf(refused)).isEqualTo("invalid");
+            assertThat((JsonArray) 
refused.get("errors")).as(String.valueOf(refused)).hasSize(2)
+                    .anySatisfy(e -> assertThat(String.valueOf(e))
+                            .contains("constant: ${header.id} is not 
evaluated")
+                            .endsWith(". Write lines 17-18 as:\n" + 
lines[16].replace("constant:", "simple:") + "\n"
+                                      + lines[17]));
+        }
+        JsonObject third = write(dir, content);
+        
assertThat(third.getString("status")).as(String.valueOf(third)).isEqualTo("created");
+        assertThat((JsonArray) third.get("repaired")).containsExactly(
+                "line 9: written as the refusals said: expression: 
\"$[?(@.orderId == '${header.id}')]\"",
+                "lines 17-18: written as the refusals said");
+        assertThat(Files.readString(dir.resolve(FILE)))
+                .isEqualTo(content.replace("')][]\"", 
"')]\"").replace("constant:\n", "simple:\n"));
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLinesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLinesTest.java
new file mode 100644
index 000000000000..dac03f2eff6f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLinesTest.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26825: the refusal of a constant with ${...} ends with the lines to 
write, simple: in place of constant:.
+ */
+class ConstantLinesTest {
+
+    private static CamelCatalog catalog;
+
+    @BeforeAll
+    static void loadCatalog() {
+        catalog = new DefaultCamelCatalog();
+    }
+
+    private static List<String> validate(String yaml) {
+        return SourceValidator.validate("orders.camel.yaml", yaml, catalog, 
null);
+    }
+
+    /** The line of the YAML, 1-based, as written. */
+    private static String line(String yaml, int number) {
+        return yaml.split("\n")[number - 1];
+    }
+
+    @Test
+    void aConstantOnItsLineGetsTheLineToWrite() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:getStock
+                      steps:
+                        - setBody:
+                            expression:
+                              constant: '{"error": "unknown sku 
${header.sku}"}'
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .startsWith("Line 7: ")
+                .contains("constant: ${header.sku} is not evaluated, a 
constant is used as written")
+                .endsWith(". Write line 7 as:\n" + line(yaml, 
7).replace("constant:", "simple:"));
+    }
+
+    @Test
+    void aConstantWithItsValueBelowGetsAllItsLines() {
+        // r31n-7 contracts-rest-code-first step 1 (shortened): sent 13 times 
as it is
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:get-order-by-id
+                      steps:
+                        - setBody:
+                            expression:
+                              constant:
+                                expression: '{"error": "unknown order 
${header.id}"}'
+                        - setHeader:
+                            name: CamelHttpResponseCode
+                            constant: "404"
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .contains("constant: ${header.id} is not evaluated")
+                .endsWith(". Write lines 7-8 as:\n" + line(yaml, 
7).replace("constant:", "simple:") + "\n" + line(yaml, 8));
+    }
+
+    @Test
+    void theShortFormOfAHeaderGetsTheLineToWrite() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:start
+                      steps:
+                        - setHeader:
+                            name: greeting
+                            constant: "Hello ${body}"
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .endsWith(". Write line 7 as:\n" + line(yaml, 
7).replace("constant:", "simple:"));
+    }
+
+    @Test
+    void aListItemKeepsItsDash() {
+        String[] lines = """
+                when:
+                  - constant: "${header.ok}"
+                    steps:
+                      - to: log:ok
+                """.split("\n");
+        assertThat(ConstantLines.linesToWrite(lines, 1)).isEqualTo(" Write 
line 2 as:\n  - simple: \"${header.ok}\"");
+    }
+
+    @Test
+    void aLongValueGetsTheLineOfTheKeyOnly() {
+        StringBuilder sb = new StringBuilder("constant: |\n");
+        for (int i = 1; i <= 12; i++) {
+            sb.append("  line ").append(i).append(" of order ${header.id}\n");
+        }
+        String[] lines = sb.toString().split("\n");
+        assertThat(ConstantLines.linesToWrite(lines, 0)).isEqualTo(" Write 
line 1 as:\nsimple: |");
+    }
+
+    @Test
+    void otherLinesAndMessagesAreLeftAsTheyAre() {
+        String[] lines = """
+                - setBody:
+                    expression:
+                      simple:
+                        expression: "${header.id}"
+                """.split("\n");
+        assertThat(ConstantLines.linesToWrite(lines, 3)).isNull();
+        assertThat(ConstantLines.withLines(List.of("Line 2: something else"), 
String.join("\n", lines)))
+                .containsExactly("Line 2: something else");
+    }
+}

Reply via email to