This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26825 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9eeafa79d9e1cf557ab9299ef66fa0db6a9917b4 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Nov 11 09:26:44 2026 +0100 CAMEL-26825: camel-jbang - AI tools: the refusal of a constant with ${...} gives the lines to write 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"); + } +}
