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

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new 94a8fcb26 WW-3784 Order annotated wildcard actions most-specific-first 
(#1813)
94a8fcb26 is described below

commit 94a8fcb26cd9f9ba42a80aca8d38e63d54e404cd
Author: Lukasz Lenart <[email protected]>
AuthorDate: Wed Jul 29 07:56:58 2026 +0200

    WW-3784 Order annotated wildcard actions most-specific-first (#1813)
    
    * WW-3784 docs: design for specificity-ordered wildcard matching in 
annotated actions
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 docs: implementation plan for annotated wildcard specificity 
ordering
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 feat(convention): add action-name specificity comparator
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 fix(convention): add Apache License header to test file
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 feat(core): add PackageConfig.Builder.reorderActionConfigs
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 docs: add javadoc for PackageConfig.Builder.reorderActionConfigs
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 feat(convention): order annotated wildcard actions 
most-specific-first
    
    Sorts each convention-built package's action configs by pattern specificity 
so a
    specific pattern (some/usefull/*) is matched before a general one (some/*),
    regardless of class-scan order. Also makes convention action ordering 
deterministic.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 docs: correct wildcard cross-segment claims and note comparator 
limitations
    
    The spec incorrectly stated that WildcardHelper's single `*` is greedy
    and crosses `/`, and that `some/*` shadows `some/usefull/*`. Verified
    against WildcardHelper.java and NamedVariablePatternMatcher.java: only
    `**` crosses `/`, so those two patterns are actually disjoint (different
    segment counts) and never compete for the same request. Correct the
    Problem narrative, ticket example, and matcher bullets to state this
    accurately, and document two known limitations of the specificity
    comparator (raw wildcard-token-count key can misrank `**` ahead of
    narrower multi-token patterns; parent-package actions bypass sorting).
    Also add a test asserting the natural-order alphabetical tiebreak key.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-3784 test(convention): prove specificity ordering fixes wildcard 
shadowing end-to-end
    
    Adds an end-to-end routing test driving the production reorder
    (PackageConfig.Builder.reorderActionConfigs + 
ActionNameSpecificityComparator)
    through the real ActionConfigMatcher/WildcardHelper. some/** and 
some/usefull/*
    genuinely overlap for some/usefull/sleeping (** crosses '/'), so the test 
asserts
    the general pattern shadows the specific one when registered first, and that
    specificity ordering makes the specific action reachable again.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../struts2/config/entities/PackageConfig.java     |  21 +
 .../entities/PackageConfigBuilderReorderTest.java  |  44 ++
 ...3784-annotated-wildcard-specificity-ordering.md | 484 +++++++++++++++++++++
 ...notated-wildcard-specificity-ordering-design.md | 197 +++++++++
 .../ActionNameSpecificityComparator.java           |  98 +++++
 .../PackageBasedActionConfigBuilder.java           |  19 +
 .../ActionNameSpecificityComparatorTest.java       |  77 ++++
 ...PackageBasedActionConfigBuilderReorderTest.java |  53 +++
 .../convention/WildcardSpecificityRoutingTest.java |  91 ++++
 9 files changed, 1084 insertions(+)

diff --git 
a/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java 
b/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java
index ccf2e756f..391ead116 100644
--- a/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java
+++ b/core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java
@@ -24,6 +24,7 @@ import org.apache.struts2.util.location.Location;
 import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -517,6 +518,26 @@ public class PackageConfig extends Located implements 
Comparable<PackageConfig>,
             return this;
         }
 
+        /**
+         * Re-inserts this package's action configs into a new 
insertion-ordered map,
+         * ordered by the supplied comparator over the action-name keys. Must 
be called
+         * before {@link #build()}.
+         *
+         * @param byActionName comparator over action names determining match 
precedence
+         * @return this builder
+         * @since 7.3.0 (WW-3784)
+         */
+        public Builder reorderActionConfigs(Comparator<String> byActionName) {
+            List<Map.Entry<String, ActionConfig>> entries = new 
ArrayList<>(target.actionConfigs.entrySet());
+            entries.sort(Map.Entry.comparingByKey(byActionName));
+            Map<String, ActionConfig> reordered = new LinkedHashMap<>();
+            for (Map.Entry<String, ActionConfig> entry : entries) {
+                reordered.put(entry.getKey(), entry.getValue());
+            }
+            target.actionConfigs = reordered;
+            return this;
+        }
+
         public Builder addParents(List<PackageConfig> parents) {
             for (PackageConfig config : parents) {
                 addParent(config);
diff --git 
a/core/src/test/java/org/apache/struts2/config/entities/PackageConfigBuilderReorderTest.java
 
b/core/src/test/java/org/apache/struts2/config/entities/PackageConfigBuilderReorderTest.java
new file mode 100644
index 000000000..71769df62
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/config/entities/PackageConfigBuilderReorderTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.config.entities;
+
+import junit.framework.TestCase;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+public class PackageConfigBuilderReorderTest extends TestCase {
+
+    public void testReorderActionConfigsAppliesComparator() {
+        PackageConfig.Builder builder = new PackageConfig.Builder("test");
+        builder.addActionConfig("some/*", action("some/*"));
+        builder.addActionConfig("some/usefull/*", action("some/usefull/*"));
+
+        // reverse-alphabetical proves the map is genuinely reordered, not 
left as-inserted
+        builder.reorderActionConfigs(Comparator.reverseOrder());
+
+        List<String> keys = new 
ArrayList<>(builder.build().getActionConfigs().keySet());
+        assertEquals(List.of("some/usefull/*", "some/*"), keys);
+    }
+
+    private ActionConfig action(String name) {
+        return new ActionConfig.Builder("test", name, 
"com.example.Action").build();
+    }
+}
diff --git 
a/docs/superpowers/plans/2026-07-26-WW-3784-annotated-wildcard-specificity-ordering.md
 
b/docs/superpowers/plans/2026-07-26-WW-3784-annotated-wildcard-specificity-ordering.md
new file mode 100644
index 000000000..2375161e5
--- /dev/null
+++ 
b/docs/superpowers/plans/2026-07-26-WW-3784-annotated-wildcard-specificity-ordering.md
@@ -0,0 +1,484 @@
+# WW-3784 Specificity-ordered wildcard matching for annotated actions — 
Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make annotation-based (Convention plugin) wildcard action patterns 
match most-specific-first, so a specific pattern like `some/usefull/*` is never 
shadowed by a general one like `some/*`, regardless of class-scan order.
+
+**Architecture:** The fix lives entirely in the Convention plugin plus one 
neutral core helper. A new `Comparator<String>` ranks action-name patterns by 
specificity; a new `PackageConfig.Builder.reorderActionConfigs(Comparator)` 
re-sorts a package's `LinkedHashMap` of action configs; and 
`PackageBasedActionConfigBuilder` applies the sort to every package after 
building it. Core matchers and XML configuration providers are untouched, so 
XML's explicit file-order semantics are preserved.
+
+**Tech Stack:** Java 17, Maven multi-module (`core`, `plugins/convention`), 
JUnit 4 (convention tests) and JUnit 3 / `junit.framework.TestCase` (core 
tests), AssertJ/Mockito available but not required here.
+
+## Global Constraints
+
+- **Commit prefix:** every commit message MUST start with `WW-3784` followed 
by a Conventional-Commits type (`feat`/`test`/`refactor`/`docs`). End each 
commit body with `Co-Authored-By: Claude Opus 4.8 <[email protected]>`.
+- **Branch:** work on `WW-3784-annotated-wildcard-specificity-ordering` 
(already created and checked out). Never commit to `main`.
+- **Fix version target:** 7.3.0. Use `@since 7.3.0 (WW-3784)` on new 
public/protected API.
+- **No XML/core-matcher behavior changes:** do not modify `AbstractMatcher`, 
`ActionConfigMatcher`, or any `config/providers/*Xml*` class.
+- **Core cannot depend on Convention:** the specificity `Comparator` lives in 
the convention plugin; the core `reorderActionConfigs` helper must accept a 
generic `Comparator<String>` and must not reference the comparator class.
+- **Core test trap:** core tests that `extends XWorkTestCase` silently ignore 
JUnit 4 `@Test`. New core tests here use `extends junit.framework.TestCase` 
with `testXxx()` methods.
+- **Build/test commands:** `mvn test -DskipAssembly -pl core -Dtest=...` and 
`mvn test -DskipAssembly -pl plugins/convention -Dtest=...` (add `-am` on the 
first plugin run so the freshly-built core is available).
+
+---
+
+### Task 1: `ActionNameSpecificityComparator` (Convention plugin)
+
+A pure `Comparator<String>` that orders wildcard action-name patterns 
most-specific-first.
+
+**Files:**
+- Create: 
`plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java`
+- Test: 
`plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `public class ActionNameSpecificityComparator implements 
java.util.Comparator<String>` with `public int compare(String a, String b)`. 
Ordering keys, most-specific first: (1) fewer wildcard tokens; (2) more literal 
characters; (3) fewer path-spanning `**` tokens; (4) natural `String` order 
(deterministic tiebreak). A wildcard token is one `*`/`**`/`***…` run **or** 
one `{var}` group.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package org.apache.struts2.convention;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class ActionNameSpecificityComparatorTest {
+
+    private final ActionNameSpecificityComparator comparator = new 
ActionNameSpecificityComparator();
+
+    @Test
+    public void moreLiteralPrefixIsMoreSpecific_ticketCase() {
+        // equal wildcard count (1 each); "some/usefull/*" has more literal 
chars -> more specific
+        assertTrue(comparator.compare("some/usefull/*", "some/*") < 0);
+    }
+
+    @Test
+    public void fewerWildcardsIsMoreSpecific() {
+        assertTrue(comparator.compare("a/*", "a/*/*") < 0);
+    }
+
+    @Test
+    public void singleStarBeatsPathStarAtEqualLiterals() {
+        // both "a/" literal (2 chars), one wildcard each; "a/*" (file) beats 
"a/**" (path)
+        assertTrue(comparator.compare("a/*", "a/**") < 0);
+    }
+
+    @Test
+    public void namedVariablesCountAsWildcards() {
+        assertTrue(comparator.compare("some/usefull/{id}", "some/{id}") < 0);
+    }
+
+    @Test
+    public void literalRanksBeforeAnyWildcard() {
+        assertTrue(comparator.compare("some/list", "some/*") < 0);
+    }
+
+    @Test
+    public void sortIsDeterministicRegardlessOfInputOrder() {
+        List<String> expected = Arrays.asList("some/usefull/*", "some/*", "*");
+        List<String> shuffled = new ArrayList<>(expected);
+        Collections.shuffle(shuffled, new Random(42));
+        shuffled.sort(comparator);
+        assertEquals(expected, shuffled);
+    }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl plugins/convention -am 
-Dtest=ActionNameSpecificityComparatorTest`
+Expected: FAIL to compile — `ActionNameSpecificityComparator` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+```java
+/*
+ * 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;
+
+import java.util.Comparator;
+
+/**
+ * Orders wildcard action-name patterns most-specific-first so that, under the 
framework's
+ * first-match-wins matching, a specific pattern (e.g. {@code some/usefull/*}) 
is evaluated
+ * before a general one (e.g. {@code some/*}).
+ *
+ * <p>Ordering keys, applied in order:</p>
+ * <ol>
+ *   <li>fewer wildcard tokens first (a {@code *}/{@code **} run, or a 
<code>{var}</code> group);</li>
+ *   <li>more literal characters first;</li>
+ *   <li>fewer path-spanning {@code **} tokens first;</li>
+ *   <li>natural (alphabetical) order of the pattern, for deterministic 
tie-breaking.</li>
+ * </ol>
+ *
+ * <p>Matcher-agnostic: it recognises both {@code *}/{@code **} 
(WildcardHelper) and
+ * <code>{var}</code> (NamedVariablePatternMatcher) wildcards.</p>
+ *
+ * @since 7.3.0 (WW-3784)
+ */
+public class ActionNameSpecificityComparator implements Comparator<String> {
+
+    @Override
+    public int compare(String a, String b) {
+        Counts ca = count(a);
+        Counts cb = count(b);
+
+        int byWildcards = Integer.compare(ca.wildcards, cb.wildcards);
+        if (byWildcards != 0) {
+            return byWildcards;
+        }
+        int byLiterals = Integer.compare(cb.literals, ca.literals); // more 
literals first
+        if (byLiterals != 0) {
+            return byLiterals;
+        }
+        int byPathWildcards = Integer.compare(ca.pathWildcards, 
cb.pathWildcards);
+        if (byPathWildcards != 0) {
+            return byPathWildcards;
+        }
+        return a.compareTo(b);
+    }
+
+    private Counts count(String pattern) {
+        int wildcards = 0;
+        int pathWildcards = 0;
+        int literals = 0;
+        int i = 0;
+        int len = pattern.length();
+        while (i < len) {
+            char c = pattern.charAt(i);
+            if (c == '*') {
+                int start = i;
+                while (i < len && pattern.charAt(i) == '*') {
+                    i++;
+                }
+                wildcards++;
+                if (i - start >= 2) {
+                    pathWildcards++;
+                }
+            } else if (c == '{') {
+                int close = pattern.indexOf('}', i);
+                if (close < 0) {
+                    literals += len - i; // malformed: treat the remainder as 
literal
+                    break;
+                }
+                wildcards++;
+                i = close + 1;
+            } else {
+                literals++;
+                i++;
+            }
+        }
+        return new Counts(wildcards, pathWildcards, literals);
+    }
+
+    private record Counts(int wildcards, int pathWildcards, int literals) {
+    }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl plugins/convention -am 
-Dtest=ActionNameSpecificityComparatorTest`
+Expected: PASS (6 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add 
plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
 \
+        
plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
+git commit -m "WW-3784 feat(convention): add action-name specificity comparator
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 2: `PackageConfig.Builder.reorderActionConfigs` (core helper)
+
+A neutral core helper that re-sorts a package's action-config map by a 
caller-supplied comparator over action names. Generic on purpose — core never 
references the convention comparator.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java` (add 
`import java.util.Comparator;` near the other `java.util` imports at lines 
25-33; add the `reorderActionConfigs` method inside the `Builder` class, next 
to `addActionConfig` at line 515)
+- Test: 
`core/src/test/java/org/apache/struts2/config/entities/PackageConfigBuilderReorderTest.java`
+
+**Interfaces:**
+- Consumes: nothing from other tasks.
+- Produces: `public PackageConfig.Builder 
reorderActionConfigs(java.util.Comparator<String> byActionName)` — re-inserts 
the builder's action configs into a fresh `LinkedHashMap` ordered by 
`byActionName` over the action-name keys; returns `this`. Must be called before 
`build()`.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+/*
+ * 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.config.entities;
+
+import junit.framework.TestCase;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+public class PackageConfigBuilderReorderTest extends TestCase {
+
+    public void testReorderActionConfigsAppliesComparator() {
+        PackageConfig.Builder builder = new PackageConfig.Builder("test");
+        builder.addActionConfig("some/*", action("some/*"));
+        builder.addActionConfig("some/usefull/*", action("some/usefull/*"));
+
+        // reverse-alphabetical proves the map is genuinely reordered, not 
left as-inserted
+        builder.reorderActionConfigs(Comparator.reverseOrder());
+
+        List<String> keys = new 
ArrayList<>(builder.build().getActionConfigs().keySet());
+        assertEquals(List.of("some/usefull/*", "some/*"), keys);
+    }
+
+    private ActionConfig action(String name) {
+        return new ActionConfig.Builder("test", name, 
"com.example.Action").build();
+    }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=PackageConfigBuilderReorderTest`
+Expected: FAIL to compile — `reorderActionConfigs` does not exist.
+
+- [ ] **Step 3: Add the import and the method**
+
+Add near the other `java.util` imports (lines 25-33):
+
+```java
+import java.util.Comparator;
+```
+
+Add inside the `Builder` class, immediately after `addActionConfig` (line 
515-518):
+
+```java
+public Builder reorderActionConfigs(Comparator<String> byActionName) {
+    List<Map.Entry<String, ActionConfig>> entries = new 
ArrayList<>(target.actionConfigs.entrySet());
+    entries.sort(Map.Entry.comparingByKey(byActionName));
+    Map<String, ActionConfig> reordered = new LinkedHashMap<>();
+    for (Map.Entry<String, ActionConfig> entry : entries) {
+        reordered.put(entry.getKey(), entry.getValue());
+    }
+    target.actionConfigs = reordered;
+    return this;
+}
+```
+
+(`ArrayList`, `List`, `Map`, and `LinkedHashMap` are already imported.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=PackageConfigBuilderReorderTest`
+Expected: PASS (1 test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java \
+        
core/src/test/java/org/apache/struts2/config/entities/PackageConfigBuilderReorderTest.java
+git commit -m "WW-3784 feat(core): add 
PackageConfig.Builder.reorderActionConfigs
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 3: Wire specificity ordering into `PackageBasedActionConfigBuilder`
+
+Apply the comparator to every package the Convention plugin builds, right 
after index actions are added and before packages are registered with the 
configuration.
+
+**Files:**
+- Modify: 
`plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java`
 (add `import java.util.Comparator;` near line 75; call the new method in 
`buildConfiguration` after `buildIndexActions(packageConfigs);` at line 796; 
add the `reorderActionConfigsBySpecificity` method)
+- Test: 
`plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderReorderTest.java`
+
+**Interfaces:**
+- Consumes: `ActionNameSpecificityComparator` (Task 1); 
`PackageConfig.Builder.reorderActionConfigs(Comparator<String>)` (Task 2).
+- Produces: `static void reorderActionConfigsBySpecificity(Map<String, 
PackageConfig.Builder> packageConfigs)` — package-private, `static` so it is 
unit-testable without constructing a builder; applies `new 
ActionNameSpecificityComparator()` to every builder in the map.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+/*
+ * 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;
+
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+public class PackageBasedActionConfigBuilderReorderTest {
+
+    @Test
+    public void reordersEveryPackageSpecificFirst() {
+        // input insertion order is general-before-specific (the bug scenario)
+        PackageConfig.Builder pkg = new PackageConfig.Builder("test");
+        pkg.addActionConfig("some/*", action("some/*"));
+        pkg.addActionConfig("some/usefull/*", action("some/usefull/*"));
+
+        Map<String, PackageConfig.Builder> packageConfigs = new HashMap<>();
+        packageConfigs.put("test", pkg);
+
+        
PackageBasedActionConfigBuilder.reorderActionConfigsBySpecificity(packageConfigs);
+
+        List<String> keys = new 
ArrayList<>(pkg.build().getActionConfigs().keySet());
+        assertEquals(List.of("some/usefull/*", "some/*"), keys);
+    }
+
+    private ActionConfig action(String name) {
+        return new ActionConfig.Builder("test", name, 
"com.example.Action").build();
+    }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl plugins/convention -am 
-Dtest=PackageBasedActionConfigBuilderReorderTest`
+Expected: FAIL to compile — `reorderActionConfigsBySpecificity` does not exist.
+
+- [ ] **Step 3: Add the import, the call site, and the method**
+
+Add near line 75 (with the other `java.util` imports):
+
+```java
+import java.util.Comparator;
+```
+
+In `buildConfiguration`, change the block at lines 796-802 so the reorder runs 
after index actions and before registration:
+
+```java
+        buildIndexActions(packageConfigs);
+
+        reorderActionConfigsBySpecificity(packageConfigs);
+
+        // Add the new actions to the configuration
+        Set<String> packageNames = packageConfigs.keySet();
+        for (String packageName : packageNames) {
+            configuration.addPackageConfig(packageName, 
packageConfigs.get(packageName).build());
+        }
+```
+
+Add the method (e.g. immediately after `buildConfiguration`, before 
`getAllowedMethods` at line 805):
+
+```java
+    /**
+     * Reorders each package's action configs most-specific-first so that 
annotated wildcard
+     * patterns follow specific-before-general precedence under 
first-match-wins matching.
+     * XML-defined packages are untouched: only packages built by this 
convention builder pass
+     * through here.
+     *
+     * @param packageConfigs the packages built during {@link 
#buildConfiguration(Set)}
+     * @since 7.3.0 (WW-3784)
+     */
+    static void reorderActionConfigsBySpecificity(Map<String, 
PackageConfig.Builder> packageConfigs) {
+        Comparator<String> bySpecificity = new 
ActionNameSpecificityComparator();
+        for (PackageConfig.Builder packageConfig : packageConfigs.values()) {
+            packageConfig.reorderActionConfigs(bySpecificity);
+        }
+    }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl plugins/convention -am 
-Dtest=PackageBasedActionConfigBuilderReorderTest`
+Expected: PASS (1 test).
+
+- [ ] **Step 5: Run the full convention + core suites for regressions**
+
+Run: `mvn test -DskipAssembly -pl core,plugins/convention -am`
+Expected: PASS. Pay attention to `PackageBasedActionConfigBuilderTest` — if 
any assertion depended on the old (arbitrary) action-config ordering, update 
that expectation to the new specificity order and note it in the commit body.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add 
plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java
 \
+        
plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderReorderTest.java
+git commit -m "WW-3784 feat(convention): order annotated wildcard actions 
most-specific-first
+
+Sorts each convention-built package's action configs by pattern specificity so 
a
+specific pattern (some/usefull/*) is matched before a general one (some/*),
+regardless of class-scan order. Also makes convention action ordering 
deterministic.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+## Notes for the implementer
+
+- **Known limitation (documented in the spec):** ordering is per-package. 
Convention places all actions of one namespace into the same package, so the 
common case is covered; competing wildcards spread across *different* 
convention packages sharing a namespace remain in package-registration order. 
Do not try to expand scope to namespace-wide ordering — that would require 
touching core `DefaultConfiguration` and risk affecting XML.
+- **Do not add a config flag.** The behavior is always on for Convention by 
design.
+- **If `PackageBasedActionConfigBuilderTest` fails only on ordering:** that is 
expected fallout of making order deterministic; re-baseline the affected 
assertions to specificity order. Any *non-ordering* failure is a real 
regression — stop and investigate.
+
+## Self-Review
+
+- **Spec coverage:**
+  - Scalar multi-key comparator (spec §"The specificity comparator") → Task 1.
+  - `PackageConfig.Builder` reorder helper (spec §"Architecture & placement") 
→ Task 2.
+  - Convention integration point after `buildIndexActions` (spec §"Integration 
point") → Task 3.
+  - Testing — comparator unit tests incl. shuffle-invariance (spec §Testing) → 
Task 1; wiring/order test → Task 3; core helper test → Task 2.
+  - XML/core-matcher untouched (spec §Scope, §Out of scope) → enforced by 
Global Constraints; no such files modified.
+- **Placeholder scan:** none — all steps contain concrete code and exact 
commands.
+- **Type consistency:** `reorderActionConfigs(Comparator<String>)` is defined 
in Task 2 and consumed with the identical signature in Task 3; 
`ActionNameSpecificityComparator` is defined in Task 1 and instantiated in Task 
3; `reorderActionConfigsBySpecificity(Map<String, PackageConfig.Builder>)` is 
defined and tested with the same signature in Task 3.
diff --git 
a/docs/superpowers/specs/2026-07-26-WW-3784-annotated-wildcard-specificity-ordering-design.md
 
b/docs/superpowers/specs/2026-07-26-WW-3784-annotated-wildcard-specificity-ordering-design.md
new file mode 100644
index 000000000..efffbeb85
--- /dev/null
+++ 
b/docs/superpowers/specs/2026-07-26-WW-3784-annotated-wildcard-specificity-ordering-design.md
@@ -0,0 +1,197 @@
+# WW-3784 — Specificity-ordered wildcard matching for annotated actions
+
+- **Jira:** [WW-3784](https://issues.apache.org/jira/browse/WW-3784) — *Greedy 
and non-greedy matching behaviour should work in action methods using annotated 
wildcards*
+- **Type:** Bug (Core Actions)
+- **Fix version:** 7.3.0
+- **Date:** 2026-07-26
+
+## Problem
+
+Struts matches wildcard action patterns on a **first-match-wins, 
insertion-order** basis.
+`AbstractMatcher.match()` iterates its `compiledPatterns` list and `break`s on 
the first hit:
+
+```java
+for (Mapping<E> m : compiledPatterns) {
+    if (wildcard.match(vars, potentialMatch, m.pattern())) {
+        config = convert(potentialMatch, m.target(), vars);
+        break;
+    }
+}
+```
+
+In **XML** configuration, precedence is controlled by *physically ordering* 
mappings — specific
+patterns are placed before general ones, so first-match-wins does the right 
thing.
+
+In **annotation-based** configuration (Convention plugin `@Action` wildcards), 
there is **no ordering
+guarantee**. The registration order is derived from `Set<Class<?>>` class-scan 
order in
+`PackageBasedActionConfigBuilder.buildConfiguration(...)`, which is 
effectively arbitrary and
+non-deterministic across JVMs/classloaders. Consequently, when two annotated 
patterns genuinely
+overlap — most clearly when a broad `**` pattern (the only token that crosses 
`/`) is registered
+before a narrower, more specific pattern it can also match, or when two 
patterns share the same
+matchable shape — the general pattern can win first-match-wins and shadow the 
specific one, leaving
+the specific action unreachable.
+
+### Concrete example (from the ticket)
+
+| Pattern (annotated) | Intent |
+|---|---|
+| `some/usefull/*` | specific — should handle `/some/usefull/sleeping` |
+| `some/*` | general — should handle `/some/eating` |
+
+**Clarification (post-ticket):** with the default `WildcardHelper` matcher, 
single `*` (`MATCH_FILE`)
+matches only within one path segment and does **not** cross `/` — only `**` 
(`MATCH_PATH`) does.
+Under that rule, `some/*` matches exactly one segment after `some/` and 
`some/usefull/*` matches
+exactly one segment after `some/usefull/` — different segment counts, so the 
two patterns are
+**disjoint** and never compete for the same incoming URL; `some/*` alone 
cannot shadow
+`some/usefull/*`. The ticket's original example predates this clarification. 
The comparator still
+orders this pair deterministically (see the worked ranking below), which is a 
harmless improvement
+over non-deterministic scan order, but the *shadowing* failure mode described 
above requires
+genuinely overlapping patterns — which arises most clearly via `**` (e.g. a 
`**` catch-all registered
+ahead of a narrower sibling it can also match) or via patterns that share the 
same matchable shape.
+
+### Matchers in play
+
+- `WildcardHelper` (default bean `struts`): `*` (`MATCH_FILE`) matches within 
a single path segment
+  and does **not** cross `/`; `**` (`MATCH_PATH`) is the only token that 
crosses `/`.
+- `NamedVariablePatternMatcher` (bean `namedVariable`): `{var}` → `([^/]+)`, 
also does **not** cross
+  `/`.
+
+The defect is fundamentally about **match precedence / ordering**, not the 
regex semantics
+themselves.
+
+## Scope decision
+
+**Automatic specificity-ordering, annotation-sourced configs only.** XML keeps 
its explicit
+file-order semantics untouched. No configuration flag — always on for 
Convention. This is safe
+because the prior Convention order was non-deterministic, so no application 
could reliably depend on
+it.
+
+Rejected alternatives:
+
+- *Specificity-ordering for all configs incl. XML* — would change 
long-standing XML first-match-wins
+  behavior and risk breaking configs that rely on order.
+- *Opt-in flag* — pushes the burden onto users who would need to discover it; 
the bug should just be
+  fixed for annotations.
+
+## Architecture & placement
+
+The entire change lives in the **Convention plugin**. Core matchers
+(`AbstractMatcher` / `ActionConfigMatcher`) and the XML configuration 
providers are **not modified**,
+so XML behavior is fully preserved.
+
+We change *the order in which Convention registers wildcard action patterns 
into each
+`PackageConfig`*. That `LinkedHashMap` insertion order is exactly what flows 
through
+`DefaultConfiguration` into the per-namespace `ActionConfigMatcher` and drives 
the runtime
+first-match-wins loop.
+
+```
+@Action wildcards
+      │  (Set<Class> scan order — arbitrary)
+      ▼
+PackageBasedActionConfigBuilder.buildConfiguration()
+      │  ── NEW: reorder each PackageConfig.Builder's actions by specificity
+      ▼
+PackageConfig.actionConfigs (LinkedHashMap, now specific-first)
+      │
+      ▼
+DefaultConfiguration → ActionConfigMatcher (first-match-wins over 
specific-first list)
+```
+
+Two pieces:
+
+1. **`ActionNameSpecificityComparator`** — new pure `Comparator<String>` over 
action-name patterns,
+   in `org.apache.struts2.convention`.
+2. **A sort pass** in `PackageBasedActionConfigBuilder`, applied to each 
`PackageConfig.Builder`
+   after all actions are collected and before packages are handed to the 
configuration.
+
+One small, generic helper is added to core so the plugin can reorder a 
builder's map:
+`PackageConfig.Builder.reorderActionConfigs(Comparator<String> byActionName)`, 
which clears and
+re-inserts `actionConfigs` in sorted key order. It is a neutral utility that 
XML code never calls.
+
+## The specificity comparator
+
+`ActionNameSpecificityComparator implements Comparator<String>` orders 
action-name patterns
+**most-specific first** using these keys, in order:
+
+1. **Fewer wildcard tokens** first. A token is a `*` / `**` run 
(WildcardHelper) or a `{var}` group
+   (NamedVariable).
+2. **More literal characters** first — total pattern length minus the 
characters consumed by wildcard
+   tokens.
+3. **`*` before `**`** — fewer path-spanning (`**`) tokens is more specific.
+4. **Alphabetical** on the raw pattern string — deterministic tiebreak.
+
+Notes:
+
+- The comparator is **matcher-agnostic**: it counts both `*`/`**` runs and 
`{var}` groups as
+  wildcards, so it behaves correctly whether the application uses 
`WildcardHelper` or
+  `NamedVariablePatternMatcher`.
+- Literal (wildcard-free) names naturally sort first (0 wildcards, 
all-literal). This is harmless:
+  literal action names are resolved by exact-map lookup (`actions.get(name)`) 
**before** the wildcard
+  loop runs, so their relative order never affects matching.
+- **Secondary benefit:** ordering becomes **deterministic** across 
JVMs/classloaders, which it is not
+  today.
+
+### Worked ranking — ticket case
+
+| Pattern | wildcards | literal chars | `**` count | order |
+|---|---|---|---|---|
+| `some/usefull/*` | 1 | 13 | 0 | **1st (specific)** |
+| `some/*` | 1 | 5 | 0 | 2nd (general) |
+
+Tie on key 1 (both 1 wildcard) → key 2 decides: `some/usefull/*` has more 
literal characters, so it
+is tried first. Result: `/some/usefull/sleeping` → specific action; 
`/some/eating` → general action —
+regardless of scan order.
+
+## Integration point
+
+In `PackageBasedActionConfigBuilder.buildConfiguration(Set<Class<?>> 
classes)`, after the `classes`
+loop finishes populating the `packageConfigs` map, iterate each 
`PackageConfig.Builder` and reorder
+its action configs via `reorderActionConfigs(new 
ActionNameSpecificityComparator())`. Existing
+downstream steps (index actions, adding packages to the configuration) run 
unchanged on the reordered
+builders.
+
+## Edge cases & limitations
+
+- **Per-package scope.** Sorting is applied within each `PackageConfig`. 
Convention places all actions
+  of a given namespace into the same package builder, so the common "several 
action classes, one
+  namespace" case is fully covered. Competing wildcards spread across 
*different* convention packages
+  that share a namespace remain in package-registration order. This is 
documented as a known limit and
+  is out of scope; addressing it would require moving ordering into core 
`DefaultConfiguration`, which
+  would risk affecting XML.
+- **Genuine ties.** Patterns of identical specificity that truly overlap 
resolve alphabetically —
+  deterministic, if arbitrary. Documented behavior.
+- **No config flag.** Always on for Convention (per scope decision). The prior 
order was
+  non-deterministic, so nothing could reliably depend on it.
+- **Known limitation — primary key can misrank `**` vs. multi-token 
patterns.** The comparator's
+  primary key is *raw* wildcard-token count, not `**`-awareness. A 
single-token `**` catch-all (1
+  wildcard) therefore ranks ahead of a narrower two-token pattern such as 
`*/*` (2 wildcards) even
+  though `*/*` matches a strictly smaller set of paths, so a `**` catch-all 
can shadow a more-specific
+  sibling within the same package. This is an accepted known limitation — it 
is still a net
+  improvement over the prior non-deterministic order, since the outcome is at 
least stable across
+  JVMs/classloaders. A future refinement would lift the `**`-count key above 
the raw
+  wildcard-token-count key so path-spanning patterns are always penalized 
first; that is out of scope
+  for this fix.
+- **Known limitation — parent-package actions are not resorted.**
+  `PackageConfig.getAllActionConfigs()` inserts parent-package actions into 
the result map *before*
+  the (sorted) own-package actions, so a parent package's wildcard action 
would still be matched ahead
+  of the sorted actions of a child package regardless of specificity. In 
practice this has no
+  observable impact: convention parent packages (e.g. `struts-default`) 
declare no wildcard action
+  mappings, so there is nothing there to shadow child-package actions.
+
+## Testing
+
+- **Unit — `ActionNameSpecificityComparator`:**
+  - Ticket case: `some/usefull/*` ranks before `some/*`.
+  - `*` vs `**`: single-star ranks before double-star at equal literal length.
+  - `{var}` patterns ranked consistently with `*` patterns.
+  - Literals rank before any wildcard pattern.
+  - **Shuffle-invariance:** a randomized input list produces an identical 
sorted output.
+- **Integration — Convention plugin:** register competing annotated wildcards 
on action classes and
+  assert that `/some/usefull/sleeping` resolves to the specific action and 
`/some/eating` to the
+  general action, independent of class-registration order.
+
+## Out of scope
+
+- Changes to XML wildcard precedence or core matcher semantics.
+- Namespace-wide (cross-package) ordering.
+- Any change to greedy vs. non-greedy regex behavior of `WildcardHelper` / 
`NamedVariablePatternMatcher`.
diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
new file mode 100644
index 000000000..a0455dced
--- /dev/null
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
@@ -0,0 +1,98 @@
+/*
+ * 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;
+
+import java.util.Comparator;
+
+/**
+ * Orders wildcard action-name patterns most-specific-first so that, under the 
framework's
+ * first-match-wins matching, a specific pattern (e.g. {@code some/usefull/*}) 
is evaluated
+ * before a general one (e.g. {@code some/*}).
+ *
+ * <p>Ordering keys, applied in order:</p>
+ * <ol>
+ *   <li>fewer wildcard tokens first (a {@code *}/{@code **} run, or a 
<code>{var}</code> group);</li>
+ *   <li>more literal characters first;</li>
+ *   <li>fewer path-spanning {@code **} tokens first;</li>
+ *   <li>natural (alphabetical) order of the pattern, for deterministic 
tie-breaking.</li>
+ * </ol>
+ *
+ * <p>Matcher-agnostic: it recognises both {@code *}/{@code **} 
(WildcardHelper) and
+ * <code>{var}</code> (NamedVariablePatternMatcher) wildcards.</p>
+ *
+ * @since 7.3.0 (WW-3784)
+ */
+public class ActionNameSpecificityComparator implements Comparator<String> {
+
+    @Override
+    public int compare(String a, String b) {
+        Counts ca = count(a);
+        Counts cb = count(b);
+
+        int byWildcards = Integer.compare(ca.wildcards, cb.wildcards);
+        if (byWildcards != 0) {
+            return byWildcards;
+        }
+        int byLiterals = Integer.compare(cb.literals, ca.literals); // more 
literals first
+        if (byLiterals != 0) {
+            return byLiterals;
+        }
+        int byPathWildcards = Integer.compare(ca.pathWildcards, 
cb.pathWildcards);
+        if (byPathWildcards != 0) {
+            return byPathWildcards;
+        }
+        return a.compareTo(b);
+    }
+
+    private Counts count(String pattern) {
+        int wildcards = 0;
+        int pathWildcards = 0;
+        int literals = 0;
+        int i = 0;
+        int len = pattern.length();
+        while (i < len) {
+            char c = pattern.charAt(i);
+            if (c == '*') {
+                int start = i;
+                while (i < len && pattern.charAt(i) == '*') {
+                    i++;
+                }
+                wildcards++;
+                if (i - start >= 2) {
+                    pathWildcards++;
+                }
+            } else if (c == '{') {
+                int close = pattern.indexOf('}', i);
+                if (close < 0) {
+                    literals += len - i; // malformed: treat the remainder as 
literal
+                    break;
+                }
+                wildcards++;
+                i = close + 1;
+            } else {
+                literals++;
+                i++;
+            }
+        }
+        return new Counts(wildcards, pathWildcards, literals);
+    }
+
+    private record Counts(int wildcards, int pathWildcards, int literals) {
+    }
+}
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 711b25b72..9054fdae9 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
@@ -69,6 +69,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -795,6 +796,8 @@ public class PackageBasedActionConfigBuilder implements 
ActionConfigBuilder {
 
         buildIndexActions(packageConfigs);
 
+        reorderActionConfigsBySpecificity(packageConfigs);
+
         // Add the new actions to the configuration
         Set<String> packageNames = packageConfigs.keySet();
         for (String packageName : packageNames) {
@@ -802,6 +805,22 @@ public class PackageBasedActionConfigBuilder implements 
ActionConfigBuilder {
         }
     }
 
+    /**
+     * Reorders each package's action configs most-specific-first so that 
annotated wildcard
+     * patterns follow specific-before-general precedence under 
first-match-wins matching.
+     * XML-defined packages are untouched: only packages built by this 
convention builder pass
+     * through here.
+     *
+     * @param packageConfigs the packages built during {@link 
#buildConfiguration(Set)}
+     * @since 7.3.0 (WW-3784)
+     */
+    static void reorderActionConfigsBySpecificity(Map<String, 
PackageConfig.Builder> packageConfigs) {
+        Comparator<String> bySpecificity = new 
ActionNameSpecificityComparator();
+        for (PackageConfig.Builder packageConfig : packageConfigs.values()) {
+            packageConfig.reorderActionConfigs(bySpecificity);
+        }
+    }
+
     private Set<String> getAllowedMethods(Class<?> actionClass) {
         List<AllowedMethods> annotations = 
AnnotationUtils.findAnnotations(actionClass, AllowedMethods.class);
         if (annotations.isEmpty()) {
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
new file mode 100644
index 000000000..f97a1d1d4
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class ActionNameSpecificityComparatorTest {
+
+    private final ActionNameSpecificityComparator comparator = new 
ActionNameSpecificityComparator();
+
+    @Test
+    public void moreLiteralPrefixIsMoreSpecific_ticketCase() {
+        // equal wildcard count (1 each); "some/usefull/*" has more literal 
chars -> more specific
+        assertTrue(comparator.compare("some/usefull/*", "some/*") < 0);
+    }
+
+    @Test
+    public void fewerWildcardsIsMoreSpecific() {
+        assertTrue(comparator.compare("a/*", "a/*/*") < 0);
+    }
+
+    @Test
+    public void singleStarBeatsPathStarAtEqualLiterals() {
+        // both "a/" literal (2 chars), one wildcard each; "a/*" (file) beats 
"a/**" (path)
+        assertTrue(comparator.compare("a/*", "a/**") < 0);
+    }
+
+    @Test
+    public void namedVariablesCountAsWildcards() {
+        assertTrue(comparator.compare("some/usefull/{id}", "some/{id}") < 0);
+    }
+
+    @Test
+    public void literalRanksBeforeAnyWildcard() {
+        assertTrue(comparator.compare("some/list", "some/*") < 0);
+    }
+
+    @Test
+    public void sortIsDeterministicRegardlessOfInputOrder() {
+        List<String> expected = Arrays.asList("some/usefull/*", "some/*", "*");
+        List<String> shuffled = new ArrayList<>(expected);
+        Collections.shuffle(shuffled, new Random(42));
+        shuffled.sort(comparator);
+        assertEquals(expected, shuffled);
+    }
+
+    @Test
+    public void naturalOrderBreaksTiesForEquallySpecificPatterns() {
+        // equal on wildcard count, literal chars, and ** count -> 
alphabetical tiebreak
+        assertTrue(comparator.compare("a/*", "b/*") < 0);
+    }
+}
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderReorderTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderReorderTest.java
new file mode 100644
index 000000000..110e2614a
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderReorderTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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;
+
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+public class PackageBasedActionConfigBuilderReorderTest {
+
+    @Test
+    public void reordersEveryPackageSpecificFirst() {
+        // input insertion order is general-before-specific (the bug scenario)
+        PackageConfig.Builder pkg = new PackageConfig.Builder("test");
+        pkg.addActionConfig("some/*", action("some/*"));
+        pkg.addActionConfig("some/usefull/*", action("some/usefull/*"));
+
+        Map<String, PackageConfig.Builder> packageConfigs = new HashMap<>();
+        packageConfigs.put("test", pkg);
+
+        
PackageBasedActionConfigBuilder.reorderActionConfigsBySpecificity(packageConfigs);
+
+        List<String> keys = new 
ArrayList<>(pkg.build().getActionConfigs().keySet());
+        assertEquals(List.of("some/usefull/*", "some/*"), keys);
+    }
+
+    private ActionConfig action(String name) {
+        return new ActionConfig.Builder("test", name, 
"com.example.Action").build();
+    }
+}
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/WildcardSpecificityRoutingTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/WildcardSpecificityRoutingTest.java
new file mode 100644
index 000000000..c61105f8e
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/WildcardSpecificityRoutingTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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;
+
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.apache.struts2.config.impl.ActionConfigMatcher;
+import org.apache.struts2.util.WildcardHelper;
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * End-to-end proof that specificity ordering fixes the WW-3784 shadowing bug: 
it drives the
+ * production reorder ({@link PackageConfig.Builder#reorderActionConfigs} with
+ * {@link ActionNameSpecificityComparator}) through the real runtime matcher
+ * ({@link ActionConfigMatcher} over {@link WildcardHelper}) and asserts that 
a request which two
+ * genuinely-overlapping wildcard patterns can both match is routed to the 
more specific action.
+ *
+ * <p>{@code some/**} and {@code some/usefull/*} genuinely overlap for {@code 
some/usefull/sleeping}
+ * because {@code **} ({@code MATCH_PATH}) crosses '/', so first-match-wins 
order decides the winner.
+ * With the general {@code some/**} registered first, it shadows {@code 
some/usefull/*}; after
+ * specificity ordering, the specific pattern wins.</p>
+ */
+public class WildcardSpecificityRoutingTest {
+
+    private static final String GENERAL_PATTERN = "some/**";
+    private static final String SPECIFIC_PATTERN = "some/usefull/*";
+    private static final String GENERAL_CLASS = "com.example.GeneralAction";
+    private static final String SPECIFIC_CLASS = "com.example.SpecificAction";
+    private static final String OVERLAPPING_REQUEST = "some/usefull/sleeping";
+    private static final String GENERAL_ONLY_REQUEST = "some/eating";
+
+    @Test
+    public void generalPatternShadowsSpecificWhenRegisteredFirst() {
+        // Reproduces the bug: general-before-specific insertion order, no 
reordering applied.
+        Map<String, ActionConfig> configs = 
generalFirstBuilder().build().getActionConfigs();
+        ActionConfigMatcher matcher = new ActionConfigMatcher(new 
WildcardHelper(), configs, false);
+
+        ActionConfig matched = matcher.match(OVERLAPPING_REQUEST);
+
+        assertEquals("general some/** shadows the specific action when 
registered first",
+                GENERAL_CLASS, matched.getClassName());
+    }
+
+    @Test
+    public void specificPatternWinsAfterSpecificityOrdering() {
+        // Same actions, same insertion order, but reordered 
most-specific-first before matching.
+        PackageConfig.Builder builder = generalFirstBuilder();
+        builder.reorderActionConfigs(new ActionNameSpecificityComparator());
+        Map<String, ActionConfig> configs = builder.build().getActionConfigs();
+        ActionConfigMatcher matcher = new ActionConfigMatcher(new 
WildcardHelper(), configs, false);
+
+        assertEquals("specific some/usefull/* is now reachable for the 
overlapping request",
+                SPECIFIC_CLASS, 
matcher.match(OVERLAPPING_REQUEST).getClassName());
+        assertEquals("general some/** still handles requests only it can 
match",
+                GENERAL_CLASS, 
matcher.match(GENERAL_ONLY_REQUEST).getClassName());
+    }
+
+    private PackageConfig.Builder generalFirstBuilder() {
+        PackageConfig.Builder builder = new PackageConfig.Builder("test");
+        builder.addActionConfig(GENERAL_PATTERN, action(GENERAL_PATTERN, 
GENERAL_CLASS));
+        builder.addActionConfig(SPECIFIC_PATTERN, action(SPECIFIC_PATTERN, 
SPECIFIC_CLASS));
+        return builder;
+    }
+
+    private ActionConfig action(String name, String className) {
+        return new ActionConfig.Builder("test", name, className)
+                .methodName("execute")
+                .setStrictMethodInvocation(false)
+                .build();
+    }
+}

Reply via email to