This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch WW-3476-scoped-constants in repository https://gitbox.apache.org/repos/asf/struts.git
commit 43115aa513820de8361c96290071004f64426425 Author: Lukasz Lenart <[email protected]> AuthorDate: Sat Oct 10 17:03:46 2026 +0200 WW-3476 docs: add implementation plan for package-scoped constants Wire the provider like other pluggable core services (struts-beans.xml, StrutsBeanSelectionProvider alias, struts.scopedConstantProvider). Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../plans/2026-10-10-WW-3476-scoped-constants.md | 1074 ++++++++++++++++++++ .../2026-10-10-WW-3476-scoped-constants-design.md | 11 +- 2 files changed, 1081 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-10-WW-3476-scoped-constants.md b/docs/superpowers/plans/2026-10-10-WW-3476-scoped-constants.md new file mode 100644 index 000000000..67d798f11 --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-WW-3476-scoped-constants.md @@ -0,0 +1,1074 @@ +# WW-3476 Package-Scoped Constants 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 a `<package>` override selected Struts constants with `<scoped-constant name value/>`, inherited through `extends`, read at request time through a `ScopedConstantProvider` that falls back to the global constant. + +**Architecture:** `PackageConfig` stores each package's own overrides and, at `build()`, an eagerly merged view (parents first, own entries win). Components opt a constant in by registering a `ScopableConstants` bean; `DefaultConfiguration` rejects any `<scoped-constant>` whose name no bean registered. `StrutsScopedConstantProvider.getValue(name)` reads the current `ActionInvocation`'s package and falls back to `container.getInstance(String.class, name)` — the singleton's injected value [...] + +**Tech Stack:** Java 17, Struts core config/DI (`org.apache.struts2.inject`), XML DTD, JUnit 3 (`XWorkTestCase`) and JUnit 4, AssertJ 3.27. + +**Spec:** `docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md` + +## Global Constraints + +- Branch: `WW-3476-scoped-constants` (already exists, holds the spec commit). Never push to `main`. +- Commit messages start with `WW-3476 ` and end with `Co-Authored-By: Claude Opus 5.5 <[email protected]>`. +- Stage explicit paths only — never `git add -A` / `git add .`; check `git diff --cached --name-only` before every commit. +- Never `git stash` in this repo. +- Tests are JUnit 3 or 4 — never Jupiter. In a class extending `XWorkTestCase`, methods are `public void testXxx()` with no `@Test`. +- Maven: the Bash tool has no `mvn`; use `./mvnw` from the repo root, log to `core/target/ww3476.log`, and read Maven's exit code — never trust a grep over the output alone. +- Every new file starts with the ASF license header, copied verbatim from `core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java` (Java) or `core/src/test/resources/org/apache/struts2/config/providers/xwork-test-package-final.xml` (XML). +- New public API carries `@since 7.5.0`. Comments: default to none; short Javadoc on new public types/methods only. +- Purely additive: do not edit `struts-6.5.dtd` or any older DTD; no existing constructor/interface signature changes. +- No constant is registered as scopable in `src/main` — `ScopableConstants` beans exist only under `src/test`. +- Default implementation name prefix is `Struts*`. +- Watch-it-fail mutations are applied with the Edit tool and confirmed with `git diff --stat` (BSD `sed -i` silently no-ops here), then reverted with Edit. + +## Review Focus + +1. **An abstract package declares an unregistered scoped constant** — must still fail at startup, even though `buildRuntimeConfiguration` skips abstract packages when collecting actions. Test: Task 3 `abstractPackageIsValidatedToo`. +2. **`<scoped-constant value=""/>`** — an empty override is a real value; the provider must return `""`, not fall back to the global constant. Test: Task 4 `testEmptyOverrideIsReturnedAsIs`. +3. **Invocation without a resolvable package** (proxy null, `ActionConfig` null, package name not in `Configuration`) — must return the global value, never throw. Tests: Task 4 `testGlobalValueWhenInvocationHasNoProxy`, `testGlobalValueWhenProxyHasNoConfig`, `testGlobalValueWhenPackageIsUnknown`. +4. **Scopable constant with no global value** — outside an override the provider returns `null` (as `container.getInstance` does), it must not throw. Test: Task 4 `testUnsetConstantWithoutOverrideIsNull`. +5. **Several `ScopableConstants` beans** — the scopable set is the union; a name from the second bean must validate. Test: Task 3 `namesFromAllBeansAreScopable`. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java` (modify) | Store own + merged scoped constants | +| `core/src/main/resources/struts-7.5.dtd` (create) | DTD with `<scoped-constant>` | +| `core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java` (modify) | Map the 7.5 public id to the DTD | +| `core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java` (modify) | Parse `<scoped-constant>` | +| `core/src/main/java/org/apache/struts2/config/ScopableConstants.java` (create) | Registry contract + union helper | +| `core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java` (modify) | Startup validation | +| `core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java` (create) | Lookup contract | +| `core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java` (create) | Default lookup | +| `core/src/main/java/org/apache/struts2/StrutsConstants.java` (modify) | Bean-selection constant | +| `core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` (modify) | Alias the default bean | +| `core/src/main/resources/struts-beans.xml` (modify) | Runtime binding | +| `core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java` (modify) | Test-harness binding | +| `core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java`, `SecondScopableConstants.java` (create) | Test-only registry beans | +| Tests + fixtures listed per task | | + +--- + +### Task 1: `PackageConfig` stores and merges scoped constants + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java` +- Test: `core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java` (JUnit 3, extends `XWorkTestCase`) + +**Interfaces:** +- Produces: `PackageConfig.Builder addScopedConstant(String name, String value)`; `Map<String, String> PackageConfig.getScopedConstants()` (own only, unmodifiable after `build()`); `Map<String, String> PackageConfig.getAllScopedConstants()` (merged, unmodifiable, empty before `build()`). + +- [ ] **Step 1: Write the failing tests** — append to `PackageConfigTest` (add `import java.util.Map;`): + +```java + public void testScopedConstantsChildOverridesParent() { + PackageConfig parent = new PackageConfig.Builder("parent") + .addScopedConstant("first", "parent-first") + .addScopedConstant("second", "parent-second") + .build(); + + PackageConfig child = new PackageConfig.Builder("child") + .addParent(parent) + .addScopedConstant("second", "child-second") + .build(); + + assertEquals(Map.of("second", "child-second"), child.getScopedConstants()); + assertEquals(Map.of("first", "parent-first", "second", "child-second"), child.getAllScopedConstants()); + } + + public void testScopedConstantsInheritedThroughTwoLevels() { + PackageConfig grandparent = new PackageConfig.Builder("grandparent") + .addScopedConstant("first", "grandparent-first") + .build(); + PackageConfig parent = new PackageConfig.Builder("parent") + .addParent(grandparent) + .build(); + + PackageConfig child = new PackageConfig.Builder("child") + .addParent(parent) + .build(); + + assertEquals(Map.of(), child.getScopedConstants()); + assertEquals(Map.of("first", "grandparent-first"), child.getAllScopedConstants()); + } + + public void testScopedConstantsFirstAddedParentWins() { + PackageConfig first = new PackageConfig.Builder("first") + .addScopedConstant("shared", "from-first") + .build(); + PackageConfig second = new PackageConfig.Builder("second") + .addScopedConstant("shared", "from-second") + .build(); + + PackageConfig child = new PackageConfig.Builder("child") + .addParent(first) + .addParent(second) + .build(); + + assertEquals("from-first", child.getAllScopedConstants().get("shared")); + } + + public void testScopedConstantsSurviveBuilderCopy() { + PackageConfig parent = new PackageConfig.Builder("parent") + .addScopedConstant("first", "parent-first") + .build(); + PackageConfig original = new PackageConfig.Builder("child") + .addParent(parent) + .addScopedConstant("second", "child-second") + .build(); + + PackageConfig copy = new PackageConfig.Builder(original).build(); + + assertEquals(original.getScopedConstants(), copy.getScopedConstants()); + assertEquals(original.getAllScopedConstants(), copy.getAllScopedConstants()); + } + + public void testScopedConstantsAreUnmodifiableAfterBuild() { + PackageConfig config = new PackageConfig.Builder("pkg") + .addScopedConstant("first", "value") + .build(); + + assertThrows(UnsupportedOperationException.class, () -> config.getScopedConstants().put("x", "y")); + assertThrows(UnsupportedOperationException.class, () -> config.getAllScopedConstants().put("x", "y")); + } + + public void testScopedConstantsTakePartInEquality() { + PackageConfig plain = new PackageConfig.Builder("pkg").build(); + PackageConfig scoped = new PackageConfig.Builder("pkg").addScopedConstant("first", "value").build(); + + assertFalse(plain.equals(scoped)); + } +``` + +Also add `import static org.junit.Assert.assertThrows;` — JUnit 4.13 provides it, and it works inside this JUnit 3 class. + +- [ ] **Step 2: Run to verify they fail** + +Run: `./mvnw test -DskipAssembly -pl core -Dtest=PackageConfigTest -Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run|cannot find symbol" core/target/ww3476.log | head -20` +Expected: `exit=1`, compilation error `cannot find symbol ... addScopedConstant`. + +- [ ] **Step 3: Implement** in `PackageConfig.java`. + +Fields, after `protected boolean strictMethodInvocation = true;`: + +```java + protected Map<String, String> scopedConstants; + protected Map<String, String> allScopedConstants; +``` + +In `PackageConfig(String name)`, after `parents = new ArrayList<>();`: + +```java + scopedConstants = new LinkedHashMap<>(); + allScopedConstants = Collections.emptyMap(); +``` + +In `PackageConfig(PackageConfig orig)`, after `this.strictMethodInvocation = orig.strictMethodInvocation;`: + +```java + this.scopedConstants = new LinkedHashMap<>(orig.scopedConstants); + this.allScopedConstants = orig.allScopedConstants; +``` + +Getters, directly after `getAllResultTypeConfigs()`: + +```java + /** + * @return scoped constants declared by this package itself, without inherited ones + * @since 7.5.0 + */ + public Map<String, String> getScopedConstants() { + return scopedConstants; + } + + /** + * @return scoped constants in effect for this package: inherited ones, overridden by its own + * @since 7.5.0 + */ + public Map<String, String> getAllScopedConstants() { + return allScopedConstants; + } +``` + +In `equals`, after the `parents` comparison line: + +```java + if (!Objects.equals(scopedConstants, that.scopedConstants)) return false; +``` + +In `hashCode`, after the `parents` line: + +```java + result = 31 * result + (scopedConstants != null ? scopedConstants.hashCode() : 0); +``` + +In `Builder`, after `addResultTypeConfig(...)`: + +```java + /** + * @since 7.5.0 + */ + public Builder addScopedConstant(String name, String value) { + target.scopedConstants.put(name, value); + return this; + } +``` + +In `Builder.build()`, before `PackageConfig result = target;`: + +```java + target.scopedConstants = Collections.unmodifiableMap(target.scopedConstants); + target.allScopedConstants = mergeScopedConstants(target); +``` + +And in `Builder`, after `build()`: + +```java + private static Map<String, String> mergeScopedConstants(PackageConfig config) { + Map<String, String> merged = new LinkedHashMap<>(); + for (PackageConfig parent : config.parents) { + merged.putAll(parent.getAllScopedConstants()); + } + merged.putAll(config.scopedConstants); + return Collections.unmodifiableMap(merged); + } +``` + +(`addParent` inserts at index 0, so iterating `parents` and `putAll`-ing lets the first-added parent win — the same order as `getAllResultTypeConfigs()`; `testScopedConstantsFirstAddedParentWins` pins it.) + +- [ ] **Step 4: Run to verify they pass** — same command as Step 2. Expected: `exit=0`, `Tests run: N, Failures: 0, Errors: 0`. + +- [ ] **Step 5: Watch it fail** — with Edit, swap the merge order in `mergeScopedConstants` (move `merged.putAll(config.scopedConstants);` above the `for` loop). `git diff --stat` must show `PackageConfig.java` changed. Re-run Step 2's command: expect `testScopedConstantsChildOverridesParent` to FAIL. Revert with Edit; re-run; expect `exit=0`. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/config/entities/PackageConfig.java core/src/test/java/org/apache/struts2/config/entities/PackageConfigTest.java +git diff --cached --name-only +git commit -m "WW-3476 feat(config): store scoped constants on PackageConfig + +Co-Authored-By: Claude Opus 5.5 <[email protected]>" +``` + +--- + +### Task 2: `struts-7.5.dtd` and parsing `<scoped-constant>` + +**Files:** +- Create: `core/src/main/resources/struts-7.5.dtd` +- Modify: `core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java:47-54` +- Modify: `core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java` (`addPackage`, ~line 411) +- Create fixtures in `core/src/test/resources/org/apache/struts2/config/providers/`: `xwork-test-scoped-constants.xml`, `xwork-test-scoped-constants-duplicate.xml`, `xwork-test-scoped-constants-old-dtd.xml` +- Test: `core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java` (JUnit 3, extends `ConfigurationTestBase`) + +**Interfaces:** +- Consumes: `PackageConfig.Builder.addScopedConstant`, `getScopedConstants`, `getAllScopedConstants` (Task 1). +- Produces: public id `-//Apache Software Foundation//DTD Struts Configuration 7.5//EN` → `struts-7.5.dtd`; `protected void XmlDocConfigurationProvider.loadScopedConstants(PackageConfig.Builder, Element)`. + +- [ ] **Step 1: Create the fixtures** (license header first in each, per Global Constraints). + +`xwork-test-scoped-constants.xml`: + +```xml +<!DOCTYPE struts PUBLIC + "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN" + "struts-7.5.dtd"> +<struts> + <package name="parent" namespace="/parent"> + <scoped-constant name="first" value="parent-first"/> + <scoped-constant name="second" value="parent-second"/> + </package> + + <package name="child" namespace="/child" extends="parent"> + <scoped-constant name="second" value="child-second"/> + </package> +</struts> +``` + +`xwork-test-scoped-constants-duplicate.xml`: + +```xml +<!DOCTYPE struts PUBLIC + "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN" + "struts-7.5.dtd"> +<struts> + <package name="dup" namespace="/dup"> + <scoped-constant name="first" value="one"/> + <scoped-constant name="first" value="two"/> + </package> +</struts> +``` + +`xwork-test-scoped-constants-old-dtd.xml`: + +```xml +<!DOCTYPE struts PUBLIC + "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN" + "struts-6.5.dtd"> +<struts> + <package name="old" namespace="/old"> + <scoped-constant name="first" value="one"/> + </package> +</struts> +``` + +- [ ] **Step 2: Write the failing tests** — append to `XmlConfigurationProviderPackagesTest` (add `import java.util.Map;` and `import static org.assertj.core.api.Assertions.assertThat;`): + +```java + public void testScopedConstantsLoadAndInherit() throws ConfigurationException { + buildConfigurationProvider(getXmlConfigFilePath("xwork-test-scoped-constants.xml")); + + PackageConfig parent = configuration.getPackageConfig("parent"); + PackageConfig child = configuration.getPackageConfig("child"); + + assertEquals(Map.of("first", "parent-first", "second", "parent-second"), parent.getScopedConstants()); + assertEquals(Map.of("second", "child-second"), child.getScopedConstants()); + assertEquals(Map.of("first", "parent-first", "second", "child-second"), child.getAllScopedConstants()); + } + + public void testDuplicateScopedConstantInOnePackageFails() { + try { + buildConfigurationProvider(getXmlConfigFilePath("xwork-test-scoped-constants-duplicate.xml")); + fail("Should have thrown a ConfigurationException"); + } catch (ConfigurationException e) { + assertThat(e).hasStackTraceContaining("Package [dup] declares scoped constant [first] more than once"); + } + } + + public void testScopedConstantIsRejectedUnderOlderDtd() { + try { + buildConfigurationProvider(getXmlConfigFilePath("xwork-test-scoped-constants-old-dtd.xml")); + fail("Should have thrown a ConfigurationException"); + } catch (ConfigurationException e) { + assertThat(e).hasStackTraceContaining("\"scoped-constant\" must be declared"); + } + } +``` + +(The old-DTD assertion matches the parser's `Element type "scoped-constant" must be declared.` — not the fixture file name, which also contains `scoped-constant`.) + +- [ ] **Step 3: Run to verify they fail** + +Run: `./mvnw test -DskipAssembly -pl core -Dtest=XmlConfigurationProviderPackagesTest -Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo "exit=$?"; grep -E "Tests run|FAIL|testScoped|testDuplicate" core/target/ww3476.log | head -20` +Expected: `exit=1`; `testScopedConstantsLoadAndInherit` and `testDuplicateScopedConstantInOnePackageFails` fail/error (7.5 public id is unmapped). `testScopedConstantIsRejectedUnderOlderDtd` already passes — that is fine, it guards that the 6.5 DTD stays unchanged. + +- [ ] **Step 4: Create the DTD** + +```bash +cp core/src/main/resources/struts-6.5.dtd core/src/main/resources/struts-7.5.dtd +``` + +Then, with Edit, in `struts-7.5.dtd`: +- In the header comment, replace `"-//Apache Software Foundation//DTD Struts Configuration 6.5//EN"` with `"-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"` and `"https://struts.apache.org/dtds/struts-6.5.dtd"` with `"https://struts.apache.org/dtds/struts-7.5.dtd"`. +- Replace + +``` +<!ELEMENT package (result-types?, interceptors?, default-interceptor-ref?, default-action-ref?, default-class-ref?, global-results?, global-allowed-methods?, global-exception-mappings?, action*)> +``` + +with + +``` +<!ELEMENT package (scoped-constant*, result-types?, interceptors?, default-interceptor-ref?, default-action-ref?, default-class-ref?, global-results?, global-allowed-methods?, global-exception-mappings?, action*)> +``` + +- Directly after the `<!ATTLIST package ... >` block, add: + +``` +<!ELEMENT scoped-constant EMPTY> +<!ATTLIST scoped-constant + name CDATA #REQUIRED + value CDATA #REQUIRED +> +``` + +Check: `diff core/src/main/resources/struts-6.5.dtd core/src/main/resources/struts-7.5.dtd` shows only those changes. + +- [ ] **Step 5: Register the DTD** — in `StrutsXmlConfigurationProvider.STRUTS_DTD_MAPPINGS`, change the last entry to: + +```java + "-//Apache Software Foundation//DTD Struts Configuration 6.5//EN", "struts-6.5.dtd", + "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN", "struts-7.5.dtd"); +``` + +- [ ] **Step 6: Parse the element** — in `XmlDocConfigurationProvider.addPackage`, directly after `LOG.debug("Loaded {}", newPackage);`: + +```java + loadScopedConstants(newPackage, packageElement); +``` + +And add, directly before `loadGlobalResults(...)`'s Javadoc: + +```java + /** + * @since 7.5.0 + */ + protected void loadScopedConstants(PackageConfig.Builder packageContext, Element packageElement) { + Set<String> declared = new HashSet<>(); + iterateChildrenByTagName(packageElement, "scoped-constant", constantElement -> { + String name = constantElement.getAttribute("name"); + if (!declared.add(name)) { + throw new ConfigurationException(format("Package [%s] declares scoped constant [%s] more than once", + packageContext.getName(), name), constantElement); + } + packageContext.addScopedConstant(name, constantElement.getAttribute("value")); + }); + } +``` + +(The static `format` import, `Set` and `HashSet` are already present in this file.) + +- [ ] **Step 7: Run to verify they pass** — Step 3's command. Expected: `exit=0`, all tests in the class pass (including the pre-existing 6.5/2.x fixtures). + +- [ ] **Step 8: Watch it fail** — with Edit, change `if (!declared.add(name)) {` to `if (false && !declared.add(name)) {`; `git diff --stat` shows the file; re-run: `testDuplicateScopedConstantInOnePackageFails` FAILS. Revert. Then, with Edit, add `scoped-constant*, ` to the `package` content model in **`struts-6.5.dtd`** plus the same `<!ELEMENT scoped-constant ...>` block; re-run: `testScopedConstantIsRejectedUnderOlderDtd` FAILS. Revert with Edit; `git diff --stat core/src/main/resour [...] + +- [ ] **Step 9: Commit** + +```bash +git add core/src/main/resources/struts-7.5.dtd \ + core/src/main/java/org/apache/struts2/config/StrutsXmlConfigurationProvider.java \ + core/src/main/java/org/apache/struts2/config/providers/XmlDocConfigurationProvider.java \ + core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderPackagesTest.java \ + core/src/test/resources/org/apache/struts2/config/providers/xwork-test-scoped-constants.xml \ + core/src/test/resources/org/apache/struts2/config/providers/xwork-test-scoped-constants-duplicate.xml \ + core/src/test/resources/org/apache/struts2/config/providers/xwork-test-scoped-constants-old-dtd.xml +git diff --cached --name-only +git commit -m "WW-3476 feat(config): add struts-7.5.dtd with <scoped-constant> + +Co-Authored-By: Claude Opus 5.5 <[email protected]>" +``` + +--- + +### Task 3: `ScopableConstants` registry and startup validation + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/config/ScopableConstants.java` +- Modify: `core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java` (`buildRuntimeConfiguration`, ~line 451) +- Create (test): `core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java`, `core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java` +- Create fixtures in `core/src/test/resources/org/apache/struts2/config/`: `scoped-constants-registered.xml`, `scoped-constants-unregistered.xml`, `scoped-constants-abstract-unregistered.xml`, `scoped-constants-no-registry.xml`, `scoped-constants-two-beans.xml` +- Test: `core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java` (new, JUnit 4) + +**Interfaces:** +- Consumes: `PackageConfig.getScopedConstants()` (Task 1); `struts-7.5.dtd` mapping (Task 2). +- Produces: `interface ScopableConstants { Set<String> getNames(); static Set<String> collectNames(Container container); }`; test beans `SampleScopableConstants` → `{"sample.scopable", "sample.unset"}`, `SecondScopableConstants` → `{"second.scopable"}`. + +- [ ] **Step 1: Create the registry contract** — `ScopableConstants.java`: + +```java +package org.apache.struts2.config; + +import org.apache.struts2.inject.Container; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Declares constants a package may override with {@code <scoped-constant>}. Register implementations as named beans; + * the scopable set is the union over all of them. + * + * @since 7.5.0 + */ +public interface ScopableConstants { + + Set<String> getNames(); + + static Set<String> collectNames(Container container) { + Set<String> names = new HashSet<>(); + for (String beanName : container.getInstanceNames(ScopableConstants.class)) { + names.addAll(container.getInstance(ScopableConstants.class, beanName).getNames()); + } + return Collections.unmodifiableSet(names); + } +} +``` + +- [ ] **Step 2: Create the test beans** + +`SampleScopableConstants.java`: + +```java +package org.apache.struts2.config; + +import java.util.Set; + +public class SampleScopableConstants implements ScopableConstants { + + @Override + public Set<String> getNames() { + return Set.of("sample.scopable", "sample.unset"); + } +} +``` + +`SecondScopableConstants.java`: + +```java +package org.apache.struts2.config; + +import java.util.Set; + +public class SecondScopableConstants implements ScopableConstants { + + @Override + public Set<String> getNames() { + return Set.of("second.scopable"); + } +} +``` + +- [ ] **Step 3: Create the fixtures.** Each file is: the XML license header, then this DOCTYPE, then the `<struts>` body shown below. + +```xml +<!DOCTYPE struts PUBLIC + "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN" + "struts-7.5.dtd"> +``` + +`scoped-constants-registered.xml`: + +```xml +<struts> + <bean type="org.apache.struts2.config.ScopableConstants" name="sample" + class="org.apache.struts2.config.SampleScopableConstants"/> + + <package name="registered" namespace="/registered"> + <scoped-constant name="sample.scopable" value="x"/> + </package> +</struts> +``` + +`scoped-constants-unregistered.xml`: + +```xml +<struts> + <bean type="org.apache.struts2.config.ScopableConstants" name="sample" + class="org.apache.struts2.config.SampleScopableConstants"/> + + <package name="unregistered" namespace="/unregistered"> + <scoped-constant name="sample.unknown" value="x"/> + </package> +</struts> +``` + +`scoped-constants-abstract-unregistered.xml`: + +```xml +<struts> + <bean type="org.apache.struts2.config.ScopableConstants" name="sample" + class="org.apache.struts2.config.SampleScopableConstants"/> + + <package name="abstractBase" abstract="true"> + <scoped-constant name="sample.unknown" value="x"/> + </package> +</struts> +``` + +`scoped-constants-no-registry.xml`: + +```xml +<struts> + <package name="unregistered" namespace="/unregistered"> + <scoped-constant name="sample.scopable" value="x"/> + </package> +</struts> +``` + +`scoped-constants-two-beans.xml`: + +```xml +<struts> + <bean type="org.apache.struts2.config.ScopableConstants" name="sample" + class="org.apache.struts2.config.SampleScopableConstants"/> + <bean type="org.apache.struts2.config.ScopableConstants" name="second" + class="org.apache.struts2.config.SecondScopableConstants"/> + + <package name="both" namespace="/both"> + <scoped-constant name="sample.scopable" value="x"/> + <scoped-constant name="second.scopable" value="y"/> + </package> +</struts> +``` + +- [ ] **Step 4: Write the failing tests** — `ScopedConstantsValidationTest.java`: + +```java +package org.apache.struts2.config; + +import org.apache.struts2.ActionContext; +import org.apache.struts2.config.providers.StrutsDefaultConfigurationProvider; +import org.apache.struts2.inject.Container; +import org.junit.After; +import org.junit.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class ScopedConstantsValidationTest { + + private ConfigurationManager configurationManager; + + @After + public void tearDown() { + if (configurationManager != null) { + configurationManager.destroyConfiguration(); + } + ActionContext.clear(); + } + + @Test + public void registeredNameLoads() { + Configuration configuration = load("scoped-constants-registered.xml"); + + assertThat(configuration.getPackageConfig("registered").getScopedConstants()) + .isEqualTo(Map.of("sample.scopable", "x")); + } + + @Test + public void unregisteredNameFails() { + assertThatThrownBy(() -> load("scoped-constants-unregistered.xml")) + .isInstanceOf(ConfigurationException.class) + .hasStackTraceContaining("Package [unregistered] declares scoped constant [sample.unknown], which is not scopable. " + + "Scopable constants: [sample.scopable, sample.unset]."); + } + + @Test + public void abstractPackageIsValidatedToo() { + assertThatThrownBy(() -> load("scoped-constants-abstract-unregistered.xml")) + .isInstanceOf(ConfigurationException.class) + .hasStackTraceContaining("Package [abstractBase] declares scoped constant [sample.unknown], which is not scopable."); + } + + @Test + public void anyScopedConstantFailsWhenNothingIsScopable() { + assertThatThrownBy(() -> load("scoped-constants-no-registry.xml")) + .isInstanceOf(ConfigurationException.class) + .hasStackTraceContaining("Package [unregistered] declares scoped constant [sample.scopable], which is not scopable. " + + "No constants are scopable in this configuration."); + } + + @Test + public void namesFromAllBeansAreScopable() { + Configuration configuration = load("scoped-constants-two-beans.xml"); + + assertThat(configuration.getPackageConfig("both").getScopedConstants()) + .isEqualTo(Map.of("sample.scopable", "x", "second.scopable", "y")); + } + + private Configuration load(String fixture) { + configurationManager = new ConfigurationManager(Container.DEFAULT_NAME); + configurationManager.addContainerProvider(new StrutsDefaultConfigurationProvider()); + configurationManager.addContainerProvider(new StrutsXmlConfigurationProvider("org/apache/struts2/config/" + fixture)); + return configurationManager.getConfiguration(); + } +} +``` + +- [ ] **Step 5: Run to verify they fail** + +Run: `./mvnw test -DskipAssembly -pl core -Dtest=ScopedConstantsValidationTest -Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo "exit=$?"; grep -E "Tests run|FAIL|Expecting" core/target/ww3476.log | head -20` +Expected: `exit=1`; the three `...Fails`/`...Too` tests fail (no exception thrown); `registeredNameLoads` and `namesFromAllBeansAreScopable` pass. + +- [ ] **Step 6: Implement validation** — in `DefaultConfiguration.java`, add the import `org.apache.struts2.config.ScopableConstants` (`java.util.Set` and `java.util.TreeSet` are already imported). As the first statement of `buildRuntimeConfiguration()`: + +```java + validateScopedConstants(); +``` + +And add, directly after `buildRuntimeConfiguration()`: + +```java + /** + * @since 7.5.0 + */ + protected void validateScopedConstants() throws ConfigurationException { + Set<String> scopableNames = ScopableConstants.collectNames(container); + for (PackageConfig packageConfig : packageContexts.values()) { + for (String name : packageConfig.getScopedConstants().keySet()) { + if (!scopableNames.contains(name)) { + throw new ConfigurationException(String.format("Package [%s] declares scoped constant [%s], which is not scopable. %s", + packageConfig.getName(), name, describeScopable(scopableNames)), packageConfig); + } + } + } + } + + private static String describeScopable(Set<String> scopableNames) { + if (scopableNames.isEmpty()) { + return "No constants are scopable in this configuration."; + } + return "Scopable constants: " + new TreeSet<>(scopableNames) + "."; + } +``` + +- [ ] **Step 7: Run to verify they pass** — Step 5's command. Expected: `exit=0`, 5 tests, 0 failures. + +- [ ] **Step 8: Watch it fail** — with Edit, comment out the `validateScopedConstants();` call; `git diff --stat` shows `DefaultConfiguration.java`; re-run: the three failure-path tests FAIL. Revert; re-run: `exit=0`. + +- [ ] **Step 9: Regression check on config loading** — `./mvnw test -DskipAssembly -pl core -Dtest='*Configuration*Test,XmlConfigurationProvider*Test' -Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo "exit=$?"; grep -E "Tests run:.*Fail" core/target/ww3476.log | tail -3`. Expected `exit=0`. + +- [ ] **Step 10: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/config/ScopableConstants.java \ + core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java \ + core/src/test/java/org/apache/struts2/config/SampleScopableConstants.java \ + core/src/test/java/org/apache/struts2/config/SecondScopableConstants.java \ + core/src/test/java/org/apache/struts2/config/ScopedConstantsValidationTest.java \ + core/src/test/resources/org/apache/struts2/config/scoped-constants-registered.xml \ + core/src/test/resources/org/apache/struts2/config/scoped-constants-unregistered.xml \ + core/src/test/resources/org/apache/struts2/config/scoped-constants-abstract-unregistered.xml \ + core/src/test/resources/org/apache/struts2/config/scoped-constants-no-registry.xml \ + core/src/test/resources/org/apache/struts2/config/scoped-constants-two-beans.xml +git diff --cached --name-only +git commit -m "WW-3476 feat(config): reject unregistered scoped constants at startup + +Co-Authored-By: Claude Opus 5.5 <[email protected]>" +``` + +--- + +### Task 4: `ScopedConstantProvider` and its wiring + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java` +- Create: `core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java` +- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (after `STRUTS_PARAMETER_ALLOWLISTER`, ~line 629) +- Modify: `core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` (~line 456) +- Modify: `core/src/main/resources/struts-beans.xml` (after the `ParameterAllowlister` bean, ~line 258) +- Modify: `core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java` (`register` chain) +- Create fixture: `core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml` +- Test: `core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java` (new, JUnit 3, extends `XWorkTestCase`) + +**Interfaces:** +- Consumes: `ScopableConstants.collectNames(Container)`, `SampleScopableConstants` (Task 3); `PackageConfig.getAllScopedConstants()` (Task 1); 7.5 DTD (Task 2). +- Produces: `interface ScopedConstantProvider { String getValue(String name); }`; `StrutsScopedConstantProvider`; `StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER = "struts.scopedConstantProvider"`. + +- [ ] **Step 1: Create the contract** — `ScopedConstantProvider.java`: + +```java +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 { + + /** + * @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); +} +``` + +- [ ] **Step 2: Create the fixture** — `scoped-constants-provider.xml`: the XML license header, then this DOCTYPE, then the body below. + +```xml +<!DOCTYPE struts PUBLIC + "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN" + "struts-7.5.dtd"> +``` + +```xml +<struts> + <bean type="org.apache.struts2.config.ScopableConstants" name="sample" + class="org.apache.struts2.config.SampleScopableConstants"/> + + <constant name="sample.scopable" value="global"/> + + <package name="base" abstract="true"> + <scoped-constant name="sample.scopable" value="base"/> + </package> + + <package name="child" namespace="/child" extends="base"/> + + <package name="override" namespace="/override" extends="base"> + <scoped-constant name="sample.scopable" value="override"/> + </package> + + <package name="empty" namespace="/empty" extends="base"> + <scoped-constant name="sample.scopable" value=""/> + </package> + + <package name="plain" namespace="/plain"/> +</struts> +``` + +- [ ] **Step 3: Write the failing tests** — `StrutsScopedConstantProviderTest.java`: + +```java +package org.apache.struts2.config; + +import org.apache.struts2.ActionContext; +import org.apache.struts2.ActionSupport; +import org.apache.struts2.XWorkTestCase; +import org.apache.struts2.config.entities.ActionConfig; +import org.apache.struts2.mock.MockActionInvocation; +import org.apache.struts2.mock.MockActionProxy; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class StrutsScopedConstantProviderTest extends XWorkTestCase { + + private ScopedConstantProvider provider; + + @Override + protected void setUp() throws Exception { + super.setUp(); + loadConfigurationProviders(new StrutsXmlConfigurationProvider("org/apache/struts2/config/scoped-constants-provider.xml")); + provider = container.getInstance(ScopedConstantProvider.class); + } + + public void testDefaultProviderIsStrutsImplementation() { + assertTrue(provider instanceof StrutsScopedConstantProvider); + } + + public void testPackageValueWinsInsideInvocation() { + enterPackage("override"); + + assertEquals("override", provider.getValue("sample.scopable")); + } + + public void testInheritedValueResolvesForChildPackage() { + enterPackage("child"); + + assertEquals("base", provider.getValue("sample.scopable")); + } + + public void testEmptyOverrideIsReturnedAsIs() { + enterPackage("empty"); + + assertEquals("", provider.getValue("sample.scopable")); + } + + public void testGlobalValueWithoutOverride() { + enterPackage("plain"); + + assertEquals("global", provider.getValue("sample.scopable")); + } + + public void testGlobalValueWithoutInvocation() { + assertEquals("global", provider.getValue("sample.scopable")); + } + + public void testGlobalValueWithoutActionContext() { + ActionContext context = ActionContext.getContext(); + ActionContext.clear(); + try { + assertEquals("global", provider.getValue("sample.scopable")); + } finally { + context.bind(); + } + } + + public void testGlobalValueWhenInvocationHasNoProxy() { + ActionContext.getContext().withActionInvocation(new MockActionInvocation()); + + assertEquals("global", provider.getValue("sample.scopable")); + } + + public void testGlobalValueWhenProxyHasNoConfig() { + MockActionInvocation invocation = new MockActionInvocation(); + invocation.setProxy(new MockActionProxy()); + ActionContext.getContext().withActionInvocation(invocation); + + assertEquals("global", provider.getValue("sample.scopable")); + } + + public void testGlobalValueWhenPackageIsUnknown() { + enterPackage("missing"); + + assertEquals("global", provider.getValue("sample.scopable")); + } + + public void testUnsetConstantWithoutOverrideIsNull() { + enterPackage("plain"); + + assertNull(provider.getValue("sample.unset")); + } + + public void testUnregisteredNameThrows() { + assertThatThrownBy(() -> provider.getValue("sample.unknown")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[sample.unknown]"); + } + + public void testGlobalConstantUntouchedByScopedRead() { + enterPackage("override"); + provider.getValue("sample.scopable"); + + assertEquals("global", container.getInstance(String.class, "sample.scopable")); + } + + private void enterPackage(String packageName) { + MockActionProxy proxy = new MockActionProxy(); + proxy.setConfig(new ActionConfig.Builder(packageName, "test", ActionSupport.class.getName()).build()); + MockActionInvocation invocation = new MockActionInvocation(); + invocation.setProxy(proxy); + ActionContext.getContext().withActionInvocation(invocation); + } +} +``` + +- [ ] **Step 4: Run to verify they fail** + +Run: `./mvnw test -DskipAssembly -pl core -Dtest=StrutsScopedConstantProviderTest -Dsurefire.failIfNoSpecifiedTests=false > core/target/ww3476.log 2>&1; echo "exit=$?"; grep -E "ERROR|Tests run|cannot find symbol" core/target/ww3476.log | head -20` +Expected: `exit=1`, `cannot find symbol ... StrutsScopedConstantProvider`. + +- [ ] **Step 5: Implement the default provider** — `StrutsScopedConstantProvider.java`: + +```java +package org.apache.struts2.config; + +import org.apache.struts2.ActionContext; +import org.apache.struts2.ActionInvocation; +import org.apache.struts2.ActionProxy; +import org.apache.struts2.config.entities.PackageConfig; +import org.apache.struts2.inject.Container; +import org.apache.struts2.inject.Inject; + +import java.util.Optional; +import java.util.Set; + +/** + * @since 7.5.0 + */ +public class StrutsScopedConstantProvider implements ScopedConstantProvider { + + private Container container; + private Configuration configuration; + private volatile Set<String> scopableNames; + + @Inject + public void setContainer(Container container) { + this.container = container; + } + + @Inject + public void setConfiguration(Configuration configuration) { + this.configuration = configuration; + } + + @Override + public String getValue(String name) { + if (!getScopableNames().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 Optional<PackageConfig> currentPackage() { + ActionContext context = ActionContext.getContext(); + ActionInvocation invocation = context != null ? context.getActionInvocation() : null; + ActionProxy proxy = invocation != null ? invocation.getProxy() : null; + if (proxy == null || proxy.getConfig() == null) { + return Optional.empty(); + } + return Optional.ofNullable(configuration.getPackageConfig(proxy.getConfig().getPackageName())); + } + + private Set<String> getScopableNames() { + Set<String> names = scopableNames; + if (names == null) { + names = ScopableConstants.collectNames(container); + scopableNames = names; + } + return names; + } +} +``` + +Note: `Optional.map` treats a `null` mapping result as empty, so a package without the override falls through to `orElseGet`; an empty-string override is non-null and is returned as is. + +- [ ] **Step 6: Wire it** + +`StrutsConstants.java`, directly after `STRUTS_PARAMETER_ALLOWLISTER`: + +```java + + /** + * The {@link org.apache.struts2.config.ScopedConstantProvider} implementation class. + * + * @since 7.5.0 + */ + public static final String STRUTS_SCOPED_CONSTANT_PROVIDER = "struts.scopedConstantProvider"; +``` + +`StrutsBeanSelectionProvider.java`, directly after the `ParameterAllowlister` alias (same package — no import): + +```java + alias(ScopedConstantProvider.class, StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER, builder, props, Scope.SINGLETON); +``` + +`struts-beans.xml`, directly after the `ParameterAllowlister` bean: + +```xml + + <bean type="org.apache.struts2.config.ScopedConstantProvider" name="struts" + class="org.apache.struts2.config.StrutsScopedConstantProvider" scope="singleton"/> +``` + +`StrutsDefaultConfigurationProvider.register`, directly after `.factory(UnknownHandlerManager.class, DefaultUnknownHandlerManager.class, Scope.SINGLETON)`: + +```java + + .factory(ScopedConstantProvider.class, StrutsScopedConstantProvider.class, Scope.SINGLETON) +``` + +with imports `org.apache.struts2.config.ScopedConstantProvider` and `org.apache.struts2.config.StrutsScopedConstantProvider`. + +- [ ] **Step 7: Run to verify they pass** — Step 4's command. Expected: `exit=0`, 13 tests, 0 failures. + +- [ ] **Step 8: Watch it fail** — with Edit, replace `.map(packageConfig -> packageConfig.getAllScopedConstants().get(name))` with `.map(packageConfig -> (String) null)`; `git diff --stat` shows the file; re-run: `testPackageValueWinsInsideInvocation`, `testInheritedValueResolvesForChildPackage`, `testEmptyOverrideIsReturnedAsIs` FAIL. Revert. Then replace `.orElseGet(() -> container.getInstance(String.class, name))` with `.orElse(null)`; re-run: the `testGlobalValue*` tests FAIL. Revert [...] + +- [ ] **Step 9: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/config/ScopedConstantProvider.java \ + core/src/main/java/org/apache/struts2/config/StrutsScopedConstantProvider.java \ + core/src/main/java/org/apache/struts2/StrutsConstants.java \ + core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java \ + core/src/main/resources/struts-beans.xml \ + core/src/main/java/org/apache/struts2/config/providers/StrutsDefaultConfigurationProvider.java \ + core/src/test/java/org/apache/struts2/config/StrutsScopedConstantProviderTest.java \ + core/src/test/resources/org/apache/struts2/config/scoped-constants-provider.xml +git diff --cached --name-only +git commit -m "WW-3476 feat(config): add ScopedConstantProvider resolving package overrides + +Co-Authored-By: Claude Opus 5.5 <[email protected]>" +``` + +--- + +### Task 5: Full verification + +**Files:** none changed unless a regression appears. + +- [ ] **Step 1: Check for stray files** — `git status --short` must show no `??` entries from this work (review agents may leave probe tests; delete those, never commit them). + +- [ ] **Step 2: Run core and the Convention plugin** (Convention compares `PackageConfig`s with `equals`, which Task 1 changed): + +Run: `./mvnw test -DskipAssembly -pl core,plugins/convention > core/target/ww3476-full.log 2>&1; echo "exit=$?"; grep -E "Tests run:.*Fail|BUILD|ERROR\]" core/target/ww3476-full.log | tail -10` +Expected: `exit=0`, `BUILD SUCCESS`. If a failure is in `DateTest.testJavaSqlDate` or another test unrelated to config, re-run that single test on `main` to confirm it is pre-existing before reporting it. + +- [ ] **Step 3: Confirm no adopter slipped in** — `grep -rn "ScopableConstants" core/src/main plugins/*/src/main` must list only `ScopableConstants.java`, `StrutsScopedConstantProvider.java`, `ScopedConstantProvider.java`, `DefaultConfiguration.java` — no `<bean type="...ScopableConstants"` in any `src/main` XML. + +- [ ] **Step 4: Hand back** — report the commits and test results. Not part of this plan (each needs the user's go-ahead): the `/security-review` pass and PR (`WW-3476 feat(config): add package-scoped constants`), and the struts-site PR publishing `dtds/struts-7.5.dtd` with docs for `<scoped-constant>`. diff --git a/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md b/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md index f6a6be2e8..8a5c08c55 100644 --- a/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md +++ b/docs/superpowers/specs/2026-10-10-WW-3476-scoped-constants-design.md @@ -36,7 +36,6 @@ mechanism that fits 7.x. - No Convention-plugin annotation. Convention-built packages extend an XML parent package and inherit its overrides. - No typed conversion — values are `String`, as with `container.getInstance(String.class, name)`. -- No user-overridable provider (no `StrutsBeanSelectionProvider` alias / new constant) until there is a need. ## Inherently non-scopable constants @@ -120,8 +119,11 @@ public interface ScopedConstantProvider { } ``` -Default implementation `StrutsScopedConstantProvider`, bound in `struts-beans.xml`. It injects `Container` and -`Configuration` (the latter is a container factory, `DefaultConfiguration.java:291`). `getValue(name)`: +Default implementation `StrutsScopedConstantProvider`, wired like every other pluggable core service +(`ParameterAuthorizer`, `OgnlGuard`): `name="struts"` in `struts-beans.xml`, aliased in `StrutsBeanSelectionProvider` +through a new `StrutsConstants.STRUTS_SCOPED_CONSTANT_PROVIDER = "struts.scopedConstantProvider"`, and registered +in `StrutsDefaultConfigurationProvider` for the test harness. It injects `Container` and `Configuration` (the +latter is a container factory, `DefaultConfiguration.java:291`). `getValue(name)`: 1. `name` not in the registry → `IllegalArgumentException`. Only an adopter's coding error reaches this; user configuration cannot. @@ -167,7 +169,8 @@ JUnit 3/4 only — match each target file's existing style; no Jupiter. ## Compatibility -Additive only: a new DTD, a new element, two new interfaces, one new default bean, new `PackageConfig` methods. +Additive only: a new DTD, a new element, two new interfaces, one new default bean with its selection constant, +new `PackageConfig` methods. Existing constructors, builders, interfaces and published DTDs are untouched — suitable for the 7.x line. ## Delivery
