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 fb86fbed28dc CAMEL-25403: camel-jbang - AI tools: note a Kamelet
camel: dependency that the template does not use (#27473)
fb86fbed28dc is described below
commit fb86fbed28dc8baf628488355493f81441662354
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 7 08:02:55 2026 +0200
CAMEL-25403: camel-jbang - AI tools: note a Kamelet camel: dependency that
the template does not use (#27473)
* CAMEL-25403: camel-jbang - AI tools: note a Kamelet camel: dependency
that the template does not use
A Kamelet file whose spec.dependencies lists a component the template does
not use (camel:timer copied from a sample into an action whose template is a
setBody) now gets a note in the answer of camel_write_file, camel_edit_file
and camel_validate_source. A note, not an error: the write is not refused.
Nothing is said when the template may use the component unseen: a bean, a
class reference, a placeholder as a scheme, or a component that runs on
another (cron on quartz, rest-openapi on http).
Over the 259 Kamelets of camel-kamelets it notes 6 dependencies, all unused.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25403: camel-jbang - AI tools: note camel:core in a Kamelet's
dependencies as implied
Every Camel runtime has camel-core, so camel:core adds nothing.
camel:kamelet
is not implied: it is the artifact that runs the template.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 18 +++
.../dsl/jbang/core/commands/ai/KameletChecks.java | 156 +++++++++++++++++++++
.../jbang/core/commands/ai/KameletChecksTest.java | 112 +++++++++++++++
3 files changed, 286 insertions(+)
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 6f65a77f9ba2..dd6fb1a460c0 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
@@ -531,6 +531,7 @@ public final class AuthoringTools {
result.put("file", file);
result.put("errors", new JsonArray(errors));
putKameletGuide(result, file, errors);
+ putKameletNotes(result, file, content);
// the problems whose fix is certain, as edits an agent can apply
(camel_edit_file find/replace)
JsonArray fixes = new JsonArray();
String[] lines = content.split("\n", -1);
@@ -923,6 +924,20 @@ public final class AuthoringTools {
return message != null ? message.replaceFirst("^Line \\d+: ", "") : "";
}
+ /**
+ * What a Kamelet file does that works but is not right, which does not
refuse a write: a camel: dependency its
+ * template does not use (CAMEL-25403).
+ */
+ private static void putKameletNotes(JsonObject result, String file, String
content) {
+ String name = file != null ? file.toLowerCase(Locale.ROOT) : "";
+ if (name.endsWith(".kamelet.yaml") || name.endsWith(".kamelet.yml")) {
+ List<String> notes = KameletChecks.unusedDependencies(content);
+ if (!notes.isEmpty()) {
+ result.put("notes", new JsonArray(notes));
+ }
+ }
+ }
+
/** Writes a file after validating it, as {@code camel_write_file} does;
no confirmation is asked here. */
public static JsonObject writeFile(ToolContext ctx, Path dir, String file,
String content, boolean validate) {
Path path = resolveFile(dir, file);
@@ -981,6 +996,9 @@ public final class AuthoringTools {
// written with problems the file already had: said, so they are
not taken for fixed
result.put("existingProblems", new JsonArray(problemsBefore));
}
+ if (validate) {
+ putKameletNotes(result, file, content);
+ }
if (watch) {
JsonObject reload = ReloadOutcome.await(ctx.pid(), processName,
sinceKey, RELOAD_WAIT_MILLIS);
result.put("reload", reload);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
index 0a6f0267545a..e0928e5de459 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
@@ -22,6 +22,7 @@ import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -371,6 +372,161 @@ public final class KameletChecks {
return errors;
}
+ /** The scheme of an endpoint uri written as a value: timer:orders,
kamelet:source, https://... */
+ private static final Pattern URI_SCHEME =
Pattern.compile("^([a-zA-Z][a-zA-Z0-9+.-]*):");
+
+ /**
+ * The camel: dependencies of a Kamelet file that name a component its
template does not use: no endpoint of the
+ * template has its scheme, and no language or data format of the template
is in its artifact. Notes, not errors:
+ * the Kamelet works, but an exported project gets the artifact for
nothing. A model copies them from a sample whose
+ * template starts from a timer, or from the route that uses the Kamelet
(CAMEL-25403). Nothing is said when the
+ * template could use a component in a way not visible here: a bean, a
class reference or a placeholder as a scheme.
+ * camel:core is noted as implied, as every Camel runtime has it.
+ */
+ public static List<String> unusedDependencies(String content) {
+ List<String> notes = new ArrayList<>();
+ Node root;
+ try {
+ root = new Yaml(new SafeConstructor(new
LoaderOptions())).compose(new StringReader(content));
+ } catch (Exception e) {
+ return notes;
+ }
+ if (!(root instanceof MappingNode doc) ||
!"Kamelet".equals(scalar(value(doc, "kind")))
+ || !(value(doc, "spec") instanceof MappingNode spec)
+ || !(value(spec, "dependencies") instanceof SequenceNode deps)
+ || !(value(spec, "template") instanceof MappingNode template))
{
+ return notes;
+ }
+ Set<String> used = new HashSet<>();
+ // false when the template may use a component unseen: then only
camel:core is noted
+ boolean inspectable = collectUsed(null, template, used);
+ CamelCatalog catalog = catalog();
+ Set<String> usedArtifacts = new HashSet<>();
+ for (String name : used) {
+ String artifact = artifactOf(catalog, name);
+ if (artifact == null) {
+ // https: is a scheme of the http component, not a component
of its own
+ artifact = alternativeSchemes(catalog).get(name);
+ }
+ if (artifact != null) {
+ usedArtifacts.add(artifact);
+ }
+ }
+ for (Node d : deps.getValue()) {
+ String dep = scalar(d);
+ if (dep == null || !dep.startsWith("camel:")) {
+ continue;
+ }
+ String name = dep.substring("camel:".length());
+ if ("core".equals(name)) {
+ // every Camel runtime has camel-core; camel:kamelet is not
implied, it runs the template
+ notes.add(EndpointChecks.linePrefix(line(d)) +
"spec.dependencies: camel:core is implied, every Camel"
+ + " runtime has it: leave it out");
+ continue;
+ }
+ if (!inspectable) {
+ continue;
+ }
+ ComponentModel model = catalog.componentModel(name);
+ // only a component is checked: a language or a data format may be
used in ways the template does not show
+ if (model == null || catalog.languageModel(name) != null ||
catalog.dataFormatModel(name) != null
+ || usedArtifacts.contains(model.getArtifactId()) ||
delegatedTo(used, name)) {
+ continue;
+ }
+ notes.add(EndpointChecks.linePrefix(line(d)) + "spec.dependencies:
" + dep + " is not used by the template"
+ + " (it has no " + name + ": endpoint): list only the
components, languages and data formats the"
+ + " template uses");
+ }
+ return notes;
+ }
+
+ /** Components that run on another one the template does not name: cron on
quartz, rest-openapi on an http one. */
+ private static final Map<String, Pattern> DELEGATES = Map.of(
+ "cron", Pattern.compile("quartz|spring.*"),
+ "rest", Pattern.compile(".*http.*|undertow|jetty"),
+ "rest-openapi", Pattern.compile(".*http.*|undertow|jetty"));
+
+ private static boolean delegatedTo(Set<String> used, String component) {
+ for (Map.Entry<String, Pattern> e : DELEGATES.entrySet()) {
+ if (used.contains(e.getKey()) &&
e.getValue().matcher(component).matches()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Adds the keys and the uri schemes of a template to the set; false when
the template may use a component in a way
+ * the keys and schemes do not show.
+ */
+ private static boolean collectUsed(String key, Node node, Set<String>
used) {
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ String name = scalar(t.getKeyNode());
+ if ("beans".equals(name) || "bean".equals(name) ||
"method".equals(name)) {
+ // a class of the Kamelet's own may need the artifact (the
Kafka transform actions need camel:kafka)
+ return false;
+ }
+ if (name != null) {
+ used.add(name);
+ }
+ if (!collectUsed(name, t.getValueNode(), used)) {
+ return false;
+ }
+ }
+ } else if (node instanceof SequenceNode seq) {
+ for (Node n : seq.getValue()) {
+ if (!collectUsed(key, n, used)) {
+ return false;
+ }
+ }
+ } else if (node instanceof ScalarNode s) {
+ String v = s.getValue().trim();
+ if (v.contains("#class:") || v.contains("#type:")
+ || v.startsWith("{{") && ("uri".equals(key) ||
ENDPOINT_KEYS.contains(key))) {
+ // a bean of a class, or an endpoint whose scheme is a property
+ return false;
+ }
+ Matcher m = URI_SCHEME.matcher(v);
+ if (m.find()) {
+ used.add(m.group(1));
+ } else if ("uri".equals(key) || ENDPOINT_KEYS.contains(key)) {
+ // uri: aws2-sqs with the options under parameters:, as the
canonical form writes it
+ used.add(v.contains("?") ? v.substring(0, v.indexOf('?')) : v);
+ }
+ }
+ return true;
+ }
+
+ private static volatile Map<String, String> alternativeSchemes;
+
+ /** The alternative schemes of the components (https of http), each with
the artifact of its component. */
+ private static Map<String, String> alternativeSchemes(CamelCatalog
catalog) {
+ Map<String, String> answer = alternativeSchemes;
+ if (answer == null) {
+ answer = new HashMap<>();
+ for (String n : catalog.findComponentNames()) {
+ ComponentModel model = catalog.componentModel(n);
+ if (model != null && model.getAlternativeSchemes() != null) {
+ for (String scheme :
model.getAlternativeSchemes().split(",")) {
+ answer.putIfAbsent(scheme.trim(),
model.getArtifactId());
+ }
+ }
+ }
+ alternativeSchemes = answer;
+ }
+ return answer;
+ }
+
+ private static CamelCatalog catalog() {
+ CamelCatalog catalog = dependencyCatalog;
+ if (catalog == null) {
+ catalog = new DefaultCamelCatalog();
+ dependencyCatalog = catalog;
+ }
+ return catalog;
+ }
+
private static volatile CamelCatalog dependencyCatalog;
/**
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
index 066e207ef06a..c3b3f142073a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
@@ -287,6 +287,118 @@ class KameletChecksTest {
expression: "%s"
""";
+ // CAMEL-25403: a camel: dependency the template does not use, as the
model copied camel:timer from the source sample
+
+ private static String withDependencies(String kamelet, String... deps) {
+ StringBuilder sb = new StringBuilder(" dependencies:\n");
+ for (String d : deps) {
+ sb.append(" - \"").append(d).append("\"\n");
+ }
+ return kamelet.replace(" template:\n", sb + " template:\n");
+ }
+
+ @Test
+ void aDependencyTheTemplateDoesNotUseIsANote() {
+ String content = withDependencies(TAG_KAMELET.formatted("${body}
[{{tag}}]"), "camel:timer");
+
assertThat(KameletChecks.unusedDependencies(content)).singleElement().asString()
+ .contains("camel:timer is not used by the template")
+ .contains("no timer: endpoint");
+ // a note, not an error: the Kamelet works, so the write is not refused
+ assertThat(KameletChecks.validateKameletFile(content)).isEmpty();
+ }
+
+ @Test
+ void camelCoreIsImpliedAndCamelKameletIsNot() {
+ String content = withDependencies(TAG_KAMELET.formatted("${body}
[{{tag}}]"), "camel:core", "camel:kamelet");
+
assertThat(KameletChecks.unusedDependencies(content)).singleElement().asString()
+ .contains("camel:core is implied");
+ // also when the template uses a bean, whose class may need the other
dependencies
+ String bean = withDependencies(TAG_KAMELET.formatted("${body}"),
"camel:core", "camel:kafka")
+ .replace(" - setBody:\n",
+ " - bean:\n beanType:
org.example.HoistField\n - setBody:\n");
+
assertThat(KameletChecks.unusedDependencies(bean)).singleElement().asString()
+ .contains("camel:core is implied");
+ }
+
+ @Test
+ void theDependenciesTheTemplateUsesAreNotNoted() {
+ String source = """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: order-source
+ labels:
+ camel.apache.org/kamelet.type: source
+ spec:
+ definition:
+ title: Order Source
+ properties:
+ period:
+ title: Period
+ type: integer
+ default: 5000
+ dependencies:
+ - "camel:timer"
+ - "camel:jq"
+ - "camel:http"
+ - "camel:kamelet"
+ - "mvn:org.example:orders:1.0"
+ template:
+ from:
+ uri: timer:orders
+ parameters:
+ period: "{{period}}"
+ steps:
+ - setBody:
+ expression:
+ jq:
+ expression: '.'
+ - to:
+ uri: https://example.com/orders
+ - to:
+ uri: kamelet:sink
+ """;
+ assertThat(KameletChecks.unusedDependencies(source)).isEmpty();
+ }
+
+ @Test
+ void aTemplateThatMayUseAComponentUnseenIsNotNoted() {
+ String placeholderScheme =
withDependencies(TAG_KAMELET.formatted("${body}"), "camel:kafka")
+ .replace("uri: kamelet:source", "uri: \"{{scheme}}:orders\"");
+
assertThat(KameletChecks.unusedDependencies(placeholderScheme)).isEmpty();
+ String bean = withDependencies(TAG_KAMELET.formatted("${body}"),
"camel:kafka")
+ .replace(" from:\n",
+ " beans:\n - name: client\n type:
\"#class:org.example.Client\"\n from:\n");
+ assertThat(KameletChecks.unusedDependencies(bean)).isEmpty();
+ // the Kafka transform actions of the catalog call a class that needs
camel:kafka
+ String beanStep = withDependencies(TAG_KAMELET.formatted("${body}"),
"camel:kafka")
+ .replace(" - setBody:\n",
+ " - bean:\n beanType:
org.example.HoistField\n - setBody:\n");
+ assertThat(KameletChecks.unusedDependencies(beanStep)).isEmpty();
+ }
+
+ @Test
+ void aComponentThatRunsOnAnotherIsNotNoted() {
+ // cron runs on quartz, rest-openapi sends with an http component:
neither is named by the template
+ String cron = withDependencies(TAG_KAMELET.formatted("${body}"),
"camel:cron", "camel:quartz")
+ .replace("uri: kamelet:source", "uri:
\"cron:tick?schedule=0/3+*+*+*+*+?\"");
+ assertThat(KameletChecks.unusedDependencies(cron)).isEmpty();
+ String rest = withDependencies(TAG_KAMELET.formatted("${body}"),
"camel:rest-openapi", "camel:http")
+ .replace(" - setBody:\n", " - to:\n
uri: rest-openapi\n - setBody:\n");
+ assertThat(KameletChecks.unusedDependencies(rest)).isEmpty();
+ }
+
+ @Test
+ void writingAKameletReportsTheNotes(@TempDir Path dir) {
+ String content = withDependencies(TAG_KAMELET.formatted("${body}
[{{tag}}]"), "camel:timer");
+ JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir,
"tag-order-action.kamelet.yaml", content, true);
+ assertThat(result.getString("status")).isEqualTo("created");
+ assertThat(result.toJson()).contains("camel:timer is not used by the
template");
+ JsonObject clean = AuthoringTools.writeFile(new ToolContext(), dir,
"tag-order-action.kamelet.yaml",
+ TAG_KAMELET.formatted("${body} [{{tag}}]"), true);
+ assertThat(clean.containsKey("notes")).isFalse();
+ }
+
@Test
void aRightKameletFileIsValid(@TempDir Path dir) {
String content = TAG_KAMELET.formatted("${body} [{{tag}}]");