This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch 
fix/WW-4421-duplicate-action-annotation-check
in repository https://gitbox.apache.org/repos/asf/struts.git

commit 99e4d496becdbabe0b58e2391ef812b32ee73558
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Feb 15 16:22:35 2026 +0100

    fix(convention): WW-4421 detect duplicate @Action names when execute() is 
annotated
    
    The duplicate @Action name detection in PackageBasedActionConfigBuilder
    was embedded inside a conditional block that only ran when execute() was
    NOT annotated with @Action. This meant two methods could map to the same
    action name silently when execute() had an @Action annotation, with one
    overwriting the other non-deterministically.
    
    Extract the duplicate check to run unconditionally before the conditional
    block, so it applies to all annotated methods regardless of whether
    execute() is annotated.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-Authored-By: Claude <[email protected]>
---
 .../PackageBasedActionConfigBuilder.java           |  27 ++--
 .../PackageBasedActionConfigBuilderTest.java       |  98 ++++++++++++++
 ...icateActionNameWithExecuteAnnotationAction.java |  38 ++++++
 ...teActionNameWithoutExecuteAnnotationAction.java |  42 ++++++
 ...21-duplicate-action-annotation-check-skipped.md | 141 +++++++++++++++++++++
 5 files changed, 334 insertions(+), 12 deletions(-)

diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java
index bbd718497..4211e0cdf 100644
--- 
a/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java
@@ -739,8 +739,21 @@ public class PackageBasedActionConfigBuilder implements 
ActionConfigBuilder {
                 // Verify that the annotations have no errors and also 
determine if the default action
                 // configuration should still be built or not.
                 Map<String, List<Action>> map = 
getActionAnnotations(actionClass);
-                Set<String> actionNames = new HashSet<>();
                 boolean hasDefaultMethod = 
ReflectionTools.containsMethod(actionClass, DEFAULT_METHOD);
+
+                // Check for duplicate action names across all annotated 
methods
+                Set<String> actionNames = new HashSet<>();
+                for (List<Action> actions : map.values()) {
+                    for (Action action : actions) {
+                        String actionName = 
action.value().equals(Action.DEFAULT_VALUE) ? defaultActionName : 
action.value();
+                        if (!actionNames.add(actionName)) {
+                            throw new ConfigurationException("The action class 
[" + actionClass +
+                                    "] contains two methods with an action 
name annotation whose value " +
+                                    "is the same (they both might be empty as 
well).");
+                        }
+                    }
+                }
+
                 if (!map.containsKey(DEFAULT_METHOD)
                         && hasDefaultMethod
                         && actionAnnotation == null && actionsAnnotation == 
null
@@ -748,20 +761,10 @@ public class PackageBasedActionConfigBuilder implements 
ActionConfigBuilder {
                     boolean found = false;
                     for (List<Action> actions : map.values()) {
                         for (Action action : actions) {
-
-                            // Check if there are duplicate action names in 
the annotations.
-                            String actionName = 
action.value().equals(Action.DEFAULT_VALUE) ? defaultActionName : 
action.value();
-                            if (actionNames.contains(actionName)) {
-                                throw new ConfigurationException("The action 
class [" + actionClass +
-                                        "] contains two methods with an action 
name annotation whose value " +
-                                        "is the same (they both might be empty 
as well).");
-                            } else {
-                                actionNames.add(actionName);
-                            }
-
                             // Check this annotation is the default action
                             if (action.value().equals(Action.DEFAULT_VALUE)) {
                                 found = true;
+                                break;
                             }
                         }
                     }
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
index 975aaea35..c7c46b93b 100644
--- 
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
@@ -30,6 +30,7 @@ import org.apache.struts2.FileManager;
 import org.apache.struts2.FileManagerFactory;
 import org.apache.struts2.ObjectFactory;
 import org.apache.struts2.config.Configuration;
+import org.apache.struts2.config.ConfigurationException;
 import org.apache.struts2.config.entities.ActionConfig;
 import org.apache.struts2.config.entities.ExceptionMappingConfig;
 import org.apache.struts2.config.entities.InterceptorConfig;
@@ -249,6 +250,103 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         assertFalse("ClassNotFoundException should be caught and class should 
be excluded", result);
     }
 
+    /**
+     * Tests that duplicate @Action name detection works when execute() is 
annotated with @Action.
+     * Before the fix for WW-4421, the duplicate check was inside a 
conditional block that was
+     * skipped when execute() had an @Action annotation, allowing duplicate 
action names silently.
+     *
+     * @see <a href="https://issues.apache.org/jira/browse/WW-4421";>WW-4421</a>
+     */
+    public void testDuplicateActionNameWithAnnotatedExecute() {
+        ResultTypeConfig defaultResult = new 
ResultTypeConfig.Builder("dispatcher",
+                
ServletDispatcherResult.class.getName()).defaultResultParam("location").build();
+        PackageConfig strutsDefault = makePackageConfig("struts-default", 
null, null, "dispatcher",
+                new ResultTypeConfig[]{defaultResult}, null, null, null, true);
+
+        final DummyContainer mockContainer = new DummyContainer();
+        Configuration configuration = new DefaultConfiguration() {
+            @Override
+            public Container getContainer() {
+                return mockContainer;
+            }
+        };
+        configuration.addPackageConfig("struts-default", strutsDefault);
+
+        ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", 
"-");
+        ObjectFactory of = new ObjectFactory();
+        of.setContainer(mockContainer);
+
+        mockContainer.setActionNameBuilder(actionNameBuilder);
+        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+
+        PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
+                configuration, mockContainer, of, "false", "struts-default", 
"false");
+        
builder.setActionPackages("org.apache.struts2.convention.duplicate.annotatedexecute");
+        builder.setActionSuffix("Action");
+
+        DefaultFileManagerFactory fileManagerFactory = new 
DefaultFileManagerFactory();
+        
fileManagerFactory.setContainer(ActionContext.getContext().getContainer());
+        fileManagerFactory.setFileManager(new DefaultFileManager());
+        builder.setFileManagerFactory(fileManagerFactory);
+        builder.setProviderAllowlist(new ProviderAllowlist());
+
+        try {
+            builder.buildActionConfigs();
+            fail("Expected ConfigurationException for duplicate action names");
+        } catch (ConfigurationException e) {
+            assertTrue("Exception message should mention duplicate action 
names",
+                    e.getMessage().contains("two methods with an action name 
annotation whose value is the same"));
+        }
+    }
+
+    /**
+     * Tests that duplicate @Action name detection works when execute() is NOT 
annotated with @Action.
+     * This is a regression guard for WW-4421 — this case was already detected 
before the fix.
+     *
+     * @see <a href="https://issues.apache.org/jira/browse/WW-4421";>WW-4421</a>
+     */
+    public void testDuplicateActionNameWithoutAnnotatedExecute() throws 
Exception {
+        ResultTypeConfig defaultResult = new 
ResultTypeConfig.Builder("dispatcher",
+                
ServletDispatcherResult.class.getName()).defaultResultParam("location").build();
+        PackageConfig strutsDefault = makePackageConfig("struts-default", 
null, null, "dispatcher",
+                new ResultTypeConfig[]{defaultResult}, null, null, null, true);
+
+        final DummyContainer mockContainer = new DummyContainer();
+        Configuration configuration = new DefaultConfiguration() {
+            @Override
+            public Container getContainer() {
+                return mockContainer;
+            }
+        };
+        configuration.addPackageConfig("struts-default", strutsDefault);
+
+        ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", 
"-");
+        ObjectFactory of = new ObjectFactory();
+        of.setContainer(mockContainer);
+
+        mockContainer.setActionNameBuilder(actionNameBuilder);
+        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+
+        PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
+                configuration, mockContainer, of, "false", "struts-default", 
"false");
+        
builder.setActionPackages("org.apache.struts2.convention.duplicate.unannotatedexecute");
+        builder.setActionSuffix("Action");
+
+        DefaultFileManagerFactory fileManagerFactory = new 
DefaultFileManagerFactory();
+        
fileManagerFactory.setContainer(ActionContext.getContext().getContainer());
+        fileManagerFactory.setFileManager(new DefaultFileManager());
+        builder.setFileManagerFactory(fileManagerFactory);
+        builder.setProviderAllowlist(new ProviderAllowlist());
+
+        try {
+            builder.buildActionConfigs();
+            fail("Expected ConfigurationException for duplicate action names");
+        } catch (ConfigurationException e) {
+            assertTrue("Exception message should mention duplicate action 
names",
+                    e.getMessage().contains("two methods with an action name 
annotation whose value is the same"));
+        }
+    }
+
     public void testActionPackages() throws MalformedURLException {
         run("org.apache.struts2.convention.actions", null, null);
     }
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/annotatedexecute/DuplicateActionNameWithExecuteAnnotationAction.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/annotatedexecute/DuplicateActionNameWithExecuteAnnotationAction.java
new file mode 100644
index 000000000..89b3256d6
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/annotatedexecute/DuplicateActionNameWithExecuteAnnotationAction.java
@@ -0,0 +1,38 @@
+/*
+ * 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.struts2.convention.duplicate.annotatedexecute;
+
+import org.apache.struts2.convention.annotation.Action;
+
+/**
+ * Test action with duplicate @Action names where execute() is annotated.
+ * This is the case that was previously not detected (WW-4421).
+ */
+public class DuplicateActionNameWithExecuteAnnotationAction {
+
+    @Action("duplicate")
+    public String execute() {
+        return "success";
+    }
+
+    @Action("duplicate")
+    public String anotherMethod() {
+        return "success";
+    }
+}
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/unannotatedexecute/DuplicateActionNameWithoutExecuteAnnotationAction.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/unannotatedexecute/DuplicateActionNameWithoutExecuteAnnotationAction.java
new file mode 100644
index 000000000..8048fa2ff
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/unannotatedexecute/DuplicateActionNameWithoutExecuteAnnotationAction.java
@@ -0,0 +1,42 @@
+/*
+ * 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.struts2.convention.duplicate.unannotatedexecute;
+
+import org.apache.struts2.convention.annotation.Action;
+
+/**
+ * Test action with duplicate @Action names where execute() is NOT annotated.
+ * This is the case that was already detected before the fix (regression guard 
for WW-4421).
+ */
+public class DuplicateActionNameWithoutExecuteAnnotationAction {
+
+    public String execute() {
+        return "success";
+    }
+
+    @Action("duplicate")
+    public String method1() {
+        return "success";
+    }
+
+    @Action("duplicate")
+    public String method2() {
+        return "success";
+    }
+}
diff --git 
a/thoughts/shared/research/2026-02-15-WW-4421-duplicate-action-annotation-check-skipped.md
 
b/thoughts/shared/research/2026-02-15-WW-4421-duplicate-action-annotation-check-skipped.md
new file mode 100644
index 000000000..b3f6d51eb
--- /dev/null
+++ 
b/thoughts/shared/research/2026-02-15-WW-4421-duplicate-action-annotation-check-skipped.md
@@ -0,0 +1,141 @@
+---
+date: 2026-02-15T12:00:00+01:00
+topic: "WW-4421: Duplicate @Action value annotation check skipped"
+tags: [research, codebase, convention-plugin, annotations, duplicate-detection]
+status: complete
+git_commit: fd874258631e999f5bd5ffc3fea8c0e416c61962
+---
+
+# Research: WW-4421 - Duplicate @Action value annotation check skipped
+
+**Date**: 2026-02-15
+**JIRA**: [WW-4421](https://issues.apache.org/jira/browse/WW-4421)
+
+## Research Question
+Investigate the core issue behind duplicate @Action annotation detection being 
bypassed in the Convention plugin.
+
+## Summary
+
+The duplicate @Action name detection logic in 
`PackageBasedActionConfigBuilder.buildConfiguration` is **guarded by a 
conditional that excludes the most common case**: when the `execute()` method 
is annotated with `@Action`. The duplicate check only runs when 
`!map.containsKey(DEFAULT_METHOD)` — meaning if the class overrides `execute()` 
and decorates it with `@Action`, the entire duplicate detection block is 
skipped.
+
+Additionally, the code uses `Class.getMethods()` and `HashMap` — both with 
**non-deterministic ordering** — making the behavior inconsistent across JVM 
versions.
+
+## Detailed Findings
+
+### The Bug: Conditional Guard Excludes Common Case
+
+**File**: 
[`PackageBasedActionConfigBuilder.java:744-747`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java#L744-L747)
+
+```java
+if (!map.containsKey(DEFAULT_METHOD)          // <-- THE BUG
+        && hasDefaultMethod
+        && actionAnnotation == null && actionsAnnotation == null
+        && (alwaysMapExecute || map.isEmpty())) {
+    // ... duplicate detection code is INSIDE this block ...
+    Set<String> actionNames = new HashSet<>();
+    for (List<Action> actions : map.values()) {
+        for (Action action : actions) {
+            String actionName = action.value().equals(Action.DEFAULT_VALUE) ? 
defaultActionName : action.value();
+            if (actionNames.contains(actionName)) {
+                throw new ConfigurationException("The action class [" + 
actionClass +
+                    "] contains two methods with an action name annotation 
whose value " +
+                    "is the same (they both might be empty as well).");
+            } else {
+                actionNames.add(actionName);
+            }
+        }
+    }
+}
+```
+
+The condition `!map.containsKey(DEFAULT_METHOD)` means:
+- **If `execute()` has an `@Action` annotation** → `map` contains key 
`"execute"` → condition is `false` → **duplicate check is SKIPPED entirely**
+- **If `execute()` does NOT have an `@Action` annotation** → condition can be 
`true` → duplicate check runs
+
+This is backwards from what you'd expect. The most common pattern — overriding 
`execute()` with `@Action` — is exactly the case that bypasses validation.
+
+### Scenario: When Duplicate Detection Fails
+
+```java
+// This class will NOT trigger the duplicate detection error:
+public class MethodCheckSkippedOnStartup extends ActionSupport {
+    @Action("myAction")        // <-- annotated execute() puts "execute" in 
the map
+    public String execute() {
+        return SUCCESS;
+    }
+
+    @Action("myAction")        // <-- duplicate name, but no error thrown!
+    public String anotherMethod() {
+        return SUCCESS;
+    }
+}
+```
+
+```java
+// This class WILL trigger the duplicate detection error:
+public class MethodCheckFailsOnStartup extends ActionSupport {
+    // execute() is NOT annotated — not in the map
+    public String execute() {
+        return SUCCESS;
+    }
+
+    @Action("myAction")
+    public String method1() {
+        return SUCCESS;
+    }
+
+    @Action("myAction")        // <-- duplicate detected, throws 
ConfigurationException
+    public String method2() {
+        return SUCCESS;
+    }
+}
+```
+
+### Secondary Issue: Non-Deterministic Method Ordering
+
+**File**: 
[`PackageBasedActionConfigBuilder.java:943-944`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java#L943-L944)
+
+```java
+Method[] methods = actionClass.getMethods();        // no guaranteed order
+Map<String, List<Action>> map = new HashMap<>();    // no guaranteed iteration 
order
+```
+
+- `Class.getMethods()` returns methods in **no particular order** (JVM spec 
does not guarantee ordering)
+- Results are stored in a `HashMap`, which also has **no guaranteed iteration 
order**
+- This means the second `@Action` annotation that "wins" (overwrites the 
mapping) is non-deterministic
+- Java 7 and Java 8 had different internal `HashMap` implementations, causing 
different behavior across versions
+
+### The Duplicate Check Should Be Independent
+
+The duplicate detection logic (lines 748-760) is embedded inside a block that 
also handles default `execute()` method mapping. These are **two separate 
concerns**:
+
+1. **Should we auto-create an action config for `execute()`?** — This depends 
on `!map.containsKey(DEFAULT_METHOD)`
+2. **Are there duplicate action names across annotated methods?** — This 
should ALWAYS be checked
+
+The fix should extract the duplicate detection loop so it runs unconditionally 
(or at least independently of whether `execute()` is in the annotation map).
+
+## Code References
+
+- 
[`PackageBasedActionConfigBuilder.java:87`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java#L87)
 — `DEFAULT_METHOD = "execute"`
+- 
[`PackageBasedActionConfigBuilder.java:741-773`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java#L741-L773)
 — The buggy block with embedded duplicate check
+- 
[`PackageBasedActionConfigBuilder.java:776-788`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java#L776-L788)
 — Action config creation loop (no duplicate check here)
+- 
[`PackageBasedActionConfigBuilder.java:942-959`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java#L942-L959)
 — `getActionAnnotations()` with non-deterministic ordering
+- 
[`ReflectionTools.java:38-45`](https://github.com/apache/struts/blob/fd874258631e999f5bd5ffc3fea8c0e416c61962/plugins/convention/src/main/java/org/apache/struts2/convention/ReflectionTools.java#L38-L45)
 — `containsMethod()` helper
+
+## Architecture Insights
+
+1. **Separation of concerns violation**: Duplicate detection is tangled with 
default-execute-mapping logic inside the same conditional block
+2. **Non-deterministic reflection**: Using `getMethods()` + `HashMap` means 
behavior varies across JVMs
+3. **Silent failure mode**: When the check is skipped, one action config 
silently overwrites another — no error, no warning, just undefined behavior at 
runtime
+
+## Suggested Fix Direction
+
+1. **Extract duplicate detection** out of the 
`!map.containsKey(DEFAULT_METHOD)` conditional so it runs for ALL annotated 
action classes
+2. **Also check class-level annotations** 
(`actionAnnotation`/`actionsAnnotation`) against method-level annotations for 
name conflicts
+3. **Consider using `LinkedHashMap`** or sorting methods for deterministic 
processing order
+
+## Open Questions
+
+1. Should duplicate detection also consider action names across different 
classes in the same namespace?
+2. Should a warning be issued instead of a `ConfigurationException` to allow 
intentional overrides?
+3. Are there backward-compatibility concerns if we start detecting duplicates 
that were previously silently ignored?

Reply via email to