This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25403 in repository https://gitbox.apache.org/repos/asf/camel.git
commit d84ec460ae61cc3364097f0de487da391b9ea9d5 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Oct 7 07:22:40 2026 +0200 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 --- .../dsl/jbang/core/commands/ai/AuthoringTools.java | 18 +++ .../dsl/jbang/core/commands/ai/KameletChecks.java | 147 +++++++++++++++++++++ .../jbang/core/commands/ai/KameletChecksTest.java | 99 ++++++++++++++ 3 files changed, 264 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..ab5d38c3af38 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,152 @@ 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. + */ + 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<>(); + if (!collectUsed(null, template, used)) { + return notes; + } + 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()); + 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..7f874fe6ee5c 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,105 @@ 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 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}}]");
