From fc0f4235a5d5090255f7b392d973ec9e795108a8 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 17:24:28 +0100
Subject: [PATCH 01/33] Update packages
configs' trunk moved to b107001, where Dependabot's bump of
zizmorcore/zizmor-action reaches its reusable workflow checks, while
the framework's workflows, npm pin and locks held c944e73, so the
framework's Workflow Checks ran the older action.
The 17 workflow refs, the npm pin, the root lock and the seven fixture
locks move to configs b107001 together, and the fixture locks take the
path packages' current references. No other Composer package has a
newer release. npm update offered only hoisting changes and a downgrade
of @babel/helper-create-regexp-features-plugin to the release the
host's seven-day cooldown shows, so the npm lock moves only the configs
pin.
Assisted-by: Claude Code:claude-opus-5-5
---
.github/workflows/audit.yml | 2 +-
.github/workflows/codeql.yml | 2 +-
.github/workflows/quality.yml | 10 +++----
.github/workflows/tests-nightly.yml | 6 ++--
.github/workflows/tests.yml | 12 ++++----
.github/workflows/workflow-checks.yml | 2 +-
composer.lock | 20 ++++++-------
package-lock.json | 4 +--
package.json | 2 +-
tests/Fixtures/consumer-a/composer.lock | 28 +++++++++----------
tests/Fixtures/consumer-b/composer.lock | 16 +++++------
.../Fixtures/personas/bootstrap/composer.lock | 10 +++----
tests/Fixtures/personas/core/composer.lock | 14 +++++-----
.../Fixtures/personas/settings/composer.lock | 16 +++++------
tests/Fixtures/personas/shared/composer.lock | 16 +++++++----
.../personas/woocommerce/composer.lock | 28 +++++++++----------
16 files changed, 96 insertions(+), 92 deletions(-)
diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
index 65dc126..fb977a4 100644
--- a/.github/workflows/audit.yml
+++ b/.github/workflows/audit.yml
@@ -18,4 +18,4 @@ jobs:
audit:
name: Audit
# npm's dev-dependency advisories surface through Dependabot alerts instead of failing this gate.
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@b107001cce9f2960495d9d50481c8540d228f60c
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 291b06e..1e96c4e 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -21,4 +21,4 @@ jobs:
actions: read
contents: read
security-events: write
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@b107001cce9f2960495d9d50481c8540d228f60c
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 5d3394d..e2f4882 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -16,7 +16,7 @@ jobs:
name: Lint PHP
permissions:
contents: read
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan", "changelog:validate"]'
@@ -24,7 +24,7 @@ jobs:
name: Lint scripts
permissions:
contents: read
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
scripts: '["lint:scripts"]'
@@ -32,7 +32,7 @@ jobs:
name: PHP 7.4 syntax (bootstrap)
permissions:
contents: read
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-versions: '["7.4"]'
paths: '["packages/bootstrap"]'
@@ -76,7 +76,7 @@ jobs:
needs: consumer-a-build
permissions:
contents: read
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-plugin-check.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-plugin-check.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
artifact: consumer-a
plugin-slug: acme-consumer-a
@@ -88,7 +88,7 @@ jobs:
needs: consumer-a-build
permissions:
contents: read
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
artifact: consumer-a
php-versions: '["7.4"]'
diff --git a/.github/workflows/tests-nightly.yml b/.github/workflows/tests-nightly.yml
index 409b541..e0f69ec 100644
--- a/.github/workflows/tests-nightly.yml
+++ b/.github/workflows/tests-nightly.yml
@@ -12,7 +12,7 @@ permissions:
jobs:
integration:
name: Integration
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip'
@@ -21,7 +21,7 @@ jobs:
integration-multisite:
name: Integration (multisite)
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip'
@@ -30,7 +30,7 @@ jobs:
integration-woocommerce-nightly:
name: Integration (WooCommerce nightly)
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
composer-script: 'test:integration:woocommerce-nightly:run'
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index ab9f25b..e1fb42f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -16,7 +16,7 @@ permissions:
jobs:
unit:
name: Unit
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
composer-script: 'test:unit'
@@ -51,7 +51,7 @@ jobs:
fail-fast: false
matrix:
wp: ${{ fromJSON(needs.wordpress.outputs.versions) }}
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
wp-version: ${{ matrix.wp }}
@@ -65,7 +65,7 @@ jobs:
fail-fast: false
matrix:
wp: ${{ fromJSON(needs.wordpress.outputs.versions) }}
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
wp-version: ${{ matrix.wp }}
@@ -79,7 +79,7 @@ jobs:
fail-fast: false
matrix:
wp: ${{ fromJSON(needs.wordpress.outputs.versions) }}
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
artifact-slug: wp-${{ matrix.wp }}
php-version: '8.5'
@@ -89,7 +89,7 @@ jobs:
integration-php74:
name: Integration (PHP 7.4)
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
composer-script: 'test:integration:php74:run'
@@ -97,7 +97,7 @@ jobs:
prefer-lowest:
name: Prefer lowest
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-version: '8.5'
composer-script: 'test:integration:lowest:run'
diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml
index d658c40..e954a68 100644
--- a/.github/workflows/workflow-checks.yml
+++ b/.github/workflows/workflow-checks.yml
@@ -24,4 +24,4 @@ jobs:
contents: read
security-events: write
actions: read
- uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@c944e73ab2656633397e7736ea104bbe72726ffd
+ uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@b107001cce9f2960495d9d50481c8540d228f60c
diff --git a/composer.lock b/composer.lock
index 1919c8e..8f22bc7 100644
--- a/composer.lock
+++ b/composer.lock
@@ -148,7 +148,7 @@
"dist": {
"type": "path",
"url": "packages/woocommerce",
- "reference": "657eb2dbc0484dae31ff8c97c7786716c9090c17"
+ "reference": "390b60d89ae0ec911501d74ebd77e7255cafc462"
},
"require": {
"ahegyes/wp-framework-settings": "^2.0",
@@ -159,14 +159,14 @@
"branch-alias": {
"dev-trunk": "2.x-dev"
},
- "scoping-stubs": [
- "php-stubs/woocommerce-stubs"
- ],
- "scoping-text-domain": "wp-framework",
"requires-wp": "7.1",
"requires-plugins": [
"woocommerce"
- ]
+ ],
+ "scoping-stubs": [
+ "php-stubs/woocommerce-stubs"
+ ],
+ "scoping-text-domain": "wp-framework"
},
"autoload": {
"psr-4": {
@@ -397,12 +397,12 @@
"source": {
"type": "git",
"url": "https://github.com/ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -530,7 +530,7 @@
"source": "https://github.com/ahegyes/wordpress-configs/tree/trunk",
"issues": "https://github.com/ahegyes/wordpress-configs/issues"
},
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "automattic/jetpack-changelogger",
diff --git a/package-lock.json b/package-lock.json
index ec29922..220ebec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,7 @@
"name": "@ahegyes/wordpress-framework",
"license": "GPL-3.0-or-later",
"devDependencies": {
- "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#c944e73ab2656633397e7736ea104bbe72726ffd",
+ "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#b107001cce9f2960495d9d50481c8540d228f60c",
"@playwright/test": "^1.64.1",
"@wordpress/e2e-test-utils-playwright": "^3.1.0",
"@wordpress/env": "^12.0.0",
@@ -20,7 +20,7 @@
},
"node_modules/@ahegyes/wordpress-configs": {
"version": "0.0.0",
- "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#c944e73ab2656633397e7736ea104bbe72726ffd",
+ "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#b107001cce9f2960495d9d50481c8540d228f60c",
"integrity": "sha512-biLQ7ozE+hfclZpFvoAb7WbWDcmdI1poZJayP5vjQ/d8yYbti3hCEnBKKyUE6Q09hBlH60vDsrS2GsKVELIzOg==",
"dev": true,
"license": "MIT",
diff --git a/package.json b/package.json
index 78d2ac2..2f9cf46 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"npm": ">=11"
},
"devDependencies": {
- "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#c944e73ab2656633397e7736ea104bbe72726ffd",
+ "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#b107001cce9f2960495d9d50481c8540d228f60c",
"@playwright/test": "^1.64.1",
"@wordpress/e2e-test-utils-playwright": "^3.1.0",
"@wordpress/env": "^12.0.0",
diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock
index b72bf6d..30f3b0b 100644
--- a/tests/Fixtures/consumer-a/composer.lock
+++ b/tests/Fixtures/consumer-a/composer.lock
@@ -13,12 +13,12 @@
"source": {
"type": "git",
"url": "https://github.com/ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -146,7 +146,7 @@
"source": "https://github.com/ahegyes/wordpress-configs/tree/trunk",
"issues": "https://github.com/ahegyes/wordpress-configs/issues"
},
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -154,7 +154,7 @@
"dist": {
"type": "path",
"url": "../../../packages/bootstrap",
- "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615"
+ "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46"
},
"require": {
"php": ">=7.4"
@@ -182,7 +182,7 @@
"dist": {
"type": "path",
"url": "../../../packages/core",
- "reference": "825003655db71a41d3a6b41621d06b1bca993d63"
+ "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e"
},
"require": {
"ahegyes/wp-framework-bootstrap": "^2.0",
@@ -223,7 +223,7 @@
"dist": {
"type": "path",
"url": "../../../packages/settings",
- "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a"
+ "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802"
},
"require": {
"ahegyes/wp-framework-core": "^2.0",
@@ -258,7 +258,7 @@
"dist": {
"type": "path",
"url": "../../../packages/shared",
- "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e"
+ "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf"
},
"require": {
"php": ">=8.5"
@@ -290,7 +290,7 @@
"dist": {
"type": "path",
"url": "../../../packages/woocommerce",
- "reference": "598819f5082d70ecf181172606d6dd2041e75fdb"
+ "reference": "79d6aea22490f658088fcea374c11a136074961f"
},
"require": {
"ahegyes/wp-framework-settings": "^2.0",
@@ -301,14 +301,14 @@
"branch-alias": {
"dev-trunk": "2.x-dev"
},
- "scoping-stubs": [
- "php-stubs/woocommerce-stubs"
- ],
- "scoping-text-domain": "wp-framework",
"requires-wp": "7.1",
"requires-plugins": [
"woocommerce"
- ]
+ ],
+ "scoping-stubs": [
+ "php-stubs/woocommerce-stubs"
+ ],
+ "scoping-text-domain": "wp-framework"
},
"autoload": {
"psr-4": {
diff --git a/tests/Fixtures/consumer-b/composer.lock b/tests/Fixtures/consumer-b/composer.lock
index ca6789f..c6836c7 100644
--- a/tests/Fixtures/consumer-b/composer.lock
+++ b/tests/Fixtures/consumer-b/composer.lock
@@ -13,12 +13,12 @@
"source": {
"type": "git",
"url": "https://github.com/ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -146,7 +146,7 @@
"source": "https://github.com/ahegyes/wordpress-configs/tree/trunk",
"issues": "https://github.com/ahegyes/wordpress-configs/issues"
},
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -154,7 +154,7 @@
"dist": {
"type": "path",
"url": "../../../packages/bootstrap",
- "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615"
+ "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46"
},
"require": {
"php": ">=7.4"
@@ -182,7 +182,7 @@
"dist": {
"type": "path",
"url": "../../../packages/core",
- "reference": "825003655db71a41d3a6b41621d06b1bca993d63"
+ "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e"
},
"require": {
"ahegyes/wp-framework-bootstrap": "^2.0",
@@ -223,7 +223,7 @@
"dist": {
"type": "path",
"url": "../../../packages/settings",
- "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a"
+ "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802"
},
"require": {
"ahegyes/wp-framework-core": "^2.0",
@@ -258,7 +258,7 @@
"dist": {
"type": "path",
"url": "../../../packages/shared",
- "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e"
+ "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf"
},
"require": {
"php": ">=8.5"
diff --git a/tests/Fixtures/personas/bootstrap/composer.lock b/tests/Fixtures/personas/bootstrap/composer.lock
index 0cc920b..e082697 100644
--- a/tests/Fixtures/personas/bootstrap/composer.lock
+++ b/tests/Fixtures/personas/bootstrap/composer.lock
@@ -13,12 +13,12 @@
"source": {
"type": "git",
"url": "https://github.com/ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -146,7 +146,7 @@
"source": "https://github.com/ahegyes/wordpress-configs/tree/trunk",
"issues": "https://github.com/ahegyes/wordpress-configs/issues"
},
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -154,7 +154,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/bootstrap",
- "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615"
+ "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46"
},
"require": {
"php": ">=7.4"
diff --git a/tests/Fixtures/personas/core/composer.lock b/tests/Fixtures/personas/core/composer.lock
index fcabd2b..a7ac4ca 100644
--- a/tests/Fixtures/personas/core/composer.lock
+++ b/tests/Fixtures/personas/core/composer.lock
@@ -13,12 +13,12 @@
"source": {
"type": "git",
"url": "git@github.com:ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -142,7 +142,7 @@
}
],
"description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.",
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -150,7 +150,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/bootstrap",
- "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615"
+ "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46"
},
"require": {
"php": ">=7.4"
@@ -178,7 +178,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/core",
- "reference": "825003655db71a41d3a6b41621d06b1bca993d63"
+ "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e"
},
"require": {
"ahegyes/wp-framework-bootstrap": "^2.0",
@@ -219,7 +219,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/shared",
- "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e"
+ "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf"
},
"require": {
"php": ">=8.5"
diff --git a/tests/Fixtures/personas/settings/composer.lock b/tests/Fixtures/personas/settings/composer.lock
index e74a928..835d7dc 100644
--- a/tests/Fixtures/personas/settings/composer.lock
+++ b/tests/Fixtures/personas/settings/composer.lock
@@ -13,12 +13,12 @@
"source": {
"type": "git",
"url": "git@github.com:ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -142,7 +142,7 @@
}
],
"description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.",
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -150,7 +150,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/bootstrap",
- "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615"
+ "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46"
},
"require": {
"php": ">=7.4"
@@ -178,7 +178,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/core",
- "reference": "825003655db71a41d3a6b41621d06b1bca993d63"
+ "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e"
},
"require": {
"ahegyes/wp-framework-bootstrap": "^2.0",
@@ -219,7 +219,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/settings",
- "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a"
+ "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802"
},
"require": {
"ahegyes/wp-framework-core": "^2.0",
@@ -254,7 +254,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/shared",
- "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e"
+ "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf"
},
"require": {
"php": ">=8.5"
diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock
index d47c7c3..61b049f 100644
--- a/tests/Fixtures/personas/shared/composer.lock
+++ b/tests/Fixtures/personas/shared/composer.lock
@@ -12,13 +12,13 @@
"version": "dev-trunk",
"source": {
"type": "git",
- "url": "git@github.com:ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "url": "https://github.com/ahegyes/wordpress-configs.git",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -142,7 +142,11 @@
}
],
"description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.",
- "time": "2026-11-07T10:23:14+00:00"
+ "support": {
+ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk",
+ "issues": "https://github.com/ahegyes/wordpress-configs/issues"
+ },
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-shared",
@@ -150,7 +154,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/shared",
- "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e"
+ "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf"
},
"require": {
"php": ">=8.5"
diff --git a/tests/Fixtures/personas/woocommerce/composer.lock b/tests/Fixtures/personas/woocommerce/composer.lock
index baf2de0..1e76be5 100644
--- a/tests/Fixtures/personas/woocommerce/composer.lock
+++ b/tests/Fixtures/personas/woocommerce/composer.lock
@@ -13,12 +13,12 @@
"source": {
"type": "git",
"url": "git@github.com:ahegyes/wordpress-configs.git",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd"
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/c944e73ab2656633397e7736ea104bbe72726ffd",
- "reference": "c944e73ab2656633397e7736ea104bbe72726ffd",
+ "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/b107001cce9f2960495d9d50481c8540d228f60c",
+ "reference": "b107001cce9f2960495d9d50481c8540d228f60c",
"shasum": ""
},
"require": {
@@ -142,7 +142,7 @@
}
],
"description": "Shared tooling that lets WordPress projects be checked, scoped and released the same way.",
- "time": "2026-11-07T10:23:14+00:00"
+ "time": "2026-11-09T15:51:41+00:00"
},
{
"name": "ahegyes/wp-framework-bootstrap",
@@ -150,7 +150,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/bootstrap",
- "reference": "7e2974207d40704338c7e945fd1057dd6f2bb615"
+ "reference": "7b8b66dbf1796b00158204faf14698cc5f875a46"
},
"require": {
"php": ">=7.4"
@@ -178,7 +178,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/core",
- "reference": "825003655db71a41d3a6b41621d06b1bca993d63"
+ "reference": "d44c1620202b9c0bcfba1cd6afd8791327a3523e"
},
"require": {
"ahegyes/wp-framework-bootstrap": "^2.0",
@@ -219,7 +219,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/settings",
- "reference": "adf1c74fb62e86f191024a94a2fe9ed831ee010a"
+ "reference": "91a0b9646ff54feb4dd17a2cd31db78a35aee802"
},
"require": {
"ahegyes/wp-framework-core": "^2.0",
@@ -254,7 +254,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/shared",
- "reference": "36f6c7a8ce0f9669d025e8935b6310ae2131537e"
+ "reference": "0d30eb24a6ff8c14fd8ae445ee4fc7efb8cd2dbf"
},
"require": {
"php": ">=8.5"
@@ -286,7 +286,7 @@
"dist": {
"type": "path",
"url": "../../../../packages/woocommerce",
- "reference": "598819f5082d70ecf181172606d6dd2041e75fdb"
+ "reference": "79d6aea22490f658088fcea374c11a136074961f"
},
"require": {
"ahegyes/wp-framework-settings": "^2.0",
@@ -297,14 +297,14 @@
"branch-alias": {
"dev-trunk": "2.x-dev"
},
- "scoping-stubs": [
- "php-stubs/woocommerce-stubs"
- ],
- "scoping-text-domain": "wp-framework",
"requires-wp": "7.1",
"requires-plugins": [
"woocommerce"
- ]
+ ],
+ "scoping-stubs": [
+ "php-stubs/woocommerce-stubs"
+ ],
+ "scoping-text-domain": "wp-framework"
},
"autoload": {
"psr-4": {
From 82bf8ca6bcd75c1321214dfd790999b0f8fe6ac2 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:01 +0100
Subject: [PATCH 02/33] Test the guards that no row catches
Audit batches framework-B7, tests-B1 and tests-B2 list guards whose
deletion no row notices: the pre-6.4 fallback of
Requirements::print_admin_notice(), two updater cache and header guards,
GuardedLogger's pass-through, raw error-log record and level check, the
cron-expression branch of ensure_recurring(), a lock released between
the failed insert and the read, a consent row under option_prefixes
after a failed run, the ABSPATH guard after each use block, a subsite
pass beside the main site's footprint, aria-describedby on textarea and
select controls, two tabs sharing one option, the boot-failure
throttle's hour, ErrorLogLogger's production threshold, the README
excerpt check's margin, the packages' extra floors, development-only
names in the closure check, leading zeros past the major and a notice
that has not expired yet.
Each guard gets a row. The bootstrap persona's notice prints through
Requirements::print_admin_notice(), so BootstrapPersonaTest pins the
fallback's markup, and ThresholdLogger, which stood in for
ErrorLogLogger, goes with its file.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Fixtures/personas/bootstrap/functions.php | 8 +-
tests/Integration/Bootstrap/UpdaterTest.php | 29 ++++++
.../Core/AdminNotice/AdminNoticesTest.php | 8 ++
.../Core/Logging/GuardedLoggerTest.php | 89 +++++++++++++++++++
tests/Integration/Core/PluginBootTest.php | 1 +
.../Core/Scheduling/SchedulerTest.php | 11 +++
tests/Integration/Core/Storage/LockTest.php | 10 +++
.../Uninstall/UninstallerOnNetworkTest.php | 64 +++++++++++++
.../Core/Uninstall/UninstallerTest.php | 19 ++--
.../Settings/AdminPage/OptionsPageTest.php | 23 +++++
.../Settings/FormsRenderControlTest.php | 17 ++++
.../Unit/Core/Logging/AbstractLoggerTest.php | 30 -------
.../Unit/Core/Logging/ErrorLogLoggerTest.php | 26 ++++++
tests/Unit/PackageManifestsTest.php | 87 +++++++++++++++++-
tests/Unit/PackageReadmesTest.php | 1 +
tests/Unit/Shared/Versioning/VersionTest.php | 21 +++--
16 files changed, 388 insertions(+), 56 deletions(-)
create mode 100644 tests/Integration/Core/Logging/GuardedLoggerTest.php
delete mode 100644 tests/Unit/Core/Logging/AbstractLoggerTest.php
create mode 100644 tests/Unit/Core/Logging/ErrorLogLoggerTest.php
diff --git a/tests/Fixtures/personas/bootstrap/functions.php b/tests/Fixtures/personas/bootstrap/functions.php
index f40ed66..4b456b2 100644
--- a/tests/Fixtures/personas/bootstrap/functions.php
+++ b/tests/Fixtures/personas/bootstrap/functions.php
@@ -8,6 +8,8 @@
* @package Acme\PersonaBootstrap
*/
+use Acme\PersonaBootstrap\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements;
+
defined( 'ABSPATH' ) || exit;
add_action( 'admin_notices', 'acme_pb_render_notice' );
@@ -21,9 +23,5 @@
* @return void
*/
function acme_pb_render_notice(): void {
- if ( ! current_user_can( 'activate_plugins' ) ) {
- return;
- }
-
- printf( '
', esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ) );
+ Requirements::print_admin_notice( esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ), array( 'type' => 'info' ) );
}
diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php
index cedfbe9..3ee8adc 100644
--- a/tests/Integration/Bootstrap/UpdaterTest.php
+++ b/tests/Integration/Bootstrap/UpdaterTest.php
@@ -202,6 +202,35 @@ public function test_a_cached_record_without_a_package_is_fetched_again(): void
self::assertSame( $this->asset_url( 'v2.0.0' ), get_transient( "{$this->slug}_github_release_stable" )['package'] ?? null );
}
+ public function test_a_cached_record_holding_a_field_of_another_type_is_fetched_again(): void {
+ Updater::register( $this->write_plugin( $this->slug ) );
+ set_transient(
+ "{$this->slug}_github_release_stable",
+ array(
+ 'version' => 9,
+ 'package' => $this->asset_url( 'v9.0.0' ),
+ ),
+ HOUR_IN_SECONDS
+ );
+ $this->stage_latest( $this->release( 'v2.0.0' ) );
+
+ $offer = $this->check_for_updates()->response[ $this->basename() ] ?? null;
+
+ self::assertSame( '2.0.0', $offer->new_version ?? null );
+ self::assertCount( 2, $this->requests );
+ }
+
+ public function test_a_requirement_past_the_first_8_kb_of_the_entry_file_reads_as_none(): void {
+ Updater::register( $this->write_plugin( $this->slug ) );
+ $this->stage_latest( $this->release( 'v2.0.0' ) );
+ $this->stage( $this->raw_url( 'v2.0.0' ), 200, "check_for_updates()->response[ $this->basename() ] ?? null;
+
+ self::assertIsObject( $offer );
+ self::assertSame( '', $offer->requires_php );
+ }
+
public function test_another_plugins_check_passes_through_without_a_request(): void {
Updater::register( $this->write_plugin( $this->slug ) );
$this->write_plugin( "$this->slug-other" );
diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
index c3c5c4d..14f177a 100644
--- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
+++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php
@@ -294,6 +294,14 @@ public function test_an_expired_notice_is_not_listed_and_does_not_show(): void {
self::assertSame( array(), $notices->get_notices( NoticeScope::Request ) );
}
+ public function test_a_notice_that_has_not_expired_yet_is_listed_and_shows(): void {
+ $notices = $this->notices();
+ $notices->add( $this->notice( 'new', NoticeScope::Site, persistent: true, expires_at: \time() + HOUR_IN_SECONDS ) );
+
+ self::assertSame( array( 'new' ), self::ids( $notices->get_notices( NoticeScope::Site ) ) );
+ self::assertSame( array( $this->dom_id( 'site', 'new' ) ), \array_keys( self::notices_in( $this->render( $this->admin, $notices ) ) ) );
+ }
+
public function test_the_visibility_filter_hides_a_notice_without_consuming_it_unless_it_returns_true(): void {
$calls = array();
add_filter(
diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php
new file mode 100644
index 0000000..2aa66ea
--- /dev/null
+++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php
@@ -0,0 +1,89 @@
+records[] = array( $level, $message, $context );
+ }
+}
+
+final class ThrowingLogger extends PsrAbstractLogger {
+ public function __construct(
+ protected \Throwable $failure
+ ) {}
+
+ #[\Override]
+ public function log( mixed $level, string|\Stringable $message, array $context = array() ): void {
+ throw $this->failure;
+ }
+}
+
+#[CoversClass( GuardedLogger::class )]
+final class GuardedLoggerTest extends TestCase {
+ protected string $log_file;
+
+ protected string|false $previous_log_file;
+
+ #[\Override]
+ protected function setUp(): void {
+ $this->log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' );
+ $this->previous_log_file = \ini_set( 'error_log', $this->log_file );
+ }
+
+ #[\Override]
+ protected function tearDown(): void {
+ \ini_set( 'error_log', (string) $this->previous_log_file );
+ wp_delete_file( $this->log_file );
+ }
+
+ public function test_a_record_passes_to_the_wrapped_logger_unchanged_and_skips_the_error_log(): void {
+ $inner = new RecordingLogger();
+
+ ( new GuardedLogger( $inner, 'acme' ) )->log( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) );
+
+ self::assertSame( array( array( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $inner->records );
+ self::assertSame( '', \file_get_contents( $this->log_file ) );
+ }
+
+ public function test_a_record_whose_logger_throws_reaches_the_error_log_raw_with_its_context_as_json_and_the_logging_failure(): void {
+ $exception = new \RuntimeException( 'Cache is cold.' );
+ $failure = new \UnexpectedValueException( 'The log is not writable.' );
+
+ ( new GuardedLogger( new ThrowingLogger( $failure ), 'acme' ) )->error(
+ 'Warm-up of {name} failed.',
+ array(
+ 'name' => 'acme',
+ 'exception' => $exception,
+ )
+ );
+
+ $log = (string) \file_get_contents( $this->log_file );
+ self::assertStringContainsString( '[acme.error] Warm-up of {name} failed. {"name":"acme"}' . "\n" . 'RuntimeException: Cache is cold. in ' . __FILE__ . ':' . $exception->getLine(), $log );
+ self::assertStringContainsString( 'The logger failed: UnexpectedValueException: The log is not writable. in ' . __FILE__ . ':' . $failure->getLine(), $log );
+ }
+
+ public function test_a_level_psr_3_does_not_define_throws_before_the_wrapped_logger_is_called(): void {
+ $inner = new RecordingLogger();
+
+ try {
+ ( new GuardedLogger( $inner, 'acme' ) )->log( 'verbose', 'Cache is cold.' );
+ self::fail( 'The level was accepted.' );
+ } catch ( PsrInvalidArgumentException $exception ) {
+ self::assertSame( "Log level 'verbose' is not a PSR-3 level.", $exception->getMessage() );
+ }
+
+ self::assertSame( array(), $inner->records );
+ self::assertSame( '', \file_get_contents( $this->log_file ) );
+ }
+}
diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php
index 8ce4683..9b60b0b 100644
--- a/tests/Integration/Core/PluginBootTest.php
+++ b/tests/Integration/Core/PluginBootTest.php
@@ -144,6 +144,7 @@ public function test_the_same_failure_is_logged_once_an_hour(): void {
self::assertSame( 'error', $this->logger->records[0][0] );
self::assertSame( 'Boot failed; the plugin did not finish starting: {message}', $this->logger->records[0][1] );
self::assertSame( 'first failure', $this->logger->records[0][2]['message'] );
+ self::assertEqualsWithDelta( \time() + HOUR_IN_SECONDS, (int) get_option( '_transient_timeout_acme_boot_failure_' . \md5( 'first failure' ) ), 5 );
}
public function test_distinct_failures_are_each_logged_once(): void {
diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php
index 1f4a816..db2d8dc 100644
--- a/tests/Integration/Core/Scheduling/SchedulerTest.php
+++ b/tests/Integration/Core/Scheduling/SchedulerTest.php
@@ -764,6 +764,17 @@ static function ( mixed ...$passed ) use ( &$received ): void {
self::assertSame( array( $args ), $received );
}
+ public function test_ensuring_replaces_a_cron_expression_chain_with_one_interval_chain(): void {
+ as_schedule_cron_action( \time(), '0 * * * *', $this->hook, array(), self::GROUP );
+
+ self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3600 ) ) );
+
+ $pending = $this->pending_action_ids();
+ self::assertCount( 1, $pending );
+ self::assertInstanceOf( \ActionScheduler_IntervalSchedule::class, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule() );
+ self::assertSame( 3600, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_recurrence() );
+ }
+
protected static function assert_wp_error( mixed $actual, string $code ): void {
self::assertInstanceOf( \WP_Error::class, $actual );
self::assertSame( $code, $actual->get_error_code() );
diff --git a/tests/Integration/Core/Storage/LockTest.php b/tests/Integration/Core/Storage/LockTest.php
index c37d482..94bdefb 100644
--- a/tests/Integration/Core/Storage/LockTest.php
+++ b/tests/Integration/Core/Storage/LockTest.php
@@ -113,6 +113,16 @@ static function () use ( $lock, &$claims ): void {
self::assertSame( self::hold( 'third', 1800000061 ), $this->stored() );
}
+ public function test_a_hold_released_between_the_failed_insert_and_the_read_is_busy(): void {
+ $lock = $this->make_lock();
+ $lock->acquire( 'first' );
+ $this->clock->advance( 10 );
+ $this->before_query( 'SELECT option_value', static fn () => $lock->release( 'first' ) );
+
+ self::assertSame( LockClaim::Busy, $lock->acquire( 'second' ) );
+ self::assertNull( $this->stored() );
+ }
+
public function test_refreshing_extends_the_hold_to_a_full_ttl_from_now(): void {
$lock = $this->make_lock();
$lock->acquire( 'first' );
diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
index fe0261c..779b463 100644
--- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
@@ -65,6 +65,70 @@ public function test_a_subsite_only_plugin_deleted_from_the_network_admin_cleans
self::assertSame( array( "cleanup:site:$this->other_site" ), $this->summarize_calls() );
}
+ public function test_a_subsite_pass_leaves_the_main_sites_prefixed_options_site_user_meta_meta_tables_and_tables_alone(): void {
+ $p = $this->prefix;
+ $sites = array( $this->main_site, $this->other_site );
+ $state = fn ( int $site_id ): array => $this->on_site(
+ $site_id,
+ function () use ( $p ): array {
+ global $wpdb;
+
+ return array(
+ $this->option_exists( "{$p}_cache_a" ),
+ $this->user_meta_exists( $wpdb->get_blog_prefix() . "{$p}_site_a" ),
+ '0' !== $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE meta_key = %s', "$wpdb->prefix{$p}_itemmeta", "{$p}_flag" ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the test's own table.
+ $this->table_exists( "{$p}_log" ),
+ );
+ }
+ );
+ foreach ( $sites as $site_id ) {
+ $this->on_site(
+ $site_id,
+ function () use ( $p ): void {
+ global $wpdb;
+
+ update_option( "{$p}_cache_a", 'x' );
+ add_user_meta( $this->user, $wpdb->get_blog_prefix() . "{$p}_site_a", 'x' );
+ $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i ( id INT )', "$wpdb->prefix{$p}_log" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Creates the test's own table.
+ $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i ( meta_id INT, meta_key VARCHAR(255) )', "$wpdb->prefix{$p}_itemmeta" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Creates the test's own table.
+ $wpdb->insert( "$wpdb->prefix{$p}_itemmeta", array( 'meta_key' => "{$p}_flag" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Seeds the test's own table.
+ }
+ );
+ }
+ $this->on_site( $this->other_site, fn (): bool => update_option( "{$p}_remove_data", 'yes' ) );
+
+ try {
+ Uninstaller::run(
+ self::PLUGIN_FILE,
+ array(
+ 'option_prefixes' => array( "{$p}_cache_" ),
+ 'meta' => array( 'item' => array( "{$p}_flag" ) ),
+ 'user_meta_site_prefixes' => array( "{$p}_site_" ),
+ 'tables' => array( "{$p}_log" ),
+ ),
+ array( 'meta_types' => array( 'item' => array( 'meta_tables' => array( "{$p}_itemmeta" ) ) ) ) + $this->args()
+ );
+ $main = $state( $this->main_site );
+ $other = $state( $this->other_site );
+ } finally {
+ foreach ( $sites as $site_id ) {
+ $this->on_site(
+ $site_id,
+ function () use ( $p ): void {
+ global $wpdb;
+
+ foreach ( array( "{$p}_log", "{$p}_itemmeta" ) as $table ) {
+ $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Drops the test's own tables.
+ }
+ }
+ );
+ }
+ }
+
+ self::assertSame( array( true, true, true, true ), $main );
+ self::assertSame( array( false, false, false, false ), $other );
+ }
+
public static function constant_consents(): array {
return array(
'with-the-constant' => array( true ),
diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php
index 2cd3aad..a096eb4 100644
--- a/tests/Integration/Core/Uninstall/UninstallerTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerTest.php
@@ -253,20 +253,21 @@ public function test_an_action_scheduler_hook_or_group_named_0_cancels_no_other_
public static function failing_statements(): array {
return array(
- 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings' ),
- 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', 'option_exists', '_cache_a' ),
- 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', 'post_type_has_posts', '_type' ),
- 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', 'network_option_exists', '_network_settings' ),
+ 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings' ),
+ 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', 'option_exists', '_cache_a' ),
+ 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', 'post_type_has_posts', '_type' ),
+ 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', 'network_option_exists', '_network_settings' ),
+ 'an-option-delete-with-prefix-consent' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings', '_cache_remove_data' ),
);
}
#[DataProvider( 'failing_statements' )]
- public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, string $kept, string $row ): void {
+ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, string $kept, string $row, string $consent = '_remove_data' ): void {
global $wpdb;
$this->seed_footprint();
- update_option( "{$this->prefix}_remove_data", 'yes' );
- $args = array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) + $this->recording_cleanup();
+ update_option( $this->prefix . $consent, 'yes' );
+ $args = array( 'consent' => array( 'option' => $this->prefix . $consent ) ) + $this->recording_cleanup();
$this->fail_queries( $needle( $wpdb, $this->prefix ) );
$failure = null;
@@ -281,14 +282,14 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t
self::assertTrue( $this->$kept( $this->prefix . $row ) );
self::assertFalse( $this->table_exists( "{$this->prefix}_log" ) );
self::assertSame( array( 'cleanup' ), \array_column( $this->calls, 0 ) );
- self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) );
+ self::assertTrue( $this->option_exists( $this->prefix . $consent ) );
remove_all_filters( 'query' );
$this->restore_query_errors();
Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args );
self::assertFalse( $this->$kept( $this->prefix . $row ) );
- self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) );
+ self::assertFalse( $this->option_exists( $this->prefix . $consent ) );
}
public static function refused_deletes(): array {
diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
index 9b5eb17..ad923ed 100644
--- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php
+++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
@@ -586,6 +586,29 @@ public function test_each_tab_shows_its_own_form_and_an_unknown_tab_shows_the_fi
self::assertNull( $single->querySelector( '.nav-tab' ) );
}
+ public function test_saving_the_second_of_two_tabs_on_one_option_keeps_the_first_tabs_field(): void {
+ $spec = $this->spec(
+ array( self::field( 'text', 'name' ) ),
+ array(
+ 'tabs' => array(
+ self::tab( 'general', $this->option, array( self::field( 'text', 'name' ) ) ),
+ self::tab( 'second', $this->option, array( self::field( 'text', 'other' ) ) ),
+ ),
+ )
+ );
+ $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'first' ) ) );
+
+ $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'second' )['body'], array( "{$this->option}[other]" => 'second' ) ) );
+
+ self::assertSame(
+ array(
+ 'name' => 'first',
+ 'other' => 'second',
+ ),
+ get_option( $this->option )
+ );
+ }
+
protected function spec( array $fields, array $page = array(), array $extra = array() ): array {
return array(
'id' => $this->id,
diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php
index 6d03a7d..2436a55 100644
--- a/tests/Integration/Settings/FormsRenderControlTest.php
+++ b/tests/Integration/Settings/FormsRenderControlTest.php
@@ -90,6 +90,23 @@ public function test_help_text_is_linked_from_the_control_by_aria_describedby():
self::assertSame( 'Shown below.', self::text( $document->getElementById( 'acme.key.description' ) ) );
}
+ public static function non_input_controls(): array {
+ return array(
+ 'textarea' => array( 'textarea', array(), 'textarea' ),
+ 'select' => array( 'select', array( 'options' => array( 'a' => 'A' ) ), 'select:not([multiple])' ),
+ 'multiselect' => array( 'multiselect', array( 'options' => array( 'a' => 'A' ) ), 'select[multiple]' ),
+ );
+ }
+
+ #[DataProvider( 'non_input_controls' )]
+ public function test_help_text_is_linked_from_a_non_input_control_by_aria_describedby( string $type, array $keys, string $selector ): void {
+ $document = self::render( $type, array( 'desc' => 'Shown below.' ) + $keys );
+ $control = $document->getElementById( 'acme.key' );
+
+ self::assertTrue( $control->matches( $selector ) );
+ self::assertSame( array( 'Shown below.' ), self::descriptions( $document, $control ) );
+ }
+
public function test_a_desc_and_a_string_desc_tip_each_get_their_own_description(): void {
$document = self::render(
'text',
diff --git a/tests/Unit/Core/Logging/AbstractLoggerTest.php b/tests/Unit/Core/Logging/AbstractLoggerTest.php
deleted file mode 100644
index 01ef2cb..0000000
--- a/tests/Unit/Core/Logging/AbstractLoggerTest.php
+++ /dev/null
@@ -1,30 +0,0 @@
-info( 'Cache is cold.' );
+ $log = \file_get_contents( $log_file );
+ } finally {
+ \ini_set( 'error_log', (string) $previous );
+ \unlink( $log_file );
+ }
+
+ self::assertSame( '', $log );
+ }
+}
diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php
index 838211d..dde984f 100644
--- a/tests/Unit/PackageManifestsTest.php
+++ b/tests/Unit/PackageManifestsTest.php
@@ -2,9 +2,15 @@
namespace DeepWebSolutions\Framework\Tests\Unit;
+use Composer\Autoload\ClassLoader;
+use Composer\InstalledVersions;
+use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\ClassLike;
+use PhpParser\Node\Stmt\Declare_;
+use PhpParser\Node\Stmt\GroupUse;
use PhpParser\Node\Stmt\Namespace_;
+use PhpParser\Node\Stmt\Use_;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
@@ -38,6 +44,29 @@ final class PackageManifestsTest extends TestCase {
),
);
+ protected const array EXTRAS = array(
+ 'bootstrap' => array(
+ 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ),
+ 'requires-wp' => '5.8',
+ ),
+ 'shared' => array(
+ 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ),
+ ),
+ 'core' => array(
+ 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ),
+ 'requires-wp' => '7.1',
+ ),
+ 'settings' => array(
+ 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ),
+ 'requires-wp' => '7.1',
+ ),
+ 'woocommerce' => array(
+ 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ),
+ 'requires-plugins' => array( 'woocommerce' ),
+ 'requires-wp' => '7.1',
+ ),
+ );
+
protected const array OWNERS = array(
'DeepWebSolutions\\Framework\\Bootstrap\\' => 'ahegyes/wp-framework-bootstrap',
'DeepWebSolutions\\Framework\\Shared\\' => 'ahegyes/wp-framework-shared',
@@ -77,6 +106,14 @@ public function test_a_package_requires_exactly_its_mapped_edges( string $packag
self::assertSame( $requires, $manifest );
}
+ #[DataProvider( 'package_names' )]
+ public function test_a_packages_extra_declares_exactly_its_floors_and_branch_alias( string $package ): void {
+ $extra = \array_intersect_key( self::read_manifest( $package )['extra'] ?? array(), \array_flip( array( 'branch-alias', 'requires-plugins', 'requires-wp' ) ) );
+ \ksort( $extra );
+
+ self::assertSame( self::EXTRAS[ $package ], $extra );
+ }
+
#[DataProvider( 'package_names' )]
public function test_a_package_references_only_its_closure( string $package ): void {
$allowed = self::closure( $package );
@@ -85,8 +122,7 @@ public function test_a_package_references_only_its_closure( string $package ): v
$files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) );
foreach ( new \RegexIterator( $files, '/\.php$/' ) as $file ) {
foreach ( self::referenced_names( $file->getPathname() ) as $name ) {
- $owner = self::owner_of( $name );
- if ( ( ! \is_null( $owner ) || \str_starts_with( $name, 'Psr\\' ) || \str_starts_with( $name, 'DeepWebSolutions\\Framework\\' ) ) && ! \in_array( $owner, $allowed, true ) ) {
+ if ( self::is_foreign( $name, $allowed ) ) {
$violations[] = $file->getPathname() . ": $name";
}
}
@@ -95,6 +131,18 @@ public function test_a_package_references_only_its_closure( string $package ): v
self::assertSame( array(), $violations );
}
+ public function test_a_name_a_development_package_owns_is_foreign_and_a_native_or_host_name_is_not(): void {
+ $allowed = self::closure( 'core' );
+
+ foreach ( array( TestCase::class, NodeFinder::class, 'DeepWebSolutions\\Framework\\Settings\\Forms', 'DeepWebSolutions\\Framework\\Tests\\Unit\\AutoloadTest' ) as $name ) {
+ self::assertTrue( self::is_foreign( $name, $allowed ), $name );
+ }
+
+ foreach ( array( 'Psr\\Log\\LoggerInterface', \Throwable::class, 'WP_Error', 'add_action', 'ActionScheduler_Store', 'as_schedule_single_action', 'Automattic\\WooCommerce\\Utilities\\OrderUtil' ) as $name ) {
+ self::assertFalse( self::is_foreign( $name, $allowed ), $name );
+ }
+ }
+
public function test_a_closure_holds_the_package_and_every_package_below_it(): void {
self::assertEqualsCanonicalizing(
array( 'ahegyes/wp-framework-woocommerce', 'ahegyes/wp-framework-settings', 'ahegyes/wp-framework-core', 'ahegyes/wp-framework-bootstrap', 'ahegyes/wp-framework-shared', 'ext-filter', 'php', 'psr/clock', 'psr/container', 'psr/log', 'psr/simple-cache' ),
@@ -121,6 +169,24 @@ public function test_an_autoloaded_package_maps_only_its_namespace_to_src( strin
self::assertSame( array( 'psr-4' => array( $prefix => 'src/' ) ), self::read_manifest( $package )['autoload'] ?? null );
}
+ public function test_every_file_outside_the_shared_package_exits_without_wordpress_right_after_its_use_block(): void {
+ $unguarded = array();
+ foreach ( array( 'bootstrap', 'core', 'settings', 'woocommerce' ) as $package ) {
+ $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) );
+ foreach ( new \RegexIterator( $files, '#(?getPathname() );
+ $nodes = ( new ParserFactory() )->createForHostVersion()->parse( $code );
+ $namespace = ( new NodeFinder() )->findFirstInstanceOf( $nodes, Namespace_::class );
+ $statement = \current( \array_filter( $namespace?->stmts ?? $nodes, static fn ( Node $node ): bool => ! $node instanceof Declare_ && ! $node instanceof Use_ && ! $node instanceof GroupUse ) );
+ if ( false === $statement || "\\defined( 'ABSPATH' ) || exit;" !== \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) ) {
+ $unguarded[] = $file->getPathname();
+ }
+ }
+ }
+
+ self::assertSame( array(), $unguarded );
+ }
+
public function test_every_package_directory_carries_an_index_stub(): void {
$missing = array();
foreach ( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory(), \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::SELF_FIRST ) as $file ) {
@@ -224,6 +290,13 @@ protected static function referenced_names( string $file ): array {
return \array_map( static fn ( Name $name ): string => $name->toString(), ( new NodeFinder() )->findInstanceOf( $nodes, Name::class ) );
}
+ protected static function is_foreign( string $name, array $allowed ): bool {
+ $owner = self::owner_of( $name );
+
+ return ( ! \is_null( $owner ) || \str_starts_with( $name, 'Psr\\' ) || \str_starts_with( $name, 'DeepWebSolutions\\Framework\\' ) ) && ! \in_array( $owner, $allowed, true );
+ }
+
+ // WordPress, WooCommerce and Action Scheduler load nothing through Composer here, so a name the autoloader finds belongs to a development package or to the root project.
protected static function owner_of( string $name ): ?string {
foreach ( self::OWNERS as $prefix => $owner ) {
if ( \str_starts_with( $name . '\\', $prefix ) ) {
@@ -231,6 +304,14 @@ protected static function owner_of( string $name ): ?string {
}
}
- return null;
+ $vendor = \dirname( __DIR__, 2 ) . '/vendor';
+ $file = ClassLoader::getRegisteredLoaders()[ $vendor ]->findFile( $name );
+ if ( false === $file ) {
+ return null;
+ }
+
+ $path = (string) \realpath( $file );
+
+ return \str_starts_with( $path, "$vendor/" ) ? \implode( '/', \array_slice( \explode( '/', \substr( $path, \strlen( "$vendor/" ) ) ), 0, 2 ) ) : InstalledVersions::getRootPackage()['name'];
}
}
diff --git a/tests/Unit/PackageReadmesTest.php b/tests/Unit/PackageReadmesTest.php
index cf97b62..e84b11a 100644
--- a/tests/Unit/PackageReadmesTest.php
+++ b/tests/Unit/PackageReadmesTest.php
@@ -82,6 +82,7 @@ public function test_a_reindented_excerpt_is_a_copy_and_a_changed_one_is_not():
self::assertTrue( self::contains( $source, array( 'if ( true ) {', "\treturn;", '}', '', "echo 'acme';" ) ) );
self::assertFalse( self::contains( $source, array( 'if ( true ) {', 'return;' ) ) );
self::assertFalse( self::contains( $source, array( "\treturn;", '}', "echo 'acme';" ) ) );
+ self::assertFalse( self::contains( array( "// echo 'acme';" ), array( "echo 'acme';" ) ) );
}
public function test_a_json_example_is_part_of_a_manifest_only_where_every_value_matches(): void {
diff --git a/tests/Unit/Shared/Versioning/VersionTest.php b/tests/Unit/Shared/Versioning/VersionTest.php
index 8577088..e2ca8e4 100644
--- a/tests/Unit/Shared/Versioning/VersionTest.php
+++ b/tests/Unit/Shared/Versioning/VersionTest.php
@@ -27,15 +27,18 @@ public function test_a_valid_version_prints_in_canonical_form( string $version,
public static function invalid_versions(): array {
return array(
- 'single-segment' => array( '2' ),
- 'four-segments' => array( '1.2.3.4' ),
- 'label-without-a-hyphen' => array( '8.5.0RC1' ),
- 'v-prefix' => array( 'v1.2.3' ),
- 'build-metadata' => array( '1.0.0+build.5' ),
- 'unknown-label' => array( '1.0.0-pre.1' ),
- 'leading-zero' => array( '01.2.3' ),
- 'dot-without-a-number' => array( '2.0.0-beta.' ),
- 'trailing-newline' => array( "1.2.3\n" ),
+ 'single-segment' => array( '2' ),
+ 'four-segments' => array( '1.2.3.4' ),
+ 'label-without-a-hyphen' => array( '8.5.0RC1' ),
+ 'v-prefix' => array( 'v1.2.3' ),
+ 'build-metadata' => array( '1.0.0+build.5' ),
+ 'unknown-label' => array( '1.0.0-pre.1' ),
+ 'leading-zero' => array( '01.2.3' ),
+ 'leading-zero-minor' => array( '1.02.3' ),
+ 'leading-zero-patch' => array( '1.2.03' ),
+ 'leading-zero-pre-release-number' => array( '1.0.0-rc.01' ),
+ 'dot-without-a-number' => array( '2.0.0-beta.' ),
+ 'trailing-newline' => array( "1.2.3\n" ),
);
}
From 1cb412004bc34916d87810824d842ac1708e9c64 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:02 +0100
Subject: [PATCH 03/33] Test the updater's labels, stable channel, details and
notice
PHP's version_compare() misorders prerelease labels other than lowercase
dev, alpha, beta and rc; the stable channel offers a suffixed tag whose
prerelease flag was never set on GitHub; the details modal shows release
notes as escaped Markdown and leaves out the author, homepage, release
date and tested-up-to version; and a refused plugin outside the plugins
directory raises a warning on every admin screen, which its row pins.
The rows expect uppercase and mixed-case labels skipped, an unflagged
prerelease kept off the stable channel, GitHub's rendered body and the
plugin's headers in the details, Tested up to in the offer, and no
warning. The channel-switch row starts from a stable release, which the
stable channel still offers.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Bootstrap/RequirementsTest.php | 4 +-
tests/Integration/Bootstrap/UpdaterTest.php | 47 ++++++++++++++++---
2 files changed, 42 insertions(+), 9 deletions(-)
diff --git a/tests/Integration/Bootstrap/RequirementsTest.php b/tests/Integration/Bootstrap/RequirementsTest.php
index 1df2f68..dd9ea7c 100644
--- a/tests/Integration/Bootstrap/RequirementsTest.php
+++ b/tests/Integration/Bootstrap/RequirementsTest.php
@@ -113,7 +113,6 @@ public function test_a_refused_plugin_outside_the_plugins_directory_prints_nothi
$plugin_file = $this->write_plugin( untrailingslashit( get_temp_dir() ), array( 'Requires PHP' => '99' ) );
self::assertFalse( Requirements::check_floors( $plugin_file ) );
- // Core reads the headers from the plugins directory, so it warns about the missing file and reports the requirements as met.
$warnings = array();
\set_error_handler(
static function ( int $level, string $message ) use ( &$warnings ): bool {
@@ -130,8 +129,7 @@ static function ( int $level, string $message ) use ( &$warnings ): bool {
}
self::assertSame( '', $html );
- self::assertCount( 1, $warnings );
- self::assertStringContainsString( 'Failed to open stream', $warnings[0] );
+ self::assertSame( array(), $warnings );
}
public function test_an_error_prints_its_message_without_a_paragraph_wrap(): void {
diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php
index 3ee8adc..2c40d4a 100644
--- a/tests/Integration/Bootstrap/UpdaterTest.php
+++ b/tests/Integration/Bootstrap/UpdaterTest.php
@@ -20,6 +20,8 @@ final class UpdaterTest extends TestCase {
protected array $requests = array();
+ protected array $accepts = array();
+
#[\Override]
public static function setUpBeforeClass(): void {
require_once \dirname( __DIR__, 3 ) . '/packages/bootstrap/load.php';
@@ -64,6 +66,7 @@ static function ( self $test ): void {
},
),
'tag-not-a-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'preview' ) ) ),
+ 'unflagged-prerelease' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.1.0-beta.1' ) ) ),
'requirement-not-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.0.0' ), '', array( 'Requires PHP' => '8.1
' ) ) ),
);
}
@@ -74,7 +77,8 @@ public static function prerelease_lists(): array {
'stable-successor' => array( '1.2.0-beta.2', array( 'v1.2.0', 'v1.2.0-beta.2' ), '1.2.0' ),
'word-tag-listed' => array( '1.1.0-beta.1', array( 'preview', 'v1.2.0-beta.1' ), '1.2.0-beta.1' ),
'unordered-label-listed' => array( '2.1.0-beta.1', array( 'v2.1.0', 'v2.1.0-pre.1' ), '2.1.0' ),
- 'uppercase-label' => array( '2.1.0-beta.1', array( 'v2.1.0-RC1' ), '2.1.0-RC1' ),
+ 'uppercase-label-listed' => array( '2.0.0-beta.1', array( 'v2.1.0-RC1', 'v2.0.0' ), '2.0.0' ),
+ 'mixed-case-label-listed' => array( '2.0.0-beta.1', array( 'v2.1.0-Beta.2', 'v2.0.0' ), '2.0.0' ),
);
}
@@ -276,7 +280,7 @@ public function test_two_plugins_never_answer_for_each_other(): void {
#[DataProvider( 'channel_switches' )]
public function test_each_channel_caches_separately_so_a_switch_never_serves_the_other_channels_release( string $first_version, string $first_path, string $second_version, string $second_path ): void {
Updater::register( $this->write_plugin( $this->slug, array( 'Version' => $first_version ) ) );
- $this->stage_release_at( $first_path, $this->release( 'v3.0.0-beta.1' ) );
+ $this->stage_release_at( $first_path, $this->release( 'v3.0.0' ) );
$this->check_for_updates();
$this->write_plugin( $this->slug, array( 'Version' => $second_version ) );
@@ -289,12 +293,25 @@ public function test_each_channel_caches_separately_so_a_switch_never_serves_the
}
public function test_details_for_its_own_slug_show_the_cached_release_without_another_request(): void {
- Updater::register( $this->write_plugin( $this->slug ) );
+ Updater::register(
+ $this->write_plugin(
+ $this->slug,
+ array(
+ 'Author' => 'Acme',
+ 'Plugin URI' => 'https://example.org/acme-updater',
+ )
+ )
+ );
$requirements = array(
'Requires at least' => '6.0',
'Requires PHP' => '8.1',
+ 'Tested up to' => '7.1',
+ );
+ $release = array(
+ 'body_html' => 'Fixes the widget.
',
+ 'published_at' => '2026-11-02T09:30:00Z',
);
- $this->stage_latest( array( 'body' => "Fixes the widget.\nAdds a setting." ) + $this->release( 'v2.0.0' ), '', $requirements );
+ $this->stage_latest( $release + $this->release( 'v2.0.0' ), '', $requirements );
$this->check_for_updates();
$details = plugins_api( 'plugin_information', array( 'slug' => $this->slug ) );
@@ -306,9 +323,15 @@ public function test_details_for_its_own_slug_show_the_cached_release_without_an
self::assertSame( $this->asset_url( 'v2.0.0' ), $details->download_link );
self::assertSame( '6.0', $details->requires );
self::assertSame( '8.1', $details->requires_php );
+ self::assertSame( '7.1', $details->tested );
+ self::assertSame( 'Acme', $details->author );
+ self::assertSame( 'https://example.org/acme-updater', $details->homepage );
+ self::assertSame( '2026-11-02T09:30:00Z', $details->last_updated );
self::assertTrue( $details->external );
- self::assertSame( "Fixes <b>the</b> widget.
\nAdds a setting.", $details->sections['changelog'] );
+ self::assertSame( 'Fixes the widget.
', $details->sections['changelog'] );
self::assertCount( 2, $this->requests );
+ self::assertSame( 'application/vnd.github.html+json', $this->accepts[ $this->api_url( 'releases/latest' ) ] ?? null );
+ self::assertSame( array( 'version', 'package', 'url', 'body', 'last_updated', 'requires', 'requires_php', 'tested' ), \array_keys( get_transient( "{$this->slug}_github_release_stable" ) ) );
}
public function test_details_for_its_own_slug_stay_local_without_a_release(): void {
@@ -384,6 +407,7 @@ public function test_an_offer_carries_the_release_requirements_and_a_php_floor_n
$requirements = array(
'Requires at least' => '5.8',
'Requires PHP' => '99',
+ 'Tested up to' => '7.1',
);
$this->stage_latest( $this->release( 'v2.0.0' ), '', $requirements );
@@ -392,6 +416,16 @@ public function test_an_offer_carries_the_release_requirements_and_a_php_floor_n
self::assertIsObject( $offer );
self::assertSame( '5.8', $offer->requires );
self::assertSame( '99', $offer->requires_php );
+ self::assertSame( '7.1', $offer->tested );
+ }
+
+ public function test_a_tested_version_that_is_not_a_version_reaches_the_offer_as_it_is(): void {
+ Updater::register( $this->write_plugin( $this->slug ) );
+ $this->stage_latest( $this->release( 'v2.0.0' ), '', array( 'Tested up to' => '7.1 beta' ) );
+
+ $offer = $this->check_for_updates()->response[ $this->basename() ] ?? null;
+
+ self::assertSame( '7.1 beta', $offer->tested ?? null );
}
public function test_an_offer_needing_a_newer_wordpress_is_withheld(): void {
@@ -486,7 +520,8 @@ public function answer_request( mixed $preempt, array $args, string $url ): mixe
}
if ( \in_array( wp_parse_url( $url, \PHP_URL_HOST ), array( 'api.github.com', 'raw.githubusercontent.com' ), true ) ) {
- $this->requests[] = $url;
+ $this->requests[] = $url;
+ $this->accepts[ $url ] = $args['headers']['Accept'] ?? null;
}
return $this->responses[ $url ] ?? new \WP_Error( 'http_request_failed', "Nothing answers $url." );
From 4669ce5ab3d87ab059c1a9e8ffedf9521a518566 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:02 +0100
Subject: [PATCH 04/33] Test a build missing a bootstrap class
When load.php exists but a class file it requires is missing, the
require fatals at include, before the entry can return, and the
uninstall shim cannot catch it on PHP 7.4. consumer-a's partial-build
rows cover a missing load.php but no missing class.
A row removes the bootstrap package's Updater.php and expects what a
missing load.php gives: the build notice, a working site and the
safeguard in place.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/ConsumerA/ConsumerABuildTest.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Integration/ConsumerA/ConsumerABuildTest.php b/tests/Integration/ConsumerA/ConsumerABuildTest.php
index d482e0e..494343f 100644
--- a/tests/Integration/ConsumerA/ConsumerABuildTest.php
+++ b/tests/Integration/ConsumerA/ConsumerABuildTest.php
@@ -18,6 +18,7 @@ final class ConsumerABuildTest extends TestCase {
public static function missing_build_files(): array {
return array(
'bootstrap-loader' => array( 'vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php', false ),
+ 'bootstrap-class' => array( 'vendor-prefixed/ahegyes/wp-framework-bootstrap/src/Updater.php', false ),
'autoloader' => array( 'vendor/autoload.php', true ),
'scoped-autoloader' => array( 'vendor-prefixed/scoper-autoload.php', true ),
'composition-root' => array( 'src/Plugin.php', false ),
From 90f4be3fe161660752431543f76599f09e5ab30d Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:03 +0100
Subject: [PATCH 05/33] Test cached entries naming a class that no longer loads
An entry holding an object or enum case whose class no longer loads, as
after an update renames it, unserializes to __PHP_Incomplete_Class or
fails, and returns as a hit.
The rows plant such entries, at the top, nested in an array or an object
and as an enum case, in both caches and expect a miss from get(), has()
and getMultiple().
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/Core/Caching/CachesTest.php | 34 +++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/tests/Integration/Core/Caching/CachesTest.php b/tests/Integration/Core/Caching/CachesTest.php
index 77b2493..1ea96f8 100644
--- a/tests/Integration/Core/Caching/CachesTest.php
+++ b/tests/Integration/Core/Caching/CachesTest.php
@@ -211,6 +211,40 @@ public function test_a_value_stored_without_the_envelope_reads_as_a_miss( string
self::assertSame( array( 'key' => 'Default.' ), $cache->getMultiple( array( 'key' ), 'Default.' ) );
}
+ public static function unloadable_classes(): array {
+ $object = \sprintf( 'O:%d:"%s":0:{}', \strlen( 'Acme\\Caching\\Missing' ), 'Acme\\Caching\\Missing' );
+ $case = 'Acme\\Caching\\MissingSuit:Hearts';
+
+ return array(
+ 'top-level' => array( $object ),
+ 'nested-in-array' => array( "a:1:{s:4:\"item\";$object}" ),
+ 'inside-object' => array( "O:8:\"stdClass\":1:{s:5:\"inner\";$object}" ),
+ 'enum-case' => array( \sprintf( 'E:%d:"%s";', \strlen( $case ), $case ) ),
+ );
+ }
+
+ public static function kinds_and_unloadable_classes(): array {
+ return self::cross( self::unloadable_classes() );
+ }
+
+ #[DataProvider( 'kinds_and_unloadable_classes' )]
+ public function test_an_entry_naming_a_class_that_no_longer_loads_reads_as_a_miss( string $kind, string $stored ): void {
+ $cache = $this->cache( $kind );
+ $cache->set( 'other', 'Value.' );
+ $this->plant(
+ $kind,
+ 'key',
+ array(
+ 'value' => $stored,
+ 'generation' => $this->generation( $kind ),
+ )
+ );
+
+ self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) );
+ self::assertFalse( $cache->has( 'key' ) );
+ self::assertSame( array( 'key' => 'Default.' ), $cache->getMultiple( array( 'key' ), 'Default.' ) );
+ }
+
public static function expired_lifetimes(): array {
$inverted = new \DateInterval( 'PT1M' );
$inverted->invert = 1;
From 41c181ce587fbaa2cc6cae8698477351c8fcffc7 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:04 +0100
Subject: [PATCH 06/33] Test the scheduler on its own site and an uninitialized
Action Scheduler
An Action Scheduler older than 3.5.5 never fires action_scheduler_init,
so the scheduler waits for it on every request and never falls back to
WP-Cron; and a switch back to the site the plugin loaded on refuses
every scheduler call.
The rows expect WP-Cron once init has passed without Action Scheduler's
init, and scheduling inside a switch to the plugin's own site. Rows that
stand before Action Scheduler's init run inside init, and the group and
hook-name rows move from Unit to Integration, because the scheduler
reads the current site when it is built.
Assisted-by: Claude Code:claude-opus-5-5
---
.../SchedulerOnSwitchedSiteTest.php | 13 +++++
.../Core/Scheduling/SchedulerTest.php | 55 ++++++++++++++++++-
tests/Unit/Core/Scheduling/SchedulerTest.php | 51 -----------------
3 files changed, 67 insertions(+), 52 deletions(-)
delete mode 100644 tests/Unit/Core/Scheduling/SchedulerTest.php
diff --git a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php
index 5a28e1a..4aa88d3 100644
--- a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php
+++ b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php
@@ -115,6 +115,19 @@ public function test_the_site_that_loaded_the_plugin_schedules_after_a_switch_is
self::assertSame( array(), $this->doing_it_wrong );
}
+ public function test_a_switch_to_the_site_that_loaded_the_plugin_still_schedules(): void {
+ $scheduler = $this->make_scheduler();
+ $hook = new ScheduledHook( $this->hook );
+ switch_to_blog( get_current_blog_id() );
+
+ $enqueued = $scheduler->enqueue( $hook );
+ restore_current_blog();
+
+ self::assertTrue( $enqueued );
+ self::assertSame( array( get_main_site_id() ), $this->writes );
+ self::assertSame( array(), $this->doing_it_wrong );
+ }
+
// The network carries no WooCommerce, so the default backends resolve to WP-Cron alone.
protected function make_scheduler(): Scheduler {
$scheduler = new Scheduler( 'acme' );
diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php
index db2d8dc..73b3e1f 100644
--- a/tests/Integration/Core/Scheduling/SchedulerTest.php
+++ b/tests/Integration/Core/Scheduling/SchedulerTest.php
@@ -77,6 +77,45 @@ protected function tearDown(): void {
$wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE hook LIKE %s', $wpdb->actionscheduler_actions, $wpdb->esc_like( $this->hook ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created.
}
+ public static function valid_groups(): array {
+ return array(
+ 'one-letter' => array( 'a' ),
+ 'hook-prefix' => array( 'dws_tpl' ),
+ 'mixed-case-hyphen' => array( 'Acme-2_tpl' ),
+ '190-bytes' => array( \str_repeat( 'a', 190 ) ),
+ );
+ }
+
+ #[DataProvider( 'valid_groups' )]
+ public function test_a_valid_group_constructs_a_scheduler( string $group ): void {
+ self::assertInstanceOf( Scheduler::class, new Scheduler( $group ) );
+ }
+
+ public static function invalid_groups(): array {
+ return array(
+ 'empty' => array( '' ),
+ 'zero' => array( '0' ),
+ 'leading-digit' => array( '1acme' ),
+ 'space' => array( 'acme tpl' ),
+ 'dot' => array( 'acme.tpl' ),
+ 'trailing-break' => array( "acme\n" ),
+ '191-bytes' => array( \str_repeat( 'a', 191 ) ),
+ );
+ }
+
+ #[DataProvider( 'invalid_groups' )]
+ public function test_an_invalid_group_is_rejected( string $group ): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ new Scheduler( $group );
+ }
+
+ public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ ( new Scheduler( 'acme' ) )->unschedule_hook( '' );
+ }
+
public static function schedulers(): array {
return array(
'action-scheduler' => array( 'production' ),
@@ -249,7 +288,8 @@ public static function writes(): array {
public function test_a_write_before_action_scheduler_initializes_is_an_error_that_writes_nothing( \Closure $write ): void {
$scheduler = $this->make_scheduler( 'production' );
unset( $GLOBALS['wp_actions']['action_scheduler_init'] );
- $this->writes = array();
+ $GLOBALS['wp_current_filter'][] = 'init';
+ $this->writes = array();
$result = $write( $scheduler, new ScheduledHook( $this->hook ) );
@@ -574,6 +614,7 @@ public function test_is_scheduled_is_false_with_a_notice_before_action_scheduler
$scheduler = $this->make_scheduler( 'production' );
wp_schedule_single_event( \time() + 3600, $this->hook );
unset( $GLOBALS['wp_actions']['action_scheduler_init'] );
+ $GLOBALS['wp_current_filter'][] = 'init';
self::assertFalse( $scheduler->is_scheduled( new ScheduledHook( $this->hook ) ) );
self::assertCount( 1, $this->doing_it_wrong );
@@ -707,6 +748,17 @@ public function test_enqueueing_counts_a_running_invocation_on_action_scheduler_
self::assertSame( array(), $this->cron_events() );
}
+ public function test_an_action_scheduler_that_never_initialized_by_the_end_of_init_is_unsupported(): void {
+ $scheduler = $this->make_scheduler( 'production' );
+ $this->report_action_scheduler_versions( array( '3.5.4' ) );
+ unset( $GLOBALS['wp_actions']['action_scheduler_init'] );
+
+ self::assertTrue( $scheduler->enqueue( new ScheduledHook( $this->hook ) ) );
+
+ self::assertSame( array(), $this->pending_action_ids() );
+ self::assertCount( 1, $this->cron_events() );
+ }
+
public function test_action_scheduler_below_the_floor_is_still_read_and_cleared(): void {
$scheduler = $this->make_scheduler( 'production' );
$hook = new ScheduledHook( $this->hook );
@@ -724,6 +776,7 @@ public function test_one_scheduler_follows_action_scheduler_readiness_from_call_
$hook = new ScheduledHook( $this->hook );
$ready = $GLOBALS['wp_actions']['action_scheduler_init'];
unset( $GLOBALS['wp_actions']['action_scheduler_init'] );
+ $GLOBALS['wp_current_filter'][] = 'init';
self::assert_wp_error( $scheduler->enqueue( $hook ), 'scheduler_not_ready' );
$GLOBALS['wp_actions']['action_scheduler_init'] = $ready;
diff --git a/tests/Unit/Core/Scheduling/SchedulerTest.php b/tests/Unit/Core/Scheduling/SchedulerTest.php
deleted file mode 100644
index 2ce02ce..0000000
--- a/tests/Unit/Core/Scheduling/SchedulerTest.php
+++ /dev/null
@@ -1,51 +0,0 @@
- array( 'a' ),
- 'hook-prefix' => array( 'dws_tpl' ),
- 'mixed-case-hyphen' => array( 'Acme-2_tpl' ),
- '190-bytes' => array( \str_repeat( 'a', 190 ) ),
- );
- }
-
- #[DataProvider( 'valid_groups' )]
- public function test_a_valid_group_constructs_a_scheduler( string $group ): void {
- self::assertInstanceOf( Scheduler::class, new Scheduler( $group ) );
- }
-
- public static function invalid_groups(): array {
- return array(
- 'empty' => array( '' ),
- 'zero' => array( '0' ),
- 'leading-digit' => array( '1acme' ),
- 'space' => array( 'acme tpl' ),
- 'dot' => array( 'acme.tpl' ),
- 'trailing-break' => array( "acme\n" ),
- '191-bytes' => array( \str_repeat( 'a', 191 ) ),
- );
- }
-
- #[DataProvider( 'invalid_groups' )]
- public function test_an_invalid_group_is_rejected( string $group ): void {
- $this->expectException( InvalidArgumentException::class );
-
- new Scheduler( $group );
- }
-
- public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void {
- $this->expectException( InvalidArgumentException::class );
-
- ( new Scheduler( 'acme' ) )->unschedule_hook( '' );
- }
-}
From 9dbdd2cae8b28ce581ed5b31cf4eb41cac72773a Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:04 +0100
Subject: [PATCH 07/33] Test the migration runner out of budget and its Run now
link
Out of budget inside its own hook, the runner schedules a continuation
that a long wp action-scheduler run keeps claiming, so the process never
ends; and the notice for a failed batched step drops the Run now link,
its only manual recovery.
The rows expect nothing of the runner due for the queue run in progress
and the next request to resume, and a failed batch's notice to link Run
now, which completes the repaired step.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Core/Migration/MigrationRunnerTest.php | 81 +++++++++++--------
1 file changed, 49 insertions(+), 32 deletions(-)
diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php
index c978bbe..22de6b7 100644
--- a/tests/Integration/Core/Migration/MigrationRunnerTest.php
+++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php
@@ -984,54 +984,41 @@ public function test_a_batched_step_finishes_through_real_scheduler_dispatch( st
$clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) );
$batches = new RecordedBatches( 5, static fn () => $clock->advance( 10 ) );
$scheduler = 'production' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler();
- $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) );
- $dispatches = 0;
- while ( 0 < $this->count_pending_runs() && $dispatches < 5 ) {
+ $requests = 0;
+ while ( 1 > MigrationRunner::get_revision( $this->option ) && $requests < 5 ) {
+ // Each request's wp_loaded pass enqueues the runner, then its queue run dispatches what is due.
remove_all_actions( "{$this->option}/migration/run" );
- $this->make_runner( array( $batches ), $clock, null, $scheduler )->register_hooks();
- // Each dispatch is a later request's queue run, by which the previous continuation is due.
- 'production' === $kind ? $this->run_due_actions( \time() + MINUTE_IN_SECONDS ) : $this->run_due_cron_events( \time() + MINUTE_IN_SECONDS );
- ++$dispatches;
+ $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) );
+ 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events();
+ ++$requests;
}
- self::assertSame( 3, $dispatches );
+ self::assertSame( 3, $requests );
self::assertSame( 5, $batches->processed );
self::assertSame( 1, MigrationRunner::get_revision( $this->option ) );
self::assertSame( 0, $this->count_pending_runs() );
}
- public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_progress(): void {
+ public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_progress_and_the_next_request_resumes(): void {
$clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) );
$batches = new RecordedBatches( 5, static fn () => $clock->advance( 30 ) );
$this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) );
- $claimed_at = \time();
$this->run_due_actions();
- $this->run_due_actions( $claimed_at );
+ // A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does.
+ $this->run_due_actions( \time() + MINUTE_IN_SECONDS );
- $pending = as_get_scheduled_actions(
- array(
- 'hook' => "{$this->option}/migration/run",
- 'status' => \ActionScheduler_Store::STATUS_PENDING,
- 'per_page' => -1,
- ),
- 'objects'
- );
- self::assertCount( 1, $pending );
- self::assertGreaterThan( $claimed_at, \current( $pending )->get_schedule()->get_date()?->getTimestamp() );
self::assertSame( 1, $batches->processed );
- self::assertCount(
- 1,
- as_get_scheduled_actions(
- array(
- 'hook' => "{$this->option}/migration/run",
- 'status' => \ActionScheduler_Store::STATUS_COMPLETE,
- 'per_page' => -1,
- ),
- 'ids'
- )
- );
+ self::assertSame( 0, $this->count_pending_runs() );
+
+ remove_all_actions( "{$this->option}/migration/run" );
+ $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) );
+ $this->run_due_actions();
+
+ self::assertSame( 2, $batches->processed );
+ self::assertSame( 0, MigrationRunner::get_revision( $this->option ) );
+ self::assertSame( 0, $this->count_pending_runs() );
}
public function test_a_request_outside_the_runner_hook_enqueues_a_pending_batched_step_without_the_lock(): void {
@@ -1081,6 +1068,36 @@ public function test_a_pending_batched_step_shows_plugin_activators_a_run_now_li
self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_admin_notices() );
}
+ public function test_a_failed_batch_shows_its_error_with_a_run_now_link_that_completes_the_repaired_step(): void {
+ $broken = true;
+ $batches = new RecordedBatches(
+ 2,
+ static function () use ( &$broken ): void {
+ if ( $broken ) {
+ throw new \RuntimeException( 'The batch failed.' );
+ }
+ }
+ );
+ $this->make_runner( array( $batches ) )->register_hooks();
+ do_action( "{$this->option}/migration/run" );
+ $this->age_lock();
+ $broken = false;
+
+ wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) );
+ $notices = $this->render_admin_notices();
+ self::assertStringContainsString( self::ERROR_NOTICE . 'The batch failed.', $notices );
+ self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices );
+ self::assertSame( 1, \preg_match( '/Run the update now<\/a>/', $notices, $link ) );
+ \parse_str( (string) wp_parse_url( \html_entity_decode( $link[1] ), \PHP_URL_QUERY ), $query );
+ $_REQUEST['_wpnonce'] = $query['_wpnonce'];
+
+ $this->post_run_now();
+
+ self::assertSame( 2, $batches->processed );
+ self::assertSame( 1, MigrationRunner::get_revision( $this->option ) );
+ self::assertFalse( get_option( "{$this->option}_error" ) );
+ }
+
public static function invalid_error_rows(): array {
$valid = array(
'v' => 1,
From 2ec471f1797abe9286e9a0894d455189d9a2f157 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:05 +0100
Subject: [PATCH 08/33] Test line breaks in interpolated log values
A value interpolated into a log message can carry a line break, which
writes a forged record into PHP's error log.
The shared message rows expect \r and \n escaped in an interpolated
value.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/Support/ProvidesLogMessagesTrait.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Integration/Support/ProvidesLogMessagesTrait.php b/tests/Integration/Support/ProvidesLogMessagesTrait.php
index 5060072..10681d9 100644
--- a/tests/Integration/Support/ProvidesLogMessagesTrait.php
+++ b/tests/Integration/Support/ProvidesLogMessagesTrait.php
@@ -32,6 +32,7 @@ public function __toString(): string {
'missing-key' => array( 'Hello {name}.', array(), 'Hello {name}.' ),
'unprintable' => array( 'from {object}', array( 'object' => $unprintable ), 'from {object}' ),
'type-error' => array( 'from {object}', array( 'object' => $erroring ), 'from {object}' ),
+ 'line-breaks' => array( 'Hello {name}.', array( 'name' => "world\r\n[acme.error] Forged." ), 'Hello world\r\n[acme.error] Forged..' ),
);
}
}
From 57ffbdf976d1b568f39bb7b359181009908835aa Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:05 +0100
Subject: [PATCH 09/33] Test paged uninstall reads, term relationships and a
failed run
The uninstall engine reads every id of a key into memory before it
deletes any, so a key on a few hundred thousand rows never clears from
the web; deleting the posts of an unregistered post type leaves their
term relationships and stale term counts; and a web uninstall whose
engine fails ends in WordPress's critical-error page, while its reason
reaches only the error log.
The rows expect the ids of a key on more than 1,000 rows read in pages,
on a site and in the network pass; a post's term relationships deleted
with it, while a post whose delete another plugin refuses keeps its own
and a user under the same id keeps those of a user taxonomy; and
consumer-a's failing web uninstall to answer 500 with the engine's
message, keeping the files and the data. The request harness gains a
spec key that fails every query holding a given text.
Assisted-by: Claude Code:claude-opus-5-5
---
.../ConsumerA/ConsumerAUninstallTest.php | 23 +++++-
.../Core/Uninstall/UninstallFixturesTrait.php | 21 ++++++
.../Uninstall/UninstallerOnNetworkTest.php | 12 +++
.../Core/Uninstall/UninstallerTest.php | 73 +++++++++++++++++++
.../Integration/Support/consumer-request.php | 6 +-
5 files changed, 130 insertions(+), 5 deletions(-)
diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php
index ed8c041..50f89cd 100644
--- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php
+++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php
@@ -50,6 +50,20 @@ public function test_a_consenting_web_uninstall_removes_the_footprint_and_the_fi
self::assertSame( '', get_user_meta( $this->user_id, 'acme_ca_network_hidden_notices', true ) );
}
+ public function test_a_web_uninstall_whose_engine_fails_shows_its_message_and_keeps_the_data_and_the_files(): void {
+ global $wpdb;
+
+ $this->seed_footprint();
+ $plugin = $this->install_consumer( 'consumer-a', self::DIR );
+ $request = $this->uninstall_on_the_web( $plugin, array(), "DELETE FROM `$wpdb->options` WHERE `option_name` = 'acme_ca_revision'" );
+
+ self::assertSame( 500, $request['died']['status'] ?? null );
+ self::assertStringStartsWith( 'Uninstall could not remove everything: option acme_ca_revision: ', $request['died']['message'] ?? '' );
+ self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' );
+ self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) );
+ self::assertSame( '1', get_option( 'acme_ca_revision' ) );
+ }
+
#[DataProvider( 'engines_that_cannot_run' )]
public function test_a_web_uninstall_that_cannot_run_the_engine_keeps_the_data_logs_once_and_deletes_the_files( array $remove, array $headers ): void {
$this->seed_footprint();
@@ -112,7 +126,7 @@ protected function seed_footprint(): void {
$this->product_id = $product->save();
}
- protected function uninstall_on_the_web( string $plugin, array $missing ): array {
+ protected function uninstall_on_the_web( string $plugin, array $missing, string $failing_query = '' ): array {
$this->log = (string) \tempnam( \sys_get_temp_dir(), 'acme-ca-log-' );
return $this->consumer_request(
@@ -120,9 +134,10 @@ protected function uninstall_on_the_web( string $plugin, array $missing ): array
'index.php',
$this->make_user( 'acme_ca_admin', 'administrator' ),
array(
- 'uninstall' => $plugin,
- 'missing' => $missing,
- 'log' => $this->log,
+ 'uninstall' => $plugin,
+ 'missing' => $missing,
+ 'log' => $this->log,
+ 'failing_query' => $failing_query,
)
);
}
diff --git a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php
index 3018f6c..a15e355 100644
--- a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php
+++ b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php
@@ -14,6 +14,8 @@ trait UninstallFixturesTrait {
protected array $ids = array();
+ protected array $id_reads = array();
+
protected function footprint(): array {
$p = $this->prefix;
@@ -244,6 +246,25 @@ protected function network_option_exists( string $name ): bool {
: $this->option_exists( $name );
}
+ // The engine reads row ids under the alias id, one page per query.
+ protected function record_id_reads(): void {
+ add_filter(
+ 'query',
+ function ( string $query ): string {
+ if ( \str_contains( $query, ' AS id, ' ) ) {
+ $this->id_reads[] = $query;
+ }
+
+ return $query;
+ }
+ );
+ }
+
+ protected function assert_id_reads_paged(): void {
+ self::assertGreaterThan( 1, \count( $this->id_reads ) );
+ self::assertSame( array(), \array_values( \array_filter( $this->id_reads, static fn ( string $query ): bool => ! \str_contains( $query, 'LIMIT 1000' ) ) ) );
+ }
+
protected function user_meta_exists( string $key ): bool {
global $wpdb;
diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
index 779b463..cfadcbf 100644
--- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php
@@ -162,6 +162,18 @@ public function test_the_network_pass_runs_only_on_the_consent_constant( bool $c
self::assertSame( ! $constant, $this->network_option_exists( "{$this->prefix}_network_settings" ) );
}
+ public function test_the_network_pass_reads_a_global_user_meta_key_in_pages_and_deletes_every_row(): void {
+ for ( $row = 0; $row <= 1000; ++$row ) {
+ add_user_meta( $this->user, "{$this->prefix}_global_state", (string) $row );
+ }
+ $this->record_id_reads();
+
+ Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) );
+
+ self::assertFalse( $this->user_meta_exists( "{$this->prefix}_global_state" ) );
+ $this->assert_id_reads_paged();
+ }
+
public function test_the_network_pass_keeps_a_network_option_that_differs_only_in_letter_case(): void {
global $wpdb;
$twin = \strtoupper( "{$this->prefix}_network_settings" );
diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php
index a096eb4..db14305 100644
--- a/tests/Integration/Core/Uninstall/UninstallerTest.php
+++ b/tests/Integration/Core/Uninstall/UninstallerTest.php
@@ -79,6 +79,79 @@ public function test_a_consenting_run_without_a_current_user_deletes_exactly_the
self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) );
}
+ public function test_a_key_on_more_rows_than_one_read_takes_is_read_in_pages_and_deleted_in_full(): void {
+ $object_id = $this->make_post( 'post', 'publish' );
+ for ( $row = 0; $row <= 1000; ++$row ) {
+ add_metadata( 'post', $object_id, "{$this->prefix}_flag", (string) $row );
+ }
+ $this->record_id_reads();
+
+ Uninstaller::run( self::PLUGIN_FILE, array( 'meta' => array( 'post' => array( "{$this->prefix}_flag" ) ) ), self::stored_consent( 'yes' )( $this->prefix ) );
+
+ self::assertFalse( $this->meta_exists( 'post', "{$this->prefix}_flag" ) );
+ $this->assert_id_reads_paged();
+ }
+
+ public function test_a_post_of_an_unregistered_type_is_deleted_with_its_term_relationships(): void {
+ global $wpdb;
+
+ $term = wp_insert_term( "$this->prefix-tagged", 'category' );
+ self::assertIsArray( $term );
+ $this->ids['term'] = (int) $term['term_id'];
+ $post_id = $this->make_post( "{$this->prefix}_type", 'publish' );
+ wp_set_object_terms( $post_id, array( $this->ids['term'] ), 'category' );
+
+ Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) );
+
+ self::assertFalse( $this->post_exists( $post_id ) );
+ self::assertSame( '0', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache.
+ }
+
+ public function test_a_deleted_posts_id_keeps_its_relationships_in_a_taxonomy_of_another_object_type(): void {
+ global $wpdb;
+
+ $taxonomy = "{$this->prefix}_team";
+ register_taxonomy( $taxonomy, 'user' );
+ $term = wp_insert_term( "$this->prefix-team", $taxonomy );
+ try {
+ self::assertIsArray( $term );
+ $post_id = $this->make_post( "{$this->prefix}_type", 'publish' );
+ wp_set_object_terms( $post_id, array( (int) $term['term_id'] ), $taxonomy ); // Stands for the user that shares the post's id.
+
+ Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) );
+
+ self::assertFalse( $this->post_exists( $post_id ) );
+ self::assertSame( '1', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache.
+ } finally {
+ if ( \is_array( $term ) ) {
+ wp_delete_term( (int) $term['term_id'], $taxonomy );
+ }
+ unregister_taxonomy( $taxonomy );
+ }
+ }
+
+ public function test_a_post_whose_delete_wordpress_refuses_keeps_its_term_relationships(): void {
+ global $wpdb;
+
+ $term = wp_insert_term( "$this->prefix-tagged", 'category' );
+ self::assertIsArray( $term );
+ $this->ids['term'] = (int) $term['term_id'];
+ $post_id = $this->make_post( "{$this->prefix}_type", 'publish' );
+ wp_set_object_terms( $post_id, array( $this->ids['term'] ), 'category' );
+ add_filter( 'pre_delete_post', '__return_false' );
+
+ try {
+ Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) );
+ self::fail( 'The refused delete was not reported.' );
+ } catch ( RuntimeException ) {
+ self::assertTrue( $this->post_exists( $post_id ) );
+ } finally {
+ remove_filter( 'pre_delete_post', '__return_false' ); // The fixtures' own deletes must not be refused.
+ }
+
+ self::assertSame( '1', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache.
+ }
+
public function test_the_cleanup_runs_first_while_the_footprint_and_the_consent_row_are_stored(): void {
$consent_option = "{$this->prefix}_cache_remove_data";
update_option( $consent_option, 'yes' );
diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php
index 7325f7f..0b40224 100644
--- a/tests/Integration/Support/consumer-request.php
+++ b/tests/Integration/Support/consumer-request.php
@@ -2,7 +2,7 @@
/**
* Runs copies of the consumer and persona fixtures in the request: activates them on the site, defines the spec's constants and locale, skips WordPress's update check, and records how each consumer and the core persona booted, once the admin initializes and again after a plugin's activation, what consumer-a and the core persona did, and what WooCommerce logged.
*
- * The spec may also mint the nonce of an action the request checks, relax consumer-a's export safeguard with a value, expect warnings for files a partial deploy removed, uninstall a copy, or run consumer-a's order-count export with a failing order query.
+ * The spec may also mint the nonce of an action the request checks, relax consumer-a's export safeguard with a value, expect warnings for files a partial deploy removed, uninstall a copy, fail every query that holds a given text, or run consumer-a's order-count export with a failing order query.
*
* @package Acme
*/
@@ -27,6 +27,10 @@ static function ( int $level, string $message, string $file, int $line ) use ( $
ini_set( 'error_log', $spec['log'] ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- Captures the plugin's log lines.
}
+if ( '' !== ( $spec['failing_query'] ?? '' ) ) {
+ add_filter( 'query', static fn ( string $query ): string => str_contains( $query, $spec['failing_query'] ) ? 'SELECT * FROM acme_missing_table' : $query );
+}
+
foreach ( $spec['constants'] ?? array() as $acme_name => $acme_value ) {
define( $acme_name, $acme_value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound -- Defines what the spec names.
}
From 11c0718638a6bc60a60711f4be19427fe5e23c07 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:06 +0100
Subject: [PATCH 10/33] Test that the object stores refuse objects
MetadataStore and OrderMetaStore accept any value, but the metadata API
rewrites an object's public properties as it unslashes them, and an
object's class name changes with each plugin's scoping prefix, so it
would not read back.
The rows expect an object, at the top or nested in an array, to throw
before any key is written, in post meta and in both of WooCommerce's
order storages.
Assisted-by: Claude Code:claude-opus-5-5
---
.../EditScreen/Stores/MetadataStoreTest.php | 29 ++++++++++++++++
.../EditScreen/Stores/OrderMetaStoreTest.php | 34 +++++++++++++++++++
2 files changed, 63 insertions(+)
diff --git a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php
index 2b6d7e2..95492de 100644
--- a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php
+++ b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php
@@ -3,6 +3,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\Settings\EditScreen\Stores;
use DeepWebSolutions\Framework\Settings\EditScreen\Stores\MetadataStore;
+use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait;
use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait;
use PHPUnit\Framework\Attributes\After;
@@ -77,6 +78,34 @@ public function test_values_are_stored_as_given_backslashes_included( string $me
self::assertSame( array( array( 'C:\\dir', 'red' ) ), get_metadata( $meta_type, $object_id, "_{$this->prefix}_list", false ) );
}
+ public static function unstorable_values(): array {
+ return array(
+ 'object' => array( (object) array( 'path' => 'C:\\reports' ) ),
+ 'nested-object' => array( array( 'red', array( (object) array( 'path' => 'C:\\reports' ) ) ) ),
+ );
+ }
+
+ #[DataProvider( 'unstorable_values' )]
+ public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws_before_any_key_is_written( mixed $value ): void {
+ $object_id = $this->make_object( 'post' );
+
+ try {
+ ( new MetadataStore( 'post' ) )->update_values(
+ $object_id,
+ array(
+ "_{$this->prefix}_first" => 'Written first.',
+ "_{$this->prefix}_value" => $value,
+ )
+ );
+ self::fail( 'The store wrote a value it cannot keep.' );
+ } catch ( InvalidArgumentException $exception ) {
+ self::assertStringContainsString( "_{$this->prefix}_value", $exception->getMessage() );
+ }
+
+ self::assertSame( array(), get_metadata( 'post', $object_id, "_{$this->prefix}_first", false ) );
+ self::assertSame( array(), get_metadata( 'post', $object_id, "_{$this->prefix}_value", false ) );
+ }
+
#[DataProvider( 'meta_types' )]
public function test_a_stored_empty_value_survives_a_round_trip( string $meta_type ): void {
$object_id = $this->make_object( $meta_type );
diff --git a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php
index 5a0362d..a7a332d 100644
--- a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php
+++ b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce\EditScreen\Stores;
+use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait;
use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait;
use DeepWebSolutions\Framework\Tests\Integration\WooCommerce\EditScreen\OrderFixturesTrait;
@@ -63,6 +64,39 @@ public function test_values_are_stored_as_given_text_backslashes_included( strin
self::assertSame( array( array( 'red', 'teal' ) ), $this->order_meta( $order->get_id(), "_{$this->prefix}_list" ) );
}
+ public static function storages_and_unstorable_values(): array {
+ $object = (object) array( 'path' => 'C:\\reports' );
+
+ return array(
+ 'order-tables-object' => array( 'hpos', $object ),
+ 'order-tables-nested-object' => array( 'hpos', array( 'red', array( $object ) ) ),
+ 'posts-table-object' => array( 'posts', $object ),
+ 'posts-table-nested-object' => array( 'posts', array( 'red', array( $object ) ) ),
+ );
+ }
+
+ #[DataProvider( 'storages_and_unstorable_values' )]
+ public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws_before_any_key_is_written( string $storage, mixed $value ): void {
+ $this->use_order_storage( $storage );
+ $order = $this->make_order();
+
+ try {
+ ( new OrderMetaStore() )->update_values(
+ $order->get_id(),
+ array(
+ "_{$this->prefix}_first" => 'Written first.',
+ "_{$this->prefix}_value" => $value,
+ )
+ );
+ self::fail( 'The store wrote a value it cannot keep.' );
+ } catch ( InvalidArgumentException $exception ) {
+ self::assertStringContainsString( "_{$this->prefix}_value", $exception->getMessage() );
+ }
+
+ self::assertSame( array(), $this->order_meta( $order->get_id(), "_{$this->prefix}_first" ) );
+ self::assertSame( array(), $this->order_meta( $order->get_id(), "_{$this->prefix}_value" ) );
+ }
+
#[DataProvider( 'order_storages' )]
public function test_a_stored_empty_value_survives_a_round_trip( string $storage ): void {
$this->use_order_storage( $storage );
From 86273f9b191a8d6e19cf3e4a61cd00d230b21366 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:06 +0100
Subject: [PATCH 11/33] Test nested value objects of different classes
Nested equality calls a value object's equals() with an object of any
class, while equals() admits only its own class, so a lenient
implementation reports two different objects as equal.
The rows expect sibling classes with equal values, a lenient object
beside another class, an integer beside a numeric string and distinct
objects of equal state to compare unequal, and one object held by both
to compare equal.
Assisted-by: Claude Code:claude-opus-5-5
---
.../ValueObject/AbstractValueObjectTest.php | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php b/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php
index f5696c6..d951399 100644
--- a/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php
+++ b/tests/Unit/Shared/ValueObject/AbstractValueObjectTest.php
@@ -49,13 +49,23 @@ public function __construct(
final readonly class Euro extends Money {}
+final readonly class Dollar extends Money {}
+
+final class Lenient implements ValueObjectInterface {
+ #[\Override]
+ public function equals( ValueObjectInterface $other ): bool {
+ return true;
+ }
+}
+
#[CoversClass( AbstractValueObject::class )]
final class AbstractValueObjectTest extends TestCase {
public static function pairs(): array {
- $keyed = array(
+ $keyed = array(
'a' => 1,
'b' => 2,
);
+ $shared = new \stdClass();
return array(
'dates-half-a-second-apart' => array( new Box( new \DateTimeImmutable( '2026-01-01 12:00:00.000000 UTC' ) ), new Box( new \DateTimeImmutable( '2026-01-01 12:00:00.500000 UTC' ) ), false ),
@@ -72,6 +82,11 @@ public static function pairs(): array {
'differing-private-property' => array( new Sealed( 1, 2 ), new Sealed( 1, 9 ), false ),
'differing-private-parent-property' => array( new Euro( 1 ), new Euro( 2 ), false ),
'different-classes-with-equal-values' => array( new Weight( 5 ), new Length( 5 ), false ),
+ 'sibling-classes-with-equal-values' => array( new Euro( 1 ), new Dollar( 1 ), false ),
+ 'nested-lenient-and-another-class' => array( new Box( new Lenient() ), new Box( new Weight( 5 ) ), false ),
+ 'integer-and-numeric-string' => array( new Box( 1 ), new Box( '1' ), false ),
+ 'distinct-objects-of-equal-state' => array( new Box( new \stdClass() ), new Box( new \stdClass() ), false ),
+ 'one-object-in-both' => array( new Box( $shared ), new Box( $shared ), true ),
);
}
From bed7ae88684cf2cb888dddeb82bcabbf05480360 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:07 +0100
Subject: [PATCH 12/33] Test typed less-than signs, checked checkboxes and the
rejection
A lone '<' typed into a text or textarea field comes back as a visible
'<' after the save, because the sanitizer encodes it and every output
escapes it again; a checkbox renders checked only for 'yes', while its
sanitizer and convention 8's typed read also treat true and '1' as on;
and the rejection sentence does not say that the other changes were
saved (X ruling 334).
The rows expect '<' kept and a typed '<' read as text, a checkbox
checked for true and '1', and the new sentence on every host. The
product panel's entity row stores a value without '<', which a save
now turns back into '<'.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/Personas/SettingsPersonaTest.php | 2 +-
.../Settings/AdminPage/OptionsPageTest.php | 10 +++++-----
.../Settings/EditScreen/Hosts/PostMetaBoxTest.php | 8 ++++----
.../Settings/EditScreen/Hosts/TermFormTest.php | 2 +-
.../Settings/EditScreen/Hosts/UserProfileTest.php | 6 +++---
.../Settings/EditScreen/ObjectFormTest.php | 4 ++--
.../Settings/FormsGetRejectionMessageTest.php | 4 ++--
.../Integration/Settings/FormsRenderControlTest.php | 12 ++++++++++++
.../Integration/Settings/FormsSanitizeValueTest.php | 3 +++
.../EditScreen/Hosts/OrderMetaBoxTest.php | 6 +++---
.../WooCommerce/ProductData/ProductDataTabTest.php | 12 ++++++------
.../Integration/WooCommerce/Setting/SettingsTest.php | 8 ++++----
12 files changed, 46 insertions(+), 31 deletions(-)
diff --git a/tests/Integration/Personas/SettingsPersonaTest.php b/tests/Integration/Personas/SettingsPersonaTest.php
index f634f42..7ad0aa8 100644
--- a/tests/Integration/Personas/SettingsPersonaTest.php
+++ b/tests/Integration/Personas/SettingsPersonaTest.php
@@ -36,7 +36,7 @@ public function test_a_rejected_limit_keeps_the_stored_one_and_its_message_shows
self::assertIsString( $saved['location'] );
$page = $this->consumer_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $saved['user'], $saved['spec'] );
- self::assertStringContainsString( 'Limit was not saved, so it keeps its previous value. Enter a whole number of at least 1.', $page['body'] );
+ self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a whole number of at least 1.', $page['body'] );
}
// Posts the settings tab as the browser submits it after the user typed the limit.
diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
index ad923ed..60fcd2a 100644
--- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php
+++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php
@@ -146,7 +146,7 @@ public function test_a_rejected_field_keeps_its_value_and_its_message_shows_once
$saved = $this->save( $spec, $this->admin, $data );
self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) );
- self::assertSame( array( 'Name was not saved, so it keeps its previous value. Use at most four characters.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) );
+ self::assertSame( array( 'Name was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) );
}
#[DataProvider( 'parents' )]
@@ -168,7 +168,7 @@ public function test_a_rejection_without_a_message_says_that_the_value_is_not_va
$saved = $this->save( $spec, $this->admin, $data );
self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) );
- self::assertSame( array( 'Name was not saved because its value is not valid, so it keeps its previous value.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) );
+ self::assertSame( array( 'Name was not saved because its value is not valid, and keeps its previous value. The other changes were saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) );
}
public function test_a_built_in_rejection_carries_what_to_enter(): void {
@@ -179,7 +179,7 @@ public function test_a_built_in_rejection_carries_what_to_enter(): void {
$saved = $this->save( $spec, $this->admin, $data );
self::assertSame( array( 'email' => 'ops@example.com' ), get_option( $this->option ) );
- self::assertSame( array( 'Email was not saved, so it keeps its previous value. Enter a valid email address, such as name@example.com.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) );
+ self::assertSame( array( 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) );
}
public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_labelled_and_named_by_its_id(): void {
@@ -208,8 +208,8 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_
self::assertNull( self::document( $form )->querySelector( 'form em' ) );
self::assertSame(
array(
- 'Email was not saved, so it keeps its previous value. Enter a valid email address, such as name@example.com.',
- 'site was not saved, so it keeps its previous value. Enter a valid URL, such as https://example.com.',
+ 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.',
+ 'site was not saved and keeps its previous value. The other changes were saved. Enter a valid URL, such as https://example.com.',
),
self::notices( $page )
);
diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
index c7eb084..535a3cc 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php
@@ -64,8 +64,8 @@ public static function group_fields(): array {
public static function silent_and_worded_rejections(): array {
return array(
- 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved, so it keeps its previous value. Use at most four characters." ),
- 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, so it keeps its previous value." ),
+ 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ),
+ 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ),
);
}
@@ -426,7 +426,7 @@ public function test_a_membership_rejection_names_the_field_and_what_to_enter():
$saved = $this->save( $spec, $post_id, array(), array( $this->id => array( "_{$this->prefix}_color" => 'black' ) ) );
self::assertSame( array( 'red' ), $this->meta( $post_id, 'color' ) );
- self::assertSame( array( 'Color was not saved, so it keeps its previous value. Choose one of the available options.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) );
+ self::assertSame( array( 'Color was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) );
}
public function test_a_new_post_renders_defaults_and_saves_them_on_publish(): void {
@@ -682,7 +682,7 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th
self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) );
self::assertNotNull( self::document( $redirected['body'] )->querySelector( 'form.metabox-base-form' ) );
- self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), \array_column( $delivered, 'content' ) );
+ self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), \array_column( $delivered, 'content' ) );
self::assertSame( array(), self::rejections( $list['body'] ) );
}
diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
index 207f372..9322e36 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php
@@ -114,7 +114,7 @@ public function test_update_saves_the_accepted_fields_and_shows_a_rejection_afte
self::assertSame( array( 'O\'Brien C:\\dir' ), $this->meta( $term_id, 'name' ) );
self::assertSame( array( 'kept' ), $this->meta( $term_id, 'note' ) );
- self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $next['body'] ) );
+ self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $next['body'] ) );
self::assertSame( array(), self::rejections( $after['body'] ) );
}
diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
index 7322f7a..942b43e 100644
--- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
+++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php
@@ -131,7 +131,7 @@ public function test_both_screens_save_the_accepted_fields_and_show_a_rejection_
self::assertSame( array( 'O\'Brien C:\\dir' ), $this->meta( $user_id, 'name' ) );
self::assertSame( array( array( 'red', 'teal' ) ), $this->meta( $user_id, 'color' ) );
self::assertSame( array( 'kept' ), $this->meta( $user_id, 'note' ) );
- self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $next['body'] ) );
+ self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $next['body'] ) );
self::assertSame( array(), self::rejections( $after['body'] ) );
}
@@ -150,7 +150,7 @@ public function test_a_rejection_on_a_multisite_profile_shows_after_the_redirect
}
self::assertStringStartsWith( \explode( '/', $path )[0] . '/profile.php', \explode( '/wp-admin/', (string) $saved['location'], 2 )[1] );
- self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $next['body'] ) );
+ self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $next['body'] ) );
}
public function test_a_core_profile_error_shows_the_forms_rejection_in_the_same_response(): void {
@@ -168,7 +168,7 @@ public function test_a_core_profile_error_shows_the_forms_rejection_in_the_same_
self::assertNull( $saved['location'] );
self::assertNotNull( self::document( $saved['body'] )->querySelector( 'div.error' ) );
- self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $saved['body'] ) );
+ self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $saved['body'] ) );
self::assertSame( array( 'saved anyway' ), $this->meta( $this->subscriber, 'name' ) );
}
diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
index f8355c1..f9c914f 100644
--- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php
+++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php
@@ -164,7 +164,7 @@ public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user
$stored = $notices->get_notices( NoticeScope::User, $this->author_id );
self::assertCount( 1, $stored );
- self::assertSame( 'Note & more was not saved, so it keeps its previous value. Use at most four characters.', $stored[0]->message );
+ self::assertSame( 'Note & more was not saved and keeps its previous value. The other changes were saved. Use at most four characters.', $stored[0]->message );
self::assertSame( NoticeType::Error, $stored[0]->type );
self::assertFalse( $stored[0]->persistent );
self::assertSame( 'Saved.', get_post_meta( $this->post_id, "_{$this->prefix}_name", true ) );
@@ -182,7 +182,7 @@ public function test_a_second_rejection_of_a_field_replaces_the_first(): void {
$stored = $notices->get_notices( NoticeScope::User, $this->author_id );
self::assertCount( 1, $stored );
self::assertSame( 'rejection-' . \md5( "{$this->prefix}_details|_{$this->prefix}_note" ), $stored[0]->id );
- self::assertSame( 'Renamed note was not saved, so it keeps its previous value. Use at most four characters.', $stored[0]->message );
+ self::assertSame( 'Renamed note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.', $stored[0]->message );
}
public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_keeps_the_accepted_values(): void {
diff --git a/tests/Integration/Settings/FormsGetRejectionMessageTest.php b/tests/Integration/Settings/FormsGetRejectionMessageTest.php
index 63ac4e4..bc640bd 100644
--- a/tests/Integration/Settings/FormsGetRejectionMessageTest.php
+++ b/tests/Integration/Settings/FormsGetRejectionMessageTest.php
@@ -9,10 +9,10 @@
#[CoversClass( Forms::class )]
final class FormsGetRejectionMessageTest extends TestCase {
public function test_a_worded_rejection_names_the_field_and_what_to_enter_as_plain_text(): void {
- self::assertSame( "Customer's note was not saved, so it keeps its previous value. Use at most four characters.", Forms::get_rejection_message( "Customer's note", new \WP_Error( 'acme_rejected', 'Use at most four characters.' ) ) );
+ self::assertSame( "Customer's note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.", Forms::get_rejection_message( "Customer's note", new \WP_Error( 'acme_rejected', 'Use at most four characters.' ) ) );
}
public function test_a_silent_rejection_names_the_field_alone(): void {
- self::assertSame( 'Note was not saved because its value is not valid, so it keeps its previous value.', Forms::get_rejection_message( 'Note', new \WP_Error( 'acme_rejected' ) ) );
+ self::assertSame( 'Note was not saved because its value is not valid, and keeps its previous value. The other changes were saved.', Forms::get_rejection_message( 'Note', new \WP_Error( 'acme_rejected' ) ) );
}
}
diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php
index 2436a55..ff33554 100644
--- a/tests/Integration/Settings/FormsRenderControlTest.php
+++ b/tests/Integration/Settings/FormsRenderControlTest.php
@@ -144,6 +144,18 @@ public function test_a_checkbox_posts_a_hidden_no_before_its_yes(): void {
self::assertFalse( self::render( 'checkbox', array(), 'no' )->getElementById( 'acme.key' )->hasAttribute( 'checked' ) );
}
+ public static function checked_values(): array {
+ return array(
+ 'true' => array( true ),
+ 'one' => array( '1' ),
+ );
+ }
+
+ #[DataProvider( 'checked_values' )]
+ public function test_a_checkbox_renders_checked_for_every_value_its_sanitizer_reads_as_yes( mixed $value ): void {
+ self::assertTrue( self::render( 'checkbox', array(), $value )->getElementById( 'acme.key' )->hasAttribute( 'checked' ) );
+ }
+
public function test_a_checkbox_with_a_desc_is_named_by_its_title_and_labeled_by_its_desc(): void {
$document = self::render(
'checkbox',
diff --git a/tests/Integration/Settings/FormsSanitizeValueTest.php b/tests/Integration/Settings/FormsSanitizeValueTest.php
index 5c055fc..289a48e 100644
--- a/tests/Integration/Settings/FormsSanitizeValueTest.php
+++ b/tests/Integration/Settings/FormsSanitizeValueTest.php
@@ -28,7 +28,10 @@ public static function accepted(): array {
return array(
'text-strips-tags-and-trims' => array( 'text', ' Hi there ', 'Hi there' ),
'text-stringifies-an-int' => array( 'text', 5, '5' ),
+ 'text-keeps-a-lone-less-than' => array( 'text', 'a < b', 'a < b' ),
+ 'text-reads-a-typed-entity-as-text' => array( 'text', 'a < b', 'a < b' ),
'textarea-keeps-newlines' => array( 'textarea', "One\ntwo", "One\ntwo" ),
+ 'textarea-keeps-a-lone-less-than' => array( 'textarea', "1 < 2\nOne", "1 < 2\nOne" ),
'email-is-trimmed' => array( 'email', ' ops@example.com ', 'ops@example.com' ),
'email-cleared' => array( 'email', '', '' ),
'url-is-kept' => array( 'url', 'https://example.com/a?b=c', 'https://example.com/a?b=c' ),
diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
index 731151a..c729b7b 100644
--- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
+++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php
@@ -58,8 +58,8 @@ public static function group_fields(): array {
public static function silent_and_worded_rejections(): array {
return array(
- 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved, so it keeps its previous value. Use at most four characters." ),
- 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, so it keeps its previous value." ),
+ 'worded' => array( 'acme:reject', "Customer's note \\o/ was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ),
+ 'silent' => array( 'acme:reject-silently', "Customer's note \\o/ was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ),
);
}
@@ -404,7 +404,7 @@ public function test_on_the_posts_table_a_rejection_shows_after_the_redirect():
$saved = $this->posts_save( $spec, $order->get_id(), array( $this->control_name( 'note' ) => 'too long' ) );
self::assertSame( array( 'kept' ), $this->meta( $order->get_id(), 'note' ) );
- self::assertSame( array( 'Note was not saved, so it keeps its previous value. Use at most four characters.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) );
+ self::assertSame( array( 'Note was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::rejections( $this->follow( $spec, $saved )['body'] ) );
}
protected function spec( array $fields, array $form = array(), array $extra = array() ): array {
diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
index 109fb7c..706025e 100644
--- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
+++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
@@ -49,8 +49,8 @@ public static function disabled_checkboxes(): array {
public static function silent_and_worded_rejections(): array {
return array(
- 'worded' => array( 'acme:reject', "Customer's note was not saved, so it keeps its previous value. Use at most four characters." ),
- 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, so it keeps its previous value." ),
+ 'worded' => array( 'acme:reject', "Customer's note was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ),
+ 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ),
);
}
@@ -481,7 +481,7 @@ public function test_a_field_whose_name_differs_from_its_id_saves_under_its_name
}
public function test_text_holding_html_entities_survives_an_unchanged_save(): void {
- $stored = 'Use <b>bold</b> & more';
+ $stored = 'Use "bold" > plain & more';
$product = $this->make_product( array( "_{$this->prefix}_name" => $stored ) );
$spec = $this->spec( array( $this->group( array( $this->field( 'text', 'name' ) ) ) ) );
@@ -535,7 +535,7 @@ public function test_a_rejected_choice_keeps_the_stored_value_and_says_what_to_c
$this->save( $spec, $product->get_id(), array(), array( "_{$this->prefix}_color" => 'purple' ) );
self::assertSame( array( 'red' ), $this->meta( $product, 'color' ) );
- self::assertSame( array( 'Color was not saved, so it keeps its previous value. Choose one of the available options.' ), $this->errors( $spec, $product ) );
+ self::assertSame( array( 'Color was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), $this->errors( $spec, $product ) );
}
#[DataProvider( 'silent_and_worded_rejections' )]
@@ -632,7 +632,7 @@ public function test_a_checkboxes_group_rejects_an_unknown_choice(): void {
$this->save( $spec, $product->get_id(), array(), array( "_{$this->prefix}_colors" => array( 'purple' ) ) );
self::assertSame( array( array( 'blue' ) ), $this->meta( $product, 'colors' ) );
- self::assertSame( array( 'Colors was not saved, so it keeps its previous value. Choose one of the available options.' ), $this->errors( $spec, $product ) );
+ self::assertSame( array( 'Colors was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), $this->errors( $spec, $product ) );
}
public function test_a_custom_type_field_stores_its_validates_return_value_under_its_key(): void {
@@ -652,7 +652,7 @@ public function test_a_custom_type_field_without_validate_is_rejected(): void {
$this->save( $spec, $product->get_id(), array( "_{$this->prefix}_widget" => 'v' ) );
self::assertSame( array( 'old' ), $this->meta( $product, 'widget' ) );
- self::assertSame( array( 'Widget was not saved, so it keeps its previous value. This field cannot be saved because nothing validates its value.' ), $this->errors( $spec, $product ) );
+ self::assertSame( array( 'Widget was not saved and keeps its previous value. The other changes were saved. This field cannot be saved because nothing validates its value.' ), $this->errors( $spec, $product ) );
}
public function test_an_unsupported_product_never_saves_the_tabs_fields(): void {
diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php
index 025a0ec..dcecd41 100644
--- a/tests/Integration/WooCommerce/Setting/SettingsTest.php
+++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php
@@ -59,8 +59,8 @@ protected function tearDown(): void {
public static function silent_and_worded_rejections(): array {
return array(
- 'worded' => array( 'acme:reject', "Customer's note was not saved, so it keeps its previous value. Use at most four characters." ),
- 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, so it keeps its previous value." ),
+ 'worded' => array( 'acme:reject', "Customer's note was not saved and keeps its previous value. The other changes were saved. Use at most four characters." ),
+ 'silent' => array( 'acme:reject-silently', "Customer's note was not saved because its value is not valid, and keeps its previous value. The other changes were saved." ),
);
}
@@ -454,7 +454,7 @@ public function test_a_custom_type_field_without_validate_is_rejected(): void {
$saved = self::document( $this->save( $spec, '', array( "{$this->prefix}_widget" => 'w' ) )['body'] );
self::assertSame( 'old', get_option( "{$this->prefix}_widget" ) );
- self::assertSame( array( 'Widget was not saved, so it keeps its previous value. This field cannot be saved because nothing validates its value.' ), self::texts( $saved, '#message.error' ) );
+ self::assertSame( array( 'Widget was not saved and keeps its previous value. The other changes were saved. This field cannot be saved because nothing validates its value.' ), self::texts( $saved, '#message.error' ) );
}
public function test_a_custom_type_field_the_helper_does_not_own_is_not_printed(): void {
@@ -503,7 +503,7 @@ public function test_a_checkboxes_group_rejects_an_unknown_choice(): void {
$saved = self::document( $this->save( $spec, '', array(), array( "{$this->prefix}_colors" => array( 'pink' ) ) )['body'] );
self::assertSame( array( 'red' ), get_option( "{$this->prefix}_colors" ) );
- self::assertSame( array( 'Colors was not saved, so it keeps its previous value. Choose one of the available options.' ), self::texts( $saved, '#message.error' ) );
+ self::assertSame( array( 'Colors was not saved and keeps its previous value. The other changes were saved. Choose one of the available options.' ), self::texts( $saved, '#message.error' ) );
}
protected function tab_spec( array $sections ): array {
From d4f01de209437e9d0aae86647b58ae8b4cb42c61 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:07 +0100
Subject: [PATCH 13/33] Test product radios and the ids WooCommerce rewrites
Product-panel radios render through woocommerce_wp_radio(), which
ignores disabled, so a disabled radio drops the user's change and a
disabled stored choice is rejected on every save; and WooCommerce passes
tab and section ids through sanitize_title(), so a rewritten id leaves
the fields unclaimed and their validate bypassed.
The rows expect a disabled radio and a disabled choice rendered disabled
and kept, and an id that sanitize_title() rewrites rejected as a tab id,
a section key and a section id. The panel's textarea, radio, email and
url fields also render and keep entities and quotes.
Assisted-by: Claude Code:claude-opus-5-5
---
.../ProductData/ProductDataTabTest.php | 91 +++++++++++++++++++
.../WooCommerce/Setting/SettingsTest.php | 27 ++++++
2 files changed, 118 insertions(+)
diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
index 706025e..87f6f7e 100644
--- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
+++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php
@@ -310,6 +310,61 @@ public function test_a_disabled_checkbox_renders_disabled_without_its_hidden_inp
self::assertNull( $screen->querySelector( "input[type=hidden][name=\"_{$this->prefix}_flag\"]" ) );
}
+ public function test_a_disabled_radio_renders_every_choice_disabled_and_keeps_its_value(): void {
+ $product = $this->make_product( array( "_{$this->prefix}_color" => 'blue' ) );
+ $spec = $this->spec(
+ array(
+ $this->group(
+ array(
+ $this->field(
+ 'radio',
+ 'color',
+ array(
+ 'options' => self::COLORS,
+ 'custom_attributes' => array( 'disabled' => 'disabled' ),
+ )
+ ),
+ )
+ ),
+ )
+ );
+
+ $screen = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] );
+ $inputs = \iterator_to_array( $screen->querySelectorAll( "fieldset.form-field input[type=radio][name=\"_{$this->prefix}_color\"]" ) );
+ $this->save( $spec, $product->get_id(), array( "_{$this->prefix}_color" => 'red' ) );
+
+ self::assertCount( \count( self::COLORS ), $inputs );
+ self::assertSame( array( true, true, true ), \array_map( static fn ( \Dom\Element $input ): bool => $input->hasAttribute( 'disabled' ), $inputs ) );
+ self::assertSame( array( 'blue' ), $this->meta( $product, 'color' ) );
+ }
+
+ public function test_a_stored_choice_that_is_disabled_renders_disabled_and_saves_without_a_rejection(): void {
+ $product = $this->make_product( array( "_{$this->prefix}_color" => 'blue' ) );
+ $spec = $this->spec(
+ array(
+ $this->group(
+ array(
+ $this->field(
+ 'radio',
+ 'color',
+ array(
+ 'options' => self::COLORS,
+ 'disabled' => array( 'blue' ),
+ )
+ ),
+ )
+ ),
+ )
+ );
+
+ $screen = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] );
+ $this->save( $spec, $product->get_id() );
+
+ self::assertTrue( $screen->querySelector( "fieldset.form-field input[type=radio][name=\"_{$this->prefix}_color\"][value=\"blue\"]" )?->hasAttribute( 'disabled' ) );
+ self::assertSame( array( 'blue' ), $this->meta( $product, 'color' ) );
+ self::assertSame( array(), $this->errors( $spec, $product ) );
+ }
+
public function test_an_unlabelled_field_is_labelled_by_its_id(): void {
$product = $this->make_product();
$field = $this->field( 'text', 'name' );
@@ -492,6 +547,42 @@ public function test_text_holding_html_entities_survives_an_unchanged_save(): vo
self::assertSame( array( $stored ), $this->meta( $product, 'name' ) );
}
+ // An email address cannot hold a semicolon, so its value carries the ampersand and the quote that its markup escapes.
+ public static function values_holding_entities_and_quotes(): array {
+ return array(
+ 'textarea' => array( 'textarea', 'Use "bold" & more', array() ),
+ 'radio' => array(
+ 'radio',
+ 'Tom & "Jerry"',
+ array(
+ 'options' => array(
+ 'plain' => 'Plain',
+ 'Tom & "Jerry"' => 'Both',
+ ),
+ ),
+ ),
+ 'email' => array( 'email', "o'neil&co@example.com", array() ),
+ 'url' => array( 'url', "https://example.com/?q='x'&page=2", array() ),
+ );
+ }
+
+ #[DataProvider( 'values_holding_entities_and_quotes' )]
+ public function test_a_value_holding_entities_and_quotes_renders_and_survives_an_unchanged_save( string $type, string $stored, array $keys ): void {
+ $product = $this->make_product( array( "_{$this->prefix}_value" => $stored ) );
+ $spec = $this->spec( array( $this->group( array( $this->field( $type, 'value', $keys ) ) ) ) );
+
+ $screen = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] );
+ $this->save( $spec, $product->get_id() );
+
+ $shown = match ( $type ) {
+ 'textarea' => $this->control( $screen, 'value' )->textContent, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property.
+ 'radio' => $screen->querySelector( "input[type=radio][name=\"_{$this->prefix}_value\"][checked]" )?->getAttribute( 'value' ),
+ default => $this->control( $screen, 'value' )->getAttribute( 'value' ),
+ };
+ self::assertSame( $stored, $shown );
+ self::assertSame( array( $stored ), $this->meta( $product, 'value' ) );
+ }
+
public function test_a_field_without_validate_is_sanitized_by_its_type(): void {
$product = $this->make_product();
$spec = $this->spec( array( $this->group( array( $this->field( 'text', 'name' ) ) ) ) );
diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php
index dcecd41..6e62e77 100644
--- a/tests/Integration/WooCommerce/Setting/SettingsTest.php
+++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php
@@ -78,6 +78,33 @@ public function test_an_invalid_section_id_is_rejected( string $id ): void {
new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() );
}
+ public static function ids_woocommerce_rewrites(): array {
+ return array(
+ 'double-hyphen' => array( 'acme--settings' ),
+ 'trailing-hyphen' => array( 'acme-settings-' ),
+ );
+ }
+
+ #[DataProvider( 'ids_woocommerce_rewrites' )]
+ public function test_an_id_woocommerce_would_rewrite_is_rejected_as_a_tab_id_a_section_key_and_a_section_id( string $id ): void {
+ $builds = array(
+ 'tab-id' => fn (): SettingsTab => new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() ),
+ 'section-key' => fn (): SettingsTab => new SettingsTab( $this->prefix, $this->tab( array( $id => 'Acme' ) ), $this->fields_definition() ),
+ 'section-id' => fn (): SettingsSection => new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() ),
+ );
+
+ $rejected = array();
+ foreach ( $builds as $kind => $build ) {
+ try {
+ $build();
+ } catch ( InvalidArgumentException ) {
+ $rejected[] = $kind;
+ }
+ }
+
+ self::assertSame( \array_keys( $builds ), $rejected );
+ }
+
#[DataProvider( 'v1_helper_ids' )]
public function test_every_v1_key_is_a_valid_tab_and_section_id( string $id ): void {
self::assertSame( $id, ( new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() ) )->get_id() );
From 9331e0b1fd8934a1975148b5da8787cc6e8948c1 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:08 +0100
Subject: [PATCH 14/33] Offer only lowercase-labelled releases and describe
them in full
The prerelease pattern's /i flag admitted labels that version_compare()
misorders; the stable channel trusted GitHub's prerelease flag, which is
set by hand; the details modal escaped the release's Markdown and left
out the author, homepage, release date and tested-up-to version; the
cached record kept an unused tag; and a refused plugin outside the
plugins directory made core warn on every admin screen (X ruling 167,
and X ruling 294's A762).
The pattern takes only lowercase dev, alpha, beta and rc, and the stable
channel skips any tag holding '-'. The release request asks for GitHub's
HTML media type, and the details carry its body_html, which core's kses
filters, with the plugin's Author and Plugin URI, the release date and
the entry's Tested up to header, which the offer carries too. The cached
record drops its tag, and a refused plugin's notice returns before core
reads headers from a file it cannot find.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/bootstrap/README.md | 2 +-
packages/bootstrap/src/Requirements.php | 11 ++++--
packages/bootstrap/src/Updater.php | 50 ++++++++++++++++---------
3 files changed, 41 insertions(+), 22 deletions(-)
diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md
index 6de0d06..13fa9b0 100644
--- a/packages/bootstrap/README.md
+++ b/packages/bootstrap/README.md
@@ -79,7 +79,7 @@ if ( ! Requirements::check_floors( __FILE__ ) ) {
require_once __DIR__ . '/functions.php';
```
-The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in any letter case, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release.
+The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release.
## Contract and versioning
diff --git a/packages/bootstrap/src/Requirements.php b/packages/bootstrap/src/Requirements.php
index 302c7b6..3797a96 100644
--- a/packages/bootstrap/src/Requirements.php
+++ b/packages/bootstrap/src/Requirements.php
@@ -47,13 +47,18 @@ public static function check_floors( string $plugin_file ): bool {
return true;
}
+ $basename = plugin_basename( $plugin_file );
add_action(
'all_admin_notices',
- static function () use ( $plugin_file ): void {
+ static function () use ( $basename ): void {
+ if ( ! \is_file( WP_PLUGIN_DIR . "/$basename" ) ) {
+ return; // Core reads the headers from the plugins directory, so it has no message for a plugin loaded from elsewhere.
+ }
+
// Core translates the plugin's headers while it builds the message, so the message waits until the notice renders.
- $requirements = validate_plugin_requirements( plugin_basename( $plugin_file ) );
+ $requirements = validate_plugin_requirements( $basename );
if ( ! is_wp_error( $requirements ) ) {
- return; // Core reads the headers from the plugins directory, so a plugin loaded from elsewhere reads as compatible.
+ return;
}
self::print_admin_notice( $requirements );
diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php
index 167de0b..cae81f5 100644
--- a/packages/bootstrap/src/Updater.php
+++ b/packages/bootstrap/src/Updater.php
@@ -59,6 +59,7 @@ static function ( $update, $plugin_data, $file ) use ( $plugin_file, $basename )
'package' => $release['package'],
'requires' => $release['requires'],
'requires_php' => $release['requires_php'],
+ 'tested' => $release['tested'],
);
},
10,
@@ -82,6 +83,8 @@ static function ( $result, $action, $args ) use ( $plugin_file ) {
'name' => $config['name'],
'slug' => $config['slug'],
'version' => $config['version'],
+ 'author' => $config['author'],
+ 'homepage' => $config['homepage'],
'sections' => array(), // WordPress's details screen reads the sections without a guard.
);
@@ -92,7 +95,9 @@ static function ( $result, $action, $args ) use ( $plugin_file ) {
'download_link' => $release['package'],
'requires' => $release['requires'],
'requires_php' => $release['requires_php'],
- 'sections' => array( 'changelog' => \nl2br( esc_html( $release['body'] ) ) ),
+ 'tested' => $release['tested'],
+ 'last_updated' => $release['last_updated'],
+ 'sections' => array( 'changelog' => $release['body'] ), // GitHub renders the notes as HTML, which WordPress's details screen passes through kses.
) + $information;
}
@@ -115,7 +120,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) {
*
* @param string $plugin_file The path to the plugin's entry file.
*
- * @return array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, file: string}|null
+ * @return array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, author: string, homepage: string, file: string}|null
*/
protected static function get_updater_config( string $plugin_file ): ?array {
if ( ! \function_exists( 'get_plugin_data' ) ) {
@@ -164,6 +169,8 @@ protected static function get_updater_config( string $plugin_file ): ?array {
'slug' => $slug,
'name' => $headers['Name'],
'version' => $headers['Version'],
+ 'author' => $headers['Author'],
+ 'homepage' => $headers['PluginURI'],
'file' => \basename( $plugin_file ),
);
}
@@ -176,9 +183,9 @@ protected static function get_updater_config( string $plugin_file ): ?array {
* @since 2.0.0
* @version 2.0.0
*
- * @param array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, file: string} $config The plugin's updater configuration.
+ * @param array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, author: string, homepage: string, file: string} $config The plugin's updater configuration.
*
- * @return array{version: string, tag: string, package: string, url: string, body: string, requires: string, requires_php: string}|null
+ * @return array{version: string, package: string, url: string, body: string, last_updated: string, requires: string, requires_php: string, tested: string}|null
*/
protected static function get_release( array $config ): ?array {
$transient = "{$config['slug']}_github_release_{$config['channel']}";
@@ -192,7 +199,7 @@ protected static function get_release( array $config ): ?array {
return $release;
}
- $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ) );
+ $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ), array( 'Accept' => 'application/vnd.github.html+json' ) );
$releases = null === $body ? null : \json_decode( $body, true );
$newest = array();
@@ -204,7 +211,11 @@ protected static function get_release( array $config ): ?array {
}
$candidate_version = \ltrim( $candidate['tag_name'], 'v' );
- if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/i', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release, and ranks others, such as 'pre' or 'patch', above it.
+ if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'.
+ continue;
+ }
+
+ if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release.
continue;
}
@@ -230,21 +241,23 @@ protected static function get_release( array $config ): ?array {
$headers = array(
'requires' => 'Requires at least',
'requires_php' => 'Requires PHP',
+ 'tested' => 'Tested up to',
);
$requirements = array();
foreach ( $headers as $field => $header ) {
$requirements[ $field ] = 1 === \preg_match( '/^(?:[ \t]*<\?(?:php)?)?[ \t\/*#@]*' . \preg_quote( $header, '/' ) . ':(.*)$/mi', $entry_file, $match ) ? \trim( (string) \preg_replace( '/\s*(?:\*\/|\?>).*/', '', $match[1] ) ) : '';
}
- // WordPress prints the PHP requirement unescaped on its details screen, so anything but a version makes the release unusable.
- if ( \preg_grep( '/^(?:\d+(?:\.\d+)*)?$/', $requirements ) === $requirements ) {
+ // WordPress prints the PHP requirement unescaped on its details screen, so a requirement that is not a version makes the release unusable, while the tested version passes through core's kses.
+ $checked = \array_diff_key( $requirements, array( 'tested' => true ) );
+ if ( \preg_grep( '/^(?:\d+(?:\.\d+)*)?$/', $checked ) === $checked ) {
$release = self::read_release(
array(
- 'version' => $version,
- 'tag' => $tag,
- 'package' => $package,
- 'url' => $newest['html_url'] ?? null,
- 'body' => $newest['body'] ?? '',
+ 'version' => $version,
+ 'package' => $package,
+ 'url' => $newest['html_url'] ?? null,
+ 'body' => $newest['body_html'] ?? '',
+ 'last_updated' => $newest['published_at'] ?? '',
) + $requirements
);
}
@@ -262,7 +275,7 @@ protected static function get_release( array $config ): ?array {
*
* @param mixed $value The candidate record.
*
- * @return array{version: string, tag: string, package: string, url: string, body: string, requires: string, requires_php: string}|null
+ * @return array{version: string, package: string, url: string, body: string, last_updated: string, requires: string, requires_php: string, tested: string}|null
*/
protected static function read_release( $value ): ?array {
if ( ! \is_array( $value ) ) {
@@ -270,7 +283,7 @@ protected static function read_release( $value ): ?array {
}
$release = array();
- foreach ( array( 'version', 'tag', 'package', 'url', 'body', 'requires', 'requires_php' ) as $field ) {
+ foreach ( array( 'version', 'package', 'url', 'body', 'last_updated', 'requires', 'requires_php', 'tested' ) as $field ) {
$release[ $field ] = $value[ $field ] ?? '';
if ( ! \is_string( $release[ $field ] ) ) {
return null;
@@ -286,12 +299,13 @@ protected static function read_release( $value ): ?array {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $url The URL.
+ * @param string $url The URL.
+ * @param array $headers Optional. The request's headers. Default none.
*
* @return string|null
*/
- protected static function get_response_body( string $url ): ?string {
- $response = wp_remote_get( $url );
+ protected static function get_response_body( string $url, array $headers = array() ): ?string {
+ $response = wp_remote_get( $url, array( 'headers' => $headers ) );
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
From c4e9eac166881edaf701ba63fb3651ab534057dd Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:09 +0100
Subject: [PATCH 15/33] Return false from load.php when a bootstrap class is
missing
With load.php present but one of its class files missing, its require
fataled at include, and PHP 7.4 cannot catch a failed require, so the
plugin took the site down (X ruling 195's A216).
load.php checks both class files before it requires either and returns
false when one is missing, and the entries and README excerpts return on
false as they do on a missing load.php. Limitation 5 drops its
partial-bootstrap sentence, and the contract names load.php's return
value.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 4 ++--
packages/bootstrap/README.md | 5 ++---
packages/bootstrap/load.php | 10 +++++++++-
packages/core/README.md | 5 ++---
packages/settings/README.md | 5 ++---
packages/shared/README.md | 2 +-
packages/woocommerce/README.md | 5 ++---
tests/Fixtures/consumer-a/acme-consumer-a.php | 3 +--
tests/Fixtures/consumer-b/acme-consumer-b.php | 3 +--
.../personas/bootstrap/acme-persona-bootstrap.php | 3 +--
tests/Fixtures/personas/core/acme-persona-core.php | 3 +--
.../personas/settings/acme-persona-settings.php | 3 +--
.../personas/woocommerce/acme-persona-woocommerce.php | 3 +--
13 files changed, 26 insertions(+), 28 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index b88efd8..6dd7f1c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,7 +88,7 @@ actionlint && zizmor .github/
## Contract
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
A package README may name namespaces whose symbols are internal, and a class there is internal even without its tag; core's is `Core\Scheduling\Backends\`.
@@ -115,7 +115,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` only reschedules the runner's hook each time it comes due, and the next process continues the migration. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge.
3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left.
4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared.
-5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin.
+5. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin.
6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level.
7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws.
8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked.
diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md
index 13fa9b0..9e8b75e 100644
--- a/packages/bootstrap/README.md
+++ b/packages/bootstrap/README.md
@@ -66,11 +66,10 @@ use Acme\PersonaBootstrap\Scoped\DeepWebSolutions\Framework\Bootstrap\Updater;
defined( 'ABSPATH' ) || exit;
$acme_pb_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php';
-if ( ! is_readable( $acme_pb_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it.
+if ( ! is_readable( $acme_pb_bootstrap ) || false === ( require_once $acme_pb_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it.
return;
}
-require_once $acme_pb_bootstrap;
Updater::register( __FILE__ ); // Before the floor gate, so a release that lowers the floors still reaches a site below them.
if ( ! Requirements::check_floors( __FILE__ ) ) {
return;
@@ -85,7 +84,7 @@ The updater offers the plugin's GitHub releases through WordPress's own update s
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes and the path of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy.
diff --git a/packages/bootstrap/load.php b/packages/bootstrap/load.php
index 1aae420..51e9816 100644
--- a/packages/bootstrap/load.php
+++ b/packages/bootstrap/load.php
@@ -1,6 +1,8 @@
Date: Mon, 9 Nov 2026 18:21:09 +0100
Subject: [PATCH 16/33] Read a cached entry naming a missing class as a miss
An entry whose serialized object or enum names a class that no longer
loads returned as a hit holding __PHP_Incomplete_Class, or failed to
unserialize (X ruling 119).
unwrap() reads a serialized entry naming any class that does not load as
a miss before unserializing it, and AGENTS.md drops the advice to clear
caches when cached classes change.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/core/src/Caching/CacheEntries.php | 14 ++++++++++++--
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 6dd7f1c..a5df469 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,5 +119,5 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level.
7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws.
8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked.
-9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. An object whose class no longer loads reads back as `__PHP_Incomplete_Class`, so a plugin clears its caches when cached classes change. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key.
+9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key.
10. The framework's queries name tables through `%i`, so a `wpdb` replacement whose `prepare()` predates WordPress 6.2 breaks them. A `Lock` lives in the current site's options table, and a hold is judged by the TTL its holder wrote. A holder learns of a takeover only at its next `refresh()`, so data the lock protects is written with a compare-and-swap, such as `OptionRows::replace()`, where a stale write matters.
diff --git a/packages/core/src/Caching/CacheEntries.php b/packages/core/src/Caching/CacheEntries.php
index b75eab4..e847609 100644
--- a/packages/core/src/Caching/CacheEntries.php
+++ b/packages/core/src/Caching/CacheEntries.php
@@ -129,7 +129,9 @@ public static function wrap( mixed $value, string $generation ): array {
}
/**
- * Returns the value of the entry wrapped in a list, or null when the entry is not one of the generation.
+ * Returns the value of the entry wrapped in a list, or null when the entry is not one of the generation or names a class that no longer loads.
+ *
+ * A class that no longer loads would read back as __PHP_Incomplete_Class, which PSR-16 counts as a value the cache cannot return. A string value that only looks like an object token reads as a miss.
*
* @since 2.0.0
* @version 2.0.0
@@ -146,7 +148,15 @@ public static function unwrap( mixed $entry, string $generation ): ?array {
}
$value = $entry['value'];
- return array( \is_string( $value ) ? maybe_unserialize( $value ) : $value );
+ if ( ! \is_string( $value ) ) {
+ return array( $value );
+ }
+
+ if ( is_serialized( $value ) && 0 < \preg_match_all( '/[OCE]:\d+:"([^"]+)"/', $value, $classes ) && ! \array_all( $classes[1], static fn ( string $token ): bool => \class_exists( \explode( ':', $token )[0] ) ) ) {
+ return null;
+ }
+
+ return array( maybe_unserialize( $value ) );
}
// endregion
From edf9881c6dc1065761a39405a95f9d89db523b2e Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:10 +0100
Subject: [PATCH 17/33] Fall back from an old Action Scheduler and schedule on
the own site
An Action Scheduler older than 3.5.5 never fires action_scheduler_init,
so get_state() reported Initializing on every request (X ruling 120);
and check_ready() refused every switched site, the plugin's own included
(X ruling 168's A19).
After init, an Action Scheduler that has not initialized is Unsupported,
so the scheduler falls back to WP-Cron. The scheduler keeps the id of
the site it was built on and refuses only another site.
Assisted-by: Claude Code:claude-opus-5-5
---
.../Backends/ActionSchedulerBackend.php | 3 ++-
packages/core/src/Scheduling/Scheduler.php | 15 +++++++++++++--
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php
index 890a355..d1d6f8b 100644
--- a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php
+++ b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php
@@ -63,7 +63,8 @@ public function get_state(): BackendState {
}
if ( 0 === did_action( 'action_scheduler_init' ) ) {
- return BackendState::Initializing;
+ // Every version from 3.5.5 fires the hook during init, so one that has not by then is older.
+ return 0 < did_action( 'init' ) && ! doing_action( 'init' ) ? BackendState::Unsupported : BackendState::Initializing;
}
$version = \ActionScheduler_Versions::instance()->latest_version();
diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php
index ec8de58..2818795 100644
--- a/packages/core/src/Scheduling/Scheduler.php
+++ b/packages/core/src/Scheduling/Scheduler.php
@@ -44,6 +44,16 @@ final class Scheduler implements SchedulerInterface, ComponentInterface {
*/
protected WpCronBackend $wp_cron;
+ /**
+ * The id of the site the scheduler was built on, the only site it schedules for.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var int
+ */
+ protected int $blog_id;
+
// endregion
// region MAGIC METHODS
@@ -65,6 +75,7 @@ public function __construct( string $group ) {
$this->wp_cron = new WpCronBackend( $group );
$this->backends = array( new ActionSchedulerBackend( $group ), $this->wp_cron );
+ $this->blog_id = get_current_blog_id();
}
// endregion
@@ -251,8 +262,8 @@ protected function check_ready( string $method ): true|\WP_Error {
$error = match ( true ) {
0 === did_action( 'init' ) || \array_any( $this->backends, static fn ( BackendInterface $backend ): bool => BackendState::Initializing === $backend->get_state() )
=> new \WP_Error( 'scheduler_not_ready', 'Scheduling waits until init has fired and Action Scheduler has initialized.' ),
- // Readiness is measured for the site that loaded the plugins, while a switched site may run other plugins or lack Action Scheduler's tables.
- is_multisite() && ms_is_switched()
+ // Readiness is measured for the site the scheduler was built on, while another site may run other plugins or lack Action Scheduler's tables.
+ get_current_blog_id() !== $this->blog_id
=> new \WP_Error( 'scheduler_switched', 'Scheduling is not supported while switched to another site.' ),
default => true,
};
From 7f0afffa0ac167f5902a57693439b7487a0ab965 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:10 +0100
Subject: [PATCH 18/33] End a spent migration run and keep its Run now link
Out of budget inside its own hook, the runner scheduled a one-second
continuation, which a long wp action-scheduler run kept claiming, so the
process never ended (X ruling 137); the notice for a failed batched step
lost the Run now link, its only manual recovery (X ruling 121); and
wp_cache_flush_runtime() ran without asking whether the object cache
supports it (X ruling 168's A17).
The continuation is an enqueue, which the running invocation satisfies,
so the next request or process continues the migration; the error notice
links Run now while a batched step is pending; and the flush runs only
where wp_cache_supports( 'flush_runtime' ). Limitation 2 says how a
persisting batched failure and a long queue run behave.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
.../core/src/Migration/MigrationRunner.php | 40 ++++++++++++-------
2 files changed, 27 insertions(+), 15 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index a5df469..f02bc60 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,7 +112,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
## Limitations
1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Schema-gated components switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request.
-2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` only reschedules the runner's hook each time it comes due, and the next process continues the migration. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge.
+2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge.
3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left.
4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared.
5. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin.
diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php
index 6da1fad..b383b07 100644
--- a/packages/core/src/Migration/MigrationRunner.php
+++ b/packages/core/src/Migration/MigrationRunner.php
@@ -269,7 +269,9 @@ public function run(): void {
$finished = true;
if ( $step instanceof BatchedMigrationStepInterface ) {
$finished = ! $step->process_batch();
- wp_cache_flush_runtime();
+ if ( wp_cache_supports( 'flush_runtime' ) ) {
+ wp_cache_flush_runtime();
+ }
} else {
$step();
}
@@ -317,7 +319,7 @@ public function run(): void {
// region HOOKS
/**
- * Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background.
+ * Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background, with a link that runs a pending batched step now.
*
* @since 2.0.0
* @version 2.0.0
@@ -330,27 +332,37 @@ public function render_notice(): void {
return;
}
- $name = get_plugin_data( $this->plugin_file, false )['Name'];
- $error = $this->get_error();
+ $name = get_plugin_data( $this->plugin_file, false )['Name'];
+ $error = $this->get_error();
+ $batched = $this->steps[ $revision ] instanceof BatchedMigrationStepInterface;
+ $run_now = esc_url( wp_nonce_url( admin_url( "admin-post.php?action={$this->get_hook()}_now" ), "{$this->get_hook()}_now" ) );
if ( ! \is_null( $error ) ) {
Requirements::print_admin_notice(
- \sprintf(
- /* translators: 1: The plugin's name. 2: The error message. */
- esc_html__( '%1$s could not update its data, so features that need the update stay off. Error: %2$s', 'wp-framework' ),
- esc_html( $name ),
- esc_html( $error['message'] )
- )
+ $batched
+ ? \sprintf(
+ /* translators: 1: The plugin's name. 2: The error message. 3: The URL that runs the update now. */
+ __( '%1$s could not update its data, so features that need the update stay off. Error: %2$s Run the update now.', 'wp-framework' ),
+ esc_html( $name ),
+ esc_html( $error['message'] ),
+ $run_now
+ )
+ : \sprintf(
+ /* translators: 1: The plugin's name. 2: The error message. */
+ esc_html__( '%1$s could not update its data, so features that need the update stay off. Error: %2$s', 'wp-framework' ),
+ esc_html( $name ),
+ esc_html( $error['message'] )
+ )
);
return;
}
- if ( $this->steps[ $revision ] instanceof BatchedMigrationStepInterface ) {
+ if ( $batched ) {
Requirements::print_admin_notice(
\sprintf(
/* translators: 1: The plugin's name. 2: The URL that runs the update now. */
__( '%1$s is updating its data in the background. Run the update now.', 'wp-framework' ),
esc_html( $name ),
- esc_url( wp_nonce_url( admin_url( "admin-post.php?action={$this->get_hook()}_now" ), "{$this->get_hook()}_now" ) )
+ $run_now
),
array( 'type' => 'info' )
);
@@ -445,7 +457,7 @@ protected function unlock(): void {
/**
* Schedules the runner's next invocation, and logs a scheduler failure, which a later request retries.
*
- * Inside its own hook the running invocation would satisfy enqueue(), so the continuation is a single invocation due a second later, and the queue run dispatching it ends instead of claiming it again at once.
+ * Inside its own hook a running Action Scheduler invocation satisfies the enqueue, so the next request or process continues, and a queue run that keeps claiming, as wp action-scheduler run does, finds nothing of the runner's due and ends.
*
* @since 2.0.0
* @version 2.0.0
@@ -453,7 +465,7 @@ protected function unlock(): void {
* @return void
*/
protected function schedule_continuation(): void {
- $scheduled = doing_action( $this->get_hook() ) ? $this->scheduler->schedule_single( $this->scheduled_hook, \time() + 1 ) : $this->scheduler->enqueue( $this->scheduled_hook );
+ $scheduled = $this->scheduler->enqueue( $this->scheduled_hook );
if ( true !== $scheduled ) {
$this->logger->warning(
'Could not schedule the next data migration run: {error}',
From cce21a91476b206a680375f352c35a631065b951 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:11 +0100
Subject: [PATCH 19/33] Escape line breaks in interpolated log values
A line break in an interpolated value could forge a record in PHP's
error log (X ruling 168's A20).
interpolate() writes \r and \n in a value as their escapes; the
exception block keeps its lines.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/src/Logging/AbstractLogger.php | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php
index 3567c59..415e2c2 100644
--- a/packages/core/src/Logging/AbstractLogger.php
+++ b/packages/core/src/Logging/AbstractLogger.php
@@ -72,9 +72,9 @@ protected static function get_default_threshold(): int {
}
/**
- * Replaces each {key} placeholder in the message with its scalar, null or Stringable context value.
+ * Replaces each {key} placeholder in the message with its scalar, null or Stringable context value, with its line breaks escaped.
*
- * Only the values the message uses are cast, and a placeholder whose value throws while casting stays as it is, because PSR-3 forbids a context value from making logging fail.
+ * Only the values the message uses are cast, and a placeholder whose value throws while casting stays as it is, because PSR-3 forbids a context value from making logging fail. Line breaks are escaped so that a value cannot start a forged record.
*
* @since 2.0.0
* @version 2.0.0
@@ -93,7 +93,7 @@ protected static function interpolate( string $message, array $context ): string
}
try {
- $replacements[ $placeholder ] = (string) $value;
+ $replacements[ $placeholder ] = \str_replace( array( "\r", "\n" ), array( '\r', '\n' ), (string) $value );
} catch ( \Throwable ) {
continue;
}
From 496f94abe7c307cdc317e3de38a7a65849df28bd Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:11 +0100
Subject: [PATCH 20/33] Page uninstall reads, clear term relationships and
report failures
The engine read every id of a key at once, so a key on a few hundred
thousand rows never cleared from the web; a post deleted while its type
is unregistered kept its term relationships and term counts; and
consumer-a's uninstall.php let the engine's exception reach WordPress's
critical-error page, so its reason, such as the large-network refusal
naming wp plugin uninstall, reached only the error log (X ruling 169).
Id reads go in pages of 1,000, still deleting only matched ids. Once
wp_delete_post() has deleted a post,
wp_delete_object_term_relationships() removes its relationships to every
registered taxonomy of a post type, so a post whose delete another
plugin refuses holds on to its terms, and a link or a user under the
same id keeps its own. consumer-a's uninstall.php catches the engine's
RuntimeException, rethrows it under WP-CLI and otherwise ends in
wp_die() with the escaped message and a 500, before WordPress deletes
the files. Limitation 3 says that exact names match under the database
collation and that the large-network bound counts the current network's
sites.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/core/src/Uninstall/Uninstaller.php | 56 ++++++++++++++-------
tests/Fixtures/consumer-a/uninstall.php | 31 ++++++++----
3 files changed, 60 insertions(+), 29 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f02bc60..d995a67 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,7 +113,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Schema-gated components switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request.
2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge.
-3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left.
+3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left.
4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared.
5. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin.
6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level.
diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php
index 01f3b3d..e1dd568 100644
--- a/packages/core/src/Uninstall/Uninstaller.php
+++ b/packages/core/src/Uninstall/Uninstaller.php
@@ -18,6 +18,16 @@
final class Uninstaller {
// region FIELDS AND CONSTANTS
+ /**
+ * The number of row ids one read takes, so that a key on very many rows clears with flat memory.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @var int
+ */
+ protected const int ID_PAGE_SIZE = 1000;
+
/**
* The failures of the run so far.
*
@@ -230,9 +240,14 @@ protected function uninstall_site( bool $network_footprint ): void {
}
foreach ( self::get_strings( $this->footprint['post_types'] ?? null ) as $post_type ) {
+ // A relationship carries no object type, so only post taxonomies are read, which leaves a link's or a user's terms under the same id alone.
+ $taxonomies = get_object_taxonomies( array( $post_type, ...get_post_types() ) );
foreach ( $this->get_matching_ids( $wpdb->posts, 'ID', 'post_type', $post_type ) as $post_id ) {
$wpdb->flush();
- $this->record_failure( "post $post_id of type $post_type", self::confirm_deleted( wp_delete_post( $post_id, true ) ) );
+ // WordPress removes only the terms of the taxonomies registered for the post's type, which its inactive plugin registered, so the rest go once the post is gone.
+ if ( $this->record_failure( "post $post_id of type $post_type", self::confirm_deleted( wp_delete_post( $post_id, true ) ) ) ) {
+ wp_delete_object_term_relationships( $post_id, $taxonomies );
+ }
}
}
@@ -472,7 +487,9 @@ protected function get_names_by_prefix( string $table, string $column, string $p
}
/**
- * Returns the ids of the rows whose column holds exactly the value, and records a failed read.
+ * Returns the ids of the rows whose column holds exactly the value, read a page at a time after the last id read, and records a failed read.
+ *
+ * The caller deletes each page's rows before the next page is read, so memory stays flat however many rows match.
*
* @since 2.0.0
* @version 2.0.0
@@ -484,26 +501,31 @@ protected function get_names_by_prefix( string $table, string $column, string $p
* @param string $column The column to match.
* @param string $value The value, matched byte for byte.
*
- * @return list
+ * @return \Generator
*/
- protected function get_matching_ids( string $table, string $id_column, string $column, string $value ): array {
+ protected function get_matching_ids( string $table, string $id_column, string $column, string $value ): \Generator {
global $wpdb;
- $wpdb->flush();
- $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS id, %i AS value FROM %i WHERE %i = %s', $id_column, $column, $table, $column, $value ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads every row, trashed posts and auto-drafts included.
- if ( ! $this->record_failure( "the $value rows of $table", $rows ) ) {
- return array();
- }
-
- $ids = array();
- foreach ( (array) $rows as $row ) {
- // The column's collation also matches values that differ in letter case or accents.
- if ( \is_array( $row ) && ( $row['value'] ?? null ) === $value && \is_numeric( $row['id'] ?? null ) ) {
- $ids[] = (int) $row['id'];
+ $last = 0;
+ do {
+ $wpdb->flush();
+ $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS id, %i AS value FROM %i WHERE %i = %s AND %i > %d ORDER BY %i LIMIT %d', $id_column, $column, $table, $column, $value, $id_column, $last, $id_column, self::ID_PAGE_SIZE ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads every row, trashed posts and auto-drafts included.
+ if ( ! $this->record_failure( "the $value rows of $table", $rows ) ) {
+ return;
}
- }
- return $ids;
+ $rows = (array) $rows;
+ $full = self::ID_PAGE_SIZE === \count( $rows );
+ foreach ( $rows as $row ) {
+ if ( \is_array( $row ) && \is_numeric( $row['id'] ?? null ) ) {
+ $last = (int) $row['id'];
+ // The column's collation also matches values that differ in letter case or accents.
+ if ( ( $row['value'] ?? null ) === $value ) {
+ yield $last;
+ }
+ }
+ }
+ } while ( $full );
}
/**
diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php
index b482f57..6721108 100644
--- a/tests/Fixtures/consumer-a/uninstall.php
+++ b/tests/Fixtures/consumer-a/uninstall.php
@@ -41,14 +41,23 @@
return;
}
-Uninstaller::run(
- $acme_ca_file,
- $acme_ca_footprint,
- array(
- 'consent' => array(
- 'option' => Plugin::PREFIX . '_settings',
- 'key' => 'delete_data',
- ),
- 'meta_types' => $acme_ca_meta_types,
- )
-);
+try {
+ Uninstaller::run(
+ $acme_ca_file,
+ $acme_ca_footprint,
+ array(
+ 'consent' => array(
+ 'option' => Plugin::PREFIX . '_settings',
+ 'key' => 'delete_data',
+ ),
+ 'meta_types' => $acme_ca_meta_types,
+ )
+ );
+} catch ( RuntimeException $exception ) {
+ if ( defined( 'WP_CLI' ) && true === WP_CLI ) {
+ throw $exception;
+ }
+
+ // wp_die() ends the request before WordPress deletes the files, so the next uninstall retries, and shows the reason where a throw shows only the critical-error page.
+ wp_die( esc_html( $exception->getMessage() ), '', array( 'response' => 500 ) );
+}
From 847e7622e38ef9d8c8d11f056aa465fa6acb45ab Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:12 +0100
Subject: [PATCH 21/33] Keep only scalars, null and arrays in the framework
stores
MetadataStore and OrderMetaStore accepted any value, but the metadata
API rewrites an object's public properties as it unslashes them, and an
object's scoped class name would not read back (X rulings 135, 332 and
336).
Core's StoredData holds the rule: is_storable_data() for OptionRows, and
check_values(), which both object stores call before they store any
value and which throws otherwise. The store interface, the validate
callables and sanitize_fields() type their values that way, and
WooCommerce's pass-through keeps a value only when it is storable.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/core/src/Storage/OptionRows.php | 16 +----
packages/core/src/Storage/StoredData.php | 69 +++++++++++++++++++
.../src/EditScreen/ObjectStoreInterface.php | 10 ++-
.../src/EditScreen/Stores/MetadataStore.php | 5 ++
packages/settings/src/Forms.php | 6 +-
.../src/EditScreen/Stores/OrderMetaStore.php | 5 ++
.../src/ProductData/ProductDataTab.php | 2 +-
packages/woocommerce/src/Setting/Fields.php | 5 +-
9 files changed, 95 insertions(+), 25 deletions(-)
create mode 100644 packages/core/src/Storage/StoredData.php
diff --git a/AGENTS.md b/AGENTS.md
index d995a67..97337c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,7 @@ wordpress-framework/
│ ├── Migration/{MigrationRunner, BatchedMigrationStepInterface}
│ ├── Permission/CapabilityGrants
│ ├── Scheduling/{SchedulerInterface, Scheduler, ScheduledHook, Recurrence, Backends/ (@internal)}
-│ ├── Storage/{OptionRows, OptionRow, Lock, LockClaim}
+│ ├── Storage/{OptionRows, OptionRow, Lock, LockClaim, StoredData}
│ └── Uninstall/Uninstaller
├── packages/settings/ ahegyes/wp-framework-settings `require.php` · core ^2.0
│ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_value, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal))
diff --git a/packages/core/src/Storage/OptionRows.php b/packages/core/src/Storage/OptionRows.php
index d24d79b..38a3a67 100644
--- a/packages/core/src/Storage/OptionRows.php
+++ b/packages/core/src/Storage/OptionRows.php
@@ -203,7 +203,7 @@ public static function check_name( string $name, int $max_length = self::MAX_NAM
* @return string
*/
protected static function serialize( string $name, mixed $value ): string {
- if ( ! self::is_storable( $value ) ) { // A stored object's class name changes with each plugin's scoping prefix, so it would not read back.
+ if ( ! StoredData::is_storable_data( $value ) ) {
throw new InvalidArgumentException( "The value of option '$name' holds something other than scalars, null and arrays." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
@@ -213,20 +213,6 @@ protected static function serialize( string $name, mixed $value ): string {
return \is_scalar( $raw ) ? (string) $raw : '';
}
- /**
- * Returns whether the value holds only scalars, null and arrays of them.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @param mixed $value The value.
- *
- * @return bool
- */
- protected static function is_storable( mixed $value ): bool {
- return \is_null( $value ) || \is_scalar( $value ) || ( \is_array( $value ) && \array_all( $value, self::is_storable( ... ) ) );
- }
-
/**
* Returns a row from its stored value.
*
diff --git a/packages/core/src/Storage/StoredData.php b/packages/core/src/Storage/StoredData.php
new file mode 100644
index 0000000..f4ac790
--- /dev/null
+++ b/packages/core/src/Storage/StoredData.php
@@ -0,0 +1,69 @@
+|null $value
+ *
+ * @param mixed $value The value.
+ *
+ * @return bool
+ */
+ public static function is_storable_data( mixed $value ): bool {
+ return \is_null( $value ) || \is_scalar( $value ) || ( \is_array( $value ) && \array_all( $value, self::is_storable_data( ... ) ) );
+ }
+
+ /**
+ * Throws unless every value holds only scalars, null and arrays of them, naming the first key whose value does not.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param array $values The values, keyed by their keys.
+ *
+ * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays.
+ *
+ * @return void
+ */
+ public static function check_values( array $values ): void {
+ foreach ( $values as $key => $value ) {
+ if ( ! self::is_storable_data( $value ) ) {
+ throw new InvalidArgumentException( "The value of key '$key' holds something other than scalars, null and arrays." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ }
+ }
+ }
+
+ // endregion
+}
diff --git a/packages/settings/src/EditScreen/ObjectStoreInterface.php b/packages/settings/src/EditScreen/ObjectStoreInterface.php
index 0d70082..6c58ab5 100644
--- a/packages/settings/src/EditScreen/ObjectStoreInterface.php
+++ b/packages/settings/src/EditScreen/ObjectStoreInterface.php
@@ -2,6 +2,8 @@
namespace DeepWebSolutions\Framework\Settings\EditScreen;
+use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
+
\defined( 'ABSPATH' ) || exit;
/**
@@ -25,13 +27,15 @@ interface ObjectStoreInterface {
public function get_values( int $object_id, array $keys ): array;
/**
- * Stores the values under their keys for the object, each as given.
+ * Stores the values under their keys for the object, each as given and each a scalar, null or an array of them.
*
* @since 2.0.0
* @version 2.0.0
*
- * @param int $object_id The object's id.
- * @param array $values The values, keyed by their keys.
+ * @param int $object_id The object's id.
+ * @param array|null> $values The values, keyed by their keys.
+ *
+ * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays, before any value is stored.
*
* @return void
*/
diff --git a/packages/settings/src/EditScreen/Stores/MetadataStore.php b/packages/settings/src/EditScreen/Stores/MetadataStore.php
index ce05533..18a4a10 100644
--- a/packages/settings/src/EditScreen/Stores/MetadataStore.php
+++ b/packages/settings/src/EditScreen/Stores/MetadataStore.php
@@ -2,7 +2,9 @@
namespace DeepWebSolutions\Framework\Settings\EditScreen\Stores;
+use DeepWebSolutions\Framework\Core\Storage\StoredData;
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface;
+use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
\defined( 'ABSPATH' ) || exit;
@@ -55,9 +57,12 @@ public function get_values( int $object_id, array $keys ): array {
*
* @since 2.0.0
* @version 2.0.0
+ *
+ * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays, before any value is stored.
*/
#[\Override]
public function update_values( int $object_id, array $values ): void {
+ StoredData::check_values( $values ); // The metadata API rewrites an object's public properties as it unslashes them.
foreach ( $values as $key => $value ) {
update_metadata( $this->meta_type, $object_id, $key, wp_slash( $value ) ); // The metadata API unslashes what it is given, which would strip a value's own backslashes.
}
diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php
index 566f279..81f86c1 100644
--- a/packages/settings/src/Forms.php
+++ b/packages/settings/src/Forms.php
@@ -14,7 +14,7 @@
* @since 2.0.0
* @version 2.0.0
*
- * @phpstan-type Field array{type: string, id?: string, title?: string, desc?: string, desc_tip?: bool|string, default?: mixed, options?: array, placeholder?: string, class?: string, css?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): mixed, render?: callable(array, mixed, string, string): string}
+ * @phpstan-type Field array{type: string, id?: string, title?: string, desc?: string, desc_tip?: bool|string, default?: mixed, options?: array, placeholder?: string, class?: string, css?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): (scalar|array|\WP_Error|null), render?: callable(array, mixed, string, string): string}
* @phpstan-type FieldRow array{field: Field, value: mixed, name: string, id: string, label_for?: string}
*/
final class Forms {
@@ -107,7 +107,7 @@ public static function render_control( array $field, mixed $value, string $name,
* @param Field $field The field.
* @param mixed $raw The unslashed submission, in which a cleared multi-value field is an empty string.
*
- * @return mixed|\WP_Error
+ * @return scalar|array|\WP_Error|null
*/
#[\NoDiscard]
public static function sanitize_value( array $field, mixed $raw ): mixed {
@@ -146,7 +146,7 @@ public static function sanitize_value( array $field, mixed $raw ): mixed {
* @param list $fields The fields.
* @param array $submitted The unslashed submission, keyed by field id.
*
- * @return array{values: array, errors: array}
+ * @return array{values: array|null>, errors: array}
*/
#[\NoDiscard]
public static function sanitize_fields( array $fields, array $submitted ): array {
diff --git a/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php b/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php
index fdab0a4..75c35b2 100644
--- a/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php
+++ b/packages/woocommerce/src/EditScreen/Stores/OrderMetaStore.php
@@ -2,7 +2,9 @@
namespace DeepWebSolutions\Framework\WooCommerce\EditScreen\Stores;
+use DeepWebSolutions\Framework\Core\Storage\StoredData;
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface;
+use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
\defined( 'ABSPATH' ) || exit;
@@ -43,9 +45,12 @@ public function get_values( int $object_id, array $keys ): array {
*
* @since 2.0.0
* @version 2.0.0
+ *
+ * @throws InvalidArgumentException Thrown when a value holds anything but scalars, null and arrays, before any value is stored.
*/
#[\Override]
public function update_values( int $object_id, array $values ): void {
+ StoredData::check_values( $values );
$order = wc_get_order( $object_id );
if ( ! $order instanceof \WC_Abstract_Order ) {
return;
diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php
index b3090aa..1e58d20 100644
--- a/packages/woocommerce/src/ProductData/ProductDataTab.php
+++ b/packages/woocommerce/src/ProductData/ProductDataTab.php
@@ -15,7 +15,7 @@
* @since 2.0.0
* @version 2.0.0
*
- * @phpstan-type ProductField array{type: string, id?: string, label?: string, description?: string, desc_tip?: bool, name?: string, default?: mixed, options?: array, placeholder?: string, class?: string, style?: string, wrapper_class?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): mixed, render?: callable(array, mixed, string, string): string}
+ * @phpstan-type ProductField array{type: string, id?: string, label?: string, description?: string, desc_tip?: bool, name?: string, default?: mixed, options?: array, placeholder?: string, class?: string, style?: string, wrapper_class?: string, custom_attributes?: array, disabled?: bool|list, validate?: callable(mixed): (scalar|array|\WP_Error|null), render?: callable(array, mixed, string, string): string}
* @phpstan-type Group array{class?: list, fields: list}
* @phpstan-type Tab array{label: string, priority?: int, class?: list, groups: list}
*/
diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php
index 653123f..5b08606 100644
--- a/packages/woocommerce/src/Setting/Fields.php
+++ b/packages/woocommerce/src/Setting/Fields.php
@@ -2,6 +2,7 @@
namespace DeepWebSolutions\Framework\WooCommerce\Setting;
+use DeepWebSolutions\Framework\Core\Storage\StoredData;
use DeepWebSolutions\Framework\Settings\Forms;
\defined( 'ABSPATH' ) || exit;
@@ -101,7 +102,7 @@ public function claim( array $settings, string $section_id ): array {
/**
* Returns the value to store for an owned field's submission, or null, under which WooCommerce keeps the stored value, and any other option's value unchanged.
*
- * A disabled field and a field the form did not post are never saved. The submission goes through Forms::sanitize_fields(), where a field without validate, a render callable or the checkboxes type keeps WooCommerce's value, and a rejection becomes one of WooCommerce's settings errors.
+ * A disabled field and a field the form did not post are never saved. The submission goes through Forms::sanitize_fields(), where a field without validate, a render callable or the checkboxes type keeps WooCommerce's value when it holds only scalars, null and arrays, and a rejection becomes one of WooCommerce's settings errors.
*
* @since 2.0.0
* @version 2.0.0
@@ -125,7 +126,7 @@ public function sanitize_option( mixed $value, mixed $option, mixed $raw ): mixe
default => null,
};
if ( ! isset( $field['validate'] ) && ! self::is_drawn_by_forms( $field ) ) {
- $field['validate'] = static fn (): mixed => $value;
+ $field['validate'] = static fn (): mixed => StoredData::is_storable_data( $value ) ? $value : null;
}
$id = $field['id'] ?? '';
From 71cb76ff2b3e421d1d0825055da99530af0b3dd8 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:12 +0100
Subject: [PATCH 22/33] Compare nested value objects only within one class
Nested equality called a value object's equals() with an object of
another class, which that method's contract does not admit; and
ExceptionInterface claimed every framework exception, while PSR-3 makes
the logger throw its own (X ruling 170).
Nested equality checks the classes before calling equals(), and
ExceptionInterface's docblock says it marks the exceptions the framework
defines.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/shared/src/Exception/ExceptionInterface.php | 2 +-
packages/shared/src/ValueObject/AbstractValueObject.php | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/Exception/ExceptionInterface.php b/packages/shared/src/Exception/ExceptionInterface.php
index 93b5470..82b882d 100644
--- a/packages/shared/src/Exception/ExceptionInterface.php
+++ b/packages/shared/src/Exception/ExceptionInterface.php
@@ -3,7 +3,7 @@
namespace DeepWebSolutions\Framework\Shared\Exception;
/**
- * Marks every exception the framework throws, so a caller can tell the framework's failures apart.
+ * Marks the exceptions the framework defines, so a caller can tell the framework's failures apart.
*
* @since 2.0.0
* @version 2.0.0
diff --git a/packages/shared/src/ValueObject/AbstractValueObject.php b/packages/shared/src/ValueObject/AbstractValueObject.php
index c50b699..44f8fce 100644
--- a/packages/shared/src/ValueObject/AbstractValueObject.php
+++ b/packages/shared/src/ValueObject/AbstractValueObject.php
@@ -27,7 +27,7 @@ final public function equals( ValueObjectInterface $other ): bool {
// region HELPERS
/**
- * Returns whether two property values are equal, comparing dates by instant.
+ * Returns whether two property values are equal, comparing dates by instant and value objects of one class through their own equals().
*
* @since 2.0.0
* @version 2.0.0
@@ -39,7 +39,7 @@ final public function equals( ValueObjectInterface $other ): bool {
*/
private static function are_equal( mixed $value, mixed $other ): bool {
return match ( true ) {
- $value instanceof ValueObjectInterface => $other instanceof ValueObjectInterface && $value->equals( $other ),
+ $value instanceof ValueObjectInterface => $other instanceof ValueObjectInterface && $value::class === $other::class && $value->equals( $other ),
$value instanceof \DateTimeInterface => $other instanceof \DateTimeInterface && $value->format( 'U.u' ) === $other->format( 'U.u' ),
\is_array( $value ) => \is_array( $other ) && \array_keys( $value ) === \array_keys( $other ) && \array_all( $value, static fn ( mixed $item, int|string $key ): bool => self::are_equal( $item, $other[ $key ] ) ),
default => $value === $other,
From ed83b4439fa4be8394117bc7f3daf7200ca9be33 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:12 +0100
Subject: [PATCH 23/33] Keep a typed '<', check on checkboxes and say the rest
was saved
The text sanitizers encode a lone '<', which every output escapes again,
so the user saw '<' after the save (X ruling 171's A33, option a); a
checkbox rendered checked only for 'yes' while its sanitizer reads true
and '1' as on (A35); and the rejection sentence left out that the other
changes were saved (X ruling 334).
The text arms turn the sanitizer's '<' back into '<', a checkbox
renders checked through sanitize_checkbox(), and both rejection
sentences say that the other changes were saved.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/settings/src/Forms.php | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php
index 81f86c1..85019d0 100644
--- a/packages/settings/src/Forms.php
+++ b/packages/settings/src/Forms.php
@@ -123,8 +123,9 @@ public static function sanitize_value( array $field, mixed $raw ): mixed {
}
return match ( $field['type'] ) {
- 'text' => self::is_text( $raw ) ? sanitize_text_field( (string) $raw ) : self::reject( 'invalid_value' ),
- 'textarea' => self::is_text( $raw ) ? sanitize_textarea_field( (string) $raw ) : self::reject( 'invalid_value' ),
+ // The sanitizers encode a lone '<' that every output escapes again, so the stored text turns it back.
+ 'text' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_text_field( (string) $raw ) ) : self::reject( 'invalid_value' ),
+ 'textarea' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_textarea_field( (string) $raw ) ) : self::reject( 'invalid_value' ),
'email' => self::sanitize_email( $raw ),
'url' => self::sanitize_url( $raw ),
'number' => self::sanitize_number( $raw ),
@@ -230,11 +231,11 @@ public static function get_rejection_message( string $title, \WP_Error $error ):
$message = $error->get_error_message();
if ( '' === $message ) {
/* translators: %s: The field's title. */
- return \sprintf( __( '%s was not saved because its value is not valid, so it keeps its previous value.', 'wp-framework' ), $title );
+ return \sprintf( __( '%s was not saved because its value is not valid, and keeps its previous value. The other changes were saved.', 'wp-framework' ), $title );
}
/* translators: 1: The field's title. 2: What to enter instead. */
- return \sprintf( __( '%1$s was not saved, so it keeps its previous value. %2$s', 'wp-framework' ), $title, $message );
+ return \sprintf( __( '%1$s was not saved and keeps its previous value. The other changes were saved. %2$s', 'wp-framework' ), $title, $message );
}
/**
@@ -420,7 +421,7 @@ protected static function render_checkbox( array $field, mixed $value, string $n
'value' => 'yes',
'class' => $field['class'] ?? null,
'style' => $field['css'] ?? null,
- 'checked' => 'yes' === $value,
+ 'checked' => 'yes' === self::sanitize_checkbox( $value ),
'disabled' => $disabled,
'aria-describedby' => self::get_described_by( $help ),
);
From 374977051b76322db5425764a0847a4869f0e5f9 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:13 +0100
Subject: [PATCH 24/33] Draw product radios through Forms and refuse ids
WooCommerce rewrites
woocommerce_wp_radio() ignores disabled, so a disabled radio dropped the
user's change and a disabled stored choice was rejected on every save;
WooCommerce passes tab and section ids through sanitize_title(), so a
rewritten id left the fields unclaimed and their validate bypassed; and
WooCommerceLogger's docblock did not say which threshold its fallback
follows (X ruling 171's A36 to A38).
Radios render in the panel's fieldset row through
Forms::render_control(), SettingsSection and SettingsTab throw when
sanitize_title() would rewrite an id or section key, and the logger's
docblock says the fallback follows WooCommerce's logging settings.
Assisted-by: Claude Code:claude-opus-5-5
---
.../src/Logging/WooCommerceLogger.php | 2 +-
.../src/ProductData/ProductDataTab.php | 4 +---
packages/woocommerce/src/Setting/Fields.php | 19 +++++++++++++++++++
.../src/Setting/SettingsSection.php | 3 ++-
.../woocommerce/src/Setting/SettingsTab.php | 5 ++++-
5 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php
index 8466261..6248880 100644
--- a/packages/woocommerce/src/Logging/WooCommerceLogger.php
+++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php
@@ -40,7 +40,7 @@ final class WooCommerceLogger extends AbstractLogger {
*
* @param string $source The source that files every record under the plugin in WooCommerce's log.
* @param string|null $threshold Optional. The lowest level to log, which replaces the default of warning, or of every level under WP_DEBUG. Default is null.
- * @param \WC_Logger_Interface|null $logger Optional. The WooCommerce logger to write to. Default is null, which resolves WooCommerce's logger on the first record, so construction never needs WooCommerce.
+ * @param \WC_Logger_Interface|null $logger Optional. The WooCommerce logger to write to. Default is null, which resolves WooCommerce's logger on the first record, so construction never needs WooCommerce. The error log fallback follows WooCommerce's logging settings, whichever logger is given.
*
* @throws PsrInvalidArgumentException Thrown when the threshold is not a PSR-3 level.
*/
diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php
index 1e58d20..4574ae4 100644
--- a/packages/woocommerce/src/ProductData/ProductDataTab.php
+++ b/packages/woocommerce/src/ProductData/ProductDataTab.php
@@ -222,7 +222,7 @@ protected function get_groups( array $tab, \WC_Product $product ): array {
}
/**
- * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable or a checkboxes group.
+ * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable, radios or checkboxes.
*
* @since 2.0.0
* @version 2.0.0
@@ -287,8 +287,6 @@ protected static function render_field( array $field, \WC_Product $product ): vo
woocommerce_wp_select( $field );
break;
case 'radio':
- woocommerce_wp_radio( $field );
- break;
case 'checkboxes':
// The row's legend names the group, so Forms draws the choices without one of its own and with the description as their help.
$group = array(
diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php
index 5b08606..c1d8314 100644
--- a/packages/woocommerce/src/Setting/Fields.php
+++ b/packages/woocommerce/src/Setting/Fields.php
@@ -4,6 +4,7 @@
use DeepWebSolutions\Framework\Core\Storage\StoredData;
use DeepWebSolutions\Framework\Settings\Forms;
+use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException;
\defined( 'ABSPATH' ) || exit;
@@ -65,6 +66,24 @@ public function register_hooks( string $tab_id ): void {
add_filter( 'woocommerce_admin_settings_sanitize_option', array( $this, 'sanitize_option' ), 10, 3 );
}
+ /**
+ * Throws unless WooCommerce keeps the tab or section id as it is, because it passes the ids through sanitize_title() and the fields are claimed by the ids it passes.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param string $id The tab or section id.
+ *
+ * @throws InvalidArgumentException Thrown when sanitize_title() changes the id.
+ *
+ * @return void
+ */
+ public static function check_id( string $id ): void {
+ if ( sanitize_title( $id ) !== $id ) {
+ throw new InvalidArgumentException( "Id '$id' is not one that WooCommerce's sanitize_title() keeps as it is." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ }
+ }
+
// endregion
// region HOOKS
diff --git a/packages/woocommerce/src/Setting/SettingsSection.php b/packages/woocommerce/src/Setting/SettingsSection.php
index f9c2df6..f8c3bc7 100644
--- a/packages/woocommerce/src/Setting/SettingsSection.php
+++ b/packages/woocommerce/src/Setting/SettingsSection.php
@@ -56,7 +56,7 @@ final class SettingsSection extends WooCommerceSettingsSection {
* @param Section $section The id of the tab the section belongs to, and the section's label.
* @param SectionFieldsDefinitionInterface $definition The definition of the section's fields, which the section asks for once, when WooCommerce first asks for them.
*
- * @throws InvalidArgumentException Thrown when the id is not a valid key.
+ * @throws InvalidArgumentException Thrown when the id is not a valid key or one WooCommerce keeps as it is.
*/
public function __construct(
protected string $id,
@@ -64,6 +64,7 @@ public function __construct(
protected SectionFieldsDefinitionInterface $definition
) {
Forms::check_helper_id( $id );
+ Fields::check_id( $id );
$this->owner = new Fields( array( $id => true ) );
}
diff --git a/packages/woocommerce/src/Setting/SettingsTab.php b/packages/woocommerce/src/Setting/SettingsTab.php
index 46c71a2..2fa5f80 100644
--- a/packages/woocommerce/src/Setting/SettingsTab.php
+++ b/packages/woocommerce/src/Setting/SettingsTab.php
@@ -65,7 +65,7 @@ final class SettingsTab extends \WC_Settings_Page {
* @param Tab $tab The tab's label and the labels of its sections by id.
* @param SectionFieldsDefinitionInterface $definition The definition of the sections' fields, which the tab asks for once per section, when WooCommerce first asks for them.
*
- * @throws InvalidArgumentException Thrown when the id is not a valid key.
+ * @throws InvalidArgumentException Thrown when the id is not a valid key, or it or a section key is not one WooCommerce keeps as it is.
*/
public function __construct(
string $id,
@@ -73,6 +73,9 @@ public function __construct(
protected SectionFieldsDefinitionInterface $definition
) {
Forms::check_helper_id( $id );
+ foreach ( array( $id, ...\array_map( 'strval', \array_keys( $tab['sections'] ) ) ) as $checked ) {
+ Fields::check_id( $checked );
+ }
$this->id = $id;
$this->label = $tab['label'];
From 58056badb5ee18627037b0a8da37a5de11679480 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:13 +0100
Subject: [PATCH 25/33] Drop PluginKernel::$resolved and name properties in the
contract
PluginKernel::$resolved was public state that only tests read, and the
published contract left public properties out (X ruling 155).
$resolved goes, the consumer rows check the skipped classes instead, and
the contract in AGENTS.md and every README names properties, a
protected(set) one for reading by its name and declared type.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/bootstrap/README.md | 2 +-
packages/core/README.md | 2 +-
packages/core/src/PluginKernel.php | 12 ------------
packages/settings/README.md | 2 +-
packages/shared/README.md | 2 +-
packages/woocommerce/README.md | 2 +-
tests/Integration/ConsumerA/ConsumerABootTest.php | 3 +--
tests/Integration/ConsumerB/ConsumerBBootTest.php | 1 -
.../Consumers/ConsumersCoexistenceTest.php | 10 +++++-----
tests/Integration/Personas/CorePersonaTest.php | 1 -
tests/Integration/Support/consumer-request.php | 1 -
tests/Unit/Core/PluginKernelTest.php | 3 ---
13 files changed, 12 insertions(+), 31 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 97337c6..f204452 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,7 +88,7 @@ actionlint && zizmor .github/
## Contract
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
A package README may name namespaces whose symbols are internal, and a class there is internal even without its tag; core's is `Core\Scheduling\Backends\`.
diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md
index 9e8b75e..ca7379d 100644
--- a/packages/bootstrap/README.md
+++ b/packages/bootstrap/README.md
@@ -84,7 +84,7 @@ The updater offers the plugin's GitHub releases through WordPress's own update s
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy.
diff --git a/packages/core/README.md b/packages/core/README.md
index 01b213d..6f55dae 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -191,7 +191,7 @@ final class Greeting implements ComponentInterface {
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
The `DeepWebSolutions\Framework\Core\Scheduling\Backends\` namespace is internal, so a class there is internal even without its tag.
diff --git a/packages/core/src/PluginKernel.php b/packages/core/src/PluginKernel.php
index 671be3d..ced7a9d 100644
--- a/packages/core/src/PluginKernel.php
+++ b/packages/core/src/PluginKernel.php
@@ -18,16 +18,6 @@
final class PluginKernel {
// region FIELDS AND CONSTANTS
- /**
- * The classes constructed, in walk order, whether registration reached them.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @var list>
- */
- protected(set) array $resolved = array();
-
/**
* The classes whose gate returned false.
*
@@ -126,8 +116,6 @@ protected function resolve( array $classes ): array {
throw new LogicException( "The container entry '$class' has type '" . \get_debug_type( $component ) . "', not that class." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
- $this->resolved[] = $class;
-
$components[] = $component;
if ( \is_a( $class, CompositeComponentInterface::class, true ) ) {
\array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // The declared class names the children, so no runtime state can add an unvisited node.
diff --git a/packages/settings/README.md b/packages/settings/README.md
index 0bc814a..4454aa0 100644
--- a/packages/settings/README.md
+++ b/packages/settings/README.md
@@ -149,7 +149,7 @@ final class Settings implements OptionsPageDefinitionInterface {
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy.
diff --git a/packages/shared/README.md b/packages/shared/README.md
index 91c1d1d..4b797fc 100644
--- a/packages/shared/README.md
+++ b/packages/shared/README.md
@@ -117,7 +117,7 @@ final readonly class Point extends AbstractValueObject {
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy.
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index 1e420dd..5610851 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -181,7 +181,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
-The 2.x contract is every public class, interface, enum, method and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy.
diff --git a/tests/Integration/ConsumerA/ConsumerABootTest.php b/tests/Integration/ConsumerA/ConsumerABootTest.php
index b2bf6fc..fcc3d4a 100644
--- a/tests/Integration/ConsumerA/ConsumerABootTest.php
+++ b/tests/Integration/ConsumerA/ConsumerABootTest.php
@@ -15,7 +15,7 @@ final class ConsumerABootTest extends TestCase {
protected const string SCOPED = 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\';
- public function test_the_plugin_boots_with_its_scheduler_and_admin_notices_as_kernel_roots(): void {
+ public function test_the_plugin_boots_and_its_components_register_their_hooks(): void {
$plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' );
$request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) );
@@ -23,7 +23,6 @@ public function test_the_plugin_boots_with_its_scheduler_and_admin_notices_as_ke
self::assertStringContainsString( 'id="wpfooter"', $request['body'] );
self::assertSame( 'booted', $request['records']['consumer_a']['status'] );
self::assertSame( 1, $request['records']['consumer_a']['boot_succeeded'] );
- self::assertSame( array( self::SCOPED . 'Core\\Scheduling\\Scheduler', self::SCOPED . 'Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerA\\ProductData', 'Acme\\ConsumerA\\Exports' ), $request['records']['consumer_a']['resolved'] );
self::assertTrue( $request['records']['export_hooked'] );
self::assertFalse( $request['records']['unloaded_include'] );
}
diff --git a/tests/Integration/ConsumerB/ConsumerBBootTest.php b/tests/Integration/ConsumerB/ConsumerBBootTest.php
index 8f687c5..7705c61 100644
--- a/tests/Integration/ConsumerB/ConsumerBBootTest.php
+++ b/tests/Integration/ConsumerB/ConsumerBBootTest.php
@@ -21,7 +21,6 @@ public function test_the_plugin_boots_without_woocommerce_and_its_settings_tab_r
self::assertNull( $request['died'] );
self::assertFalse( $request['records']['woocommerce_present'] );
self::assertSame( 'booted', $request['records']['consumer_b']['status'] );
- self::assertSame( array( 'Acme\\ConsumerB\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerB\\Settings' ), $request['records']['consumer_b']['resolved'] );
self::assertStringContainsString( 'name="acme_cb_settings[greeting]"', $request['body'] );
}
diff --git a/tests/Integration/Consumers/ConsumersCoexistenceTest.php b/tests/Integration/Consumers/ConsumersCoexistenceTest.php
index c4ab536..4976dc5 100644
--- a/tests/Integration/Consumers/ConsumersCoexistenceTest.php
+++ b/tests/Integration/Consumers/ConsumersCoexistenceTest.php
@@ -13,9 +13,9 @@ final class ConsumersCoexistenceTest extends TestCase {
use CreatesUsersTrait;
use InstallsConsumersTrait;
- protected const array ROOTS = array(
- 'consumer_a' => array( 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\Core\\Scheduling\\Scheduler', 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerA\\ProductData', 'Acme\\ConsumerA\\Exports' ),
- 'consumer_b' => array( 'Acme\\ConsumerB\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\ConsumerB\\Settings' ),
+ protected const array SKIPPED = array(
+ 'consumer_a' => array( 'Acme\\ConsumerA\\Reports\\Reports', 'Acme\\ConsumerA\\FaultInjector' ),
+ 'consumer_b' => array(),
);
public static function activation_orders(): array {
@@ -59,10 +59,10 @@ public function test_both_plugins_boot_with_their_own_components_in_either_load_
self::assertNull( $request['died'] );
self::assertStringContainsString( 'id="wpfooter"', $request['body'] );
- foreach ( self::ROOTS as $consumer => $roots ) {
+ foreach ( self::SKIPPED as $consumer => $skipped ) {
self::assertSame( 'booted', $request['records'][ $consumer ]['status'] );
self::assertSame( 1, $request['records'][ $consumer ]['boot_succeeded'] );
- self::assertSame( $roots, $request['records'][ $consumer ]['resolved'] );
+ self::assertSame( $skipped, $request['records'][ $consumer ]['skipped'] );
}
}
}
diff --git a/tests/Integration/Personas/CorePersonaTest.php b/tests/Integration/Personas/CorePersonaTest.php
index fb4b991..f9cb284 100644
--- a/tests/Integration/Personas/CorePersonaTest.php
+++ b/tests/Integration/Personas/CorePersonaTest.php
@@ -21,7 +21,6 @@ public function test_the_plugin_boots_its_components_and_runs_its_migration_with
self::assertNull( $request['died'] );
self::assertFalse( $request['records']['woocommerce_present'] );
self::assertSame( 'booted', $request['records']['persona_core']['status'] );
- self::assertSame( array( 'Acme\\PersonaCore\\Scoped\\DeepWebSolutions\\Framework\\Core\\AdminNotice\\AdminNotices', 'Acme\\PersonaCore\\Greeting' ), $request['records']['persona_core']['resolved'] );
self::assertSame( 1, $request['records']['persona_core']['boot_succeeded'] );
self::assertSame( 'Acme\\PersonaCore\\Scoped\\DeepWebSolutions\\Framework\\Core\\Migration\\MigrationRunner', $request['records']['persona_core']['migrations'] );
self::assertTrue( $request['records']['greeting_shortcode'] );
diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php
index 0b40224..6aa941a 100644
--- a/tests/Integration/Support/consumer-request.php
+++ b/tests/Integration/Support/consumer-request.php
@@ -61,7 +61,6 @@ static function ( int $level, string $message, string $file, int $line ) use ( $
}
$GLOBALS['acme_outcome']['records'][ $key ] = array(
'status' => $plugin?->boot->status,
- 'resolved' => $plugin?->kernel?->resolved,
'skipped' => $plugin?->kernel?->skipped,
'boot_succeeded' => did_action( "{$prefix}/boot/succeeded" ),
'functions_loaded' => function_exists( "{$prefix}_plugin" ),
diff --git a/tests/Unit/Core/PluginKernelTest.php b/tests/Unit/Core/PluginKernelTest.php
index 530751b..cf8fad6 100644
--- a/tests/Unit/Core/PluginKernelTest.php
+++ b/tests/Unit/Core/PluginKernelTest.php
@@ -145,7 +145,6 @@ public function test_boot_constructs_every_component_depth_first_before_register
array( 'construct CompositeA', 'construct ComponentB', 'construct ComponentC', 'construct ComponentD', 'register CompositeA', 'register ComponentB', 'register ComponentC', 'register ComponentD' ),
$journal->entries
);
- self::assertSame( array( CompositeA::class, ComponentB::class, ComponentC::class, ComponentD::class ), $kernel->resolved );
}
public function test_a_second_boot_adds_disjoint_roots(): void {
@@ -159,7 +158,6 @@ public function test_a_second_boot_adds_disjoint_roots(): void {
array( 'construct ComponentD', 'register ComponentD', 'construct CompositeA', 'construct ComponentB', 'construct ComponentC', 'register CompositeA', 'register ComponentB', 'register ComponentC' ),
$journal->entries
);
- self::assertSame( array( ComponentD::class, CompositeA::class, ComponentB::class, ComponentC::class ), $kernel->resolved );
}
public function test_a_second_boot_reaching_a_class_already_walked_throws_before_it_registers_anything(): void {
@@ -194,7 +192,6 @@ public function test_a_gated_out_child_is_skipped_while_its_siblings_load(): voi
array( 'construct CompositeP', 'construct LoadingL1', 'construct UngatedL3', 'register CompositeP', 'register LoadingL1', 'register UngatedL3' ),
$journal->entries
);
- self::assertSame( array( CompositeP::class, LoadingL1::class, UngatedL3::class ), $kernel->resolved );
self::assertSame( array( GatedOutL2::class ), $kernel->skipped );
}
From e97f762416d3e2431de337d7c794fa4c6566ed6e Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:14 +0100
Subject: [PATCH 26/33] Document SettingsSection's API, editor notice markup
and grant timing
SettingsSection inherits WooCommerce's experimental settings API, which
may change in any release (X ruling 116); the post editor shows a
notice's markup through core/notices' __unstableHTML (X ruling 294's
A763); and a capability grant reaches the current user only from the
next request (X ruling 168's A21).
The contract in AGENTS.md and the woocommerce README, limitation 7 and
limitation 8 say so.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 6 +++---
packages/woocommerce/README.md | 2 ++
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f204452..f54f2d2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -90,7 +90,7 @@ actionlint && zizmor .github/
The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
-A package README may name namespaces whose symbols are internal, and a class there is internal even without its tag; core's is `Core\Scheduling\Backends\`.
+A package README may name namespaces whose symbols are internal, and a class there is internal even without its tag; core's is `Core\Scheduling\Backends\`. `SettingsSection` follows WooCommerce's experimental settings API, so the methods it inherits, `get_settings_ui_page()`, `get_script_handles()` and `get_save_adapter()`, follow WooCommerce and may change in a minor release.
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release.
@@ -117,7 +117,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared.
5. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin.
6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level.
-7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws.
-8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked.
+7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws.
+8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted, and the current user sees a grant or revocation from the next request. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked.
9. `TransientCache` and `ObjectCache` are best effort. On the options table a value holding invalid UTF-8 is refused and `set()` returns false, and a `delete()` whose database write fails still returns true, so later requests read the entry until it expires or is set again. `getMultiple()` returns a decimal-string key as an integer, as PHP arrays do. `clear()` leaves earlier entries in place until they expire or the object cache evicts them, and behind a multi-node object cache that is not coherent, a node may read the old generation or salt until the new one reaches it. Without a persistent object cache, `ObjectCache` entries last one request, and an entry outlives its TTL until the request ends; some Memcached drop-ins keep a TTL over 30 days until eviction. Concurrent misses all recompute; nothing locks a key.
10. The framework's queries name tables through `%i`, so a `wpdb` replacement whose `prepare()` predates WordPress 6.2 breaks them. A `Lock` lives in the current site's options table, and a hold is judged by the TTL its holder wrote. A holder learns of a takeover only at its next `refresh()`, so data the lock protects is written with a compare-and-swap, such as `OptionRows::replace()`, where a stale write matters.
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index 5610851..c23d38a 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -183,6 +183,8 @@ The five packages release together under one version, which the [changelog](http
The 2.x contract is every public class, interface, enum, method, property and constant, the protected members of non-final classes, and the hook and priority each public callback method is registered on, unless tagged `@internal`. A `protected(set)` property is contract for reading, by its name and declared type. `@internal`, `private` and protected-on-final members are not contract, and an `@internal` symbol serves only its own package, because a caret edge installs a package beside an older or newer release of another. Hook names and their arguments, option and meta keys, REST routes, and the path and return value of bootstrap's `load.php` are contract too.
+`SettingsSection` follows WooCommerce's experimental settings API, so the methods it inherits, `get_settings_ui_page()`, `get_script_handles()` and `get_save_adapter()`, follow WooCommerce and may change in a minor release.
+
Releases follow SemVer on this contract from the first tag: a break of it ships as the next major, and internals change in any release. Plugins on different majors run side by side, because each ships its own prefixed copy.
This package is developed in the [wordpress-framework](https://github.com/ahegyes/wordpress-framework) monorepo, which holds its issues and pull requests.
From 5883ba20164f7a98b961f1840d846fbb8753d2e1 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:14 +0100
Subject: [PATCH 27/33] Spell each shape, message, handle and name check once
The updater spelled its two shapes twice each, three migration messages
and a comment hard-coded the lock's ten minutes, the cache-key message
repeated the reserved characters, the notice handles and DOM id were
spelled at each use, OptionRows hid its name check in serialize(),
Fields took a map whose values it never read, and three hosts imported a
type they never use (X ruling 179's A112 to A116, A120 and A121).
Each has one spelling: Config and Release aliases, LOCK_TTL,
RESERVED_CHARACTERS, one handle per script method and get_dom_id(), a
name check in insert() as in get(), a list of section ids, and no unused
import.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/bootstrap/src/Updater.php | 11 ++++---
.../core/src/AdminNotice/AdminNotices.php | 32 ++++++++++++++-----
packages/core/src/Caching/CacheEntries.php | 2 +-
.../core/src/Migration/MigrationRunner.php | 15 ++++++---
packages/core/src/Storage/OptionRows.php | 7 ++--
.../src/EditScreen/Hosts/TermForm.php | 2 --
.../src/EditScreen/Hosts/UserProfile.php | 2 --
.../src/EditScreen/Hosts/OrderMetaBox.php | 2 --
packages/woocommerce/src/Setting/Fields.php | 6 ++--
.../src/Setting/SettingsSection.php | 2 +-
.../woocommerce/src/Setting/SettingsTab.php | 5 +--
11 files changed, 53 insertions(+), 33 deletions(-)
diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php
index cae81f5..355f855 100644
--- a/packages/bootstrap/src/Updater.php
+++ b/packages/bootstrap/src/Updater.php
@@ -9,6 +9,9 @@
*
* @since 2.0.0
* @version 2.0.0
+ *
+ * @phpstan-type Config array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, author: string, homepage: string, file: string}
+ * @phpstan-type Release array{version: string, package: string, url: string, body: string, last_updated: string, requires: string, requires_php: string, tested: string}
*/
final class Updater {
// region MAGIC METHODS
@@ -120,7 +123,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) {
*
* @param string $plugin_file The path to the plugin's entry file.
*
- * @return array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, author: string, homepage: string, file: string}|null
+ * @return Config|null
*/
protected static function get_updater_config( string $plugin_file ): ?array {
if ( ! \function_exists( 'get_plugin_data' ) ) {
@@ -183,9 +186,9 @@ protected static function get_updater_config( string $plugin_file ): ?array {
* @since 2.0.0
* @version 2.0.0
*
- * @param array{repository: string, asset: string, channel: 'stable'|'prerelease', slug: string, name: string, version: string, author: string, homepage: string, file: string} $config The plugin's updater configuration.
+ * @param Config $config The plugin's updater configuration.
*
- * @return array{version: string, package: string, url: string, body: string, last_updated: string, requires: string, requires_php: string, tested: string}|null
+ * @return Release|null
*/
protected static function get_release( array $config ): ?array {
$transient = "{$config['slug']}_github_release_{$config['channel']}";
@@ -275,7 +278,7 @@ protected static function get_release( array $config ): ?array {
*
* @param mixed $value The candidate record.
*
- * @return array{version: string, package: string, url: string, body: string, last_updated: string, requires: string, requires_php: string, tested: string}|null
+ * @return Release|null
*/
protected static function read_release( $value ): ?array {
if ( ! \is_array( $value ) ) {
diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php
index 31329d9..387d959 100644
--- a/packages/core/src/AdminNotice/AdminNotices.php
+++ b/packages/core/src/AdminNotice/AdminNotices.php
@@ -321,7 +321,7 @@ public function render_notices(): void {
array(
'type' => $notice->type->value,
'dismissible' => $notice->dismissible,
- 'id' => "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}",
+ 'id' => $this->get_dom_id( $notice ),
'attributes' => $attributes,
)
);
@@ -390,6 +390,7 @@ public function enqueue_editor_script(): void {
return;
}
+ $handle = "{$this->prefix}-notice-delivery";
$settings = wp_json_encode(
array(
'prefix' => $this->prefix,
@@ -398,9 +399,9 @@ public function enqueue_editor_script(): void {
)
);
- wp_register_script( "{$this->prefix}-notice-delivery", false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache.
+ wp_register_script( $handle, false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache.
wp_add_inline_script(
- "{$this->prefix}-notice-delivery",
+ $handle,
<<<'JS'
( function ( settings ) {
function deliver() {
@@ -428,7 +429,7 @@ function deliver() {
} )
JS . '( ' . $settings . ' );'
);
- wp_enqueue_script( "{$this->prefix}-notice-delivery" );
+ wp_enqueue_script( $handle );
}
/**
@@ -446,6 +447,7 @@ public function enqueue_dismiss_script(): void {
return;
}
+ $handle = "{$this->prefix}-notice-dismissal";
$settings = wp_json_encode(
array(
'prefix' => $this->prefix,
@@ -453,9 +455,9 @@ public function enqueue_dismiss_script(): void {
)
);
- wp_register_script( "{$this->prefix}-notice-dismissal", false, array( 'wp-api-fetch' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache.
+ wp_register_script( $handle, false, array( 'wp-api-fetch' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache.
wp_add_inline_script(
- "{$this->prefix}-notice-dismissal",
+ $handle,
<<<'JS'
( function ( settings ) {
document.addEventListener( 'click', function ( event ) {
@@ -480,7 +482,7 @@ public function enqueue_dismiss_script(): void {
} )
JS . '( ' . $settings . ' );'
);
- wp_enqueue_script( "{$this->prefix}-notice-dismissal" );
+ wp_enqueue_script( $handle );
}
/**
@@ -558,7 +560,7 @@ public function rest_deliver(): array {
$delivered = array();
foreach ( $this->select( true ) as $notice ) {
$delivered[] = array(
- 'id' => "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}",
+ 'id' => $this->get_dom_id( $notice ),
'status' => $notice->type->value,
'content' => wp_kses_post( $this->get_message( $notice ) ),
'isDismissible' => $notice->dismissible,
@@ -812,6 +814,20 @@ protected function log( string $message, NoticeScope $scope, \Throwable $excepti
);
}
+ /**
+ * Returns the id of the notice's element, which the classic screens and the block editor share.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param Notice $notice The notice.
+ *
+ * @return string
+ */
+ protected function get_dom_id( Notice $notice ): string {
+ return "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}";
+ }
+
/**
* Returns the REST namespace.
*
diff --git a/packages/core/src/Caching/CacheEntries.php b/packages/core/src/Caching/CacheEntries.php
index e847609..21a89d3 100644
--- a/packages/core/src/Caching/CacheEntries.php
+++ b/packages/core/src/Caching/CacheEntries.php
@@ -62,7 +62,7 @@ public static function check_key( mixed $key ): string {
if ( ! \is_string( $key ) || '' === $key || false !== \strpbrk( $key, self::RESERVED_CHARACTERS ) ) {
$shown = \is_string( $key ) ? $key : \get_debug_type( $key );
- throw new InvalidCacheKeyException( "Cache key '$shown' is empty, not a string or holds one of {}()/\\@:." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ throw new InvalidCacheKeyException( "Cache key '$shown' is empty, not a string or holds one of " . self::RESERVED_CHARACTERS . '.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
return $key;
diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php
index b383b07..b00daf6 100644
--- a/packages/core/src/Migration/MigrationRunner.php
+++ b/packages/core/src/Migration/MigrationRunner.php
@@ -234,8 +234,14 @@ public function run(): void {
);
$revision = $target;
} elseif ( LockClaim::TakenOver === $claim && \is_null( $this->get_error() ) ) {
- $this->record_error( 'Step ' . ( $revision + 1 ) . ' did not finish within 10 minutes.', $revision );
- $this->logger->warning( 'Data migration step {step} did not finish within 10 minutes, so it runs again.', array( 'step' => $revision + 1 ) );
+ $this->record_error( 'Step ' . ( $revision + 1 ) . ' did not finish within ' . self::LOCK_TTL / MINUTE_IN_SECONDS . ' minutes.', $revision );
+ $this->logger->warning(
+ 'Data migration step {step} did not finish within {minutes} minutes, so it runs again.',
+ array(
+ 'step' => $revision + 1,
+ 'minutes' => self::LOCK_TTL / MINUTE_IN_SECONDS,
+ )
+ );
}
if ( \is_null( $row ) ) {
@@ -302,7 +308,7 @@ public function run(): void {
)
);
- // In production the lock stays until its hold expires, so a failing step runs again 10 minutes later; elsewhere the fixed step runs on the next request.
+ // In production the lock stays until its hold expires, when a failing step runs again; elsewhere the fixed step runs on the next request.
if ( PluginBoot::is_development_environment() ) {
$this->unlock();
throw $exception;
@@ -445,8 +451,9 @@ protected function unlock(): void {
$this->lock->release( $this->owner );
} catch ( RuntimeException $exception ) {
$this->logger->warning(
- 'Could not release the data migration lock, which expires in 10 minutes: {error}',
+ 'Could not release the data migration lock, which expires in {minutes} minutes: {error}',
array(
+ 'minutes' => self::LOCK_TTL / MINUTE_IN_SECONDS,
'error' => $exception->getMessage(),
'exception' => $exception,
)
diff --git a/packages/core/src/Storage/OptionRows.php b/packages/core/src/Storage/OptionRows.php
index 38a3a67..9997802 100644
--- a/packages/core/src/Storage/OptionRows.php
+++ b/packages/core/src/Storage/OptionRows.php
@@ -95,6 +95,7 @@ public static function insert( string $name, mixed $value ): ?OptionRow {
global $wpdb;
$raw = self::serialize( $name, $value );
+ self::check_name( $name );
$inserted = self::write( $wpdb->prepare( 'INSERT IGNORE INTO %i (option_name, option_value, autoload) VALUES (%s, %s, %s)', $wpdb->options, $name, $raw, 'off' ) ?? '', "Could not insert option '$name'" );
if ( 0 === $inserted ) {
@@ -116,7 +117,7 @@ public static function insert( string $name, mixed $value ): ?OptionRow {
* @param OptionRow $observed The row as it was read.
* @param mixed $value The new value: a scalar, null, or an array of them, stored other than the observed one.
*
- * @throws InvalidArgumentException Thrown when the value is invalid or would be stored as the observed one, or the row's name is invalid.
+ * @throws InvalidArgumentException Thrown when the value is invalid or would be stored as the observed one.
* @throws RuntimeException Thrown when the write fails.
*
* @return OptionRow|null
@@ -198,7 +199,7 @@ public static function check_name( string $name, int $max_length = self::MAX_NAM
* @param string $name The option name.
* @param mixed $value The value.
*
- * @throws InvalidArgumentException Thrown when the name is invalid or the value holds anything but scalars, null and arrays.
+ * @throws InvalidArgumentException Thrown when the value holds anything but scalars, null and arrays.
*
* @return string
*/
@@ -207,8 +208,6 @@ protected static function serialize( string $name, mixed $value ): string {
throw new InvalidArgumentException( "The value of option '$name' holds something other than scalars, null and arrays." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
- self::check_name( $name );
-
$raw = maybe_serialize( $value );
return \is_scalar( $raw ) ? (string) $raw : '';
}
diff --git a/packages/settings/src/EditScreen/Hosts/TermForm.php b/packages/settings/src/EditScreen/Hosts/TermForm.php
index d01bf49..39bcebd 100644
--- a/packages/settings/src/EditScreen/Hosts/TermForm.php
+++ b/packages/settings/src/EditScreen/Hosts/TermForm.php
@@ -17,8 +17,6 @@
*
* @since 2.0.0
* @version 2.0.0
- *
- * @phpstan-import-type Row from ObjectFormHostInterface
*/
final class TermForm implements ObjectFormHostInterface {
// region FIELDS AND CONSTANTS
diff --git a/packages/settings/src/EditScreen/Hosts/UserProfile.php b/packages/settings/src/EditScreen/Hosts/UserProfile.php
index d20406e..80264c0 100644
--- a/packages/settings/src/EditScreen/Hosts/UserProfile.php
+++ b/packages/settings/src/EditScreen/Hosts/UserProfile.php
@@ -17,8 +17,6 @@
*
* @since 2.0.0
* @version 2.0.0
- *
- * @phpstan-import-type Row from ObjectFormHostInterface
*/
final class UserProfile implements ObjectFormHostInterface {
// region FIELDS AND CONSTANTS
diff --git a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php
index 154ddb7..a80db01 100644
--- a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php
+++ b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php
@@ -18,8 +18,6 @@
*
* @since 2.0.0
* @version 2.0.0
- *
- * @phpstan-import-type Row from ObjectFormHostInterface
*/
final class OrderMetaBox implements ObjectFormHostInterface {
// region FIELDS AND CONSTANTS
diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php
index c1d8314..5b0bebc 100644
--- a/packages/woocommerce/src/Setting/Fields.php
+++ b/packages/woocommerce/src/Setting/Fields.php
@@ -41,10 +41,10 @@ final class Fields {
* @since 2.0.0
* @version 2.0.0
*
- * @param array $sections The helper's sections, keyed by id.
+ * @param list $section_ids The ids of the helper's sections.
*/
public function __construct(
- protected array $sections
+ protected array $section_ids
) {}
// endregion
@@ -100,7 +100,7 @@ public static function check_id( string $id ): void {
* @return array
*/
public function claim( array $settings, string $section_id ): array {
- if ( ! isset( $this->sections[ $section_id ] ) ) {
+ if ( ! \in_array( $section_id, $this->section_ids, true ) ) {
return $settings;
}
diff --git a/packages/woocommerce/src/Setting/SettingsSection.php b/packages/woocommerce/src/Setting/SettingsSection.php
index f8c3bc7..7e997a5 100644
--- a/packages/woocommerce/src/Setting/SettingsSection.php
+++ b/packages/woocommerce/src/Setting/SettingsSection.php
@@ -65,7 +65,7 @@ public function __construct(
) {
Forms::check_helper_id( $id );
Fields::check_id( $id );
- $this->owner = new Fields( array( $id => true ) );
+ $this->owner = new Fields( array( $id ) );
}
// endregion
diff --git a/packages/woocommerce/src/Setting/SettingsTab.php b/packages/woocommerce/src/Setting/SettingsTab.php
index 2fa5f80..a5c2d20 100644
--- a/packages/woocommerce/src/Setting/SettingsTab.php
+++ b/packages/woocommerce/src/Setting/SettingsTab.php
@@ -73,7 +73,8 @@ public function __construct(
protected SectionFieldsDefinitionInterface $definition
) {
Forms::check_helper_id( $id );
- foreach ( array( $id, ...\array_map( 'strval', \array_keys( $tab['sections'] ) ) ) as $checked ) {
+ $section_ids = \array_map( 'strval', \array_keys( $tab['sections'] ) );
+ foreach ( array( $id, ...$section_ids ) as $checked ) {
Fields::check_id( $checked );
}
@@ -82,7 +83,7 @@ public function __construct(
$this->sections = $tab['sections'];
parent::__construct();
- $this->owner = new Fields( $this->sections );
+ $this->owner = new Fields( $section_ids );
$this->owner->register_hooks( $id );
}
From e582b30866ce686fd38abe593402229ca974b558 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:15 +0100
Subject: [PATCH 28/33] Clean the uninstall footprint once
The footprint was cleaned at 15 read sites, while the meta types were
cleaned once in run() (X ruling 179's A117).
run() cleans every kind once and hands the engine lists of names, with
the meta kinds keyed by the meta types it knows.
Assisted-by: Claude Code:claude-opus-5-5
---
packages/core/src/Uninstall/Uninstaller.php | 82 +++++++++++++++------
1 file changed, 59 insertions(+), 23 deletions(-)
diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php
index e1dd568..ffd6bd8 100644
--- a/packages/core/src/Uninstall/Uninstaller.php
+++ b/packages/core/src/Uninstall/Uninstaller.php
@@ -48,15 +48,19 @@ final class Uninstaller {
* @since 2.0.0
* @version 2.0.0
*
+ * @phpstan-param array> $footprint
+ * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint
* @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args
*
- * @param string $plugin_file The path to the plugin's entry file.
- * @param array $footprint The plugin's stored data by kind.
- * @param array $args The consent, the meta types by name and the cleanup.
+ * @param string $plugin_file The path to the plugin's entry file.
+ * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind.
+ * @param array $meta_footprint The meta keys and meta prefixes, by meta type name.
+ * @param array $args The consent, the meta types by name and the cleanup.
*/
protected function __construct(
protected readonly string $plugin_file,
protected readonly array $footprint,
+ protected readonly array $meta_footprint,
protected readonly array $args
) {}
@@ -114,7 +118,24 @@ public static function run( string $plugin_file, array $footprint, array $args =
}
$args['meta_types'] = $meta_types;
- $uninstaller = new self( $plugin_file, $footprint, $args );
+ $uninstaller = new self(
+ $plugin_file,
+ \array_map(
+ self::get_strings( ... ),
+ \array_diff_key(
+ $footprint,
+ array(
+ 'meta' => true,
+ 'meta_prefixes' => true,
+ )
+ )
+ ),
+ array(
+ 'meta' => self::get_meta_strings( $footprint['meta'] ?? null, $meta_types ),
+ 'meta_prefixes' => self::get_meta_strings( $footprint['meta_prefixes'] ?? null, $meta_types ),
+ ),
+ $args
+ );
if ( ! is_multisite() ) {
$uninstaller->uninstall_site( true );
} else {
@@ -176,7 +197,7 @@ protected function uninstall_site( bool $network_footprint ): void {
$failed = \count( $this->failures );
try {
- foreach ( self::get_strings( $this->footprint['cron_hooks'] ?? null ) as $hook ) {
+ foreach ( $this->footprint['cron_hooks'] ?? array() as $hook ) {
$wpdb->flush();
try {
$result = wp_unschedule_hook( $hook, true );
@@ -188,10 +209,10 @@ protected function uninstall_site( bool $network_footprint ): void {
if ( \function_exists( 'as_unschedule_all_actions' ) ) { // Action Scheduler is loaded only while a plugin that bundles it is active.
$jobs = array();
- foreach ( self::get_strings( $this->footprint['as_hooks'] ?? null ) as $hook ) {
+ foreach ( $this->footprint['as_hooks'] ?? array() as $hook ) {
$jobs[ "Action Scheduler hook $hook" ] = array( $hook );
}
- foreach ( self::get_strings( $this->footprint['as_groups'] ?? null ) as $group ) {
+ foreach ( $this->footprint['as_groups'] ?? array() as $group ) {
$jobs[ "Action Scheduler group $group" ] = array( '', array(), $group );
}
@@ -225,8 +246,8 @@ protected function uninstall_site( bool $network_footprint ): void {
return;
}
- $options = self::get_strings( $this->footprint['options'] ?? null );
- foreach ( self::get_strings( $this->footprint['option_prefixes'] ?? null ) as $prefix ) {
+ $options = $this->footprint['options'] ?? array();
+ foreach ( $this->footprint['option_prefixes'] ?? array() as $prefix ) {
$options = \array_merge( $options, $this->get_names_by_prefix( $wpdb->options, 'option_name', $prefix ) );
}
foreach ( \array_diff( \array_unique( $options ), array( $this->args['consent']['option'] ) ) as $option ) {
@@ -234,12 +255,12 @@ protected function uninstall_site( bool $network_footprint ): void {
$this->record_failure( "option $option", delete_option( $option ) );
}
- foreach ( self::get_strings( $this->footprint['transients'] ?? null ) as $transient ) {
+ foreach ( $this->footprint['transients'] ?? array() as $transient ) {
$wpdb->flush();
$this->record_failure( "transient $transient", delete_transient( $transient ) );
}
- foreach ( self::get_strings( $this->footprint['post_types'] ?? null ) as $post_type ) {
+ foreach ( $this->footprint['post_types'] ?? array() as $post_type ) {
// A relationship carries no object type, so only post taxonomies are read, which leaves a link's or a user's terms under the same id alone.
$taxonomies = get_object_taxonomies( array( $post_type, ...get_post_types() ) );
foreach ( $this->get_matching_ids( $wpdb->posts, 'ID', 'post_type', $post_type ) as $post_id ) {
@@ -251,15 +272,15 @@ protected function uninstall_site( bool $network_footprint ): void {
}
}
- foreach ( self::get_strings( $this->footprint['comment_types'] ?? null ) as $comment_type ) {
+ foreach ( $this->footprint['comment_types'] ?? array() as $comment_type ) {
foreach ( $this->get_matching_ids( $wpdb->comments, 'comment_ID', 'comment_type', $comment_type ) as $comment_id ) {
$wpdb->flush();
$this->record_failure( "comment $comment_id of type $comment_type", self::confirm_deleted( wp_delete_comment( $comment_id, true ) ) );
}
}
- $meta_keys = \array_intersect_key( \is_array( $this->footprint['meta'] ?? null ) ? $this->footprint['meta'] : array(), $this->args['meta_types'] );
- $meta_prefixes = \array_intersect_key( \is_array( $this->footprint['meta_prefixes'] ?? null ) ? $this->footprint['meta_prefixes'] : array(), $this->args['meta_types'] );
+ $meta_keys = $this->meta_footprint['meta'];
+ $meta_prefixes = $this->meta_footprint['meta_prefixes'];
foreach ( \array_keys( $meta_keys + $meta_prefixes ) as $name ) {
$meta_type = $this->args['meta_types'][ $name ];
@@ -272,8 +293,8 @@ protected function uninstall_site( bool $network_footprint ): void {
}
}
- $keys = self::get_strings( $meta_keys[ $name ] ?? null );
- foreach ( self::get_strings( $meta_prefixes[ $name ] ?? null ) as $prefix ) {
+ $keys = $meta_keys[ $name ] ?? array();
+ foreach ( $meta_prefixes[ $name ] ?? array() as $prefix ) {
foreach ( '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ) as $table ) {
$keys = \array_merge( $keys, $this->get_names_by_prefix( $table, 'meta_key', $prefix ) );
}
@@ -299,12 +320,12 @@ protected function uninstall_site( bool $network_footprint ): void {
}
$blog_prefix = $wpdb->get_blog_prefix(); // Site user meta keys carry the site's table prefix, as update_user_option() writes them.
- $this->delete_meta_keys( 'user', \array_map( static fn ( string $key ): string => $blog_prefix . $key, self::get_strings( $this->footprint['user_meta_site'] ?? null ) ) );
- foreach ( self::get_strings( $this->footprint['user_meta_site_prefixes'] ?? null ) as $prefix ) {
+ $this->delete_meta_keys( 'user', \array_map( static fn ( string $key ): string => $blog_prefix . $key, $this->footprint['user_meta_site'] ?? array() ) );
+ foreach ( $this->footprint['user_meta_site_prefixes'] ?? array() as $prefix ) {
$this->delete_meta_keys( 'user', $this->get_names_by_prefix( $wpdb->usermeta, 'meta_key', $blog_prefix . $prefix ) );
}
- foreach ( self::get_strings( $this->footprint['capabilities'] ?? null ) as $capability ) {
+ foreach ( $this->footprint['capabilities'] ?? array() as $capability ) {
foreach ( wp_roles()->role_objects as $role ) {
$wpdb->flush();
$role->remove_cap( $capability );
@@ -312,7 +333,7 @@ protected function uninstall_site( bool $network_footprint ): void {
}
}
- foreach ( self::get_strings( $this->footprint['tables'] ?? null ) as $table ) {
+ foreach ( $this->footprint['tables'] ?? array() as $table ) {
$wpdb->flush();
$this->record_failure( "table $table", $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ?? '' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A plugin's own table, which no WordPress API drops.
}
@@ -344,7 +365,7 @@ protected function uninstall_site( bool $network_footprint ): void {
protected function delete_network_footprint(): void {
global $wpdb;
- $options = self::get_strings( $this->footprint['network_options'] ?? null );
+ $options = $this->footprint['network_options'] ?? array();
if ( is_multisite() ) {
// WordPress's delete_network_option() matches keys under the column's collation, which would also delete another plugin's key that differs only in letter case.
$this->delete_meta_keys( 'site', $options );
@@ -358,8 +379,8 @@ protected function delete_network_footprint(): void {
}
}
- $this->delete_meta_keys( 'user', self::get_strings( $this->footprint['user_meta_global'] ?? null ) );
- foreach ( self::get_strings( $this->footprint['user_meta_global_prefixes'] ?? null ) as $prefix ) {
+ $this->delete_meta_keys( 'user', $this->footprint['user_meta_global'] ?? array() );
+ foreach ( $this->footprint['user_meta_global_prefixes'] ?? array() as $prefix ) {
$this->delete_meta_keys( 'user', $this->get_names_by_prefix( $wpdb->usermeta, 'meta_key', $prefix ) );
}
}
@@ -572,5 +593,20 @@ protected static function get_strings( mixed $value ): array {
return \array_values( \array_filter( \is_array( $value ) ? $value : array(), static fn ( mixed $item ): bool => \is_string( $item ) && '' !== $item && '0' !== $item ) );
}
+ /**
+ * Returns a meta kind's strings for each of the meta types the run knows, or none when it is not an array.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param mixed $value The meta kind's entries, by meta type name.
+ * @param array $meta_types The run's meta types, by name.
+ *
+ * @return array>
+ */
+ protected static function get_meta_strings( mixed $value, array $meta_types ): array {
+ return \array_map( self::get_strings( ... ), \array_intersect_key( \is_array( $value ) ? $value : array(), $meta_types ) );
+ }
+
// endregion
}
From 5562c65004463a7ebfd6b1a1f2d505afde1d3a53 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:15 +0100
Subject: [PATCH 29/33] Inline Forms::sanitize_value() into sanitize_fields()
sanitize_value() was public API with no caller outside Forms (X ruling
179's A118).
Its body moves into sanitize_fields(), its rules into that method's
docblock, and its rows into FormsSanitizeFieldsTest through one helper.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
packages/settings/src/Forms.php | 69 +++----
.../Settings/FormsSanitizeFieldsTest.php | 190 +++++++++++++++++
.../Settings/FormsSanitizeValueTest.php | 194 ------------------
4 files changed, 217 insertions(+), 238 deletions(-)
delete mode 100644 tests/Integration/Settings/FormsSanitizeValueTest.php
diff --git a/AGENTS.md b/AGENTS.md
index f54f2d2..f19a5d4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,7 +24,7 @@ wordpress-framework/
│ ├── Storage/{OptionRows, OptionRow, Lock, LockClaim, StoredData}
│ └── Uninstall/Uninstaller
├── packages/settings/ ahegyes/wp-framework-settings `require.php` · core ^2.0
-│ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_value, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal))
+│ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal))
│ ├── AdminPage/{OptionsPage, OptionsPageDefinitionInterface} · pages store each tab's fields in one option through the Settings API
│ └── EditScreen/{ObjectForm, ObjectFormDefinitionInterface, ObjectFormHostInterface, ObjectStoreInterface, FormTable (@internal), Hosts/{PostMetaBox, UserProfile, TermForm}, Stores/MetadataStore} · forms store each field on the edited object through a store
├── packages/woocommerce/ ahegyes/wp-framework-woocommerce `require.php` · settings ^2.0 · WooCommerce as host, at the plugin's `WC requires at least`
diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php
index 85019d0..24a01ca 100644
--- a/packages/settings/src/Forms.php
+++ b/packages/settings/src/Forms.php
@@ -96,56 +96,16 @@ public static function render_control( array $field, mixed $value, string $name,
};
}
- /**
- * Returns the value to store for the field's submission, or a WP_Error under which the host keeps the stored value.
- *
- * A field with validate hands the submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @param Field $field The field.
- * @param mixed $raw The unslashed submission, in which a cleared multi-value field is an empty string.
- *
- * @return scalar|array|\WP_Error|null
- */
- #[\NoDiscard]
- public static function sanitize_value( array $field, mixed $raw ): mixed {
- if ( '' === $raw && \in_array( $field['type'], array( 'multiselect', 'checkboxes' ), true ) ) {
- $raw = array();
- }
-
- if ( isset( $field['validate'] ) ) {
- return $field['validate']( $raw );
- }
- if ( isset( $field['render'] ) ) {
- return self::reject( 'missing_validate' );
- }
-
- return match ( $field['type'] ) {
- // The sanitizers encode a lone '<' that every output escapes again, so the stored text turns it back.
- 'text' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_text_field( (string) $raw ) ) : self::reject( 'invalid_value' ),
- 'textarea' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_textarea_field( (string) $raw ) ) : self::reject( 'invalid_value' ),
- 'email' => self::sanitize_email( $raw ),
- 'url' => self::sanitize_url( $raw ),
- 'number' => self::sanitize_number( $raw ),
- 'checkbox' => self::sanitize_checkbox( $raw ),
- 'select', 'radio' => self::sanitize_choice( $field, $raw ),
- 'multiselect', 'checkboxes' => self::sanitize_choices( $field, $raw ),
- default => self::reject( 'unknown_type' ),
- };
- }
-
/**
* Returns the accepted values and the rejections of a submission, keyed by field id, for the defined, present and enabled fields only.
*
- * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value.
+ * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes.
*
* @since 2.0.0
* @version 2.0.0
*
* @param list $fields The fields.
- * @param array $submitted The unslashed submission, keyed by field id.
+ * @param array $submitted The unslashed submission, keyed by field id, in which a cleared multi-value field is an empty string.
*
* @return array{values: array|null>, errors: array}
*/
@@ -161,7 +121,30 @@ public static function sanitize_fields( array $fields, array $submitted ): array
continue;
}
- $value = self::sanitize_value( $field, $submitted[ $id ] );
+ $raw = $submitted[ $id ];
+ if ( '' === $raw && \in_array( $field['type'], array( 'multiselect', 'checkboxes' ), true ) ) {
+ $raw = array();
+ }
+
+ if ( isset( $field['validate'] ) ) {
+ $value = $field['validate']( $raw );
+ } elseif ( isset( $field['render'] ) ) {
+ $value = self::reject( 'missing_validate' );
+ } else {
+ $value = match ( $field['type'] ) {
+ // The sanitizers encode a lone '<' that every output escapes again, so the stored text turns it back.
+ 'text' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_text_field( (string) $raw ) ) : self::reject( 'invalid_value' ),
+ 'textarea' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_textarea_field( (string) $raw ) ) : self::reject( 'invalid_value' ),
+ 'email' => self::sanitize_email( $raw ),
+ 'url' => self::sanitize_url( $raw ),
+ 'number' => self::sanitize_number( $raw ),
+ 'checkbox' => self::sanitize_checkbox( $raw ),
+ 'select', 'radio' => self::sanitize_choice( $field, $raw ),
+ 'multiselect', 'checkboxes' => self::sanitize_choices( $field, $raw ),
+ default => self::reject( 'unknown_type' ),
+ };
+ }
+
if ( is_wp_error( $value ) ) {
$result['errors'][ $id ] = $value;
} else {
diff --git a/tests/Integration/Settings/FormsSanitizeFieldsTest.php b/tests/Integration/Settings/FormsSanitizeFieldsTest.php
index a0a4af3..d9e58b6 100644
--- a/tests/Integration/Settings/FormsSanitizeFieldsTest.php
+++ b/tests/Integration/Settings/FormsSanitizeFieldsTest.php
@@ -4,10 +4,187 @@
use DeepWebSolutions\Framework\Settings\Forms;
use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass( Forms::class )]
final class FormsSanitizeFieldsTest extends TestCase {
+ protected const array OPTIONS = array(
+ 1 => 'One',
+ 2 => 'Two',
+ );
+
+ protected const array MESSAGES = array(
+ 'invalid_value' => 'Enter a valid value.',
+ 'invalid_choice' => 'Choose one of the available options.',
+ 'invalid_email' => 'Enter a valid email address, such as name@example.com.',
+ 'invalid_url' => 'Enter a valid URL, such as https://example.com.',
+ 'invalid_number' => 'Enter a number, such as 12 or 3.5.',
+ 'unknown_type' => 'This field cannot be saved because its type is not supported.',
+ 'missing_validate' => 'This field cannot be saved because nothing validates its value.',
+ );
+
+ public static function accepted(): array {
+ return array(
+ 'text-strips-tags-and-trims' => array( 'text', ' Hi there ', 'Hi there' ),
+ 'text-stringifies-an-int' => array( 'text', 5, '5' ),
+ 'text-keeps-a-lone-less-than' => array( 'text', 'a < b', 'a < b' ),
+ 'text-reads-a-typed-entity-as-text' => array( 'text', 'a < b', 'a < b' ),
+ 'textarea-keeps-newlines' => array( 'textarea', "One\ntwo", "One\ntwo" ),
+ 'textarea-keeps-a-lone-less-than' => array( 'textarea', "1 < 2\nOne", "1 < 2\nOne" ),
+ 'email-is-trimmed' => array( 'email', ' ops@example.com ', 'ops@example.com' ),
+ 'email-cleared' => array( 'email', '', '' ),
+ 'url-is-kept' => array( 'url', 'https://example.com/a?b=c', 'https://example.com/a?b=c' ),
+ 'url-gains-a-scheme' => array( 'url', 'example.com', 'http://example.com' ),
+ 'url-cleared' => array( 'url', ' ', '' ),
+ 'number-integer' => array( 'number', '12', '12' ),
+ 'number-is-trimmed' => array( 'number', ' 4 ', '4' ),
+ 'number-negative-decimal' => array( 'number', '-1.5', '-1.5' ),
+ 'number-leading-point' => array( 'number', '.5', '.5' ),
+ 'number-exponent' => array( 'number', '1e3', '1e3' ),
+ 'number-cleared' => array( 'number', '', '' ),
+ 'number-from-an-int' => array( 'number', 5, '5' ),
+ 'number-from-a-float' => array( 'number', 1.5, '1.5' ),
+ 'checkbox-yes' => array( 'checkbox', 'yes', 'yes' ),
+ 'checkbox-one' => array( 'checkbox', '1', 'yes' ),
+ 'checkbox-true' => array( 'checkbox', true, 'yes' ),
+ 'checkbox-no' => array( 'checkbox', 'no', 'no' ),
+ 'checkbox-empty' => array( 'checkbox', '', 'no' ),
+ 'checkbox-false' => array( 'checkbox', false, 'no' ),
+ 'select-integer-key-as-post-string' => array( 'select', '2', '2' ),
+ 'select-integer-key-as-int' => array( 'select', 2, '2' ),
+ 'radio-integer-key-as-post-string' => array( 'radio', '1', '1' ),
+ 'multiselect-post-strings' => array( 'multiselect', array( '1', '2' ), array( '1', '2' ) ),
+ 'multiselect-ints-as-strings' => array( 'multiselect', array( 2, 1 ), array( '2', '1' ) ),
+ 'multiselect-deduplicated' => array( 'multiselect', array( '2', '1', '2' ), array( '2', '1' ) ),
+ 'multiselect-reindexed' => array( 'multiselect', array( 3 => '2' ), array( '2' ) ),
+ 'multiselect-cleared' => array( 'multiselect', '', array() ),
+ 'checkboxes-post-strings' => array( 'checkboxes', array( '1', '2' ), array( '1', '2' ) ),
+ 'checkboxes-cleared' => array( 'checkboxes', '', array() ),
+ 'checkboxes-empty-array' => array( 'checkboxes', array(), array() ),
+ );
+ }
+
+ #[DataProvider( 'accepted' )]
+ public function test_a_value_without_validate_is_sanitized_by_its_type( string $type, mixed $raw, mixed $expected ): void {
+ self::assertSame( $expected, self::sanitize( self::field( $type ), $raw ) );
+ }
+
+ public static function rejected(): array {
+ return array(
+ 'text-array' => array( 'text', array( 'a' ), 'invalid_value' ),
+ 'text-object' => array( 'text', new \stdClass(), 'invalid_value' ),
+ 'text-bool' => array( 'text', true, 'invalid_value' ),
+ 'text-null' => array( 'text', null, 'invalid_value' ),
+ 'textarea-array' => array( 'textarea', array( 'a' ), 'invalid_value' ),
+ 'email-without-a-dotted-domain' => array( 'email', 'ops@localhost', 'invalid_email' ),
+ 'email-the-sanitizer-rewrites' => array( 'email', 'jöhn@example.com', 'invalid_email' ),
+ 'email-not-an-address' => array( 'email', 'not an email', 'invalid_email' ),
+ 'email-array' => array( 'email', array( 'ops@example.com' ), 'invalid_value' ),
+ 'url-script' => array( 'url', 'javascript:alert(1)', 'invalid_url' ),
+ 'url-mistyped-scheme' => array( 'url', 'htps://example.com', 'invalid_url' ),
+ 'url-array' => array( 'url', array( 'https://example.com' ), 'invalid_value' ),
+ 'number-leading-plus' => array( 'number', '+1', 'invalid_number' ),
+ 'number-trailing-point' => array( 'number', '1.', 'invalid_number' ),
+ 'number-trailing-letters' => array( 'number', '12abc', 'invalid_number' ),
+ 'number-decimal-comma' => array( 'number', '1,5', 'invalid_number' ),
+ 'number-overflowing' => array( 'number', '1e999', 'invalid_number' ),
+ 'number-infinite-float' => array( 'number', \INF, 'invalid_number' ),
+ 'number-nan' => array( 'number', \NAN, 'invalid_number' ),
+ 'number-array' => array( 'number', array( '1' ), 'invalid_value' ),
+ 'checkbox-other-string' => array( 'checkbox', 'maybe', 'invalid_choice' ),
+ 'checkbox-array' => array( 'checkbox', array( 'yes' ), 'invalid_choice' ),
+ 'select-unknown-choice' => array( 'select', '3', 'invalid_choice' ),
+ 'select-empty-choice' => array( 'select', '', 'invalid_choice' ),
+ 'select-array' => array( 'select', array( '1' ), 'invalid_value' ),
+ 'radio-unknown-choice' => array( 'radio', '3', 'invalid_choice' ),
+ 'multiselect-unknown-choice' => array( 'multiselect', array( '1', '3' ), 'invalid_choice' ),
+ 'multiselect-string' => array( 'multiselect', '1', 'invalid_value' ),
+ 'multiselect-nested-array' => array( 'multiselect', array( array( '1' ) ), 'invalid_value' ),
+ 'checkboxes-unknown-choice' => array( 'checkboxes', array( '3' ), 'invalid_choice' ),
+ 'unknown-type' => array( 'acme_color', '#fff', 'unknown_type' ),
+ );
+ }
+
+ #[DataProvider( 'rejected' )]
+ public function test_a_value_without_validate_is_rejected_with_the_message_of_its_code( string $type, mixed $raw, string $code ): void {
+ $result = self::sanitize( self::field( $type ), $raw );
+
+ self::assertInstanceOf( \WP_Error::class, $result );
+ self::assertSame( $code, $result->get_error_code() );
+ self::assertSame( self::MESSAGES[ $code ], $result->get_error_message() );
+ }
+
+ public function test_a_radio_rejects_a_disabled_choice_and_accepts_an_enabled_one(): void {
+ $field = self::field( 'radio' ) + array( 'disabled' => array( 2 ) );
+
+ self::assertSame( 'invalid_choice', self::sanitize( $field, '2' )->get_error_code() );
+ self::assertSame( '1', self::sanitize( $field, '1' ) );
+ }
+
+ public function test_a_custom_type_without_validate_is_rejected(): void {
+ $field = array(
+ 'type' => 'text',
+ 'render' => static fn (): string => '',
+ );
+
+ $result = self::sanitize( $field, 'x' );
+
+ self::assertSame( 'missing_validate', $result->get_error_code() );
+ self::assertSame( self::MESSAGES['missing_validate'], $result->get_error_message() );
+ }
+
+ public function test_validate_receives_the_raw_submission_alone_and_its_return_is_stored_as_is(): void {
+ $calls = array();
+ $field = array(
+ 'type' => 'select',
+ 'options' => array( 'a' => 'A' ),
+ 'validate' => static function ( mixed ...$arguments ) use ( &$calls ): array {
+ $calls[] = $arguments;
+ return array( 'not a choice' );
+ },
+ );
+
+ self::assertSame( array( 'not a choice' ), self::sanitize( $field, ' z ' ) );
+ self::assertSame( array( array( ' z ' ) ), $calls );
+ }
+
+ public function test_validate_receives_a_cleared_multi_value_field_as_an_empty_array(): void {
+ $received = null;
+ $field = array(
+ 'type' => 'checkboxes',
+ 'options' => array( 'a' => 'A' ),
+ 'validate' => static function ( mixed $raw ) use ( &$received ): mixed {
+ $received = $raw;
+ return $raw;
+ },
+ );
+
+ self::assertSame( array(), self::sanitize( $field, '' ) );
+ self::assertSame( array(), $received );
+ }
+
+ public function test_a_string_callable_validates_with_one_argument(): void {
+ $field = array(
+ 'type' => 'text',
+ 'validate' => 'wp_kses_post',
+ );
+
+ $raw = 'Docs';
+
+ self::assertSame( wp_kses_post( $raw ), self::sanitize( $field, $raw ) );
+ }
+
+ public function test_a_validate_rejection_passes_through(): void {
+ $error = new \WP_Error( 'acme_too_long', 'Use at most ten characters.' );
+ $field = array(
+ 'type' => 'text',
+ 'validate' => static fn ( mixed $raw ): \WP_Error => $error,
+ );
+
+ self::assertSame( $error, self::sanitize( $field, 'x' ) );
+ }
+
public function test_only_defined_present_and_enabled_fields_get_an_entry(): void {
$result = Forms::sanitize_fields(
array(
@@ -120,4 +297,17 @@ public function test_merging_the_values_onto_the_stored_row_keeps_every_untouche
\array_replace( $stored, $result['values'] )
);
}
+
+ protected static function field( string $type ): array {
+ return array(
+ 'type' => $type,
+ 'options' => self::OPTIONS,
+ );
+ }
+
+ protected static function sanitize( array $field, mixed $raw ): mixed {
+ $result = Forms::sanitize_fields( array( array( 'id' => 'key' ) + $field ), array( 'key' => $raw ) );
+
+ return $result['errors']['key'] ?? $result['values']['key'];
+ }
}
diff --git a/tests/Integration/Settings/FormsSanitizeValueTest.php b/tests/Integration/Settings/FormsSanitizeValueTest.php
deleted file mode 100644
index 289a48e..0000000
--- a/tests/Integration/Settings/FormsSanitizeValueTest.php
+++ /dev/null
@@ -1,194 +0,0 @@
- 'One',
- 2 => 'Two',
- );
-
- protected const array MESSAGES = array(
- 'invalid_value' => 'Enter a valid value.',
- 'invalid_choice' => 'Choose one of the available options.',
- 'invalid_email' => 'Enter a valid email address, such as name@example.com.',
- 'invalid_url' => 'Enter a valid URL, such as https://example.com.',
- 'invalid_number' => 'Enter a number, such as 12 or 3.5.',
- 'unknown_type' => 'This field cannot be saved because its type is not supported.',
- 'missing_validate' => 'This field cannot be saved because nothing validates its value.',
- );
-
- public static function accepted(): array {
- return array(
- 'text-strips-tags-and-trims' => array( 'text', ' Hi there ', 'Hi there' ),
- 'text-stringifies-an-int' => array( 'text', 5, '5' ),
- 'text-keeps-a-lone-less-than' => array( 'text', 'a < b', 'a < b' ),
- 'text-reads-a-typed-entity-as-text' => array( 'text', 'a < b', 'a < b' ),
- 'textarea-keeps-newlines' => array( 'textarea', "One\ntwo", "One\ntwo" ),
- 'textarea-keeps-a-lone-less-than' => array( 'textarea', "1 < 2\nOne", "1 < 2\nOne" ),
- 'email-is-trimmed' => array( 'email', ' ops@example.com ', 'ops@example.com' ),
- 'email-cleared' => array( 'email', '', '' ),
- 'url-is-kept' => array( 'url', 'https://example.com/a?b=c', 'https://example.com/a?b=c' ),
- 'url-gains-a-scheme' => array( 'url', 'example.com', 'http://example.com' ),
- 'url-cleared' => array( 'url', ' ', '' ),
- 'number-integer' => array( 'number', '12', '12' ),
- 'number-is-trimmed' => array( 'number', ' 4 ', '4' ),
- 'number-negative-decimal' => array( 'number', '-1.5', '-1.5' ),
- 'number-leading-point' => array( 'number', '.5', '.5' ),
- 'number-exponent' => array( 'number', '1e3', '1e3' ),
- 'number-cleared' => array( 'number', '', '' ),
- 'number-from-an-int' => array( 'number', 5, '5' ),
- 'number-from-a-float' => array( 'number', 1.5, '1.5' ),
- 'checkbox-yes' => array( 'checkbox', 'yes', 'yes' ),
- 'checkbox-one' => array( 'checkbox', '1', 'yes' ),
- 'checkbox-true' => array( 'checkbox', true, 'yes' ),
- 'checkbox-no' => array( 'checkbox', 'no', 'no' ),
- 'checkbox-empty' => array( 'checkbox', '', 'no' ),
- 'checkbox-false' => array( 'checkbox', false, 'no' ),
- 'select-integer-key-as-post-string' => array( 'select', '2', '2' ),
- 'select-integer-key-as-int' => array( 'select', 2, '2' ),
- 'radio-integer-key-as-post-string' => array( 'radio', '1', '1' ),
- 'multiselect-post-strings' => array( 'multiselect', array( '1', '2' ), array( '1', '2' ) ),
- 'multiselect-ints-as-strings' => array( 'multiselect', array( 2, 1 ), array( '2', '1' ) ),
- 'multiselect-deduplicated' => array( 'multiselect', array( '2', '1', '2' ), array( '2', '1' ) ),
- 'multiselect-reindexed' => array( 'multiselect', array( 3 => '2' ), array( '2' ) ),
- 'multiselect-cleared' => array( 'multiselect', '', array() ),
- 'checkboxes-post-strings' => array( 'checkboxes', array( '1', '2' ), array( '1', '2' ) ),
- 'checkboxes-cleared' => array( 'checkboxes', '', array() ),
- 'checkboxes-empty-array' => array( 'checkboxes', array(), array() ),
- );
- }
-
- #[DataProvider( 'accepted' )]
- public function test_a_value_without_validate_is_sanitized_by_its_type( string $type, mixed $raw, mixed $expected ): void {
- self::assertSame( $expected, Forms::sanitize_value( self::field( $type ), $raw ) );
- }
-
- public static function rejected(): array {
- return array(
- 'text-array' => array( 'text', array( 'a' ), 'invalid_value' ),
- 'text-object' => array( 'text', new \stdClass(), 'invalid_value' ),
- 'text-bool' => array( 'text', true, 'invalid_value' ),
- 'text-null' => array( 'text', null, 'invalid_value' ),
- 'textarea-array' => array( 'textarea', array( 'a' ), 'invalid_value' ),
- 'email-without-a-dotted-domain' => array( 'email', 'ops@localhost', 'invalid_email' ),
- 'email-the-sanitizer-rewrites' => array( 'email', 'jöhn@example.com', 'invalid_email' ),
- 'email-not-an-address' => array( 'email', 'not an email', 'invalid_email' ),
- 'email-array' => array( 'email', array( 'ops@example.com' ), 'invalid_value' ),
- 'url-script' => array( 'url', 'javascript:alert(1)', 'invalid_url' ),
- 'url-mistyped-scheme' => array( 'url', 'htps://example.com', 'invalid_url' ),
- 'url-array' => array( 'url', array( 'https://example.com' ), 'invalid_value' ),
- 'number-leading-plus' => array( 'number', '+1', 'invalid_number' ),
- 'number-trailing-point' => array( 'number', '1.', 'invalid_number' ),
- 'number-trailing-letters' => array( 'number', '12abc', 'invalid_number' ),
- 'number-decimal-comma' => array( 'number', '1,5', 'invalid_number' ),
- 'number-overflowing' => array( 'number', '1e999', 'invalid_number' ),
- 'number-infinite-float' => array( 'number', \INF, 'invalid_number' ),
- 'number-nan' => array( 'number', \NAN, 'invalid_number' ),
- 'number-array' => array( 'number', array( '1' ), 'invalid_value' ),
- 'checkbox-other-string' => array( 'checkbox', 'maybe', 'invalid_choice' ),
- 'checkbox-array' => array( 'checkbox', array( 'yes' ), 'invalid_choice' ),
- 'select-unknown-choice' => array( 'select', '3', 'invalid_choice' ),
- 'select-empty-choice' => array( 'select', '', 'invalid_choice' ),
- 'select-array' => array( 'select', array( '1' ), 'invalid_value' ),
- 'radio-unknown-choice' => array( 'radio', '3', 'invalid_choice' ),
- 'multiselect-unknown-choice' => array( 'multiselect', array( '1', '3' ), 'invalid_choice' ),
- 'multiselect-string' => array( 'multiselect', '1', 'invalid_value' ),
- 'multiselect-nested-array' => array( 'multiselect', array( array( '1' ) ), 'invalid_value' ),
- 'checkboxes-unknown-choice' => array( 'checkboxes', array( '3' ), 'invalid_choice' ),
- 'unknown-type' => array( 'acme_color', '#fff', 'unknown_type' ),
- );
- }
-
- #[DataProvider( 'rejected' )]
- public function test_a_value_without_validate_is_rejected_with_the_message_of_its_code( string $type, mixed $raw, string $code ): void {
- $result = Forms::sanitize_value( self::field( $type ), $raw );
-
- self::assertInstanceOf( \WP_Error::class, $result );
- self::assertSame( $code, $result->get_error_code() );
- self::assertSame( self::MESSAGES[ $code ], $result->get_error_message() );
- }
-
- public function test_a_radio_rejects_a_disabled_choice_and_accepts_an_enabled_one(): void {
- $field = self::field( 'radio' ) + array( 'disabled' => array( 2 ) );
-
- self::assertSame( 'invalid_choice', Forms::sanitize_value( $field, '2' )->get_error_code() );
- self::assertSame( '1', Forms::sanitize_value( $field, '1' ) );
- }
-
- public function test_a_custom_type_without_validate_is_rejected(): void {
- $field = array(
- 'type' => 'text',
- 'render' => static fn (): string => '',
- );
-
- $result = Forms::sanitize_value( $field, 'x' );
-
- self::assertSame( 'missing_validate', $result->get_error_code() );
- self::assertSame( self::MESSAGES['missing_validate'], $result->get_error_message() );
- }
-
- public function test_validate_receives_the_raw_submission_alone_and_its_return_is_stored_as_is(): void {
- $calls = array();
- $field = array(
- 'type' => 'select',
- 'options' => array( 'a' => 'A' ),
- 'validate' => static function ( mixed ...$arguments ) use ( &$calls ): array {
- $calls[] = $arguments;
- return array( 'not a choice' );
- },
- );
-
- self::assertSame( array( 'not a choice' ), Forms::sanitize_value( $field, ' z ' ) );
- self::assertSame( array( array( ' z ' ) ), $calls );
- }
-
- public function test_validate_receives_a_cleared_multi_value_field_as_an_empty_array(): void {
- $received = null;
- $field = array(
- 'type' => 'checkboxes',
- 'options' => array( 'a' => 'A' ),
- 'validate' => static function ( mixed $raw ) use ( &$received ): mixed {
- $received = $raw;
- return $raw;
- },
- );
-
- self::assertSame( array(), Forms::sanitize_value( $field, '' ) );
- self::assertSame( array(), $received );
- }
-
- public function test_a_string_callable_validates_with_one_argument(): void {
- $field = array(
- 'type' => 'text',
- 'validate' => 'wp_kses_post',
- );
-
- $raw = 'Docs';
-
- self::assertSame( wp_kses_post( $raw ), Forms::sanitize_value( $field, $raw ) );
- }
-
- public function test_a_validate_rejection_passes_through(): void {
- $error = new \WP_Error( 'acme_too_long', 'Use at most ten characters.' );
- $field = array(
- 'type' => 'text',
- 'validate' => static fn ( mixed $raw ): \WP_Error => $error,
- );
-
- self::assertSame( $error, Forms::sanitize_value( $field, 'x' ) );
- }
-
- protected static function field( string $type ): array {
- return array(
- 'type' => $type,
- 'options' => self::OPTIONS,
- );
- }
-}
From ea876e8bc2399ab9b515ce9d86ad4f2fef80cf24 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Mon, 9 Nov 2026 18:21:15 +0100
Subject: [PATCH 30/33] Read a field row's id and title without fallbacks
FieldRow typed its field as any Field, so OptionsPage and ObjectForm
read the id and title through ?? '' fallbacks, although get_rows() keeps
only fields with an id and sets each one's title (X ruling 179's A119).
FieldRow's field carries its id and title, so both hosts read them as
they are. FieldRow keeps WordPress's label_for?: string, set only for a
field that is not a group, because OptionsPage hands each row to
add_settings_field() as it is, and ObjectForm maps it once onto the
host's Row (X ruling 335).
Assisted-by: Claude Code:claude-opus-5-5
---
packages/settings/src/AdminPage/OptionsPage.php | 2 +-
packages/settings/src/EditScreen/ObjectForm.php | 2 +-
packages/settings/src/Forms.php | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php
index a19512d..12ac803 100644
--- a/packages/settings/src/AdminPage/OptionsPage.php
+++ b/packages/settings/src/AdminPage/OptionsPage.php
@@ -190,7 +190,7 @@ public function render_page(): void {
$option = $tab['option'];
$stored = get_option( $option );
foreach ( Forms::get_rows( $tab['fields'], \is_array( $stored ) ? $stored : array(), $option ) as $row ) {
- add_settings_field( $row['field']['id'] ?? '', esc_html( $row['field']['title'] ?? '' ), array( $this, 'render_field' ), $this->id, 'default', $row );
+ add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row );
}
echo '