From 37817db6f782847473175ec0f28cd4cab10fec91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:18:09 +0200 Subject: [PATCH 01/11] build(deps): bump com.fasterxml.jackson.core:jackson-core (#32) Bumps [com.fasterxml.jackson.core:jackson-core](https://github.com/FasterXML/jackson-core) from 2.18.0 to 2.18.6. - [Commits](https://github.com/FasterXML/jackson-core/compare/jackson-core-2.18.0...jackson-core-2.18.6) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-core dependency-version: 2.18.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- linter-core/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linter-core/pom.xml b/linter-core/pom.xml index 6186007..17ab91c 100644 --- a/linter-core/pom.xml +++ b/linter-core/pom.xml @@ -79,7 +79,7 @@ com.fasterxml.jackson.core jackson-core - 2.18.0 + 2.18.6 From 79b110a0c3500806c6b5005f7d59097526aa3773 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:33:15 +0200 Subject: [PATCH 02/11] Add new error type for missing Spring configuration in plugin definitions --- .../src/main/java/dev/dsf/linter/output/LintingType.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java index 374b8d2..fa7396c 100644 --- a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java +++ b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java @@ -239,7 +239,12 @@ public enum LintingType { PLUGIN_DEFINITION_PROCESS_PLUGIN_RESOURCE_NOT_LOADED("Plugin definition process plugin resource not loaded."), PLUGIN_DEFINITION_UNPARSABLE_BPMN_RESOURCE("Plugin definition BPMN resource could not be parsed."), PLUGIN_DEFINITION_UNPARSABLE_FHIR_RESOURCE("Plugin definition FHIR resource could not be parsed."), - PLUGIN_DEFINITION_RESOURCE_VERSION_NULL("Plugin definition getResourceVersion() returned null - version pattern invalid."); + PLUGIN_DEFINITION_RESOURCE_VERSION_NULL("Plugin definition getResourceVersion() returned null - version pattern invalid."), + + // ==================== PLUGIN DEFINITION - SPRING CONFIGURATIONS ==================== + PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING( + "A BPMN-referenced delegate or listener class is not provided as a @Bean " + + "in any @Configuration class returned by getSpringConfigurations()."); private final String defaultMessage; From 03901ac240c573dc843d1060d1810feeec0b3581 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:34:30 +0200 Subject: [PATCH 03/11] Add support for retrieving Spring configuration classes in plugin definitions --- .../plugin/PluginDefinitionDiscovery.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java b/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java index ab14d88..c34ff47 100644 --- a/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java +++ b/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java @@ -57,6 +57,22 @@ public interface PluginAdapter { * @return the resource version (e.g., "1.5"), or null if the version pattern is invalid */ String getResourceVersion(); + + /** + * Returns the list of Spring {@code @Configuration} classes that the plugin + * registers with the DSF Spring application context. + *

+ * The Camunda engine in DSF does not instantiate BPMN delegate or listener + * classes directly; Spring manages them via {@code @Bean} methods defined + * in {@code @Configuration} classes. The classes returned from this method + * are the ones declared via the plugin's + * {@code ProcessPluginDefinition#getSpringConfigurations()} method. + *

+ * + * @return the list of registered Spring configuration classes, or an empty + * list if none were registered or the call failed. + */ + List> getSpringConfigurations(); } /** @@ -117,6 +133,20 @@ public String getResourceVersion() { throw new RuntimeException("getResourceVersion", e); } } + + @Override + @SuppressWarnings("unchecked") + public List> getSpringConfigurations() { + try { + List> r = (List>) + delegateClass.getMethod("getSpringConfigurations").invoke(delegate); + return r != null ? r : Collections.emptyList(); + } catch (NoSuchMethodException e) { + return Collections.emptyList(); + } catch (Exception e) { + throw new RuntimeException("getSpringConfigurations", e); + } + } } /** @@ -177,6 +207,20 @@ public String getResourceVersion() { throw new RuntimeException("getResourceVersion", e); } } + + @Override + @SuppressWarnings("unchecked") + public List> getSpringConfigurations() { + try { + List> r = (List>) + delegateClass.getMethod("getSpringConfigurations").invoke(delegate); + return r != null ? r : Collections.emptyList(); + } catch (NoSuchMethodException e) { + return Collections.emptyList(); + } catch (Exception e) { + throw new RuntimeException("getSpringConfigurations", e); + } + } } /** From ffcbbc66e799b6c0131332fb84e47647a556d555 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:35:25 +0200 Subject: [PATCH 04/11] Add `SpringConfigurationLinter` to validate BPMN delegate and listener classes against registered Spring configuration beans --- .../service/SpringConfigurationLinter.java | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java diff --git a/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java b/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java new file mode 100644 index 0000000..d7fd93e --- /dev/null +++ b/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java @@ -0,0 +1,343 @@ +package dev.dsf.linter.service; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.output.item.PluginLintItem; +import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; + +import org.camunda.bpm.model.bpmn.Bpmn; +import org.camunda.bpm.model.bpmn.BpmnModelInstance; +import org.camunda.bpm.model.bpmn.instance.BaseElement; +import org.camunda.bpm.model.bpmn.instance.EventDefinition; +import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition; +import org.camunda.bpm.model.bpmn.instance.SendTask; +import org.camunda.bpm.model.bpmn.instance.ServiceTask; +import org.camunda.bpm.model.bpmn.instance.ThrowEvent; +import org.camunda.bpm.model.bpmn.instance.camunda.CamundaExecutionListener; +import org.camunda.bpm.model.bpmn.instance.camunda.CamundaTaskListener; + +import java.io.File; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Validates that every class referenced as a Camunda delegate or listener in a + * BPMN file is provided as a {@code @Bean} in at least one of the + * {@code @Configuration} classes registered via + * {@code ProcessPluginDefinition#getSpringConfigurations()}. + * + *

Background:

+ *

+ * In the DSF environment the Camunda engine does not instantiate Java delegate + * or listener classes directly. Spring creates those instances via {@code @Bean} + * methods declared in {@code @Configuration} classes. For those beans to be + * available at runtime, every BPMN-referenced class must have a corresponding + * {@code @Bean} method in a configuration class that is explicitly returned by + * {@code ProcessPluginDefinition#getSpringConfigurations()}. + * A missing entry typically surfaces as a {@code BeanCreationException} or + * {@code ClassNotFoundException} only at deployment time. + *

+ * + *

Emitted lint items:

+ *
    + *
  • ERROR {@link LintingType#PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING} + * for every BPMN-referenced class that is not provided as a {@code @Bean} + * in any registered {@code @Configuration} class.
  • + *
  • SUCCESS when every BPMN delegate/listener reference is covered + * by a {@code @Bean} in a registered configuration, or when no BPMN + * delegate/listener references exist.
  • + *
+ */ +public final class SpringConfigurationLinter { + + private static final String CAMUNDA_NS = "http://camunda.org/schema/1.0/bpmn"; + + /** Fully qualified name of the Spring {@code @Bean} annotation. */ + private static final String BEAN_ANNOTATION = "org.springframework.context.annotation.Bean"; + + private SpringConfigurationLinter() { + } + + /** + * Runs the Spring-configuration validation. + * + * @param adapter the plugin adapter used to invoke + * {@code getSpringConfigurations()} reflectively. + * @param bpmnFiles BPMN files belonging to this plugin (used to collect + * delegate/listener class references). + * @param projectDir the extracted project root (used only to derive the + * location label for lint items; may be {@code null}). + * @param logger logger for diagnostic output (may be {@code null}). + * @return list of lint items; never {@code null}. + */ + public static List lint(PluginAdapter adapter, + List bpmnFiles, + File projectDir, + Logger logger) { + List items = new ArrayList<>(); + if (adapter == null) { + return items; + } + + String pluginLocation = adapter.sourceClass().getName(); + File locationFile = projectDir != null ? projectDir : new File("."); + + // Step 1: Get the registered @Configuration classes + List> registered; + try { + registered = adapter.getSpringConfigurations(); + } catch (RuntimeException e) { + if (logger != null) { + logger.debug("Could not invoke getSpringConfigurations() on plugin '" + + pluginLocation + "': " + e.getMessage()); + } + registered = Collections.emptyList(); + } + if (registered == null) { + registered = Collections.emptyList(); + } + + // Step 2: Collect all BPMN-referenced delegate/listener class names + Set referencedBpmnClasses = collectBpmnDelegateReferences(bpmnFiles, logger); + if (logger != null) { + logger.debug("Spring configuration check: " + referencedBpmnClasses.size() + + " BPMN delegate/listener class reference(s) found."); + } + + if (referencedBpmnClasses.isEmpty()) { + items.add(PluginLintItem.success( + locationFile, + pluginLocation, + "No BPMN delegate/listener references found; Spring configuration check skipped." + )); + return items; + } + + // Step 3: Build a map of configClassName -> set of @Bean return-type names + // from the REGISTERED configurations only. + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + Map> registeredConfigBeans = new LinkedHashMap<>(); + for (Class configClass : registered) { + if (configClass == null) { + continue; + } + Set beanTypes = extractBeanReturnTypes(configClass, logger); + registeredConfigBeans.put(configClass.getName(), beanTypes); + if (logger != null) { + logger.debug("Registered @Configuration '" + configClass.getName() + + "' exposes " + beanTypes.size() + " @Bean type(s)."); + } + } + + // Step 4: For each BPMN-referenced class check whether any registered + // configuration provides a @Bean for it (exact type or supertype). + List uncoveredClasses = new ArrayList<>(); + for (String refClass : referencedBpmnClasses) { + boolean covered = false; + for (Set beanTypes : registeredConfigBeans.values()) { + if (configProvidesClass(beanTypes, refClass, cl)) { + covered = true; + break; + } + } + if (!covered) { + uncoveredClasses.add(refClass); + } + } + + // Step 5: Emit one ERROR per uncovered BPMN-referenced class + for (String uncoveredClass : uncoveredClasses) { + items.add(new PluginLintItem( + LinterSeverity.ERROR, + LintingType.PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING, + locationFile, + uncoveredClass, + "BPMN-referenced class '" + simpleName(uncoveredClass) + "' (" + + uncoveredClass + ") is not provided as a @Bean " + + "in any of the " + registeredConfigBeans.size() + + " @Configuration class(es) registered via getSpringConfigurations() " + + "of plugin '" + adapter.getName() + "'. " + + "Add a @Bean method returning " + simpleName(uncoveredClass) + + " to one of the registered @Configuration classes." + )); + } + + if (uncoveredClasses.isEmpty()) { + items.add(PluginLintItem.success( + locationFile, + pluginLocation, + "getSpringConfigurations() registers " + registered.size() + + " @Configuration class(es); all " + referencedBpmnClasses.size() + + " BPMN delegate/listener reference(s) are covered by a registered @Bean." + )); + } + + return items; + } + + // ==================== BPMN REFERENCE COLLECTION ==================== + + private static Set collectBpmnDelegateReferences(List bpmnFiles, Logger logger) { + Set refs = new LinkedHashSet<>(); + if (bpmnFiles == null) { + return refs; + } + for (File bpmnFile : bpmnFiles) { + if (bpmnFile == null || !bpmnFile.isFile()) { + continue; + } + try { + BpmnModelInstance model = Bpmn.readModelFromFile(bpmnFile); + collectFromModel(model, refs); + } catch (Exception e) { + if (logger != null) { + logger.debug("Could not parse BPMN file for Spring config check: " + + bpmnFile.getAbsolutePath() + " - " + e.getMessage()); + } + } + } + return refs; + } + + private static void collectFromModel(BpmnModelInstance model, Set refs) { + for (ServiceTask t : model.getModelElementsByType(ServiceTask.class)) { + addIfNotBlank(t.getCamundaClass(), refs); + addListenerClasses(t, refs); + } + for (SendTask t : model.getModelElementsByType(SendTask.class)) { + addIfNotBlank(t.getCamundaClass(), refs); + addListenerClasses(t, refs); + } + for (ThrowEvent event : model.getModelElementsByType(ThrowEvent.class)) { + addDirectCamundaClass(event, refs); + addMessageEventClasses(event.getEventDefinitions(), refs); + addListenerClasses(event, refs); + } + for (BaseElement e : model.getModelElementsByType(BaseElement.class)) { + addListenerClasses(e, refs); + } + } + + private static void addDirectCamundaClass(BaseElement element, Set refs) { + String direct = element.getAttributeValueNs(CAMUNDA_NS, "class"); + addIfNotBlank(direct, refs); + } + + private static void addMessageEventClasses(Collection defs, Set refs) { + if (defs == null) return; + for (EventDefinition d : defs) { + if (d instanceof MessageEventDefinition med) { + addIfNotBlank(med.getCamundaClass(), refs); + } + } + } + + private static void addListenerClasses(BaseElement element, Set refs) { + try { + for (CamundaExecutionListener l : element.getChildElementsByType(CamundaExecutionListener.class)) { + addIfNotBlank(l.getCamundaClass(), refs); + } + } catch (RuntimeException ignored) { + // Element does not support execution listeners – skip silently. + } + try { + for (CamundaTaskListener l : element.getChildElementsByType(CamundaTaskListener.class)) { + addIfNotBlank(l.getCamundaClass(), refs); + } + } catch (RuntimeException ignored) { + // Element does not support task listeners – skip silently. + } + } + + private static void addIfNotBlank(String v, Set target) { + if (v != null && !v.isBlank()) { + target.add(v.trim()); + } + } + + // ==================== @Bean DISCOVERY ON REGISTERED CONFIGS ==================== + + /** + * Returns the set of return-type names of all {@code @Bean}-annotated methods + * declared directly on {@code configClass}. + */ + private static Set extractBeanReturnTypes(Class configClass, Logger logger) { + Set beanReturnTypes = new LinkedHashSet<>(); + try { + for (Method m : configClass.getDeclaredMethods()) { + if (hasAnnotationByName(m.getAnnotations())) { + Class rt = m.getReturnType(); + if (rt != void.class && rt != Void.class) { + beanReturnTypes.add(rt.getName()); + } + } + } + } catch (Throwable t) { + if (logger != null) { + logger.debug("Could not read @Bean methods of '" + + configClass.getName() + "': " + t.getMessage()); + } + } + return beanReturnTypes; + } + + private static boolean hasAnnotationByName(Annotation[] annotations) { + if (annotations == null) return false; + for (Annotation a : annotations) { + if (a == null) continue; + Class type = a.annotationType(); + if (type != null && SpringConfigurationLinter.BEAN_ANNOTATION.equals(type.getName())) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} if the set of {@code @Bean} return-type names includes + * {@code referencedClass} exactly, or if any listed type is a supertype of + * {@code referencedClass} (to handle abstract/interface return types). + */ + private static boolean configProvidesClass(Set beanReturnTypes, + String referencedClass, + ClassLoader cl) { + if (beanReturnTypes.contains(referencedClass)) { + return true; + } + if (cl == null) { + return false; + } + Class ref; + try { + ref = Class.forName(referencedClass, false, cl); + } catch (ClassNotFoundException | LinkageError e) { + return false; + } + for (String rt : beanReturnTypes) { + try { + Class returnType = Class.forName(rt, false, cl); + if (returnType.isAssignableFrom(ref)) { + return true; + } + } catch (ClassNotFoundException | LinkageError ignored) { + // ignore and continue + } + } + return false; + } + + private static String simpleName(String fqn) { + int i = fqn.lastIndexOf('.'); + return (i >= 0) ? fqn.substring(i + 1) : fqn; + } +} From 33552cd8d151b2439514615a4a0e4711349fa369 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:35:51 +0200 Subject: [PATCH 05/11] Integrate logger into `PluginLintingOrchestrator` and enhance linting with `SpringConfigurationLinter` for BPMN delegate validation. --- .../linter/service/PluginLintingOrchestrator.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java b/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java index c3578a1..d3d199e 100644 --- a/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java +++ b/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java @@ -32,6 +32,7 @@ public class PluginLintingOrchestrator { private final LeftoverResourceDetector leftoverDetector; private final LintingReportGenerator reportGenerator; private final Path reportBasePath; + private final Logger logger; /** * Context information for validating a plugin in a multi-plugin environment. @@ -57,6 +58,7 @@ public PluginLintingOrchestrator( this.leftoverDetector = leftoverDetector; this.reportGenerator = reportGenerator; this.reportBasePath = reportBasePath; + this.logger = logger; } /** @@ -105,6 +107,19 @@ public DsfLinter.PluginLinter lintSinglePlugin( context.projectPath() ); + // Step 4.6: Validate Spring configuration registration + // (cross-references getSpringConfigurations() with BPMN delegate/listener classes) + List springConfigItems = SpringConfigurationLinter.lint( + plugin.adapter(), + plugin.bpmnFiles(), + context.projectDir(), + logger + ); + if (!springConfigItems.isEmpty()) { + metadataItems = new ArrayList<>(metadataItems); + metadataItems.addAll(springConfigItems); + } + // Step 5: Get leftover items for this plugin List leftoverItems = leftoverDetector.getItemsForPlugin( leftoverAnalysis, From f1bb902e8c91136f13fd3f33351a17a36a67bdb2 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:36:10 +0200 Subject: [PATCH 06/11] Add sample classes for SpringConfigurationLinter integration testing --- .../dsf/linter/service/SampleAppConfig.java | 25 +++++++++++++++++++ .../dsf/linter/service/SampleDelegate.java | 8 ++++++ 2 files changed, 33 insertions(+) create mode 100644 linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java create mode 100644 linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java b/linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java new file mode 100644 index 0000000..8dd797e --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java @@ -0,0 +1,25 @@ +package dev.dsf.linter.service; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Sample {@code @Configuration} class used by + * {@link SpringConfigurationLinterIntegrationTest} to simulate a real + * DSF plugin configuration. The {@code @Bean} method return type matches + * the {@code camunda:class} reference placed into the test BPMN file, + * so the linter can cross-reference them. + */ +@Configuration +public class SampleAppConfig { + + /** + * A {@code @Bean} method whose return type is a BPMN-delegate-like class. + * The actual behavior is irrelevant for the linter – only the return type + * is inspected. + */ + @Bean + public SampleDelegate sampleDelegate() { + return new SampleDelegate(); + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java b/linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java new file mode 100644 index 0000000..fef10d4 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java @@ -0,0 +1,8 @@ +package dev.dsf.linter.service; + +/** + * A stand-in for a BPMN service-task delegate class. Referenced via + * {@code camunda:class} in the integration test's BPMN file. + */ +public class SampleDelegate { +} From 8450ea0c0d93bd742c45653bfbeac7fd53e2a10c Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:38:44 +0200 Subject: [PATCH 07/11] Add unit tests for `SpringConfigurationLinter` to validate BPMN delegates against Spring beans --- .../SpringConfigurationLinterTest.java | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java new file mode 100644 index 0000000..cbacd1f --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java @@ -0,0 +1,199 @@ +package dev.dsf.linter.service; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link SpringConfigurationLinter}. + * + *

These tests exercise the decisions the linter makes from the + * {@link PluginAdapter#getSpringConfigurations()} result and BPMN input. + * The linter checks whether every BPMN-referenced class is provided as a + * {@code @Bean} in one of the registered {@code @Configuration} classes.

+ */ +class SpringConfigurationLinterTest { + + private Path tempProjectRoot; + + @BeforeEach + void setUp() throws IOException { + tempProjectRoot = Files.createTempDirectory("spring-config-linter-test-"); + } + + @AfterEach + void tearDown() throws IOException { + if (tempProjectRoot != null) { + deleteRecursively(tempProjectRoot.toFile()); + } + } + + @Test + void emptySpringConfigurations_noBpmn_yieldsSuccess() { + // Empty registered list + no BPMN references → nothing to check → SUCCESS. + PluginAdapter adapter = stubAdapter(Collections.emptyList()); + + List items = SpringConfigurationLinter.lint( + adapter, Collections.emptyList(), tempProjectRoot.toFile(), null); + + assertEquals(1, items.size(), "Expected exactly one lint item"); + assertEquals(LinterSeverity.SUCCESS, items.getFirst().getSeverity()); + } + + @Test + void emptySpringConfigurations_withBpmn_yieldsError() throws IOException { + // Empty registered list + BPMN reference → no @Bean can cover it → ERROR. + File bpmn = writeBpmn(tempProjectRoot, "com.example.MyDelegate"); + PluginAdapter adapter = stubAdapter(Collections.emptyList()); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "Expected ERROR when no registered config can cover a BPMN reference"); + } + + @Test + void nonEmptyConfigurations_noBpmn_noProjectScan_yieldsSuccess() { + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, Collections.emptyList(), tempProjectRoot.toFile(), null); + + assertEquals(1, items.size()); + assertEquals(LinterSeverity.SUCCESS, items.getFirst().getSeverity()); + assertEquals(LintingType.SUCCESS, items.getFirst().getType()); + } + + @Test + void nullProjectDir_nonEmptyConfigurations_yieldsSuccess() { + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, Collections.emptyList(), null, null); + + assertEquals(1, items.size()); + assertEquals(LinterSeverity.SUCCESS, items.getFirst().getSeverity()); + } + + @Test + void bpmnReference_notCoveredByRegisteredBean_yieldsError() throws IOException { + // DummyConfig has no @Bean methods at all, so the BPMN-referenced class + // cannot be resolved → the linter must report an ERROR. + File bpmn = writeBpmn(tempProjectRoot, + "dev.dsf.bpe.service.SelectPingTargets"); + + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "Expected ERROR when a BPMN-referenced class has no matching @Bean"); + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "No SUCCESS item expected when at least one reference is uncovered"); + } + + @Test + void bpmnReference_coveredByRegisteredBean_yieldsSuccess() throws IOException { + // ConfigWithBean declares a @Bean for the exact BPMN-referenced class → SUCCESS. + File bpmn = writeBpmn(tempProjectRoot, + ConfigWithBean.BeanClass.class.getName()); + + PluginAdapter adapter = stubAdapter(List.of(ConfigWithBean.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "No ERROR expected when the BPMN reference is covered by a registered @Bean"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "Expected SUCCESS when all BPMN references are covered"); + } + + + // ==================== Helpers ==================== + + private static PluginAdapter stubAdapter(List> springConfigs) { + return new PluginAdapter() { + @Override public String getName() { return "test-plugin"; } + @Override public List getProcessModels() { return Collections.emptyList(); } + @Override public Map> getFhirResourcesByProcessId() { return Collections.emptyMap(); } + @Override public Class sourceClass() { return DummyConfig.class; } + @Override public String getResourceVersion() { return "1.0"; } + @Override public List> getSpringConfigurations() { return springConfigs; } + }; + } + + private static Logger silentLogger() { + return new Logger() { + @Override public void info(String message) {} + @Override public void warn(String message) {} + @Override public void error(String message) {} + @Override public void error(String message, Throwable throwable) {} + @Override public void debug(String message) {} + @Override public boolean verbose() { return false; } + @Override public boolean isVerbose() { return false; } + }; + } + + private static File writeBpmn(Path dir, String camundaClass) throws IOException { + String bpmn = """ + + + + + + + """.formatted(camundaClass); + Path p = dir.resolve("sample.bpmn"); + Files.writeString(p, bpmn, StandardCharsets.UTF_8); + return p.toFile(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void deleteRecursively(File f) { + if (f == null || !f.exists()) return; + File[] kids = f.listFiles(); + if (kids != null) { + for (File k : kids) deleteRecursively(k); + } + f.delete(); + } + + /** Placeholder with no @Bean methods – any BPMN reference will be uncovered. */ + static final class DummyConfig { + } + + /** Config that provides exactly one @Bean for {@link BeanClass}. */ + static final class ConfigWithBean { + static final class BeanClass { + } + + @org.springframework.context.annotation.Bean + public BeanClass beanClass() { + return new BeanClass(); + } + } +} From 37ca15b2d41c63e1f49b87024f792abd5bf1223c Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Wed, 22 Apr 2026 20:39:09 +0200 Subject: [PATCH 08/11] Add integration tests for `SpringConfigurationLinter` to verify BPMN delegate validation against Spring beans --- ...ingConfigurationLinterIntegrationTest.java | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java new file mode 100644 index 0000000..ba49155 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java @@ -0,0 +1,137 @@ +package dev.dsf.linter.service; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration-style tests for {@link SpringConfigurationLinter}. + * + *

Verifies the core rule: every class referenced as a Camunda delegate or + * listener in a BPMN file must be provided as a {@code @Bean} in at least one + * of the {@code @Configuration} classes returned by + * {@code ProcessPluginDefinition#getSpringConfigurations()}.

+ */ +class SpringConfigurationLinterIntegrationTest { + + private Path tempProjectRoot; + + @BeforeEach + void setUp() throws IOException { + tempProjectRoot = Files.createTempDirectory("spring-config-integ-"); + } + + @AfterEach + void tearDown() { + deleteRecursively(tempProjectRoot.toFile()); + } + + @Test + void missingRegistration_producesError() throws IOException { + // BPMN references SampleDelegate; Object.class has no @Bean for it → ERROR. + File bpmn = writeBpmn(tempProjectRoot, SampleDelegate.class.getName()); + + PluginAdapter adapter = stubAdapter(List.of(Object.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + AbstractLintItem errorItem = items.stream() + .filter(i -> i.getSeverity() == LinterSeverity.ERROR) + .findFirst() + .orElse(null); + assertNotNull(errorItem, "Expected an ERROR when the BPMN-referenced class has no @Bean"); + assertEquals(LintingType.PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING, errorItem.getType()); + assertTrue(errorItem.getDescription().contains(SampleDelegate.class.getSimpleName()), + "Error description should mention the uncovered BPMN-referenced class"); + + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "No SUCCESS item expected when at least one reference is uncovered"); + } + + @Test + void registeredConfiguration_producesSuccess() throws IOException { + File bpmn = writeBpmn(tempProjectRoot, SampleDelegate.class.getName()); + + PluginAdapter adapter = stubAdapter(List.of(SampleAppConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "Expected no ERROR when the required configuration is registered"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "Expected a SUCCESS item when all references resolve"); + } + + // ==================== Helpers ==================== + + private static PluginAdapter stubAdapter(List> springConfigs) { + return new PluginAdapter() { + @Override public String getName() { return "sample-plugin"; } + @Override public List getProcessModels() { return Collections.emptyList(); } + @Override public Map> getFhirResourcesByProcessId() { return Collections.emptyMap(); } + @Override public Class sourceClass() { return SampleAppConfig.class; } + @Override public String getResourceVersion() { return "1.0"; } + @Override public List> getSpringConfigurations() { return springConfigs; } + }; + } + + private static Logger silentLogger() { + return new Logger() { + @Override public void info(String message) {} + @Override public void warn(String message) {} + @Override public void error(String message) {} + @Override public void error(String message, Throwable throwable) {} + @Override public void debug(String message) {} + @Override public boolean verbose() { return false; } + @Override public boolean isVerbose() { return false; } + }; + } + + private static File writeBpmn(Path dir, String camundaClass) throws IOException { + String bpmn = """ + + + + + + + """.formatted(camundaClass); + Path p = dir.resolve("sample.bpmn"); + Files.writeString(p, bpmn, StandardCharsets.UTF_8); + return p.toFile(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void deleteRecursively(File f) { + if (f == null || !f.exists()) return; + File[] kids = f.listFiles(); + if (kids != null) { + for (File k : kids) deleteRecursively(k); + } + f.delete(); + } +} From 94ce57725b4672889cf671180e7676306dc5ecf2 Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 27 Apr 2026 20:31:02 +0200 Subject: [PATCH 09/11] Add new error types to handle various Spring bean scope validations in `LintingType` --- .../main/java/dev/dsf/linter/output/LintingType.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java index fa7396c..8aa7bc3 100644 --- a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java +++ b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java @@ -244,7 +244,17 @@ public enum LintingType { // ==================== PLUGIN DEFINITION - SPRING CONFIGURATIONS ==================== PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING( "A BPMN-referenced delegate or listener class is not provided as a @Bean " - + "in any @Configuration class returned by getSpringConfigurations()."); + + "in any @Configuration class returned by getSpringConfigurations()."), + + // ==================== SPRING BEAN SCOPE ==================== + SPRING_BEAN_SCOPE_MISSING( + "BPMN-referenced bean has no @Scope annotation (defaults to singleton)."), + SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT( + "BPMN-referenced bean is explicitly configured as singleton."), + SPRING_BEAN_SCOPE_PROTOTYPE( + "BPMN-referenced bean is correctly configured as prototype."), + SPRING_BEAN_SCOPE_MUTABLE_SINGLETON( + "BPMN-referenced singleton bean has mutable (non-static, non-final) instance fields."); private final String defaultMessage; From 5d7b77b2e5ef184ad4c3669f8d767c2a431cb6eb Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 27 Apr 2026 20:37:49 +0200 Subject: [PATCH 10/11] Enhance `SpringConfigurationLinter` to validate Spring bean scope and mutable state for BPMN-referenced classes. --- .../service/SpringConfigurationLinter.java | 314 ++++++++++++++++-- 1 file changed, 287 insertions(+), 27 deletions(-) diff --git a/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java b/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java index d7fd93e..0fb24b8 100644 --- a/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java @@ -20,7 +20,9 @@ import java.io.File; import java.lang.annotation.Annotation; +import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -28,34 +30,74 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; /** - * Validates that every class referenced as a Camunda delegate or listener in a - * BPMN file is provided as a {@code @Bean} in at least one of the - * {@code @Configuration} classes registered via - * {@code ProcessPluginDefinition#getSpringConfigurations()}. + * Validates DSF process plugins against how Spring provides Camunda delegates + * and listeners: bean registration and (for covered beans) {@code @Scope} plus + * mutable instance fields. * - *

Background:

+ *

Background

*

* In the DSF environment the Camunda engine does not instantiate Java delegate * or listener classes directly. Spring creates those instances via {@code @Bean} - * methods declared in {@code @Configuration} classes. For those beans to be - * available at runtime, every BPMN-referenced class must have a corresponding - * {@code @Bean} method in a configuration class that is explicitly returned by - * {@code ProcessPluginDefinition#getSpringConfigurations()}. - * A missing entry typically surfaces as a {@code BeanCreationException} or - * {@code ClassNotFoundException} only at deployment time. + * methods on {@code @Configuration} classes that must be returned by + * {@code ProcessPluginDefinition#getSpringConfigurations()}. A missing + * {@code @Bean} for a BPMN-referenced class often appears only at deployment + * time as a {@code BeanCreationException} or {@code ClassNotFoundException}. *

+ *

+ * DSF best practice is prototype scope for such beans. Omitting + * {@code @Scope} defaults Spring to singleton, which is unsafe if + * the implementation ever holds mutable instance state (non-{@code static}, + * non-{@code final} fields) across concurrent process executions. + *

+ * + *

Validation pipeline (see {@link #lint})

+ *
    + *
  1. Load registered {@code @Configuration} classes from + * {@code getSpringConfigurations()}.
  2. + *
  3. Scan plugin BPMN files for {@code camunda:class} references (service + * and send tasks, throw events with message definitions, execution and + * task listeners).
  4. + *
  5. Build {@code @Bean} return types per configuration class; check each + * referenced class is covered (exact FQN or supertype assignability).
  6. + *
  7. For each covered class, find the covering {@code @Bean} method + * and inspect its {@code @Scope} and the implementation class for mutable + * instance fields.
  8. + *
* - *

Emitted lint items:

+ *

Emitted {@link LintingType} values

+ *

Bean registration

*
    - *
  • ERROR {@link LintingType#PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING} - * for every BPMN-referenced class that is not provided as a {@code @Bean} - * in any registered {@code @Configuration} class.
  • - *
  • SUCCESS when every BPMN delegate/listener reference is covered - * by a {@code @Bean} in a registered configuration, or when no BPMN - * delegate/listener references exist.
  • + *
  • ERROR – {@link LintingType#PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING}: + * BPMN references a class that is not provided as a {@code @Bean} in any + * registered configuration.
  • + *
  • SUCCESS – {@link LintingType#SUCCESS}: all references are + * covered by a registered {@code @Bean} and there are no registration + * errors (a summary item is also emitted when the reference set is + * non-empty and fully covered; see {@link #lint}).
  • + *
  • When there are no BPMN delegate/listener references, a single success + * item is emitted and the run returns early (scope checks do not run).
  • + *
+ *

Scope and mutable state (covered classes only)

+ *
    + *
  • SUCCESS – {@link LintingType#SPRING_BEAN_SCOPE_PROTOTYPE}: + * the covering {@code @Bean} has {@code @Scope} with value + * {@code "prototype"} (recommended for Camunda hooks).
  • + *
  • ERROR – {@link LintingType#SPRING_BEAN_SCOPE_MUTABLE_SINGLETON}: + * the bean is effectively singleton (no {@code @Scope} or explicit + * non-prototype scope) and the implementation class has mutable + * instance fields. Emitted before the corresponding scope warning for that + * reference.
  • + *
  • WARN – {@link LintingType#SPRING_BEAN_SCOPE_MISSING}: + * the covering {@code @Bean} has no {@code @Scope} (Spring defaults to + * singleton).
  • + *
  • WARN – {@link LintingType#SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT}: + * the covering {@code @Bean} has an explicit non-prototype + * {@code @Scope} (e.g. {@code "singleton"}); the implementation must be + * provably stateless for a shared instance.
  • *
*/ public final class SpringConfigurationLinter { @@ -65,20 +107,37 @@ public final class SpringConfigurationLinter { /** Fully qualified name of the Spring {@code @Bean} annotation. */ private static final String BEAN_ANNOTATION = "org.springframework.context.annotation.Bean"; + /** Fully qualified name of the Spring {@code @Scope} annotation. */ + private static final String SCOPE_ANNOTATION = "org.springframework.context.annotation.Scope"; + private SpringConfigurationLinter() { } /** - * Runs the Spring-configuration validation. + * Runs Spring configuration validation: {@code @Bean} coverage for all BPMN + * delegate/listener references, then for each covered class an + * optional scope/mutability pass ({@code @Scope} on the covering + * {@code @Bean} method and mutable instance fields on the implementation + * type). * - * @param adapter the plugin adapter used to invoke - * {@code getSpringConfigurations()} reflectively. - * @param bpmnFiles BPMN files belonging to this plugin (used to collect - * delegate/listener class references). - * @param projectDir the extracted project root (used only to derive the - * location label for lint items; may be {@code null}). - * @param logger logger for diagnostic output (may be {@code null}). - * @return list of lint items; never {@code null}. + *

When {@code bpmnFiles} yields no references, returns a single success item + * and does not run registration or scope checks.

+ * + *

When there are references, emits one error per uncovered class, then for + * each covered class (if a covering {@code @Bean} method was resolved): + * prototype scope → one success item; otherwise if mutable fields → error, + * then either missing {@code @Scope} → warning or explicit non-prototype scope + * → warning. If every reference is covered by some {@code @Bean}, a summary + * success item for full registration coverage is appended.

+ * + * @param adapter the plugin adapter used to invoke + * {@code getSpringConfigurations()} reflectively + * @param bpmnFiles BPMN files belonging to this plugin (used to collect + * delegate/listener class references) + * @param projectDir the extracted project root (used only for the file + * label on lint items; may be {@code null}) + * @param logger logger for diagnostic output (may be {@code null}) + * @return ordered list of lint items; never {@code null} */ public static List lint(PluginAdapter adapter, List bpmnFiles, @@ -172,6 +231,79 @@ public static List lint(PluginAdapter adapter, )); } + // Step 5.5: For each covered class check @Scope on the covering @Bean method + // and report mutable-field hazards for effective-singleton beans. + Map> configBeanMethods = new LinkedHashMap<>(); + for (Class configClass : registered) { + if (configClass != null) { + configBeanMethods.put(configClass.getName(), + extractBeanMethodMap(configClass, logger)); + } + } + for (String refClass : referencedBpmnClasses) { + if (uncoveredClasses.contains(refClass)) { + continue; // already reported as ERROR in step 5 + } + Optional coveringMethod = findCoveringMethod(configBeanMethods, refClass, cl); + if (coveringMethod.isEmpty()) { + continue; + } + String scopeValue = getScopeValue(coveringMethod.get()); + boolean mutable = hasMutableInstanceFields(refClass, cl); + + if ("prototype".equals(scopeValue)) { + items.add(new PluginLintItem( + LinterSeverity.SUCCESS, + LintingType.SPRING_BEAN_SCOPE_PROTOTYPE, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is correctly configured as a prototype-scoped @Bean." + )); + continue; // prototype is safe – no further scope checks needed + } + + // Effective singleton (missing @Scope or explicit non-prototype): check mutable fields first. + if (mutable) { + items.add(new PluginLintItem( + LinterSeverity.ERROR, + LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is effectively singleton-scoped and " + + "contains mutable (non-static, non-final) instance fields. " + + "This will cause race conditions under concurrent process execution." + )); + } + + if (scopeValue == null) { + items.add(new PluginLintItem( + LinterSeverity.WARN, + LintingType.SPRING_BEAN_SCOPE_MISSING, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") has a @Bean method without an explicit @Scope " + + "annotation. Spring defaults to singleton scope, which is risky " + + "for Camunda delegates and listeners. Consider adding " + + "@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)." + )); + } else { + // Explicit non-prototype scope (typically "singleton") + items.add(new PluginLintItem( + LinterSeverity.WARN, + LintingType.SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is explicitly configured with @Scope(\"" + + scopeValue + "\"). Singleton-scoped Camunda delegates and " + + "listeners must be actually completely stateless." + )); + } + } + if (uncoveredClasses.isEmpty()) { items.add(PluginLintItem.success( locationFile, @@ -340,4 +472,132 @@ private static String simpleName(String fqn) { int i = fqn.lastIndexOf('.'); return (i >= 0) ? fqn.substring(i + 1) : fqn; } + + // ==================== @Bean METHOD MAP (for scope checks) ==================== + + /** + * Returns a map from return-type name to the {@code @Bean}-annotated {@link Method} + * for all bean methods declared directly on {@code configClass}. + * Analogous to {@link #extractBeanReturnTypes} but retains the {@code Method} + * object so callers can inspect further annotations such as {@code @Scope}. + */ + private static Map extractBeanMethodMap(Class configClass, Logger logger) { + Map result = new LinkedHashMap<>(); + try { + for (Method m : configClass.getDeclaredMethods()) { + if (hasAnnotationByName(m.getAnnotations())) { + Class rt = m.getReturnType(); + if (rt != void.class && rt != Void.class) { + result.putIfAbsent(rt.getName(), m); + } + } + } + } catch (Throwable t) { + if (logger != null) { + logger.debug("Could not read @Bean methods of '" + + configClass.getName() + "' for scope check: " + t.getMessage()); + } + } + return result; + } + + /** + * Searches all registered config bean-method maps for the first {@code @Bean} + * method whose return type covers {@code referencedClass} – either by exact name + * or by assignability ({@code returnType.isAssignableFrom(referencedClass)}). + */ + private static Optional findCoveringMethod( + Map> configBeanMethods, + String referencedClass, + ClassLoader cl) { + + // 1. Exact match across all configs + for (Map methodMap : configBeanMethods.values()) { + Method m = methodMap.get(referencedClass); + if (m != null) { + return Optional.of(m); + } + } + + // 2. Assignability fallback + if (cl == null) { + return Optional.empty(); + } + Class ref; + try { + ref = Class.forName(referencedClass, false, cl); + } catch (ClassNotFoundException | LinkageError e) { + return Optional.empty(); + } + for (Map methodMap : configBeanMethods.values()) { + for (Map.Entry entry : methodMap.entrySet()) { + try { + Class returnType = Class.forName(entry.getKey(), false, cl); + if (returnType.isAssignableFrom(ref)) { + return Optional.of(entry.getValue()); + } + } catch (ClassNotFoundException | LinkageError ignored) { + // skip unresolvable types + } + } + } + return Optional.empty(); + } + + // ==================== @Scope READING ==================== + + /** + * Returns the {@code value()} of the {@code @Scope} annotation present on + * {@code beanMethod}, or {@code null} if no {@code @Scope} annotation is found. + * + *

The annotation is identified by its fully-qualified class name to tolerate + * cases where the annotation was loaded by a different {@link ClassLoader} than + * the linter's own class loader.

+ */ + private static String getScopeValue(Method beanMethod) { + for (Annotation a : beanMethod.getAnnotations()) { + if (SCOPE_ANNOTATION.equals(a.annotationType().getName())) { + try { + Object value = a.annotationType().getMethod("value").invoke(a); + if (value instanceof String s && !s.isBlank()) { + return s; + } + // scopeName() is an alias for value() in some Spring versions + Object scopeName = a.annotationType().getMethod("scopeName").invoke(a); + if (scopeName instanceof String s && !s.isBlank()) { + return s; + } + } catch (Exception ignored) { + // Annotation structure unexpected – treat as no scope + } + return null; + } + } + return null; + } + + // ==================== MUTABLE FIELD DETECTION ==================== + + /** + * Returns {@code true} when the class identified by {@code className} declares at + * least one instance field that is neither {@code static} nor {@code final}. + * Such fields are a concurrency hazard when the bean is singleton-scoped. + */ + private static boolean hasMutableInstanceFields(String className, ClassLoader cl) { + if (cl == null) { + return false; + } + try { + Class clazz = Class.forName(className, false, cl); + for (Field f : clazz.getDeclaredFields()) { + int mod = f.getModifiers(); + if (!Modifier.isStatic(mod) && !Modifier.isFinal(mod)) { + return true; + } + } + } catch (ClassNotFoundException | LinkageError ignored) { + // Cannot load class – skip mutable-field check + } + return false; + } } From e90626a1b94064a54fe5043da2f329b8beb8daee Mon Sep 17 00:00:00 2001 From: khalilmalla95 Date: Mon, 27 Apr 2026 20:38:31 +0200 Subject: [PATCH 11/11] Add unit tests to validate Spring bean scope and mutable state with `SpringConfigurationLinter` --- .../SpringConfigurationLinterTest.java | 131 +++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java index cbacd1f..1e275ae 100644 --- a/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java @@ -6,6 +6,9 @@ import dev.dsf.linter.output.item.AbstractLintItem; import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -131,6 +134,78 @@ void bpmnReference_coveredByRegisteredBean_yieldsSuccess() throws IOException { } + // ==================== @Scope tests ==================== + + @Test + void beanWithoutScope_noMutableFields_yieldsWarn() throws IOException { + // ConfigNoScope provides a @Bean with no @Scope; the bean class has only + // final fields → WARN about missing scope, but no ERROR for mutable fields. + File bpmn = writeBpmn(tempProjectRoot, ConfigNoScope.ImmutableBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigNoScope.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MISSING), + "Expected WARN for missing @Scope"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "No ERROR expected when bean class has no mutable fields"); + } + + @Test + void beanWithoutScope_mutableField_yieldsWarnAndError() throws IOException { + // ConfigNoScopeMutable provides a @Bean with no @Scope; the bean class has a + // mutable field → WARN for missing scope AND ERROR for mutable singleton. + File bpmn = writeBpmn(tempProjectRoot, ConfigNoScopeMutable.MutableBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigNoScopeMutable.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MISSING), + "Expected WARN for missing @Scope"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "Expected ERROR for mutable fields on effective-singleton bean"); + } + + @Test + void beanWithExplicitSingleton_mutableField_yieldsWarnAndError() throws IOException { + // ConfigExplicitSingleton uses @Scope("singleton") explicitly; the bean class + // has a mutable field → WARN for explicit singleton AND ERROR for mutable fields. + File bpmn = writeBpmn(tempProjectRoot, ConfigExplicitSingleton.MutableBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigExplicitSingleton.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT), + "Expected WARN for explicit singleton scope"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "Expected ERROR for mutable fields on explicit-singleton bean"); + } + + @Test + void beanWithPrototypeScope_yieldsSuccess_evenWithMutableFields() throws IOException { + // ConfigPrototype uses @Scope("prototype"); the bean class has a mutable field + // but prototype scope is safe → SUCCESS, no ERROR for mutable fields. + File bpmn = writeBpmn(tempProjectRoot, ConfigPrototype.AnyBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigPrototype.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS + && i.getType() == LintingType.SPRING_BEAN_SCOPE_PROTOTYPE), + "Expected SUCCESS for prototype-scoped bean"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "No ERROR expected for prototype-scoped bean regardless of mutable fields"); + } + // ==================== Helpers ==================== private static PluginAdapter stubAdapter(List> springConfigs) { @@ -191,9 +266,63 @@ static final class ConfigWithBean { static final class BeanClass { } - @org.springframework.context.annotation.Bean + @Bean public BeanClass beanClass() { return new BeanClass(); } } + + /** Config with a @Bean and no @Scope; bean class has only final fields (immutable). */ + static final class ConfigNoScope { + @SuppressWarnings("unused") + static final class ImmutableBean { + private final int x = 0; + } + + @Bean + public ImmutableBean bean() { + return new ImmutableBean(); + } + } + + /** Config with a @Bean and no @Scope; bean class has a mutable instance field. */ + static final class ConfigNoScopeMutable { + @SuppressWarnings("unused") + static final class MutableBean { + private int counter; // mutable – not static, not final + } + + @Bean + public MutableBean bean() { + return new MutableBean(); + } + } + + /** Config with an explicit @Scope("singleton"); bean class has a mutable field. */ + static final class ConfigExplicitSingleton { + @SuppressWarnings("unused") + static final class MutableBean { + private String state; // mutable + } + + @Bean + @Scope("singleton") + public MutableBean bean() { + return new MutableBean(); + } + } + + /** Config with @Scope("prototype"); bean class has a mutable field (safe for prototype). */ + static final class ConfigPrototype { + @SuppressWarnings("unused") + static final class AnyBean { + private int x; // mutable, but prototype-scoped → no ERROR + } + + @Bean + @Scope("prototype") + public AnyBean bean() { + return new AnyBean(); + } + } }