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 6d778ac9b fix(convention): WW-5594 exclude root package classes with
wildcard patterns (#1468)
6d778ac9b is described below
commit 6d778ac9b7729b2cbcd15eecb90272f210a11f57
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Dec 14 19:41:59 2025 +0100
fix(convention): WW-5594 exclude root package classes with wildcard
patterns (#1468)
The exclusion pattern "org.apache.struts2.*" was not properly excluding
classes directly in the root package (like XWorkTestCase) because:
1. PackageBasedActionConfigBuilder extracts package names using
substringBeforeLast(className, ".") which produces "org.apache.struts2"
(no trailing dot)
2. The wildcard pattern requires a literal "." before "*"
3. Result: Pattern doesn't match root package classes
Fix: Enhanced checkExcludePackages() to automatically handle patterns
ending with ".*" by also checking if the package name equals the base
pattern (without ".*").
Now "org.apache.struts2.*" properly excludes both:
- Classes in root package: org.apache.struts2.XWorkTestCase
- Classes in subpackages: org.apache.struts2.dispatcher.SomeClass
Closes [WW-5594](https://issues.apache.org/jira/browse/WW-5594)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <[email protected]>
---
.../apache/struts2/util/WildcardHelperTest.java | 53 +++
.../PackageBasedActionConfigBuilder.java | 31 +-
.../src/main/resources/struts-plugin.xml | 86 ++--
.../PackageBasedActionConfigBuilderTest.java | 140 +++++--
...025-12-02-WW-5594-wildcard-exclusion-pattern.md | 460 +++++++++++++++++++++
5 files changed, 693 insertions(+), 77 deletions(-)
diff --git a/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java
b/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java
index e856bfce8..6312b31a2 100644
--- a/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java
+++ b/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java
@@ -80,4 +80,57 @@ public class WildcardHelperTest extends XWorkTestCase {
assertEquals("", matchedPatterns.get("1"));
}
+ /**
+ * WW-5594: Tests that pattern "org.apache.struts2.*" does NOT match
"org.apache.struts2"
+ * (package name without trailing dot).
+ * <p>
+ * This is important because when checking exclusion patterns, the
convention plugin
+ * extracts package names from class names using
StringUtils.substringBeforeLast(className, "."),
+ * which produces package names without trailing dots.
+ * <p>
+ * For example:
+ * - Class "org.apache.struts2.XWorkTestCase" -> package
"org.apache.struts2" (no trailing dot)
+ * - Pattern "org.apache.struts2.*" requires a literal "." before the
wildcard
+ * - Result: Pattern doesn't match, class is not excluded
+ * <p>
+ * The fix is to include both "org.apache.struts2" and
"org.apache.struts2.*" in exclusion patterns.
+ */
+ public void testWW5594_WildcardPatternRequiresTrailingDot() {
+ // given
+ HashMap<String, String> matchedPatterns = new HashMap<>();
+ int[] wildcardPattern =
wildcardHelper.compilePattern("org.apache.struts2.*");
+
+ // when & then - Pattern with wildcard does NOT match package name
without trailing dot
+ // This is the root cause of WW-5594
+ assertFalse("Pattern 'org.apache.struts2.*' should NOT match
'org.apache.struts2' (no trailing dot)",
+ wildcardHelper.match(matchedPatterns, "org.apache.struts2",
wildcardPattern));
+
+ // But it DOES match with trailing dot
+ assertTrue("Pattern 'org.apache.struts2.*' should match
'org.apache.struts2.' (with trailing dot)",
+ wildcardHelper.match(matchedPatterns, "org.apache.struts2.",
wildcardPattern));
+
+ // And it DOES match full class names
+ assertTrue("Pattern 'org.apache.struts2.*' should match
'org.apache.struts2.SomeClass'",
+ wildcardHelper.match(matchedPatterns,
"org.apache.struts2.SomeClass", wildcardPattern));
+ }
+
+ /**
+ * WW-5594: Tests that exact package pattern matches package names
correctly.
+ * To properly exclude classes in a root package, use both the exact
package name
+ * and the wildcard pattern.
+ */
+ public void testWW5594_ExactPackagePatternMatchesPackageName() {
+ // given
+ HashMap<String, String> matchedPatterns = new HashMap<>();
+ int[] exactPattern =
wildcardHelper.compilePattern("org.apache.struts2");
+
+ // when & then - Exact pattern matches exactly
+ assertTrue("Exact pattern 'org.apache.struts2' should match
'org.apache.struts2'",
+ wildcardHelper.match(matchedPatterns, "org.apache.struts2",
exactPattern));
+
+ // But exact pattern does NOT match subpackages
+ assertFalse("Exact pattern 'org.apache.struts2' should NOT match
'org.apache.struts2.core'",
+ wildcardHelper.match(matchedPatterns,
"org.apache.struts2.core", exactPattern));
+ }
+
}
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 45eb3f8b8..ea9850568 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
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.convention;
+import org.apache.commons.lang3.Strings;
import org.apache.struts2.ActionContext;
import org.apache.struts2.FileManager;
import org.apache.struts2.FileManagerFactory;
@@ -406,7 +407,7 @@ public class PackageBasedActionConfigBuilder implements
ActionConfigBuilder {
if (ctx != null) {
classLoaderInterface = (ClassLoaderInterface)
ctx.get(ClassLoaderInterface.CLASS_LOADER_INTERFACE);
}
- return ObjectUtils.defaultIfNull(classLoaderInterface, new
ClassLoaderInterfaceDelegate(getClassLoader()));
+ return ObjectUtils.getIfNull(classLoaderInterface, new
ClassLoaderInterfaceDelegate(getClassLoader()));
}
}
@@ -561,7 +562,17 @@ public class PackageBasedActionConfigBuilder implements
ActionConfigBuilder {
}
/**
- * Checks if provided class package is on the exclude list
+ * Checks if provided class package is on the exclude list.
+ * <p>
+ * WW-5594: For patterns ending with ".*", this method also checks if the
package name
+ * equals the base pattern (without ".*"). This ensures that classes
directly in the
+ * root package are excluded, not just classes in subpackages.
+ * <p>
+ * For example, pattern "org.apache.struts2.*" will exclude both:
+ * <ul>
+ * <li>Classes in subpackages like
"org.apache.struts2.dispatcher.SomeClass"</li>
+ * <li>Classes directly in the root package like
"org.apache.struts2.XWorkTestCase"</li>
+ * </ul>
*
* @param classPackageName name of class package
* @return false if class package is on the {@link #excludePackages} list
@@ -574,6 +585,16 @@ public class PackageBasedActionConfigBuilder implements
ActionConfigBuilder {
Map<String, String> matchMap = new HashMap<>();
for (String packageExclude : excludePackages) {
+ // WW-5594: For patterns ending with ".*", also check if
package equals the base
+ // This handles root package exclusion (e.g., pattern
"org.apache.struts2.*"
+ // should also exclude classes in package "org.apache.struts2")
+ if (packageExclude.endsWith(".*")) {
+ String basePackage = packageExclude.substring(0,
packageExclude.length() - 2);
+ if (classPackageName.equals(basePackage)) {
+ return false;
+ }
+ }
+
int[] packagePattern =
wildcardHelper.compilePattern(packageExclude);
if (wildcardHelper.match(matchMap, classPackageName,
packagePattern)) {
return false;
@@ -647,7 +668,7 @@ public class PackageBasedActionConfigBuilder implements
ActionConfigBuilder {
* should be included in the package scan
*/
protected Test<ClassFinder.ClassInfo> getActionClassTest() {
- return new Test<ClassFinder.ClassInfo>() {
+ return new Test<>() {
public boolean test(ClassFinder.ClassInfo classInfo) {
// Why do we call includeClassNameInActionScan here, when it's
@@ -973,7 +994,7 @@ public class PackageBasedActionConfigBuilder implements
ActionConfigBuilder {
String className = actionClass.getName();
if (annotation != null) {
actionName = annotation.value().equals(Action.DEFAULT_VALUE) ?
actionName : annotation.value();
- actionName = StringUtils.contains(actionName, "/") &&
!slashesInActionNames ? StringUtils.substringAfterLast(actionName, "/") :
actionName;
+ actionName = Strings.CI.contains(actionName, "/") &&
!slashesInActionNames ? StringUtils.substringAfterLast(actionName, "/") :
actionName;
if (!Action.DEFAULT_VALUE.equals(annotation.className())) {
className = annotation.className();
}
@@ -1060,7 +1081,7 @@ public class PackageBasedActionConfigBuilder implements
ActionConfigBuilder {
if (action != null && !action.value().equals(Action.DEFAULT_VALUE)) {
LOG.trace("Using non-default action namespace from the Action
annotation of [{}]", action.value());
String actionName = action.value();
- actionNamespace = StringUtils.contains(actionName, "/") ?
StringUtils.substringBeforeLast(actionName, "/") : StringUtils.EMPTY;
+ actionNamespace = Strings.CI.contains(actionName, "/") ?
StringUtils.substringBeforeLast(actionName, "/") : StringUtils.EMPTY;
}
// Next grab the parent annotation from the class
diff --git a/plugins/convention/src/main/resources/struts-plugin.xml
b/plugins/convention/src/main/resources/struts-plugin.xml
index 541c79f0c..afafe0f66 100644
--- a/plugins/convention/src/main/resources/struts-plugin.xml
+++ b/plugins/convention/src/main/resources/struts-plugin.xml
@@ -21,53 +21,61 @@
-->
<!DOCTYPE struts PUBLIC
- "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
- "https://struts.apache.org/dtds/struts-6.0.dtd">
+ "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
+ "https://struts.apache.org/dtds/struts-6.0.dtd">
<struts order="20">
- <bean type="org.apache.struts2.UnknownHandler" name="convention"
class="org.apache.struts2.convention.ConventionUnknownHandler"/>
+ <bean type="org.apache.struts2.UnknownHandler" name="convention"
+ class="org.apache.struts2.convention.ConventionUnknownHandler"/>
- <bean type="org.apache.struts2.convention.ActionConfigBuilder"
name="convention"
class="org.apache.struts2.convention.PackageBasedActionConfigBuilder"/>
- <bean type="org.apache.struts2.convention.ActionNameBuilder"
name="convention" class="org.apache.struts2.convention.SEOActionNameBuilder"/>
- <bean type="org.apache.struts2.convention.ResultMapBuilder"
name="convention"
class="org.apache.struts2.convention.DefaultResultMapBuilder"/>
- <bean type="org.apache.struts2.convention.InterceptorMapBuilder"
name="convention"
class="org.apache.struts2.convention.DefaultInterceptorMapBuilder"/>
- <bean type="org.apache.struts2.convention.ConventionsService"
name="convention" class="org.apache.struts2.convention.ConventionsServiceImpl"/>
+ <bean type="org.apache.struts2.convention.ActionConfigBuilder"
name="convention"
+
class="org.apache.struts2.convention.PackageBasedActionConfigBuilder"/>
+ <bean type="org.apache.struts2.convention.ActionNameBuilder"
name="convention"
+ class="org.apache.struts2.convention.SEOActionNameBuilder"/>
+ <bean type="org.apache.struts2.convention.ResultMapBuilder"
name="convention"
+ class="org.apache.struts2.convention.DefaultResultMapBuilder"/>
+ <bean type="org.apache.struts2.convention.InterceptorMapBuilder"
name="convention"
+ class="org.apache.struts2.convention.DefaultInterceptorMapBuilder"/>
+ <bean type="org.apache.struts2.convention.ConventionsService"
name="convention"
+ class="org.apache.struts2.convention.ConventionsServiceImpl"/>
- <bean type="org.apache.struts2.config.PackageProvider"
name="convention.packageProvider"
class="org.apache.struts2.convention.ClasspathPackageProvider"/>
- <bean type="org.apache.struts2.config.PackageProvider"
name="convention.containerProvider"
class="org.apache.struts2.convention.ClasspathConfigurationProvider"/>
+ <bean type="org.apache.struts2.config.PackageProvider"
name="convention.packageProvider"
+ class="org.apache.struts2.convention.ClasspathPackageProvider"/>
+ <bean type="org.apache.struts2.config.PackageProvider"
name="convention.containerProvider"
+
class="org.apache.struts2.convention.ClasspathConfigurationProvider"/>
- <constant name="struts.convention.actionConfigBuilder" value="convention"/>
- <constant name="struts.convention.actionNameBuilder" value="convention"/>
- <constant name="struts.convention.resultMapBuilder" value="convention"/>
- <constant name="struts.convention.interceptorMapBuilder" value="convention"/>
- <constant name="struts.convention.conventionsService" value="convention"/>
+ <constant name="struts.convention.actionConfigBuilder" value="convention"/>
+ <constant name="struts.convention.actionNameBuilder" value="convention"/>
+ <constant name="struts.convention.resultMapBuilder" value="convention"/>
+ <constant name="struts.convention.interceptorMapBuilder"
value="convention"/>
+ <constant name="struts.convention.conventionsService" value="convention"/>
- <constant name="struts.convention.result.path" value="/WEB-INF/content/"/>
- <constant name="struts.convention.result.flatLayout" value="true"/>
- <constant name="struts.convention.action.suffix" value="Action"/>
- <constant name="struts.convention.action.disableScanning" value="false"/>
- <constant name="struts.convention.action.mapAllMatches" value="false"/>
- <constant name="struts.convention.action.checkImplementsAction"
value="true"/>
- <constant name="struts.convention.default.parent.package"
value="convention-default"/>
- <constant name="struts.convention.action.name.lowercase" value="true"/>
- <constant name="struts.convention.action.name.separator" value="-"/>
- <constant name="struts.convention.package.locators"
value="action,actions,struts,struts2"/>
- <constant name="struts.convention.package.locators.disable" value="false"/>
- <constant name="struts.convention.package.locators.basePackage" value=""/>
- <constant name="struts.convention.exclude.packages"
value="org.apache.struts.*,org.apache.struts2.*,org.springframework.web.struts.*,org.springframework.web.struts2.*,org.hibernate.*"/>
- <constant name="struts.convention.relative.result.types"
value="dispatcher,velocity,freemarker"/>
- <constant name="struts.convention.redirect.to.slash" value="true"/>
- <constant name="struts.convention.action.alwaysMapExecute" value="true"/>
- <constant name="struts.mapper.alwaysSelectFullNamespace" value="true"/>
- <!-- <constant name="struts.convention.action.includeJars" /> -->
- <constant name="struts.convention.action.fileProtocols" value="jar" />
+ <constant name="struts.convention.result.path" value="/WEB-INF/content/"/>
+ <constant name="struts.convention.result.flatLayout" value="true"/>
+ <constant name="struts.convention.action.suffix" value="Action"/>
+ <constant name="struts.convention.action.disableScanning" value="false"/>
+ <constant name="struts.convention.action.mapAllMatches" value="false"/>
+ <constant name="struts.convention.action.checkImplementsAction"
value="true"/>
+ <constant name="struts.convention.default.parent.package"
value="convention-default"/>
+ <constant name="struts.convention.action.name.lowercase" value="true"/>
+ <constant name="struts.convention.action.name.separator" value="-"/>
+ <constant name="struts.convention.package.locators"
value="action,actions,struts,struts2"/>
+ <constant name="struts.convention.package.locators.disable" value="false"/>
+ <constant name="struts.convention.package.locators.basePackage" value=""/>
+ <constant name="struts.convention.exclude.packages"
value="org.apache.struts.*,org.apache.struts2.*,org.springframework.web.struts.*,org.springframework.web.struts2.*,org.hibernate.*"/>
+ <constant name="struts.convention.relative.result.types"
value="dispatcher,velocity,freemarker"/>
+ <constant name="struts.convention.redirect.to.slash" value="true"/>
+ <constant name="struts.convention.action.alwaysMapExecute" value="true"/>
+ <constant name="struts.mapper.alwaysSelectFullNamespace" value="true"/>
+ <!-- <constant name="struts.convention.action.includeJars" /> -->
+ <constant name="struts.convention.action.fileProtocols" value="jar"/>
- <constant name="struts.convention.classes.reload" value="false" />
+ <constant name="struts.convention.classes.reload" value="false"/>
- <constant name="struts.convention.exclude.parentClassLoader" value="true" />
+ <constant name="struts.convention.exclude.parentClassLoader" value="true"/>
- <constant name="struts.convention.enable.smi.inheritance" value="false" />
+ <constant name="struts.convention.enable.smi.inheritance" value="false"/>
- <package name="convention-default" extends="struts-default">
- </package>
+ <package name="convention-default" extends="struts-default">
+ </package>
</struts>
diff --git
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
index dcb0b4352..bb85dcac5 100644
---
a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
+++
b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java
@@ -18,7 +18,6 @@
*/
package org.apache.struts2.convention;
-import org.apache.struts2.result.ActionChainResult;
import jakarta.servlet.ServletContext;
import junit.framework.TestCase;
import org.apache.commons.lang3.StringUtils;
@@ -94,6 +93,7 @@ import org.apache.struts2.inject.Container;
import org.apache.struts2.inject.Scope.Strategy;
import org.apache.struts2.ognl.OgnlReflectionProvider;
import org.apache.struts2.ognl.ProviderAllowlist;
+import org.apache.struts2.result.ActionChainResult;
import org.apache.struts2.result.Result;
import org.apache.struts2.result.ServletDispatcherResult;
import org.apache.struts2.util.TextParseUtil;
@@ -127,8 +127,8 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
public void setUp() throws Exception {
super.setUp();
ActionContext.of()
- .withContainer(new DummyContainer())
- .bind();
+ .withContainer(new DummyContainer())
+ .bind();
}
public void testActionPackages() throws MalformedURLException {
@@ -155,9 +155,83 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
run("org.apache.struts2.convention.actions", null, null, "false");
}
+ /**
+ * WW-5594: Tests that exclusion patterns properly exclude classes in root
packages.
+ * <p>
+ * The issue was that pattern "org.apache.struts2.*" did NOT match package
name
+ * "org.apache.struts2" (without trailing dot) because:
+ * 1. PackageBasedActionConfigBuilder extracts package name using
substringBeforeLast(className, ".")
+ * 2. For class "org.apache.struts2.XWorkTestCase", this produces
"org.apache.struts2" (no trailing dot)
+ * 3. The wildcard pattern requires a literal "." before the "*"
+ * <p>
+ * The fix is in checkExcludePackages() which now handles patterns ending
with ".*" by also
+ * checking if the package name equals the base pattern (without ".*").
+ */
+ public void testWW5594_RootPackageExclusion() {
+ // Setup minimal configuration
+ final DummyContainer mockContainer = new DummyContainer();
+ Configuration configuration = new DefaultConfiguration() {
+ @Override
+ public Container getContainer() {
+ return mockContainer;
+ }
+ };
+
+ ResultTypeConfig[] defaultResults = new ResultTypeConfig[]{
+ new ResultTypeConfig.Builder("dispatcher",
ServletDispatcherResult.class.getName())
+ .defaultResultParam("location").build()
+ };
+ PackageConfig strutsDefault = makePackageConfig("struts-default",
null, null, "dispatcher", defaultResults);
+ configuration.addPackageConfig("struts-default", strutsDefault);
+
+ ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true",
"-");
+ ObjectFactory of = new ObjectFactory();
+ of.setContainer(mockContainer);
+
+ mockContainer.setActionNameBuilder(actionNameBuilder);
+ mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+
+ PackageBasedActionConfigBuilder builder = new
PackageBasedActionConfigBuilder(
+ configuration, mockContainer, of, "false", "struts-default",
"false");
+
+ // Test 1: Wildcard pattern now properly excludes root package classes
(WW-5594 fix)
+ builder.setActionPackages("org.apache.struts2");
+ builder.setExcludePackages("org.apache.struts2.*");
+
+ // Class in root package - package name is "org.apache.struts2" (no
trailing dot)
+ // With the fix, pattern "org.apache.struts2.*" now also matches the
base package
+ boolean includeRootPackageClass =
builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase");
+ assertFalse("With wildcard pattern, root package class should be
excluded (WW-5594 fix)",
+ includeRootPackageClass);
+
+ // Test 2: Subpackage classes should also be excluded by wildcard
pattern
+ boolean includeSubpackageClass =
builder.includeClassNameInActionScan("org.apache.struts2.core.ActionSupport");
+ assertFalse("Subpackage classes should be excluded by wildcard
pattern",
+ includeSubpackageClass);
+
+ // Test 3: Exact pattern still works for specific package exclusion
+ builder.setExcludePackages("org.apache.struts2");
+ boolean includeWithExactPattern =
builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase");
+ assertFalse("Exact pattern should exclude root package class",
+ includeWithExactPattern);
+
+ // Test 4: Exact pattern should NOT exclude subpackage classes
+ boolean includeSubpackageWithExact =
builder.includeClassNameInActionScan("org.apache.struts2.core.ActionSupport");
+ assertTrue("Exact pattern should NOT exclude subpackage classes",
+ includeSubpackageWithExact);
+
+ // Test 5: Classes in unrelated packages should NOT be excluded
+ builder.setActionPackages("com.example.actions");
+ builder.setExcludePackages("org.apache.struts2.*");
+ boolean includeUnrelated =
builder.includeClassNameInActionScan("com.example.actions.MyAction");
+ assertTrue("Classes in unrelated packages should not be excluded",
+ includeUnrelated);
+ }
+
private void run(String actionPackages, String packageLocators, String
excludePackages) throws MalformedURLException {
run(actionPackages, packageLocators, excludePackages, "");
}
+
private void run(String actionPackages, String packageLocators, String
excludePackages, String enableSmiInheritance) throws MalformedURLException {
//setup interceptors
List<InterceptorConfig> defaultInterceptors = new ArrayList<>();
@@ -198,7 +272,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
PackageConfig classLevelParentPkg = makePackageConfig("class-level",
null, null, null);
PackageConfig rootPkg =
makePackageConfig("org.apache.struts2.convention.actions#struts-default#",
- "", strutsDefault, null);
+ "", strutsDefault, null);
PackageConfig paramsPkg =
makePackageConfig("org.apache.struts2.convention.actions.params#struts-default#/params",
"/params", strutsDefault, null);
PackageConfig defaultInterceptorPkg =
makePackageConfig("org.apache.struts2.convention.actions.defaultinterceptor#struts-default#/defaultinterceptor",
@@ -206,17 +280,17 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
PackageConfig exceptionPkg =
makePackageConfig("org.apache.struts2.convention.actions.exception#struts-default#/exception",
"/exception", strutsDefault, null);
PackageConfig actionPkg =
makePackageConfig("org.apache.struts2.convention.actions.action#struts-default#/action",
- "/action", strutsDefault, null);
+ "/action", strutsDefault, null);
PackageConfig idxPkg =
makePackageConfig("org.apache.struts2.convention.actions.idx#struts-default#/idx",
- "/idx", strutsDefault, null);
+ "/idx", strutsDefault, null);
PackageConfig idx2Pkg =
makePackageConfig("org.apache.struts2.convention.actions.idx.idx2#struts-default#/idx/idx2",
- "/idx/idx2", strutsDefault, null);
+ "/idx/idx2", strutsDefault, null);
PackageConfig interceptorRefsPkg =
makePackageConfig("org.apache.struts2.convention.actions.interceptor#struts-default#/interceptor",
"/interceptor", strutsDefault, null);
PackageConfig packageLevelPkg =
makePackageConfig("org.apache.struts2.convention.actions.parentpackage#package-level#/parentpackage",
- "/parentpackage", packageLevelParentPkg, null);
+ "/parentpackage", packageLevelParentPkg, null);
PackageConfig packageLevelSubPkg =
makePackageConfig("org.apache.struts2.convention.actions.parentpackage.sub#package-level#/parentpackage/sub",
- "/parentpackage/sub", packageLevelParentPkg, null);
+ "/parentpackage/sub", packageLevelParentPkg, null);
// Unexpected method call build(class
org.apache.struts2.convention.actions.allowedmethods.PackageLevelAllowedMethodsAction,
null, "package-level-allowed-methods", PackageConfig:
[org.apache.struts2.convention.actions.allowedmethods#struts-default#/allowedmethods]
for namespace [/allowedmethods] with parents [[PackageConfig: [struts-default]
for namespace [] with parents [[]]]]):
PackageConfig packageLevelAllowedMethodsPkg =
makePackageConfig("org.apache.struts2.convention.actions.allowedmethods#struts-default#/allowedmethods",
@@ -228,17 +302,17 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
"/allowedmethods", strutsDefault, null);
PackageConfig differentPkg =
makePackageConfig("org.apache.struts2.convention.actions.parentpackage#class-level#/parentpackage",
- "/parentpackage", classLevelParentPkg, null);
+ "/parentpackage", classLevelParentPkg, null);
PackageConfig differentSubPkg =
makePackageConfig("org.apache.struts2.convention.actions.parentpackage.sub#class-level#/parentpackage/sub",
- "/parentpackage/sub", classLevelParentPkg, null);
+ "/parentpackage/sub", classLevelParentPkg, null);
PackageConfig pkgLevelNamespacePkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/package-level",
- "/package-level", strutsDefault, null);
+ "/package-level", strutsDefault, null);
PackageConfig classLevelNamespacePkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/class-level",
- "/class-level", strutsDefault, null);
+ "/class-level", strutsDefault, null);
PackageConfig actionLevelNamespacePkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/action-level",
- "/action-level", strutsDefault, null);
+ "/action-level", strutsDefault, null);
PackageConfig defaultNamespacePkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace2#struts-default#/namespace2",
- "/namespace2", strutsDefault, null);
+ "/namespace2", strutsDefault, null);
PackageConfig namespaces1Pkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace3#struts-default#/namespaces1",
"/namespaces1", strutsDefault, null);
PackageConfig namespaces2Pkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace3#struts-default#/namespaces2",
@@ -248,19 +322,19 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
PackageConfig namespaces4Pkg =
makePackageConfig("org.apache.struts2.convention.actions.namespace4#struts-default#/namespaces4",
"/namespaces4", strutsDefault, null);
PackageConfig resultPkg =
makePackageConfig("org.apache.struts2.convention.actions.result#struts-default#/result",
- "/result", strutsDefault, null);
+ "/result", strutsDefault, null);
PackageConfig globalResultPkg =
makePackageConfig("org.apache.struts2.convention.actions.result#class-level#/result",
"/result", classLevelParentPkg, null);
PackageConfig resultPathPkg =
makePackageConfig("org.apache.struts2.convention.actions.resultpath#struts-default#/resultpath",
- "/resultpath", strutsDefault, null);
+ "/resultpath", strutsDefault, null);
PackageConfig skipPkg =
makePackageConfig("org.apache.struts2.convention.actions.skip#struts-default#/skip",
- "/skip", strutsDefault, null);
+ "/skip", strutsDefault, null);
PackageConfig chainPkg =
makePackageConfig("org.apache.struts2.convention.actions.chain#struts-default#/chain",
- "/chain", strutsDefault, null);
+ "/chain", strutsDefault, null);
PackageConfig transPkg =
makePackageConfig("org.apache.struts2.convention.actions.transactions#struts-default#/transactions",
- "/transactions", strutsDefault, null);
+ "/transactions", strutsDefault, null);
PackageConfig excludePkg =
makePackageConfig("org.apache.struts2.convention.actions.exclude#struts-default#/exclude",
- "/exclude", strutsDefault, null);
+ "/exclude", strutsDefault, null);
ResultMapBuilder resultMapBuilder =
createStrictMock(ResultMapBuilder.class);
checkOrder(resultMapBuilder, false);
@@ -409,7 +483,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
mockContainer.setResultMapBuilder(resultMapBuilder);
mockContainer.setConventionsService(new ConventionsServiceImpl(""));
- PackageBasedActionConfigBuilder builder = new
PackageBasedActionConfigBuilder(configuration, mockContainer , of, "false",
"struts-default", enableSmiInheritance);
+ PackageBasedActionConfigBuilder builder = new
PackageBasedActionConfigBuilder(configuration, mockContainer, of, "false",
"struts-default", enableSmiInheritance);
builder.setFileProtocols("jar");
if (actionPackages != null) {
builder.setActionPackages(actionPackages);
@@ -706,7 +780,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
verifyActionConfig(pkgConfig, "default-result-path",
DefaultResultPathAction.class, "execute", pkgConfig.getName());
verifyActionConfig(pkgConfig, "skip", Skip.class, "execute",
pkgConfig.getName());
verifyActionConfig(pkgConfig, "idx",
org.apache.struts2.convention.actions.idx.Index.class, "execute",
- "org.apache.struts2.convention.actions.idx#struts-default#/idx");
+
"org.apache.struts2.convention.actions.idx#struts-default#/idx");
/* org.apache.struts2.convention.actions.transactions */
pkgConfig =
configuration.getPackageConfig("org.apache.struts2.convention.actions.transactions#struts-default#/transactions");
@@ -741,7 +815,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
}
private void verifyActionConfig(PackageConfig pkgConfig, String
actionName, Class<?> actionClass,
- String methodName, String packageName) {
+ String methodName, String packageName) {
ActionConfig ac = pkgConfig.getAllActionConfigs().get(actionName);
assertNotNull(ac);
assertEquals(actionClass.getName(), ac.getClassName());
@@ -756,7 +830,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
}
private void verifyMissingActionConfig(PackageConfig pkgConfig, String
actionName, Class<?> actionClass,
- String methodName, String packageName) {
+ String methodName, String
packageName) {
ActionConfig ac = pkgConfig.getAllActionConfigs().get(actionName);
assertNull(ac);
}
@@ -769,10 +843,10 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
assertEquals(packageName, ac.getPackageName());
}
- private void checkSmiValue(PackageConfig pkgConfig, PackageConfig
parentConfig, boolean isSmiInheritanceEnabled) {
+ private void checkSmiValue(PackageConfig pkgConfig, PackageConfig
parentConfig, boolean isSmiInheritanceEnabled) {
if (isSmiInheritanceEnabled) {
assertEquals(parentConfig.isStrictMethodInvocation(),
pkgConfig.isStrictMethodInvocation());
- } else if (!isSmiInheritanceEnabled &&
!parentConfig.isStrictMethodInvocation()){
+ } else if (!isSmiInheritanceEnabled &&
!parentConfig.isStrictMethodInvocation()) {
assertTrue(pkgConfig.isStrictMethodInvocation());
}
}
@@ -788,13 +862,13 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
}
private PackageConfig makePackageConfig(String name, String namespace,
PackageConfig parent,
- String defaultResultType, ResultTypeConfig... results) {
+ String defaultResultType,
ResultTypeConfig... results) {
return makePackageConfig(name, namespace, parent, defaultResultType,
results, null, null, null, true);
}
private PackageConfig makePackageConfig(String name, String namespace,
PackageConfig parent,
- String defaultResultType, ResultTypeConfig[] results,
List<InterceptorConfig> interceptors,
- List<InterceptorStackConfig> interceptorStacks, Set<String>
globalAllowedMethods, boolean strictMethodInvocation) {
+ String defaultResultType,
ResultTypeConfig[] results, List<InterceptorConfig> interceptors,
+ List<InterceptorStackConfig>
interceptorStacks, Set<String> globalAllowedMethods, boolean
strictMethodInvocation) {
PackageConfig.Builder builder = new PackageConfig.Builder(name);
if (namespace != null) {
builder.namespace(namespace);
@@ -852,7 +926,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
public boolean equals(Object obj) {
PackageConfig other = (PackageConfig) obj;
return getName().equals(other.getName()) &&
getNamespace().equals(other.getNamespace()) &&
- getParents().get(0) == other.getParents().get(0) &&
getParents().size() == other.getParents().size();
+ getParents().get(0) == other.getParents().get(0) &&
getParents().size() == other.getParents().size();
}
}
@@ -893,7 +967,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
T obj;
if (type == ObjectFactory.class) {
obj = type.getConstructor().newInstance();
- ((ObjectFactory)obj).setContainer(this);
+ ((ObjectFactory) obj).setContainer(this);
OgnlReflectionProvider rp = new OgnlReflectionProvider() {
@@ -929,7 +1003,7 @@ public class PackageBasedActionConfigBuilderTest extends
TestCase {
}
return obj;
} catch (Exception e) {
- throw new RuntimeException(e);
+ throw new RuntimeException(e);
}
}
diff --git
a/thoughts/shared/research/2025-12-02-WW-5594-wildcard-exclusion-pattern.md
b/thoughts/shared/research/2025-12-02-WW-5594-wildcard-exclusion-pattern.md
new file mode 100644
index 000000000..d0f05822a
--- /dev/null
+++ b/thoughts/shared/research/2025-12-02-WW-5594-wildcard-exclusion-pattern.md
@@ -0,0 +1,460 @@
+---
+date: 2025-12-02T18:33:08+01:00
+topic: "WW-5594: Convention plugin exclusion pattern wildcard matching issue"
+tags: [research, codebase, convention-plugin, wildcard-matching,
configuration, bug-analysis, WW-5594]
+status: complete
+jira_ticket: WW-5594
+related_tickets: [WW-5593]
+---
+
+# Research: WW-5594 - Convention Plugin Wildcard Exclusion Pattern Issue
+
+**Date**: 2025-12-02T18:33:08+01:00
+**JIRA**: [WW-5594](https://issues.apache.org/jira/browse/WW-5594)
+**Related**: [WW-5593](https://issues.apache.org/jira/browse/WW-5593)
+
+## Research Question
+
+Why doesn't the default exclusion pattern `org.apache.struts2.*` properly
exclude classes directly in the `org.apache.struts2` package (like
`XWorkTestCase`)?
+
+## Summary
+
+The convention plugin's `PackageBasedActionConfigBuilder` extracts package
names from class names using `StringUtils.substringBeforeLast(className, ".")`,
then matches these package names against exclusion patterns. For class
`org.apache.struts2.XWorkTestCase`, this produces package name
`org.apache.struts2` (no trailing dot). The pattern `org.apache.struts2.*`
expects something after the dot for the `*` to match, causing a mismatch for
classes directly in the root package.
+
+This is a conceptual mismatch between patterns designed for full class names
vs. matching against extracted package names.
+
+## Detailed Findings
+
+### 1. Package Exclusion Logic
+
+**Location**:
`plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:558-584`
+
+#### Package Name Extraction (Line 558-560)
+
+```java
+protected boolean includeClassNameInActionScan(String className) {
+ String classPackageName = StringUtils.substringBeforeLast(className, ".");
+ return (checkActionPackages(classPackageName) ||
checkPackageLocators(classPackageName)) &&
checkExcludePackages(classPackageName);
+}
+```
+
+**Examples**:
+- `"org.apache.struts2.core.ActionSupport"` → `"org.apache.struts2.core"`
+- `"com.example.actions.UserAction"` → `"com.example.actions"`
+- `"org.apache.struts2.XWorkTestCase"` → `"org.apache.struts2"` ⚠️
+
+#### Exclusion Pattern Matching (Lines 569-584)
+
+```java
+protected boolean checkExcludePackages(String classPackageName) {
+ if (excludePackages != null && excludePackages.length > 0) {
+ WildcardHelper wildcardHelper = new WildcardHelper();
+ Map<String, String> matchMap = new HashMap<>();
+
+ for (String packageExclude : excludePackages) {
+ int[] packagePattern =
wildcardHelper.compilePattern(packageExclude);
+ if (wildcardHelper.match(matchMap, classPackageName,
packagePattern)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+```
+
+### 2. The Conceptual Mismatch
+
+**The Problem**:
+
+When checking if `org.apache.struts2.XWorkTestCase` should be excluded:
+
+1. **Step 1**: Extract package name
+ - Input: `"org.apache.struts2.XWorkTestCase"`
+ - `substringBeforeLast(className, ".")` → `"org.apache.struts2"`
+ - No trailing dot
+
+2. **Step 2**: Match against pattern `org.apache.struts2.*`
+ - Pattern expects: `org.apache.struts2.` + `*` (something)
+ - Actual input: `org.apache.struts2` (nothing after last segment)
+ - **Mismatch**: Pattern requires a literal `.` that doesn't exist in the
package name
+
+**Root Cause**:
+- Exclusion patterns are written for **fully qualified class names** (e.g.,
`org.apache.struts2.*` should match `org.apache.struts2.XWorkTestCase`)
+- But matching is performed against **package names only** (e.g.,
`org.apache.struts2` after stripping class name)
+- This creates edge cases for classes directly in a package
+
+### 3. WildcardHelper Pattern Matching
+
+**Location**: `core/src/main/java/org/apache/struts2/util/WildcardHelper.java`
+
+#### Pattern Compilation Constants
+
+From `WildcardHelper.java:42-48`:
+```java
+public static final int MATCH_FILE = -1; // Single * - matches zero or
more chars (excluding /)
+public static final int MATCH_PATH = -2; // Double ** - matches zero or
more chars (including /)
+public static final int MATCH_BEGIN = -4; // Pattern must match from
beginning
+public static final int MATCH_THEEND = -5; // Pattern must match to the end
+```
+
+#### How Pattern `org.apache.struts2.*` Compiles
+
+The pattern compiles to:
+```
+[MATCH_BEGIN, 'o', 'r', 'g', '.', 'a', 'p', 'a', 'c', 'h', 'e', '.', 's', 't',
'r', 'u', 't', 's', '2', '.', MATCH_FILE, MATCH_THEEND]
+```
+
+**Matching Requirements**:
+1. Must start with `org.apache.struts2.` (literal characters)
+2. Must have `MATCH_FILE` (zero or more non-slash characters)
+3. Must end exactly (`MATCH_THEEND`)
+
+#### Test Evidence
+
+From
`core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java:54-76`:
+
+```java
+public void testMatchStrutsPackages() {
+ // given
+ HashMap<String, String> matchedPatterns = new HashMap<>();
+ int[] pattern = wildcardHelper.compilePattern("org.apache.struts2.*");
+
+ // when & then
+ assertTrue(wildcardHelper.match(matchedPatterns,
"org.apache.struts2.XWorkTestCase", pattern));
+ assertEquals("org.apache.struts2.XWorkTestCase", matchedPatterns.get("0"));
+ assertEquals("XWorkTestCase", matchedPatterns.get("1"));
+
+ assertTrue(wildcardHelper.match(matchedPatterns,
"org.apache.struts2.core.SomeClass", pattern));
+ assertEquals("org.apache.struts2.core.SomeClass",
matchedPatterns.get("0"));
+ assertEquals("core.SomeClass", matchedPatterns.get("1"));
+
+ // IMPORTANT: Pattern matches even with nothing after dot
+ assertTrue(wildcardHelper.match(matchedPatterns, "org.apache.struts2.",
pattern));
+ assertEquals("org.apache.struts2.", matchedPatterns.get("0"));
+ assertEquals("", matchedPatterns.get("1"));
+}
+```
+
+**Key Insight**: The pattern `org.apache.struts2.*` **WILL match**
`org.apache.struts2.` (with trailing dot) because `MATCH_FILE` can match zero
characters. However, it **WON'T match** `org.apache.struts2` (without trailing
dot) because the literal `.` character before `MATCH_FILE` is required.
+
+### 4. Why This Matters
+
+**Scenario**: Class `org.apache.struts2.XWorkTestCase` should be excluded
+
+**What Happens**:
+1. Pattern in config: `org.apache.struts2.*`
+2. Class name: `org.apache.struts2.XWorkTestCase`
+3. Extracted package: `org.apache.struts2` (no trailing dot)
+4. Pattern match: `"org.apache.struts2"` vs pattern `"org.apache.struts2.*"`
+5. **Result**: NO MATCH (pattern expects literal `.` before the `*`)
+6. Class is NOT excluded
+7. Convention plugin tries to scan it
+8. Triggers WW-5593 (NoClassDefFoundError)
+
+**Contrast with subpackage classes**:
+1. Class name: `org.apache.struts2.core.ActionSupport`
+2. Extracted package: `org.apache.struts2.core`
+3. Pattern match: `"org.apache.struts2.core"` vs pattern
`"org.apache.struts2.*"`
+4. **Result**: NO MATCH (pattern expects `org.apache.struts2.` + something,
but we have `org.apache.struts2.core` which is a different string)
+
+**Wait, that's also wrong!** Let me reconsider...
+
+Actually, looking at the test more carefully:
+
+The test shows that `"org.apache.struts2.*"` matches:
+- `"org.apache.struts2.XWorkTestCase"` (full class name)
+- `"org.apache.struts2.core.SomeClass"` (subpackage class name)
+
+But in the actual code, we're matching:
+- `"org.apache.struts2"` (extracted package, no class name)
+
+So the pattern `"org.apache.struts2.*"` is designed to match **class names**
like `"org.apache.struts2.XWorkTestCase"`, but it's being used to match
**package names** like `"org.apache.struts2"`.
+
+### 5. Default Configuration
+
+**Location**: `plugins/convention/src/main/resources/struts-plugin.xml:57`
+
+```xml
+<constant name="struts.convention.exclude.packages"
+
value="org.apache.struts.*,org.apache.struts2.*,org.springframework.web.struts.*,org.springframework.web.struts2.*,org.hibernate.*"/>
+```
+
+**Packages That Should Be Excluded**:
+- `org.apache.struts.*` - Struts 1.x packages
+- `org.apache.struts2.*` - Struts 2.x packages
+- `org.springframework.web.struts.*` - Spring Struts integration
+- `org.springframework.web.struts2.*` - Spring Struts 2 integration
+- `org.hibernate.*` - Hibernate packages
+
+**Problem**: Classes directly in these root packages (without subpackages) may
not be properly excluded:
+- `org.apache.struts2.XWorkTestCase` ❌ Not excluded
+- `org.apache.struts2.StrutsConstants` ❌ Not excluded
+- But: `org.apache.struts2.core.ActionSupport` - needs investigation
+
+### 6. Email Thread Evidence
+
+From Florian Schlittgen's debugging (2024-12-19 21:41):
+
+```java
+public static void main(String[] args) {
+ String packageExclude = "org.apache.struts2.*";
+ String classPackageName = "org.apache.struts2";
+ WildcardHelper wildcardHelper = new WildcardHelper();
+ int[] packagePattern = wildcardHelper.compilePattern(packageExclude);
+ System.out.println(wildcardHelper.match(new HashMap<>(), classPackageName,
packagePattern));
+}
+```
+
+**Output**: `false`
+
+The pattern `"org.apache.struts2.*"` does NOT match `"org.apache.struts2"`
(without trailing dot).
+
+From Lukasz Lenart's response (2024-12-23 19:12):
+
+```java
+// This WORKS (with trailing dot)
+String classPackageName = "org.apache.struts2.";
+// Output: true
+```
+
+So the pattern requires a trailing dot to match.
+
+## Code References
+
+-
`plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:558-584`
- Package exclusion logic
+- `core/src/main/java/org/apache/struts2/util/WildcardHelper.java:71-254` -
Wildcard pattern matching implementation
+- `core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java:54-76` -
Pattern matching tests
+- `plugins/convention/src/main/resources/struts-plugin.xml:57` - Default
exclusion configuration
+
+## Recommended Fixes
+
+### Option A: Update Default Configuration (Simplest)
+
+**File**: `plugins/convention/src/main/resources/struts-plugin.xml:57`
+
+Add both root packages and wildcard patterns to the configuration.
+
+**Disadvantages**:
+- ❌ Users with custom exclusion patterns still need to know about this
+- ❌ Makes the configuration string longer
+- ❌ Requires pattern duplication
+
+### Option B: Match Against Full Class Name
+
+Match exclusion patterns against the full class name instead of just the
package name.
+
+**Disadvantages**:
+- ❌ Changes method logic significantly
+- ❌ May affect performance (two wildcard checks instead of one)
+- ❌ Could have unintended side effects with existing configurations
+
+### Option C: Enhance WildcardHelper for Package Matching
+
+Create a new method `matchPackagePattern()` that understands package-style
matching.
+
+**Disadvantages**:
+- ❌ Most complex solution
+- ❌ Changes core utility class
+- ❌ Requires extensive testing
+- ❌ Overkill for the problem
+
+### Option D: Enhance checkExcludePackages to Handle Root Packages
(IMPLEMENTED ✅)
+
+**File**:
`plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java`
+
+Modify `checkExcludePackages()` to automatically handle patterns ending with
`.*` by also checking if the package name equals the base pattern (without
`.*`).
+
+**Implementation**:
+```java
+protected boolean checkExcludePackages(String classPackageName) {
+ if (excludePackages != null && excludePackages.length > 0) {
+ WildcardHelper wildcardHelper = new WildcardHelper();
+ Map<String, String> matchMap = new HashMap<>();
+
+ for (String packageExclude : excludePackages) {
+ // WW-5594: For patterns ending with ".*", also check if package
equals the base
+ if (packageExclude.endsWith(".*")) {
+ String basePackage = packageExclude.substring(0,
packageExclude.length() - 2);
+ if (classPackageName.equals(basePackage)) {
+ return false;
+ }
+ }
+
+ int[] packagePattern =
wildcardHelper.compilePattern(packageExclude);
+ if (wildcardHelper.match(matchMap, classPackageName,
packagePattern)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+```
+
+**Advantages**:
+- ✅ No configuration duplication needed
+- ✅ Backward compatible - existing configurations with `.*` patterns now work
correctly
+- ✅ Single point of fix in `checkExcludePackages()` method
+- ✅ Intuitive behavior - `org.apache.struts2.*` now excludes both root and
subpackages
+- ✅ Minimal code change
+- ✅ Users don't need to update their configurations
+
+**Disadvantages**:
+- ❌ Slightly more processing per pattern (trivial performance impact)
+
+### Recommendation
+
+**Option D** was chosen and implemented because:
+1. Most elegant solution - no configuration duplication
+2. Backward compatible - existing configurations work better
+3. Single point of fix
+4. Intuitive behavior for users
+5. Minimal code change with maximum benefit
+
+## Impact Assessment
+
+### Current Risk
+
+**Severity**: MEDIUM
+
+**Scenarios Affected**:
+1. Classes directly in root Struts packages (like `XWorkTestCase`)
+2. Users setting `struts.convention.action.includeJars`
+3. Custom exclusion patterns not including root packages
+
+**Current Behavior**:
+- ❌ Test classes in root packages not excluded
+- ❌ Can trigger WW-5593 (NoClassDefFoundError)
+- ❌ Users must manually add root packages to exclusions
+- ❌ Non-intuitive pattern matching behavior
+
+### After Fix
+
+**Expected Behavior**:
+- ✅ Root package classes properly excluded by default
+- ✅ Reduces occurrence of WW-5593
+- ✅ More intuitive default configuration
+- ✅ Better documentation for custom patterns
+
+## Testing Strategy
+
+### Unit Tests to Add
+
+1. **Test Root Package Exclusion**:
+```java
+@Test
+public void testExcludeRootPackageClasses() {
+ String[] excludePackages = {"org.apache.struts2", "org.apache.struts2.*"};
+ builder.setExcludePackages(excludePackages);
+
+ // Should exclude classes directly in org.apache.struts2
+
assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase"));
+
assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.StrutsConstants"));
+
+ // Should also exclude subpackages
+
assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.core.ActionSupport"));
+
assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.dispatcher.Dispatcher"));
+}
+```
+
+2. **Test Wildcard Pattern Behavior**:
+```java
+@Test
+public void testWildcardPatternMatchingWithPackageNames() {
+ WildcardHelper helper = new WildcardHelper();
+ int[] pattern = helper.compilePattern("org.apache.struts2.*");
+
+ // Package name without trailing dot should NOT match
+ assertFalse(helper.match(new HashMap<>(), "org.apache.struts2", pattern));
+
+ // Package name with trailing dot SHOULD match
+ assertTrue(helper.match(new HashMap<>(), "org.apache.struts2.", pattern));
+
+ // Full class name SHOULD match
+ assertTrue(helper.match(new HashMap<>(),
"org.apache.struts2.XWorkTestCase", pattern));
+}
+```
+
+3. **Test Default Configuration**:
+```java
+@Test
+public void testDefaultExclusionPatterns() {
+ // Load default patterns from struts-plugin.xml
+ builder.setExcludePackages(getDefaultExclusionPatterns());
+
+ // Should exclude Struts 2 root package classes
+
assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase"));
+
+ // Should exclude Struts 1 root package classes
+
assertFalse(builder.includeClassNameInActionScan("org.apache.struts.Action"));
+
+ // Should exclude Hibernate root package classes
+ assertFalse(builder.includeClassNameInActionScan("org.hibernate.Session"));
+}
+```
+
+### Integration Tests
+
+1. Test scanning with `struts.convention.action.includeJars` configured
+2. Verify classes in root excluded packages are not scanned
+3. Verify classes in subpackages are properly excluded
+4. Verify user action classes are still included
+
+## Workaround for Users
+
+Until the fix is released, users should update their exclusion patterns:
+
+```xml
+<constant name="struts.convention.exclude.packages"
+
value="org.apache.struts2,org.apache.struts2.*,com.opensymphony.xwork2,com.opensymphony.xwork2.*"
/>
+```
+
+**Pattern**: For each package to exclude, include both:
+- Root package without wildcard: `org.apache.struts2`
+- Subpackages with wildcard: `org.apache.struts2.*`
+
+## Documentation Updates Needed
+
+1. **Convention Plugin Documentation**:
+ - Explain wildcard pattern matching behavior
+ - Provide examples of correct exclusion patterns
+ - Document the difference between `org.example` and `org.example.*`
+
+2. **Migration Guide**:
+ - Note the improved default exclusions in release notes
+ - Explain that custom exclusion patterns should include root packages
+
+3. **Configuration Reference**:
+ - Update `struts.convention.exclude.packages` documentation
+ - Add examples showing both patterns
+
+## Related Issues
+
+- **WW-5593**: NoClassDefFoundError bug that this exclusion issue can trigger
+- Related research:
`thoughts/shared/research/2025-12-02-WW-5593-convention-plugin-noclass-exception.md`
+
+## Email Thread Reference
+
+**From**: Florian Schlittgen ([email protected])
+**Date**: December 19, 2024 - January 19, 2025
+**Subject**: Struts 7: action class finder
+**List**: [email protected]
+
+**Key Insight from Thread** (Florian, 2024-12-23 19:41):
+> "Thanks for looking into it. Your examples/tests are alright, but I think
this is not the way it is being called. Please take a look at
org.apache.struts2.convention.PackageBasedActionConfigBuilder.includeClassNameInActionScan(String).
While debugging I can see that this method is being called with parameter
"className" = "org.apache.struts2.XWorkTestCase". In the method's first line
the package name is derived from the className by using
"StringUtils.substringBeforeLast(className, "." [...]
+
+## Implementation Status
+
+1. ✅ Create JIRA ticket WW-5594
+2. ✅ Modify `checkExcludePackages()` to handle `.*` patterns for root packages
(Option D)
+3. ✅ Default exclusion patterns in `struts-plugin.xml` unchanged (no
duplication needed)
+4. ✅ Add unit tests for root package exclusion
(`PackageBasedActionConfigBuilderTest.testWW5594_RootPackageExclusion`)
+5. ✅ Add unit tests documenting WildcardHelper behavior
(`WildcardHelperTest.testWW5594_*`)
+6. ⏳ Add integration tests with includeJars configured
+7. ⏳ Update documentation for pattern matching behavior
+8. ⏳ Update migration guide with new default patterns
+
+## Files Changed
+
+-
`plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java`
- Enhanced `checkExcludePackages()` method
+-
`plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java`
- Added `testWW5594_RootPackageExclusion()`
+- `core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java` - Added
`testWW5594_WildcardPatternRequiresTrailingDot()` and
`testWW5594_ExactPackagePatternMatchesPackageName()`
\ No newline at end of file