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 b078bbeaf9a0 CAMEL-24715: camel-yaml-dsl-validator - table-driven 
hints; the camel-jbang checks require a catalog
b078bbeaf9a0 is described below

commit b078bbeaf9a01753b411175c68008f855e74a5b6
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 16 11:39:32 2026 +0200

    CAMEL-24715: camel-yaml-dsl-validator - table-driven hints; the camel-jbang 
checks require a catalog
    
    The four hint methods of YamlValidator and compactNotationHint had grown 
into
    if chains with the Error.builder() boilerplate copied 22 times. They are now
    five row tables (STEP, EXPRESSION, LIST, PROPERTY, COMPACT) in a new
    package-private SchemaHints class, with one matcher that picks the first
    matching row and builds the rewritten Error in one place. Behaviour-neutral:
    every existing validator, camel-jbang-core, -mcp and -tui test that pins a
    hint text passes unchanged; SchemaHintsTest pins the matcher itself.
    YamlValidator loses ~400 lines.
    
    In camel-jbang the write-time checks now require a catalog, as every other
    check already did: the no-catalog fallback list in BeanRefChecks only ever
    checked aggregationStrategy (its other entries were Camel 2/3 names), so it
    is gone and the interface an option needs always comes from the EIP models.
    camel validate yaml now passes its catalog, so the CLI checks the same
    interfaces as the MCP and TUI tools; the TUI validates against the CLI's own
    catalog when no integration is selected; camel_catalog_sample validates its
    samples against the schema of the catalog version they come from through the
    new validateYamlSchema(content, catalog). ChecksCatalogDriftTest 
cross-checks
    the remaining hardcoded lists (INVENTED_OPTIONS, EXCHANGE_PROPERTIES) 
against
    the catalog so they cannot drift silently.
    
    Closes #26489
    
    Co-authored-by: Claude <[email protected]>
---
 .../dsl/jbang/core/commands/ai/BeanRefChecks.java  |  43 +--
 .../dsl/jbang/core/commands/ai/CatalogSamples.java |   2 +-
 .../dsl/jbang/core/commands/ai/HeaderChecks.java   |   2 +-
 .../jbang/core/commands/ai/PropertiesChecks.java   |  58 ++-
 .../jbang/core/commands/ai/SourceValidator.java    |  50 ++-
 .../core/commands/ai/ChecksCatalogDriftTest.java   | 109 ++++++
 .../commands/ai/SourceValidatorBeanRefsTest.java   |  69 ++--
 .../commands/ai/SourceValidatorJavaXsltTest.java   |   3 +-
 .../commands/ai/SourceValidatorVersionTest.java    |   4 +-
 .../jbang/core/commands/tui/SourceEditAssist.java  |  39 +-
 .../commands/validate/YamlValidateCommand.java     |   2 +-
 .../camel/dsl/yaml/validator/SchemaHints.java      | 412 ++++++++++++++++++++
 .../camel/dsl/yaml/validator/YamlValidator.java    | 425 +--------------------
 .../camel/dsl/yaml/validator/SchemaHintsTest.java  | 127 ++++++
 14 files changed, 816 insertions(+), 529 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
index f67f9a3d6440..ad8d2ce43ac6 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/BeanRefChecks.java
@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -47,11 +48,6 @@ final class BeanRefChecks {
 
     static final Pattern BEAN_NAME_PATTERN = 
Pattern.compile("^\\s*-?\\s*name:\\s*(\\S+)\\s*$");
 
-    static final Pattern BEAN_REF_PATTERN = Pattern.compile(
-            
"^\\s*-?\\s*(ref|aggregationStrategy|strategyRef|processorRef|loadBalancerRef|executorServiceRef|onPrepareRef"
-                                                            + 
"|onRedeliveryRef|aggregationRepositoryRef|comparatorRef|bean|processor)"
-                                                            + 
":\\s*(\\S+)\\s*$");
-
     static final Pattern BEAN_TYPE_PATTERN = 
Pattern.compile("^\\s*type:\\s*[\"']?#class:([\\w.$]+)");
 
     /** The beans declared under {@code beans:} with a {@code #class:} type, 
name to fully qualified class name. */
@@ -75,15 +71,6 @@ final class BeanRefChecks {
         return types;
     }
 
-    /**
-     * Without a catalog: the options whose bean must implement an interface, 
and which (a subset of the EIP models).
-     */
-    static final Map<String, String> REQUIRED_TYPES = Map.of(
-            "aggregationStrategy", "org.apache.camel.AggregationStrategy",
-            "strategyRef", "org.apache.camel.AggregationStrategy",
-            "processorRef", "org.apache.camel.Processor",
-            "processor", "org.apache.camel.Processor");
-
     private static final Map<CamelCatalog, Map<String, String>> 
REQUIRED_TYPES_BY_CATALOG
             = java.util.Collections.synchronizedMap(new 
java.util.WeakHashMap<>());
     private static final Map<CamelCatalog, Pattern> BEAN_REF_PATTERN_BY_CATALOG
@@ -93,12 +80,9 @@ final class BeanRefChecks {
      * The interface the bean of an option must implement, from the EIP models 
of the catalog: every object option whose
      * javaType is a Camel interface (aggregationStrategy needs 
org.apache.camel.AggregationStrategy,
      * idempotentRepository needs org.apache.camel.spi.IdempotentRepository, 
...), the same metadata the visual editors
-     * use to offer the built-in beans. Without a catalog the static subset 
above.
+     * use to offer the built-in beans.
      */
     static String requiredType(CamelCatalog catalog, String option) {
-        if (catalog == null) {
-            return REQUIRED_TYPES.get(option);
-        }
         return requiredTypes(catalog).get(option);
     }
 
@@ -122,16 +106,18 @@ final class BeanRefChecks {
         });
     }
 
-    /** {@link #BEAN_REF_PATTERN} plus every option the catalog's EIP models 
type with a Camel interface. */
+    /** The options whose value is a bean name whatever the catalog says: ref, 
bean and the *Ref options. */
+    private static final List<String> REF_OPTIONS = List.of("ref", 
"aggregationStrategy", "loadBalancerRef",
+            "executorServiceRef", "onPrepareRef", "onRedeliveryRef", 
"aggregationRepositoryRef", "comparatorRef", "bean");
+
+    /**
+     * The lines that reference a bean: option: name, for {@link #REF_OPTIONS} 
and every option the catalog's EIP models
+     * type with a Camel interface.
+     */
     static Pattern beanRefPattern(CamelCatalog catalog) {
-        if (catalog == null) {
-            return BEAN_REF_PATTERN;
-        }
         return BEAN_REF_PATTERN_BY_CATALOG.computeIfAbsent(catalog, c -> {
-            java.util.Set<String> names = new 
java.util.TreeSet<>(requiredTypes(c).keySet());
-            names.addAll(List.of("ref", "aggregationStrategy", "strategyRef", 
"processorRef", "loadBalancerRef",
-                    "executorServiceRef", "onPrepareRef", "onRedeliveryRef", 
"aggregationRepositoryRef", "comparatorRef",
-                    "bean", "processor"));
+            Set<String> names = new TreeSet<>(requiredTypes(c).keySet());
+            names.addAll(REF_OPTIONS);
             return Pattern.compile("^\\s*-?\\s*(" + String.join("|", names) + 
"):\\s*(\\S+)\\s*$");
         });
     }
@@ -173,11 +159,6 @@ final class BeanRefChecks {
      */
     static final Pattern SIMPLE_BEAN_FUNCTION = 
Pattern.compile("\\$\\{bean:([A-Za-z_][\\w-]*)");
 
-    public static List<String> validateYamlBeanRefs(String content, 
BeanDeclarations external) {
-        return validateYamlBeanRefs(content, external, null);
-    }
-
-    /** As above, and with a catalog the messages about a strategy name the 
built-in implementations. */
     public static List<String> validateYamlBeanRefs(String content, 
BeanDeclarations external, CamelCatalog catalog) {
         List<String> msgs = new ArrayList<>();
         if (content == null) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
index f6d0996e6f24..71c4e4f20cf0 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
@@ -172,7 +172,7 @@ public final class CatalogSamples {
                     if (!yaml.stripLeading().startsWith("- ")) {
                         continue;
                     }
-                    if (SourceValidator.validateCamelYaml(yaml, 
null).isEmpty()) {
+                    if (SourceValidator.validateYamlSchema(yaml, 
catalog).isEmpty()) {
                         answer.add(Map.of("source", page + ".adoc (Camel " + 
catalog.getCatalogVersion() + ")", "yaml", yaml));
                     }
                 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
index 23fd298f82d2..0d520c946ee4 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HeaderChecks.java
@@ -58,7 +58,7 @@ final class HeaderChecks {
 
     public static List<String> validateKnownHeaders(String content, 
CamelCatalog catalog) {
         List<String> msgs = new ArrayList<>();
-        if (content == null || catalog == null) {
+        if (content == null) {
             return msgs;
         }
         Set<String> known = new LinkedHashSet<>();
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
index 7781d6fda0fc..4ddc2265e610 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/PropertiesChecks.java
@@ -61,41 +61,39 @@ final class PropertiesChecks {
             return "logging.level.root=" + rl.group(1) + " also hides the 
route's own log steps (they log at INFO under"
                    + " the route file's name): keep root at INFO, or add 
logging.level.<route-file-name>=INFO";
         }
-        if (catalog != null) {
-            // camel.component.logger.level: the catalog skips a component it 
does not know, camel run does not
-            Matcher km = COMPONENT_KEY_PATTERN.matcher(line);
-            if (km.find()) {
-                String kind = km.group(1);
-                String name = km.group(2);
-                List<String> known = switch (kind) {
-                    case "component" -> catalog.findComponentNames();
-                    case "dataformat" -> catalog.findDataFormatNames();
-                    default -> catalog.findLanguageNames();
-                };
-                if (!known.contains(name)) {
-                    String closest = closestName(name, known);
-                    return name + "    Unknown " + kind + (closest != null ? " 
(did you mean " + closest + "?)" : "");
-                }
+        // camel.component.logger.level: the catalog skips a component it does 
not know, camel run does not
+        Matcher km = COMPONENT_KEY_PATTERN.matcher(line);
+        if (km.find()) {
+            String kind = km.group(1);
+            String name = km.group(2);
+            List<String> known = switch (kind) {
+                case "component" -> catalog.findComponentNames();
+                case "dataformat" -> catalog.findDataFormatNames();
+                default -> catalog.findLanguageNames();
+            };
+            if (!known.contains(name)) {
+                String closest = closestName(name, known);
+                return name + "    Unknown " + kind + (closest != null ? " 
(did you mean " + closest + "?)" : "");
             }
-            try {
-                ConfigurationPropertiesValidationResult result = 
catalog.validateConfigurationProperty(line);
-                if (result.isAccepted()) {
-                    if (!result.isSuccess()) {
-                        String msg = result.summaryErrorMessage(false);
-                        if (msg != null) {
-                            msg = msg.trim();
-                            String hint = mainOptionHint(line, catalog);
-                            if (hint == null && km.reset().find() && 
"component".equals(km.group(1))) {
-                                hint = endpointOptionHint(km.group(2), line, 
catalog);
-                            }
-                            return hint != null ? msg + " " + hint : msg;
+        }
+        try {
+            ConfigurationPropertiesValidationResult result = 
catalog.validateConfigurationProperty(line);
+            if (result.isAccepted()) {
+                if (!result.isSuccess()) {
+                    String msg = result.summaryErrorMessage(false);
+                    if (msg != null) {
+                        msg = msg.trim();
+                        String hint = mainOptionHint(line, catalog);
+                        if (hint == null && km.reset().find() && 
"component".equals(km.group(1))) {
+                            hint = endpointOptionHint(km.group(2), line, 
catalog);
                         }
+                        return hint != null ? msg + " " + hint : msg;
                     }
-                    return null;
                 }
-            } catch (Exception e) {
-                // ignore validation errors
+                return null;
             }
+        } catch (Exception e) {
+            // ignore validation errors
         }
         return extra != null ? extra.apply(line) : null;
     }
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 939c3672a0a1..a596167f0bcc 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
@@ -25,6 +25,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Function;
@@ -71,7 +72,8 @@ public final class SourceValidator {
      *
      * @param  fileName          the file name; its extension picks the checks
      * @param  content           the source
-     * @param  catalog           the catalog of the Camel version the source 
is for
+     * @param  catalog           the catalog of the Camel version the source 
is for (required: every check that is not
+     *                           the schema reads it)
      * @param  extraPropertyLine an extra check for a properties line the 
catalog does not know (Spring Boot
      *                           properties), returning the message or null; 
may be null
      * @return                   the messages, empty when the source is valid
@@ -99,6 +101,7 @@ public final class SourceValidator {
     public static List<String> validate(
             String fileName, String content, CamelCatalog catalog, 
Function<String, String> extraPropertyLine,
             Path directory, YamlValidator schemaValidator) {
+        Objects.requireNonNull(catalog, "catalog");
         String name = fileName == null ? "" : 
fileName.toLowerCase(Locale.ROOT);
         if (name.endsWith(".yaml") || name.endsWith(".yml")) {
             List<String> msgs = validateCamelYaml(content, catalog, 
schemaValidator);
@@ -147,22 +150,44 @@ public final class SourceValidator {
         if (content == null || content.isBlank()) {
             return msgs;
         }
+        if (validateYamlSchema(content, catalog, schemaValidator, msgs)) {
+            msgs.addAll(validateYamlCatalog(content, catalog));
+        }
+        return msgs;
+    }
+
+    /**
+     * The schema half of {@link #validateCamelYaml(String, CamelCatalog)}: 
the YAML DSL schema of the catalog's Camel
+     * version, without the catalog checks. For a sample that is right for its 
version but uses what the catalog cannot
+     * know (a custom step, a header a component sets at runtime).
+     */
+    public static List<String> validateYamlSchema(String content, CamelCatalog 
catalog) {
+        Objects.requireNonNull(catalog, "catalog");
+        List<String> msgs = new ArrayList<>();
+        if (content != null && !content.isBlank()) {
+            validateYamlSchema(content, catalog, null, msgs);
+        }
+        return msgs;
+    }
+
+    /** Adds the schema errors to msgs; false when the YAML could not be 
checked at all (no schema, not YAML). */
+    private static boolean validateYamlSchema(
+            String content, CamelCatalog catalog, YamlValidator 
schemaValidator, List<String> msgs) {
         YamlValidator validator;
         try {
             validator = schemaValidator != null ? schemaValidator : 
yamlValidator(catalog);
         } catch (Exception e) {
             msgs.add("Cannot validate against the YAML DSL schema of Camel " + 
catalog.getCatalogVersion() + ": "
                      + e.getMessage());
-            return msgs;
+            return false;
         }
         try {
             msgs.addAll(formatSchemaErrors(validator.validate(content)));
+            return true;
         } catch (Exception e) {
             msgs.add("Invalid YAML: " + e.getMessage());
-            return msgs;
+            return false;
         }
-        msgs.addAll(validateYamlCatalog(content, catalog));
-        return msgs;
     }
 
     /**
@@ -170,7 +195,7 @@ public final class SourceValidator {
      * for the schema of the version the catalog was loaded for (CAMEL-24711), 
built once per version.
      */
     static YamlValidator yamlValidator(CamelCatalog catalog) throws Exception {
-        String version = catalog != null ? catalog.getCatalogVersion() : null;
+        String version = catalog.getCatalogVersion();
         if (version == null || version.equals(BUILTIN_VERSION)) {
             return yamlValidator();
         }
@@ -198,7 +223,7 @@ public final class SourceValidator {
      */
     public static List<String> validateYamlCatalog(String content, 
CamelCatalog catalog) {
         List<String> msgs = new ArrayList<>();
-        if (content == null || content.isBlank() || catalog == null) {
+        if (content == null || content.isBlank()) {
             return msgs;
         }
         msgs.addAll(validateYamlEndpoints(content, catalog));
@@ -207,7 +232,7 @@ public final class SourceValidator {
         return msgs;
     }
 
-    private static YamlValidator yamlValidator() throws Exception {
+    static YamlValidator yamlValidator() throws Exception {
         YamlValidator v = yamlValidator;
         if (v == null) {
             synchronized (SourceValidator.class) {
@@ -394,11 +419,10 @@ public final class SourceValidator {
         return BeanRefChecks.declaredBeans(content);
     }
 
-    public static List<String> validateYamlBeanRefs(String content, 
BeanDeclarations external) {
-        return BeanRefChecks.validateYamlBeanRefs(content, external);
-    }
-
-    /** As above, and with a catalog the messages about a strategy name the 
built-in implementations. */
+    /**
+     * Bean references in the YAML that nothing declares, each with how to 
declare it; a bean whose option needs a Camel
+     * interface (an aggregationStrategy, an onPrepare processor...) must 
implement it.
+     */
     public static List<String> validateYamlBeanRefs(String content, 
BeanDeclarations external, CamelCatalog catalog) {
         return BeanRefChecks.validateYamlBeanRefs(content, external, catalog);
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChecksCatalogDriftTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChecksCatalogDriftTest.java
new file mode 100644
index 000000000000..427e7a5d0908
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ChecksCatalogDriftTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24715: the write-time checks carry a few lists the catalog has no 
metadata for. Each states something about a
+ * component or an EIP that a later release can change; this cross-checks them 
against the catalog and the API so they
+ * cannot drift silently. A failure names every entry that drifted.
+ */
+class ChecksCatalogDriftTest {
+
+    private static CamelCatalog catalog;
+
+    @BeforeAll
+    static void loadCatalog() {
+        catalog = new DefaultCamelCatalog();
+    }
+
+    /**
+     * The premise of every entry is that the component does not have that 
option: the day it does, the hint tells the
+     * user to remove or rename an option that works.
+     */
+    @Test
+    void inventedOptionsAreNotComponentOptions() {
+        List<String> drifted = new ArrayList<>();
+        for (String key : new 
TreeSet<>(EndpointChecks.INVENTED_OPTIONS.keySet())) {
+            String scheme = key.substring(0, key.indexOf(':'));
+            String option = key.substring(scheme.length() + 1);
+            ComponentModel model = catalog.componentModel(scheme);
+            if (model == null) {
+                drifted.add(key + ": no component " + scheme + " in the 
catalog");
+                continue;
+            }
+            Set<String> options = new TreeSet<>();
+            model.getEndpointOptions().forEach(o -> options.add(o.getName()));
+            model.getComponentOptions().forEach(o -> options.add(o.getName()));
+            if (options.contains(option)) {
+                drifted.add(key + ": " + option + " is a real option of " + 
scheme + " now, drop the entry");
+            }
+        }
+        assertThat(drifted).as("INVENTED_OPTIONS entries the catalog 
contradicts").isEmpty();
+    }
+
+    /**
+     * The exchange properties of the timer are the Exchange.TIMER_* constants 
the consumer sets; a name the catalog
+     * lists as a header of the component is a header, and the hint would send 
the user to the wrong place.
+     */
+    @Test
+    void exchangePropertiesAreTheApiConstantsAndNotHeaders() throws Exception {
+        Set<String> timerConstants = new TreeSet<>();
+        for (Field f : Exchange.class.getFields()) {
+            if (f.getName().startsWith("TIMER_") && 
Modifier.isStatic(f.getModifiers()) && f.getType() == String.class) {
+                timerConstants.add((String) f.get(null));
+            }
+        }
+        assertThat(timerConstants).isNotEmpty();
+        List<String> drifted = new ArrayList<>();
+        for (Map.Entry<String, List<String>> entry : 
HeaderChecks.EXCHANGE_PROPERTIES.entrySet()) {
+            String scheme = entry.getKey();
+            ComponentModel model = catalog.componentModel(scheme);
+            if (model == null) {
+                drifted.add(scheme + ": no such component in the catalog");
+                continue;
+            }
+            Set<String> headers = new TreeSet<>();
+            model.getEndpointHeaders().forEach(h -> headers.add(h.getName()));
+            for (String name : entry.getValue()) {
+                if ("timer".equals(scheme) && !timerConstants.contains(name)) {
+                    drifted.add(scheme + ": " + name + " is not an 
Exchange.TIMER_* constant");
+                }
+                if (headers.contains(name)) {
+                    drifted.add(scheme + ": " + name + " is a header in the 
catalog, not an exchange property");
+                }
+            }
+        }
+        assertThat(drifted).as("EXCHANGE_PROPERTIES entries the API or the 
catalog contradicts").isEmpty();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
index cc12a96aab76..cb468d430b02 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorBeanRefsTest.java
@@ -21,6 +21,8 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
@@ -31,6 +33,8 @@ import static org.assertj.core.api.Assertions.assertThat;
  */
 public class SourceValidatorBeanRefsTest {
 
+    private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
     private static final String ROUTE = """
             - route:
                 from:
@@ -53,7 +57,8 @@ public class SourceValidatorBeanRefsTest {
                 }
                 """);
         List<String> msgs
-                = SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"));
+                = SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                        CATALOG);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0))
                 .startsWith("Line 9: aggregationStrategy: bean 'myAggregator' 
is not declared")
@@ -68,14 +73,15 @@ public class SourceValidatorBeanRefsTest {
                     - name: myAggregator
                       type: "#class:com.example.MyAggregator"
                 """ + ROUTE;
-        assertThat(SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+        assertThat(SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
 
         Files.writeString(dir.resolve("beans.camel.yaml"), """
                 - beans:
                     - name: myAggregator
                       type: "#class:com.example.MyAggregator"
                 """);
-        assertThat(SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+        assertThat(SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                CATALOG))
                 .isEmpty();
 
         Files.delete(dir.resolve("beans.camel.yaml"));
@@ -86,7 +92,8 @@ public class SourceValidatorBeanRefsTest {
                 public class MyAggregator {
                 }
                 """);
-        assertThat(SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+        assertThat(SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                CATALOG))
                 .isEmpty();
     }
 
@@ -94,24 +101,23 @@ public class SourceValidatorBeanRefsTest {
     void classReferencesPlaceholdersAndUnknownDirectoryAreLeftAlone(@TempDir 
Path dir) {
         String yaml = ROUTE.replace("aggregationStrategy: myAggregator",
                 "aggregationStrategy: \"#class:com.example.MyAggregator\"");
-        assertThat(SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+        assertThat(SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
         yaml = ROUTE.replace("aggregationStrategy: myAggregator", 
"aggregationStrategy: \"{{strategy}}\"");
-        assertThat(SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+        assertThat(SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
         yaml = ROUTE.replace("aggregationStrategy: myAggregator", 
"aggregationStrategy: com.example.MyAggregator");
-        assertThat(SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE)).isEmpty();
+        assertThat(SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
         // no directory given: the check is not run at all by validate(...)
-        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, null, 
null)).isEmpty();
+        assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null)).isEmpty();
     }
 
     @Test
     void theRequiredInterfaceComesFromTheEipModels(@TempDir Path dir) throws 
IOException {
-        // idempotentRepository is not in the static subset; the catalog's EIP 
model says what it needs
-        org.apache.camel.catalog.CamelCatalog catalog = new 
org.apache.camel.catalog.DefaultCamelCatalog();
-        assertThat(BeanRefChecks.requiredType(catalog, "idempotentRepository"))
+        // the catalog's EIP model says what each option needs
+        assertThat(BeanRefChecks.requiredType(CATALOG, "idempotentRepository"))
                 .isEqualTo("org.apache.camel.spi.IdempotentRepository");
-        assertThat(BeanRefChecks.requiredType(catalog, "aggregationStrategy"))
+        assertThat(BeanRefChecks.requiredType(CATALOG, "aggregationStrategy"))
                 .isEqualTo("org.apache.camel.AggregationStrategy");
-        assertThat(BeanRefChecks.requiredType(catalog, "ref")).isNull();
+        assertThat(BeanRefChecks.requiredType(CATALOG, "ref")).isNull();
         Files.writeString(dir.resolve("MyRepo.java"), """
                 package com.example;
                 public class MyRepo {
@@ -131,7 +137,7 @@ public class SourceValidatorBeanRefsTest {
                             - log: hi
                 """;
         List<String> msgs = SourceValidator.validateYamlBeanRefs(yaml,
-                SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"), 
catalog);
+                SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"), 
CATALOG);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).contains("com.example.MyRepo must implement 
org.apache.camel.spi.IdempotentRepository")
                 .contains("the built-in ones are");
@@ -150,13 +156,15 @@ public class SourceValidatorBeanRefsTest {
                       type: "#class:com.example.MyAggregator"
                 """ + ROUTE;
         List<String> msgs
-                = SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"));
+                = SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                        CATALOG);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).contains("com.example.MyAggregator must 
implement org.apache.camel.AggregationStrategy");
 
         String direct = ROUTE.replace("aggregationStrategy: myAggregator",
                 "aggregationStrategy: \"#class:com.example.MyAggregator\"");
-        assertThat(SourceValidator.validateYamlBeanRefs(direct, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+        assertThat(SourceValidator.validateYamlBeanRefs(direct, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                CATALOG))
                 .hasSize(1);
 
         Files.writeString(dir.resolve("MyAggregator.java"), """
@@ -165,7 +173,8 @@ public class SourceValidatorBeanRefsTest {
                 public class MyAggregator implements AggregationStrategy {
                 }
                 """);
-        assertThat(SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+        assertThat(SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                CATALOG))
                 .isEmpty();
     }
 
@@ -182,7 +191,8 @@ public class SourceValidatorBeanRefsTest {
                       - log: "${bean:counter?method=count}"
                 """;
         List<String> msgs
-                = SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"));
+                = SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                        CATALOG);
         assertThat(msgs).hasSize(2);
         assertThat(msgs.get(0))
                 .startsWith("Line 5: ${bean:counter}: bean 'counter' is not 
declared: Counter.java is in the directory")
@@ -192,7 +202,8 @@ public class SourceValidatorBeanRefsTest {
                     - name: counter
                       type: "#class:com.example.Counter"
                 """ + yaml;
-        assertThat(SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml")))
+        assertThat(SourceValidator.validateYamlBeanRefs(declared, 
SourceValidator.BeanDeclarations.scan(dir, "r.camel.yaml"),
+                CATALOG))
                 .isEmpty();
     }
 
@@ -208,17 +219,17 @@ public class SourceValidatorBeanRefsTest {
                       - setBody:
                           simple: "${bean:counter:count}"
                 """;
-        List<String> msgs = SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE);
+        List<String> msgs = SourceValidator.validateYamlBeanRefs(yaml, 
SourceValidator.BeanDeclarations.NONE, CATALOG);
         assertThat(msgs).hasSize(1);
         
assertThat(msgs.get(0)).contains("${bean:counter.count}").contains("${bean:counter?method=count}")
                 .contains("not with a single colon");
         
assertThat(SourceValidator.validateYamlBeanRefs(yaml.replace("counter:count", 
"counter::count"),
-                SourceValidator.BeanDeclarations.NONE)).isEmpty();
+                SourceValidator.BeanDeclarations.NONE, CATALOG)).isEmpty();
     }
 
     @Test
     void unknownBeanWithoutAJavaFileSaysHowToDeclareIt() {
-        List<String> msgs = SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.NONE);
+        List<String> msgs = SourceValidator.validateYamlBeanRefs(ROUTE, 
SourceValidator.BeanDeclarations.NONE, CATALOG);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).contains("not declared in this file or its 
directory").contains("- beans:");
     }
@@ -240,7 +251,7 @@ public class SourceValidatorBeanRefsTest {
                           uri: velocity:missing.vm
                       - to:
                           uri: xslt:http://example.com/x.xsl
-                """, null, null, dir);
+                """, CATALOG, null, dir);
         assertThat(msgs).hasSize(3);
         assertThat(msgs.get(0)).startsWith("Line 5: xslt: the file 
stylesheets/customers-to-html.xsl does not exist")
                 .contains("write xslt:customers-to-html.xsl");
@@ -263,7 +274,7 @@ public class SourceValidatorBeanRefsTest {
                     - name: myAggregator
                       type: "#class:MyAggregator"
                 """ + ROUTE;
-        assertThat(SourceValidator.validate("r.camel.yaml", declared, null, 
null, dir)).isEmpty();
+        assertThat(SourceValidator.validate("r.camel.yaml", declared, CATALOG, 
null, dir)).isEmpty();
 
         Files.writeString(dir.resolve("MyAggregator.java"), """
                 public class MyAggregator {
@@ -277,7 +288,7 @@ public class SourceValidatorBeanRefsTest {
                     }
                 }
                 """);
-        List<String> msgs = SourceValidator.validate("r.camel.yaml", declared, 
null, null, dir);
+        List<String> msgs = SourceValidator.validate("r.camel.yaml", declared, 
CATALOG, null, dir);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).contains("has 2 public 
methods").contains("aggregationStrategyMethodName");
     }
@@ -307,7 +318,7 @@ public class SourceValidatorBeanRefsTest {
                           ref: leakSimulator
                           method: addObjects
                 """;
-        List<String> msgs = SourceValidator.validate("r.camel.yaml", route, 
null, null, dir);
+        List<String> msgs = SourceValidator.validate("r.camel.yaml", route, 
CATALOG, null, dir);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).startsWith("Line 8: bean leakSimulator has 2 
public methods (addObjects, getLeakedObjectCount)")
                 .contains("add method: <name>");
@@ -332,7 +343,7 @@ public class SourceValidatorBeanRefsTest {
                           aggregationStrategy: 
"#class:org.apache.camel.processor.aggregate.StringAggregationStrategy"
                           steps:
                             - log: "${body}"
-                """, null, null, dir);
+                """, CATALOG, null, dir);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0))
                 .startsWith("Line 3: type: class 
org.apache.camel.support.StringAggregationStrategy was not found")
@@ -356,7 +367,7 @@ public class SourceValidatorBeanRefsTest {
                         return new MemoryLeakSimulator().count();
                     }
                 }
-                """, null, null, dir);
+                """, CATALOG, null, dir);
         assertThat(msgs).isNotEmpty();
         assertThat(msgs.get(0)).contains("cannot find symbol")
                 .contains("MemoryLeakSimulator is the class in 
MemoryLeakSimulator.java next to this file")
@@ -374,7 +385,7 @@ public class SourceValidatorBeanRefsTest {
                     uri: timer:tick
                     steps:
                       - log: "a"
-                """, null, null, dir);
+                """, CATALOG, null, dir);
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).contains("is an inner class of 
Sim").contains("Leak.java next to the route");
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
index 032407fe32cd..6c82698c8c2a 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorJavaXsltTest.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
 
 import java.util.List;
 
+import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -214,7 +215,7 @@ public class SourceValidatorJavaXsltTest {
         assertThat(SourceValidator.isValidatableFile("t.xsl")).isTrue();
         assertThat(SourceValidator.isValidatableFile("in.xml")).isTrue();
         assertThat(SourceValidator.isValidatableFile("README.md")).isFalse();
-        assertThat(SourceValidator.validate("t.xsl", "<not-xslt/>", null, 
null)).isNotEmpty();
+        assertThat(SourceValidator.validate("t.xsl", "<not-xslt/>", new 
DefaultCamelCatalog(), null)).isNotEmpty();
     }
 
     @Test
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
index b105e6e0c8e4..687d6bbd0fcb 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorVersionTest.java
@@ -74,7 +74,7 @@ class SourceValidatorVersionTest {
     @Test
     void theBuiltInCatalogUsesTheBuiltInSchema() throws Exception {
         YamlValidator v = SourceValidator.yamlValidator(new 
DefaultCamelCatalog());
-        assertThat(v).isSameAs(SourceValidator.yamlValidator(null));
+        assertThat(v).isSameAs(SourceValidator.yamlValidator());
     }
 
     @Test
@@ -85,7 +85,7 @@ class SourceValidatorVersionTest {
         assertThat(older.getCatalogVersion()).isEqualTo("4.18.0");
 
         YamlValidator v = SourceValidator.yamlValidator(older);
-        assertThat(v).isNotSameAs(SourceValidator.yamlValidator(null));
+        assertThat(v).isNotSameAs(SourceValidator.yamlValidator());
         assertThat(SourceValidator.yamlValidator(older)).as("one validator per 
version").isSameAs(v);
 
         // a route both versions accept validates; the schema read from the 
4.18.0 jar is the one that answers
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index 548950dcc4c2..247d49a96e00 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -34,6 +34,7 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator;
 import org.apache.camel.tooling.model.BaseOptionModel;
 import org.apache.camel.tooling.model.ComponentModel;
@@ -53,6 +54,7 @@ final class SourceEditAssist {
 
     private final MonitorContext ctx;
     private final CatalogCache catalogCache = new CatalogCache();
+    private volatile CamelCatalog defaultCatalog;
     private Path rootDir;
 
     // ---- YAML DSL completion ----
@@ -118,6 +120,23 @@ final class SourceEditAssist {
         return catalogCache.get(ctx.findSelectedIntegration());
     }
 
+    /**
+     * The catalog the validation checks run against: the one of the selected 
integration's Camel version, or the CLI's
+     * own when no integration is selected or that version's catalog could not 
be loaded (the checks need one).
+     */
+    CamelCatalog validationCatalog() {
+        CamelCatalog catalog = getCatalog();
+        if (catalog == null) {
+            CamelCatalog fallback = defaultCatalog;
+            if (fallback == null) {
+                fallback = new DefaultCamelCatalog();
+                defaultCatalog = fallback;
+            }
+            catalog = fallback;
+        }
+        return catalog;
+    }
+
     Map<Integer, List<SourceViewer.DocEntry>> 
provideCamelQuickDocs(List<JsonObject> codeData) {
         CamelCatalog catalog = getCatalog();
         if (catalog == null || codeData.isEmpty()) {
@@ -1431,7 +1450,7 @@ final class SourceEditAssist {
     }
 
     String validatePropertyLine(String line) {
-        return SourceValidator.validatePropertyLine(line, getCatalog(), 
this::validateSpringBootPropertyLine);
+        return SourceValidator.validatePropertyLine(line, validationCatalog(), 
this::validateSpringBootPropertyLine);
     }
 
     String validateSpringBootPropertyLine(String line) {
@@ -1478,12 +1497,12 @@ final class SourceEditAssist {
      * empty when the source is valid.
      */
     List<String> validateCamelYaml(String content) {
-        return SourceValidator.validateCamelYaml(content, getCatalog());
+        return SourceValidator.validateCamelYaml(content, validationCatalog());
     }
 
     /** Validates a properties file (application.properties) line by line 
against the catalog, as the editor does. */
     List<String> validateProperties(String content) {
-        return SourceValidator.validateProperties(content, getCatalog(), 
this::validateSpringBootPropertyLine);
+        return SourceValidator.validateProperties(content, 
validationCatalog(), this::validateSpringBootPropertyLine);
     }
 
     /**
@@ -1491,7 +1510,7 @@ final class SourceEditAssist {
      * Camel and Spring Boot options for .properties files. Other file types 
have no validation and yield no messages.
      */
     List<String> validateSource(String fileName, String content) {
-        return SourceValidator.validate(fileName, content, getCatalog(), 
this::validateSpringBootPropertyLine);
+        return SourceValidator.validate(fileName, content, 
validationCatalog(), this::validateSpringBootPropertyLine);
     }
 
     static boolean isValidatableFile(String fileName) {
@@ -1499,19 +1518,11 @@ final class SourceEditAssist {
     }
 
     List<String> validateYamlEndpoints(String content) {
-        CamelCatalog catalog = getCatalog();
-        if (catalog == null) {
-            return List.of();
-        }
-        return SourceValidator.validateYamlEndpoints(content, catalog);
+        return SourceValidator.validateYamlEndpoints(content, 
validationCatalog());
     }
 
     List<String> validateYamlSimple(String content) {
-        CamelCatalog catalog = getCatalog();
-        if (catalog == null) {
-            return List.of();
-        }
-        return SourceValidator.validateYamlSimple(content, catalog);
+        return SourceValidator.validateYamlSimple(content, 
validationCatalog());
     }
 
     static String extractEipFromLine(String trimmed) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
index 68d8112311c9..816c7b175ac5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
@@ -85,7 +85,7 @@ public class YamlValidateCommand extends CamelCommand {
                         File parent = new 
File(n).getAbsoluteFile().getParentFile();
                         var declared = 
SourceValidator.BeanDeclarations.scan(parent != null ? parent.toPath() : null,
                                 new File(n).getName());
-                        for (String msg : 
SourceValidator.validateYamlBeanRefs(content, declared)) {
+                        for (String msg : 
SourceValidator.validateYamlBeanRefs(content, declared, camelCatalog)) {
                             report.add(catalogError(msg));
                         }
                         if (parent != null) {
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
new file mode 100644
index 000000000000..7e2c096c1cf6
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
@@ -0,0 +1,412 @@
+/*
+ * 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.yaml.validator;
+
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.Error;
+
+/**
+ * The hints the validator adds to the schema library's errors so they say 
what to write: one table per stage of the
+ * pipeline in {@link YamlValidator}, each row a keyword, a location pattern, 
a condition and the text. The first row
+ * that matches an error rewrites it; an error no row matches is kept as is.
+ */
+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) {
+
+        static Match of(Error error, YamlValidator validator) {
+            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);
+        }
+
+        /** The key before the last segment: the EIP a when/0 or doCatch/0 
item belongs to. */
+        String parentName() {
+            String parent = location.substring(0, location.lastIndexOf('/'));
+            return parent.substring(parent.lastIndexOf('/') + 1);
+        }
+
+        boolean nameIsIndex() {
+            return name.matches("\\d+");
+        }
+
+        boolean locationEndsWith(String suffix) {
+            return location.endsWith(suffix);
+        }
+
+        String schemaLocation() {
+            return String.valueOf(error.getSchemaLocation());
+        }
+    }
+
+    /**
+     * A row of a table. The error's keyword and location select the row, the 
condition refines it, and the text is
+     * either appended to the error's message in parentheses, or replaces it.
+     */
+    record Hint(String keyword, Pattern location, Predicate<Match> when, 
Function<Match, String> text, String outKeyword,
+            String messageKey, boolean append) {
+
+        boolean matches(Match m) {
+            return keyword.equals(m.error().getKeyword())
+                    && (location == null || 
location.matcher(m.location()).matches())
+                    && when.test(m);
+        }
+
+        Error rewrite(Match m) {
+            String hint = text.apply(m);
+            // the message is not a MessageFormat pattern (it contains 
braces), so pass it as the single argument
+            return Error.builder()
+                    .keyword(outKeyword)
+                    .instanceLocation(m.error().getInstanceLocation())
+                    .messageKey(messageKey)
+                    .format(new MessageFormat("{0}"))
+                    .arguments(append ? m.message() + " (" + hint + ")" : hint)
+                    .build();
+        }
+    }
+
+    /** A row that keeps the error's message and appends the hint in 
parentheses. */
+    static Hint append(String keyword, String location, Predicate<Match> when, 
Function<Match, String> text) {
+        return new Hint(keyword, location == null ? null : 
Pattern.compile(location), when, text, keyword, keyword, true);
+    }
+
+    /** A row that replaces the error's message with the hint, reported under 
the given keyword and message key. */
+    static Hint replace(
+            String keyword, String location, Predicate<Match> when, 
Function<Match, String> text, String outKeyword,
+            String messageKey) {
+        return new Hint(
+                keyword, location == null ? null : Pattern.compile(location), 
when, text, outKeyword, messageKey,
+                false);
+    }
+
+    /** An additionalProperties row: the unknown property name is what the 
condition and the text are about. */
+    static Hint unknownProperty(String location, Predicate<Match> when, 
Function<Match, String> text) {
+        return append("additionalProperties", location, m -> m.unknown() != 
null && when.test(m), text);
+    }
+
+    private static final Predicate<Match> ANY = m -> true;
+
+    /**
+     * Applies a table to the errors: the first matching row rewrites each 
error. Two rewrites that say the same at the
+     * 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) {
+        List<Error> answer = new ArrayList<>(errors.size());
+        Set<String> seen = new LinkedHashSet<>();
+        for (Error error : errors) {
+            Error hinted = apply(table, error, validator);
+            if (hinted == error || seen.add(hinted.getInstanceLocation() + " " 
+ hinted.getMessage())) {
+                answer.add(hinted);
+            }
+        }
+        return answer;
+    }
+
+    static Error apply(List<Hint> table, Error error, YamlValidator validator) 
{
+        if (error.getMessage() == null) {
+            return error;
+        }
+        Match m = Match.of(error, validator);
+        for (Hint row : table) {
+            if (row.matches(m)) {
+                return row.rewrite(m);
+            }
+        }
+        return error;
+    }
+
+    // 
-------------------------------------------------------------------------------------------------------------
+    // step hints
+    // 
-------------------------------------------------------------------------------------------------------------
+
+    /**
+     * "must have at most 1 properties" at a step: a step holds one EIP, and 
the second key is either an option that
+     * belongs under the EIP (indented one level more) or another step (its 
own - item).
+     */
+    static final List<Hint> STEP = List.of(
+            // - beans:\n  myBean: ... : the second key was meant to be inside 
the first; it is not indented enough
+            append("maxProperties", "/\\d+", ANY,
+                    m -> "a top-level entry is one key: - route:, - beans:, - 
rest:...; the lines that belong to it"
+                         + " must be indented under it, a second key at the 
same level as the entry is read as a"
+                         + " separate property"),
+            append("maxProperties", ".*/steps/\\d+", ANY,
+                    m -> "a step is one EIP: an option of that EIP is indented 
under its key, and the next EIP is its"
+                         + " own - item"));
+
+    // 
-------------------------------------------------------------------------------------------------------------
+    // expression hints
+    // 
-------------------------------------------------------------------------------------------------------------
+
+    private static final String EXPRESSION_SUB_ELEMENT = 
"ExpressionSubElementDefinition";
+
+    /**
+     * "string found, object expected" where the schema wants an expression: 
says to write it as a language map,
+     * constant for a fixed value and simple for a dynamic one.
+     */
+    static final List<Hint> EXPRESSION = List.of(
+            replace("type", null,
+                    m -> {
+                        JsonNode instance = m.error().getInstanceNode();
+                        return instance != null && instance.isValueNode()
+                                && 
(m.schemaLocation().contains(EXPRESSION_SUB_ELEMENT)
+                                        || 
String.valueOf(m.error().getEvaluationPath()).contains(EXPRESSION_SUB_ELEMENT));
+                    },
+                    m -> {
+                        String value = m.error().getInstanceNode().asText();
+                        return String.format(
+                                "a plain value (%s) found, an expression 
expected: write %s: {constant: {expression: \"%s\"}} for a fixed value, or %s: 
{simple: {expression: \"...\"}} for a dynamic one",
+                                value, m.name(), value, m.name());
+                    },
+                    "type", "expression"));
+
+    // 
-------------------------------------------------------------------------------------------------------------
+    // list hints
+    // 
-------------------------------------------------------------------------------------------------------------
+
+    /**
+     * "object found, array expected" says what the schema wants, not how to 
write it: a list, each item starting with
+     * "- ". At the root of the file it also names the entries (route, from, 
beans, rest, onException).
+     */
+    static final List<Hint> LIST = List.of(
+            // script: {language: groovy, text: ...}: the language is the key 
of the expression, not a property
+            append("type", ".*/language", m -> m.message().contains("object 
expected"),
+                    m -> "an expression is written with the language as the 
key and its expression: property, e.g."
+                         + " groovy: {expression: \"...\"}, simple: 
{expression: \"...\"}, constant: {expression: \"...\"};"
+                         + " the language: form is language: {language: 
groovy, expression: \"...\"}"),
+            // message: {simple: "..."}: a string property that is already an 
expression, or a plain option
+            append("type", null, m -> m.message().contains("object found, 
string expected"),
+                    m -> m.name() + " is a plain string"
+                         + (m.locationEndsWith("/log/message")
+                                 ? " that is already a simple expression: 
write message: \"... ${body} ...\""
+                                 : ": write " + m.name() + ": \"...\", not a 
language map")),
+            // - onException: [ ... ]: the entry is a map; several handlers 
are several - onException: items
+            append("type",
+                    
"/\\d+/(onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|errorHandler|route|rest|restConfiguration)",
+                    m -> m.message().contains("array found, object expected"),
+                    m -> m.name() + " is a map, not a list: - " + m.name() + 
": followed by its properties indented"
+                         + (m.name().equals("onException")
+                                 ? " (exception: [java.lang.Exception], 
handled: {constant: {expression: \"true\"}},"
+                                   + " steps: [...])"
+                                 : "")
+                         + "; several of them are several - " + m.name() + ": 
items"),
+            // otherwise: [- log: ...]: the block is a map whose steps: holds 
the list
+            append("type", ".*/(otherwise|doTry|doFinally|doCatch/\\d+)", m -> 
m.message().contains("object expected"),
+                    m -> {
+                        String eip = m.nameIsIndex() ? "doCatch" : m.name();
+                        return eip + " holds its EIPs under steps: " + eip + 
": {steps: [- log: \"...\"]}"
+                               + (eip.equals("doCatch") ? ", each - doCatch: 
item with exception: and steps:" : "");
+                    }),
+            append("type", "/?", m -> m.message().contains("array expected"),
+                    m -> "a Camel YAML file is a list of entries, each 
starting with \"- \": - route:, - from:, - beans:,"
+                         + " - rest:, - onException:"),
+            append("type", ".*/beans", m -> m.message().contains("array 
expected"),
+                    m -> "beans is a list: - name: myBean followed by type: 
\"#class:com.example.MyBean\" (indented"
+                         + " under the -)"),
+            append("type", ".*/(steps|when|get|post|exception|doCatch)", m -> 
m.message().contains("array expected"),
+                    m -> m.name() + " is a list: each item starts with \"- 
\""),
+            append("type", null, m -> m.message().contains("array expected"),
+                    m -> "write it as a list: each item starts with \"- \""));
+
+    // 
-------------------------------------------------------------------------------------------------------------
+    // property hints
+    // 
-------------------------------------------------------------------------------------------------------------
+
+    /** The options of the log component: written on the log EIP, they are 
reported as such. */
+    private static final Set<String> LOG_COMPONENT_OPTIONS = Set.of(
+            "showAll", "showBody", "showBodyType", "showHeaders", 
"showExchangePattern", "showProperties",
+            "showAllProperties", "showVariables", "showExchangeId", 
"showException", "showCaughtException",
+            "showStackTrace", "showStreams", "showFiles", "showFuture", 
"showRouteId", "showRouteGroup", "multiline",
+            "maxChars", "skipBodyLineSeparator", "groupSize", "groupInterval", 
"groupDelay", "groupActiveOnly",
+            "level", "plain", "sourceLocationLoggerName", "style");
+
+    private static final Pattern CLASS_NAME = 
Pattern.compile("([a-z][\\w]*\\.)+[A-Z]\\w*");
+
+    /**
+     * Adds a hint to "property 'x' is not defined in the schema": the closest 
property name of that node (did you mean
+     * 'logName'?), or, when the property is a top-level entry such as 
onException written inside a route, where it goes
+     * instead.
+     */
+    static final List<Hint> PROPERTY = List.of(
+            append("required", ".*/route/from", m -> 
m.message().contains("required property 'steps' not found"),
+                    m -> "steps: is a property of from:, next to uri:; a 
steps: written at the route level must be"
+                         + " indented under from:"),
+            // - id: myBean / class: ... : the bean properties are name and 
type
+            unknownProperty("/\\d+/beans/\\d+", m -> Set.of("id", "ref", 
"class").contains(m.unknown()),
+                    m -> "a bean is - name: myBean followed by type: 
\"#class:com.example.MyBean\" (name instead of "
+                         + m.unknown() + (m.unknown().equals("class") ? ", 
type instead of class" : "") + ")"),
+            // - myBean: {type: ...} instead of - name: myBean / type: ...
+            unknownProperty("/\\d+/beans/\\d+", ANY,
+                    m -> "a bean item is written as - name: " + m.unknown()
+                         + " followed by type: \"#class:com.example.MyBean\" 
(the name is a property, not the key)"),
+            unknownProperty(null,
+                    m -> m.validator().topLevelEntries().contains(m.unknown())
+                            && m.location().chars().filter(c -> c == 
'/').count() >= 2,
+                    m -> "'" + m.unknown() + "' is a top-level entry: write it 
as a list item at the same level as the"
+                         + " route, not inside it"),
+            // onException: {java.lang.Exception: ...}: the class is a list 
item under exception:
+            unknownProperty(".*/(onException|doCatch/\\d+)", m -> 
CLASS_NAME.matcher(m.unknown()).matches(),
+                    m -> "the exception class is a list item under exception: 
("
+                         + (m.locationEndsWith("/onException") ? "onException" 
: "doCatch") + ": {exception: ["
+                         + m.unknown() + "], steps: [...]})"),
+            unknownProperty(".*/circuitBreaker", m -> 
m.unknown().equals("name"),
+                    m -> "the circuit breaker's name is its id: 
circuitBreaker: {id: myBreaker, ...}"),
+            // circuitBreaker: {failureThreshold: 5}: the thresholds and 
timeouts are resilience4j configuration
+            unknownProperty(".*/circuitBreaker", m -> {
+                Set<String> resilience = m.validator().resilienceProperties();
+                String lower = m.unknown().toLowerCase(Locale.ROOT);
+                return !resilience.isEmpty()
+                        && (resilience.contains(m.unknown()) || 
YamlValidator.closest(m.unknown(), resilience) != null
+                                || lower.contains("threshold") || 
lower.contains("timeout"));
+            }, m -> {
+                Set<String> resilience = m.validator().resilienceProperties();
+                String best = resilience.contains(m.unknown()) ? m.unknown() : 
YamlValidator.closest(m.unknown(), resilience);
+                return "the thresholds, timeouts and the like are written 
under resilience4jConfiguration:"
+                       + " (circuitBreaker: {resilience4jConfiguration: {" + 
(best != null ? best : "failureRateThreshold")
+                       + ": ...}, steps: [...], onFallback: {steps: [...]}})";
+            }),
+            unknownProperty(".*/log", m -> m.unknown().equals("level") || 
m.unknown().equals("logLevel"),
+                    m -> "did you mean 'loggingLevel'?"),
+            // log: {message: ..., showHeaders: true}: those are options of 
the log component endpoint
+            unknownProperty(".*/log", m -> 
LOG_COMPONENT_OPTIONS.contains(m.unknown()),
+                    m -> "'" + m.unknown() + "' is an option of the log 
component, not of the log EIP: write a to: step"
+                         + " with uri: \"log:com.example?" + m.unknown() + 
"=...\" (the log EIP has message,"
+                         + " loggingLevel, logName, marker)"),
+            // - route: {from: {uri: ...}, steps: [...]}: steps belongs under 
from:
+            unknownProperty(".*/route", m -> m.unknown().equals("steps"),
+                    m -> "steps: goes under from:, indented at the same level 
as uri: (route: {from: {uri: ...,"
+                         + " steps: [...]}})"),
+            // otherwise: {log: ...} or when: [- simple: ..., log: ...]: the 
EIPs go under steps:
+            
unknownProperty(".*/(otherwise|when/\\d+|doTry|doCatch/\\d+|doFinally|split|filter|loop|aggregate"
+                            + 
"|circuitBreaker|onFallback|multicast|pipeline|saga|resequence|throttle|delay"
+                            + 
"|onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|route|from)",
+                    m -> m.validator().stepNames().contains(m.unknown()),
+                    m -> {
+                        String eip = m.nameIsIndex() ? m.parentName() : 
m.name();
+                        return "'" + m.unknown() + "' is a step: the steps of 
" + eip + " go under steps: (" + eip
+                               + ": {steps: [- " + m.unknown() + ": ...]})";
+                    }),
+            // setBody: {script: ...}: script is an EIP; the language is the 
key of an expression
+            unknownProperty(null,
+                    m -> m.unknown().equals("script") && 
!m.locationEndsWith("/steps")
+                            && 
(YamlValidator.EXPRESSION_REQUIRED.contains(m.name()) || 
m.locationEndsWith("/expression")),
+                    m -> "script is an EIP step, not a language: write the 
language as the key of the expression"
+                         + " (expression: {groovy: {expression: \"...\"}}, 
expression: {simple: {expression: \"...\"}}),"
+                         + " or run a script as its own step with - script: 
{expression: {groovy: {expression: \"...\"}}}"),
+            // setBody: {bean: myBean} : the bean language is method:
+            unknownProperty(null, m -> m.unknown().equals("bean") && 
!m.locationEndsWith("/steps"),
+                    m -> "the bean language is written as method: (expression: 
{method: {ref: myBean, method:"
+                         + " process}}), or call the bean as a step with - 
bean: {ref: myBean, method: process}"),
+            // setHeader: {CamelNumberA: {simple: ...}} : the name is a 
property, not the key
+            
unknownProperty(".*/(setHeader|setProperty|setVariable|removeHeader|removeProperty|removeVariable)",
+                    m -> YamlValidator.closest(m.unknown(), 
m.validator().knownProperties(m.schemaLocation())) == null,
+                    m -> "the name is a property: " + m.name() + ": {name: " + 
m.unknown()
+                         + (m.name().startsWith("set") ? ", expression: 
{simple: {expression: \"...\"}}}" : "}")
+                         + " (" + m.unknown() + " is not the key)"),
+            unknownProperty(".*/bean", m -> Set.of("parameters", "args", 
"arguments").contains(m.unknown()),
+                    m -> "arguments are written in the method call: bean: 
{ref: myBean, method: \"process(${body},"
+                         + " 'x')\"}"),
+            unknownProperty(null,
+                    m -> YamlValidator.closest(m.unknown(), 
m.validator().knownProperties(m.schemaLocation())) != null,
+                    m -> "did you mean '"
+                         + YamlValidator.closest(m.unknown(), 
m.validator().knownProperties(m.schemaLocation())) + "'?"));
+
+    // 
-------------------------------------------------------------------------------------------------------------
+    // compact notation hints (canonical schema only)
+    // 
-------------------------------------------------------------------------------------------------------------
+
+    /** The property a step written as a string sets: the argument of the 
definition's String constructor. */
+    private static final Map<String, String> STRING_STEP_PROPERTY = 
Map.ofEntries(
+            Map.entry("bean", "ref"), Map.entry("convertBodyTo", "type"), 
Map.entry("log", "message"),
+            Map.entry("poll", "uri"), Map.entry("removeHeader", "name"), 
Map.entry("removeHeaders", "pattern"),
+            Map.entry("removeProperties", "pattern"), 
Map.entry("removeProperty", "name"),
+            Map.entry("removeVariable", "name"), Map.entry("rollback", 
"message"),
+            Map.entry("setExchangePattern", "pattern"), Map.entry("to", 
"uri"), Map.entry("toD", "uri"));
+
+    private static final String NORMALIZE_HINT = "; camel validate normalize 
rewrites a file in the canonical format";
+
+    private static final String COMPACT_NOTATION = "compactNotation";
+
+    /**
+     * The canonical schema rejects the compact notation as a schema error 
that says nothing about it: "property
+     * 'simple' is not defined" for a language key directly on the EIP, 
"string found, object expected" for a step or a
+     * language written as a string. Each is replaced with a message that 
names the notation, the canonical form of that
+     * line, and the normalize command.
+     */
+    static final List<Hint> COMPACT = List.of(
+            // setBody: {simple: ...} or when: [- simple: ...]: the language 
key sits on the EIP, not under expression:
+            replace("additionalProperties", null,
+                    m -> m.unknown() != null && 
m.validator().languageKeys().contains(m.unknown()),
+                    m -> {
+                        String form = m.validator().languageForm(m.unknown());
+                        if (m.nameIsIndex()) {
+                            return "a " + m.parentName() + " item with " + 
m.unknown() + ": ... is the deprecated compact"
+                                   + " notation: an expression is written 
under expression: (- expression: {"
+                                   + m.unknown() + ": {" + form + "}})" + 
NORMALIZE_HINT;
+                        }
+                        return m.name() + ": {" + m.unknown() + ": ...} is the 
deprecated compact notation: an"
+                               + " expression is written under expression: (" 
+ m.name() + ": {expression: {"
+                               + m.unknown() + ": {" + form + "}}})" + 
NORMALIZE_HINT;
+                    },
+                    COMPACT_NOTATION, COMPACT_NOTATION),
+            // simple: "..." : the language is a map with its expression
+            replace("type", null,
+                    m -> m.message().contains("string found, object expected")
+                            && m.validator().languageKeys().contains(m.name()),
+                    m -> m.name() + ": \"...\" is the deprecated compact 
notation: write " + m.name() + ": {"
+                         + m.validator().languageForm(m.name()) + "}" + 
NORMALIZE_HINT,
+                    COMPACT_NOTATION, COMPACT_NOTATION),
+            // log: "..." : the step is a map with its properties
+            replace("type", null,
+                    m -> m.message().contains("string found, object expected")
+                            && (m.validator().stepNames().contains(m.name())
+                                    || 
m.validator().topLevelEntries().contains(m.name())),
+                    m -> {
+                        String property = STRING_STEP_PROPERTY.get(m.name());
+                        return m.name() + ": \"...\" is the deprecated compact 
notation: write " + m.name()
+                               + (property != null ? ": {" + property + ": 
\"...\"}" : " as a map with its properties")
+                               + NORMALIZE_HINT;
+                    },
+                    COMPACT_NOTATION, COMPACT_NOTATION));
+
+    static String between(String text, String start, String end) {
+        int i = text.indexOf(start);
+        if (i < 0) {
+            return null;
+        }
+        int j = text.indexOf(end, i + start.length());
+        return j < 0 ? null : text.substring(i + start.length(), j);
+    }
+
+    private SchemaHints() {
+    }
+}
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 9a050d5ad4c0..148be7445cf9 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
@@ -285,12 +285,12 @@ public class YamlValidator {
         var errors = filterOneOfNoise(new 
ArrayList<>(schema.validate(target)));
         errors.removeIf(YamlValidator::isRuntimeAcceptedScalar);
         if (canonical) {
-            errors = withCompactNotationHints(errors);
+            errors = SchemaHints.apply(SchemaHints.COMPACT, errors, this);
         }
-        errors = withExpressionHints(errors);
-        errors = withPropertyHints(errors);
-        errors = withListHints(errors);
-        errors = withStepHints(errors);
+        errors = SchemaHints.apply(SchemaHints.EXPRESSION, errors, this);
+        errors = SchemaHints.apply(SchemaHints.PROPERTY, errors, this);
+        errors = SchemaHints.apply(SchemaHints.LIST, errors, this);
+        errors = SchemaHints.apply(SchemaHints.STEP, errors, this);
         // CAMEL-24707: the schema requires the expression, so a node without 
one fails its oneOf with "0 are valid"
         // plus one "required property <language> not found" per language; 
replace that with one line that says
         // what to write, at the node's own location
@@ -323,82 +323,8 @@ public class YamlValidator {
         return errors;
     }
 
-    /** The property a step written as a string sets: the argument of the 
definition's String constructor. */
-    private static final Map<String, String> STRING_STEP_PROPERTY = 
Map.ofEntries(
-            Map.entry("bean", "ref"), Map.entry("convertBodyTo", "type"), 
Map.entry("log", "message"),
-            Map.entry("poll", "uri"), Map.entry("removeHeader", "name"), 
Map.entry("removeHeaders", "pattern"),
-            Map.entry("removeProperties", "pattern"), 
Map.entry("removeProperty", "name"),
-            Map.entry("removeVariable", "name"), Map.entry("rollback", 
"message"),
-            Map.entry("setExchangePattern", "pattern"), Map.entry("to", 
"uri"), Map.entry("toD", "uri"));
-
-    private static final String NORMALIZE_HINT = "; camel validate normalize 
rewrites a file in the canonical format";
-
-    /**
-     * The canonical schema rejects the compact notation as a schema error 
that says nothing about it: "property
-     * 'simple' is not defined" for a language key directly on the EIP, 
"string found, object expected" for a step or a
-     * language written as a string. Each is replaced with a message that 
names the notation, the canonical form of that
-     * line, and the normalize command.
-     */
-    List<Error> withCompactNotationHints(List<Error> errors) {
-        List<Error> answer = new ArrayList<>(errors.size());
-        for (Error error : errors) {
-            String hint = compactNotationHint(error);
-            if (hint == null) {
-                answer.add(error);
-                continue;
-            }
-            answer.add(Error.builder()
-                    .keyword("compactNotation")
-                    .instanceLocation(error.getInstanceLocation())
-                    .messageKey("compactNotation")
-                    .format(new MessageFormat("{0}"))
-                    .arguments(hint + NORMALIZE_HINT)
-                    .build());
-        }
-        return answer;
-    }
-
-    private String compactNotationHint(Error error) {
-        String message = error.getMessage();
-        if (message == null) {
-            return null;
-        }
-        String location = String.valueOf(error.getInstanceLocation());
-        String name = location.substring(location.lastIndexOf('/') + 1);
-        if ("additionalProperties".equals(error.getKeyword())) {
-            // setBody: {simple: ...} or when: [- simple: ...]: the language 
key sits on the EIP, not under expression:
-            String unknown = between(message, "property '", "'");
-            if (unknown == null || !languageKeys.contains(unknown)) {
-                return null;
-            }
-            if (name.matches("\\d+")) {
-                String parent = location.substring(0, 
location.lastIndexOf('/'));
-                name = parent.substring(parent.lastIndexOf('/') + 1);
-                return "a " + name + " item with " + unknown + ": ... is the 
deprecated compact notation: an expression"
-                       + " is written under expression: (- expression: {" + 
unknown + ": {" + languageForm(unknown)
-                       + "}})";
-            }
-            return name + ": {" + unknown + ": ...} is the deprecated compact 
notation: an expression is written under"
-                   + " expression: (" + name + ": {expression: {" + unknown + 
": {" + languageForm(unknown) + "}}})";
-        }
-        if ("type".equals(error.getKeyword()) && message.contains("string 
found, object expected")) {
-            if (languageKeys.contains(name)) {
-                // simple: "..." : the language is a map with its expression
-                return name + ": \"...\" is the deprecated compact notation: 
write " + name + ": {" + languageForm(name)
-                       + "}";
-            }
-            if (stepNames.contains(name) || topLevelEntries.contains(name)) {
-                // log: "..." : the step is a map with its properties
-                String property = STRING_STEP_PROPERTY.get(name);
-                return name + ": \"...\" is the deprecated compact notation: 
write " + name
-                       + (property != null ? ": {" + property + ": \"...\"}" : 
" as a map with its properties");
-            }
-        }
-        return null;
-    }
-
     /** The canonical body of a language: its expression property, or token 
for tokenize, as key: "...". */
-    private String languageForm(String language) {
+    String languageForm(String language) {
         JsonNode ref = 
model.at("/items/definitions/org.apache.camel.model.language.ExpressionDefinition/properties/"
                                 + language + "/$ref");
         JsonNode properties = ref.isTextual() ? 
model.at(ref.asText().substring(1) + "/properties") : null;
@@ -407,56 +333,12 @@ public class YamlValidator {
         return property + ": \"...\"";
     }
 
-    /**
-     * "must have at most 1 properties" at a step: a step holds one EIP, and 
the second key is either an option that
-     * belongs under the EIP (indented one level more) or another step (its 
own - item).
-     */
-    static List<Error> withStepHints(List<Error> errors) {
-        List<Error> answer = new ArrayList<>(errors.size());
-        for (Error error : errors) {
-            String location = String.valueOf(error.getInstanceLocation());
-            if ("maxProperties".equals(error.getKeyword()) && 
location.matches("/\\d+")) {
-                // - beans:\n  myBean: ... : the second key was meant to be 
inside the first; it is not indented enough
-                answer.add(Error.builder()
-                        .keyword("maxProperties")
-                        .instanceLocation(error.getInstanceLocation())
-                        .messageKey("maxProperties")
-                        .format(new MessageFormat("{0}"))
-                        .arguments(error.getMessage() + " (a top-level entry 
is one key: - route:, - beans:, - rest:...;"
-                                   + " the lines that belong to it must be 
indented under it, a second key at the same"
-                                   + " level as the entry is read as a 
separate property)")
-                        .build());
-                continue;
-            }
-            if ("maxProperties".equals(error.getKeyword()) && 
location.matches(".*/steps/\\d+")) {
-                answer.add(Error.builder()
-                        .keyword("maxProperties")
-                        .instanceLocation(error.getInstanceLocation())
-                        .messageKey("maxProperties")
-                        .format(new MessageFormat("{0}"))
-                        .arguments(error.getMessage() + " (a step is one EIP: 
an option of that EIP is indented under"
-                                   + " its key, and the next EIP is its own - 
item)")
-                        .build());
-            } else {
-                answer.add(error);
-            }
-        }
-        return answer;
-    }
-
     /**
      * The EIPs whose expression the runtime needs: the schema leaves it 
optional for every expression node, and a split
      * written with only delimiter: "," (or a filter, setBody, when... with 
only options) fails when the route is
      * created with "Unsupported definition: null".
      */
-    private static final Set<String> LOG_COMPONENT_OPTIONS = Set.of(
-            "showAll", "showBody", "showBodyType", "showHeaders", 
"showExchangePattern", "showProperties",
-            "showAllProperties", "showVariables", "showExchangeId", 
"showException", "showCaughtException",
-            "showStackTrace", "showStreams", "showFiles", "showFuture", 
"showRouteId", "showRouteGroup", "multiline",
-            "maxChars", "skipBodyLineSeparator", "groupSize", "groupInterval", 
"groupDelay", "groupActiveOnly",
-            "level", "plain", "sourceLocationLoggerName", "style");
-
-    private static final Set<String> EXPRESSION_REQUIRED = Set.of(
+    static final Set<String> EXPRESSION_REQUIRED = Set.of(
             "split", "filter", "when", "setBody", "setHeader", "setProperty", 
"setVariable", "transform", "loop",
             "delay", "recipientList", "routingSlip", "dynamicRouter", 
"validate", "script", "throttle", "resequence",
             "idempotentConsumer");
@@ -860,290 +742,30 @@ public class YamlValidator {
         return (instance.isNumber() || instance.isBoolean()) && 
isExpectedType(error, "string");
     }
 
-    /**
-     * Replaces the schema's "boolean found, object expected" for a plain 
value at an option that takes an expression
-     * (such as {@code handled: true} on onException, or {@code 
completionSizeExpression: 10} on aggregate) with a
-     * message that shows the expression form, and drops the duplicates the 
schema composition produces for it.
-     */
-    static List<Error> withExpressionHints(List<Error> errors) {
-        List<Error> answer = new ArrayList<>(errors.size());
-        Set<String> seen = new LinkedHashSet<>();
-        for (Error error : errors) {
-            Error hinted = withExpressionHint(error);
-            if (hinted == error || seen.add(hinted.getInstanceLocation() + " " 
+ hinted.getMessage())) {
-                answer.add(hinted);
-            }
-        }
-        return answer;
-    }
-
-    static Error withExpressionHint(Error error) {
-        if (!"type".equals(error.getKeyword())) {
-            return error;
-        }
-        JsonNode instance = error.getInstanceNode();
-        if (instance == null || !instance.isValueNode()) {
-            return error;
-        }
-        String schemaLocation = String.valueOf(error.getSchemaLocation());
-        String evaluationPath = String.valueOf(error.getEvaluationPath());
-        if (!schemaLocation.contains(EXPRESSION_SUB_ELEMENT) && 
!evaluationPath.contains(EXPRESSION_SUB_ELEMENT)) {
-            return error;
-        }
-        String location = String.valueOf(error.getInstanceLocation());
-        String name = location.substring(location.lastIndexOf('/') + 1);
-        String value = instance.asText();
-        String message = String.format(
-                "a plain value (%s) found, an expression expected: write %s: 
{constant: {expression: \"%s\"}} for a fixed value, or %s: {simple: 
{expression: \"...\"}} for a dynamic one",
-                value, name, value, name);
-        // the message is not a MessageFormat pattern (it contains braces), so 
pass it as the single argument
-        return Error.builder()
-                .keyword("type")
-                .instanceLocation(error.getInstanceLocation())
-                .messageKey("expression")
-                .format(new MessageFormat("{0}"))
-                .arguments(message)
-                .build();
-    }
-
-    private static final String EXPRESSION_SUB_ELEMENT = 
"ExpressionSubElementDefinition";
-
     private JsonNode model;
     private Set<String> topLevelEntries = Set.of();
     private Set<String> languageKeys = Set.of();
     private Set<String> stepNames = Set.of();
     private Set<String> resilienceProperties = Set.of();
 
-    /**
-     * "object found, array expected" says what the schema wants, not how to 
write it: a list, each item starting with
-     * "- ". At the root of the file it also names the entries (route, from, 
beans, rest, onException).
-     */
-    static List<Error> withListHints(List<Error> errors) {
-        List<Error> answer = new ArrayList<>(errors.size());
-        for (Error error : errors) {
-            answer.add(withListHint(error));
-        }
-        return answer;
+    /** The keys of the file's entries (route, from, beans, rest, 
onException...), from the schema. */
+    Set<String> topLevelEntries() {
+        return topLevelEntries;
     }
 
-    static Error withListHint(Error error) {
-        if (!"type".equals(error.getKeyword()) || error.getMessage() == null) {
-            return error;
-        }
-        String location = String.valueOf(error.getInstanceLocation());
-        if (location.endsWith("/language") && 
error.getMessage().contains("object expected")) {
-            // script: {language: groovy, text: ...}: the language is the key 
of the expression, not a property
-            return Error.builder()
-                    .keyword("type")
-                    .instanceLocation(error.getInstanceLocation())
-                    .messageKey("type")
-                    .format(new MessageFormat("{0}"))
-                    .arguments(error.getMessage() + " (an expression is 
written with the language as the key and its"
-                               + " expression: property, e.g. groovy: 
{expression: \"...\"}, simple: {expression: \"...\"},"
-                               + " constant: {expression: \"...\"}; the 
language: form is"
-                               + " language: {language: groovy, expression: 
\"...\"})")
-                    .build();
-        }
-        if (error.getMessage().contains("object found, string expected")) {
-            // message: {simple: "..."}: a string property that is already an 
expression, or a plain option
-            String prop = location.substring(location.lastIndexOf('/') + 1);
-            return Error.builder()
-                    .keyword("type")
-                    .instanceLocation(error.getInstanceLocation())
-                    .messageKey("type")
-                    .format(new MessageFormat("{0}"))
-                    .arguments(error.getMessage() + " (" + prop + " is a plain 
string"
-                               + (location.endsWith("/log/message")
-                                       ? " that is already a simple 
expression: write message: \"... ${body} ...\""
-                                       : ": write " + prop + ": \"...\", not a 
language map")
-                               + ")")
-                    .build();
-        }
-        if (location.matches(
-                
"/\\d+/(onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|errorHandler|route|rest|restConfiguration)")
-                && error.getMessage().contains("array found, object 
expected")) {
-            // - onException: [ ... ]: the entry is a map; several handlers 
are several - onException: items
-            String entry = location.substring(location.lastIndexOf('/') + 1);
-            return Error.builder()
-                    .keyword("type")
-                    .instanceLocation(error.getInstanceLocation())
-                    .messageKey("type")
-                    .format(new MessageFormat("{0}"))
-                    .arguments(error.getMessage() + " (" + entry + " is a map, 
not a list: - " + entry + ": followed by its"
-                               + " properties indented" + 
(entry.equals("onException")
-                                       ? " (exception: [java.lang.Exception], 
handled: {constant: {expression: \"true\"}},"
-                                         + " steps: [...])"
-                                       : "")
-                               + "; several of them are several - " + entry + 
": items)")
-                    .build();
-        }
-        if (location.matches(".*/(otherwise|doTry|doFinally|doCatch/\\d+)") && 
error.getMessage().contains("object expected")) {
-            // otherwise: [- log: ...]: the block is a map whose steps: holds 
the list
-            String eip = location.substring(location.lastIndexOf('/') + 1);
-            if (eip.matches("\\d+")) {
-                eip = "doCatch";
-            }
-            return Error.builder()
-                    .keyword("type")
-                    .instanceLocation(error.getInstanceLocation())
-                    .messageKey("type")
-                    .format(new MessageFormat("{0}"))
-                    .arguments(error.getMessage() + " (" + eip + " holds its 
EIPs under steps: " + eip
-                               + ": {steps: [- log: \"...\"]}" + 
(eip.equals("doCatch")
-                                       ? ", each - doCatch: item with"
-                                         + " exception: and steps:"
-                                       : "")
-                               + ")")
-                    .build();
-        }
-        if (!error.getMessage().contains("array expected")) {
-            return error;
-        }
-        String name = location.substring(location.lastIndexOf('/') + 1);
-        String hint;
-        if (location.isEmpty() || location.equals("/")) {
-            hint = "a Camel YAML file is a list of entries, each starting with 
\"- \": - route:, - from:, - beans:, - rest:,"
-                   + " - onException:";
-        } else if (name.equals("beans")) {
-            hint = "beans is a list: - name: myBean followed by type: 
\"#class:com.example.MyBean\" (indented under the -)";
-        } else if (name.equals("steps") || name.equals("when") || 
name.equals("get") || name.equals("post")
-                || name.equals("exception") || name.equals("doCatch")) {
-            hint = name + " is a list: each item starts with \"- \"";
-        } else {
-            hint = "write it as a list: each item starts with \"- \"";
-        }
-        return Error.builder()
-                .keyword("type")
-                .instanceLocation(error.getInstanceLocation())
-                .messageKey("type")
-                .format(new MessageFormat("{0}"))
-                .arguments(error.getMessage() + " (" + hint + ")")
-                .build();
+    /** The language keys of an expression (simple, constant, groovy...), from 
the schema. */
+    Set<String> languageKeys() {
+        return languageKeys;
     }
 
-    /**
-     * Adds a hint to "property 'x' is not defined in the schema": the closest 
property name of that node (did you mean
-     * 'logName'?), or, when the property is a top-level entry such as 
onException written inside a route, where it goes
-     * instead.
-     */
-    List<Error> withPropertyHints(List<Error> errors) {
-        List<Error> answer = new ArrayList<>(errors.size());
-        for (Error error : errors) {
-            answer.add(withPropertyHint(error));
-        }
-        return answer;
+    /** The names of the EIP steps, from the schema. */
+    Set<String> stepNames() {
+        return stepNames;
     }
 
-    Error withPropertyHint(Error error) {
-        if ("required".equals(error.getKeyword()) && error.getMessage() != null
-                && error.getMessage().contains("required property 'steps' not 
found")
-                && 
String.valueOf(error.getInstanceLocation()).matches(".*/route/from")) {
-            return Error.builder()
-                    .keyword("required")
-                    .instanceLocation(error.getInstanceLocation())
-                    .messageKey("required")
-                    .format(new MessageFormat("{0}"))
-                    .arguments(error.getMessage() + " (steps: is a property of 
from:, next to uri:; a steps: written at"
-                               + " the route level must be indented under 
from:)")
-                    .build();
-        }
-        if (!"additionalProperties".equals(error.getKeyword()) || 
error.getMessage() == null) {
-            return error;
-        }
-        String message = error.getMessage();
-        String unknown = between(message, "property '", "'");
-        if (unknown == null) {
-            return error;
-        }
-        String location = String.valueOf(error.getInstanceLocation());
-        String hint = null;
-        if (location.matches("/\\d+/beans/\\d+")
-                && (unknown.equals("id") || unknown.equals("ref") || 
unknown.equals("class"))) {
-            // - id: myBean / class: ... : the bean properties are name and 
type
-            hint = "a bean is - name: myBean followed by type: 
\"#class:com.example.MyBean\" (name instead of " + unknown
-                   + (unknown.equals("class") ? ", type instead of class" : 
"") + ")";
-        } else if (location.matches("/\\d+/beans/\\d+")) {
-            // - myBean: {type: ...} instead of - name: myBean / type: ...
-            hint = "a bean item is written as - name: " + unknown + " followed 
by type: \"#class:com.example.MyBean\" "
-                   + "(the name is a property, not the key)";
-        } else if (topLevelEntries.contains(unknown) && 
location.chars().filter(c -> c == '/').count() >= 2) {
-            hint = "'" + unknown + "' is a top-level entry: write it as a list 
item at the same level as the route, "
-                   + "not inside it";
-        } else if (location.matches(".*/(onException|doCatch/\\d+)") && 
unknown.matches("([a-z][\\w]*\\.)+[A-Z]\\w*")) {
-            // onException: {java.lang.Exception: ...}: the class is a list 
item under exception:
-            String eip = location.endsWith("/onException") ? "onException" : 
"doCatch";
-            hint = "the exception class is a list item under exception: (" + 
eip + ": {exception: [" + unknown
-                   + "], steps: [...]})";
-        } else if (location.endsWith("/circuitBreaker") && 
unknown.equals("name")) {
-            hint = "the circuit breaker's name is its id: circuitBreaker: {id: 
myBreaker, ...}";
-        } else if (location.endsWith("/circuitBreaker") && 
!resilienceProperties.isEmpty()
-                && (resilienceProperties.contains(unknown) || closest(unknown, 
resilienceProperties) != null
-                        || 
unknown.toLowerCase(Locale.ROOT).contains("threshold")
-                        || 
unknown.toLowerCase(Locale.ROOT).contains("timeout"))) {
-            // circuitBreaker: {failureThreshold: 5}: the thresholds and 
timeouts are resilience4j configuration
-            String best = resilienceProperties.contains(unknown) ? unknown : 
closest(unknown, resilienceProperties);
-            hint = "the thresholds, timeouts and the like are written under 
resilience4jConfiguration: (circuitBreaker:"
-                   + " {resilience4jConfiguration: {" + (best != null ? best : 
"failureRateThreshold") + ": ...}, steps:"
-                   + " [...], onFallback: {steps: [...]}})";
-        } else if (location.endsWith("/log") && (unknown.equals("level") || 
unknown.equals("logLevel"))) {
-            hint = "did you mean 'loggingLevel'?";
-        } else if (location.endsWith("/log") && 
LOG_COMPONENT_OPTIONS.contains(unknown)) {
-            // log: {message: ..., showHeaders: true}: those are options of 
the log component endpoint
-            hint = "'" + unknown + "' is an option of the log component, not 
of the log EIP: write a to: step with"
-                   + " uri: \"log:com.example?" + unknown + "=...\" (the log 
EIP has message, loggingLevel, logName,"
-                   + " marker)";
-        } else if (unknown.equals("steps") && location.matches(".*/route")) {
-            // - route: {from: {uri: ...}, steps: [...]}: steps belongs under 
from:
-            hint = "steps: goes under from:, indented at the same level as 
uri: (route: {from: {uri: ..., steps: [...]}})";
-        } else if (stepNames.contains(unknown) && 
!location.matches(".*/steps/\\d+")
-                && 
location.matches(".*/(otherwise|when/\\d+|doTry|doCatch/\\d+|doFinally|split|filter|loop|aggregate"
-                                    + 
"|circuitBreaker|onFallback|multicast|pipeline|saga|resequence|throttle|delay"
-                                    + 
"|onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|route|from)"))
 {
-            // otherwise: {log: ...} or when: [- simple: ..., log: ...]: the 
EIPs go under steps:
-            String eip = location.substring(location.lastIndexOf('/') + 1);
-            if (eip.matches("\\d+")) {
-                String parent = location.substring(0, 
location.lastIndexOf('/'));
-                eip = parent.substring(parent.lastIndexOf('/') + 1);
-            }
-            hint = "'" + unknown + "' is a step: the steps of " + eip + " go 
under steps: (" + eip
-                   + ": {steps: [- " + unknown + ": ...]})";
-        } else if (unknown.equals("script") && !location.endsWith("/steps")
-                && 
(EXPRESSION_REQUIRED.contains(location.substring(location.lastIndexOf('/') + 1))
-                        || location.endsWith("/expression"))) {
-            // setBody: {script: ...}: script is an EIP; the language is the 
key of an expression
-            hint = "script is an EIP step, not a language: write the language 
as the key of the expression (expression:"
-                   + " {groovy: {expression: \"...\"}}, expression: {simple: 
{expression: \"...\"}}), or run a script as"
-                   + " its own step with - script: {expression: {groovy: 
{expression: \"...\"}}}";
-        } else if (unknown.equals("bean") && !location.endsWith("/steps")) {
-            // setBody: {bean: myBean} : the bean language is method:
-            hint = "the bean language is written as method: (expression: 
{method: {ref: myBean, method: process}}), or"
-                   + " call the bean as a step with - bean: {ref: myBean, 
method: process}";
-        } else if 
(location.matches(".*/(setHeader|setProperty|setVariable|removeHeader|removeProperty|removeVariable)")
-                && closest(unknown, 
knownProperties(String.valueOf(error.getSchemaLocation()))) == null) {
-            // setHeader: {CamelNumberA: {simple: ...}} : the name is a 
property, not the key
-            String eip = location.substring(location.lastIndexOf('/') + 1);
-            hint = "the name is a property: " + eip + ": {name: " + unknown
-                   + (eip.startsWith("set") ? ", expression: {simple: 
{expression: \"...\"}}}" : "}")
-                   + " (" + unknown + " is not the key)";
-        } else if (location.endsWith("/bean")
-                && (unknown.equals("parameters") || unknown.equals("args") || 
unknown.equals("arguments"))) {
-            hint = "arguments are written in the method call: bean: {ref: 
myBean, method: \"process(${body}, 'x')\"}";
-        } else {
-            String best = closest(unknown, 
knownProperties(String.valueOf(error.getSchemaLocation())));
-            if (best != null) {
-                hint = "did you mean '" + best + "'?";
-            }
-        }
-        if (hint == null) {
-            return error;
-        }
-        return Error.builder()
-                .keyword("additionalProperties")
-                .instanceLocation(error.getInstanceLocation())
-                .messageKey("additionalProperties")
-                .format(new MessageFormat("{0}"))
-                .arguments(message + " (" + hint + ")")
-                .build();
+    /** The properties of resilience4jConfiguration, from the schema. */
+    Set<String> resilienceProperties() {
+        return resilienceProperties;
     }
 
     /** The property names the schema allows at the definition an 
additionalProperties error points to. */
@@ -1229,15 +851,6 @@ public class YamlValidator {
         return prev[b.length()];
     }
 
-    private static String between(String text, String start, String end) {
-        int i = text.indexOf(start);
-        if (i < 0) {
-            return null;
-        }
-        int j = text.indexOf(end, i + start.length());
-        return j < 0 ? null : text.substring(i + start.length(), j);
-    }
-
     private static boolean isBooleanText(String text) {
         String s = text.trim();
         return "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s);
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
new file mode 100644
index 000000000000..3dfc0d49d529
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.yaml.validator;
+
+import java.text.MessageFormat;
+import java.util.List;
+
+import com.networknt.schema.Error;
+import com.networknt.schema.path.NodePath;
+import com.networknt.schema.path.PathType;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24715: the matcher behind the hint tables. The texts of the rows are 
pinned by the YamlValidator*Test classes
+ * through the validator; this pins how a row is selected and how the error is 
rewritten.
+ */
+public class SchemaHintsTest {
+
+    private static final YamlValidator VALIDATOR = new YamlValidator();
+
+    private static Error error(String keyword, String location, String 
message) {
+        NodePath path = new NodePath(PathType.JSON_POINTER);
+        for (String segment : location.split("/")) {
+            if (!segment.isEmpty()) {
+                path = segment.matches("\\d+") ? 
path.append(Integer.parseInt(segment)) : path.append(segment);
+            }
+        }
+        return 
Error.builder().keyword(keyword).instanceLocation(path).messageKey(keyword)
+                .format(new MessageFormat("{0}")).arguments(message).build();
+    }
+
+    @Test
+    public void testFirstMatchingRowWins() {
+        List<SchemaHints.Hint> table = List.of(
+                SchemaHints.append("type", ".*/beans", m -> true, m -> 
"first"),
+                SchemaHints.append("type", ".*/beans", m -> true, m -> 
"second"),
+                SchemaHints.append("type", null, m -> true, m -> "any"));
+        Error beans = SchemaHints.apply(table, error("type", "/0/beans", 
"object found, array expected"), VALIDATOR);
+        assertThat(beans.getMessage()).isEqualTo("object found, array expected 
(first)");
+        Error other = SchemaHints.apply(table, error("type", "/0/route/from", 
"object found, array expected"), VALIDATOR);
+        assertThat(other.getMessage()).isEqualTo("object found, array expected 
(any)");
+    }
+
+    @Test
+    public void testKeywordLocationAndConditionSelectTheRow() {
+        List<SchemaHints.Hint> table = List.of(
+                SchemaHints.append("type", "/\\d+/steps/\\d+", m -> 
m.message().contains("array expected"), m -> "hint"));
+        Error unrelatedKeyword = error("required", "/0/steps/1", "array 
expected");
+        Error unrelatedLocation = error("type", "/0/steps", "array expected");
+        Error unrelatedMessage = error("type", "/0/steps/1", "object 
expected");
+        Error match = error("type", "/0/steps/1", "array expected");
+        assertThat(SchemaHints.apply(table, unrelatedKeyword, 
VALIDATOR)).isSameAs(unrelatedKeyword);
+        assertThat(SchemaHints.apply(table, unrelatedLocation, 
VALIDATOR)).isSameAs(unrelatedLocation);
+        assertThat(SchemaHints.apply(table, unrelatedMessage, 
VALIDATOR)).isSameAs(unrelatedMessage);
+        assertThat(SchemaHints.apply(table, match, 
VALIDATOR).getMessage()).isEqualTo("array expected (hint)");
+    }
+
+    @Test
+    public void testReplaceRewritesKeywordAndMessage() {
+        List<SchemaHints.Hint> table = List.of(
+                SchemaHints.replace("additionalProperties", null, m -> 
"simple".equals(m.unknown()),
+                        m -> m.name() + ": {" + m.unknown() + ": ...} is the 
compact notation", "compactNotation",
+                        "compactNotation"));
+        Error hinted = SchemaHints.apply(table,
+                error("additionalProperties", "/0/route/from/steps/0/setBody", 
"property 'simple' is not defined"),
+                VALIDATOR);
+        assertThat(hinted.getKeyword()).isEqualTo("compactNotation");
+        assertThat(hinted.getMessageKey()).isEqualTo("compactNotation");
+        assertThat(hinted.getMessage()).isEqualTo("setBody: {simple: ...} is 
the compact notation");
+        
assertThat(String.valueOf(hinted.getInstanceLocation())).isEqualTo("/0/route/from/steps/0/setBody");
+    }
+
+    @Test
+    public void testUnknownPropertyRowNeedsTheName() {
+        List<SchemaHints.Hint> table = List.of(
+                SchemaHints.unknownProperty(null, m -> true, m -> "did you 
mean '" + m.unknown() + "'?"));
+        Error noName = error("additionalProperties", "/0/log", "not defined in 
the schema");
+        assertThat(SchemaHints.apply(table, noName, 
VALIDATOR)).isSameAs(noName);
+        Error named = error("additionalProperties", "/0/log", "property 'lvl' 
is not defined in the schema");
+        assertThat(SchemaHints.apply(table, named, VALIDATOR).getMessage())
+                .isEqualTo("property 'lvl' is not defined in the schema (did 
you mean 'lvl'?)");
+    }
+
+    @Test
+    public void testSameHintAtSameLocationReportedOnce() {
+        List<SchemaHints.Hint> table = List.of(
+                SchemaHints.replace("type", null, m -> true, m -> "an 
expression expected", "type", "expression"));
+        List<Error> errors = List.of(
+                error("type", "/0/onException/handled", "boolean found, object 
expected"),
+                error("type", "/0/onException/handled", "boolean found, string 
expected"),
+                error("type", "/0/onException/continued", "boolean found, 
object expected"),
+                error("required", "/0/onException", "required property 'steps' 
not found"));
+        List<Error> hinted = SchemaHints.apply(table, errors, VALIDATOR);
+        assertThat(hinted).hasSize(3);
+        assertThat(hinted.get(0).getMessage()).isEqualTo("an expression 
expected");
+        
assertThat(String.valueOf(hinted.get(1).getInstanceLocation())).isEqualTo("/0/onException/continued");
+        assertThat(hinted.get(2)).isSameAs(errors.get(3));
+    }
+
+    @Test
+    public void testMatchNamesTheItemAndItsParent() {
+        SchemaHints.Match m = SchemaHints.Match.of(
+                error("additionalProperties", 
"/0/route/from/steps/2/choice/when/0", "property 'log' is not defined"),
+                VALIDATOR);
+        assertThat(m.name()).isEqualTo("0");
+        assertThat(m.nameIsIndex()).isTrue();
+        assertThat(m.parentName()).isEqualTo("when");
+        assertThat(m.unknown()).isEqualTo("log");
+        assertThat(m.locationEndsWith("/when/0")).isTrue();
+    }
+}

Reply via email to