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 362151425 WW-5770 Make struts.convention.result.path scopable per 
package (#2009)
362151425 is described below

commit 3621514250368aa1d762558d9be7462dc7bcfcc8
Author: Lukasz Lenart <[email protected]>
AuthorDate: Wed Oct 14 11:17:47 2026 +0200

    WW-5770 Make struts.convention.result.path scopable per package (#2009)
    
    * WW-5770 docs: add design for scopable Convention result path
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 docs: add implementation plan, make the provider method default 
in the design
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 feat(core): resolve a scoped constant against an explicit package
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 feat(convention): resolve the result path against the action's 
package
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 feat(convention): honour the scoped result path when building 
results
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 feat(convention): honour the scoped result path for unknown 
actions and results
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 fix(core): resolve an explicit package without falling back to 
the current invocation
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * WW-5770 chore: address review comments
    
    Drop the inert convention-default redeclaration from the test fixture,
    narrow the @throws of the default getValue(String, PackageConfig),
    and mark the design as implemented.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../struts2/config/ScopedConstantProvider.java     |  18 +-
 .../providers/StrutsScopedConstantProvider.java    |  22 +-
 .../StrutsScopedConstantProviderTest.java          |  47 +
 ...-10-14-WW-5770-scoped-convention-result-path.md | 986 +++++++++++++++++++++
 ...WW-5770-scoped-convention-result-path-design.md | 121 +++
 .../convention/ConventionScopableConstants.java    |  21 +-
 .../convention/ConventionUnknownHandler.java       |  10 +-
 .../struts2/convention/ConventionsService.java     |  26 +
 .../struts2/convention/ConventionsServiceImpl.java |  86 +-
 .../convention/DefaultResultMapBuilder.java        |   2 +-
 .../src/main/resources/struts-plugin.xml           |   2 +
 .../convention/ConventionScopedResultPathTest.java |  33 +-
 .../convention/ConventionUnknownHandlerTest.java   |  95 +-
 .../convention/ConventionsServiceImplTest.java     | 151 ++++
 .../convention/DefaultResultMapBuilderTest.java    |  95 +-
 .../PackageBasedActionConfigBuilderTest.java       |  20 +-
 .../src/test/resources/scoped/message-success.ftl  |  23 +
 .../struts-convention-scoped-result-path.xml       |  29 +-
 18 files changed, 1680 insertions(+), 107 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java 
b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
index 53e655483..5148cfce5 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++ b/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
@@ -18,8 +18,10 @@
  */
 package org.apache.struts2.config;
 
+import org.apache.struts2.config.entities.PackageConfig;
+
 /**
- * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
+ * Resolves a scopable constant for the current action invocation or a given 
package: the package's {@code <scoped-constant>}, inherited
  * through {@code extends}, or else the global constant.
  *
  * @since 7.5.0
@@ -32,4 +34,18 @@ public interface ScopedConstantProvider {
      * @throws IllegalArgumentException when {@code name} is not scopable
      */
     String getValue(String name);
+
+    /**
+     * @param name          a constant registered through a {@link 
ScopableConstants} bean
+     * @param packageConfig the package whose scoped constants, inherited ones 
included, take precedence; may be
+     *                      {@code null}
+     * @return the package's scoped value, or else {@link #getValue(String)}
+     * @throws IllegalArgumentException when {@code name} is not scopable; 
this default checks only on the
+     *                                  {@link #getValue(String)} fallback, as 
package values were validated at startup
+     * @since 7.5.0
+     */
+    default String getValue(String name, PackageConfig packageConfig) {
+        String value = packageConfig != null ? 
packageConfig.getAllScopedConstants().get(name) : null;
+        return value != null ? value : getValue(name);
+    }
 }
diff --git 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
index ebfc968f5..647fa9eb6 100644
--- 
a/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/providers/StrutsScopedConstantProvider.java
@@ -49,13 +49,29 @@ public class StrutsScopedConstantProvider implements 
ScopedConstantProvider {
 
     @Override
     public String getValue(String name) {
+        requireScopable(name);
+        return resolve(name, currentPackage().orElse(null));
+    }
+
+    @Override
+    public String getValue(String name, PackageConfig packageConfig) {
+        if (packageConfig == null) {
+            return getValue(name);
+        }
+        requireScopable(name);
+        return resolve(name, packageConfig);
+    }
+
+    private void requireScopable(String name) {
         if (!scopableNames.contains(name)) {
             throw new IllegalArgumentException(String.format(
                     "Constant [%s] is not scopable; register it through a %s 
bean", name, ScopableConstants.class.getName()));
         }
-        return currentPackage()
-                .map(packageConfig -> 
packageConfig.getAllScopedConstants().get(name))
-                .orElseGet(() -> container.getInstance(String.class, name));
+    }
+
+    private String resolve(String name, PackageConfig packageConfig) {
+        String value = packageConfig != null ? 
packageConfig.getAllScopedConstants().get(name) : null;
+        return value != null ? value : container.getInstance(String.class, 
name);
     }
 
     private Optional<PackageConfig> currentPackage() {
diff --git 
a/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
 
b/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
index c00ded1c5..b6bde8657 100644
--- 
a/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
@@ -24,6 +24,7 @@ import org.apache.struts2.XWorkTestCase;
 import org.apache.struts2.config.ScopedConstantProvider;
 import org.apache.struts2.config.StrutsXmlConfigurationProvider;
 import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.config.entities.PackageConfig;
 import org.apache.struts2.mock.MockActionInvocation;
 import org.apache.struts2.mock.MockActionProxy;
 
@@ -121,6 +122,52 @@ public class StrutsScopedConstantProviderTest extends 
XWorkTestCase {
         assertEquals("global", container.getInstance(String.class, 
"sample.scopable"));
     }
 
+    public void testExplicitPackageResolvesInheritedValue() {
+        assertEquals("base", provider.getValue("sample.scopable", 
configuration.getPackageConfig("child")));
+    }
+
+    public void testExplicitPackageWinsOverInvocationPackage() {
+        enterPackage("override");
+
+        assertEquals("base", provider.getValue("sample.scopable", 
configuration.getPackageConfig("child")));
+    }
+
+    public void testExplicitPackageWithoutOverrideIgnoresInvocationPackage() {
+        enterPackage("override");
+
+        assertEquals("global", provider.getValue("sample.scopable", 
configuration.getPackageConfig("plain")));
+    }
+
+    public void 
testExplicitPackageWithoutOverrideGivesGlobalOutsideInvocation() {
+        assertEquals("global", provider.getValue("sample.scopable", 
configuration.getPackageConfig("plain")));
+    }
+
+    public void testNullPackageFallsBackToInvocation() {
+        enterPackage("override");
+
+        assertEquals("override", provider.getValue("sample.scopable", null));
+    }
+
+    public void testExplicitPackageEmptyOverrideIsReturnedAsIs() {
+        assertEquals("", provider.getValue("sample.scopable", 
configuration.getPackageConfig("empty")));
+    }
+
+    public void testExplicitPackageRejectsUnregisteredName() {
+        PackageConfig plain = configuration.getPackageConfig("plain");
+
+        assertThatThrownBy(() -> provider.getValue("sample.unknown", plain))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[sample.unknown]");
+    }
+
+    public void testLambdaProviderGetsPackageLookup() {
+        ScopedConstantProvider lambda = name -> "fallback";
+
+        assertEquals("base", lambda.getValue("sample.scopable", 
configuration.getPackageConfig("child")));
+        assertEquals("fallback", lambda.getValue("sample.scopable", 
configuration.getPackageConfig("plain")));
+        assertEquals("fallback", lambda.getValue("sample.scopable", null));
+    }
+
     private void enterPackage(String packageName) {
         MockActionProxy proxy = new MockActionProxy();
         proxy.setConfig(new ActionConfig.Builder(packageName, "test", 
ActionSupport.class.getName()).build());
diff --git 
a/docs/superpowers/plans/2026-10-14-WW-5770-scoped-convention-result-path.md 
b/docs/superpowers/plans/2026-10-14-WW-5770-scoped-convention-result-path.md
new file mode 100644
index 000000000..e6e2f75d8
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-14-WW-5770-scoped-convention-result-path.md
@@ -0,0 +1,986 @@
+# WW-5770 Scopable Convention Result Path 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:** Let `<scoped-constant name="struts.convention.result.path">` on a 
parent package set the Convention
+result path for every action under it.
+
+**Architecture:** A `default` method `ScopedConstantProvider.getValue(name, 
PackageConfig)` resolves a constant
+against an explicit package. `ConventionsService` gains package-aware 
`default` overloads; `ConventionsServiceImpl`
+implements them with precedence `@ResultPath` → package scoped value → global. 
`DefaultResultMapBuilder` (startup)
+and `ConventionUnknownHandler` (runtime) pass the package they already hold.
+
+**Tech Stack:** Java 17, Struts container (`@Inject`), JUnit 4 + JUnit 3-style 
`TestCase`, EasyMock, AssertJ
+(core only — the Convention module has no AssertJ), `StrutsJUnit4TestCase` 
from `struts2-junit-plugin`.
+
+**Spec:** 
`docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md`
+
+## Global Constraints
+
+- Fix version 7.5.0; new public API carries `@since 7.5.0`.
+- Tests are JUnit 3 or 4 — never Jupiter. Match the target file's style; an 
`@Test` in a `TestCase` subclass never runs.
+- `ScopedConstantProvider` must stay a functional interface (23 test sites 
stub it as `name -> ...`).
+- `ConventionsServiceImpl`'s constructor signature does not change.
+- No null guard on the injected `ScopedConstantProvider`; tests inject one.
+- No new comments beyond Javadoc on new public methods; match the file's 
density.
+- Commit messages: `WW-5770 <type>(<scope>): ...`, ending with 
`Co-Authored-By: Claude Opus 5.5 <[email protected]>`;
+  no session links.
+- Stage explicit paths only — never `git add -A`, never `git stash`.
+- Use `./mvnw` (there is no `mvn` on the PATH); log to a file and read Maven's 
exit code, not a grep of the output.
+
+## Review Focus
+
+1. **Override without a trailing slash** (`/WEB-INF/admin`) — must behave like 
the global constant does: a slash is
+   appended. Pinned in Task 3 (startup) and Task 4 (runtime).
+2. **Empty override** (`value=""`) — must be used as-is, not fall back to the 
global path. Pinned in Task 2.
+3. **Package-level `@ResultPath` (`package-info.java`)** — must beat the 
scoped value just as the class-level one
+   does. Pinned in Task 2.
+4. **Action-less request when the default parent package has no override** — 
global path, unchanged from today.
+   Pinned in Task 4.
+5. **A custom `ConventionsService` that implements only the one-argument 
methods** — keeps its old behaviour.
+   Pinned in Task 2.
+
+---
+
+### Task 1: Package-aware lookup on `ScopedConstantProvider`
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java`
+- Test: 
`core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java`
+
+**Interfaces:**
+- Produces: `default String getValue(String name, PackageConfig 
packageConfig)` on
+  `org.apache.struts2.config.ScopedConstantProvider` — the package's 
`getAllScopedConstants().get(name)` when
+  non-null, else `getValue(name)`; a `null` package goes straight to 
`getValue(name)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `StrutsScopedConstantProviderTest` (JUnit 3 style, before 
`enterPackage`), and add imports
+`org.apache.struts2.config.entities.PackageConfig`:
+
+```java
+    public void testExplicitPackageResolvesInheritedValue() {
+        assertEquals("base", provider.getValue("sample.scopable", 
configuration.getPackageConfig("child")));
+    }
+
+    public void testExplicitPackageWinsOverInvocationPackage() {
+        enterPackage("override");
+
+        assertEquals("base", provider.getValue("sample.scopable", 
configuration.getPackageConfig("child")));
+    }
+
+    public void testExplicitPackageWithoutOverrideFallsBackToInvocation() {
+        enterPackage("override");
+
+        assertEquals("override", provider.getValue("sample.scopable", 
configuration.getPackageConfig("plain")));
+    }
+
+    public void 
testExplicitPackageWithoutOverrideGivesGlobalOutsideInvocation() {
+        assertEquals("global", provider.getValue("sample.scopable", 
configuration.getPackageConfig("plain")));
+    }
+
+    public void testNullPackageFallsBackToInvocation() {
+        enterPackage("override");
+
+        assertEquals("override", provider.getValue("sample.scopable", null));
+    }
+
+    public void testExplicitPackageEmptyOverrideIsReturnedAsIs() {
+        assertEquals("", provider.getValue("sample.scopable", 
configuration.getPackageConfig("empty")));
+    }
+
+    public void testExplicitPackageRejectsUnregisteredName() {
+        PackageConfig plain = configuration.getPackageConfig("plain");
+
+        assertThatThrownBy(() -> provider.getValue("sample.unknown", plain))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[sample.unknown]");
+    }
+
+    public void testLambdaProviderGetsPackageLookup() {
+        ScopedConstantProvider lambda = name -> "fallback";
+
+        assertEquals("base", lambda.getValue("sample.scopable", 
configuration.getPackageConfig("child")));
+        assertEquals("fallback", lambda.getValue("sample.scopable", 
configuration.getPackageConfig("plain")));
+        assertEquals("fallback", lambda.getValue("sample.scopable", null));
+    }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+./mvnw test -DskipAssembly -pl core -Dtest=StrutsScopedConstantProviderTest > 
/tmp/ww5770-t1.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run" 
/tmp/ww5770-t1.log | head -20
+```
+Expected: non-zero exit, compilation error `method getValue in interface 
ScopedConstantProvider cannot be applied`.
+
+- [ ] **Step 3: Add the default method**
+
+In `ScopedConstantProvider.java` add the import 
`org.apache.struts2.config.entities.PackageConfig`, change the
+class Javadoc's first sentence to "Resolves a scopable constant for the 
current action invocation or a given
+package:" and add after `getValue(String name)`:
+
+```java
+    /**
+     * @param name          a constant registered through a {@link 
ScopableConstants} bean
+     * @param packageConfig the package whose scoped constants, inherited ones 
included, take precedence; may be
+     *                      {@code null}
+     * @return the package's scoped value, or else {@link #getValue(String)}
+     * @throws IllegalArgumentException when {@code name} is not scopable
+     * @since 7.5.0
+     */
+    default String getValue(String name, PackageConfig packageConfig) {
+        String value = packageConfig != null ? 
packageConfig.getAllScopedConstants().get(name) : null;
+        return value != null ? value : getValue(name);
+    }
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+./mvnw test -DskipAssembly -pl core 
-Dtest='StrutsScopedConstantProviderTest,ScopedConstantProviderWiringTest,StrutsJavaConfigurationProviderTest,ConstantConfigTest'
 > /tmp/ww5770-t1.log 2>&1; echo "exit=$?"; grep -E "Tests run:|FAIL" 
/tmp/ww5770-t1.log | tail -5
+```
+Expected: `exit=0`, no failures.
+
+- [ ] **Step 5: Install core so the Convention module compiles against it**
+
+```bash
+./mvnw install -DskipAssembly -DskipTests -pl core > /tmp/ww5770-install.log 
2>&1; echo "exit=$?"
+```
+Expected: `exit=0`. Repeat this step whenever core changes again.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java 
core/src/test/java/org/apache/struts2/config/providers/StrutsScopedConstantProviderTest.java
+git diff --cached --name-only
+git commit -m "WW-5770 feat(core): resolve a scoped constant against an 
explicit package
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 2: Package-aware result path in `ConventionsService`
+
+**Files:**
+- Create: 
`plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java`
+- Modify: `plugins/convention/src/main/resources/struts-plugin.xml` (after the 
`ConventionsService` bean, line ~40)
+- Modify: 
`plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java`
+- Modify: 
`plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java`
+- Create: 
`plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java`
+
+**Interfaces:**
+- Consumes: `ScopedConstantProvider.getValue(String, PackageConfig)` (Task 1).
+- Produces:
+  - `ConventionsService#determineResultPath(Class<?> actionClass, 
PackageConfig packageConfig)` — `default`, delegates to 
`determineResultPath(actionClass)`.
+  - `ConventionsService#determineResultPath(ActionConfig actionConfig, 
PackageConfig packageConfig)` — `default`, delegates to 
`determineResultPath(actionConfig)`.
+  - `ConventionsServiceImpl#setScopedConstantProvider(ScopedConstantProvider)` 
— `@Inject` setter.
+  - `ConventionScopableConstants` — `ScopableConstants` returning 
`Set.of(ConventionConstants.CONVENTION_RESULT_PATH)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `ConventionsServiceImplTest.java` with the ASF license header copied 
from `ConventionsServiceImpl.java`:
+
+```java
+package org.apache.struts2.convention;
+
+import org.apache.struts2.config.entities.ActionConfig;
+import org.apache.struts2.config.entities.PackageConfig;
+import org.apache.struts2.config.entities.ResultTypeConfig;
+import org.apache.struts2.convention.actions.NoAnnotationAction;
+import 
org.apache.struts2.convention.actions.resultpath.ClassLevelResultPathAction;
+import 
org.apache.struts2.convention.actions.resultpath.PackageLevelResultPathAction;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class ConventionsServiceImplTest {
+
+    private static final String GLOBAL = "/WEB-INF/content/";
+    private static final String SCOPED = "/WEB-INF/admin/";
+
+    private ConventionsServiceImpl service;
+    private PackageConfig scopedPackage;
+    private PackageConfig plainPackage;
+
+    @Before
+    public void setUp() {
+        service = new ConventionsServiceImpl(GLOBAL);
+        service.setScopedConstantProvider(name -> GLOBAL);
+        scopedPackage = childOf(SCOPED);
+        plainPackage = new PackageConfig.Builder("plain").build();
+    }
+
+    @Test
+    public void scopedValueOfParentPackageWinsOverGlobal() {
+        assertEquals(SCOPED, 
service.determineResultPath(NoAnnotationAction.class, scopedPackage));
+    }
+
+    @Test
+    public void classLevelResultPathWinsOverScopedValue() {
+        assertEquals("/class-level", 
service.determineResultPath(ClassLevelResultPathAction.class, scopedPackage));
+    }
+
+    @Test
+    public void packageLevelResultPathWinsOverScopedValue() {
+        assertEquals("/package-level", 
service.determineResultPath(PackageLevelResultPathAction.class, scopedPackage));
+    }
+
+    @Test
+    public void packageWithoutScopedValueGivesGlobal() {
+        assertEquals(GLOBAL, 
service.determineResultPath(NoAnnotationAction.class, plainPackage));
+    }
+
+    @Test
+    public void emptyScopedValueIsUsedAsIs() {
+        assertEquals("", service.determineResultPath(NoAnnotationAction.class, 
childOf("")));
+    }
+
+    @Test
+    public void nullPackageGivesGlobalWithoutConsultingProvider() {
+        ConventionsServiceImpl withoutProvider = new 
ConventionsServiceImpl(GLOBAL);
+
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath(NoAnnotationAction.class, null));
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath(NoAnnotationAction.class));
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath((ActionConfig) null, null));
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath((ActionConfig) null));
+    }
+
+    @Test
+    public void actionlessRequestUsesScopedValueOfPackage() {
+        assertEquals(SCOPED, service.determineResultPath((ActionConfig) null, 
scopedPackage));
+    }
+
+    @Test
+    public void actionConfigResolvesThroughItsClass() {
+        ActionConfig annotated = 
actionConfig(ClassLevelResultPathAction.class.getName());
+        ActionConfig plain = actionConfig(NoAnnotationAction.class.getName());
+
+        assertEquals("/class-level", service.determineResultPath(annotated, 
scopedPackage));
+        assertEquals(SCOPED, service.determineResultPath(plain, 
scopedPackage));
+    }
+
+    @Test
+    public void unknownActionClassStillFails() {
+        ActionConfig missing = actionConfig("com.example.MissingAction");
+
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.determineResultPath(missing, scopedPackage));
+        assertTrue(e.getMessage().contains("[com.example.MissingAction]"));
+    }
+
+    @Test
+    public void customServiceWithOnlyOneArgumentMethodsKeepsItsBehaviour() {
+        ConventionsService custom = new ConventionsService() {
+            @Override
+            public String determineResultPath(Class<?> actionClass) {
+                return "/custom/";
+            }
+
+            @Override
+            public String determineResultPath(ActionConfig actionConfig) {
+                return "/custom/";
+            }
+
+            @Override
+            public Map<String, ResultTypeConfig> 
getResultTypesByExtension(PackageConfig packageConfig) {
+                return Collections.emptyMap();
+            }
+        };
+
+        assertEquals("/custom/", 
custom.determineResultPath(NoAnnotationAction.class, scopedPackage));
+        assertEquals("/custom/", custom.determineResultPath((ActionConfig) 
null, scopedPackage));
+    }
+
+    @Test
+    public void resultPathIsScopable() {
+        assertEquals(Set.of(ConventionConstants.CONVENTION_RESULT_PATH), new 
ConventionScopableConstants().getNames());
+    }
+
+    private static PackageConfig childOf(String scopedResultPath) {
+        PackageConfig parent = new PackageConfig.Builder("admin-base")
+                .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, 
scopedResultPath)
+                .build();
+        return new 
PackageConfig.Builder("admin-base#/admin").namespace("/admin").addParent(parent).build();
+    }
+
+    private static ActionConfig actionConfig(String className) {
+        return new ActionConfig.Builder("admin-base#/admin", "foo", 
className).build();
+    }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+./mvnw test -DskipAssembly -pl plugins/convention 
-Dtest=ConventionsServiceImplTest > /tmp/ww5770-t2.log 2>&1; echo "exit=$?"; 
grep -E "ERROR|Tests run" /tmp/ww5770-t2.log | head -20
+```
+Expected: non-zero exit, compilation errors for `setScopedConstantProvider`, 
`ConventionScopableConstants` and the
+two-argument `determineResultPath`.
+
+- [ ] **Step 3: Add `ConventionScopableConstants` and register it**
+
+Create `ConventionScopableConstants.java` (ASF header, same shape as
+`core/src/main/java/org/apache/struts2/components/StrutsUiScopableConstants.java`):
+
+```java
+package org.apache.struts2.convention;
+
+import org.apache.struts2.config.ScopableConstants;
+
+import java.util.Set;
+
+/**
+ * Lets a package override the Convention result path with {@code 
<scoped-constant>}.
+ *
+ * @since 7.5.0
+ */
+public class ConventionScopableConstants implements ScopableConstants {
+
+    @Override
+    public Set<String> getNames() {
+        return Set.of(ConventionConstants.CONVENTION_RESULT_PATH);
+    }
+}
+```
+
+In `struts-plugin.xml`, directly after the `ConventionsService` bean:
+
+```xml
+    <bean type="org.apache.struts2.config.ScopableConstants" name="convention"
+          class="org.apache.struts2.convention.ConventionScopableConstants"/>
+```
+
+- [ ] **Step 4: Add the default methods to `ConventionsService`**
+
+After `String determineResultPath(ActionConfig actionConfig);`:
+
+```java
+    /**
+     * Locates the result location like {@link #determineResultPath(Class)}, 
using the package's scoped
+     * {@code struts.convention.result.path} when the action carries no {@code 
@ResultPath}.
+     *
+     * @param   actionClass The action class.
+     * @param   packageConfig The package the action belongs to, or null for 
the global default.
+     * @return  The result location.
+     * @since 7.5.0
+     */
+    default String determineResultPath(Class<?> actionClass, PackageConfig 
packageConfig) {
+        return determineResultPath(actionClass);
+    }
+
+    /**
+     * Locates the result location like {@link 
#determineResultPath(ActionConfig)}, using the package's scoped
+     * {@code struts.convention.result.path} when the action carries no {@code 
@ResultPath}.
+     *
+     * @param   actionConfig (Optional) The configuration for the action, or 
null when there is no action.
+     * @param   packageConfig The package whose scoped value applies, or null 
for the global default.
+     * @return  The result location.
+     * @since 7.5.0
+     */
+    default String determineResultPath(ActionConfig actionConfig, 
PackageConfig packageConfig) {
+        return determineResultPath(actionConfig);
+    }
+```
+
+- [ ] **Step 5: Implement them in `ConventionsServiceImpl`**
+
+Add imports `org.apache.struts2.config.ScopedConstantProvider`. Add the field 
and setter after the constructor:
+
+```java
+    private ScopedConstantProvider scopedConstantProvider;
+```
+(declare next to `private String resultPath;`)
+
+```java
+    @Inject
+    public void setScopedConstantProvider(ScopedConstantProvider 
scopedConstantProvider) {
+        this.scopedConstantProvider = scopedConstantProvider;
+    }
+```
+
+Replace both existing `determineResultPath` methods with:
+
+```java
+    /**
+     * {@inheritDoc}
+     */
+    public String determineResultPath(Class<?> actionClass) {
+        return determineResultPath(actionClass, null);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String determineResultPath(Class<?> actionClass, PackageConfig 
packageConfig) {
+        String annotatedResultPath = findAnnotatedResultPath(actionClass);
+        return annotatedResultPath != null ? annotatedResultPath : 
scopedResultPath(packageConfig);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public String determineResultPath(ActionConfig actionConfig) {
+        return determineResultPath(actionConfig, null);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String determineResultPath(ActionConfig actionConfig, PackageConfig 
packageConfig) {
+        if (actionConfig == null) {
+            return scopedResultPath(packageConfig);
+        }
+
+        try {
+            return 
determineResultPath(ClassLoaderUtil.loadClass(actionConfig.getClassName(), 
this.getClass()), packageConfig);
+        } catch (ClassNotFoundException e) {
+            throw new RuntimeException("Invalid action class configuration 
that references an unknown " +
+                "class named [" + actionConfig.getClassName() + "]", e);
+        }
+    }
+
+    private String scopedResultPath(PackageConfig packageConfig) {
+        if (packageConfig == null) {
+            return resultPath;
+        }
+        return 
scopedConstantProvider.getValue(ConventionConstants.CONVENTION_RESULT_PATH, 
packageConfig);
+    }
+
+    private String findAnnotatedResultPath(Class<?> actionClass) {
+        ResultPath resultPathAnnotation = 
AnnotationUtils.findAnnotation(actionClass, ResultPath.class);
+        if (resultPathAnnotation == null) {
+            return null;
+        }
+        if (resultPathAnnotation.value().equals("") && 
resultPathAnnotation.property().equals("")) {
+            throw new ConfigurationException("The ResultPath annotation must 
have either" +
+                " a value or property specified.");
+        }
+
+        String property = resultPathAnnotation.property();
+        if (property.equals("")) {
+            return resultPathAnnotation.value();
+        }
+        try {
+            ResourceBundle strutsBundle = ResourceBundle.getBundle("struts");
+            return strutsBundle.getString(property);
+        } catch (Exception e) {
+            throw new ConfigurationException("The action class [" + 
actionClass + "] defines" +
+                " a @ResultPath annotation and a property definition however 
the" +
+                " struts.properties could not be found in the classpath using 
ResourceBundle" +
+                " OR the bundle exists but the property [" + property + "] is 
not defined" +
+                " in the file.", e);
+        }
+    }
+```
+
+The exception messages are copied verbatim from the current implementation.
+
+- [ ] **Step 6: Run the tests to verify they pass, plus the module**
+
+```bash
+./mvnw test -DskipAssembly -pl plugins/convention > /tmp/ww5770-t2.log 2>&1; 
echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t2.log | tail -5
+```
+Expected: `exit=0`. `DefaultResultMapBuilderTest` is still green because 
`DefaultResultMapBuilder` still calls the
+one-argument method.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add 
plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
 plugins/convention/src/main/resources/struts-plugin.xml 
plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java
 
plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java
 
plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java
+git diff --cached --name-only
+git commit -m "WW-5770 feat(convention): resolve the result path against the 
action's package
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 3: Startup — `DefaultResultMapBuilder` uses the package's result path
+
+**Files:**
+- Modify: 
`plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java:159`
+- Modify: 
`plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java`
+- Create: 
`plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java`
+- Create: 
`plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml`
+- Create: `plugins/convention/src/test/resources/scoped/message-success.ftl`
+
+**Interfaces:**
+- Consumes: `ConventionsService#determineResultPath(Class<?>, PackageConfig)`,
+  `ConventionsServiceImpl#setScopedConstantProvider` (Task 2).
+
+- [ ] **Step 1: Route the existing test sites through a helper**
+
+In `DefaultResultMapBuilderTest`, use Edit with `replace_all` to replace `new 
ConventionsServiceImpl(` with
+`newConventionsService(` (21 sites). Then add, next to 
`createPackageConfigBuilder`:
+
+```java
+    private ConventionsService newConventionsService(String resultPath) {
+        ConventionsServiceImpl service = new 
ConventionsServiceImpl(resultPath);
+        service.setScopedConstantProvider(name -> resultPath);
+        return service;
+    }
+```
+
+Check with `git diff --stat` that only this file changed and `grep -c 
"newConventionsService(" ` reports 22.
+
+- [ ] **Step 2: Write the failing unit tests**
+
+Replace the existing `createPackageConfigBuilder(String namespace)` with these 
two methods (the result types are
+unchanged; the body only moves into a `builder` local so a parent can be 
added):
+
+```java
+    private PackageConfig createPackageConfigBuilder(String namespace) {
+        return createPackageConfigBuilder(namespace, null);
+    }
+
+    private PackageConfig createPackageConfigBuilder(String namespace, String 
scopedResultPath) {
+        ResultTypeConfig resultType = new 
ResultTypeConfig.Builder("dispatcher", 
"org.apache.struts2.result.ServletDispatcherResult").
+                addParam("key", "value").addParam("key1", 
"value1").defaultResultParam("location").build();
+
+        ResultTypeConfig redirect = new 
ResultTypeConfig.Builder("redirectAction",
+                
"org.apache.struts2.result.ServletActionRedirectResult").defaultResultParam("actionName").build();
+
+        ResultTypeConfig ftlResultType = new 
ResultTypeConfig.Builder("freemarker",
+                
"org.apache.struts2.views.freemarker.FreemarkerResult").defaultResultParam("location").build();
+
+        PackageConfig.Builder builder = new PackageConfig.Builder("package").
+                namespace(namespace).
+                defaultResultType("dispatcher").
+                addResultTypeConfig(resultType).
+                addResultTypeConfig(redirect).
+                addResultTypeConfig(ftlResultType);
+        if (scopedResultPath != null) {
+            builder.addParent(new PackageConfig.Builder("scoped-parent")
+                    
.addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, scopedResultPath)
+                    .build());
+        }
+        return builder.build();
+    }
+```
+
+Then add the tests:
+
+```java
+    @Test
+    public void testScopedResultPathFromParentPackage() {
+        ServletContext context = mockServletContext("/WEB-INF/admin");
+
+        PackageConfig packageConfig = createPackageConfigBuilder("/namespace", 
"/WEB-INF/admin/");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
+        DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
+        Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
+        verify(context, "/WEB-INF/admin", results, false);
+    }
+
+    @Test
+    public void testScopedResultPathWithoutTrailingSlash() {
+        ServletContext context = mockServletContext("/WEB-INF/admin");
+
+        PackageConfig packageConfig = createPackageConfigBuilder("/namespace", 
"/WEB-INF/admin");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
+        DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
+        Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
+        verify(context, "/WEB-INF/admin", results, false);
+    }
+
+    @Test
+    public void testResultPathAnnotationWinsOverScopedResultPath() {
+        ServletContext context = mockServletContext("/class-level");
+
+        PackageConfig packageConfig = createPackageConfigBuilder("/namespace", 
"/WEB-INF/admin/");
+        this.conventionsService = newConventionsService("/not-used");
+        DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
+        Map<String, ResultConfig> results = 
builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig);
+        verify(context, "/class-level", results, false);
+    }
+```
+
+- [ ] **Step 3: Write the failing integration test**
+
+`struts-convention-scoped-result-path.xml` (copy the license comment block from
+`struts-convention-configuration.xml`):
+
+```xml
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "https://struts.apache.org/dtds/struts-7.5.dtd";>
+
+<struts>
+    <constant name="struts.convention.result.path" value="/"/>
+
+    <package name="convention-default" extends="struts-default">
+        <scoped-constant name="struts.convention.result.path" 
value="/scoped/"/>
+    </package>
+</struts>
+```
+
+`scoped/message-success.ftl`:
+
+```
+<#--
+  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.
+-->
+<html>
+<body>
+This is the scoped view ${message}
+</body>
+</html>
+```
+
+`ConventionScopedResultPathTest.java` (ASF header; same shape as 
`ConventionJUnit4Test`):
+
+```java
+package org.apache.struts2.convention;
+
+import actions.MessageAction;
+import org.apache.struts2.junit.StrutsJUnit4TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+
+public class ConventionScopedResultPathTest extends 
StrutsJUnit4TestCase<MessageAction> {
+
+    @Test
+    public void testParentPackageScopedResultPath() throws Exception {
+        String output = executeAction("/message.action");
+
+        assertTrue(output, output.contains("This is the scoped view Hello 
World"));
+    }
+
+    @Override
+    protected String getConfigPath() {
+        return "struts-plugin.xml,struts-convention-scoped-result-path.xml";
+    }
+}
+```
+
+The root `message-success.ftl` ("This is the view …") stays where it is: if 
the override is ignored, that file
+is picked and the assertion fails.
+
+- [ ] **Step 4: Run the tests to verify they fail**
+
+```bash
+./mvnw test -DskipAssembly -pl plugins/convention 
-Dtest='DefaultResultMapBuilderTest,ConventionScopedResultPathTest' > 
/tmp/ww5770-t3.log 2>&1; echo "exit=$?"; grep -E "Tests 
run:|FAIL|testScoped|testResultPathAnnotationWins|testParentPackage" 
/tmp/ww5770-t3.log | head -20
+```
+Expected: non-zero exit; `testScopedResultPathFromParentPackage`, 
`testScopedResultPathWithoutTrailingSlash`
+and `testParentPackageScopedResultPath` fail (the EasyMock servlet context 
sees `/WEB-INF/location/namespace/`;
+the integration output is "This is the view Hello World"). 
`testResultPathAnnotationWinsOverScopedResultPath`
+already passes — that is expected, it guards precedence. If 
`ConventionScopedResultPathTest` fails at startup
+instead of on the assertion, read the cause before going on — it is the spec's 
startup-ordering check.
+
+- [ ] **Step 5: Pass the package at the call site**
+
+`DefaultResultMapBuilder.java:159`:
+
+```java
+        String defaultResultPath = 
conventionsService.determineResultPath(actionClass, packageConfig);
+```
+
+- [ ] **Step 6: Run the module to verify everything passes**
+
+```bash
+./mvnw test -DskipAssembly -pl plugins/convention > /tmp/ww5770-t3.log 2>&1; 
echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t3.log | tail -5
+```
+Expected: `exit=0`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add 
plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java
 
plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java
 
plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
 plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml 
plugins/convention/src/test/resources/scoped/message-success.ftl
+git diff --cached --name-only
+git commit -m "WW-5770 feat(convention): honour the scoped result path when 
building results
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 4: Runtime — `ConventionUnknownHandler` uses the package's result path
+
+**Files:**
+- Modify: 
`plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java`
 (lines 134, 282, 385-386)
+- Modify: 
`plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java`
+
+**Interfaces:**
+- Consumes: `ConventionsService#determineResultPath(ActionConfig, 
PackageConfig)` (Task 2).
+- Produces: `protected String determinePath(ActionConfig actionConfig, 
PackageConfig packageConfig, String namespace)`
+  replacing `determinePath(ActionConfig, String)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+In `ConventionUnknownHandlerTest` (JUnit 3 `TestCase`, EasyMock static imports 
already present), add imports
+`java.util.ArrayList`, `java.util.List`, 
`org.apache.struts2.convention.actions.NoAnnotationAction`, and these
+tests plus helpers:
+
+```java
+    private static final String DEFAULT_PARENT = "convention-default";
+
+    public void testUnknownActionUsesScopedResultPathOfDefaultParentPackage() 
throws Exception {
+        List<String> requested = new ArrayList<>();
+        ConventionUnknownHandler handler = 
scopedHandler(recordingServletContext(requested),
+                packageWithResultPath(DEFAULT_PARENT, "", "/WEB-INF/scoped/"), 
null);
+
+        handler.handleUnknownAction("", "does-not-exist");
+
+        assertFalse(requested.isEmpty());
+        for (String path : requested) {
+            assertTrue(path, 
path.startsWith("/WEB-INF/scoped/does-not-exist"));
+        }
+    }
+
+    public void testUnknownActionWithoutOverrideUsesGlobalResultPath() throws 
Exception {
+        List<String> requested = new ArrayList<>();
+        ConventionUnknownHandler handler = 
scopedHandler(recordingServletContext(requested),
+                new PackageConfig.Builder(DEFAULT_PARENT).build(), null);
+
+        handler.handleUnknownAction("", "does-not-exist");
+
+        assertFalse(requested.isEmpty());
+        for (String path : requested) {
+            assertTrue(path, 
path.startsWith("/WEB-INF/content/does-not-exist"));
+        }
+    }
+
+    public void testUnknownResultUsesScopedResultPathOfActionPackage() throws 
Exception {
+        List<String> requested = new ArrayList<>();
+        PackageConfig actionPackage = packageWithResultPath("admin", "/admin", 
"/WEB-INF/scoped/");
+        ConventionUnknownHandler handler = 
scopedHandler(recordingServletContext(requested),
+                new PackageConfig.Builder(DEFAULT_PARENT).build(), 
actionPackage);
+        ActionConfig actionConfig = new ActionConfig.Builder("admin", "foo", 
NoAnnotationAction.class.getName()).build();
+
+        handler.handleUnknownResult(null, "foo", actionConfig, "success");
+
+        assertFalse(requested.isEmpty());
+        for (String path : requested) {
+            assertTrue(path, path.startsWith("/WEB-INF/scoped/admin/"));
+        }
+    }
+
+    public void testDeterminePathAppendsSlashToScopedResultPath() {
+        PackageConfig parent = packageWithResultPath(DEFAULT_PARENT, "", 
"/WEB-INF/scoped");
+        ConventionUnknownHandler handler = scopedHandler(null, parent, null);
+
+        assertEquals("/WEB-INF/scoped/admin/", handler.determinePath(null, 
parent, "/admin"));
+    }
+
+    private ServletContext recordingServletContext(List<String> requested) 
throws Exception {
+        ServletContext servletContext = createNiceMock(ServletContext.class);
+        expect(servletContext.getResource(anyString())).andAnswer(() -> {
+            requested.add((String) getCurrentArguments()[0]);
+            return null;
+        }).anyTimes();
+        replay(servletContext);
+        return servletContext;
+    }
+
+    private static PackageConfig packageWithResultPath(String name, String 
namespace, String resultPath) {
+        PackageConfig parent = new PackageConfig.Builder(name + "-base")
+                .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, 
resultPath)
+                .build();
+        return new 
PackageConfig.Builder(name).namespace(namespace).addParent(parent).build();
+    }
+
+    private ConventionUnknownHandler scopedHandler(ServletContext 
servletContext, PackageConfig defaultParent,
+                                                   PackageConfig 
actionPackage) {
+        Configuration configuration = createNiceMock(Configuration.class);
+        
expect(configuration.getPackageConfig(DEFAULT_PARENT)).andStubReturn(defaultParent);
+        if (actionPackage != null) {
+            
expect(configuration.getPackageConfig(actionPackage.getName())).andStubReturn(actionPackage);
+        }
+        RuntimeConfiguration runtime = 
createNiceMock(RuntimeConfiguration.class);
+        expect(configuration.getRuntimeConfiguration()).andStubReturn(runtime);
+
+        ConventionsServiceImpl service = new 
ConventionsServiceImpl("/WEB-INF/content/");
+        service.setScopedConstantProvider(name -> "/WEB-INF/content/");
+        Container container = createNiceMock(Container.class);
+        expect(container.getInstance(String.class, 
ConventionConstants.CONVENTION_CONVENTIONS_SERVICE)).andStubReturn("test");
+        expect(container.getInstance(ConventionsService.class, 
"test")).andStubReturn(service);
+
+        replay(configuration, runtime, container);
+
+        return new ConventionUnknownHandler(configuration, null, 
servletContext, container, DEFAULT_PARENT,
+                "false", "-");
+    }
+```
+
+In the existing `container()` helper, replace
+
+```java
+        ActionConfig actionConfig = null;
+        expect(service.determineResultPath(actionConfig)).andReturn("");
+```
+
+with
+
+```java
+        expect(service.determineResultPath((ActionConfig) null, 
packageConfiguration)).andReturn("");
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+./mvnw test -DskipAssembly -pl plugins/convention 
-Dtest=ConventionUnknownHandlerTest > /tmp/ww5770-t4.log 2>&1; echo "exit=$?"; 
grep -E "ERROR|Tests run|FAIL" /tmp/ww5770-t4.log | head -20
+```
+Expected: non-zero exit — compilation error on the three-argument 
`determinePath`. (Without that test the two
+`ScopedResultPath` tests fail on paths starting `/WEB-INF/content/`.)
+
+- [ ] **Step 3: Change `determinePath` and its two callers**
+
+```java
+    protected String determinePath(ActionConfig actionConfig, PackageConfig 
packageConfig, String namespace) {
+        String finalPrefix = 
conventionsService.determineResultPath(actionConfig, packageConfig);
+```
+
+Add to its Javadoc, after `@param actionConfig`:
+
+```java
+     * @param packageConfig The package whose scoped result path applies: the 
action's package, or the default
+     *                      parent package when there is no action.
+```
+
+Line 134 in `handleUnknownAction`:
+
+```java
+        String pathPrefix = determinePath(null, parentPackage, namespace);
+```
+
+Line 282 in `handleUnknownResult`:
+
+```java
+        String pathPrefix = determinePath(actionConfig, pkg, ns);
+```
+
+- [ ] **Step 4: Run the module to verify everything passes**
+
+```bash
+./mvnw test -DskipAssembly -pl plugins/convention > /tmp/ww5770-t4.log 2>&1; 
echo "exit=$?"; grep -E "Tests run:|FAIL" /tmp/ww5770-t4.log | tail -5
+```
+Expected: `exit=0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add 
plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java
 
plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java
+git diff --cached --name-only
+git commit -m "WW-5770 feat(convention): honour the scoped result path for 
unknown actions and results
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+### Task 5: Full verification
+
+- [ ] **Step 1: Compile the whole reactor and run core + Convention tests**
+
+```bash
+./mvnw install -DskipAssembly -DskipTests > /tmp/ww5770-reactor.log 2>&1; echo 
"exit=$?"
+./mvnw test -DskipAssembly -pl core,plugins/convention,plugins/javatemplates > 
/tmp/ww5770-tests.log 2>&1; echo "exit=$?"; grep -E "Tests run:.*Fail|BUILD" 
/tmp/ww5770-tests.log | tail -8
+```
+Expected: both `exit=0`. The javatemplates module proves its `name -> null` 
lambdas still compile.
+
+- [ ] **Step 2: Check for stray files**
+
+```bash
+git status --short
+```
+Expected: no `??` entries from this work (review agents leave probe tests 
behind).
+
+---
+
+### Task 6: struts-site documentation
+
+Separate repository: `~/Projects/Apache/struts-site`, branch 
`WW-5770-scoped-convention-result-path` from `main`.
+
+**Files:**
+- Modify: `source/core-developers/package-configuration.md` (the list after 
"Since Struts 7.5.0 these core constants are scopable:", line ~120)
+- Modify: `source/plugins/convention/index.md` (end of "### ResultPath 
annotation", line ~641)
+
+- [ ] **Step 1: List the constant on the package-configuration page**
+
+After the core-constants bullet list, add:
+
+```markdown
+The [Convention plugin](../plugins/convention/) makes 
`struts.convention.result.path` scopable, see
+[Result path per package](../plugins/convention/#result-path-per-package).
+```
+
+- [ ] **Step 2: Document it on the Convention page**
+
+After the last paragraph of "### ResultPath annotation":
+
+~~~markdown
+#### Result path per package
+
+Since Struts 7.5.0 a parent package can set the result path for all actions 
placed under it with a
+[scoped 
constant](../../core-developers/package-configuration#scoped-constants):
+
+```xml
+<package name="admin-base" extends="convention-default">
+    <scoped-constant name="struts.convention.result.path" 
value="/WEB-INF/admin/"/>
+</package>
+```
+
+The result path of an action is taken from, in order:
+
+1. `@ResultPath` on the action class or its Java package
+2. the `struts.convention.result.path` scoped constant of the action's 
package, including inherited ones
+3. the global `struts.convention.result.path` constant
+
+A request without an action class, served straight from a template, uses the 
scoped constant of the default
+parent package (`struts.convention.default.parent.package`).
+~~~
+
+- [ ] **Step 3: Check both anchors exist**
+
+```bash
+grep -n "^## Scoped constants" 
~/Projects/Apache/struts-site/source/core-developers/package-configuration.md
+grep -n "^#### Result path per package" 
~/Projects/Apache/struts-site/source/plugins/convention/index.md
+```
+Expected: one hit each, matching the `#scoped-constants` and 
`#result-path-per-package` link targets.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git -C ~/Projects/Apache/struts-site add 
source/core-developers/package-configuration.md 
source/plugins/convention/index.md
+git -C ~/Projects/Apache/struts-site commit -m "WW-5770 docs: document the 
scopable Convention result path
+
+Co-Authored-By: Claude Opus 5.5 <[email protected]>"
+```
+
+---
+
+## After the tasks
+
+- Run `/security-review` on the struts branch diff before `gh pr create` 
(CLAUDE.md), reading `SECURITY.md` and
+  `THREAT_MODEL.md` first.
+- PR titles: `WW-5770 Make struts.convention.result.path scopable per package` 
(struts) and
+  `WW-5770 docs: document the scopable Convention result path` (struts-site); 
body links
+  `Fixes [WW-5770](https://issues.apache.org/jira/browse/WW-5770)`.
+- 7.5.0 migration notes (Version Notes, at release time): new default method
+  `ScopedConstantProvider.getValue(String, PackageConfig)`; 
`ConventionUnknownHandler.determinePath` signature change.
diff --git 
a/docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md
 
b/docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md
new file mode 100644
index 000000000..f0ef78243
--- /dev/null
+++ 
b/docs/superpowers/specs/2026-10-14-WW-5770-scoped-convention-result-path-design.md
@@ -0,0 +1,121 @@
+# WW-5770 — Scopable `struts.convention.result.path`
+
+**Ticket:** [WW-5770](https://issues.apache.org/jira/browse/WW-5770) — *Make 
struts.convention.result.path scopable per package*
+**Target:** 7.5.0 (ticket fix version)
+**Date:** 2026-10-14
+**Status:** Implemented — [PR 
#2009](https://github.com/apache/struts/pull/2009)
+
+## Problem
+
+WW-3476 added `<scoped-constant>`; WW-5768 made the UI theme its first 
adopter. Convention needs more than a
+`ScopableConstants` bean, because `ScopedConstantProvider.getValue(name)` 
resolves the package from the current
+`ActionInvocation`, and Convention reads the result path where there is none:
+
+1. **Startup** — `DefaultResultMapBuilder.build(...)` → 
`ConventionsService.determineResultPath(Class)`. No
+   invocation exists, so `getValue(name)` always returns the global value. 
`build()` does receive the action's
+   `PackageConfig` (`PackageBasedActionConfigBuilder` passes `pkgCfg.build()`, 
created with `addParent(parentPkg)`),
+   and its `getAllScopedConstants()` already includes the parent's overrides.
+2. **Runtime** — `ConventionUnknownHandler.determinePath(...)` → 
`determineResultPath(ActionConfig)`, where the
+   `ActionConfig` is `null` for an action-less request.
+
+## Goals
+
+- A parent package sets the Convention result path for every action under it:
+
+  ```xml
+  <package name="admin-base" extends="convention-default">
+      <scoped-constant name="struts.convention.result.path" 
value="/WEB-INF/admin/"/>
+  </package>
+  ```
+
+- Precedence: `@ResultPath` on the action class or package → the package's 
scoped constant (inherited ones
+  included) → the global `struts.convention.result.path`.
+- Custom `ConventionsService` implementations keep compiling and behaving as 
before.
+- A swapped-in `struts.scopedConstantProvider` governs the result path too, as 
it does the UI theme.
+
+## Non-goals
+
+- No deprecation of the one-argument `determineResultPath` methods: they still 
mean "the path without package
+  scope".
+- No namespace-based package lookup for action-less requests (see Decisions).
+
+## Decisions
+
+- **Action-less requests use the default parent package.** 
`handleUnknownAction` has no `ActionConfig`; it resolves
+  the scoped value against `struts.convention.default.parent.package`, the 
package it already uses for result
+  types. An override on another package does not apply to action-less JSPs 
under its namespace. Namespace lookup
+  was rejected: several packages can share a namespace and Convention 
generates one per parent + namespace.
+- **Resolve through `ScopedConstantProvider`, not `PackageConfig` directly.** 
Reading
+  `getAllScopedConstants()` in Convention would bypass a custom provider and 
duplicate the lookup.
+- **`ConventionsServiceImpl` keeps its constructor.** The injected global 
`resultPath` stays the fallback for a
+  `null` package; the provider is called only when there is a package. Test 
sites that pass a package
+  (all of `DefaultResultMapBuilderTest`) inject a lambda provider through a 
helper.
+- **The new provider method is `default`.** `ScopedConstantProvider` is 
stubbed as a lambda in 23 test sites
+  (`name -> null`); an abstract method would break them and any lambda-based 
user provider. The default returns
+  the package's value when it declares one, else `getValue(name)`. 
`StrutsScopedConstantProvider` overrides it:
+  a non-null package without the constant gives the global value, never the 
current invocation's package — a
+  nested request (`<s:action>`, chain) reaches `handleUnknownAction` with the 
caller's invocation still bound.
+  A `null` package still means `getValue(name)`.
+
+## Design
+
+### Core — `org.apache.struts2.config`
+
+- `ScopedConstantProvider` gains `default String getValue(String name, 
PackageConfig packageConfig)`,
+  `@since 7.5.0`: the package's `getAllScopedConstants()` value when present, 
else `getValue(name)`. A `null`
+  package goes straight to `getValue(name)`. The interface stays functional.
+- `StrutsScopedConstantProvider` overrides it: name check, then the package's 
value or the global value; a `null`
+  package delegates to `getValue(name)`.
+
+### Convention plugin
+
+- `ConventionScopableConstants implements ScopableConstants` returns 
`Set.of(CONVENTION_RESULT_PATH)`, registered
+  in `struts-plugin.xml` as a named `ScopableConstants` bean (pattern: 
`StrutsUiScopableConstants`).
+- `ConventionsService` gains two `default` methods:
+  - `determineResultPath(Class<?> actionClass, PackageConfig packageConfig)` → 
`determineResultPath(actionClass)`
+  - `determineResultPath(ActionConfig actionConfig, PackageConfig 
packageConfig)` → `determineResultPath(actionConfig)`
+- `ConventionsServiceImpl`:
+  - `@Inject setScopedConstantProvider(ScopedConstantProvider)` — setter, so 
the constructor is unchanged.
+  - `@ResultPath` handling extracted to a private 
`findAnnotatedResultPath(Class)`.
+  - Two-argument `Class` form: annotation if present; else, with a package,
+    `scopedConstantProvider.getValue(CONVENTION_RESULT_PATH, packageConfig)`; 
with a `null` package, `resultPath`.
+  - Two-argument `ActionConfig` form: `null` action → the package's scoped 
value (or `resultPath` for a `null`
+    package); otherwise load the class and delegate, keeping the 
`ClassNotFoundException` handling.
+  - One-argument forms are unchanged in behaviour.
+- `DefaultResultMapBuilder.build(...)` calls `determineResultPath(actionClass, 
packageConfig)`.
+- `ConventionUnknownHandler.determinePath(ActionConfig, PackageConfig, String 
namespace)` — signature change
+  (protected): `handleUnknownAction` passes `parentPackage`; 
`handleUnknownResult` passes the `pkg` it already
+  resolves from `actionConfig.getPackageName()`.
+
+### Error handling
+
+Nothing new. A misspelled name, or the constant used without the Convention 
plugin, already fails at startup in
+`DefaultConfiguration.validateScopedConstants`. `${...}` substitution applies 
via WW-5767. A scoped value is
+treated exactly like the global one, including trailing-slash handling.
+
+### Startup ordering — verify, don't assume
+
+`PackageBasedActionConfigBuilder` runs inside `loadPackages`; 
`StrutsScopedConstantProvider.setContainer` fetches
+`Configuration` and collects scopable names when injected. The integration 
test below proves both are available at
+that point, with the Convention `ScopableConstants` bean already registered.
+
+## Testing
+
+Each test goes into the existing file, in that file's style.
+
+| File (style) | Cases |
+|---|---|
+| `StrutsScopedConstantProviderTest` (`XWorkTestCase`) | `getValue(name, pkg)` 
returns an inherited override; an explicit package wins over the invocation's; 
a package without the constant and a `null` package fall back to 
`getValue(name)`; non-scopable name throws; a lambda provider gets the package 
lookup |
+| `ConventionsServiceImplTest` — new (JUnit 4) | `@ResultPath` beats the 
override; override beats global; `null` package → global |
+| `DefaultResultMapBuilderTest` (JUnit 4) | parent-package override moves 
results located at startup; no override → global path |
+| `ConventionUnknownHandlerTest` (`TestCase`, EasyMock) | action-less request 
uses the default parent package's override; `handleUnknownResult` uses the 
action package's override; existing `determineResultPath(actionConfig)` 
expectations move to the two-argument form |
+| Startup integration (harness located during planning) | the real convention 
`struts-plugin.xml` plus an XML parent package declaring the constant boots; a 
Convention action under it gets the overridden locations |
+
+## Docs and migration
+
+- **struts-site:** list the constant in the scoped-constants section of 
`core-developers/package-configuration.md`;
+  add a section to the Convention plugin page showing the precedence.
+- **7.5.0 migration notes:** new default method 
`ScopedConstantProvider.getValue(String, PackageConfig)` (custom
+  providers may override it); `ConventionUnknownHandler.determinePath` 
signature change (an override without
+  `@Override` silently stops being called); subclasses of 
`ConventionsServiceImpl` overriding the one-argument
+  `determineResultPath` methods must override the two-argument forms, which 
the framework now calls.
diff --git 
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
similarity index 60%
copy from 
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
copy to 
plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
index 53e655483..624a76b00 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionScopableConstants.java
@@ -16,20 +16,21 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.config;
+package org.apache.struts2.convention;
+
+import org.apache.struts2.config.ScopableConstants;
+
+import java.util.Set;
 
 /**
- * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
- * through {@code extends}, or else the global constant.
+ * Lets a package override the Convention result path with {@code 
<scoped-constant>}.
  *
  * @since 7.5.0
  */
-public interface ScopedConstantProvider {
+public class ConventionScopableConstants implements ScopableConstants {
 
-    /**
-     * @param name a constant registered through a {@link ScopableConstants} 
bean
-     * @return the scoped value, or the global value ({@code null} when there 
is none)
-     * @throws IllegalArgumentException when {@code name} is not scopable
-     */
-    String getValue(String name);
+    @Override
+    public Set<String> getNames() {
+        return Set.of(ConventionConstants.CONVENTION_RESULT_PATH);
+    }
 }
diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java
index fd95c87d5..b63b3a0f8 100644
--- 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionUnknownHandler.java
@@ -131,7 +131,7 @@ public class ConventionUnknownHandler implements 
UnknownHandler {
         }
 
         Map<String, ResultTypeConfig> resultsByExtension = 
conventionsService.getResultTypesByExtension(parentPackage);
-        String pathPrefix = determinePath(null, namespace);
+        String pathPrefix = determinePath(null, parentPackage, namespace);
         ActionConfig actionConfig = null;
 
         // Try /idx/action.jsp if actionName is not empty, otherwise it will 
just be /.jsp
@@ -279,7 +279,7 @@ public class ConventionUnknownHandler implements 
UnknownHandler {
 
         PackageConfig pkg = 
configuration.getPackageConfig(actionConfig.getPackageName());
         String ns = pkg.getNamespace();
-        String pathPrefix = determinePath(actionConfig, ns);
+        String pathPrefix = determinePath(actionConfig, pkg, ns);
 
         Result result = scanResultsByExtension(ns, actionName, pathPrefix, 
resultCode, actionContext);
 
@@ -379,11 +379,13 @@ public class ConventionUnknownHandler implements 
UnknownHandler {
      *
      * @param actionConfig (Optional) The might be a ConventionActionConfig, 
from which we can get the
      *                     default base result location of that specific 
action.
+     * @param packageConfig The package whose scoped result path applies: the 
action's package, or the default
+     *                      parent package when there is no action.
      * @param namespace    The current URL namespace.
      * @return The path prefix and never null.
      */
-    protected String determinePath(ActionConfig actionConfig, String 
namespace) {
-        String finalPrefix = 
conventionsService.determineResultPath(actionConfig);
+    protected String determinePath(ActionConfig actionConfig, PackageConfig 
packageConfig, String namespace) {
+        String finalPrefix = 
conventionsService.determineResultPath(actionConfig, packageConfig);
 
         if (!finalPrefix.endsWith("/")) {
             finalPrefix += "/";
diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java
index 7a3ef6ec8..816f97d1e 100644
--- 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsService.java
@@ -54,6 +54,32 @@ public interface ConventionsService {
      */
     String determineResultPath(ActionConfig actionConfig);
 
+    /**
+     * Locates the result location like {@link #determineResultPath(Class)}, 
using the package's scoped
+     * {@code struts.convention.result.path} when the action carries no {@code 
@ResultPath}.
+     *
+     * @param   actionClass The action class.
+     * @param   packageConfig The package the action belongs to, or null for 
the global default.
+     * @return  The result location.
+     * @since 7.5.0
+     */
+    default String determineResultPath(Class<?> actionClass, PackageConfig 
packageConfig) {
+        return determineResultPath(actionClass);
+    }
+
+    /**
+     * Locates the result location like {@link 
#determineResultPath(ActionConfig)}, using the package's scoped
+     * {@code struts.convention.result.path} when the action carries no {@code 
@ResultPath}.
+     *
+     * @param   actionConfig (Optional) The configuration for the action, or 
null when there is no action.
+     * @param   packageConfig The package whose scoped value applies, or null 
for the global default.
+     * @return  The result location.
+     * @since 7.5.0
+     */
+    default String determineResultPath(ActionConfig actionConfig, 
PackageConfig packageConfig) {
+        return determineResultPath(actionConfig);
+    }
+
     /**
      * Returns a mapping between the result type strings and the {@link 
ResultTypeConfig} instances
      * based on the {@link PackageConfig} given.
diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java
index fb58c4dbd..91b14f149 100644
--- 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ConventionsServiceImpl.java
@@ -19,6 +19,7 @@
 package org.apache.struts2.convention;
 
 import org.apache.struts2.config.ConfigurationException;
+import org.apache.struts2.config.ScopedConstantProvider;
 import org.apache.struts2.config.entities.ActionConfig;
 import org.apache.struts2.config.entities.PackageConfig;
 import org.apache.struts2.config.entities.ResultTypeConfig;
@@ -39,6 +40,7 @@ import java.util.ResourceBundle;
  */
 public class ConventionsServiceImpl implements ConventionsService {
     private String resultPath;
+    private ScopedConstantProvider scopedConstantProvider;
 
     /**
      * Constructs a new instance.
@@ -51,54 +53,84 @@ public class ConventionsServiceImpl implements 
ConventionsService {
         this.resultPath = resultPath;
     }
 
+    @Inject
+    public void setScopedConstantProvider(ScopedConstantProvider 
scopedConstantProvider) {
+        this.scopedConstantProvider = scopedConstantProvider;
+    }
+
     /**
      * {@inheritDoc}
      */
     public String determineResultPath(Class<?> actionClass) {
-        String localResultPath = resultPath;
-        ResultPath resultPathAnnotation = 
AnnotationUtils.findAnnotation(actionClass, ResultPath.class);
-        if (resultPathAnnotation != null) {
-            if (resultPathAnnotation.value().equals("") && 
resultPathAnnotation.property().equals("")) {
-                throw new ConfigurationException("The ResultPath annotation 
must have either" +
-                    " a value or property specified.");
-            }
-
-            String property = resultPathAnnotation.property();
-            if (property.equals("")) {
-                localResultPath = resultPathAnnotation.value();
-            } else {
-                try {
-                    ResourceBundle strutsBundle = 
ResourceBundle.getBundle("struts");
-                    localResultPath = strutsBundle.getString(property);
-                } catch (Exception e) {
-                    throw new ConfigurationException("The action class [" + 
actionClass + "] defines" +
-                        " a @ResultPath annotation and a property definition 
however the" +
-                        " struts.properties could not be found in the 
classpath using ResourceBundle" +
-                        " OR the bundle exists but the property [" + property 
+ "] is not defined" +
-                        " in the file.", e);
-                }
-            }
-        }
+        return determineResultPath(actionClass, null);
+    }
 
-        return localResultPath;
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String determineResultPath(Class<?> actionClass, PackageConfig 
packageConfig) {
+        String annotatedResultPath = findAnnotatedResultPath(actionClass);
+        return annotatedResultPath != null ? annotatedResultPath : 
scopedResultPath(packageConfig);
     }
 
     /**
      * {@inheritDoc}
      */
     public String determineResultPath(ActionConfig actionConfig) {
+        return determineResultPath(actionConfig, null);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String determineResultPath(ActionConfig actionConfig, PackageConfig 
packageConfig) {
         if (actionConfig == null) {
-            return resultPath;
+            return scopedResultPath(packageConfig);
         }
 
         try {
-            return  
determineResultPath(ClassLoaderUtil.loadClass(actionConfig.getClassName(), 
this.getClass()));
+            return 
determineResultPath(ClassLoaderUtil.loadClass(actionConfig.getClassName(), 
this.getClass()), packageConfig);
         } catch (ClassNotFoundException e) {
             throw new RuntimeException("Invalid action class configuration 
that references an unknown " +
                 "class named [" + actionConfig.getClassName() + "]", e);
         }
     }
 
+    private String scopedResultPath(PackageConfig packageConfig) {
+        if (packageConfig == null) {
+            return resultPath;
+        }
+        return 
scopedConstantProvider.getValue(ConventionConstants.CONVENTION_RESULT_PATH, 
packageConfig);
+    }
+
+    private String findAnnotatedResultPath(Class<?> actionClass) {
+        ResultPath resultPathAnnotation = 
AnnotationUtils.findAnnotation(actionClass, ResultPath.class);
+        if (resultPathAnnotation == null) {
+            return null;
+        }
+        if (resultPathAnnotation.value().equals("") && 
resultPathAnnotation.property().equals("")) {
+            throw new ConfigurationException("The ResultPath annotation must 
have either" +
+                " a value or property specified.");
+        }
+
+        String property = resultPathAnnotation.property();
+        if (property.equals("")) {
+            return resultPathAnnotation.value();
+        }
+        try {
+            ResourceBundle strutsBundle = ResourceBundle.getBundle("struts");
+            return strutsBundle.getString(property);
+        } catch (Exception e) {
+            throw new ConfigurationException("The action class [" + 
actionClass + "] defines" +
+                " a @ResultPath annotation and a property definition however 
the" +
+                " struts.properties could not be found in the classpath using 
ResourceBundle" +
+                " OR the bundle exists but the property [" + property + "] is 
not defined" +
+                " in the file.", e);
+        }
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java
index 380b77994..2eac72671 100644
--- 
a/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/DefaultResultMapBuilder.java
@@ -156,7 +156,7 @@ public class DefaultResultMapBuilder implements 
ResultMapBuilder {
                                            PackageConfig packageConfig) {
 
         // Get the default result location from the annotation or configuration
-        String defaultResultPath = 
conventionsService.determineResultPath(actionClass);
+        String defaultResultPath = 
conventionsService.determineResultPath(actionClass, packageConfig);
 
         // Add a slash
         if (!defaultResultPath.endsWith("/")) {
diff --git a/plugins/convention/src/main/resources/struts-plugin.xml 
b/plugins/convention/src/main/resources/struts-plugin.xml
index afafe0f66..acffb1616 100644
--- a/plugins/convention/src/main/resources/struts-plugin.xml
+++ b/plugins/convention/src/main/resources/struts-plugin.xml
@@ -38,6 +38,8 @@
           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.ScopableConstants" name="convention"
+          class="org.apache.struts2.convention.ConventionScopableConstants"/>
 
     <bean type="org.apache.struts2.config.PackageProvider" 
name="convention.packageProvider"
           class="org.apache.struts2.convention.ClasspathPackageProvider"/>
diff --git 
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
similarity index 55%
copy from 
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
copy to 
plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
index 53e655483..2d836da4b 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionScopedResultPathTest.java
@@ -16,20 +16,25 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.config;
+package org.apache.struts2.convention;
 
-/**
- * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
- * through {@code extends}, or else the global constant.
- *
- * @since 7.5.0
- */
-public interface ScopedConstantProvider {
+import actions.MessageAction;
+import org.apache.struts2.junit.StrutsJUnit4TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+
+public class ConventionScopedResultPathTest extends 
StrutsJUnit4TestCase<MessageAction> {
+
+    @Test
+    public void testParentPackageScopedResultPath() throws Exception {
+        String output = executeAction("/message.action");
+
+        assertTrue(output, output.contains("This is the scoped view Hello 
World"));
+    }
 
-    /**
-     * @param name a constant registered through a {@link ScopableConstants} 
bean
-     * @return the scoped value, or the global value ({@code null} when there 
is none)
-     * @throws IllegalArgumentException when {@code name} is not scopable
-     */
-    String getValue(String name);
+    @Override
+    protected String getConfigPath() {
+        return "struts-plugin.xml,struts-convention-scoped-result-path.xml";
+    }
 }
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java
index ea33f10cd..65360f262 100644
--- 
a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionUnknownHandlerTest.java
@@ -23,14 +23,17 @@ import org.apache.struts2.config.RuntimeConfiguration;
 import org.apache.struts2.config.entities.ActionConfig;
 import org.apache.struts2.config.entities.PackageConfig;
 import org.apache.struts2.config.entities.ResultTypeConfig;
+import org.apache.struts2.convention.actions.NoAnnotationAction;
 import org.apache.struts2.inject.Container;
 import junit.framework.TestCase;
 import org.apache.struts2.result.ServletDispatcherResult;
 
 import jakarta.servlet.ServletContext;
 import java.net.URL;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -38,6 +41,8 @@ import static org.easymock.EasyMock.*;
 
 public class ConventionUnknownHandlerTest extends TestCase {
 
+    private static final String DEFAULT_PARENT = "convention-default";
+
     private PackageConfig packageConfiguration;
 
     public void testCanonicalizeShouldReturnNullWhenPathIsNull() throws 
Exception {
@@ -130,6 +135,93 @@ public class ConventionUnknownHandlerTest extends TestCase 
{
         assertEquals("/canonicalized/path.default_extension", resource.path);
     }
 
+    public void testUnknownActionUsesScopedResultPathOfDefaultParentPackage() 
throws Exception {
+        List<String> requested = new ArrayList<>();
+        ConventionUnknownHandler handler = 
scopedHandler(recordingServletContext(requested),
+                packageWithResultPath(DEFAULT_PARENT, "", "/WEB-INF/scoped/"), 
null);
+
+        handler.handleUnknownAction("", "does-not-exist");
+
+        assertFalse(requested.isEmpty());
+        for (String path : requested) {
+            assertTrue(path, 
path.startsWith("/WEB-INF/scoped/does-not-exist"));
+        }
+    }
+
+    public void testUnknownActionWithoutOverrideUsesGlobalResultPath() throws 
Exception {
+        List<String> requested = new ArrayList<>();
+        ConventionUnknownHandler handler = 
scopedHandler(recordingServletContext(requested),
+                new PackageConfig.Builder(DEFAULT_PARENT).build(), null);
+
+        handler.handleUnknownAction("", "does-not-exist");
+
+        assertFalse(requested.isEmpty());
+        for (String path : requested) {
+            assertTrue(path, 
path.startsWith("/WEB-INF/content/does-not-exist"));
+        }
+    }
+
+    public void testUnknownResultUsesScopedResultPathOfActionPackage() throws 
Exception {
+        List<String> requested = new ArrayList<>();
+        PackageConfig actionPackage = packageWithResultPath("admin", "/admin", 
"/WEB-INF/scoped/");
+        ConventionUnknownHandler handler = 
scopedHandler(recordingServletContext(requested),
+                new PackageConfig.Builder(DEFAULT_PARENT).build(), 
actionPackage);
+        ActionConfig actionConfig = new ActionConfig.Builder("admin", "foo", 
NoAnnotationAction.class.getName()).build();
+
+        handler.handleUnknownResult(null, "foo", actionConfig, "success");
+
+        assertFalse(requested.isEmpty());
+        for (String path : requested) {
+            assertTrue(path, path.startsWith("/WEB-INF/scoped/admin/"));
+        }
+    }
+
+    public void testDeterminePathAppendsSlashToScopedResultPath() {
+        PackageConfig parent = packageWithResultPath(DEFAULT_PARENT, "", 
"/WEB-INF/scoped");
+        ConventionUnknownHandler handler = scopedHandler(null, parent, null);
+
+        assertEquals("/WEB-INF/scoped/admin/", handler.determinePath(null, 
parent, "/admin"));
+    }
+
+    private ServletContext recordingServletContext(List<String> requested) 
throws Exception {
+        ServletContext servletContext = createNiceMock(ServletContext.class);
+        expect(servletContext.getResource(anyString())).andAnswer(() -> {
+            requested.add((String) getCurrentArguments()[0]);
+            return null;
+        }).anyTimes();
+        replay(servletContext);
+        return servletContext;
+    }
+
+    private static PackageConfig packageWithResultPath(String name, String 
namespace, String resultPath) {
+        PackageConfig parent = new PackageConfig.Builder(name + "-base")
+                .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, 
resultPath)
+                .build();
+        return new 
PackageConfig.Builder(name).namespace(namespace).addParent(parent).build();
+    }
+
+    private ConventionUnknownHandler scopedHandler(ServletContext 
servletContext, PackageConfig defaultParent,
+                                                   PackageConfig 
actionPackage) {
+        Configuration configuration = createNiceMock(Configuration.class);
+        
expect(configuration.getPackageConfig(DEFAULT_PARENT)).andStubReturn(defaultParent);
+        if (actionPackage != null) {
+            
expect(configuration.getPackageConfig(actionPackage.getName())).andStubReturn(actionPackage);
+        }
+        RuntimeConfiguration runtime = 
createNiceMock(RuntimeConfiguration.class);
+        expect(configuration.getRuntimeConfiguration()).andStubReturn(runtime);
+
+        ConventionsServiceImpl service = new 
ConventionsServiceImpl("/WEB-INF/content/");
+        service.setScopedConstantProvider(name -> "/WEB-INF/content/");
+        Container container = createNiceMock(Container.class);
+        expect(container.getInstance(String.class, 
ConventionConstants.CONVENTION_CONVENTIONS_SERVICE)).andStubReturn("test");
+        expect(container.getInstance(ConventionsService.class, 
"test")).andStubReturn(service);
+
+        replay(configuration, runtime, container);
+
+        return new ConventionUnknownHandler(configuration, null, 
servletContext, container, DEFAULT_PARENT,
+                "false", "-");
+    }
+
     private Configuration configuration(final String packageName) {
         final Configuration mock = createNiceMock(Configuration.class);
 
@@ -151,8 +243,7 @@ public class ConventionUnknownHandlerTest extends TestCase {
         expect(mock.getInstance(String.class, 
ConventionConstants.CONVENTION_CONVENTIONS_SERVICE)).andReturn("test");
         expect(mock.getInstance(ConventionsService.class, 
"test")).andStubReturn(service);
 
-        ActionConfig actionConfig = null;
-        expect(service.determineResultPath(actionConfig)).andReturn("");
+        expect(service.determineResultPath((ActionConfig) null, 
packageConfiguration)).andReturn("");
         Map<String, ResultTypeConfig> results = new HashMap<>();
         results.put("jsp", new ResultTypeConfig.Builder("dispatcher", 
ServletDispatcherResult.class.getName()).build());
         
expect(service.getResultTypesByExtension(packageConfiguration)).andReturn(results);
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java
new file mode 100644
index 000000000..228b99e75
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ConventionsServiceImplTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.entities.ResultTypeConfig;
+import org.apache.struts2.convention.actions.NoAnnotationAction;
+import 
org.apache.struts2.convention.actions.resultpath.ClassLevelResultPathAction;
+import 
org.apache.struts2.convention.actions.resultpath.PackageLevelResultPathAction;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class ConventionsServiceImplTest {
+
+    private static final String GLOBAL = "/WEB-INF/content/";
+    private static final String SCOPED = "/WEB-INF/admin/";
+
+    private ConventionsServiceImpl service;
+    private PackageConfig scopedPackage;
+    private PackageConfig plainPackage;
+
+    @Before
+    public void setUp() {
+        service = new ConventionsServiceImpl(GLOBAL);
+        service.setScopedConstantProvider(name -> GLOBAL);
+        scopedPackage = childOf(SCOPED);
+        plainPackage = new PackageConfig.Builder("plain").build();
+    }
+
+    @Test
+    public void scopedValueOfParentPackageWinsOverGlobal() {
+        assertEquals(SCOPED, 
service.determineResultPath(NoAnnotationAction.class, scopedPackage));
+    }
+
+    @Test
+    public void classLevelResultPathWinsOverScopedValue() {
+        assertEquals("/class-level", 
service.determineResultPath(ClassLevelResultPathAction.class, scopedPackage));
+    }
+
+    @Test
+    public void packageLevelResultPathWinsOverScopedValue() {
+        assertEquals("/package-level", 
service.determineResultPath(PackageLevelResultPathAction.class, scopedPackage));
+    }
+
+    @Test
+    public void packageWithoutScopedValueGivesGlobal() {
+        assertEquals(GLOBAL, 
service.determineResultPath(NoAnnotationAction.class, plainPackage));
+    }
+
+    @Test
+    public void emptyScopedValueIsUsedAsIs() {
+        assertEquals("", service.determineResultPath(NoAnnotationAction.class, 
childOf("")));
+    }
+
+    @Test
+    public void nullPackageGivesGlobalWithoutConsultingProvider() {
+        ConventionsServiceImpl withoutProvider = new 
ConventionsServiceImpl(GLOBAL);
+
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath(NoAnnotationAction.class, null));
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath(NoAnnotationAction.class));
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath((ActionConfig) null, null));
+        assertEquals(GLOBAL, 
withoutProvider.determineResultPath((ActionConfig) null));
+    }
+
+    @Test
+    public void actionlessRequestUsesScopedValueOfPackage() {
+        assertEquals(SCOPED, service.determineResultPath((ActionConfig) null, 
scopedPackage));
+    }
+
+    @Test
+    public void actionConfigResolvesThroughItsClass() {
+        ActionConfig annotated = 
actionConfig(ClassLevelResultPathAction.class.getName());
+        ActionConfig plain = actionConfig(NoAnnotationAction.class.getName());
+
+        assertEquals("/class-level", service.determineResultPath(annotated, 
scopedPackage));
+        assertEquals(SCOPED, service.determineResultPath(plain, 
scopedPackage));
+    }
+
+    @Test
+    public void unknownActionClassStillFails() {
+        ActionConfig missing = actionConfig("com.example.MissingAction");
+
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.determineResultPath(missing, scopedPackage));
+        assertTrue(e.getMessage().contains("[com.example.MissingAction]"));
+    }
+
+    @Test
+    public void customServiceWithOnlyOneArgumentMethodsKeepsItsBehaviour() {
+        ConventionsService custom = new ConventionsService() {
+            @Override
+            public String determineResultPath(Class<?> actionClass) {
+                return "/custom/";
+            }
+
+            @Override
+            public String determineResultPath(ActionConfig actionConfig) {
+                return "/custom/";
+            }
+
+            @Override
+            public Map<String, ResultTypeConfig> 
getResultTypesByExtension(PackageConfig packageConfig) {
+                return Collections.emptyMap();
+            }
+        };
+
+        assertEquals("/custom/", 
custom.determineResultPath(NoAnnotationAction.class, scopedPackage));
+        assertEquals("/custom/", custom.determineResultPath((ActionConfig) 
null, scopedPackage));
+    }
+
+    @Test
+    public void resultPathIsScopable() {
+        assertEquals(Set.of(ConventionConstants.CONVENTION_RESULT_PATH), new 
ConventionScopableConstants().getNames());
+    }
+
+    private static PackageConfig childOf(String scopedResultPath) {
+        PackageConfig parent = new PackageConfig.Builder("admin-base")
+                .addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, 
scopedResultPath)
+                .build();
+        return new 
PackageConfig.Builder("admin-base#/admin").namespace("/admin").addParent(parent).build();
+    }
+
+    private static ActionConfig actionConfig(String className) {
+        return new ActionConfig.Builder("admin-base#/admin", "foo", 
className).build();
+    }
+}
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java
index 53e93f670..0a4351e6e 100644
--- 
a/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/DefaultResultMapBuilderTest.java
@@ -64,7 +64,7 @@ public class DefaultResultMapBuilderTest {
 
         // Test with a slash
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
         verify(context, "/WEB-INF/location", results, false);
@@ -72,7 +72,7 @@ public class DefaultResultMapBuilderTest {
         // Test without a slash
         context = mockServletContext("/WEB-INF/location");
         packageConfig = createPackageConfigBuilder("namespace");
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         builder = new DefaultResultMapBuilder(context, container, 
"dispatcher,velocity,freemarker");
         results = builder.build(NoAnnotationAction.class, null, "action", 
packageConfig);
         verify(context, "/WEB-INF/location", results, false);
@@ -84,7 +84,7 @@ public class DefaultResultMapBuilderTest {
 
         // Test with a slash
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Action actionAnn = 
OverrideResultAction.class.getMethod("execute").getAnnotation(Action.class);
         Map<String, ResultConfig> results = 
builder.build(OverrideResultAction.class, actionAnn, "action100", 
packageConfig);
@@ -97,7 +97,7 @@ public class DefaultResultMapBuilderTest {
     public void testGlobalResult() {
 
         ServletContext context = mockServletContext("/WEB-INF/location");
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
 
         ResultTypeConfig resultType = new 
ResultTypeConfig.Builder("dispatcher", ServletDispatcherResult.class.getName()).
@@ -131,7 +131,7 @@ public class DefaultResultMapBuilderTest {
         EasyMock.expect(context.getResourcePaths(resultPath + 
"/namespace/")).andReturn(resources);
         EasyMock.replay(context);
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
 
         ResultTypeConfig resultType = new 
ResultTypeConfig.Builder("dispatcher", ServletDispatcherResult.class.getName()).
@@ -161,7 +161,7 @@ public class DefaultResultMapBuilderTest {
 
         // Test with a slash
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
         assertEquals(0, results.size());
@@ -174,7 +174,40 @@ public class DefaultResultMapBuilderTest {
 
         // Test with a result path
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
-        this.conventionsService = new ConventionsServiceImpl("/not-used");
+        this.conventionsService = newConventionsService("/not-used");
+        DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
+        Map<String, ResultConfig> results = 
builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig);
+        verify(context, "/class-level", results, false);
+    }
+
+    @Test
+    public void testScopedResultPathFromParentPackage() {
+        ServletContext context = mockServletContext("/WEB-INF/admin");
+
+        PackageConfig packageConfig = createPackageConfigBuilder("/namespace", 
"/WEB-INF/admin/");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
+        DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
+        Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
+        verify(context, "/WEB-INF/admin", results, false);
+    }
+
+    @Test
+    public void testScopedResultPathWithoutTrailingSlash() {
+        ServletContext context = mockServletContext("/WEB-INF/admin");
+
+        PackageConfig packageConfig = createPackageConfigBuilder("/namespace", 
"/WEB-INF/admin");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
+        DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
+        Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "action", packageConfig);
+        verify(context, "/WEB-INF/admin", results, false);
+    }
+
+    @Test
+    public void testResultPathAnnotationWinsOverScopedResultPath() {
+        ServletContext context = mockServletContext("/class-level");
+
+        PackageConfig packageConfig = createPackageConfigBuilder("/namespace", 
"/WEB-INF/admin/");
+        this.conventionsService = newConventionsService("/not-used");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ClassLevelResultPathAction.class, null, "action", packageConfig);
         verify(context, "/class-level", results, false);
@@ -193,7 +226,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
         assertEquals(0, results.size());
@@ -215,7 +248,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
         assertEquals(4, results.size());
@@ -250,7 +283,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         builder.setFlatResultLayout("false");
 
@@ -286,7 +319,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
         assertEquals(1, results.size());
@@ -309,7 +342,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ClassLevelResultAction.class, null, "class-level-result", 
packageConfig);
         assertEquals(1, results.size());
@@ -333,7 +366,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(InheritedResultExtends.class, null, "result-inheritance-extends", 
packageConfig);
         assertEquals(1, results.size());
@@ -357,7 +390,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(OverrideInheritedResultExtends.class, null, 
"result-inheritance-extends", packageConfig);
         assertEquals(1, results.size());
@@ -383,7 +416,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ClassLevelResultsAction.class, null, "class-level-results", 
packageConfig);
         assertEquals(4, results.size());
@@ -423,7 +456,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(InheritedResultsExtends.class, null, "class-level-results", 
packageConfig);
         assertEquals(4, results.size());
@@ -463,7 +496,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ActionLevelResultAction.class, 
getAnnotation(ActionLevelResultAction.class, "execute", Action.class), 
"action-level-result", packageConfig);
         assertEquals(1, results.size());
@@ -487,7 +520,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ActionLevelResultsAction.class, 
getAnnotation(ActionLevelResultsAction.class, "execute", Action.class), 
"action-level-results", packageConfig);
         assertEquals(4, results.size());
@@ -523,7 +556,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ActionLevelResultsNamesAction.class, 
getAnnotation(ActionLevelResultsNamesAction.class, "execute", Action.class), 
"action-level-results", packageConfig);
         assertEquals(4, results.size());
@@ -559,7 +592,7 @@ public class DefaultResultMapBuilderTest {
 
         PackageConfig packageConfig = createPackageConfigBuilder("/namespace");
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/location");
+        this.conventionsService = newConventionsService("/WEB-INF/location");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(ActionLevelResultsNamesAction.class, 
getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class), 
"action-level-results", packageConfig);
         assertEquals(1, results.size());
@@ -581,7 +614,7 @@ public class DefaultResultMapBuilderTest {
         PackageConfig packageConfig = new PackageConfig.Builder("package").
                 
defaultResultType("dispatcher").addResultTypeConfig(resultType).build();
 
-        this.conventionsService = new 
ConventionsServiceImpl("/WEB-INF/component");
+        this.conventionsService = newConventionsService("/WEB-INF/component");
         DefaultResultMapBuilder builder = new DefaultResultMapBuilder(context, 
container, "dispatcher,velocity,freemarker");
         Map<String, ResultConfig> results = 
builder.build(NoAnnotationAction.class, null, "no-annotation", packageConfig);
         assertEquals(4, results.size());
@@ -599,7 +632,17 @@ public class DefaultResultMapBuilderTest {
         assertEquals("/WEB-INF/component/no-annotation-foo.ftl", 
results.get("foo").getParams().get("location"));
     }
 
+    private ConventionsService newConventionsService(String resultPath) {
+        ConventionsServiceImpl service = new 
ConventionsServiceImpl(resultPath);
+        service.setScopedConstantProvider(name -> resultPath);
+        return service;
+    }
+
     private PackageConfig createPackageConfigBuilder(String namespace) {
+        return createPackageConfigBuilder(namespace, null);
+    }
+
+    private PackageConfig createPackageConfigBuilder(String namespace, String 
scopedResultPath) {
         ResultTypeConfig resultType = new 
ResultTypeConfig.Builder("dispatcher", 
"org.apache.struts2.result.ServletDispatcherResult").
                 addParam("key", "value").addParam("key1", 
"value1").defaultResultParam("location").build();
 
@@ -609,12 +652,18 @@ public class DefaultResultMapBuilderTest {
         ResultTypeConfig ftlResultType = new 
ResultTypeConfig.Builder("freemarker",
                 
"org.apache.struts2.views.freemarker.FreemarkerResult").defaultResultParam("location").build();
 
-        return new PackageConfig.Builder("package").
+        PackageConfig.Builder builder = new PackageConfig.Builder("package").
                 namespace(namespace).
                 defaultResultType("dispatcher").
                 addResultTypeConfig(resultType).
                 addResultTypeConfig(redirect).
-                addResultTypeConfig(ftlResultType).build();
+                addResultTypeConfig(ftlResultType);
+        if (scopedResultPath != null) {
+            builder.addParent(new PackageConfig.Builder("scoped-parent")
+                    
.addScopedConstant(ConventionConstants.CONVENTION_RESULT_PATH, scopedResultPath)
+                    .build());
+        }
+        return builder.build();
     }
 
     private ServletContext mockServletContext(String resultPath) {
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 1d21061b3..437610adc 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
@@ -173,7 +173,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         of.setContainer(mockContainer);
 
         mockContainer.setActionNameBuilder(actionNameBuilder);
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
 
         // Create the builder with a package that will trigger 
NoClassDefFoundError simulation
         PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
@@ -229,7 +229,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         of.setContainer(mockContainer);
 
         mockContainer.setActionNameBuilder(actionNameBuilder);
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
 
         PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
                 configuration, mockContainer, of, "false", "struts-default", 
"false");
@@ -284,7 +284,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         of.setContainer(mockContainer);
 
         mockContainer.setActionNameBuilder(actionNameBuilder);
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
 
         PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
                 configuration, mockContainer, of, "false", "struts-default", 
"false");
@@ -332,7 +332,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         of.setContainer(mockContainer);
 
         mockContainer.setActionNameBuilder(actionNameBuilder);
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
 
         PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
                 configuration, mockContainer, of, "false", "struts-default", 
"false");
@@ -495,7 +495,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         of.setContainer(mockContainer);
 
         mockContainer.setActionNameBuilder(actionNameBuilder);
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
 
         PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(
                 configuration, mockContainer, of, "false", "struts-default", 
"false");
@@ -547,7 +547,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
 
         final DummyContainer mockContainer = new DummyContainer();
         mockContainer.setActionNameBuilder(new SEOActionNameBuilder("true", 
"-"));
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
         mockContainer.setResultMapBuilder((actionClass, annotation, 
actionName, packageConfig) -> new HashMap<>());
         mockContainer.setInterceptorMapBuilder((actionClass, builder, 
actionName, annotation) -> new ArrayList<>());
         Configuration configuration = new DefaultConfiguration() {
@@ -839,7 +839,7 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         mockContainer.setActionNameBuilder(actionNameBuilder);
         mockContainer.setInterceptorMapBuilder(interceptorBuilder);
         mockContainer.setResultMapBuilder(resultMapBuilder);
-        mockContainer.setConventionsService(new ConventionsServiceImpl(""));
+        mockContainer.setConventionsService(newConventionsService());
 
         PackageBasedActionConfigBuilder builder = new 
PackageBasedActionConfigBuilder(configuration, mockContainer, of, "false", 
"struts-default", enableSmiInheritance);
         builder.setFileProtocols("jar");
@@ -1270,6 +1270,12 @@ public class PackageBasedActionConfigBuilderTest extends 
TestCase {
         return builder.build();
     }
 
+    private ConventionsService newConventionsService() {
+        ConventionsServiceImpl service = new ConventionsServiceImpl("");
+        service.setScopedConstantProvider(name -> "");
+        return service;
+    }
+
     private InterceptorStackConfig makeInterceptorStackConfig(String name, 
InterceptorMapping... interceptors) {
         InterceptorStackConfig.Builder builder = new 
InterceptorStackConfig.Builder(name);
         for (InterceptorMapping interceptor : interceptors)
diff --git a/plugins/convention/src/test/resources/scoped/message-success.ftl 
b/plugins/convention/src/test/resources/scoped/message-success.ftl
new file mode 100644
index 000000000..bbcac88fa
--- /dev/null
+++ b/plugins/convention/src/test/resources/scoped/message-success.ftl
@@ -0,0 +1,23 @@
+<#--
+  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.
+-->
+<html>
+<body>
+This is the scoped view ${message}
+</body>
+</html>
diff --git 
a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java 
b/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml
similarity index 58%
copy from 
core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
copy to 
plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml
index 53e655483..706c668f2 100644
--- a/core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java
+++ 
b/plugins/convention/src/test/resources/struts-convention-scoped-result-path.xml
@@ -1,3 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
 /*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -16,20 +18,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.struts2.config;
+-->
 
-/**
- * Resolves a scopable constant for the current action invocation: the 
package's {@code <scoped-constant>}, inherited
- * through {@code extends}, or else the global constant.
- *
- * @since 7.5.0
- */
-public interface ScopedConstantProvider {
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+        "https://struts.apache.org/dtds/struts-7.5.dtd";>
+
+<struts>
+    <constant name="struts.convention.result.path" value="/"/>
+    <constant name="struts.convention.default.parent.package" 
value="scoped-default"/>
 
-    /**
-     * @param name a constant registered through a {@link ScopableConstants} 
bean
-     * @return the scoped value, or the global value ({@code null} when there 
is none)
-     * @throws IllegalArgumentException when {@code name} is not scopable
-     */
-    String getValue(String name);
-}
+    <package name="scoped-default" extends="convention-default">
+        <scoped-constant name="struts.convention.result.path" 
value="/scoped/"/>
+    </package>
+</struts>

Reply via email to