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( '

%s

', 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 '
'; diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index 7092ee6..f4c8161 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -174,7 +174,7 @@ public function render( int $object_id ): void { $control = Forms::render_control( $row['field'], $row['value'], $row['name'], $row['id'] ); if ( '' !== $control ) { $rows[] = array( - 'title' => esc_html( $row['field']['title'] ?? '' ), + 'title' => esc_html( $row['field']['title'] ), 'label_for' => $row['label_for'] ?? null, 'control' => $control, ); diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 24a01ca..08ad0c9 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -15,7 +15,7 @@ * @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): (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} + * @phpstan-type FieldRow array{field: Field&array{id: string, title: string}, value: mixed, name: string, id: string, label_for?: string} */ final class Forms { // region FIELDS AND CONSTANTS From b47c7fb72fb009f8b9dd2e077f90dd7ce630c009 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:21:16 +0100 Subject: [PATCH 31/33] Print the product panel's group wrapper once render_field() printed the fieldset-and-legend wrapper in its render branch and again for radios and checkboxes (X ruling 179's A122). Radios and checkboxes join the render branch, which hands Forms the field without its own legend. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/ProductData/ProductDataTab.php | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index 4574ae4..b332903 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -245,10 +245,15 @@ protected static function render_field( array $field, \WC_Product $product ): vo $field['value'] = ProductMeta::get_value( $product, $key, $field['default'] ?? '', 'edit' ); $row_class = \trim( "form-field {$id}_field " . ( $field['wrapper_class'] ?? '' ) ); - if ( isset( $field['render'] ) ) { - $group = Forms::is_group( $field ); + if ( isset( $field['render'] ) || \in_array( $field['type'], array( 'radio', 'checkboxes' ), true ) ) { + // The row's legend names a group, so Forms draws its choices without one of their own and with the description as their help. + $control = isset( $field['render'] ) ? $field : array( + 'title' => '', + 'desc' => $field['description'] ?? '', + ) + $field; + $group = Forms::is_group( $field ); echo $group ? '
' . wp_kses_post( $field['label'] ) . '' : '

'; - echo Forms::render_control( $field, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The render callable escapes its control. + echo Forms::render_control( $control, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control, and a render callable its own. echo $group ? '

' : '

'; return; } @@ -286,17 +291,6 @@ protected static function render_field( array $field, \WC_Product $product ): vo $field['custom_attributes'] = ( $field['custom_attributes'] ?? array() ) + array( 'multiple' => 'multiple' ); woocommerce_wp_select( $field ); break; - case 'radio': - 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( - 'title' => '', - 'desc' => $field['description'] ?? '', - ) + $field; - echo '
' . wp_kses_post( $field['label'] ) . ''; - echo Forms::render_control( $group, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control. - echo '
'; - break; } } From 2c4734c90b2ae2dd042242d9df07bf5d0a7ae660 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:57:11 +0100 Subject: [PATCH 32/33] Require the bootstrap classes in load.php unchecked load.php checked its two class files and returned false when one was missing, a guard for a torn copy of the bootstrap package, which is rare and heals once the copy completes, while a missing build already fails the entry's check on load.php. The guard also put load.php's return value in the 2.x contract (X ruling 337, reversing X ruling 195's A216). load.php requires both classes as it did, the entries and README excerpts require it after their readability check, the contract names only its path, limitation 5 keeps its partial-bootstrap sentence, and the build row for a missing bootstrap class goes. 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 ++- tests/Integration/ConsumerA/ConsumerABuildTest.php | 1 - 14 files changed, 28 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f19a5d4..68b7fca 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, 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 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 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\`. `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. @@ -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, 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. 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. +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. 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. 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. diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md index ca7379d..d43eadc 100644 --- a/packages/bootstrap/README.md +++ b/packages/bootstrap/README.md @@ -66,10 +66,11 @@ 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 ) || false === ( require_once $acme_pb_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it. +if ( ! is_readable( $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; @@ -84,7 +85,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, 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 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 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 51e9816..1aae420 100644 --- a/packages/bootstrap/load.php +++ b/packages/bootstrap/load.php @@ -1,8 +1,6 @@ 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 e0c14b8d88ff4339b5cc1cd371045e88cd952e8b Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:57:29 +0100 Subject: [PATCH 33/33] Resolve the plugin's basename inside the floor notice check_floors() resolved the plugin's basename before it hooked the notice, although only the notice reads it, so a request that renders no notice resolved it for nothing (X ruling 337). The notice callback resolves the basename itself. Assisted-by: Claude Code:claude-opus-5-5 --- packages/bootstrap/src/Requirements.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bootstrap/src/Requirements.php b/packages/bootstrap/src/Requirements.php index 3797a96..97b6223 100644 --- a/packages/bootstrap/src/Requirements.php +++ b/packages/bootstrap/src/Requirements.php @@ -47,10 +47,10 @@ public static function check_floors( string $plugin_file ): bool { return true; } - $basename = plugin_basename( $plugin_file ); add_action( 'all_admin_notices', - static function () use ( $basename ): void { + static function () use ( $plugin_file ): void { + $basename = plugin_basename( $plugin_file ); 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. }