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();
+ }
+}